Zero 架构 · 第十一课

抽流:一条 StreamEvent channel,同时喂给「画面」和「循环」

第九课给出了 provider 的出向词汇 —— 一条 StreamEvent 的 channel(9 种事件)。第六课讲了 TUI 怎么把这些事件 实时画上屏。但第一课的循环最终要的不是「流」,而是一份攒齐的结果:这一轮模型说了什么文本、 发起了哪些 tool call、用了多少 token。CollectStreamWithOptions helpers.go:90 就是这台「抽干机」:它一边把流实时转发给回调(喂画面),一边把它成一个 CollectedStream(喂循环)。

对齐 mission:流式和非流式是同一份数据的两副面孔。一个 coding agent 既要让用户看着字一个个蹦出来(流), 又要在这一轮结束后拿到完整的tool call 列表去执行(非流)。这一课就是把「流」优雅收束成「一次结果」的那个接缝 —— 一次遍历,两个用途。

核心形状:一个 for-select 抽干 channel,四条出口都走 finish()

函数主体就是一个 for { select {...} } helpers.go:102,把 channel 抽到干。有四种方式结束, 全部经由同一个 finish() 闭包收尾:

出口触发
ctx.Done()取消 → 记 Error=ctx.Err()(helpers.go:104)
channel 关闭ok==false → 正常收尾(helpers.go:108)
StreamEventErrorError 立即返回(helpers.go:155)
StreamEventDone正常终止(helpers.go:158)

finish() helpers.go:94 做两件事:collector.flush() 把攒的 tool call 和文本落定,并在见过 usage 时补发一次 OnUsage关键在「单一收尾点」: 无论从哪条出口走,flush 恰好被调一次 —— 这是文本只被物化一次、tool call 只被输出一次的保证。

两副面孔:同一个 switch 里,攒 + 转发

每个事件在 switch event.Type helpers.go:125 里同时做两件事 —— 攒进结果转发给回调:

case StreamEventText:
    collector.text.WriteString(event.Content)   // 攒(给循环)
    if options.OnText != nil { options.OnText(...) }  // 转发(给画面)
case StreamEventToolCallStart:
    collector.start(id, name, signature)         // 攒
    if options.OnToolCallStart != nil { ... }    // 转发
case StreamEventToolCallDelta: ...  // 攒参数片段 + 转发
case StreamEventUsage:  collected.Usage = mergeUsageSnapshot(...)
case StreamEventToolCallDropped: collected.DroppedToolCalls++

这正是第六课那些回调(OnText/OnToolCallStart/OnToolCallDelta/OnUsage)的来源: CollectOptions helpers.go:34 就是 TUI 挂进来那组闭包。不需要回调的调用方 (会话标题、recap、压缩)传空 options,抽流就退化成纯粹的「攒」。一个函数,流式和非流式两用。

两个「骑在任意事件上」的字段 FinishReason(截断/内容过滤)和 ReasoningBlocks(Anthropic thinking)按类型 case 处理, 而是在 switch 之前无条件累积 helpers.go:116。因为 provider 会把它们挂在自己的终止事件上, 类型不定 —— 无条件收集,才不会漏掉一个「其实被截断了」的响应,也不会丢掉要在下一轮重放的思考块(呼应第九课)。

硬骨头:toolCallCollector —— 为什么「攒」而不是「end 即发」

tool call 是流式到达的:start(给 id+name)、若干 delta(参数片段一点点拼)、end。 最容易想到的是「end 时就把这个 call 发出去」。Zero 偏不 —— end 什么都不发 helpers.go:278,只有 flush 在最后按 start 顺序一次性全发 helpers.go:319

为什么坚持 start 顺序 多个 tool call 可能并发流式,谁先 end 是不确定的。若「end 即发」,输出顺序就随机了。 攒进 order 切片、flush 时按 start 顺序遍历 —— 保证 agent 拿到的 tool call 列表永远等于模型发起它们的顺序, 与谁先拼完参数无关。这对可复现的执行顺序很重要。

空 ID 的泥潭:合成 key + delta 先到的收养

有的后端不给 tool call 分配 ID(id=="")。天真地用 ID 当 map key,两个并发的空 ID call 就会合并成一个。 collector 用一套机制把它们分开:

两处小而正确的决定

这一课接住了两条线 第九课产出 9 种事件的流;本课把流抽干成一个 CollectedStream{Text, ToolCalls, Usage, ...}; 第一课的循环拿这个结果:有 ToolCalls 就去执行(第二课的关卡)、没有就结束这一轮。而第六课的实时画面, 不过是给同一次抽流传了一组回调。流式 UI 与非流式循环,共用这一次遍历 —— 没有第二份解析逻辑,也就没有两者不一致的机会。

动手回忆

tool call 为什么在 flush 统一发,而不在 end 时就发?

FinishReasonReasoningBlocks 为什么在 switch 之前无条件收集?

同一个 CollectStreamWithOptions 怎么同时服务流式 UI 和非流式循环?

接下来该读的一手源码

less +90  ../zero/internal/zeroruntime/helpers.go   # CollectStreamWithOptions:for-select 抽干 + 四条出口
less +94  ../zero/internal/zeroruntime/helpers.go   # finish():单一收尾点,flush 只调一次
less +204 ../zero/internal/zeroruntime/helpers.go   # toolCallCollector:攒调用、按 start 顺序 flush
less +229 ../zero/internal/zeroruntime/helpers.go   # start/delta/end:空 ID 合成 key + delta 收养
less +165 ../zero/internal/zeroruntime/helpers.go   # mergeUsageSnapshot:逐字段取最新非零

读的顺序:先看主 for-select 的四条出口都汇到 finish() —— 理解「单一收尾点」。再看 switch 里每个 case 「攒 + 转发」的对称。最后啃 toolCallCollector 的空 ID 处理 —— 合成 key、delta 先到的收养、无名丢弃, 这是全函数唯一需要多读两遍的地方,也是把「乱序、缺 ID、并发」的真实流式收得干净的功夫所在。

我是你的老师 —— 随时问我。 适合现在追问: 「loop.go 里为什么要在一次 collect 失败后用 retryStream 再 collect 一遍(loop.go:266/:325)?」、 「沙箱引擎怎么在 executeToolCall(第二课)里真正隔离命令执行?」, 或者「会话怎么 /fork、specialist 子会话(EventSessionChild)怎么挂到父会话上?」