DeepSeek Harness 源码精读 05:心跳机 Agent Loop

第 05 章 心跳机:Agent Loop

代码基线:commit 8f86b979a1(2026-10-01)| 0.2.0-rc.1 | 本系列讲次:M4

本章导读

这一章讲"是谁让这些事发生"——那个把回合、步骤、模型请求串起来的驱动。

第 4 章讲发生了什么被记下来,这一章讲它怎么发生的。核心文件是 packages/core/agent-loop/src/agent.ts,其中 turn() 方法只有 100 行,却装着整条循环最密集的决策。

本章会让你看清 agent/* 的 12 个事件怎么分成三类——只有三个 waterfall 能改变循环行为,其余九个全是通知。这条清单就是扩展点地图:想加策略去 pre-step(拒绝或改写进入 step 的消息),想换模型去 request(替换冻结后的调用配置),想接管失败重试去 request-error。

本章也纠正了书稿第四处错误:EpochHeader 里已经没有系统提示了。system?: never,还标着 @persistenceReserved。系统提示被搬成了 system/message 事件——也就是派生历史的第 0 个表面节点。这一条同时补全了第 4 章那个"SurfaceEventType 为什么从 3 种变 5 种"的成因。

最值得反复读的是 agent/turn-stopping 的设计原则:由数据决定,所以监听器顺序无法改变结果。 当"是否继续"是一个二元判断时,不要交给监听器的执行顺序去决定它,要交给数据。

学习要点

  • 12 个 agent/* 事件,按派发模式分成"3 个把手 + 9 个通知"
  • @mode serial 与 waterfall 的区别:前者没有返回值,只能"投反对票"
  • pre-step 的三种结局,尤其是首 step 为空时回合照常开启但不烧 token
  • max-tokens 是粘性的,后续正常完成的 step 不能把结局降级
  • turn/end 在 finally 里无条件 append——这解释了为什么会有不配平的日志

本章目标

  1. 拿到 agent/* 的权威清单——12 个事件,按派发模式分成三类,一眼看出"哪个把手能改行为"
  2. 读懂 turn() 这 100 行主干,看清 pre-step 的三种结局与 turn 的关闭判定
  3. 修正书稿四处关于循环的错误断言
  4. 看懂 agent/turn-stopping 里那条"由数据决定"的设计原则

一、agent/*:12 个事件,三种派发模式

词表在 packages/core/agent/src/runtime-types.ts:259-393。实验脚本抽出来的实测:

serial    agent/created, agent/turn-stopping  
emit      agent/disposed, agent/status,  
          agent/inbox/inserted, agent/inbox/claimed, agent/inbox/discarded,  
          agent/assistant-stream, agent/error  
waterfall agent/pre-step, agent/request, agent/request-error  

这张表就是"扩展点地图":只有三个 waterfall 能改变循环的行为,其余全是通知。

把手 能力
agent/pre-step 拒绝一个 step,或替换进入它的消息
agent/request 替换冻结后的调用配置(provider、模型、参数)
agent/request-error 接管一次失败的模型请求,返回 {kind:'retry'} 就归它恢复

1.1 ★ 12 个事件全部带 this: Scoped<Agent>

实测 12/12。每一事件的 JSDoc 都重复同一句:

Scope-filtered dispatch (@deepseek-ai/dsh-scope): agent-scoped listeners receive only that agent.

这正是 M1 §4.3 讲的 events.ts:171-173 上下文过滤在循环层的全面落地——一个监听器只收到它自己 agent 的事件,除非声明 { global: true }。

跨讲回调:M1 说这条机制"是 dsh agent scope 的地基"但没给实例。M4 就是那个实例——读 M1 时完全可以先跳过这层,M4 读完再回头看,它就通了。


二、turn() 主干:100 行,六个决策点

packages/core/agent-loop/src/agent.ts:296-395 整个 turn() 方法。返回值是 boolean——是否还要再跑一个回合。

2.1 pre-step 的三种结局(313-327)

const decision = await this.preStep(target, { turn, step })  
if (decision.kind === 'reject') {  
  turnEnds = { kind: 'blocked' }  
  return false                                    // ① 拒绝:不写 user/message,不开 step  
}  
if (turnEnds && decision.messages.length === 0) break      // ② 后续 step 空批次 → 收  
if (phase.step === 0 && decision.messages.length === 0) {  
  turnEnds = { kind: 'completed' }  
  return false                                    // ③ 首 step 空 → 回合照常开启但零次模型调用  
}  

第 ③ 条的注释值得逐字读:

A removed waking message or an enter decision rewritten to empty still owns the initial turn boundary, but it spends no model call.

回合边界仍然存在,只是不烧 token。 这就是书稿 §4.4 说的"即使拒绝,空回合也会被记录"——代码上体现为 turn/start 在循环开头无条件 append(第 305 行),turn/end 在 finally 里无条件 append(第 385 行)。

2.2 max-tokens 是粘性的(336-341)

// max-tokens is sticky: once any step hits the ceiling, later steps  
// that complete normally must not downgrade the turn outcome.  
if (turnEnds === null || turnEnds.kind !== 'max-tokens') turnEnds = stepEnd  

一旦某个 step 撞上输出上限,后面正常完成的 step 不能把回合结局"降级"回 completed。

2.3 工具结果的补记(331-334, 342-352)

const toolRecovery = new ToolCallRecovery()  
const stopRecovery = this.ctx.on('session/event', (session, event) => {  
  if (session === this.session) toolRecovery.observe(event)  
})  

step 抛错时,已经发起但还没落账的工具结果会被补记进日志;若补记也失败,抛 AggregateError([error, recoveryError])——两个错误都不丢。

2.4 ★ agent/turn-stopping 只在真要关时才发(358-363)

signal.throwIfAborted()  
if (turnEnds && this.inbox.nextStep.length === 0) {  
  await this.dispatch.serial('agent/turn-stopping', { turn, signal })  
  signal.throwIfAborted()  
}  
if (turnEnds && this.inbox.nextStep.length === 0) break  

发事件之后再检查一次 inbox。 因为监听器可能 agent.steer(...) 塞进新输入。

事件文档把这条设计原则写得很清楚:

Awaited before the boundary commits — a listener that objects steers (agent.steer(...)) and the machine re-reads its inbox: fresh steering runs another step, none closes the turn. Data decides, so listener order cannot change the outcome. The inverse control (stop a tool loop early) is data too: a tool result carrying concludesTurn ends the turn at its step. The conclusion never short-circuits already-submitted next-step work.

三个要点:

  1. serial 而非 waterfall——监听器没有返回值,只能"投反对票"(通过 steer)
  2. 由数据决定,监听器顺序无法改变结果
  3. 反向控制也是数据:工具结果带 concludesTurn 就地结束回合

这是全书最值得学的一条工程原则:当"是否继续"是一个二元判断时,不要交给监听器的执行顺序去决定它,要交给数据。

2.5 turn/end 在 finally 里(382-389)

} finally {  
  try {  
    this.session.append('turn/end', { turn, reason: turnEnds! })  
  } catch (error: unknown) {  
    this.throwError(error)  
  }  
}  

这也是 M3 实测那条 turn/start=1, turn/end=0 不配平日志的成因——finally 都没跑到,说明进程被硬杀了。


三、★ 勘误一:循环写进日志的是 12 类事件,assistant/chunk 不在其中

实测 agent.ts 里的全部 .append(...) 调用点:

turn/start   user/message   step/start   system/message   developer/message  
assistant/message   assistant/attempt   tool/result   step/end  
turn/end     request/header   request/context  

没有 assistant/chunk。 这是 M3 E-13 的第二处独立佐证(第一处是 known-event-types.ts 里没有它)。

书稿 §4.5 第四步说:

循环把适配器吐出的 assistant/chunk 逐条追加进日志(保证 token 级回放保真)……最终产生一条 assistant/message 事件(携带 token 使用量、以及标注它源自哪些 chunk 的 sourceEventSeqs)

两处都错。 反过来看 agent/assistant-stream 的文档注释(M3 已引):

Chunk frames are transient; the loop appends one final assistant/message or assistant/attempt with the same stream before a committed end frame.

chunk 是进程内的瞬时帧,落盘的是"整个流内嵌进去"的一条事件。


四、★ 勘误二:TurnEndReason 有 7 个取值,两个循环永不发出

types.ts:201-229 实测:

completed   aborted   blocked   error   max-tokens   interrupted   forked  
reason 含义
completed 正常收尾
aborted 被取消请求打断,带 TurnEndCancelCause
blocked pre-step 拒绝了这一步
error 回合失败,error 永远结构化:LlmError 保留 facts,其余摊平成 {message: errorChain(e), code:'UNKNOWN'}
max-tokens 至少一个 step 撞到输出上限(粘性,见 §2.2)
interrupted 崩溃孤儿回合事后补写:agent-loop resume 为"最后一个回合没结束"的存储日志补上;session-query 冷读时合成。循环永不实时发出
forked fork 边界处仍开启的回合被闭合。只有 fork seed 携带,循环同样永不发出,子会话里边界前的事件原封不动

书稿 §3.3.2 写"那个唯一的特例 interrupted……它是唯一一种循环自己永远不发出的 reason"——

错。forked 是第二个。 这两个的共同点是:都不是"运行中发生的",而是"事后为了日志完整性补的"。


五、★ 勘误三:EpochHeader 里已经没有系统提示了

书稿 §4.5 第三步写"循环构建 EpochHeader(call config + 系统提示 + 工具清单)"。实测:

export interface EpochHeader {  
  config: LlmCallConfig  
  adapterDefaults?: LlmCallConfigAdapterDefaults  
  tools?: ToolSchema[]  
  /** Retired request text; system prompts belong to system/message events.  
   *  @persistenceReserved */  
  system?: never  
}  

