第二部:一句话的旅程——输入怎样变成模型请求
你按下回车,消息沿着 Inbox 双队列进入 turn 边界,System Prompt 不是静态字符串而是四层组装,历史消息从事件日志投影重建,LLM Service 是一堵适配器注册表的墙,流式 chunk 经过 BlockAssembler 拼成消息,Retry/Abort/Token 耗尽各有结账路径。
![[图片占位:一条消息从输入框出发,经过 Inbox、turn、System Prompt 拼装、LLM Service、流式chunk,最终到达模型。蓝鲸工程师骑着消息气泡穿越管道。色调:荧光蓝+紫色流光。]](/static/images/handbook/deepseek-harness-internals/parts/02-model-loop.png)
展开阅读路线与实验入口
你输入了一句话,按下回车。屏幕上出现一个跳动的光标,然后一个字一个字地蹦出回复。你可能以为就是”把消息发给模型,模型返回结果”这么简单。
但你错了。你的消息进不了模型。它先到 Inbox,Inbox 有两个队列,不是一个。它要排队等 turn 边界打开,turn 打开前要过 preStep 关卡——preStep 可能拒绝它。System Prompt 不是一段写死的字符串,而是 sections/contexts/tools/variables 四层叠出来的,还带 waterfall 拦截。送给模型的历史消息不是从聊天界面读的,是从事件日志增量投影的。LLM Service 不直接调模型,它是一个适配器注册表,中间还夹着可短路的 waterfall。模型回来的 chunk 一个一个被 append 成 assistant/chunk 事件,再由 BlockAssembler 组装成消息。如果出错了,还有 retry 退避、context overflow 压缩重试、AbortController 取消、finally 保证 turn/end 一定写入。
这一部跟踪你按下回车到模型返回这整条链路。
这一部解决什么
读完这七章,你能回答六个反直觉的问题:
- 为什么 Inbox 有两个队列?
send/followup/steer/inject四种投递目标各自走哪条路,为什么 abort 后的消息强制走 next-turn。 - 谁有权开始新一轮? ReactLoopAgent 三态机(idle/maintenance/running),preStep 怎么决定 enter 还是 reject,step/start 为什么必须等 preStep 通过才 append。
- System Prompt 为什么不是字符串拼接? 四层组装顺序、section order 数值约定、
{{variable}}严格模式、complete section 替换语义、runtime context 为什么以 user-role 消息注入。 - 模型看到的历史从哪来? 只有三类事件能进 surface,chunk 和 turn boundary 为什么不算,append-origin 和 replacement 为什么分开,compaction 为什么不删原事件。
- LLM Service 这堵墙后面是什么? 适配器注册表原子替换、prepareCall 为什么要一次性锁定、markAgentLoopRequest 为什么 deep-freeze 请求。
- 一轮 turn 怎么失败和结账? 指数退避+抖动两种模式、max-tokens 粘滞性、AbortController per-phase、finally 块保证 turn/end 写入、abort 后 started calls drain 到 quiescence。
accTitle: 第二部阅读路径:一句话的旅程
accDescr: 用户消息经 Inbox 投递、turn 前置、系统提示组装、Surface 投影派生历史,送入 LLM 流式响应,经重试/中止后结算 turn
accDescription: 第二部阅读路径流程图,从用户输入开始,经过 Inbox 双队列、wakeDriver、turn/start append、preStep、deriveMessages、buildRequest、llm/stream、BlockAssembler,最后根据 finish reason 分流到 executeToolCalls、retry/compact/abort 或 turn/end append。
flowchart LR
A["用户输入"] --> B["Inbox 双队列<br/>next-turn / next-step"]
B --> C["wakeDriver<br/>创建 running phase"]
C --> D["turn/start append"]
D --> E["preStep: claim inbox<br/>+ assemble prompt<br/>+ agent/pre-step"]
E --> F{"decision?"}
F -->|reject| G["turnEnds=blocked"]
F -->|enter| H["step/start + user/message append"]
H --> I["deriveMessages<br/>从 surface 投影历史"]
I --> J["buildRequest<br/>prepareCall 锁定适配器"]
J --> K["llm/stream waterfall<br/>→ adapter.stream"]
K --> L["BlockAssembler<br/>逐 chunk append + 组装"]
L --> M{"finish?"}
M -->|tool-calls| N["executeToolCalls<br/>→ next-step inbox"]
N --> E
M -->|max-tokens/error/aborted| O["retry / compact / abort"]
O -->|retry| J
M -->|completed/stop| P["turn/end append"]
G --> P
这一部所有实验都是只读的 git grep 和 cat,不安装依赖、不启动服务。你可以把 DSH_SOURCE_DIR 指向那个 checkout 到 commit 47f94385 的目录,直接复现。
从消息到达 Inbox 开始。
Inbox 双队列:消息不是直接进模型的
用户消息在到达模型之前,会先经过一个双队列暂存区(Inbox)。next-turn 队列和 next-step 队列决定了消息是开启新 turn 还是注入当前 step。splice 是唯一的写入点,claim 是唯一的消费点,durable append-first 保证崩溃恢复不丢消息。turn 边界由 wakeDriver 的 phase 状态机管理,preStep 瀑布流决定消息能否真正进入模型。
消息不是直达模型
你在终端里敲了一句话,回车。模型开始输出。产品体验像一条直线:用户 → prompt → 模型。但代码里真正要处理的是队列、暂存、排序和消费。
最简单的一问一答确实会掩盖中间层;一旦碰到下面这些情况,直连模型的解释就不够用了:
- 模型正在输出的时候,你按了 Ctrl+C 中断,然后立刻输入了新的一句话。这句话该接在被中断的 turn 后面,还是开启一个全新的 turn?
- 一个工具执行完毕,需要把结果”注入”到当前 step 让模型继续推理。这个注入和用户的下一句话,谁先谁后?
- 一个 subagent 想在当前 turn 中间”插话”(steer),同时用户也发了新消息。两者的优先级是什么?
如果消息是直达模型的,这些问题没有答案。你需要一个中间层来做消息的分类、暂存、排序和消费。这个中间层就是 Inbox。
更准确地说,你以为的 “用户→模型” 直连模型其实是这样的:
用户 → followup() → send() → splice() → [inbox queue]
↓
wakeDriver → preStep → claim() → waterfall → model
中间至少有五个独立的处理阶段。每个阶段都有自己的职责和失败模式。下面我们把它们逐一拆开。
它实际是什么:一个持久化优先的双队列暂存区
Inbox 不是 prompt。Inbox 是 prompt 的上游。它是一个带两条队列的暂存区:next-turn 队列存放需要开启新 turn 的消息,next-step 队列存放需要注入当前 step 的消息。所有消息先进 Inbox,再被 claim 消费,最后才拼进 prompt 交给模型。
两个队列的类型签名很简单:
'next-turn': UserMessage[]
'next-step': UserMessage[]
但这个简单的数据结构承载了三层语义:
- 分类语义:消息是要”开新话题”(next-turn)还是”补充当前话题”(next-step)?这不是消息自身的属性,是发送者的意图。同一条消息,通过 followup 发就是 next-turn,通过 steer 发就是 next-step。
- 消费语义:claim 的规则是”全部 next-step + 最多一条 next-turn”。这保证了 step 注入的消息不会积压,而 turn 消息严格一对一。
- 持久化语义:splice 先写日志再更新内存,crash recovery 从日志重建队列状态。
关键设计决策:先持久化,再更新内存。splice 是唯一的写入点,它先把事件 durably append 到 session 的事件日志,然后才更新 Inbox 的内存队列。这意味着即使进程在 splice 和 claim 之间崩溃,重启后从持久化日志恢复,消息不会丢。
为什么不用单队列加优先级标记?因为消费规则不同。next-step 是”贪婪消费”(一次全取),next-turn 是”节流消费”(一次一条)。单队列没法简洁地表达这种差异化消费策略。
从最简场景推导:一条消息,一个 turn
我们从最简单的场景开始:你输入一句话,模型回复一句话。跟着消息走完全程。
第一步:followup 把消息送进 next-turn
你在终端输入 “hello”,CLI 层调用 agent.followup(input)。followup 的实现是:
followup(input) {
return this.send(input, 'next-turn', true)
}
三个参数:消息内容、目标队列 next-turn、是否唤醒(wake = true)。
第二步:send 的职责
send 方法做三件事:
- 检查 wakingAfterAbort 条件——如果当前 activity 已经被 abort 且请求唤醒,强制把目标队列从
next-step重定向到next-turn。这是一个安全网:abort 之后你不该往当前 step 注入东西,因为当前 step 已经不存在了。 - 调用
inbox.splice(queue, message)写入目标队列。 - 如果 wake = true,触发 wakeDriver。
对于我们的简单场景:activity 没被 abort,目标就是 next-turn,splice 写入,然后唤醒。
第三步:splice 的 durable-first 语义
splice 被调用时:
- 先调用 session 的 durable append,把 UserMessage 事件写入持久化日志。这个写入是同步的(对于文件系统后端)或者至少是 await 返回后保证落盘的。
- 然后把消息 push 到对应的内存队列(
this.queues['next-turn'].push(message))。
顺序不能反。如果先更新内存再写磁盘,进程崩溃时内存队列里有但磁盘没有,消息就丢了。先写磁盘再更新内存,最坏情况是磁盘有但内存没有——重启时从磁盘恢复就行。
第四步:wakeDriver 启动新的 running phase
wakeDriver 被触发后,检查当前 phase:
- 如果是
idle:创建新的 running phase,分配一个新的 AbortController,进入 turn 循环。 - 如果是
running:不做什么,当前 turn 循环会在下一次 preStep 时自然 claim 到新消息。 - 如果是
maintenance:等 maintenance 结束后再检查。
我们的场景是 idle(agent 刚启动或上一次 turn 已结束),所以 wakeDriver 创建新的 running phase。它做的第一件事是 append 一个 turn/start 事件到 session 日志。
第五步:preStep claim 消息
running phase 进入循环后,第一步是 preStep。preStep 调用 inbox.claim():
claim() {
const messages = [
...this.queues['next-step'].splice(0), // 取走全部 next-step
...this.queues['next-turn'].splice(0, 1) // 取走一条 next-turn
]
return messages
}
注意这个设计:全部 next-step + 一条 next-turn。next-step 是当前 step 的注入,全部取走是合理的——它们都属于这个 step。但 next-turn 只取一条——每条 next-turn 消息代表一个新 turn 的意图,一次只处理一个 turn。
在我们的场景里,next-step 是空的,next-turn 有一条 “hello”。claim 返回 ["hello"]。
第六步:preStep 瀑布流裁决
claim 拿到消息后,preStep 把它们组装成 prompt context,然后跑一个 preStep waterfall(一系列插件可以拦截、修改、或拒绝)。waterfall 的结果是两种之一:
{kind: 'enter', messages}— 通过,带着最终消息列表进入模型调用。{kind: 'reject'}— 被拒绝,本次 step 不执行。
通过的情况下,一个 step/start 事件被 append 到 session 日志。注意:step/start 是在 preStep 通过之后才写入的,不是在 claim 时就写。这意味着如果 preStep reject 了,日志里不会有 step/start,就好像这个 step 从未存在过。
第七步:消息终于进入 prompt
step/start 之后,messages 被传给 model adapter,拼入 prompt,发送 API 请求。到这里,你的 “hello” 才真正到达模型。
第八步:turn 结束
模型回复完毕,没有工具调用需要执行,循环退出。running phase 的 finally 块 append turn/end 事件。phase 切回 idle。
一个完整的生命周期:followup → send → splice(next-turn) → wake → wakeDriver(idle→running) → turn/start → preStep → claim → waterfall(enter) → step/start → model call → step/end → turn/end → idle。
复杂场景一:abort-then-send
用户输入了一句话,模型正在生成回复。用户按 Ctrl+C。CLI 调用 agent.abort(),当前 running phase 的 AbortController 被 abort。模型生成中断。
紧接着(可能在同一个事件循环 tick 里),用户输入了新的一句话,CLI 调用 agent.followup(newInput)。
这时候发生什么?followup 调用 send(newInput, 'next-turn', true)。send 首先检查 wakingAfterAbort:
// Captured before the insertion so a reentrant cancel
// from a splice observer cannot reclassify it
const targetQueue = (this.activity?.aborted && wakeup)
? 'next-turn'
: queue
activity 已经 aborted,wakeup 是 true,所以 targetQueue 强制是 next-turn。在这个例子里 queue 本来就是 next-turn,所以没区别。但如果某个插件在 abort 处理中调用了 steer(目标是 next-step),这个安全网会把它重定向到 next-turn——因为当前 step 已经不存在了,往 next-step 写东西没有意义。
splice 写入 next-turn 队列。然后 wakeDriver 被触发。此时 phase 可能还在 running(abort 触发后 finally 块还没跑完),也可能已经回到 idle(finally 块跑完了)。如果还在 running,wakeDriver 会等当前 phase 结束后自动开启新 phase。如果已经 idle,直接开新 phase。
无论哪种情况,新的 running phase 启动后,preStep claim 到新消息,新 turn 正常进行。abort 不会丢消息。
复杂场景二:steer 在 running 阶段注入
agent 正在一个 turn 的中间——模型回复了一个工具调用,工具正在执行。这时一个 orchestrator 插件想”插话”:它有新的上下文信息需要让模型在下一个 step 看到。
orchestrator 调用 agent.steer(contextMessage)。steer 的实现是:
steer(input) {
return this.send(input, 'next-step', true)
}
目标队列是 next-step,wake = true。
send 检查 wakingAfterAbort:activity 没被 abort(正在正常运行),所以 targetQueue 不变,就是 next-step。splice 写入 next-step 队列。wakeDriver 被触发,但 phase 已经是 running,所以不做什么——当前 turn 会自然地在下一个 preStep 消费到新消息。
工具执行完毕后,循环进入下一个 step 的 preStep。preStep 调用 claim:next-step 里有 orchestrator 注入的消息,next-turn 是空的。claim 返回注入的消息。这些消息和工具结果一起被拼入 prompt,模型在下一次推理时能看到这个新上下文。
steer 和 followup 的核心区别:steer 不开新 turn,它在当前 turn 内部注入。模型看到的是连续的对话流,不会看到 turn 边界标记。这对于”纠正方向”类的操作很重要——你想让模型觉得这个信息一直在那里,而不是用户重新开了一个话题。
一个典型的 steer 使用场景:parent agent 委托 subagent 执行任务,执行到一半发现方向不对,parent 用 steer 注入 “停下来,换一种方式” 的指令。subagent 在下一个 step 看到这个指令,调整行为。这比 abort + followup 更优雅——不会中断当前 turn,不会丢失已有的工具执行结果。
但 steer 的 wake=true 看起来多余?如果 agent 已经在 running 状态,wake 什么都不做。为什么不像 inject 那样 wake=false?答案是:steer 也可能在 idle 状态下被调用。比如上一个 turn 结束了(模型认为任务完成了),但 orchestrator 不同意,想让 agent 继续。steer 的 wake=true 保证即使 agent 已经 idle,也会被唤醒来处理注入的消息。只不过此时”next-step”在 idle 状态下的行为和 next-turn 类似——都是触发新 turn——因为 claim 不区分消息来源,只区分队列。
复杂场景三:inject 静默注入
inject 和 steer 很像,但 wake = false:
inject(input) {
return this.send(input, 'next-step', false)
}
消息被写入 next-step 队列,但不触发 wakeDriver。这意味着如果 agent 当前是 idle,inject 不会唤醒它。消息就静静地躺在队列里,等到下一次有人唤醒 agent(比如用户发了新 followup),preStep claim 时才会被一起取出。
inject 的使用场景:系统级的元信息注入。比如环境变量变了、文件系统状态变了,你想让模型下次推理时知道,但不想因此触发一个新 turn。消息”搭便车”——等到下一次 turn 自然发生时一起被消费。
另一个场景是”预加载”。某些信息你知道模型下次一定需要(比如上一次工具调用的后续结果刚到),你 inject 进去,等用户下次提问时模型自动就能看到。用户体验上是”模型很聪明,我一问它就知道最新状态”,实际上是 inject 提前把信息放好了。
三种发送方式可以压成这样:
| 方法 | 目标队列 | 唤醒 | 语义 |
|---|---|---|---|
followup | next-turn | true | 用户的新话题,开新 turn |
steer | next-step | true | 插话/纠正,不开新 turn,但确保被处理 |
inject | next-step | false | 静默预加载,等下一次 turn 自然消费 |
注意 wakingAfterAbort 的影响:如果 activity 已被 abort,steer 和 inject 的目标队列都会被强制重定向到 next-turn。这是因为”当前 step”已经不存在了,next-step 语义无意义。但 inject 的 wake=false 不变——所以 abort 之后 inject 进了 next-turn 但不唤醒,消息等到下一次显式唤醒才被消费。
复杂场景四:多条 next-turn 排队
用户连续快速输入了三句话(比如粘贴了三行),产生了三次 followup 调用。三条消息都进了 next-turn 队列。
第一次 wakeDriver 触发时,phase 从 idle 切到 running。preStep claim 取走一条(splice(0,1))。这一条触发一个完整的 turn:preStep → step/start → model call → step/end。模型处理完毕,step 循环检查 inbox——发现 next-turn 还有两条。但当前 turn 不会继续处理它们。turn/end 被 append,当前 turn 结束。
然后 wakeDriver 的 post-turn 检查发现 inbox 非空——立刻启动新 phase。第二次 claim 取走第二条。第三次 claim 取走第三条。三句话被顺序处理成三个独立的 turn,每个都有自己的 turn/start 和 turn/end。
这就是 claim 只取一条 next-turn 的原因:一条 next-turn = 一个 turn。多条消息不会被合并成一个 turn,每条都有自己独立的推理上下文和 step 循环。
你可能会问:为什么不把三条消息合并成一个 turn?答案是”turn 隔离”。每个 turn 有独立的 AbortController,独立的 billing boundary,独立的 memory checkpoint。合并了就没法单独 abort 其中一个,也没法单独计费。如果用户第二句话触发了内容过滤,你可以 reject 它而不影响第一和第三句话的处理。
但如果你确实想”合并”——比如你有一个批处理场景,想让模型一次看到多条用户消息——你应该在应用层把多条消息拼成一条 followup 发送,而不是分多次发。框架层面坚持一对一是为了给你最大的控制粒度。
复杂场景五:step 内的多次 claim
一个 turn 里可以有多个 step(模型调用工具→拿到结果→再推理→再调用工具→…)。每个 step 开始前都有 preStep,每次 preStep 都会 claim。
如果在 step 1 执行工具的过程中,有人 steer 了两条消息,这两条消息进了 next-step。当 step 1 结束、step 2 的 preStep 运行时,claim 取走这两条 next-step 消息。它们被拼入 step 2 的 prompt。
这个设计让 step 之间有了”信箱”语义:你不需要精确计时,只要在下一个 step 开始前把消息放进去就行。工具执行可能要几秒甚至几十秒,这段时间内任何 steer 或 inject 都会被下一个 step 接住。
一个有趣的时序问题:如果消息恰好在 claim 执行的那一瞬间被 splice 进来怎么办?JavaScript 是单线程的,claim 的 splice(0) 操作是同步的——它要么拿到这条消息,要么没拿到。不存在”拿了一半”的情况。如果 splice 在 claim 之前完成了内存更新(splice 是同步的),claim 就能拿到。如果 splice 还在等持久化写入的 await,内存队列还没更新,claim 就拿不到——这条消息会等到下一个 preStep。
这种”恰好错过”不是 bug,是 feature。每个 step 有明确的输入边界——claim 那一刻队列里有什么,step 就处理什么。后续到达的消息等下一个 step。不存在”消息到了但 step 没看到所以出了问题”这种情况,因为每个 step 都是自洽的。
复杂场景六:preStep reject
preStep waterfall 中的插件可以 reject。比如 rate limiter 插件判断用户发太快了,或者 content filter 插件判断消息有问题。reject 不是异常,是正常的控制流——waterfall 中的任何一环返回 {kind:'reject'} 就终止。
reject 发生后,claim 已经执行过了——消息已经从 inbox 队列里取出了。但 step/start 不会被 append(因为 preStep 返回 {kind:'reject'})。这些消息就”消失”了——它们被 claim 消费了,但没有进入任何 step。
这是有意的设计。被 reject 的消息不会回到队列里重试。如果需要重试语义,reject 插件自己负责把消息重新 splice 回去。默认行为是”丢弃”。
为什么选择丢弃而不是重新入队?两个原因:
- 避免无限循环。如果 reject 的消息自动回队列,下次 claim 又拿到它,又被 reject,无限循环。
- 给插件最大灵活性。reject 插件知道为什么要 reject——是临时的(rate limit,等一会儿就好)还是永久的(内容违规,永远不该进模型)。临时的,插件可以自己 schedule 一个延迟后的 splice 回写。永久的,直接丢弃。框架不帮你做这个决定。
session 日志里只有 splice 写入时的事件(UserMessage event),没有 step/start。恢复时能看到消息存在过,但也能看到没有对应的 step——这就是 reject 的证据。审计时你可以据此回溯:哪些消息被过滤了、什么时间被过滤的、是哪个 preStep 插件做的决定(通过 reject 事件的 metadata)。
reject 之后 turn 怎么处理?看 inbox 里还有没有消息。如果还有其他消息,循环继续尝试下一个 preStep。如果没有了,step 循环退出,turn/end 被 append,phase 回到 idle。一个 turn 可以因为所有消息都被 reject 而”空转”——turn/start 和 turn/end 之间没有任何 step/start。
turn 边界的状态机
ReactLoopAgent 维护一个三态 phase:
- idle:没有活跃 turn。inbox 可能为空也可能不为空。
- maintenance:在做非推理工作(比如 memory compaction)。不接受新 turn,但 splice 可以写入 inbox。
- running:有活跃 turn。step 循环正在执行。
状态转移规则:
idle → running (wakeDriver 触发,inbox 非空)
running → idle (step 循环结束,inbox 为空)
running → running (step 循环结束,inbox 还有消息,立即开新 turn)
idle → maintenance (系统触发)
maintenance → idle (维护完成)
maintenance → running (维护完成且 inbox 非空)
每次 idle→running 转移时,一个新的 AbortController 被创建。这个 AbortController 的 signal 贯穿整个 turn——模型调用、工具执行、preStep waterfall 都监听这个 signal。abort 这个 controller,整个 turn 的所有异步操作都会被中断。
turn/start 在 idle→running 转移时立即 append。turn/end 在 running→idle 或 running→running 的转移点 append(即当前 turn 结束时)。这两个事件是配对的,由 finally 块保证。即使 step 循环因未捕获异常崩了,finally 块也会 append turn/end。
这个 finally 保证是全系统可靠性的基石。turn/end 不仅是日志记录——它还触发了一系列 cleanup 操作:memory flush、临时文件清理、billing 计量。如果 turn/end 丢了,这些操作都不会执行,资源会泄漏。所以 finally 块里不做任何可能抛异常的操作(或者说,即使里面的操作抛了异常,也会被吞掉,turn/end 事件本身一定会被写入)。
AbortController 的 signal 也被传给了 preStep waterfall 中的每个插件。这意味着如果一个 preStep 插件执行耗时操作(比如远程鉴权),abort 可以中断这个操作。不需要等插件自己超时。
失败边界
splice 写入失败
splice 先写持久化。如果持久化写入失败(磁盘满、权限问题),splice 抛异常。内存队列不会被更新。调用 send 的代码会收到异常。消息既不在磁盘也不在内存——但消息在调用者的栈上,调用者可以重试或上报。
这里有个微妙点:splice 不做回滚。如果持久化系统是 append-only log,写入失败意味着什么都没写进去(因为 append 是原子的)。但如果持久化系统有 buffer,可能写了一半——这是持久化后端的责任,不是 Inbox 的责任。Inbox 假设持久化操作要么成功要么完整失败。
claim 消费后 preStep 崩溃
claim 把消息从队列取出后,如果 preStep waterfall 中的某个插件抛了未捕获异常(不是正常 reject,是 bug 导致的 crash),消息已经离开了 inbox。running phase 的 try-catch 会捕获异常,append turn/end,phase 回到 idle。
这些消息丢了吗?从 inbox 的角度看,是的。但从持久化日志的角度看,splice 时已经写入了 UserMessage event。恢复时重建 inbox 状态,这些消息的事件在日志里,但对应的 step/start 不存在。恢复逻辑可以据此判断:这些消息被 claim 但没有被处理,需要重新入队。
wakeDriver 重入
wakeDriver 可能被同时多次调用(多个 splice 各自触发一次 wake)。但 wakeDriver 内部有 phase 检查:如果已经是 running,什么都不做。如果正在从 running 切到 idle(finally 块正在执行),有一个 pending wake 标记,等 finally 完成后检查。不会出现两个 running phase 并行的情况。
splice observer 导致的 reentrant cancel
send 方法的注释特别提到了这个场景:splice 会触发 observer(session 事件监听器)。observer 可能做各种事情,包括 cancel 当前 activity。如果 cancel 发生在 splice 之后、wakingAfterAbort 检查之前,targetQueue 的判断可能出错。
解决方案是:targetQueue 在 splice 之前就确定了(captured before the insertion)。splice observer 的 reentrant cancel 不会改变已经确定的 targetQueue。消息已经按照 splice 之前的状态分类了。这是一个防御性设计——放弃了”最新状态”的精确性,换来了”不会被重入修改”的一致性。
abort 后的 next-step 消息
如果有消息在 abort 之前就已经进了 next-step 队列(还没被 claim),abort 发生后这些消息怎么办?
答案是:它们留在 next-step 队列里。下一次 claim 时(新 turn 的第一个 preStep),它们会被取出。新 turn 的第一个 step 会看到上一个 turn 残留的 next-step 消息。这通常是安全的——这些消息是工具结果或 steer 注入,在新 turn 的上下文里作为”历史信息”也是合理的。
但如果这不是你想要的行为,你需要在 abort 处理逻辑中手动清空 inbox(inbox.flush('next-step'))。框架不自动清——因为有些场景确实需要这些残留消息被下一个 turn 消费。
turn/start 和 turn/end 不对称
正常情况下 turn/start 和 turn/end 严格配对。但有一种边缘情况:进程在 turn/start 之后、turn/end 之前硬崩溃(kill -9、OOM killed)。此时持久化日志里有 turn/start 没有 turn/end。
恢复逻辑遇到这种情况怎么处理?它扫描日志,找到最后一个 turn/start,检查它后面是否有 turn/end。如果没有,说明上一个 turn 非正常结束。恢复逻辑会:
- 补写一个
turn/end事件(带recovery: true标记,区别于正常结束)。 - 把 turn/start 之后、crash 之前 splice 进来但未被 claim 的消息重新入队。
- 不会重放已完成的 step——那些 step 的结果已经在日志里了。
这个恢复策略是”向前修复”而不是”回滚”。它假设 crash 之前的工作是有价值的(比如已经执行的工具调用不该重复),只修复未完成的边界标记。
并发 splice 的顺序保证
多个调用方可能”同时”调 splice(虽然 JS 是单线程的,但 await 点之间可以交错)。两个 splice 调用的持久化写入是序列化的吗?
答案取决于持久化后端。文件系统后端用 append-only write,每次 write 是一个独立的 syscall,操作系统保证同一文件的 append 不会交错(对于小于 pipe buffer 的写入)。内存后端直接 push 到数组,同步操作,天然有序。
但如果你用了网络持久化后端(比如远程 event store),两个并发的 splice 可能乱序到达。框架对此的处理是:每个事件有递增的 seq 编号(在 splice 调用时分配,不是在持久化写入时分配)。消费端按 seq 排序,不依赖物理到达顺序。
一张图收住这条链路
用户输入
│
▼
followup / steer / inject
│
▼ (wakingAfterAbort 检查)
│
▼
splice ──→ [持久化 append] ──→ [内存队列 push]
│ │
│ (wake=true?) │
▼ │
wakeDriver │
│ │
│ (phase=idle?) │
▼ │
new running phase │
│ │
│ turn/start │
▼ │
preStep │
│ │
▼ │
claim ◄───────────────────────────┘
│
▼ (waterfall)
│
├─ reject → 消息被丢弃,no step/start
│
▼ enter
│
│ step/start
▼
model call
│
▼
step/end → 回到 preStep 或 turn/end
连接下一章
Inbox 解决了”消息什么时候进模型”的问题——答案是不直接进,先暂存、分类、消费、裁决,最后才进。但消息进了 preStep waterfall 之后呢?模型的 prompt 是怎么拼的?那些 system prompt、工具定义、历史消息、memory summary 是怎么组装成一个完整请求的?
claim 返回的 UserMessage 只是 prompt 的一小部分——还有大量的上下文信息需要从 session 日志、memory store、工具注册表中提取和组装。prompt 不是简单的字符串拼接,它是一个有优先级、有 token 预算、有截断策略的组装流水线。
claim 只是把消息从队列里取出来;从这里到最终 API request body,中间还要经过 prompt assembly 那几层变换。让模型“看到”正确的东西,比让消息“到达”模型更复杂。