写这篇东西的起因,是上周我调一个接模型服务的后端接口,压测数据让我差点怀疑人生:并发从 30 提到 60,预期 QPS 翻倍,结果总耗时几乎原地踏步,p99 从 2 秒一路飙到 8 秒。更诡异的是,链路里所有组件的 CPU 和内存都不高,模型服务端也没有任何报错和限流,整条链路看起来"一切正常",只有调用方的响应时间在悄悄恶化。
排查到最后,问题出在一个我从来没认真审视过的老伙计身上——HTTP 连接池。LLM(大语言模型)服务的请求特征跟普通 API 完全不一样,单个请求动辄占用连接十几秒甚至几十秒,只要连接池的容量配置、复用策略、超时逻辑没有跟上,高并发下服务不会直接挂掉,而是以一种非常隐蔽的方式慢慢变慢。这篇文章就完整复盘一下我踩过的坑、追查的思路,以及最后沉淀下来的连接池参数推导方法,希望能给同样在做 LLM 应用工程化的朋友一些可复用的经验。
1. 为什么连接池这种"老掉牙"的配置,偏偏能卡死 LLM 应用
1.1 普通 API 和模型 API 在连接占用时长上的量级差异
HTTP 连接池本质上干的事很简单:把 TCP 连接、TLS 加密握手这些开销大的动作缓存起来,让后续请求复用已有连接,避免每次请求都重新建连。在传统微服务场景里,一个后端接口的响应时间通常只有几十毫秒,连接池里一条连接可以在一秒内被来回使用几十次,池子的效率极高。
但 LLM 请求完全不同。我做过一次简单的日志统计:调用模型接口时,最慢的请求从发起到完整返回要 40 多秒;即便只统计非流式的中等长度输出,平均耗时也接近 10 秒。流式场景更夸张,连接从首字开始就一直被占着,直到整个流结束。
这意味着什么?一条连接在一个请求身上被占用的时间,比普通 API 长了 200 到 1000 倍。同样的连接池容量,在普通 API 下能支撑几百乃至上千并发,在模型调用场景里可能连几十个都撑不住。连接池容量没变,但每条连接的"周转率"暴跌,有效并发容量随之骤降。这就是标题里说的"悄悄变慢"——它不是突然报错,而是随着并发升高,越来越多的请求在客户端本地排队。
1.2 连接复用的隐性失效:keepalive 连接不够用
连接池通常分两部分:一部分是"正在工作的活跃连接",另一部分是"处理完请求但还没关掉的空闲连接",也就是 keepalive 连接。很多客户端库默认允许的空闲连接数很小,比如常见的默认值是 20 甚至更少。
在普通 API 下,这 20 个空闲连接完全够用,因为请求体量小、速度快,连接释放后立刻能被下一个请求抢走。可一旦请求耗时变成十几秒,活跃连接数会迅速膨胀,而池子里空闲连接往往已经耗尽。这时候每个新请求都必须重新建 TCP 连接、重新做 TLS 握手,建立连接的过程又恰好发生在服务端压力最大、网络栈最繁忙的时候,进一步推高响应时间。
我复盘时的第一直觉也差点被带偏:我一直观察服务端的 QPS 和错误率,服务端确实没到瓶颈,问题出在客户端这边根本没有把请求顺利发出去。
1.3 超时重试机制在放大问题
更隐蔽的是,LLM 应用的高延迟会跟超时重试机制形成恶性循环。很多团队习惯把超时时间按照普通 API 的标准来设置,比如总超时 5 秒、读超时 3 秒。模型服务正常生成一段内容可能就需要 10 秒以上,于是客户端在服务端还没完成生成时就主动断开连接。断开之后,SDK 通常会自动重试,重试又发起一个新请求,继续占一个连接。
服务端那侧,被断开的请求并没有立即中止计算,很多推理服务会继续把已经算完的部分算完,这就造成了客户端超时重试、服务端资源又被反复消耗的双重浪费。并发一高,连接池被这些"半途而废"的请求占据,真正的正常请求反而排不上队。
2. 连接池底层机制拆解:容量边界、排队行为与信号量
2.1 连接池的"容量"到底指什么
要弄明白配置怎么改,先得搞清楚连接池内部是怎么运作的。大多数 HTTP 客户端库在底层实现的逻辑可以抽象成一层"信号量 + 队列":
- 池子有一个最大连接数上限,这个上限限制的是同时建立并使用的连接总数。
- 活跃连接数达到上限后,新请求进入等待队列,而不是直接报错。
- 等某条连接释放后,等待队列里的请求再抢占这条连接。
用停车场的例子类比:车位总数是 max_connections,其中"空闲但保留的车位"是 keepalive 连接,剩下的就是正在停车的。停车场满了之后,后来的车只能在入口排队等位。而 LLM 请求的问题是每辆车占车位的时间特别长,停车场再大也很快被占满。
需要特别注意的是,连接池的容量是按"连接数"算的,不是按"并发数"算的。一次 HTTP 请求在成熟阶段只使用一条连接,但这条连接从发出请求到收到完整响应、再到连接被放回池子,整整一个生命周期都要计算在容量内。延迟越高,容量被占用的时间就越长,单位时间内能处理的请求就越少。
2.2 活跃连接、空闲连接与队列等待的不同角色
连接池里其实存在三种状态,配置时一定要分开理解:
| 状态 | 作用 | 配置入口 | 配置不当的后果 |
|---|---|---|---|
| 活跃连接 | 正在处理请求的连接,无法被复用 | max_connections | 太小导致请求积压 |
| 空闲连接 | 已被服务器关闭但未释放的连接,可直接复用 | max_keepalive_connections | 太小导致频繁建连 |
| 排队等待 | 池满之后在客户端本地等待的请求 | 一般由并发上限隐式形成 | 等待时间会被计入总耗时 |
有些客户端库还支持设置"排队超时时间",也就是请求在池子里等待连接的时间上限。如果这个时间设置得过短,高并发下请求会直接抛出"连接池等待超时"之类的异常;如果设置得过长,调用方线程会一直阻塞在等待上,看起来就像服务被"拖死"了。
2.3 SDK 内部往往还有一层线程池或事件循环瓶颈
这是很容易被忽略的第二层瓶颈。很多 LLM 服务的官方 SDK 是异步客户端,底层跑在事件循环上;但也有不少工程为了省事,在同步业务代码里去调用异步 SDK,或者用线程池去跑同步版客户端。
这时候连接池容量看似调大了,线程池却可能只有默认的 30 到 40 个线程。连接池让你能开 200 条连接,但线程池只允许 40 个线程同时干活,剩下 160 个请求照样在本地排队。结果就是:你看到的"慢"可能根本不是连接池的锅,而是线程资源不够。
排查时要同时确认三件事:连接池容量、底层线程池/事件循环的并发能力、调用方的阻塞方式。缺一个都容易被表象带偏。
3. 三种典型错误配置复盘:故障现场、排查链路与根因
3.1 错误一:keepalive 空闲连接默认值太小,高并发下疯狂重建连接
这是最常见的情况。某个服务用默认配置接入模型服务,平时并发不大还好,压测到 80 到 100 并发时,响应时间开始明显上涨。从服务端抓包看,连接是不断新建又不断关闭的,服务端连接数飙升,TIME_WAIT 状态数量也很多,但正常的请求体量和吞吐却不高。
排查链路是这样的:
- 先看客户端 CPU。如果 CPU 不高,说明卡在等待而不是计算。
- 再看服务端接入日志,发现大量请求实际是"还在路上"的,客户端耗时高但服务端接收时间晚,说明客户端在发请求前就已经等了很久。
- 抓包确认新建连接频率。正常复用率下,一分钟内新建的 TCP 连接数应该远小于请求数;如果两者接近,说明 keepalive 复用失效。
根因是默认的空闲连接上限太小,请求一多就把空闲连接耗尽,每个请求都得重新走一轮建连和 TLS 握手。在模型服务这种高延迟场景下,建连成本被无限放大。
提示:查看客户端网络指标时,不要只看 CPU 和内存,还要观察 TCP 新建连接速率。这个数字比任何链路监控都能更快暴露连接池问题。
3.2 错误二:只调大 max_connections,没管线程池和事件循环
另一个项目里,开发把 max_connections 调到了 500,结果并发一高还是慢。这次的服务端指标更迷惑:服务端几乎没有什么压力,客户端线程数却满了。
查看堆栈发现,大量线程阻塞在为一个信号量等待连接释放,而实际被占用的连接数远没有到 500。仔细一看,SDK 底层开了 4 个独立的 HTTP 客户端实例,每个实例各自独立的连接池,默认容量是 100。全局看起来连接上限是 400,但由于负载均衡把请求分散到各实例,每个实例的实际并发只有几十,池子容量并没有被充分利用,反而因为实例之间"各自为政",空闲连接碎片化,无法互相复用。
这个问题让我意识到:多个 client 实例不等于更大的池子,它们只是多个独立的小池子。正确的做法要么是全局共用一个大型连接池,要么明确计算每个独立池子的分片容量。线程池和事件循环的配置也是同理,连接池只是门,门再宽,后面的走廊(线程池)不够宽,一样堵。
3.3 错误三:超时配置与模型响应时间不匹配,直接引发雪崩
最严重的一次故障来自超时配置。因为接口是给内部系统用的,最初按普通 API 标准设置了 5 秒读超时。模型服务偶尔生成长文时需要 8 到 10 秒,客户端读超时后会立刻抛错并自动重试。
故障现场从监控看就是典型的"雪崩曲线":服务端负载先冲到高位,客户端错误率上升,然后重试流量叠加,服务端负载进一步冲高。最终模型服务网关开始限流,大量正常请求也被限流拒掉。
排查后确认,服务端日志能看到第一批请求是被客户端主动断开的,而服务端仍继续计算了很久。这就是前面说的"半途而废的请求占用连接":连接池里的连接被超时请求占着,重试请求又新增连接,池子很快被无效流量填满。
根因很清楚——读超时时间必须覆盖模型服务的真实响应分布,而不是沿用普通 API 的经验值。长短超时的设定要建立在真实压测和线上 p99 数据之上。
4. 连接池参数该怎么算:一套可以直接抄的推导方法
4.1 先确定目标并发和请求时长分布
配置不能拍脑袋,需要先收集两个基础数据:
- 目标并发数:业务高峰期同时发起模型调用的请求数量。如果一个网关入口要支撑 200 QPS,而单个请求平均耗时 10 秒,那就意味着同一时刻大约有 2000 个请求在途,这是非常恐怖的数字。实际大多数场景的目标并发是几十到几百。
- 请求耗时分布:采集线上真实数据,至少记录平均耗时、p95 耗时和 p99 耗时。如果还没有线上数据,就用量级相近的模型服务压测数据代替。
核心公式很简单:同一时刻在途的活跃连接数 ≈ 并发 QPS × 平均响应时间。比如目标是 50 QPS,平均响应时间 10 秒,那么在途连接大约是 500。但考虑到请求到达并不均匀,峰谷波动明显,实际需要预留 1.3 到 1.5 倍的余量。
4.2 连接池容量与空闲连接数的推导经验值
基于这个在途连接数,我一般按以下经验来设置:
max_connections(总连接上限)通常取在途连接数的 1.3 到 1.5 倍。这样既能容纳高峰期的突发流量,又不会因为池子过大导致系统维护大量无用连接。
max_keepalive_connections(空闲连接数)不建议设得太小。我踩过默认 20 的坑后,现在倾向于设成 max_connections 的 70% 到 80%,确保高峰过后池子里仍有一批连接可以被后续请求复用。这个值也不是越大越好,空闲连接太多会占用文件描述符和服务端资源,需要根据单机可能达到的并发峰值做平衡。
keepalive_expiry(连接空闲保留时间)应当至少覆盖模型请求的 p95 耗时。如果模型响应 p95 是 30 秒,而连接在空闲 5 秒后就被回收,那复用率会大幅下降。实际可以设到 p99 甚至略高于 p99,以覆盖长尾请求。
注意:不要把 keepalive_expiry 设置到超过模型服务的网关超时值。如果服务端网关在 60 秒无流量后关闭连接,客户端却保留 120 秒,就会出现大量连接已失效却不自知的情况,反而增加请求重试。
4.3 超时参数的配套设计
超时至少要分成三类来配置:
- 连接超时(connect):客户端到服务端建立 TCP 连接的时间,一般 3 到 10 秒足够。
- 读超时/总超时(read/overall):必须覆盖模型生成的最长耗时,建议取线上 p99 并额外加 20% 到 30% 的缓冲。比如 p99 是 25 秒,读超时可以设 30 到 35 秒。
- 连接池等待超时(pool timeout):如果客户端支持,可以设置一个相对较大的值,比如 30 秒,但不要设成无限大。无限大等待会让调用方线程全部卡在池子上,比直接报错更难以排查。
重试策略也要配套调整。模型服务的重试建议只在以下情况触发:连接建立失败、网络层异常、明确的 5xx 或限流错误。单纯的读超时不应该立即触发重试,而是先做退避,比如等待 1 秒、2 秒、4 秒的指数退避。一口气连着重试三次,效果就是把一个超时请求放大成四个请求,连接池直接被撑爆。
4.4 一份可落地的客户端配置示例
以常见的 Python HTTP 客户端库为例,连接池参数一般像下面这样配置(大多数 LLM SDK 的底层 HTTP Client 都支持透传这些参数):
import httpx limits = httpx.Limits( max_connections=200, # 总连接上限,按在途连接数算出来后取 1.3~1.5 倍 max_keepalive_connections=150, # 空闲连接上限,建议为 max_connections 的 70%~80% keepalive_expiry=30.0, # 连接空闲保留时间,覆盖模型响应 p95 ) timeouts = httpx.Timeout( connect=10.0, # 建连超时 read=35.0, # 读超时,覆盖模型响应 p99 write=10.0, # 写超时,请求体上传阶段 pool=35.0, # 连接池等待超时,尽量不设无限大 ) client = httpx.AsyncClient( limits=limits, timeout=timeouts, max_retries=2, # 部分客户端支持在 Client 层面设置重试次数 )如果你用的 SDK 不允许直接传连接池参数,可以看一下它是否允许传入自定义的 HTTP Client 实例。大部分主流 SDK 都支持,这会成为你精细化调参的最关键入口。
5. 实测对比:连接池配置不同,压测数据能差出好几倍
5.1 模拟环境与压测方案
为了验证这套配置逻辑,我搭了一个模拟环境:用一个简单的服务端程序模拟模型服务,随机延迟 200ms 到 15 秒,模拟生成时间的长尾;客户端分别用三组配置去压同一服务端,每组压 3 分钟,统计 100 并发下的表现。
三组配置分别是:
- 默认配置组:连接池上限 100,空闲连接 20,读超时 5 秒。
- 只调大容量组:连接池上限 300,空闲连接 200,但读超时仍然 5 秒。
- 完整调整组:连接池上限 300,空闲连接 200,读超时 30 秒,并关闭了激进的自动重试。
5.2 三组压测的关键数据对比
结果差异非常直观:
| 指标 | 默认配置组 | 只调大容量组 | 完整调整组 |
|---|---|---|---|
| 有效 QPS | 8.2 | 12.6 | 45.8 |
| 平均耗时 | 9.4s | 7.1s | 2.3s |
| p99 耗时 | 31s+ | 22s+ | 5.8s |
| 请求失败率 | 31% | 24% | 0.5% |
| TCP 新建连接速率 | 每秒 89 条 | 每秒 61 条 | 每秒 3 条 |
默认配置组的问题一眼就能看出来:大量请求因为 5 秒读超时被中断,重试又挤占连接,最终有效 QPS 只有 8 左右。只调大容量组稍微好一点,但因为超时问题没有解决,仍然有大量无效请求占用连接。完整调整组把读超时覆盖到 p99 之后,TLS 握手频率大幅下降,QPS 直接翻了 5 倍多。
这个对比让我彻底明白一件事:连接池不是孤立配置,它跟超时、重试、线程池是同一套系统,任何一环不匹配,瓶颈都会以意想不到的方式转移。
5.3 压测时还要盯哪些隐藏指标
压测中除了 QPS 和耗时,我还会额外盯这几个指标:
- TCP 新建连接速率:如果并发升高导致新建连接速率同步飙升,说明连接复用没生效。
- 客户端线程池活跃线程数:如果线程数打满但连接池还没满,说明是线程瓶颈。
- 连接池内部指标:很多客户端库可以通过监控暴露 active、idle、waiting 三类连接数量。active 持续接近上限,说明池子确实满了;waiting 持续增长,说明负载已超过容量。
6. 比连接池更容易被忽略的周边配置
6.1 DNS 缓存的 TTL
如果模型服务的域名解析到一组共享网关 IP,DNS 缓存时间设置不当会导致长连接连到一个已经被摘除的节点上。连接池里的连接看起来是活的,实际上已经无法正常通信,请求就会卡在读超时上。建议把 DNS 缓存 TTL 调到与服务端节点生命周期匹配的水平,至少不要和连接空闲时间相差一个数量级。
6.2 TLS 会话复用
连接池只解决 TCP 层面的复用,TLS 握手一样很贵。好在现代 HTTP 客户端默认都会做 TLS 会话复用,但有些环境里因为代理、负载均衡器等中间层的原因,TLS 会话复用会被跳过。如果 TLS 握手耗时明显偏高,可以检查一下客户端是否启用了 TLS 会话票据,或者是否有中间层强制中断连接。
6.3 连接池碎片化:多个 client 实例的隐患
我在前面已经提到过,多个独立 client 实例各自维护一个连接池,会导致连接碎片化。实际工程里常见的做法是给不同业务场景创建不同的 client(比如一个用于流式,一个用于非流式),这样做本身没问题,但要仔细计算每个 client 的容量,避免 "总上限看着很大、实际每个分栈都不够用" 的尴尬情况。
6.4 流式响应与连接释放
流式场景下,连接要一直持有到流结束,连接池的容量需求比非流式更紧张。流式响应的读超时也要单独设置,它应该覆盖"相邻数据块之间的最大间隔时间",而不是整个流的总时长。一个常见的错误是把流式超时设成整个对话的总时长,导致长时间静默时客户端误判超时;或者反过来,超时太短,模型生成快但网络抖动一下就被断开。
6.5 服务端限流与客户端队列的配合
最后想提一点:很多模型服务网关都有限流策略,客户端连接池排队的现象跟服务端限流可能会叠加。压测时如果服务端开始返回 429,客户端会自动触发重试,此时连接池里会堆积大量重试请求。建议在客户端侧实现一个"排队上限 + 快速失败"的策略,当本地等待超过一定阈值时直接返回"系统繁忙",而不是无限等待或重试。这么做表面上损失了一部分请求,实际保全了整体稳定性。
最后说点实在的
这次排查的最大收获,不是把几个参数调对了,而是建立了一套接入任何 LLM 服务商时的固定动作:先取真实调用耗时分布,再按 p95/p99 定超时,按在途连接估算连接池容量,最后用压测验证 TCP 新建连接速率和连接池 waiting 指标。每一步都不复杂,但少走一步都可能让你在高并发下面对一个看似毫无头绪的"慢"。
一个小技巧分享给大家:接入新服务后,第一天不要急着看 QPS,先盯一天的"连接池 waiting 数量"和"TCP 新建连接速率"。这两个指标比任何监控大盘都诚实,一旦它们开始异常增长,你已经提前知道了问题所在。连接池的配置从来没有"一劳永逸",每次模型服务升级、请求体量变化,都值得重新做一次这套校准动作。