Skip to content
My Blog
Go back

第 04 课 · 一次 Turn 的完整旅程

前置:第 01–03 课 | 预计时长:60–90 分钟 | 动手环节:必须完成

你将学会

问题引入

前两课是骨架和语法,现在看产品主干:用户发一句话,到模型回复并执行完工具,中间到底发生了什么? 答案浓缩成一句:kick() 就是 while (await this.turn()) {}packages/core/agent-loop/src/agent.ts:217-230)——主循环只有一行,全部复杂度在 turn() 的事件编排里。这一课把 turn() 拆开。

正文

4.1 事件序列总览

对应 packages/core/agent-loop/src/agent.tsturn():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-stepwaterfall否决整个 turn(reject)或替换入步消息plan 模式压力压缩挂在这里
agent/requestwaterfall整体替换模型调用配置换模型、注入参数
agent/request-errorwaterfall返回 {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-stoppingserial 而非 waterfall:监听者可以借 agent.steer() 投递数据,机器随后重读 inbox 决定再跑一步还是关 turn。也就是说:是否继续不取决于某个监听器的「投票」(waterfall 语义),而取决于 inbox 里有没有新数据——谁投的、什么顺序投的都无所谓。

4.5 工具调用阶段的事件

对应 packages/core/agent-loop/src/tool-calls.tstool/call 记录调用(:262-265)→ 按模型给出的顺序 commitReady 提交(:146-160)→ tool/result 记录结果并用 sourceEventSeqs: [callSeq] 回引原调用(:268-289)。执行本身的四道关卡是第 09 课的主题。

4.6 作用域路由

所有派发经 scopeTarget 过滤:作用域监听者只收到本 agent 的事件。每个 agent 有独立子上下文与作用域标记(第 02 课的 fiber 在这里兑现为多 agent 隔离)。

动手环节

  1. 从快照反推序列:从 snapshots/ 挑一个录制会话文件(第 01 课跑过的),按 seq 顺序列出一次 turn 内的事件类型序列,与本课 4.1 的事件序逐条对齐。注意找:request/headerassistant/chunktool/calltool/result 的配对、step/endturn/end
  2. 定位拦截点grep -rn "agent/pre-step\|agent/request-error\|agent/turn-stopping" packages/ --include=*.ts | grep -v tests,对每个命中判断:它是监听方还是派发方?监听方调 next() 了吗?
  3. 推演题:为什么 step/end / turn/end 写在 finally 里?如果 llm.stream 中途抛异常且无人重试,日志里应该看到哪几种事件、缺哪几种?

自检清单

常见误解

延伸阅读


Share this post:

Previous Post
状态就是日志:plan、todo、goal 在 dsh 里怎么存
Next Post
一切皆插件:读 DeepSeek Harness 的架构