ARTICLE / ai
一次503重试让同一条命令跑了两遍,然后整个会话再也发不出请求
Gemini CLI 的维护者和贡献者这几天在处理一个链条很长的 bug:模型回复到一半网络抖了一下,客户端自动重试,重试成功后界面上看起来一切正常——但那条被重试前"说了一半"的命令已经被执行了一遍,重试后的回答又让它执行了第二遍。从这一刻起,这个会话里每一个后续请求都会被 API 拒绝,只能整段丢弃。
10 月 29 日,用户报告了完整分析(google-gemini/gemini-cli issue #30014);同一天,修复 PR #30020 提交,改动 463 行、覆盖 14 个文件。截至 11 月 2 日本文写作时,这个 PR 仍在等待合并。这篇文章拆开它的机制,因为这条失败链不是 Gemini CLI 独有的怪病,而是所有做流式重试的 Agent 框架都可能踩中的结构性坑。
流式输出比对话历史跑得快
问题出在两个"记账系统"的速度差上。
Gemini CLI 收到模型响应是逐块流式处理的:每来一个 chunk 就立刻交给上层,前端把里面的 function call 转成 ToolCallRequest 事件、马上开始收集和执行。而"这轮对话算不算数"是另一套规则——只有当整个流成功读完,这一轮的模型回复才会写进会话历史。
平时两套账是一致的。不一致发生在中途失败:流读了一半断了,已经吐出来的 function call 早就被前端拿走执行了,但这半截内容永远不会进历史。接着客户端对可重试的错误(429、499、5xx,或可重试的网络与 SSL 错误,最多重试 3 次)发起第二次请求,重试回来的回答里通常带着同一条命令的新调用。于是前端手里攒了两个调用请求:失败那个 attempt 的,和成功这个 attempt 的。
关键细节在于,前端并不是没收到信号——重试发生时,核心层会明确发出一个 Retry 事件。协议文档对这个事件早有约定:“UI 应当丢弃刚刚失败的 attempt 产生的所有内容”。文字写了,代码没做:已确认的消费端都没按契约丢弃调用——ACP 会话里 Retry 落进 default: break,交互式界面与 headless 模式的 Retry 处理什么都不做;子代理执行器则靠宽容侥幸没出错。
一次意外触发的连锁反应
把时间线拉出来看,后果分三步走:
第一步,副作用翻倍。如果那条命令是 run_shell_command 里的 rm、部署脚本或者写库操作,它就在你眼皮底下跑了两次。有审批弹窗的场景下,用户会被要求批准两个调用;无审批场景下,两次静默执行。报告里还指出更隐蔽的一种:重试后的 attempt 可能干脆换了个命令,那么第一次那个"从未进入历史的调用"照样被执行——模型等于开了一张没入账的支票。
第二步,下一轮请求必死。执行完两个调用后,前端会把两份 function response 一起发给 API。但历史里只有成功 attempt 的那一轮,只认一个调用。API 要求 user 轮里的 function response 必须与上一条模型轮里的调用一一对应,多出来的那份直接把请求顶回 400 INVALID_ARGUMENT。
第三步,会话永久报废。到这里如果只是报错还能忍,真正致命的是这一步里被有意不回滚的部分:发送失败的 function response 回合(源码注释里写明了这样设计的理由),错位的记录留在历史里。此后每次新提问前,客户端还会自动补一句"上一轮回复被打断"的收尾,但那多出来的一份 response 依然嵌在历史中。结果就是这个会话的每一次请求都撞上同样的 400,除了弃用会话重新开始没有别的出路。
一次瞬时的 503,代价是"命令执行两遍 + 整个会话作废"。这就是为什么这个 issue 值得每个做 Agent 运行时的人停下来看一眼。
修复选择了哪个方向
报告给出了两种修法:要么等整个 attempt 流结束后再把调用发给前端——代价是工具调用要等整个流跑完才到达前端、开始执行;要么让每个消费端真正执行 Retry 事件的既有契约。PR #30020 选了后者:核心层的 Turn 在收到重试信号时清空待处理调用队列,六个消费端各自在 Retry 分支丢弃已收集的调用请求。不重试时行为完全不变。
评审过程暴露了一个测试盲区。10 月 29 日的评论里,贡献者发现子代理执行器的消息处理路径虽然因为"宽容"而没有实际出错,但控制事件的路由是靠巧合而不是靠设计——测试只断言了"哪些调用活下来",删掉新增的处理分支测试照样绿。补上的断言是"Retry 永远不该到达那个处理器",删除分支立刻变红。“隐式的宽容"和"显式的处理"在今天的测试里表现相同,在下次的重构里就是事故现场。
11 月 1 日又一轮评审追问找到了第二条漏网路径:开启实验开关时 headless 模式走的是 LegacyAgentSession,这条路的缓冲调用同样要在 Retry 时清掉,否则双执行在这个组合下依然可达。当天补丁跟进,新增用例验证"重试后只调度第二个调用”。一个 463 行的修复在自己身上又被评审揪出两处不完整,恰恰说明这类"事件语义没人认领"的 bug 有多难扫干净。
这条坑不是你家的专属
把 Gemini CLI 的具体文件名剥掉,剩下的结构在任何流式 Agent 框架里都成立:
响应边收边执行,历史等流完整才写入;中间插一个自动重试;重试信号发出来了,但下游没人按契约清理。三个条件凑齐,双执行加历史错位就是必然结果,跟用哪家模型、什么语言写的客户端无关。
对照着可以自查三件事。第一,你的框架有没有"半途失败"这个概念——重试逻辑是否区分"流还没吐出任何调用就断了"(安全,直接重来)和"已经吐出调用后断了"(危险,本案例的情形)。第二,协议里定义过的控制事件(retry、cancel、rollback 之类),逐个检查每个消费端的处理分支是显式的还是落进默认分支。第三,最便宜的保险是发送前的配对校验:任何 function response 如果找不到对应的历史内调用,就地丢弃。这条兜底不在本次修复范围内,但它决定了下一次配对出错时你是损失一个回合还是损失整个会话。
顺带一提,同一天的 OpenCode 社区也在处理相邻的问题:模型正常结束流但以内容过滤收尾时,插件的重试钩子根本不会被触发,导致同一个"换模型重试"的兜底策略对 HTTP 错误有效、对 finish 信号无效(issue #60822,修复 PR #60836)。两个项目撞车的主题一致——失败的形式不止一种,而恢复逻辑往往只认其中一种。
写在最后
截至发稿复核,#30014 和 #30020 都还是开放状态,文中机制描述基于报告者在 commit 363a131(2026-10-26 的 main)上的源码静态分析,以及 PR 作者补充测试的真实运行记录;本报告未独立复现该故障。这类问题的诚实边界也值得说清:静态分析能证明链路存在,双执行的实际频率取决于你的网络和服务端错误率。
但工程判断不需要等复现:上一条的三项自查只要对上,这个 bug 的形状就已经在你家地基里了——区别只是哪天被 503 抽中。