PiAgent 03|Agent Loop 如何驱动多步行动#
从
prompt()、turn、tool call 到stopReason,理解 Agent 为什么会继续工作以及何时真正结束
普通的模型调用是一问一答:提交上下文,等待一条 assistant message。Agent 的新增部分不是“模型拥有了意志”,而是宿主程序把模型输出接进一个可重复的控制流:模型提出动作,工具执行动作,结果回到上下文,循环再请求模型,直到满足停止条件。
1. 三种容易混淆的调用方式#
直接生成
程序固定下一步做什么,模型只生成当前步骤的文字。流程可预测,但每增加一种分支都要改宿主代码。
Workflow#
程序规定多个步骤,模型在每一步中做判断。它适合审批、抽取、校验这类边界清晰的流程,代价是灵活性被流程图限制。
Agent Loop#
程序提供工具、上下文和停止规则,把下一步行动交给模型选择。它更灵活,但也意味着工具契约、错误、取消、预算和上下文都必须设计好。
Pi 的 Agent Loop 解决的正是第三种问题:让“模型响应”和“工具执行”形成可观察、可中止、可重试的状态机。
2. prompt() 不是一次 HTTP 请求#
await session.prompt("找出当前项目里最大的三个文件,并说明原因");调用方看到的是一个 Promise,但内部可能经历:
prompt()
→ 组装 system prompt、历史和工具定义
→ 请求模型
→ assistant 返回 tool call
→ 校验并执行工具
→ 追加 tool result
→ 再次请求模型
→ assistant 返回最终文本
→ prompt() resolve因此 prompt() resolve 的含义是本次 Agent 运行已经收束,不是模型只被请求了一次。工具调用、自动重试或上下文压缩都可能让一次 prompt 变成多次底层模型交互。
3. trace、turn、message 的三个尺度#
为了读事件和日志,必须区分三个尺度:
一次用户任务 trace
├─ turn 1:user → assistant(tool call)
│ └→ tool result
├─ turn 2:tool result → assistant(tool call)
│ └→ tool result
└─ turn 3:tool result → assistant(final)- Trace:从一个用户目标开始到任务终局的整体过程。
- Turn:一次模型响应及其紧接着的工具执行结果。
- Message:上下文中的一条 user、assistant 或 tool result 消息。
turn_end 不能简单等同于任务结束,因为工具结果可能让 Loop 继续下一轮;agent_end 也可能在重试时出现多次。对外部服务来说,真正的终局应该由 prompt() 的 Promise、Session 层的 settled 事件或明确的状态机共同决定。
4. Loop 的核心状态#
可以把一轮循环抽象成下面的状态转移:
Idle
→ Preparing
→ StreamingAssistant
├─ text only → Settled
├─ tool call → ValidatingTool
│ ├─ rejected → AppendErrorResult → StreamingAssistant
│ └─ accepted → ExecutingTool → AppendToolResult
│ → StreamingAssistant
└─ abort/error → Aborted/Failed循环真正依赖的不是“模型是否聪明”,而是这些状态之间的协议:
- 模型能否用结构化格式表达 tool call。
- 工具结果能否通过 call ID 与调用配对。
- 错误能否作为模型可理解的结果回写,而不是直接让进程崩溃。
- 循环能否区分继续、成功结束、失败、取消和等待重试。
5. stop reason 决定下一步#
模型响应通常包含一个停止原因。不同 Provider 的字段名称可能不同,但适配层会把它归一化成 Loop 可以判断的语义:
toolUse或等价语义:assistant 请求工具,继续执行工具管道。stop:模型认为当前回复已经完成,可以结束本次运行。length:输出触及上限,可能需要继续、截断或报告不完整。error:请求失败,进入错误处理或重试策略。aborted:宿主或用户取消,不应被当成模型成功。
伪代码可以写成:
while (!aborted) {
const response = await streamModel(context);
appendAssistantMessage(response.message);
if (response.stopReason === "toolUse") {
const results = await executeToolCalls(response.toolCalls);
appendToolResults(results);
continue;
}
if (response.stopReason === "stop") break;
if (response.stopReason === "error") await retryOrFail(response);
}真实实现还要处理并行工具、流式增量、队列、压缩和事件投影,但这段伪代码说明了核心:工具结果是下一次模型调用的输入,不是 Loop 外面的附加日志。
6. 工具调用为什么必须经过宿主程序#
模型可以生成:
{
"name": "query_data",
"arguments": { "column": "地区", "operator": "=", "value": "华东" }
}这只是建议执行的结构化输出。宿主还必须检查:
name是否在当前 Session 的工具集合中。- 参数是否符合 schema,是否存在额外字段或超长值。
- 当前用户、会话和运行是否有权限执行。
- 工具是否应该串行执行,是否允许与其他工具并行。
- 执行超时、取消和异常如何变成
ToolResultMessage。
这也是为什么把一个任意函数直接塞进 prompt 不是 Agent 工具系统。完整工具协议既是能力描述,也是副作用边界。
7. Steering 与 Follow-up 在 Loop 中的差别#
运行中的用户输入有两种不同语义:
steer():纠正当前路线,例如“先不要修改文件,先解释测试失败原因”。它应在合适的 turn 边界进入当前工作流。followUp():排在当前任务之后,例如“完成后再生成一份摘要”。它不抢占当前路线,而是在当前运行自然收束后继续。
如果把两种输入混在一个队列里,模型会分不清哪个约束必须立即生效,哪个任务可以延后。队列的存在不是 UI 细节,而是 Loop 的控制语义。
8. 取消、错误与重试#
一个可用的 Loop 必须区分三类非成功结果:
用户取消
调用 session.abort() 后,后台模型流、工具执行和等待中的任务应尽快收到 abort signal。取消不是工具错误,不应该继续自动重试。
工具失败
工具失败通常要把 isError 与可读错误消息回写给模型,让模型决定修正参数、换工具或向用户说明。不要把异常堆栈原样暴露给模型或浏览器。
Provider 暂时失败#
网络超时、限流或服务暂时不可用可以进入重试,但需要预算和次数上限。重试期间要发出 auto_retry_start/end 等事件,外部 UI 才能解释为什么 agent_end 可能出现不止一次。
9. 观察一次真实的 Loop#
const off = session.subscribe((event) => {
switch (event.type) {
case "agent_start":
console.log("agent started");
break;
case "tool_execution_start":
console.log("tool:", event.toolName, event.args);
break;
case "tool_execution_end":
console.log("tool finished:", event.toolName, event.isError);
break;
case "turn_end":
console.log("turn finished");
break;
case "agent_end":
console.log("agent turn ended", event.willRetry);
break;
}
});
await session.prompt("当前目录有哪些文件?");
off();典型事件顺序会接近:
agent_start
turn_start
message_start / message_update
tool_execution_start
tool_execution_end
turn_end
turn_start
message_start / message_update
message_end
turn_end
agent_end第二个 turn 不是重复调用,而是模型看到了工具结果后继续完成任务。事件系统让这个事实可见,也为日志、指标和前端状态提供统一时钟。
常见误区
- 把一次
prompt()当作一次模型请求,导致超时和成本统计偏小。 - 看到
agent_end就释放资源,忽略自动重试或 Session 层的最终收束。 - 工具异常直接
throw出 Loop,导致模型没有机会修正。 - 把
steer当作普通的下一条用户消息,破坏运行中的控制语义。 - 只记录最终答案,不记录工具调用和 stop reason,出了错无法定位。
小结
Pi 的 Agent Loop 是一个由消息、工具结果和停止语义驱动的状态机。模型决定“下一步想做什么”,工具管道决定“这一步能不能做”,Loop 决定“结果是否需要回到模型”,Session 决定“过程如何被外部观察和中止”。理解这条链路后,后面讨论流式、事件和 Web 接入就不再是 API 拼贴。
源码定位
- Loop 主体:
packages/agent/src/agent-loop.ts - Agent 状态与事件:
packages/agent/src/agent.ts、packages/agent/src/types.ts - Session 对 Loop 的封装:
packages/coding-agent/src/agent-session.ts - 工具执行阶段:
packages/agent/src/agent-loop.ts中的 tool call 处理路径 - 重试与压缩事件:
packages/coding-agent/src/agent-session.ts