Spring AI 重试引起的 LLM 重复调用

Spring AI 重试引起的 LLM 重复调用

摘要:换模型引爆事故:Qwen 3.8 Max 解析耗时越过网关 90s 断连点,撞上 Spring AI 默认 10 次重试,一次调用放大成多次请求,平台全绿、我方全红。经日志对账与反编译实锤根因,附超时对齐与重试封顶方案。
TL;DR :一条"自动化预判断"请求,在被 Spring AI 内置的默认 10 次重试消息队列重复投递 两层放大后,最终向大模型平台打了 23 个请求;而每次失败都要等 10 轮「~90s 挂起 + 指数退避」跑完才落到人工兜底,空耗 35 分钟。本文完整还原"客户端日志 × 平台侧日志对账 → 反编译 jar 包挖默认配置 → 用数学拟合重试时间线"的排查推理链,并给出修复方案与 6 条可复用的经验。

适合人群:Java 后端、分布式系统开发者、所有正在把 LLM 接进生产系统的团队。


一、背景:30 秒看懂我们的"自动化预判断"链路

我们做的是一个航司政策运营平台。运营同学创建投放任务后,系统会尝试"半自动化投放":先用大模型从航司政策文件里提取大客户码,再结合缺省规则判断这个任务能不能自动投放,能就开自动化流程自动跑,不能就转人工。

1.0 前传:一次"换模型"埋下的引线

这条链路上的大模型原本是 Claude Opus 4.7 ,解析一份政策文件一分钟内就能完成。但前一阵子 Opus 4.7 频繁报"模型返回异常",属于模型侧稳定性问题,业务层无从解决;恰逢 Qwen 3.8 Max 发布,中文理解能力出色,我们便把模型切换为 Qwen 3.8 Max。

切换后解析质量明显提升,但换模型的同时也换了"性能画像" :Qwen 3.8 Max 实测耗时 100~300 秒,绝大部分超过 90 秒。当时没人把这个"90 秒"当回事------直到下文这场事故告诉我们:Opus 4.7 时期的平安无事,恰恰掩盖了链路里一个从未被触发的隐患

1.1 调用链(DDD 分层,按层标注职责)

scss 复制代码
上游状态变更消息(消息队列,PROCESSING 事件)
  → 任务自动执行处理器                     (适配层:消费消息、判断任务是否可处理)
  → 自动投放应用服务                       (应用层:编排投放流程)
  → 大客户码提取适配器                     (适配层:本地缓存 + 文件转 Markdown)
  → AI 调用适配器                          (适配层:Spring AI OpenAI 兼容协议)
  → 公司内部统一 LLM 网关(OpenAI 兼容端点,模型 Qwen 3.8 Max)

两个关键设计:

  • 缓存只在成功时写入:只有 AI 解析成功才写缓存(TTL 300s),失败不写缓存;
  • AI 调用走 Spring AI 的 OpenAI 兼容客户端 :创建 AI 客户端时,RestClientOpenAiChatModel 都没有配置任何超时和重试参数------这两个"没配置",就是后面所有故事的起点。

二、案发现场:网关控制台里 23 条"绿色成功"

某天下午,网关平台侧同学找过来:你们有一个任务,从 14:50 到 15:23 在网关侧循环调用了 23 次,每次耗时 100~260 秒,全部成功。截图里一片绿色对勾,但每一行的耗时都是刺眼的红色。

第一反应是"我们代码里写了死循环重试?"。但翻遍业务代码,一次调用只发一次 HTTP 请求,没有任何显式 retry 逻辑

那 23 次是从哪来的?只能上日志。


三、排查过程:用"两侧日志对账"把问题逼进死角

3.1 第一步:日志平台拉出我们侧的调用记录

用 traceId 检索事发时间段之后的日志,同一个任务(某航司,Markdown 文档长度 1928 字符)在我们侧一共发起了 6 次调用,3 成 3 败:

# 开始时间 结束时间 机器 结果
1 14:50:13 15:25:11 生产机器 A ❌ Connection reset,空等 ~35 分钟
2 15:12:11 15:47:46 生产机器 A ❌ Connection reset,~35 分钟
3 15:13:44 15:48:37 测试机器 B ❌ Connection reset,~35 分钟
4 15:17:13 15:19:09 生产机器 A ✅ 116s
5 15:33:01 15:35:58 测试机器 B ✅ 177s
6 15:42:27 15:47:06 测试机器 B ✅ 279s

两个反直觉的现象:

  1. 失败的调用"只有开始、没有结束" ,最后以 Connection reset 收尾,且从发起到报错整整 ~35 分钟;
  2. 所有调用都跑在 MQ 消费线程上 ------全是消息消费线程,说明同一个任务的消息被消费了至少 6 次,而且生产、测试两个环境都在消费同一个 topic(各 3 次)。

3.2 第二步:和网关截图逐行对账

把 6 次调用和网关的 23 行记录按时间戳对齐,出现了一个决定性的规律:

成功的调用,在网关侧恰好对应 1 行 (如 15:17:14 / 115.62s 与我们侧 15:17:13→15:19:09 完全吻合);失败的调用,在网关侧对应"一串"请求------截图里 14:50~15:24 之间的 20 行,全部落在 3 个失败调用的时间窗内。

也就是说: "一次逻辑调用"在失败时被放大成了"多次物理请求" 。放大器在哪?

3.3 第三步:异常堆栈里浮出的 RetryTemplate

失败调用的 ERROR 日志里,堆栈中有这么几行:

less 复制代码
org.springframework.web.client.ResourceAccessException: I/O error on POST request for
"https://<llm-gateway>/v1/chat/completions": Connection reset
    at org.springframework.ai.openai.api.OpenAiApi.chatCompletionEntity(OpenAiApi.java:198)
    at org.springframework.ai.openai.OpenAiChatModel.lambda$internalCall$1(OpenAiChatModel.java:200)
    at org.springframework.retry.support.RetryTemplate.doExecute(RetryTemplate.java:357)   ← 注意这里
    at org.springframework.ai.openai.OpenAiChatModel.lambda$internalCall$3(OpenAiChatModel.java:200)

Spring AI 的 OpenAiChatModel 内部自带一个 RetryTemplate,而我们构建它时没有自定义,用的就是默认值。 那默认值是什么?没有源码没关系,jar 包在本地 Maven 仓库里,直接反编译:

3.4 第四步:javap 反编译,挖出默认重试参数

spring-ai-retry-1.1.0-M3.jar 里的 RetryUtils.class 执行 javap -c,静态初始化块里的常量一览无余:

scss 复制代码
RetryTemplate.builder()
    .maxAttempts(10)                                  // bipush 10
    .retryOn(TransientAiException.class)
    .retryOn(ResourceAccessException.class)           // Connection reset 正好被它包成 ResourceAccessException
    .exponentialBackoff(2000ms, multiplier=5.0, max=180000ms)   // 2s → 10s → 50s → 250s 封顶 180s

默认最多重试 10 次,且 ResourceAccessException(也就是 Connection reset)在重试白名单里。

顺带把配套的响应错误处理器 也挖了出来:4xx 客户端错误抛 NonTransientAiException不重试 ),其余错误抛 TransientAiException(重试)------这是"401 不重试、5xx 重试"的经典语义。它解释了为什么 Connection reset 会命中重试白名单,而鉴权失败这类错误不会被无谓地重试 10 遍。

另外一个重要发现:这些默认值同时暴露成了配置项 spring.ai.retry.*max-attempts 默认 10、退避初始 2s / 乘数 5 / 封顶 180s)------收敛重试既可以在代码里换 RetryTemplate,也可以在配置层显式覆盖。后文 5.1 会用到这一点。

3.5 第五步:用数学"拟合"网关时间线,实锤重试行为

如果"~90s 被网关断连 + 上述退避"是真的,那么网关截图里相邻请求的间隔应该等于 ~91s + backoff。拿失败调用 #1 的时间线验算:

相邻请求间隔(实测) 93s 101s 141s 270s 271s 271s
~91s 挂起 + 退避(理论) 91+2 91+10 91+50 91+180 91+180 91+180

逐格吻合,误差 1~2 秒。 至此实锤:

网关会在 ~90s 左右间歇性重置客户端连接;客户端报 Connection reset 后,Spring AI 默认 RetryTemplate 按 2s/10s/50s/180s 的退避继续重试,最多 10 次。而网关后端并不会因为客户端断开就取消任务,而是继续跑完------于是平台侧看到一串"绿色成功",我们侧却一个响应都拿不到。

23 次的账也对上了:3 个失败调用各烧掉接近 10 次重试(10+7+5 量级),加上 3 个成功调用各 1 次,与截图逐行对应。


四、根因分析:两层放大机制的叠加

4.1 放大层一:调用内放大------"默认重试 × 无超时 × 服务端不取消"

三个条件缺一不可:

  1. 网关 ~90s 断连:客户端在 ~90s 时收到 Connection reset(间歇性,偶尔能穿透,所以有 3 次成功);
  2. Spring AI 默认重试maxAttempts=10 + retryOn(ResourceAccessException),每次重试都是一个全新的网关请求
  3. 客户端无 read timeout、业务无总时长熔断 :创建 AI 客户端时既没给 RestClient 配超时,也没给 OpenAiChatModel 传自定义 retryTemplate

这一层的后果是:一次失败的逻辑调用 ≈ 向网关打 7~10 个请求,且要空等 ~35 分钟才抛出异常(计算见 4.4)。

这里顺带回答一个必然被追问的问题:为什么 Opus 4.7 时期从未出事? 因为它的解析耗时 < 60s,远在 90s 断连红线之内,重试机制从未被触发过。不是框架变了,是模型的耗时画像变了 ------Qwen 3.8 Max 的 100~300s 几乎每次都跨过 90s 红线,把沉睡的默认重试彻底唤醒。这正是"换模型只评估质量与平均耗时"埋下的坑:要拿耗时分布去比对每一层超时上限,而不是只看平均值

4.2 放大层二:调用间放大------"同一条消息被消费了 6 次"

第一层已经够疼了,但为什么同一个任务会进入 6 次自动投放流程?

  1. 消费线程被挂起的 AI 调用阻塞 ~35 分钟 ,远超 MQ 并发消费的消费超时(默认 15 分钟),触发消费超时重投 ;而 MQ 消费失败进入重试队列默认最多 16 次重试,叠加超时重投,一条消息理论上可被执行十几次(量级约 18 次),应用层没有任何与业务幂等挂钩的上限
  2. 阻塞期间任务状态一直是"等待处理 + 系统账户" ,每次重投都被放行进入自动投放;
  3. 失败不写缓存(只在成功时写入,且成功缓存 TTL 仅 300s------15:19 写入、15:24 过期,15:33/15:42 的重投再次 miss),失败场景下缓存零保护;
  4. 没有 in-flight 去重锁,并发重投各自发起 AI 调用;
  5. 测试环境订阅了生产 topic,直接把所有调用再翻一倍。

4.3 一个值得所有 AI 集成方记住的现象:"对面全绿,我们全红"

客户端断开 ≠ 服务端取消。 平台侧的 23 条记录全是成功,我们侧却 3 次超时失败------重试对着一个"昂贵且服务端不取消"的下游打,本质上是对平台侧的放大攻击:你重试得越勤,平台负载越高,网关断连越频繁,你又重试得更勤。这是一个正反馈雪崩。

没有幽灵,也没有谁在说谎:三份证据------平台日志全绿、我们日志全红、代码里零处循环------各自都"没骗人",只是"客户端重试风暴"与"服务端僵尸任务"叠加在了一起。

4.4 附赠一问:为什么"转人工"要等 35 分钟?

转人工本身不慢(异常 catch 里直接调转人工),慢的是**"失败"这个信号要等 10 轮重试跑完才产生**:

组成部分 计算 耗时
10 次 HTTP 尝试,每次 ~91s 被网关重置 10 × 91s ≈ 910s ≈ 15.2 分钟
9 次退避等待:2+10+50+180×6 1142s ≈ 19.0 分钟
合计 2052s ≈ 34.2 分钟(实测 34m58s / 35m35s / 34m53s)

用调用 #1 的网关时间戳逐步推导(上次发起 + 91s reset + backoff = 下次发起):

ini 复制代码
14:50:13 +91s+2s   = 14:51:46 ✓
14:51:46 +91s+10s  = 14:53:27 ✓
14:53:27 +91s+50s  = 14:55:48 ✓
14:55:48 +91s+180s = 15:00:19 ✓
15:00:18 +91s+180s = 15:04:49 ✓
15:04:49 +91s+180s = 15:09:20 ✓
......第 10 次 ~15:24:2x reset → 15:25:11 异常抛出,此刻才转人工

排查时这段"数学拟合"比任何口头解释都有说服力------当你能用默认参数把线上时间线逐秒复算出来,根因就不存在争议了


五、解决方案:先止血,再加固,最后立规矩

5.1 立即修复:给 AI 调用加"超时 + 重试封顶"

scss 复制代码
// 创建 AI 客户端的修复版
RestClient.Builder restClientBuilder = RestClient.builder()
        .requestFactory(ClientHttpRequestFactories.get(ClientHttpRequestFactorySettings.DEFAULTS
                .withConnectTimeout(Duration.ofSeconds(3))
                .withReadTimeout(Duration.ofSeconds(80))));  // 略小于网关 ~90s 断连点,快速失败

OpenAiChatModel chatModel = OpenAiChatModel.builder()
        .openAiApi(api)
        .defaultOptions(options)
        .retryTemplate(RetryTemplate.builder()
                .maxAttempts(1)      // AI 调用层不重试;如确需重试,由业务层带总时长封顶地做
                .build())
        .build();

效果:失败信号从 ~35 分钟缩短到 ≤ 80s,业务 catch 能立刻转人工,人工兜底真正兜得住;单次逻辑调用对网关的请求数从"最多 10"降为 1。

补充一条配置层的等价写法 (3.4 里挖出的 spring.ai.retry.* 配置项,适合不想改代码、需要快速止血的场景):

ini 复制代码
spring.ai.retry.max-attempts=1
spring.ai.retry.backoff.initial-interval=2s
spring.ai.retry.backoff.max-interval=10s

无论哪种写法,核心都是一句话:框架默认值必须显式化,哪怕写成和默认值一样。

5.2 短期优化:去重 + 负缓存 + 降级

  1. in-flight 去重锁 :进入大客户码提取时对缓存 key 做 setnx(TTL 覆盖超时窗口),并发重投不再各自打 AI,拿不到锁的直接快速失败转人工;
  2. 失败负缓存 :AI 失败也写一个短 TTL 的失败标记,消息重投命中后直接走人工,让失败路径也享受缓存保护
  3. 业务层重试带预算:如确需重试,放在业务层做,且"总时长预算 + 次数"双封顶(例如 3 分钟内最多 2 次),重试前查询平台侧是否已有同请求结果(幂等键)。幂等键建议用"任务 ID + 文件 hash + 模型版本"生成请求指纹,让重试变得"免费"------命中已有结果就直接取,而不是再打一次昂贵的 LLM。

5.3 长期治理:统一 AI 调用框架 + 环境治理 + 超时对齐

  1. 统一 AI 调用出口:全应用收敛到一个 AI 网关适配器,强制内置"超时、降级、重试预算、人工兜底"四件套------这也是我们团队的工程纪律:"AI 调用必须有超时和降级";

  2. 环境隔离:测试环境禁止订阅生产 topic,消除双环境翻倍消费;

  3. 与平台侧对齐超时语义,两个方向二选一

    • 方向 A:适应 90s 限制(快失败) ------即 5.1 的做法,把客户端 readTimeout 配到 80s,先于网关断连主动失败,转人工兜底。代价是单次自动化成功率受限:任何超过 90s 的解析都会失败;
    • 方向 B:突破 90s 限制(高阶线路) ------与网关平台确认后,把 base-url 从普通线路切到高阶接入线路,读超时放宽到分钟级,覆盖 Qwen 3.8 Max 的 100~300s 耗时,让一次同步调用完整完成。代价是依赖平台侧线路 SLA。
    • 我们的取舍:预解析这类"必须同步拿到结果、且确定要跑几分钟"的场景走高阶线路 ;其余非关键 AI 调用统一走"80s 快失败 + 业务层重试预算"。两个方向背后是同一条原则------三层超时对齐:客户端 HTTP 读超时 ≥ 网关读超时 ≥ 模型最大耗时,任何一层"配窄了",都会在它那一层断开,而服务端继续执行。
  4. 长耗时推理改异步或流式:流式调用(SSE)保持连接活跃,或"提交 + 轮询"异步模式,从根上避开"长连接被重置"的土壤------我们的自动化流程外层已经是异步工作流,但框架内部的同步语义仍然埋了这颗雷。

