Skip to content

feat(llm): LLM 调用复用 HTTP 连接,不再每次重新握手 (#206) - #207

Merged
fxbin merged 1 commit into
mainfrom
issue-206-llm-http-connection-pool
Nov 1, 2026
Merged

fxbin merged 1 commit into
mainfrom
issue-206-llm-http-connection-pool

Conversation

@fxbin

@fxbin fxbin commented Nov 1, 2026

Copy link
Copy Markdown
Owner

做了什么

关闭 #206。三个传输适配器原来每次 LLM 调用都新建一个 httpx.AsyncClient、用完就扔——每次调用都多一次 TCP + TLS 握手。这是 #181 试点时就记录的「已知未做」,自研层成为唯一路径(#200 / #204)后它落在每一次生产调用上。

  • 新增 services/llm/adapters/http_pool.py:共享客户端,按事件循环惰性创建。httpx 的连接池按「协议+主机+端口」分池,一个客户端能同时服务多家厂商。
  • 三个适配器改为从池里取客户端,超时按请求下发(client.post(..., timeout=)),每个模型各自生效,语义与原来逐请求建客户端完全一致。
  • 停机时在后台任务 drain 之后关闭客户端(在途调用先用完连接)。
  • 两个新配置:LLM_HTTP_POOL_CONNECTIONS(默认 16:主链路 4 并发 + ��测 8 并发 + 探测,超出排队不报错)、LLM_HTTP_KEEPALIVE_SECONDS(默认 300——httpx 自带只保活 5 秒,对间隔几十秒的 LLM 调用等于没复用)。
  • owning note 的「连接复用」段落更新为已解决。

为什么按事件循环隔离

pytest-asyncio 每个用例一个新循环,跨循环复用客户端会在已关闭的循环上发请求。客户端同时记住创建它的循环(弱引用),换循环就新建;生产进程只有一个循环,这条路径只对测试有意义。

不变的东西

URL 拼接、请求体、请求头、错误分类、用量提取一行没动;编排层的 asyncio.wait_for 外层截止时间照旧。

Verification

  • 全量后端测试(一次性 PG 5486):1641 passed, 0 failed(含新增 6 项池测试)。
  • make lint 全过(ruff / 分层 / 评分镜像 / 刊名 / tsc)。
  • 新增 tests/test_llm_http_pool.py:同循环复用同一客户端;配置上限与保活进客户端;关闭后再取是新的;换循环不复用旧客户端;适配器两次调用只建一个客户端;超时值随请求写进 httpx 的 request.extensions["timeout"](MockTransport 不做超时强制,所以验证的是下发而不是伪造计时)。
  • 现有适配器测试(patch httpx.AsyncClient 注入 MockTransport)不改语义继续通过——池在每个用例的新循环上惰性创建,拿到的就是 patch 后的构造器。
  • notes 校验器 pass(0 problems / 0 warnings)。

未覆盖:真实网络上的复用收益(握手次数/延迟)本地只有 mock,合并后可对线上模型连跑两次 ping 观察第二次延迟下降。

关联:#181(首次记录缺口)、#204(退役后成为唯一路径)

三个传输适配器(OpenAI 兼容 / anthropic / gemini)原来每次请求都新建一个
HTTP 客户端、用完就扔,每次 LLM 调用都多做一次 TCP + TLS 握手。现在共用
一个客户端:httpx 的连接池本来就按「协议+主机+端口」分开管,一个客户端
能同时服务多家厂商,空闲连接保活 5 分钟(httpx 自带的 5 秒对间隔几十秒的
调用等于没复用)。

- 连接上限默认 16(LLM_HTTP_POOL_CONNECTIONS),盖住主链路 4 并发 + 评测
  最多 8 并发 + 探测;超出时排队等空闲连接,不报错。
- 单次调用的超时还是按请求下发,每个模型各自生效。
- 应用停机时在后台任务收尾之后关掉客户端,在途调用先用完手上的连接。
- 客户端按事件循环隔离:测试框架每个用例一个新循环,跨循环复用会报
  「Event loop is closed」;生产只有一个循环,不受影响。
@fxbin

fxbin commented Nov 1, 2026

Copy link
Copy Markdown
Owner Author

复核状态说明

计划中的独立复核(全新上下文子代理,同 #205)因子代理用量达到账号周期上限无法执行(额度 2026-11-05 恢复)。owner 指示继续——本评论是实现者自审,不构成独立复核;如需要,额度恢复后可补一次真正的独立复核。

实现者自审(对照原定复核清单,逐项带代码证据)

  1. 陈旧连接(行为差异的核心):keepalive 300s 内服务端先断连接的情形——httpcore 1.0.9 复用前会检测对端已断开(_async/http11.py 的 has_expired():空闲连接 socket 可读即视为服务端已关闭,不拿去发请求)。残余竞态(检查后到发送前对端恰好断开)会变成 LlmTransportError → 编排层 tenacity 重试一次(2s 后退避),由新连接成功。判定:可接受,与任何共享客户端实现(含原 litellm 的 aclient_session)同一暴露面。
  2. 池等待超时:按请求传 timeout=45.0 时 httpx 展开为 connect/read/write/pool 全 45(实测)——等空闲连接的时间算在请求自己的超时里,不是 httpx 默认的 pool=5s;且 max_connections=16 > 最坏并发 13,实际不会等。
  3. 构造器 timeout vs 请求级 timeout:原 AsyncClient(timeout=X)(该客户端所有请求默认 X)与现 post(..., timeout=X) 逐请求等价。
  4. trust_env / 代理:新旧都是默认构造(trust_env=True),对环境变量代理的行为完全一致,无变化。
  5. 客户端级状态污染:适配器不依赖任何客户端级状态(无 cookies/.auth/client.headers 引用);唯一隐式共享是 httpx 的 cookie jar(按主机隔离,厂商 CDN/WAF 的 cookie 跨请求携带;鉴权走 Authorization 头,非身份)。与 litellm 共享客户端时代行为相同。P3 记录,不改。
  6. 停机顺序:uvicorn Server.shutdown() 先停止接收、等在途请求完成(_wait_tasks_to_complete),然后才 lifespan.shutdown()(源码核对)——关池时请求内的 LLM 调用已结束;停机后若有漏网调用,get_pooled_client 会惰性新建客户端,不会崩,只是进程退出时不再显式关。
  7. 事件循环隔离的原子性:get_pooled_client 全程无 await(同步函数),同一循环上不存在交错创建;多 worker 各自进程独立持有模块级变量。
  8. 分支基点:基于 main tip 79cbdec,无分叉;notes 链接断链 0。

结论:未发现需要修复的问题;1 项 P3(cookie jar 共享,与既有行为一致)记录在案。

@fxbin
fxbin merged commit 8845960 into main Nov 1, 2026
8 checks passed
@fxbin
fxbin deleted the issue-206-llm-http-connection-pool branch November 1, 2026 15:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant