Zero 架构 · 第十二课

循环层的重试:三道防线,一条判据

第十课讲了 providerioHTTP 层重试(429/503,只重「服务器没接活」的失败)。但那只覆盖 「发一个 POST、收首个响应头」。如果流已经连上、吐了一点字、然后卡死了呢?或者流到一半撞上 上下文超限呢?这些 HTTP 层管不着 —— 得由 loop.go一整轮的粒度上恢复。 这一课拆开循环里包着一次 turn 的三道恢复防线。

对齐 mission:一个跑长任务(大重构、swarm 成员、headless/cron)的 agent,不该因为一次网络抖动或一次模型卡顿 就整轮暴毙、把已烧的 token 全部重烧。但「重发一整轮」是危险的 —— 弄不好会把用户已经读到的答案重复一遍。 这一课的全部功夫,就在回答一个问题:什么时候重发是安全的?

贯穿三道防线的判据:这一轮提交过答案文本吗?

延续第十课那条「非幂等 → 不可盲目重放」的铁律,循环层的版本是:只有当这一轮还没向用户吐出任何 最终答案散文(prose),才敢把整轮再发一遍。已经吐了字再重发,用户就会看到重复的答案。 这个判据由一个布尔量看守 —— forwardedVisibleText loop.go:223:

forwardedVisibleText := false
if options.OnText != nil {
    forwardingOpts.OnText = func(s string) { forwardedVisibleText = true; options.OnText(s) }
}
为什么只认 OnText,不认 reasoning / 工具预览 关键的不对称:OnText(最终散文)一响就置位;但 OnReasoningOnToolCallDelta (「正在写 write_file…」的临时预览)置位 loop.go:228。 因为临时输出重发是安全的:TUI 在下一个 tool-call-start 会重置预览,而一个卡死的 turn 从不会被 append 进 messages(它在 append 之前就带着 collected.Error 返回了)。所以只有真正的 答案散文,才是「重发会可见重复」的那种输出。这直接建立在第十一课的回调之上 —— OnText 就是那里来的。

防线一:连接级重连(还没吐字就断了)

streamWithReconnect reconnect.go:64 只重试连接本身:StreamCompletion任何内容被转发之前就以「断连状」失败,就退避重连, 最多 2 次(maxStreamReconnects,base 500ms 指数退避)。因为一个字都还没吐,重连绝不会重复任何 OnText

它只对「断连状」的错误出手 —— shouldReconnect reconnect.go:91 的白名单:eof / connection reset / timeout / 502 / 503 / broken pipe…;并明确排除 ctx 取消(调用方在关停)、上下文超限(压缩器管)、图片拒绝(有专门话术) reconnect.go:96 —— 不去和别的处理器抢活。

防线二:内容卡死重试(连上了、吐了临时输出、然后沉默)

这是最精妙的一道。第十课的双看门狗会把「一直心跳却不产出」的流判成 ErrStreamStalled/ErrStreamIdle; 这些错误经 collected.Error 冒到循环里,由 isStreamTimeoutError compaction.go:289 认出。循环据此重发整个请求, 最多 2 次(maxStreamStallRetries loop.go:28)—— 但只在判据成立时:

for attempt := 1; attempt <= maxStreamStallRetries &&
    isStreamTimeoutError(collected.Error) && !forwardedVisibleText &&
    collected.Text == ""; attempt++ {          // ← loop.go:305 三重闸门
    ...
    retryStream, _ := streamWithReconnect(ctx, provider, retryRequest, ...)
    collected = zeroruntime.CollectStreamWithOptions(ctx, retryStream, forwardingOpts)
}
「丢弃并重试」——半截 tool call 的处理 覆盖两种卡死 loop.go:289:①什么都没流出来就断了(macOS 陈旧连接挂死); ②流了点 reasoning、开了个 tool call(比如大的 write_file)然后卡在参数中间(gpt-5.x/ollama 常见)。 第②种的半截 tool call 永不执行(带 error 的 turn 在 dispatch 之前就返回)、永不进 messages —— 所以重试是拿干净的上下文重发,唯一的重渲染只是临时预览。这就是为什么闸门里不排除 collected.ToolCalls: 超时流里的半截 tool call 是「丢弃并重试」,而不是输出。

反过来:一个已经吐了真散文的 turn 即便随后卡死,也重试(会重复可见答案)—— 直接落到下面的错误返回。 每次重试前经 stallRetryNoticeFor 通过 OnReasoning(非内容通道)告诉用户「模型卡住,重试中」, 措辞刻意区别于防线一的「连接断了」—— 连接明明是好的,是模型卡了。

防线三:反应式压缩(流到一半撞上上下文超限)

recoverStreamError loop.go:243 处理 collected.Error 里的非卡死错误:

巧妙之处:这个恢复器对初始流和被重发的流一视同仁 —— 防线二重发出来的流若又报了个非卡死错误, 同样路由回 recoverStreamError loop.go:331,而不是把原始错误抛出去。 (还有一条先发制人的压缩:在流开始之前就预判超限并压缩,loop.go:186 —— 与这条反应式的互补。)

顺序也是正确性 恢复逻辑里 ctx.Err() 必须先查 loop.go:285:取消时 helpers.go(第十一课)会把 collected.Error 设成 ctx.Err().Error(),若此时直接 errors.New(collected.Error),就丢掉了 被包装的 sentinel,errors.Is(err, context.Canceled) 就失效了。所以先认取消、再认业务错误 —— 一个把「取消」误判成「上游错误」的 bug,就藏在这个先后顺序里。

三道防线一张表

防线治什么重发什么/几次
① 重连连接时断连(未吐字)只重连,×2
② 卡死重试连上后沉默/只心跳整轮,×2,须未吐散文
③ 反应式压缩流中报上下文超限压缩后整轮,×1

动手回忆

循环敢把一整轮请求重发,靠的是哪个判据?

流卡在半截 write_file 的参数里,那个半截 tool call 怎么办?

恢复逻辑里为什么必须先查 ctx.Err() 再看 collected.Error?

接下来该读的一手源码

less +64  ../zero/internal/agent/reconnect.go  # streamWithReconnect:连接级重连(防线一)
less +91  ../zero/internal/agent/reconnect.go  # shouldReconnect:断连白名单 + 三类排除
less +223 ../zero/internal/agent/loop.go       # forwardedVisibleText:重发安全的判据
less +305 ../zero/internal/agent/loop.go       # 卡死重试 for 循环(防线二)+ 三重闸门
less +243 ../zero/internal/agent/loop.go       # recoverStreamError:图片拒绝 + 反应式压缩(防线三)

读的顺序:先看 forwardedVisibleText 那个只被 OnText 置位的闭包 —— 整章的判据就在这一行。 再对读三道防线:streamWithReconnect(只重连)、卡死 for 循环(重整轮、闸门 !forwardedVisibleText && Text=="")、 recoverStreamError(压缩后重发)。最后体会 loop.go:289 那段长注释 —— 它把「为什么半截 tool call 可以丢弃重试」 讲得比任何教材都清楚。

我是你的老师 —— 随时问我。 适合现在追问: 「compactor.recover(第三课的压缩)怎么判断一个错误是上下文超限、又怎么只压一次?」、 「沙箱引擎怎么在 executeToolCall(第二课)里真正隔离命令执行?」, 或者「会话怎么 /fork、specialist 子会话(EventSessionChild)怎么挂到父会话上?」