返回课程首页

6

为什么需要 Agent Loop

理解模型判断、工具执行与结果反馈形成的循环。

源码基线:Pi v0.82.0 · commit 518855d

上一章的 answerWithOneToolRound() 只能处理一轮工具调用:

模型 → 工具 → 模型

如果模型还要读取某个 PDF,再查询另一个目录,应用就得继续手写同一套判断。现在我们把它替换成 Pi 的 Agent

本章要建立一个核心认识:

Agent 不是一次模型调用,而是“模型判断—工具执行—结果反馈”的循环。

1. 先把上一章的 Tool 升级成 AgentTool

上一章已经定义了:

const listDownloadFilesTool = {
  name: "list_download_files",
  description:
    "列出当前用户下载目录中的文件。用户询问下载目录内容时使用。",
  parameters: ListDownloadFilesParameters,
};

pi-agentAgentTool 继承这些 Provider 可见字段,再增加本地执行函数:

import type {
  AgentTool,
} from "@earendil-works/pi-agent-core";

const listDownloadFilesAgentTool: AgentTool<
  typeof ListDownloadFilesParameters,
  { files: string[] }
> = {
  ...listDownloadFilesTool,
  label: "查询下载目录",

  async execute(_toolCallId, args) {
    try {
      const files = await listDownloadFiles(
        desktopPaths.downloads,
        args,
      );

      return {
        content: [
          {
            type: "text",
            text: JSON.stringify({ files }),
          },
        ],
        details: { files },
      };
    } catch (error) {
      desktopDiagnostics.capture(error);
      throw new Error(
        "无法查询下载目录:目录暂时不可访问。",
      );
    }
  },
};

execute() 返回 AgentToolResult,其中:

  • content 是模型可见的文字或图片;
  • details 是应用可保存和渲染的结构化数据;
  • 还可以带 usageaddedToolNamesterminate

工具执行失败时应 throw。Agent Loop 会把异常转换成错误 Tool Result,而不是让整个应用因未捕获异常退出。

2. 最小 Agent

把 Model、Tool 和 Stream Function 交给 Agent。下面是建立在前五章代码之上的累计示例,不是一个脱离前文的单文件:

import {
  Agent,
} from "@earendil-works/pi-agent-core";

const agent = new Agent({
  initialState: {
    systemPrompt: [
      "你是一个桌面助手。",
      "需要本机信息时使用工具,不要猜测。",
    ].join("\n"),
    model,
    tools: [listDownloadFilesAgentTool],
  },
  streamFn: models.streamSimple.bind(models),
});

agent.subscribe((event) => {
  if (
    event.type === "message_update" &&
    event.assistantMessageEvent.type === "text_delta"
  ) {
    appendAnswerText(event.assistantMessageEvent.delta);
  }
});

await agent.prompt("我的下载目录里有哪些 PDF?");

这里的 modelmodels 来自前四章,工具和 desktopPaths 来自上一章的桌面宿主。

与上一章相比,应用没有再手工:

  • 找 Tool Call;
  • 验证参数;
  • 调用执行函数;
  • 创建 Tool Result;
  • 判断是否再次请求模型。

这些工作进入 Agent Loop。

3. streamFn 是 Agent 与模型层的接缝

Agent 不直接依赖某个 Provider,也不自己创建 Models。构造时必须给它一个符合 StreamFn 的函数:

type StreamFn = (
  model: Model<Api>,
  context: Context,
  options?: SimpleStreamOptions,
) =>
  | AssistantMessageEventStream
  | Promise<AssistantMessageEventStream>;

models.streamSimple.bind(models) 正好符合这个形状。

flowchart LR
    A["Agent"] --> F["StreamFn"]
    F --> M["Models.streamSimple"]
    M --> P["Provider"]
    P --> API["目标模型 API"]

这种接缝带来两个结果:

  • Agent Loop 只关心“怎样获得一条统一 Assistant Message Stream”;
  • 模型目录、认证和 Provider 协议仍由 pi-ai 的 Models 处理。

StreamFn 的契约还要求请求、模型和运行时失败进入 Stream error,而不是用 rejected Promise 表达普通模型失败。Models.streamSimple() 通过 lazyStream() 满足这个边界。

