Zero 架构 · 第十一课
StreamEvent channel,同时喂给「画面」和「循环」
第九课给出了 provider 的出向词汇 —— 一条 StreamEvent 的 channel(9 种事件)。第六课讲了 TUI 怎么把这些事件
实时画上屏。但第一课的循环最终要的不是「流」,而是一份攒齐的结果:这一轮模型说了什么文本、
发起了哪些 tool call、用了多少 token。CollectStreamWithOptions
helpers.go:90
就是这台「抽干机」:它一边把流实时转发给回调(喂画面),一边把它攒成一个
CollectedStream(喂循环)。
对齐 mission:流式和非流式是同一份数据的两副面孔。一个 coding agent 既要让用户看着字一个个蹦出来(流), 又要在这一轮结束后拿到完整的tool call 列表去执行(非流)。这一课就是把「流」优雅收束成「一次结果」的那个接缝 —— 一次遍历,两个用途。
函数主体就是一个 for { select {...} }
helpers.go:102,把 channel 抽到干。有四种方式结束,
全部经由同一个 finish() 闭包收尾:
| 出口 | 触发 |
|---|---|
ctx.Done() | 取消 → 记 Error=ctx.Err()(helpers.go:104) |
| channel 关闭 | ok==false → 正常收尾(helpers.go:108) |
StreamEventError | 记 Error 立即返回(helpers.go:155) |
StreamEventDone | 正常终止(helpers.go:158) |
finish()
helpers.go:94 做两件事:collector.flush()
把攒的 tool call 和文本落定,并在见过 usage 时补发一次 OnUsage。关键在「单一收尾点」:
无论从哪条出口走,flush 恰好被调一次 —— 这是文本只被物化一次、tool call 只被输出一次的保证。
每个事件在 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。
end 是不确定的。若「end 即发」,输出顺序就随机了。
攒进 order 切片、flush 时按 start 顺序遍历 —— 保证 agent 拿到的 tool call 列表永远等于模型发起它们的顺序,
与谁先拼完参数无关。这对可复现的执行顺序很重要。
有的后端不给 tool call 分配 ID(id=="")。天真地用 ID 当 map key,两个并发的空 ID call 就会合并成一个。
collector 用一套机制把它们分开:
\x00synthetic-N 的独立 key,压进一个栈
helpers.go:238;delta/end 用「栈顶那个最近打开的」来路由
helpers.go:294。pendingEmptyDelta
helpers.go:266;紧接着的空 ID start 收养这个 call
helpers.go:234,而不是另开一个 —— 于是先到的参数片段不会变成「无主孤儿」。Name=="" 的 call 不进 ToolCalls,而是计入 DroppedToolCalls
helpers.go:326 —— agent 绝不派发一个空工具名。strings.Builder 而非 +=
helpers.go:130:collected.Text += chunk 每次都重新分配整个字符串,
长响应下是 O(n²)。攒进 Builder、flush 时物化一次。mergeUsageSnapshot
helpers.go:165 合并多次 usage 快照(有些 provider 分多次报),
每个字段保留最新的非零值,再走第九课的 NormalizeUsage 校验不变量。CollectedStream{Text, ToolCalls, Usage, ...};
第一课的循环拿这个结果:有 ToolCalls 就去执行(第二课的关卡)、没有就结束这一轮。而第六课的实时画面,
不过是给同一次抽流传了一组回调。流式 UI 与非流式循环,共用这一次遍历 —— 没有第二份解析逻辑,也就没有两者不一致的机会。
tool call 为什么在 flush 统一发,而不在 end 时就发?
FinishReason 和 ReasoningBlocks 为什么在 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)怎么挂到父会话上?」