第三章:Agent 循环 -- 双层循环与工具执行
对应源文件:
packages/agent/src/agent-loop.ts
核心循环结构
agent 循环有两层嵌套:
外层循环(follow-up loop)
│ 当 agent 本来要停下时,检查有没有后续消息
│ 有 → 把消息加入上下文,继续
│ 没有 → 退出
│
└── 内层循环(tool-call loop)
│ stream AI 响应 → 检查是否有工具调用
│ 有 → 执行工具 → 检查 steering 消息 → 继续
│ 没有 → 退出内层循环
│
└── 每一轮:
1. 注入 pending 消息(steering)
2. 调用 LLM 获取 assistant 响应
3. 如果有 tool calls → 执行
4. 检查 steering 消息队列
5. 回到 1 或退出
用伪代码表达
function runLoop():
pending = getSteeringMessages() // 开始前检查一次
while true: // 外层循环
while hasMoreToolCalls or pending.length > 0: // 内层循环
// 注入 steering 消息
if pending:
加入上下文
pending = []
// 调用 LLM
message = streamAssistantResponse()
if message 出错或被取消:
return // 直接结束
// 处理工具调用
if message 包含 toolCalls:
执行工具
hasMoreToolCalls = !全部工具都说了 terminate
// 检查新的 steering 消息
pending = getSteeringMessages()
// 内层循环结束了,检查 follow-up
followUp = getFollowUpMessages()
if followUp:
pending = followUp
continue // 继续外层循环
else:
break // 真正结束
Steering 和 Follow-Up 消息
这两种消息机制是 pi 交互设计的关键。
Steering(转向消息)
在 AI 正在工作时,用户输入的新指令
场景:AI 正在帮你写代码(内层循环还在转),你突然打了一句"等等,用 TypeScript 而不是 JavaScript"。
这条消息进入 steering 队列。每次内层循环完成一轮工具执行后,会检查队列。如果有消息,就注入到上下文中,AI 下一轮就能看到你的新指令。
时间线:
用户: "帮我写一个排序函数"
AI: [思考...] → toolCall: write("sort.js", ...)
AI: 执行 write 工具 ← 同时用户打了 "用 TypeScript"
AI: 检查 steering 队列 → 发现新消息!
AI: 把 "用 TypeScript" 加入上下文
AI: [思考...] "好的,我用 TypeScript 重写"
AI: toolCall: write("sort.ts", ...)
Follow-Up(后续消息)
在 AI 完全停下来后,等待处理的新请求
场景:AI 完成了当前任务(内层循环结束了),但用户在 AI 工作期间又输入了一个新问题。
这条消息进入 follow-up 队列。外层循环检查到有后续消息,把它加入上下文,重新启动内层循环。
时间线:
用户: "帮我写排序函数"
AI: [写完了] stopReason: "stop"
AI: 内层循环结束
AI: 检查 follow-up 队列 → 发现 "再写一个搜索函数"
AI: 把新消息加入上下文
AI: 重新进入内层循环
AI: [开始写搜索函数...]
区别总结
| 特性 | Steering | Follow-Up |
|---|---|---|
| 什么时候注入 | 每次工具执行后 | 内层循环结束后 |
| 类比 | "中途插嘴" | "接着问" |
| 典型场景 | 修正 AI 正在做的事 | 在 AI 空闲时追加任务 |
消息转换流程
每次调用 LLM 前,消息列表会经过两步转换:
AgentMessage[]
│
▼ transformContext()(可选)
AgentMessage[] ← 仍然是 AgentMessage,但可能被裁剪了
│
▼ convertToLlm()(必须)
Message[] ← LLM 能理解的标准消息
│
▼ 构建 Context
Context { systemPrompt, messages, tools }
│
▼ 调用 streamSimple()
AssistantMessageEventStream
transformContext 的典型用途:
- 上下文窗口管理:当消息太多时,删除最早的消息
- 注入外部上下文:从数据库拉取相关信息注入对话
- 上下文压缩:把旧消息压缩成摘要(compaction)
工具执行流程
当 AI 返回包含 tool calls 的消息时,每个工具调用经过以下生命周期:
1. 查找工具
在 tools 列表中查找 toolCall.name 对应的 AgentTool
找不到 → 返回错误 ToolResult
2. 参数预处理(prepareArguments)
如果工具定义了 prepareArguments,先修正参数
比如把 file_path 映射为 path
3. 参数验证(validateToolArguments)
用 TypeBox schema 验证参数格式
验证失败 → 返回错误 ToolResult
4. 执行前钩子(beforeToolCall)
调用 config.beforeToolCall()
返回 { block: true } → 阻止执行,返回错误 ToolResult
5. 执行工具(tool.execute())
实际运行工具逻辑
抛异常 → 捕获,返回错误 ToolResult
正常返回 → AgentToolResult
6. 执行后钩子(afterToolCall)
调用 config.afterToolCall()
可以修改 result 的 content、details、isError、terminate
7. 生成 ToolResultMessage
包装为标准的 ToolResultMessage 加入上下文
并行执行 vs 串行执行
当 AI 一次返回多个工具调用时:
串行模式 (sequential):
toolCall_1: prepare → execute → finalize → emit
toolCall_2: prepare → execute → finalize → emit
toolCall_3: prepare → execute → finalize → emit
并行模式 (parallel),默认行为:
toolCall_1: prepare ──┐
toolCall_2: prepare ──┼── 所有 prepare 串行完成
toolCall_3: prepare ──┘
│
▼
toolCall_1: execute ──┐
toolCall_2: execute ──┼── 允许的工具并发执行
toolCall_3: execute ──┘
│
▼
所有 execute 完成后,按原始顺序 emit ToolResultMessage
注意:prepare 阶段始终是串行的(因为 beforeToolCall 可能需要用户确认)。只有 execute 阶段才并发。
如果某个工具自身声明了 executionMode: "sequential",整个批次会退化为串行模式。
早停机制(terminate)
工具可以在返回结果时设置 terminate: true:
execute: async () => ({
content: [{ type: "text", text: "已退出" }],
details: {},
terminate: true, // 建议停止循环
})
只有当同一批次的所有工具都设了 terminate: true 时,循环才会跳过下一次 LLM 调用直接结束。这确保了单个工具不会意外中止整个循环。
streamAssistantResponse -- LLM 调用桥梁
这个函数是 agent 循环与 ai 包的连接点:
1. 如果配置了 transformContext → 先转换 AgentMessage[]
2. 调用 convertToLlm → 得到 Message[]
3. 构建 LLM Context(systemPrompt + messages + tools)
4. 如果配置了 getApiKey → 动态获取 API key
5. 调用 streamFn(默认是 streamSimple)
6. 消费事件流:
- start → 把 partial AssistantMessage 加入上下文
- text_delta/thinking_delta/toolcall_delta → 更新 partial,emit message_update
- done/error → 用最终的 AssistantMessage 替换 partial
关键细节:partial message 直接写入 context.messages。这意味着在流式传输过程中,如果你去读 context.messages,最后一条就是当前正在生成的不完整消息。流结束时,它被替换为最终的完整消息。
小结
runLoop()
┌──────────────────────────────────────────────────┐
│ 外层循环 │
│ ┌──────────────────────────────────────────────┐ │
│ │ 内层循环 │ │
│ │ 1. 注入 steering 消息 │ │
│ │ 2. transformContext → convertToLlm → LLM │ │
│ │ 3. 收到 AssistantMessage │ │
│ │ 4. 如果有 toolCalls: │ │
│ │ prepare → validate → before → execute │ │
│ │ → after → emit ToolResult │ │
│ │ 5. 检查 steering 队列 │ │
│ └──────────────────────────────────────────────┘ │
│ 内层循环结束 → 检查 follow-up 队列 │
│ 有 → 继续外层循环 │
│ 没有 → 结束 │
└──────────────────────────────────────────────────┘
下一章我们看 Agent 类如何在这个底层循环上封装出好用的高层 API。