stream-fn.ts 还提供 setDefaultStreamFn(),宿主可以注册一个运行时默认值。不过当前 AgentOptions 类型要求显式传入 streamFn;本书也始终显式传入,依赖关系更清楚。

源码位置:

  • packages/agent/src/types.ts 中的 StreamFn
  • packages/agent/src/stream-fn.ts
  • packages/ai/src/models.ts 中的 streamSimple

4. Agent State 保存什么

Agent 是有状态包装。它公开的 AgentState 包括:

interface AgentState {
  systemPrompt: string;
  model: Model<Api>;
  thinkingLevel: ThinkingLevel;
  tools: AgentTool[];
  messages: AgentMessage[];
  readonly isStreaming: boolean;
  readonly streamingMessage?: AgentMessage;
  readonly pendingToolCalls: ReadonlySet<string>;
  readonly errorMessage?: string;
}

这些字段分成三类:

类型 字段 含义
下次运行配置 systemPromptmodelthinkingLeveltools 下一次运行创建快照时使用什么
对话记录 messages 当前 Agent Transcript
运行时状态 isStreamingstreamingMessagependingToolCallserrorMessage 当前执行到了哪里

桌面界面不需要再自己猜“Agent 是否忙”。可以读取:

sendButton.disabled = agent.state.isStreaming;

类型注释把 streamingMessage 描述为当前流式 Assistant Message。当前 Agent.processEvents() 的实际 reducer 更宽:

  • 任意 message_start 都把它设为当前消息,包括 User 和 ToolResult;
  • message_update 再用最新 Assistant partial 更新;
  • message_end 清空,并把完整消息加入 state.messages

User 和 ToolResult 的 start/end 紧邻,但监听器会被等待,因此事件监听器中仍可能短暂观察到它们。桌面 UI 应先检查消息 role,不能无条件把 streamingMessage 当作 Assistant partial。

state.toolsstate.messages 赋新数组时,Agent 会复制顶层数组。但读取后直接修改返回数组,会修改当前状态。它不是深度不可变 Store。

Agent 开始一次运行时,createContextSnapshot()createLoopConfig() 会复制当前配置。运行中直接修改 agent.state.modeltoolssystemPrompt,不会自动改变当前运行的下一 Turn;若要在同一次 Loop 中更新 Context、Model 或 Thinking Level,应使用 prepareNextTurn 返回替换快照。

源码位置:

  • packages/agent/src/types.ts 中的 AgentState
  • packages/agent/src/agent.ts 中的 createMutableAgentState

5. 一次 Prompt 怎样进入 Loop

agent.prompt("...") 的调用链:

flowchart TD
    P["agent.prompt(text)"] --> N["normalizePromptInput()"]
    N --> RPM["runPromptMessages()"]
    RPM --> L["runWithLifecycle()"]
    L --> R["runAgentLoop()"]
    R --> E1["agent_start"]
    E1 --> E2["turn_start"]
    E2 --> U["user message_start / end"]
    U --> LOOP["runLoop()"]

normalizePromptInput() 把字符串变成 User Message,并可附加图片。

runPromptMessages() 建立当前 State 的 Context 快照和 Loop Config,再通过 runWithLifecycle() 开始本次运行。

runWithLifecycle()

  1. 拒绝同一个 Agent 同时开始第二个运行;
  2. 创建本次运行的 AbortController
  3. 设置 isStreaming = true
  4. 调用 Loop;
  5. 捕获 Loop 层意外抛出的失败并转换成错误 Assistant Message;
  6. 最后清理运行时状态。

低层 transformContextconvertToLlmshouldStopAfterTurn 等回调的源码契约要求调用者不要抛异常。若它们违反契约,低层 Loop 会直接 reject;使用 Agent 包装时,runWithLifecycle() 才会把这类意外失败转换成错误 Assistant Message,并补齐 message_start/endturn_endagent_end 生命周期事件。

runAgentLoop() 会复制已有 Context,加上新 Prompt,然后发出 Agent 和第一轮事件。

源码位置:packages/agent/src/agent.tspromptrunPromptMessagesrunWithLifecycle

