青雲的博客
深入浅出 DeepSeek Harness 第二部:一句话的旅程——输入怎样变成模型请求 第 07 章

turn/start 之前:谁有权开始新一轮

ReactLoopAgent 有三态 idle/maintenance/running,不是简单的"空闲/忙碌"。wakeDriver 创建 running phase + 新的 AbortController。turn() 先 append turn/start,然后 preStep 做四件事:claim inbox、assemble system prompt、project runtime context、跑 agent/pre-step waterfall 决策 enter/reject。被 reject 则 turnEnds=blocked,不浪费模型调用。step/start 和 user/message 只在 preStep 通过后才 append。

源码版本
47f943859bef60e4160492346772ded9b24f765a
验证日期
Commit
47f943859bef60e4160492346772ded9b24f765a

你可能以为:Inbox 里有消息了,模型就会被调用。Inbox 有消息,说明有活要干,那就干呗。

错了。在 Inbox 消息到达和模型被调用之间,有一道关卡叫 preStep。而且 turn/start 事件在 preStep 之前就写入了——你看到 turn/start,不代表这轮一定会调模型。preStep 可以 reject 这一轮,让 turn 以 blocked 结束,模型一毛钱都没花。

三态机:不是空闲/忙碌那么简单

ReactLoopAgent 的 phase 不是布尔值”空闲/忙碌”,它有三个状态:

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '38,47p' "$repo/packages/core/agent-loop/src/agent.ts"
  • idle:没有任何活动。`lastTurn 记录最后完成的 turn 编号。只有 idle 状态才能开始新的 driver。
  • maintenance:正在跑维护任务(比如 compaction)。有自己的 AbortController,wakeRequested 标志位。维护任务期间收到的 wake 不会立即执行,而是 latch 住,等维护结束后再触发。
  • running:正在跑 turn 循环。有自己的 AbortController、turn 编号、step 编号、wakeRequested 标志位。

status getter 把 idle 和 maintenance 都映射为 'idle',running 映射为 'running'。外部观察者看到的是二值,但内部是三态。

为什么 maintenance 需要单独的状态?因为 compaction 这类维护任务要读 session、写事件,但它不是用户 turn。如果在 maintenance 期间把 phase 当成 running,那 wakingAfterAbort 的逻辑会误判。而且 maintenance 有自己的 AbortController,和 running 的 controller 是分开的——取消维护任务不等于取消 turn(虽然维护期间也不该有 turn)。

flowchart TD
    A["idle"] -->|wakeDriver| B["running + new AbortController"]
    A -->|runMaintenance| C["maintenance + new AbortController"]
    B -->|turn 结束 + no pending| A
    B -->|turn 结束 + wakeRequested| B
    C -->|维护结束 + wakeRequested + hasPending| B
    C -->|维护结束 + no wake| A
    B -->|abort| D{"wakeRequested?"}
    D -->|是| B
    D -->|否| A

wakeDriver:不总是立即启动

wakeDriver(wakeAfterAbort) 是所有唤醒的入口。它的逻辑分两种情况:

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '172,193p' "$repo/packages/core/agent-loop/src/agent.ts"

如果 phase 不是 idle(正在 maintenance 或 aborted running):不能直接开 turn。检查是否应该 latch wakeRequested:

  • disposed 原因不 latch(正在销毁,别再唤醒了)。
  • maintenance 状态 latch。
  • wakeAfterAbort(abort 后的唤醒)latch。
  • 正常 running 状态不 latch(因为 live driver 自己会 claim inbox 里的活)。

如果 phase 是 idle:创建新的 running phase,分配新的 AbortController,turn 设为 lastTurn(不是 lastTurn+1,因为 turn() 里会 +1),step 设为 0,然后通过 loopCtx.agents.withInitiator 调用 kick()

注意:每个 phase 都有自己的 AbortController。当一个 turn 结束后,如果还有 pending 消息,turn() 方法会创建新的 AbortController(第 325 行 phase.abort = new AbortController()),而不是复用旧的。旧 controller 的 signal 可能已经 aborted,新的 turn 需要一个干净的 signal。

turn():先开门,再检查有没有资格进

kick() 循环调用 turn(),直到 turn() 返回 false(没有更多 pending 工作)。

turn() 方法的执行顺序是:

  1. 检查 running phase,取 signal。
  2. append turn/start(第 255 行)。
  3. phase.turn++。
  4. 初始化 turnEnds = null,target = 'next-turn'
  5. 进入 step 循环: a. signal.throwIfAborted()。 b. step++。 c. 调用 preStep(target, {turn, step})。 d. 如果 decision.kind === ‘reject’:turnEnds = {kind:'blocked'},return false。 e. 如果 turnEnds 已设置且 messages 为空:break。 f. 如果第一步(phase.step===0)且 messages 为空:turnEnds = {kind:'completed'},return false(空 turn,不调模型)。 g. append step/start。 h. phase.step = step。 i. 对每个 message append user/message(带 surfaceOp:‘append’)。 j. 调用 step() 执行模型调用。 k. 更新 turnEnds(max-tokens 粘滞)。 l. append step/end(在 finally 里)。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '246,300p' "$repo/packages/core/agent-loop/src/agent.ts"

关键发现:turn/start 在 preStep 之前就写了。这意味着即使 preStep reject,事件日志里也有一个 turn/start 记录,但不会有 step/start 和 user/message。turn/end 会在 finally 里以 blocked 原因写入。

preStep:四道关卡

preStep 是真正的守门人。它做四件事,顺序严格:

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '225,243p' "$repo/packages/core/agent-loop/src/agent.ts"
  1. claim inboxthis.inbox.claim(target, position.turn),取出应消费的消息。
  2. assemble system promptthis.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal)),组装系统提示词(下一章详细讲)。
  3. signal.throwIfAborted():组装 prompt 可能是异步的,期间可能被 abort。
  4. render context sections + project runtime context:把 context sections 渲染成文本,通过 runtimeContext.project() 投影成运行时上下文消息。
  5. agent/pre-step waterfall:跑 waterfall 链,默认决策是 {kind:'enter', messages: claimed 或 claimed+context}。插件可以拦截返回 reject,或者修改 messages。

waterfall 链返回 PreStepDecision,要么 {kind:'enter', messages: [...]} 要么 {kind:'reject'}。为什么需要 reject?一个插件可能判断”现在不该让模型说话”——比如有个权限检查没通过,或者当前状态不允许执行任何操作。reject 比 throw 好,因为 reject 是正常业务决策(turn 以 blocked 结束),throw 是错误(turn 以 error 结束)。

注意第 238 行默认决策:messages: context === undefined ? claimed : [...claimed, context]。runtime context 如果存在,被追加到 claimed 消息的末尾,作为最后一条 user-role 消息送给模型。这就是为什么 context 里的信息能覆盖之前的上下文——它在最靠近模型的位置。

step/start 和 user/message 延迟写入

你可能注意到了,step/start 和 user/message 不是在 turn/start 之后立即写的,而是在 preStep 返回 enter 之后才写。这是刻意设计的。

如果你在 turn/start 之后就写 step/start 和 user/message,但 preStep 最终 reject 了,那事件日志里就会出现”孤儿” step/start——没有对应的 step/end(因为 step 根本没执行),也没有模型调用。这会让 replay 和审计变得混乱。

延迟写入保证了:事件日志里有 step/start,就一定有对应的 step/end(finally 块保证),且中间一定发生了模型调用或工具执行。

sequenceDiagram
    participant WD as wakeDriver
    participant T as turn()
    participant PS as preStep
    participant WF as agent/pre-step waterfall
    participant S as step()

    WD->>T: kick() → turn()
    T->>T: append turn/start
    T->>PS: preStep(target, pos)
    PS->>PS: claim inbox
    PS->>PS: assemble system prompt
    PS->>PS: render context + project
    PS->>WF: waterfall (default: enter)
    alt reject
        WF-->>PS: {kind:'reject'}
        PS-->>T: reject
        T->>T: turnEnds=blocked
    else enter with messages
        WF-->>PS: {kind:'enter', messages}
        PS-->>T: enter + messages
        T->>T: append step/start
        T->>T: append user/message(s)
        T->>S: step(assembly)
        S-->>T: stepEnd
    end
    T->>T: append turn/end (finally)

容易踩的坑

坑一:看到 turn/start 就以为模型被调用了。 不对。turn/start 只是”开门”,preStep 可能 reject,或者第一步的 messages 为空(被清空了),这两种情况都不会调模型。你查事件日志的时候,要配合 step/start 一起看——有 step/start 才说明这一步真的走到了模型。

坑二:maintenance 期间的消息不丢。 wakeDriver 在 maintenance 时设置 wakeRequested=true,等 maintenance 结束后(runMaintenance 的 finally 块)检查 wakeRequested && this.inbox.hasPending,如果有就调用 wakeDriver()。消息在 inbox 里安全地等着,不会因为 maintenance 而丢失。

坑三:每个 running phase 都有新 AbortController。 turn() 第 325 行 phase.abort = new AbortController()——一轮 turn 结束后如果还有 pending,创建新 controller。不要缓存跨 turn 的 AbortSignal 引用,旧 signal 已经 aborted 了,新 turn 需要干净的 signal。

坑四:空 turn 是正常的。 第 274-277 行:phase.step===0 且 messages.length===0 时,turnEnds=completed 直接返回。什么时候会空?wakeup 触发了 turn 开启,但在 preStep claim inbox 之前,消息被 cancel 了(keepInbox=false 的 cancel 会清空 inbox)。turn 开了,但没消息可送——正常结账,不算错误。

turn 开了,preStep 通过了,step/start 写了,user/message 也写了。接下来要把这些东西组装成模型能理解的请求。但在送给模型之前,还需要做一件大事:拼 System Prompt。System Prompt 不是一段写死的文本,它是怎么拼出来的?下一章揭晓。