第 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——这解释了为什么会有不配平的日志
本章目标
- 拿到
agent/*的权威清单——12 个事件,按派发模式分成三类,一眼看出"哪个把手能改行为" - 读懂
turn()这 100 行主干,看清 pre-step 的三种结局与 turn 的关闭判定 - 修正书稿四处关于循环的错误断言
- 看懂
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 carryingconcludesTurnends the turn at its step. The conclusion never short-circuits already-submitted next-step work.
三个要点:
serial而非waterfall——监听器没有返回值,只能"投反对票"(通过 steer)- 由数据决定,监听器顺序无法改变结果
- 反向控制也是数据:工具结果带
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/messageorassistant/attemptwith 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/messageevent.
这是一次架构级的搬迁:系统提示从"请求头的一部分"变成了"派生历史的第 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 subsequentprepareCall()resolution commits neither.
三个推论:
agent/request在step/start之后、在系统提示与用户批次落盘之前- 在这里或后续解析期间取消,两边都不提交——不留半个回合
- 监听器拿到的
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 · 勘误总表。