6. Turn 到底是什么

Pi 的定义是:

一个 Turn = 一次 Assistant 响应 + 由该响应触发的全部 Tool Call 与 Tool Result。

User Message 在第一次 turn_start 后发出消息事件,但它不是 turn_end.messageturn_end.message 是这一轮的 Assistant Message。

有工具时,事件大致是:

sequenceDiagram
    participant UI
    participant Agent
    participant Model
    participant Tool

    Agent-->>UI: turn_start
    Agent->>Model: Context + Tools
    Model-->>Agent: AssistantMessage(ToolCall)
    Agent-->>UI: message_end
    Agent->>Tool: execute(validated args)
    Tool-->>Agent: AgentToolResult
    Agent-->>UI: ToolResult message
    Agent-->>UI: turn_end
    Agent-->>UI: turn_start
    Agent->>Model: Context + ToolResult
    Model-->>Agent: 最终 AssistantMessage
    Agent-->>UI: turn_end
    Agent-->>UI: agent_end

一次用户 Prompt 可以包含多个 Turn。不要把“Turn”理解成一整次用户任务。

7. runLoop() 为什么有两层循环

源码中的结构可以简化为:

while (true) {
  while (hasMoreToolCalls || pendingMessages.length > 0) {
    const message = await streamAssistantResponse(...);
    const toolResults = await executeToolCalls(...);
    // 追加结果,完成当前 Turn
    pendingMessages = await getSteeringMessages();
  }

  const followUps = await getFollowUpMessages();
  if (followUps.length > 0) {
    pendingMessages = followUps;
    continue;
  }

  break;
}

内层循环负责:

  • 模型生成;
  • 工具执行;
  • 工具结果反馈;
  • 运行途中注入的 Steering Message。

外层循环负责:

  • Agent 原本准备结束后,再处理 Follow-up Message。

Steering 和 Follow-up 会在第 14 章细讲。本章只需知道,双层循环不是为了“无限调用模型”,而是表达两种不同的继续来源。

8. 一轮模型调用发生了什么

streamAssistantResponse() 在每轮请求前:

  1. 可选执行 transformContext()
  2. convertToLlm() 把 AgentMessage 转成标准 Message;
  3. 组装 Context,带上 System Prompt 和 Tools;
  4. 可选解析本轮动态 API Key;
  5. 调用 streamFn
  6. 把 pi-ai 内容事件转换成 Agent message_update
  7. 把最终 Assistant Message 写进当前 Context。
flowchart LR
    AM["AgentMessage[]"] --> TC["transformContext"]
    TC --> CL["convertToLlm"]
    CL --> LM["Message[]"]
    LM --> CTX["Context + Tools"]
    CTX --> SF["streamFn"]
    SF --> EV["Agent message events"]

默认 convertToLlm() 只保留 User、Assistant 和 ToolResult。应用自定义消息需要提供自己的转换函数。

源码位置:

  • packages/agent/src/agent-loop.tsstreamAssistantResponse
  • packages/agent/src/agent.tsdefaultConvertToLlm

9. Tool Call 怎样进入执行阶段

模型响应结束后,Loop 从 message.content 找出全部 Tool Call。

如果消息以 "length" 结束,Loop 不执行任何调用,而是为每个调用生成错误 Tool Result。

否则进入:

flowchart TD
    C["ToolCall"] --> F{"按名称找到 AgentTool?"}
    F -->|否| NF["错误 Tool Result"]
    F -->|是| PA["prepareArguments 可选预处理"]
    PA --> V["validateToolArguments"]
    V --> B["beforeToolCall 可选拦截"]
    B --> E["tool.execute"]
    E --> A["afterToolCall 可选后处理"]
    A --> R["ToolResultMessage"]

本章只关注主路径。并行执行、取消和两个 Hook 会在第 8 章展开。

执行函数抛出的异常会被 executePreparedToolCall() 捕获,并转换成:

{
  content: [{ type: "text", text: message }],
  details: {},
}

同时对应 Tool Result 的 isErrortrue

这保证“单个工具失败”通常会成为模型可理解的结果。但工具作者仍应避免在异常消息中包含秘密,因为基础 Loop 会使用异常文本。coding-agent 或桌面宿主可以通过 Hook 做安全裁剪;第 8、9 章会处理。

