11 min read教程

PiAgent 03|Agent Loop 如何驱动多步行动

从 prompt()、turn、tool call 到 stopReason,理解 Agent 为什么会继续工作以及何时真正结束

PiAgent 03|Agent Loop 如何驱动多步行动#

prompt()、turn、tool call 到 stopReason,理解 Agent 为什么会继续工作以及何时真正结束

普通的模型调用是一问一答:提交上下文,等待一条 assistant message。Agent 的新增部分不是“模型拥有了意志”,而是宿主程序把模型输出接进一个可重复的控制流:模型提出动作,工具执行动作,结果回到上下文,循环再请求模型,直到满足停止条件。

1. 三种容易混淆的调用方式#

直接生成

程序固定下一步做什么,模型只生成当前步骤的文字。流程可预测,但每增加一种分支都要改宿主代码。

Workflow#

程序规定多个步骤,模型在每一步中做判断。它适合审批、抽取、校验这类边界清晰的流程,代价是灵活性被流程图限制。

Agent Loop#

程序提供工具、上下文和停止规则,把下一步行动交给模型选择。它更灵活,但也意味着工具契约、错误、取消、预算和上下文都必须设计好。

Pi 的 Agent Loop 解决的正是第三种问题:让“模型响应”和“工具执行”形成可观察、可中止、可重试的状态机。

2. prompt() 不是一次 HTTP 请求#

ts
await session.prompt("找出当前项目里最大的三个文件,并说明原因");

调用方看到的是一个 Promise,但内部可能经历:

text
prompt()
  → 组装 system prompt、历史和工具定义
  → 请求模型
  → assistant 返回 tool call
  → 校验并执行工具
  → 追加 tool result
  → 再次请求模型
  → assistant 返回最终文本
  → prompt() resolve

因此 prompt() resolve 的含义是本次 Agent 运行已经收束,不是模型只被请求了一次。工具调用、自动重试或上下文压缩都可能让一次 prompt 变成多次底层模型交互。

3. trace、turn、message 的三个尺度#

为了读事件和日志,必须区分三个尺度:

text
一次用户任务 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 的核心状态#

可以把一轮循环抽象成下面的状态转移:

text
Idle
  → Preparing
  → StreamingAssistant
      ├─ text only → Settled
      ├─ tool call → ValidatingTool
      │                 ├─ rejected → AppendErrorResult → StreamingAssistant
      │                 └─ accepted → ExecutingTool → AppendToolResult
      │                                           → StreamingAssistant
      └─ abort/error → Aborted/Failed

循环真正依赖的不是“模型是否聪明”,而是这些状态之间的协议:

  1. 模型能否用结构化格式表达 tool call。
  2. 工具结果能否通过 call ID 与调用配对。
  3. 错误能否作为模型可理解的结果回写,而不是直接让进程崩溃。
  4. 循环能否区分继续、成功结束、失败、取消和等待重试。

5. stop reason 决定下一步#

模型响应通常包含一个停止原因。不同 Provider 的字段名称可能不同,但适配层会把它归一化成 Loop 可以判断的语义:

  • toolUse 或等价语义:assistant 请求工具,继续执行工具管道。
  • stop:模型认为当前回复已经完成,可以结束本次运行。
  • length:输出触及上限,可能需要继续、截断或报告不完整。
  • error:请求失败,进入错误处理或重试策略。
  • aborted:宿主或用户取消,不应被当成模型成功。

伪代码可以写成:

ts
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. 工具调用为什么必须经过宿主程序#

模型可以生成:

json
{
  "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#

ts
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();

典型事件顺序会接近:

text
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.tspackages/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

相关文章