CHAPTER 06 · STAGE 02 · 追踪完整对话

Agent 如何完成一轮对话:Turn、Step 与 Inbox

用户输入:“读取 package.json,并告诉我项目使用什么包管理器。”模型第一次收到请求后,可能决定调用文件读取工具。工具返回内容后,模型还要第二次收到历史和工具结果,才能生成自然语言答案。

一个 Turn 为什么不等于一次模型调用

用户输入:“读取 package.json,并告诉我项目使用什么包管理器。”模型第一次收到请求后,可能决定调用文件读取工具。工具返回内容后,模型还要第二次收到历史和工具结果,才能生成自然语言答案。

从用户开始这项工作到系统不再欠任何后续动作,称为一个 Turn。每次“请求模型,再执行该次模型请求的工具调用”称为一个 Step。因此,一个 Turn 可以有零个、一个或多个 Step。

Turn 1
  Step 1:模型决定读取 package.json → 执行工具
  Step 2:模型看到工具结果 → 输出最终答案

Agent 与 AgentLoop 的职责不同

Agent 是对外接口。入口、UI、协议驱动和插件通过它发送消息、取消工作、等待 idle 或注入上下文。

AgentLoop 是默认的具体驱动实现。它领取 Inbox 消息、打开 Turn/Step、组装请求、调用 LLM、调度 Tool Call、记录 SessionEvent,并决定是否继续下一步。

其他包应该依赖 ctx.agents 暴露的 Agent 接口,而不是构造 Loop 内部的具体类。这样 AgentLoop 仍然可以被替换,UI、协议和扩展插件也不会依赖私有实现。

Inbox:所有输入从一个入口进入

Agent 有两类待处理目标:next-turn 和 next-step。

followup() 把消息放入 next-turn FIFO,并唤醒空闲 Agent。它适合开始一项新的用户工作。

steer() 把消息放入 next-step,并唤醒 Agent。它适合在当前工作进行时追加明确指令,让下一次模型请求尽快看到。

inject() 也把内容放入 next-step,但不会单独唤醒 Agent。它适合文件变化通知、技能内容或动态上下文:这些内容等待下一次本来就会发生的请求。

三者都使用统一的消息与 Inbox 机制,只是目标和 wakeup 策略不同。

Turn 开始时怎样领取输入

Loop 被唤醒后先记录 turn/start,然后原子地领取待处理的 next-step 内容和一条 queued prompt。领取意味着消息从 pending projection 中删除,并通过运行时事件通知 UI。

随后 agent/pre-step waterfall 决定这些消息是否进入 Step。监听器可以修改消息、拒绝这一步,或调用 next() 让其他监听器继续处理。

如果第一批输入被拒绝或被改写为空,Turn 仍会记录开始与结束,但不产生 step/start。这保留了“系统确实尝试处理过这批输入”的持久边界,同时没有虚构一次模型调用。

Step 内部发生什么

一次正常 Step 的顺序可以压缩为:

  1. 记录 step/start。
  2. 把已接纳输入记录为 user/message。
  3. 组装 System Prompt、动态上下文和 Tool Schema。
  4. 从 Session 日志推导模型消息历史。
  5. 通过 agent/request 和 llm/stream 发出请求。
  6. 把流式 chunk 记录为 assistant/chunk。
  7. 把完整回复记录为 assistant/message。
  8. 调度模型请求的 Tool Call,记录 tool/call 与 tool/result。
  9. 记录 step/end。

如果有工具结果需要模型继续处理,或者 next-step Inbox 有新内容,Loop 开始下一个 Step。

agent/pre-step 为什么重要

它位于输入已经领取、模型请求尚未构造之间,适合做上下文压缩压力检查、输入改写和接纳策略。

这个 waterfall 的返回值是权威结果。只想观察的监听器必须调用 next();想拒绝的监听器可以返回 reject。错误地吞掉 next 会让 Turn 在没有 Step 的情况下关闭。

agent/request 与 llm/stream

agent/request 允许插件在最终调用前参与模型路由和请求构造,例如补充 provider/model。它仍然处于 Agent 生命周期域。

llm/stream 属于 LLM 能力,终端 Adapter 或中间件在这里处理具体流式调用。把两者区分开,可以让 Agent 级策略不绑定特定 Provider。

Tool Call 为什么仍属于当前 Step

Step 定义为“一次模型请求,加上它要求的工具执行”。模型在一次回复中可能请求多个工具。Loop 会按照执行模式建立屏障和有界并发池,但持久的 Tool Call 与 Tool Result 仍按模型顺序形成可重建历史。

工具执行结束后,下一次模型调用才会进入新 Step。这样 Step 的边界与模型请求次数一一对应。

Turn 怎样自然结束

一个 Step 结束后,如果没有待处理 Tool 工作,也没有 next-step 输入,Loop 到达 agent/turn-stopping 检查点。插件可以在这里安排继续,否则记录 turn/end 并把 Agent 状态切回 idle。

如果插件在 turn-stopping 阶段追加下一步工作,当前 Turn 可以继续,而不是必须先关闭再打开新 Turn。

取消不是简单抛异常

取消会触发当前 admission 或 Turn 的 AbortSignal。已经开始的异步工作需要观察 signal,并在停止后收敛;Loop 不能假装同进程函数已经终止。

取消发生后,Loop 停止启动新的 Tool Call,等待已启动调用处理完取消,并为尚未分发的模型 Tool Call 记录合成错误结果,避免历史中出现永远没有结果的调用。

用户取消、父 Agent 取消和插件 disposal 会产生不同结束原因。原因影响报告和 Session 记录,但资源释放都必须等待工作达到 quiescence。

AgentHandle 与所有权

程序化创建 Agent 时,ctx.agents.create() 返回 AgentHandle。持有者负责调用 handle 的 dispose;Provider 卸载又是独立的结构性释放边。

创建过程把 Session、scoped Context、Agent 注册和启动放在一段可回滚生命周期中。只有设置完成并成功发布后,其他消费者才能观察到活跃 Agent。

源码阅读顺序

  1. packages/core/agent/src/runtime-types.ts:先看 Agent 对外接口。
  2. packages/core/agent/src/inbox.ts:看两类队列和 splice。
  3. packages/core/agent-loop/README.zh.md:先看完整行为说明。
  4. packages/core/agent-loop/src/agent.ts:追踪 Turn/Step 主循环。
  5. packages/core/agent-loop/src/tool-calls.ts:单独研究工具调度。

本篇练习

  1. 用“查 package.json”的例子画出两个 Step。
  2. 解释 followup、steer、inject 的目标和唤醒差异。
  3. 判断“文件变更通知”更适合 followup 还是 inject,并说明原因。
  4. 阅读 packages/core/agent-loop/tests/loop.spec.ts,找一个多 Step 行为测试。

验收标准:能够从 Agent.followup() 开始,不看本文说出 Turn 开始、Step 执行、Tool Result 回流和 Turn 结束的顺序。