10. 为什么 Tool Result 会触发下一轮

执行结束后,Loop 把每个 Tool Result 同时加入:

  • 当前 Context;
  • 本次运行的 newMessages

如果这批工具没有请求提前终止:

hasMoreToolCalls = true;

内层循环因此开始下一 Turn,模型看到刚才的 Tool Result,再决定:

  • 生成最终回答;
  • 继续调用另一个工具;
  • 返回错误或停止。

这就是上一章手工重复代码被自动化的地方。

11. Agent Loop 什么时候退出

退出条件不是只有“模型返回普通文字”。

11.1 模型错误或用户取消

Assistant Message 的 stopReason"error""aborted" 时:

  1. 发出 turn_end
  2. 发出 agent_end
  3. 立即退出,不执行其中 Tool Call。

11.2 当前没有继续工作的理由

当:

  • 没有更多 Tool Call;
  • 没有 Steering Message;
  • Agent 原本结束后也没有 Follow-up Message;

双层循环自然结束并发出 agent_end

11.3 shouldStopAfterTurn() 请求优雅停止

低层 Loop 调用者可以在完整 Turn 结束后返回 true

shouldStopAfterTurn: async ({ context }) => {
  return shouldPauseBeforeNextTurn(context.messages);
},

它不会中断当前模型或正在执行的工具,而是在 turn_end 之后、轮询 Steering/Follow-up 之前结束。

11.4 整批工具都要求 terminate

AgentToolResult.terminate = true 是跳过自动下一次模型调用的提示。只有这一批所有最终 Tool Result 都为 terminate: true,Loop 才把 hasMoreToolCalls 设为 false

它仍不阻止之后已经排队的 Steering 或 Follow-up。

11.5 输出达到长度限制

  • 没有 Tool Call:本轮结束,若无队列则 Agent 结束;
  • 有 Tool Call:不执行截断调用,生成错误 Tool Result,并继续一轮,让模型有机会重新请求。

12. agent_end 与真正空闲有一点距离

Agent.subscribe() 的监听器按注册顺序执行,而且异步监听器会被等待。

agent_end 表示:

Loop 不会再产生新事件。

但本次运行要等 agent_end 的所有监听器也完成后,prompt()waitForIdle() 才完成,随后 finishRun()isStreaming 设回 false

因此在一个耗时的 agent_end 监听器内部,读取 agent.state.isStreaming 仍可能得到 true。这符合源码生命周期,不是状态更新遗漏。

13. Agent 与低层 Loop API

Pi 同时暴露:

API 适合什么场景
Agent 需要持久状态、订阅、队列、取消和易用方法的应用
agentLoop() 应用自己管理 Context 和 EventStream
runAgentLoop() 应用已经有事件 Sink,希望直接等待消息结果
agentLoopContinue() / runAgentLoopContinue() 从现有 User/ToolResult 结尾继续,不添加新 Prompt

桌面 Agent 通常先使用 Agent。低层函数适合自定义 Runtime 或测试。

agentLoop() 返回 EventStream<AgentEvent, AgentMessage[]>runAgentLoop() 则直接把事件交给 emit 回调,最后返回本次新消息。

14. Continue 不等于“再问一遍”

低层 Continue 从已有 Context 开始,不添加新的 User Message。

它有两个前置条件:

  • Context 不能为空; -最后一条消息不能是 Assistant Message。

更准确地说,源码注释要求最后一条 AgentMessage 经 convertToLlm() 后成为 User 或 ToolResult。低层入口只能在转换前检查显式 Assistant role,无法验证自定义消息最终会被转换成什么。

Agent.continue() 也拒绝正在运行时调用。如果当前最后一条是 Assistant Message,它会先尝试消费已经排队的 Steering 或 Follow-up;两者都没有时就抛错。

因此 Continue 的典型用途是:

  • Context 已经加入一条 User Message,但尚未生成 Assistant;
  • Context 以 Tool Result 结束,需要让模型继续;
  • 上层恢复流程已经把 Context 调整到可继续状态。

它不是绕过消息顺序规则的重试按钮。

