前置:第 01–03 课 | 预计时长:60–90 分钟 | 动手环节:必须完成
你将学会
kick()主循环为什么极简,复杂度去了哪里;- 一次 Turn 的事件序列,以及三个瀑布拦截点分别能改写什么;
agent/turn-stopping为什么是 serial 而不是 waterfall;- 「请求体必须能从日志重建」这条运行时不变量在代码里的位置。
问题引入
前两课是骨架和语法,现在看产品主干:用户发一句话,到模型回复并执行完工具,中间到底发生了什么?
答案浓缩成一句:kick() 就是 while (await this.turn()) {}(packages/core/agent-loop/src/agent.ts:217-230)——主循环只有一行,全部复杂度在 turn() 的事件编排里。这一课把 turn() 拆开。
正文
4.1 事件序列总览
对应 packages/core/agent-loop/src/agent.ts 的 turn()(:253-337):
async turn() {
emit('turn/start') // :262
inputs = await inbox.claim() // 领取排队输入,发 agent/inbox/claimed
decision = await waterfall('agent/pre-step', ...) // :241-247 拦截点①:可 {kind:'reject'} 关掉 turn
if (decision.kind === 'reject') return false // 或替换入步消息
emit('step/start') // :286
for (msg of inputs) append('user/message', msg) // :289-291
config = await waterfall('agent/request', buildRequest(...)) // :476-483 拦截点②:可整体替换调用配置
append('request/header', { prompt: renderPrompt(assembly) }) // :496-516 提示词渲染结果入日志
append('request/context', contextSnapshot) // 动态上下文快照(持久)
messages = session.deriveMessages() // :353 请求体 = 日志重建(见 4.3)
stream = llm.stream({ ...config, messages }) // :362(前有不变量比对)
for await (chunk of stream) append('assistant/chunk', chunk) // :366
append('assistant/message', final) // :416-425
// 失败时:waterfall('agent/request-error', err) 拦截点③,可返回 {kind:'retry'} :390-406
if (hasToolCalls) executeToolCalls() // :430-433 → 工具管线见第 09 课
finally { emit('step/end') } // :299 无论成败必发
await serial('agent/turn-stopping') // :303 监听者可 steer() 投递数据
if (inbox 有新数据) return true // 重读 inbox:再跑一步
finally { emit('turn/end', { reason }) } // :326 TurnEndReason 见 session types
return false
}
记忆锚点:turn 开始(领输入、给拦截点①)→ step(记消息、组请求、流式、工具、给拦截点③)→ 收尾(serial 停止协商、决定再跑一步还是关 turn)。
4.2 三个瀑布拦截点决定了走向
| 拦截点 | 模式 | 能做什么 | 已有用途 |
|---|---|---|---|
agent/pre-step | waterfall | 否决整个 turn(reject)或替换入步消息 | plan 模式压力压缩挂在这里 |
agent/request | waterfall | 整体替换模型调用配置 | 换模型、注入参数 |
agent/request-error | waterfall | 返回 {kind:'retry'} | 上下文溢出恢复(compaction 捕获 CONTEXT_WINDOW_EXCEEDED_CODE 后重试) |
第 03 课的语法在这里全部兑现:这三个点都是 waterfall,监听器要么调 next() 放行/包裹,要么返回自己的决策。
4.3 不变量:请求必须能从日志重建
对应 packages/core/agent-loop/src/invariant.ts:21-52,在 llm/stream 前置拦截:
// invariant.ts:39-41
expected = session.deriveMessages()
if (JSON.stringify(options.messages) !== JSON.stringify(expected)) {
fail('llm request ... diverges from the dispatch-time durable derivation')
}
含义:发给模型的请求体不来自内存里的消息对象,而来自会话日志的派生结果 session.deriveMessages(),并在发请求前逐字节比对。这是「模型可见 ⟺ 已记录」不变量的运行时强制层(完整三层见第 05 课)。配套的 session 结构校验(seq 严格递增、turn/step 开闭配对、tool/result 必须有同 step 的 tool/call)在 packages/core/session/src/invariant.ts:60-160。
4.4 turn-stopping:数据决定,顺序无关
agent/turn-stopping 是 serial 而非 waterfall:监听者可以借 agent.steer() 投递数据,机器随后重读 inbox 决定再跑一步还是关 turn。也就是说:是否继续不取决于某个监听器的「投票」(waterfall 语义),而取决于 inbox 里有没有新数据——谁投的、什么顺序投的都无所谓。
4.5 工具调用阶段的事件
对应 packages/core/agent-loop/src/tool-calls.ts:tool/call 记录调用(:262-265)→ 按模型给出的顺序 commitReady 提交(:146-160)→ tool/result 记录结果并用 sourceEventSeqs: [callSeq] 回引原调用(:268-289)。执行本身的四道关卡是第 09 课的主题。
4.6 作用域路由
所有派发经 scopeTarget 过滤:作用域监听者只收到本 agent 的事件。每个 agent 有独立子上下文与作用域标记(第 02 课的 fiber 在这里兑现为多 agent 隔离)。
动手环节
- 从快照反推序列:从
snapshots/挑一个录制会话文件(第 01 课跑过的),按seq顺序列出一次 turn 内的事件类型序列,与本课 4.1 的事件序逐条对齐。注意找:request/header、assistant/chunk、tool/call与tool/result的配对、step/end与turn/end。 - 定位拦截点:
grep -rn "agent/pre-step\|agent/request-error\|agent/turn-stopping" packages/ --include=*.ts | grep -v tests,对每个命中判断:它是监听方还是派发方?监听方调next()了吗? - 推演题:为什么
step/end/turn/end写在finally里?如果llm.stream中途抛异常且无人重试,日志里应该看到哪几种事件、缺哪几种?
自检清单
- 主循环为什么只有一行?「再跑一步」的判断依据是什么?
- 三个拦截点各自能改写什么?压缩溢出恢复挂在哪一点?
- 「请求体 = 日志重建」这条不变量由哪两段代码强制?分别在什么时机?
- turn-stopping 用 serial 的理由是什么?如果换成 waterfall 会破坏什么?
常见误解
- 「消息历史是内存里的数组发给模型」——不是。内存数组只是日志,请求体由
deriveMessages()从日志派生,并且被逐字节比对。 - 「turn 失败日志会不完整」——开闭事件在
finally必写;不完整的是内容(如没有 assistant 消息),不是结构。 - 「steer 是直接插消息进当前请求」——它投递到 inbox,由停止协商重读后决定下一步。
延伸阅读
- 维基:《turn-execution-flow》
- 仓库:
docs/agent-lifecycle.md、docs/architecture.md - 下一课:第 05 课 · 会话日志与「模型可见 ⟺ 已记录」