system?: never,还标了 @persistenceReserved。 类型定义的文档说:

The system prompt is derived history — surface node 0, a system/message event.

这是一次架构级的搬迁:系统提示从"请求头的一部分"变成了"派生历史的第 0 个表面节点"。

这正好补上了 M3 E-14 的另一半:我当初只指出 SurfaceEventType 从 3 种变成 5 种(多出 system/message),但没说为什么。现在明白了——因为系统提示被搬进了 surface。

教学点:request/header 记的是"派生历史之外的东西"。书稿 §3.8 说"让这次模型调用也成为日志的纯函数"是对的,但它对"纯函数"的边界理解过时了:请求头不再是函数自变量的全部,系统提示已经从自变量变成输出的一部分。

顺带:书稿没提 RequestHeaderReason 的四个取值——'initial' | 'resume' | 'change' | 'series',其中 'series' 表示"未变的 header 开启了一个显式独立的消息序列"(PreStepDecision.startsRequestSeries 就是它)。


六、★ 勘误四:书稿 §4.4 说 pre-step 能"改写消息",这条要加限定

书稿 §4.4 说 pre-step 监听者可以"改写进入 step 的消息"。实测 PreStepDecision:

export type PreStepDecision =  
  | { kind: 'reject' }  
  | { kind: 'enter'; messages: UserMessage[]; startsRequestSeries?: true }  

