把本地推理服务接进 Spring Boot 应用,最容易出问题的不是模型效果,而是超时和连接池这两件事被当成两个独立参数去调。真正出事时它们的表现会互相伪装:连接池报的是“拿不到连接”,根因却是读取超时设得太长。

超时是一条链,缺一环就会伪装成另一环

一次调用本地 Ollama 服务的请求,通常要穿过好几层超时控制:反向代理或网关的空闲/读取超时、Web 容器的异步请求超时、HTTP 客户端的连接超时与读取超时、连接池的借出等待超时,以及业务代码自己设的截止时间。这几层里只要有一层比上层更晚触发,它实际上就永远不会触发。

最典型的现象是:客户端配置了较长的读取超时,但网关在更短的时间内就断开了连接。此时应用侧看到的不是读超时,而是连接被对端关闭或 reset,日志里完全看不到自己那段等待逻辑留下的痕迹。排查时只盯着客户端异常类型,很容易误判成服务端崩溃。

所以第一步不是调参数,而是把每一层实际生效的值列出来,按触发时间从外到内排序,确认外层严格大于内层。只有内层有机会执行,重试和降级才有意义。

本地推理要把首 token 和生成中分开算

本地部署的模型和云端 API 在时间分布上不一样:冷启动或模型首次加载时,首 token 延迟可能远高于稳态;而流式输出开始之后,相邻数据块之间的间隔通常很短。用一个读取超时同时覆盖这两段,会陷入两难——按首 token 设,稳态下等于没有保护;按生成间隔设,第一次请求必然超时。

一个可行的做法是给冷启动路径单独的预算,或者在启动阶段做一次预热请求,把加载成本从业务请求里挪出去。具体怎么落地,取决于所用客户端是否具备区分建连等待、首字节等待和块间间隔的能力,这一点需要先确认。

本地场景还有一点和云 API 不同:客户端因为超时断开,服务端并不会停下来。推理仍在消耗 CPU 或 GPU,只是结果没人接收。在云上这只是一次浪费,本地部署上它会直接拖慢同一台机器上其他正在处理的请求。

连接池耗尽多数是超时策略的下游后果

连接池借出等待超时的日志很有辨识度:请求在排队,整体响应时间抬升,超时集中在并发高峰。但把它当成池子太小直接调大,往往只是把排队从客户端挪到服务端——本地模型的算力固定,客户端多发十个并发,单请求时长只会更长,然后更多人超时。

根因链通常是这样:读取超时设得过长,服务端在慢慢算,连接被长期占用;并发数高于池容量;上层还挂着重试。这里最容易被忽略的是重试与超时的耦合——读取超时触发一次重试,重试会占用一个新的连接,池压力直接翻倍。重试次数、退避间隔和服务端实际处理时长必须放在一起算,否则重试就是在自我放大。

池大小要反推,不要正着调

顺序应该是先测后算:测出稳态下单请求的 P95 占用时长,再用 Little's Law 估算所需并发,所需连接数约等于请求到达率乘以平均占用时长。举例来说,如果目标吞吐是每秒 2 个请求、单请求平均占用 10 秒,稳态就需要约 20 个并发在途,池容量小于这个数必然排队。

算出这个数字之后,它同时也是限流阈值:超过它的请求应该在入口被拒绝或排队,而不是塞进池里等。池容量、限流上限和读取超时三者需要自洽,单独改其中一个通常没有意义。

流式响应的读取超时语义不一样

非流式调用中,读取超时基本等价于整个请求的耗时上限;流式调用中,它衡量的通常是两次可读事件之间的间隔,而不是总时长。这意味着流式场景需要两个约束同时存在:一个较短的间隔超时,用来发现“卡住不动”;一个较长的总截止时间,用来兜住“一直在输出、但输出很久”。

如果客户端只提供前者,总截止时间就得在上层自己加:业务层计时,超过预算主动取消并关闭流,否则请求可能一直没有终点。同时要注意中间层——网关和负载均衡器的空闲超时对 SSE 或分块传输是按数据间隔判断的,需要大于生成间隔,而不是大于总生成时间。

哪些细节必须回到官方文档核对

上面讲的是通用的分层关系与容量估算。具体到 Spring AI 的配置项名称、层级结构、默认值,以及它是否支持单独设置连接超时、读取超时或整体截止时间,都应当以当前使用版本的官方文档和配置元数据为准。这类客户端抽象在不同版本之间调整过配置结构,直接从旧项目复制 yml 是很常见的错误来源;用 IDE 的配置补全核对一遍,比对照二手文章可靠。

验证时建议每次只改一个维度,并用可控的慢端点确认超时确实在预期时间触发,而不是只看代码里写的数字。

一份可执行的排查顺序

按同一请求 ID 把客户端异常、网关日志和服务端日志对齐时间戳,先判断断在哪一层:建连失败、首字节等待超时、块间间隔超时、池借出等待超时,还是对端 reset。这几种情况对应的处理方式完全不同,混在一起调参数只会互相掩盖。

然后量化四个数:峰值并发、单请求 P95 占用时长、池容量、池等待时间。这四个数对不上,说明池容量和超时组合有问题;对得上却依然超时,说明瓶颈在服务端算力,此时继续调客户端参数是无效动作。

最后确认重试配置:重试次数乘以单次超时,是否已经超过外层网关的容忍时间。如果超过了,重试永远走不完,只会把失败的连接继续留在池里,让排队在下一轮更严重。