流还在走,工具已经开工
模型还在打字,读文件的声音已经响了。采样循环的时序是:流内建 future,流后统一 drain。先 persist,再等结果。
- 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 留在原处。