这与书稿一致。但 agent/request 的文档注释划了一条红线:

Model-visible content must use logged channels; this waterfall cannot mutate messages.

即:能改消息内容的只有 pre-step(且是用户消息),agent/request 只能改调用配置。 这个分工就是 M3 那条铁律在循环层的具体投影。


七、agent/request 的时机与"纯函数"

书稿 §4.6 说"一次模型请求就是一段日志的纯函数"。实测 agent/request 的注释给出了精确时机:

On step admission, this runs after assembly and step/start, before the system prompt and accepted user batch are committed. Cancellation here or during subsequent prepareCall() resolution commits neither.

三个推论:

  1. agent/request 在 step/start 之后、在系统提示与用户批次落盘之前
  2. 在这里或后续解析期间取消,两边都不提交——不留半个回合
  3. 监听器拿到的 next() 是"机器本来会用的配置";要换就返回替代值,不要就地改

配合书稿 §4.5.1 讲的 deep-frozen GenerateOptions:日志里记的 request/header 与最终发出的请求因此始终对得上。这一段书稿写得很好,不用改。



本章实验(附录)

八、动手验证

export PATH="/opt/homebrew/bin:$PATH"  
cd /Users/ygs/ygs/deepseek-harness  
node --import tsx/esm "ygsdoc/学习笔记/labs/M4-loop-lab.ts"  