5.4 修复前 vs 修复后

维度 修复前 修复后
单次 AI 调用失败时网关请求数 最多 10 1(业务层重试另计,≤2)
失败到转人工的时延 ~35 分钟 ≤ 80 秒
同一任务极端场景网关请求数 23 ≤ 2
并发重复消息 每次都打 AI in-flight 锁去重,快速转人工
缓存 仅成功写入,TTL 300s 成功缓存 + 失败负缓存
测试环境消费生产 topic 存在 订阅关系隔离
MQ 消费线程占用 单条消息阻塞 35 分钟 ≤ 80 秒释放,不再触发超时重投
长耗时同步解析(>90s) 必断连重试 高阶线路完整跑完

六、经验总结:6 条可以直接带走的实践

  1. AI 调用必须有超时和降级,而且"默认配置"不算配置。 Spring AI、LangChain4j 这类 SDK 的默认重试/超时是为"通用场景"设计的,对着一个 90s 会断连的网关,默认值就是事故值。接入生产前,把 retryTemplatereadTimeout 显式写出来,哪怕写成和默认值一样。
  2. 重试前先问一句:我的下游"取消"吗? 对着"客户端断开但服务端继续跑"的下游重试,等于对平台发起放大攻击。重试语义必须和平台侧对齐,或用幂等键让重试"免费"。
  3. MQ 消费者必须按"同一条消息会来很多次"来设计。 in-flight 去重 + 业务幂等 + 显式重试上限,三件套缺一不可;消费线程被外部调用长时间阻塞,还会触发消费超时重投,把"慢"变成"重复"。
  4. 缓存要覆盖失败路径。 只缓存成功的缓存,在最需要它的失败场景里恰好是空的。负缓存(短 TTL 失败标记)是失败场景的保险丝。
  5. 排障时做"两侧日志对账",并敢于反编译。 "对面全绿、我们全红"不是灵异事件,是客户端/服务端生命周期不一致的特征信号;而 javap 一个本地 jar 包,往往比翻文档快一个数量级------能用数学复算出来的根因,才叫实锤
  6. 换模型 = 换性能画像,要重新校准三层超时。 换模型前只评估"质量 + 平均耗时"是不够的,要拿耗时分布 去比对每一层超时上限(客户端 readTimeout、网关断连点、框架重试退避)。本次事故的引线不是 Qwen 3.8 Max 太慢,而是 Opus 4.7 太快、从未唤醒沉睡的默认重试------换模型是一次完整的上线评估,不是改一行配置

最后送大家一句话:生产系统里最贵的 bug,往往不是写错的代码,而是"没写"的配置。 与诸君共勉。


本文基于一次真实线上事故的复盘撰写,文中涉及的平台、域名、业务与机器细节均已脱敏。

相关推荐
howdoyoudo2026061 小时前
AI审计手记 #01(数据补全版):107小时、17,600次操作——OpenAI越狱案完整攻击链量化分析
大数据·人工智能·安全·ai·语言模型
风流 少年1 小时前
hutool
java·服务器·开发语言
hrrrrxeeeee1 小时前
不同基础怎么报考 CAIE 认证|Level I 与 Level II 报考指南
大数据·人工智能·产品经理
Tangyuewei1 小时前
388 个 PR:AI 自主运维实测
运维·人工智能
H_oRIZoN_2 小时前
Linux入门DAY27(文件IO(系统调用)详解|open/read/write/lseek)
java·linux·服务器
OpenMiniServer2 小时前
复利普化型社会——从关系协同走向产业协同
人工智能
延凡科技2 小时前
从 “人管机器“ 到 “数据管人“:智慧矿山综合管控落地实践
java·后端·struts
大模型丫丫2 小时前
检索增强生成(RAG)入门:原理、架构与实践
人工智能·rag
martindelophy2 小时前
使用 Timeline Studio 制作 AI 视频二创:从高光分析、音乐卡点到 15 秒成片
人工智能·音视频