流还在走,工具已经开工
模型还在打字,读文件的声音已经响了。采样循环的时序是:流内建 future,流后统一 drain。先 persist,再等结果。
本页解决的问题
先给结论「流还在走,工具已经开工」要解决的关键问题是什么?
模型还在打字,读文件的声音已经响了。采样循环的时序是:流内建 future,流后统一 drain。先 persist,再等结果。
跟着交接处走,不要只看 Demo。 系统是否可靠,往往取决于模型、工具、状态、权限和人的交接处。把每次交接都当成可以观察、测试和恢复的地方。
为一个自动化步骤写清输入、负责人、审批和恢复动作。
一次运行成功了,却说不清发生了什么,也无法安全重放。
- SSE 帧先解成通用事件,还没有业务含义responses.rs L164
- kind 是 output_item.done,就产出 OutputItemDoneresponses.rs L352
- 采样循环一到就交给 handle_output_item_doneturn.rs L2384
- 先把 function_call 写入历史和 rolloutstream_events_utils.rs L316
- 再 pin 工具,推进有序队列stream_events_utils.rs L320
- 流结束、断流或取消,都只是离开收流循环turn.rs L2282
- drain 按插入顺序把结果写入历史turn.rs L2135
- 然后才看取消令牌;Stream 可重试turn.rs L2760
你让模型读三个文件再写摘要。屏幕上还在打字,读文件的声音已经响了。然后你按 Esc。界面停了,历史里却留下那次请求,有时还留下结果。你以为取消等于什么都没发生。运行时并不这么记账。
另一头更常见:模型已经发出两个 function_call,第三个还在路上,SSE 在 response.completed 到来之前关掉。下一次重试该看见空历史,还是已经落盘的调用和结果?
若等 Completed 再写入,提前关流会把已经完整的调用一起扔掉。重试让模型再发一遍同样的调用。若取消时跳过写入,历史只剩半截请求,模型和界面都看见一个没闭合的调用。
OutputItemDone 是解析层把一帧 response.output_item.done 收成的业务事件。它一到,采样循环先把这一条写入会话历史和 rollout,再把工具执行包成 future 挂到有序队列上。取消令牌用子令牌,父令牌一亮,这个工具跟着停。取消来得再快,这一条 function_call 已经进历史。最多再多写一条 aborted by user。
出处:codex-rs/core/src/stream_events_utils.rs 第 190 至 192 行;第 316 至 327 行。类型别名上方的注释把合同写死:完成的模型输出要立刻记下来,后面 turn 被取消,历史和 rollout 也保持同步。
历史只能往上加,不能改写。工具已经读了磁盘,这条事实已经发生。取消树可以打断执行,打断不了已经写下的请求。先写请求、后写结果,transcript 始终闭合。这条合同不依赖 Rust,换 TypeScript 也是先 append 再 push 一个 promise。
出处:AGENTS.md 第 91 至 100 行,Model visible context 第一条:No history rewrite。
模型常常先发出读文件,再继续写一段说明。等收工哨再开工,等于把读文件的延迟和打字的延迟串起来。流内开工能让这两段时间重叠。代价是取消和断流必须认领已经开工的 future。没有认领人,就会出现孤儿任务:工具还在跑,历史对不上。
收流循环无论正常 Completed、提前关流还是 or_cancel,都只是离开 loop。函数还没返回。随后固定调用 drain_in_flight,按插入顺序等到每条 future 给出结果,再写入历史。然后才检查取消令牌。
断流走 Stream,可重试。重试从 clone_history 重建 prompt,已经写下的调用和结果都在。Esc 走 TurnAborted,不可重试。正在跑的工具写出中止文案,drain 把它当普通输出写入。
出处:codex-rs/core/src/session/turn.rs 第 2282 至 2284 行;第 2744 至 2762 行。codex-rs/protocol/src/error.rs 第 88 至 93 行、第 364 至 390 行。
中途开工就必须在每个出口等齐。所有权留在采样函数的局部变量里,没有另一条后台回收队列。成功、错误、取消共用这一段收尾。换语言也一样:离开异步循环之后先 allSettled,再决定重试还是中止。
三个工具可以同时跑。谁先跑完,历史仍按模型发出的顺序写结果。按完成顺序写,同一段会话重放两次可能对不上,prompt cache 也会更脆。有序队列把观测顺序和执行顺序拆开。并发闸门在别的一层,这里只记:挂起顺序就是日后 drain 的顺序。
出处:codex-rs/core/src/session/turn.rs 第 2130 至 2154 行;第 2391 至 2397 行。
Claude Code:默认等流结束,另有一扇流内闸门
默认路径里,流式循环只收集 tool_use,for await 结束后才进入 runTools。流断了只需丢掉已经收集的 block,省掉每个出口都 drain 的局部所有权。代价是工具延迟和打字延迟串行。
streamingToolExecution 打开时,行为靠近 Codex:流内 addTool,立刻开工。失败回退要 discard 已经开工的工具,避免旧 id 漏进重试。Codex 没有对等的 discard,因为它选择先 persist,重试读历史。
DSH:三段瀑布加单调 Guard,管的是谁能拒绝
DSH 的入口是一条已经成型的工具调用。pre / guard / around / post 回答谁能拒绝,拒绝之后结果还在不在。Guard 只有拒绝理由或弃权,没有放行这个选项。它的 drained 是单次 execute 内部的收尾。SSE 还在飞的时候,这套瀑布还没开始。
两边词面相近,出口不同。一边护权限单调,一边护流式 transcript 闭合。把 Guard 搬进 Codex,挡不住断流丢 transcript。把 persist-then-drain 搬进 DSH,也回答不了插件能不能把拒绝改成放行。
两侧均已核对源码 · tools/src/index.ts 第 1 至 4 行、第 703 至 711 行、第 1328 至 1337 行 · DSH · 三段瀑布与单调 Guard把写入和挂起对调
在 handle_output_item_done 的工具分支里,把 record_completed_response_item 和 Box::pin(handle_tool_call) 对调。取消发生在 pin 之前、persist 之前。下一轮采样和会话恢复会看见什么?
把答案落到历史只能增量追加,以及 drain_in_flight 的写入时机上。
OutputItemDone 一到就先写入再开工。流的每个出口先 drain,结果按发出顺序写。取消打断执行,已经写下的 transcript 留在原处。
「先玩一遍 · 拖进度,看哪一帧起跑」的能力藏在每次交接里
「你让模型读三个文件再写摘要。屏幕上还在打字,读文件的声音已经响了。然后你按 Esc。界面停了,历史里却留下那次请求,有时还留下结果。你以为取消等于什么都没发生。运行时并不这么记账」说明,Agent 的表现不只由模型决定。模型、上下文、工具、状态、权限和人之间的每次交接,都会改变任务能否继续以及出了问题能否恢复。
先写清状态,再增加能力
从「另一头更常见:模型已经发出两个 function_call ,第三个还在路上,SSE 在 response.completed 到来之前关掉。下一次重试该看见空历史,还是已经落盘的调用和结果」出发,可以把流程拆成起始状态、下一步动作、工具返回、状态更新和停止条件。这样调试时找的是第一处丢失信息或权限的位置,而不是笼统地说“模型变笨了”。
- SSE 帧先解成通用事件,还没有业务含义 responses.rs L164
- kind 是 output_item.done,就产出 OutputItemDone responses.rs L352
- 采样循环一到就交给 handle_output_item_done turn.rs L2384
成功路径不能代表系统可靠
用「把答案落到历史只能增量追加,以及 drain_in_flight 的写入时机上」重放一次成功和一次失败,记录每一轮真正传入的上下文、工具结果和负责人;只要第二个人能复述这条链,系统才有可维护性。
从「先玩一遍 · 拖进度,看哪一帧起跑」走到「思路一 · 请求先盖章,工具再开工」
「先玩一遍 · 拖进度,看哪一帧起跑」先把问题落在「同一条 SSE 流:拖进度,看工具何时开工 播放 单步 重置 出口 正常完成 提前关流 Esc 拖滑块或点事件格。左边流内执行,右边等流结束再执行。出口开关改最后一帧怎么收场。 流内执行 0 条已落盘 等待开始。 等流结束 0 条已落盘 等待开始。 逻辑轨迹 · 动画每一步对应源码里的哪一段 SSE 帧先解成通用事件,还没有业务含义 responses.rs L164 kind 是 output_item.done…」上;到了「思路一 · 请求先盖章,工具再开工」,讨论继续推进到「你让模型读三个文件再写摘要。屏幕上还在打字,读文件的声音已经响了。然后你按 Esc。界面停了,历史里却留下那次请求,有时还留下结果。你以为取消等于什么都没发生。运行时并不这么记账」。两段连起来,重点就不只是记住一个结论,而是看清它成立所依赖的条件。
把这条判断带到下一个场景
分析 Agent 时,沿着状态、动作、工具结果和下一步的顺序走一遍;每次交接都要能说明信息从哪里来、由谁确认、失败时停在哪里。
- 「先玩一遍 · 拖进度,看哪一帧起跑」:同一条 SSE 流:拖进度,看工具何时开工 播放 单步 重置 出口 正常完成 提前关流 Esc 拖滑块或点事件格。左边流内执行,右边等流结束再执行。出口开关改最后一帧怎么收场。 流内执行 0 条已落盘 等待开始。 等流结束 0 条已落盘 等待开始。 逻辑轨迹 · 动画每一步对应源码里的哪一段 SSE 帧先解成通用事件,还没有业务含义 responses.rs L164 kind 是 output_item.done…
- 「思路一 · 请求先盖章,工具再开工」:你让模型读三个文件再写摘要。屏幕上还在打字,读文件的声音已经响了。然后你按 Esc。界面停了,历史里却留下那次请求,有时还留下结果。你以为取消等于什么都没发生。运行时并不这么记账
- 「最后的要点」:再 pin 工具,推进有序队列 stream_events_utils.rs L320
最后的「最后的要点」把讨论落到「再 pin 工具,推进有序队列 stream_events_utils.rs L320」。回看这条线索时,最值得保留的是:当输入、规模或风险改变,哪些判断需要重新做一遍。
我把这篇文章里的一个判断改写成了今天可以验证的小实验。比记住结论更有用的是,知道下一步要观察什么。
读完以后我先回头找它成立的条件,而不是直接把方法搬进项目。这个顺序让后面的取舍清楚很多。
如果把这个判断放到真实工作里,最先需要补的约束是什么?我想知道从阅读到第一次实践之间,哪一步最值得先做。
还没有这篇文章的讨论。