六项输出:① 12 个 agent/* 事件 + 派发模式分组;② scope-filtered 覆盖率;③ TurnEndReason 7 个取值与"永不实时发出"的两个;④ EpochHeader 字段;⑤ 循环 append 的 12 类会话事件;⑥ 书稿四条断言的逐条对质。

第 ⑥ 项输出:

✗ 书稿错  assistant/chunk 逐条追加进日志  
     事实:agent-loop 的 append 调用点里不存在此类型  
✗ 书稿错  assistant/message 标注源自哪些 chunk 的 sourceEventSeqs  
     事实:SurfaceIntent 对 assistant/message 规定 sourceEventSeqs?: never  
✗ 书稿错  EpochHeader = call config + 系统提示 + 工具清单  
     事实:EpochHeader.system?: never(@persistenceReserved)  
✗ 书稿错  interrupted 是唯一一种循环自己永远不发的 reason  
     事实:forked 同样永不实时发出  

M4 通过标准:能说清 agent/turn-stopping 为什么用 serial 而不是 waterfall,并解释"由数据决定,所以监听器顺序无法改变结果"这句话在代码里对应的两处判断(turn-stopping 前后各查一次 inbox.nextStep)。


实验脚本:labs/M4-loop-lab.ts

运行(在仓库根目录):

export PATH="/opt/homebrew/bin:$PATH"  
cd /Users/ygs/ygs/deepseek-harness  
node --import tsx/esm "dsh-源码精读/labs/M4-loop-lab.ts"  

预期输出(本机实测,任何一行对不上就说明基线变了):

1) agent/* 事件共 12 个:  
   serial    agent/created  
   emit      agent/disposed  
   emit      agent/status  
   emit      agent/inbox/inserted  
   emit      agent/inbox/claimed  
   emit      agent/inbox/discarded  
   waterfall agent/pre-step  
   waterfall agent/request  
   waterfall agent/request-error  
   emit      agent/assistant-stream  
   serial    agent/turn-stopping  
   emit      agent/error  
   按派发模式分组(这就是"哪个把手能改行为"的清单):  
   serial    agent/created, agent/turn-stopping  
   emit      agent/disposed, agent/status, agent/inbox/inserted, agent/inbox/claimed, agent/inbox/discarded, agent/assistant-stream, agent/error  
   waterfall agent/pre-step, agent/request, agent/request-error  
2) 带 this: Scoped<Agent> 的 agent 事件:12/12  
   → 每个事件的 JSDoc 都重复 "agent-scoped listeners receive only that agent"  
   → 这就是 M1 讲的 events.ts:171-173 上下文过滤在循环层的落地  
3) TurnEndReason 共 7 个取值: completed, aborted, blocked, error, max-tokens, interrupted, forked  
   循环永不实时发出的: interrupted, forked  
   → interrupted:崩溃孤儿回合由 resume 补写 / session-query 冷读合成  
   → forked:只有 fork seed 携带  
4) EpochHeader 字段:  
   config  
   adapterDefaults?  
   tools?  
   system?  
   → system?: never 且标了 @persistenceReserved  
   → 系统提示已变成 system/message 事件,也就是派生历史的 surface node 0  
5) agent-loop/src/agent.ts 写入的会话事件类型(12 类):  
   assistant/attempt, assistant/message, developer/message, request/context, request/header, step/end, step/start, system/message, tool/result, turn/end, turn/start, user/message  
   → 没有 assistant/chunk:chunk 只是进程内的瞬时帧  
   → 落盘的是 assistant/message 或 assistant/attempt,流内嵌在 stream 数组里  
6) 书稿原文断言 vs 源码事实  
   ✗ 书稿错  assistant/chunk 逐条追加进日志  
        事实:agent-loop 的 append 调用点里不存在此类型  
   ✗ 书稿错  assistant/message 标注源自哪些 chunk 的 sourceEventSeqs  
        事实:SurfaceIntent 对 assistant/message 规定 sourceEventSeqs?: never  
   …(完整输出见运行脚本)  

本章的勘误条目见 附录 A · 勘误总表。