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 的顺序可以压缩为:
- 记录
step/start。 - 把已接纳输入记录为
user/message。 - 组装 System Prompt、动态上下文和 Tool Schema。
- 从 Session 日志推导模型消息历史。
- 通过
agent/request和llm/stream发出请求。 - 把流式 chunk 记录为
assistant/chunk。 - 把完整回复记录为
assistant/message。 - 调度模型请求的 Tool Call,记录
tool/call与tool/result。 - 记录
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。
源码阅读顺序
packages/core/agent/src/runtime-types.ts:先看 Agent 对外接口。packages/core/agent/src/inbox.ts:看两类队列和 splice。packages/core/agent-loop/README.zh.md:先看完整行为说明。packages/core/agent-loop/src/agent.ts:追踪 Turn/Step 主循环。packages/core/agent-loop/src/tool-calls.ts:单独研究工具调度。
本篇练习
- 用“查 package.json”的例子画出两个 Step。
- 解释 followup、steer、inject 的目标和唤醒差异。
- 判断“文件变更通知”更适合 followup 还是 inject,并说明原因。
- 阅读
packages/core/agent-loop/tests/loop.spec.ts,找一个多 Step 行为测试。
验收标准:能够从 Agent.followup() 开始,不看本文说出 Turn 开始、Step 执行、Tool Result 回流和 Turn 结束的顺序。