15. 从事件更新桌面状态

这一章只做最小映射:

agent.subscribe((event) => {
  switch (event.type) {
    case "agent_start":
      setRunStatus("running");
      break;

    case "turn_start":
      incrementTurnCount();
      break;

    case "message_update":
      if (
        event.assistantMessageEvent.type === "text_delta"
      ) {
        appendAnswerText(
          event.assistantMessageEvent.delta,
        );
      }
      break;

    case "agent_end":
      setRunStatus("settling");
      break;
  }
});

await agent.prompt(userText);
setRunStatus("idle");

这里用 "settling" 是为了体现:收到 agent_end 时监听器仍在运行;await agent.prompt() 返回后才真正完成本次运行。

工具执行的详细 UI 状态留到下一章。

16. 源码调用链复盘

flowchart TD
    P["Agent.prompt()"] --> RPM["runPromptMessages()"]
    RPM --> RS["runWithLifecycle()"]
    RS --> RAL["runAgentLoop()"]
    RAL --> RL["runLoop()"]
    RL --> SAR["streamAssistantResponse()"]
    SAR --> SF["streamFn / Models.streamSimple"]
    SF --> AM["AssistantMessage"]
    AM --> TC{"有 ToolCall?"}
    TC -->|否| Q{"有 Steering / Follow-up?"}
    TC -->|是| ETC["executeToolCalls()"]
    ETC --> TR["ToolResultMessage[]"]
    TR --> RL
    Q -->|是| RL
    Q -->|否| AE["agent_end"]

对应源码:

  1. packages/agent/src/agent.ts:有状态 Agent 包装;
  2. packages/agent/src/types.ts:State、Tool、Context、Config 和 Event;
  3. packages/agent/src/agent-loop.ts:模型与工具循环;
  4. packages/agent/src/stream-fn.ts:默认 Stream Function 接缝;
  5. packages/ai/src/models.ts:本课程实际传入的模型 Stream。

17. 本章小结

  • AgentTool 在 Tool 定义上增加本地执行契约。
  • Agent 通过 streamFnpi-ai Models 连接,不绑定具体 Provider。
  • AgentState 同时保存后续配置、对话和运行时状态。
  • 一个 Turn 是一次 Assistant 响应及其全部 Tool Call/Result。
  • 一次 Prompt 可以包含多个 Turn。
  • 内层循环处理模型、工具和 Steering,外层循环处理 Agent 原本结束后的 Follow-up。
  • 每次模型调用前都可以变换 Agent Context,再转换成标准 LLM Message。
  • Tool Call 会经历查找、参数准备、验证、拦截、执行和结果封装。
  • Tool Result 进入 Context 后会自动触发下一 Turn。
  • 错误、取消、无后续工作、优雅停止和整批 terminate 都可能结束 Loop。
  • 截断消息中的 Tool Call 不会被执行。
  • agent_end 后仍要等待异步监听器完成,运行才真正空闲。
  • Continue 从已有 User/ToolResult 结尾继续,不添加新 Prompt。

下一章会使用完整 Agent Event 联合类型,把模型流、工具执行和 Turn 状态映射成桌面界面。

18. 自测

  1. AgentToolpi-ai Tool 多了哪些运行时能力?
  2. 为什么 Agent 需要注入 streamFn
  3. 一个 Turn 是否等于一次完整用户任务?
  4. Tool Result 为什么会自动触发下一次模型调用?
  5. runLoop() 为什么有内外两层循环?
  6. 模型响应以 length 结束且含 Tool Call 时,Loop 怎么处理?
  7. terminate: true 在什么条件下才会停止自动后续调用?
  8. agent_end 到达后,为什么 isStreaming 仍可能是 true
  9. agentLoop()runAgentLoop() 的主要差异是什么?
  10. Continue 对 Context 最后一条消息有什么要求?

本章源码依据

  • packages/agent/src/agent.ts
  • packages/agent/src/agent-loop.ts
  • packages/agent/src/types.ts
  • packages/agent/src/stream-fn.ts
  • packages/agent/src/index.ts
  • packages/agent/README.md
  • packages/ai/src/models.ts
  • packages/ai/src/types.ts