从零开始拆解Pi系列——(3)agent loop

一、引言:agent loop 的作用和地位

什么是 agent

chatbot、workflow、agent 是三种不同的自动化模式,本质区别在于谁主导控制流

chatbot 是"一问一答":用户输入 → LLM 回复 → 结束。每轮交互独立,LLM 没有跨轮目标,也不会主动采取行动。你问它"帮我整理这个目录下的文件",它只能告诉你"你可以用 mv 命令...",然后等你手动执行。

workflow 是"预先编排":开发者提前定义好步骤和分支------第一步读目录、第二步过滤、第三步移动。每一步做什么、什么时候做,都是代码写死的。LLM 只在某个节点里被调用,负责"理解"或"生成",但不决定流程走向。

agent 把控制流交给模型:同样是"整理文件",agent 自己决定先调工具读目录、再调工具移动文件、检查结果、继续整理------直到它认为任务完成。干活的过程由模型主导,而不是由 workflow 主导。这就是 agent loop 和 chatbot、workflow 最大的区别:不仅可以对话,还可以真正干活,并且干活的过程是由模型主导,而不是由 workflow 主导。

驱动这一切的就是 agent loop------它决定 agent 什么时候调 LLM、什么时候执行工具、什么时候停下来。

二、pi 的实现方式

初始上下文

agent loop 启动前,context 已经持有三样东西:

yaml 复制代码
context = {
  systemPrompt: <见下方>,
  messages: [
    { role: "user", content: "请用 echo 工具回显 hello", timestamp: ... }
  ],
  tools: [echoTool, bashTool, readTool, ...]
}
  • systemPrompt 是 system 消息,每轮调用 LLM 时都会带上。它定义 agent 的人格、能力边界、可用工具的使用约定。
  • messages 是对话历史。初始只有用户的一条 prompt,之后每轮都会往里追加------assistant 回复、toolCall、toolResult 都累积在这里。
  • tools 是工具定义列表。LLM 调用工具时看到的就是这个列表(被转成 OpenAI 的 function schema 格式)。

pi 的默认 system prompt(coding-agent/src/core/system-prompt.ts:130-147):

sql 复制代码
You are an expert coding assistant operating inside pi, a coding agent harness.
You help users by reading files, executing commands, editing code, and writing new files.

Available tools:
- read: <一行描述>
- bash: <一行描述>
- edit: <一行描述>
- write: <一行描述>

In addition to the tools above, you may have access to other custom tools
depending on the project.

Guidelines:
- <动态生成的行为约束,如 "Be concise in your responses">
- <如 "Use bash for file operations like ls, rg, find">

Pi documentation (read only when the user asks about pi itself...):
- Main documentation: <readmePath>
- Additional docs: <docsPath>
- Examples: <examplesPath>
- When reading pi docs or examples, resolve docs/... under Additional docs
  and examples/... under Examples, not the current working directory
- ...

Current date: 2026-08-25
Current working directory: /Users/ivan.bao/lab/...

它的结构分五块:

  • 角色定义:告诉模型"你是 pi 里的 coding assistant"
  • 可用工具:列出工具名 + 一行描述
  • 行为约束:动态生成的 guidelines(根据启用的工具组合不同)
  • 文档索引:pi 自己的文档路径(模型被问到 pi 本身时才读)
  • 环境信息:当前日期 + 工作目录(放最后)

两层循环

pi 的 agent loop 用双层 while 循环驱动 agent 运转:

  • 外层循环处理"后续消息"------当 agent 本来要停下来时,检查是否有用户排队的后续消息(比如用户在 agent 执行过程中又发了一条"顺便也改一下配置文件"),有就继续,没有就退出。
  • 内层循环处理"工具调用 + 插话"------只要 LLM 还在请求工具调用,或者有用户中途插话的消息没处理完,就继续转。每一圈调一次 LLM、检查有没有工具调用、有就执行并回填结果,然后判断下一圈怎么转。

两个循环嵌套在一起:外层决定"agent 整体什么时候停",内层决定"每一轮什么时候转到下一轮"。退出条件、转向控制由四个可选钩子配合完成------它们在第四章展开。

概览图

flowchart TD A([用户输入]) --> B[agent_start] B --> C[emit prompt 消息] C --> D[turn_start] D --> E[streamAssistantResponse<br/>调用 LLM 流式响应] E --> F{stopReason?} F -->|error / aborted| G[turn_end] G --> H([agent_end]) F -->|stop / toolUse| I{content 中有 toolCall?} I -->|有| J[executeToolCalls] J --> K[回填 toolResults 到 context] K --> L[turn_end] L --> M{钩子检查<br/>prepareNextTurn<br/>shouldStopAfterTurn<br/>getSteeringMessages} M -->|继续| D M -->|停止| H I -->|无| L

单轮细节图

flowchart TD A([turn_start]) --> B[streamAssistantResponse<br/>消费 AssistantMessageEventStream] B --> C[拿到 AssistantMessage<br/>写入 context.messages] C --> D{stopReason?} D -->|error / aborted| E([turn_end + agent_end]) D -->|stop / toolUse| F{content 中有 toolCall?} F -->|否| G([turn_end]) F -->|是| H[emit tool_execution_start] H --> I[查工具 + 校验参数] I --> J[tool_execution] J --> K[emit tool_execution_end] K --> L[构造 ToolResultMessage] L --> M[emit message_start / message_end] M --> N[push 到 context.messages] N --> O{还有未执行的 toolCall?} O -->|是| H O -->|否| P([turn_end])

循环各阶段的 context 变化

以"请用 echo 工具回显 hello"为例,展示 context.messages 在每个阶段怎么累积:

arduino 复制代码
初始状态
┌─────────────────────────────────────┐
│ messages:                          │
│   [0] user: "请用 echo 工具回显..."  │
└─────────────────────────────────────┘

stream 后(turn 1 第一步)
┌─────────────────────────────────────┐
│ messages:                          │
│   [0] user: "请用 echo 工具回显..."  │
│   [1] assistant:                   │
│     content: [                     │
│       { type:"text", text:"我来..." },│
│       { type:"toolCall",           │
│         name:"echo",               │
│         arguments:{text:"hello"} }  │
│     ]                              │
└─────────────────────────────────────┘

execute 后(turn 1 第二步)
┌─────────────────────────────────────┐
│ messages:                          │
│   [0] user: "请用 echo 工具回显..."  │
│   [1] assistant: [text + toolCall] │
│   [2] toolResult:                   │
│       toolCallId: "call_xxx",       │
│       toolName: "echo",             │
│       content: [                    │
│         { type:"text", text:"Echo: hello" }
│       ],                           │
│       isError: false               │
└─────────────────────────────────────┘

stream 后(turn 2)
┌─────────────────────────────────────┐
│ messages:                          │
│   [0] user: "请用 echo 工具回显..."  │
│   [1] assistant: [text + toolCall] │
│   [2] toolResult: "Echo: hello"    │
│   [3] assistant:                   │
│     content: [                     │
│       { type:"text", text:"已成功回显 hello" }
│     ]                              │
│     stopReason: "stop"            │
└─────────────────────────────────────┘

无工具调用 → 循环停止 → agent_end

context.messages 是 agent 的记忆------每一轮往里追加,LLM 下一轮调用时看到的就是完整的累积历史。这就是为什么 LLM 能在 turn 2 知道"已成功回显"------因为 turn 1 的 toolResult 已经在 context 里了。

三、最小循环骨架

第 2 章的流程图展示了完整的 agent loop,但 pi 的真实 runLoop 代码里还埋着四个钩子(第 4 章展开)。先把钩子全部摘掉,看最小循环长什么样。

以下是 pi runLooppackages/agent/src/agent-loop.ts:155-274)的最小骨架,用伪代码省略非核心逻辑:

typescript 复制代码
async function runLoop(context, newMessages, config, signal, emit):
    hasMoreToolCalls = true

    while hasMoreToolCalls:                              # 内层循环
        if not firstTurn:
            emit(turn_start)

        # 1. 调 LLM,拿到 assistant 消息
        message = streamAssistantResponse(context, config, signal, emit)
        #   ↑ 文章 2 已讲:消费 AssistantMessageEventStream,
        #     翻译成 AgentEvent,把最终 AssistantMessage 写回 context.messages
        newMessages.push(message)

        # 2. 异常退出
        if message.stopReason in (error, aborted):
            emit(turn_end, message, toolResults=[])
            emit(agent_end, newMessages)
            return

        # 3. 检查有没有工具调用
        toolCalls = message.content.filter(type == "toolCall")
        toolResults = []
        hasMoreToolCalls = false

        # 4. 有工具调用 → 执行 → 回填结果
        if toolCalls.length > 0:
            batch = executeToolCalls(context, message, toolCalls, signal, emit)
            #   ↑ 文章 4 展开:查工具 → 校验参数 → execute → 构造 ToolResultMessage
            toolResults = batch.messages
            hasMoreToolCalls = not batch.terminate

            for result in toolResults:
                context.messages.push(result)             # 回填到对话历史
                newMessages.push(result)

        emit(turn_end, message, toolResults)

    emit(agent_end, newMessages)

循环的核心只有四步:

  1. 调 LLM ------streamAssistantResponse 拿到 assistant 消息(文章 2 已讲,这里当黑盒,签名是 (context, config, signal, emit) → AssistantMessage
  2. 异常检查 ------如果 stopReasonerroraborted,直接退出
  3. 检查工具调用 ------从 message.content 里筛出 type: "toolCall" 的块
  4. 执行工具 + 回填 ------executeToolCalls 执行工具拿到 ToolResultMessage[],push 回 context.messages

hasMoreToolCalls 是循环的驱动条件:LLM 请求了工具调用就继续转,没有就停。注意第 4 步回填后,下一轮 streamAssistantResponse 调用时,LLM 能在 context.messages 里看到完整的 toolResult------这就是 agent 能"基于工具结果继续推理"的机制。

入口封装在上面一层,pi 提供了 agentLooprunAgentLoop 两种调用形态:

php 复制代码
function agentLoop(prompts, context, config, signal):     # 返回 EventStream
    stream = createAgentStream()
    runAgentLoop(prompts, context, config,
        event => stream.push(event),                      # emit 回调转发到 stream
        signal
    ).then(messages => stream.end(messages))
    return stream

async function runAgentLoop(prompts, context, config, emit, signal):
    newMessages = [...prompts]
    context.messages.push(...prompts)

    emit(agent_start)
    emit(turn_start)
    for prompt in prompts:
        emit(message_start, prompt)
        emit(message_end, prompt)

    await runLoop(context, newMessages, config, signal, emit)
    return newMessages

agentLoop 返回 EventStream<AgentEvent, AgentMessage[]>------调用方可以 for await 消费事件,也可以 await .result() 拿最终消息列表。两种调用形态对应两种使用场景:交互式 UI 用 EventStream 实时渲染,脚本批处理用 async 直接 await。

四、钩子

第 3 章的最小循环骨架能跑通"调 LLM → 执行工具 → 回填 → 继续",但缺少真实场景需要的能力:用户中途插话、每轮后判断是否该停、动态切换模型。pi 用四个可选钩子解决这些需求------它们都在 AgentLoopConfig 上,不传就不生效,传了就在循环的特定位置被调用。

1. prepareNextTurn------每轮后调整下一轮的状态

调用位置:turn_end 之后、下一轮 turn_start 之前。

ini 复制代码
# 在 runLoop 的内层循环末尾(伪代码接第 3 章)
emit(turn_end, message, toolResults)

nextTurn = config.prepareNextTurn?({
    message,           # 本轮 assistant 消息
    toolResults,       # 本轮工具结果
    context,           # 当前 context
    newMessages,       # 本次运行累积的所有新消息
})
if nextTurn:
    context = nextTurn.context ?? context        # 换对话上下文
    config.model = nextTurn.model ?? config.model # 换模型
    config.reasoning = nextTurn.thinkingLevel    # 换思考等级

返回值影响控制流:如果返回了 AgentLoopTurnUpdate,下一轮用新的 context / model / thinkingLevel 调 LLM。典型用途是 context window 管理------对话太长时裁剪旧消息,或者切换到更大 context 的模型。

2. shouldStopAfterTurn------每轮后判断是否提前终止

调用位置:prepareNextTurn 之后。

bash 复制代码
if config.shouldStopAfterTurn?({
    message,        # 本轮 assistant 消息
    toolResults,    # 本轮工具结果
    context,        # 当前 context
    newMessages,    # 累积消息
}):
    emit(agent_end, newMessages)
    return                                  # 退出 runLoop

返回 true 就直接 agent_end 退出,不再检查 steering / follow-up。典型用途是 token 预算耗尽时优雅停止------让当前轮的工具调用完成,但不再启动新一轮。

3. getSteeringMessages------用户中途插话

调用位置:内层循环每一圈开始时,以及 shouldStopAfterTurn 之后。

ini 复制代码
# 内层循环开头
pendingMessages = config.getSteeringMessages?() ?? []

while hasMoreToolCalls or pendingMessages.length > 0:
    # 先处理插话消息
    for msg in pendingMessages:
        emit(message_start, msg)
        emit(message_end, msg)
        context.messages.push(msg)
        newMessages.push(msg)
    pendingMessages = []

    # 然后正常调 LLM
    message = streamAssistantResponse(...)
    ...

    # shouldStopAfterTurn 检查后,再查一次 steering
    pendingMessages = config.getSteeringMessages?() ?? []

返回的消息会注入到 context.messages,LLM 下一轮调用时就能看到。典型用途是用户在 agent 执行过程中追加指令------"顺便也改一下配置文件"------agent 不用等当前任务完成就能收到新指令。

4. getFollowUpMessages------本来要停了但有后续消息

调用位置:内层循环退出后、外层循环判断是否继续时。

bash 复制代码
# 内层循环退出(hasMoreToolCalls == false 且无 steering)
while true:                                                # 外层循环
    while hasMoreToolCalls or pendingMessages:             # 内层循环
        ...

    # 内层停了,检查有没有后续消息
    followUpMessages = config.getFollowUpMessages?() ?? []
    if followUpMessages.length > 0:
        pendingMessages = followUpMessages
        continue                                          # 回到内层循环
    else:
        break                                             # 真的停了

getSteeringMessages 的区别:steering 是中途插话 (内层循环还在转),follow-up 是追加任务(内层循环已经停了,用户又给了新任务)。典型用途是用户在 agent 完成回答后说"很好,现在帮我做另一件事"------agent 继续跑,不用重新启动。

钩子调用时序

四个钩子在每一轮的调用顺序:

markdown 复制代码
turn_start
  → streamAssistantResponse
  → executeToolCalls
  → turn_end
  → prepareNextTurn          ← 调整下一轮状态
  → shouldStopAfterTurn      ← 判断是否提前终止
                             ↗ 是 → agent_end
  → getSteeringMessages      ← 检查插话
                             ↗ 有 → 回到 turn_start
  → (内层循环退出)
  → getFollowUpMessages      ← 检查后续任务
                             ↗ 有 → 回到 turn_start
                             ↗ 无 → agent_end

四个钩子都是可选的------不传就不调用,循环行为退化为第 3 章的最小骨架。AgentHarness 实现了全部四个,用于支撑 session 管理和消息队列;第三方 agent 可以按需选择。

五、Q&A

Q1:Agent 在满足什么条件后退出循环,完成与用户的交互?

四个退出条件,分两类。

正常退出(agent 认为任务完成):

  1. 无工具调用 ------streamAssistantResponse 返回的 AssistantMessage.content 里没有 toolCall 块。LLM 自己决定"我不需要再调工具了,直接回复用户",hasMoreToolCalls 置为 false,内层循环退出。
  2. 无后续消息 ------内层循环退出后,getFollowUpMessages 返回空数组。没有用户排队的追加任务,外层循环也退出,发 agent_end

异常/提前退出(agent 被迫停止):

  1. error / aborted ------streamAssistantResponse 返回的 stopReasonerror(LLM 调用失败)或 aborted(用户 Ctrl-C)。立即发 turn_end + agent_end,不执行工具、不检查钩子。
  2. shouldStopAfterTurn 返回 true ------某轮结束后,钩子判断"该停了"(如 token 预算耗尽、用户主动要求停止)。发 agent_end,不检查 steering / follow-up。

关键区分:条件 1 是"LLM 自己决定不调工具了"------这是 agent 自主性的体现,模型判断任务完成。条件 2 是"用户没有追加任务"------这是外部的确认。两者都满足才正常退出。条件 3、4 是外部强制中断,不是 agent 自主决策。

还有第五个边缘情况:terminate 标记 ------如果工具执行后所有 ToolResultterminate 字段都是 truehasMoreToolCalls 也会置 false。这是工具主动告诉 agent"别再转了"(比如某个工具发现了致命错误需要立即停止)。

Q2:几个 hook 的作用分别是什么?

钩子 什么时候调 干什么 返回值怎么影响循环
prepareNextTurn 每轮 turn_end 之后 调整下一轮的状态------裁剪过长的对话历史、切换到更大 context 的模型、调整思考等级 返回 AgentLoopTurnUpdate,下一轮用新的 context / model / thinkingLevel
shouldStopAfterTurn prepareNextTurn 之后 判断"该不该停"------token 预算耗尽、用户主动要求停止、上下文窗口快满了 返回 true → 直接 agent_end,跳过所有后续检查
getSteeringMessages 内层循环每一圈开头 + shouldStopAfterTurn 之后 取用户中途插话的消息------agent 在执行任务时用户追加了"顺便也改一下配置文件" 返回的消息注入 context.messages,LLM 下一轮就能看到
getFollowUpMessages 内层循环退出后 取用户排队的后续任务------agent 完成回答后用户说"很好,现在做另一件事" 返回非空 → 外层循环继续,消息变 pendingMessages 重新进入内层

两两配对的设计:

  • prepareNextTurn + shouldStopAfterTurn:管"下一轮要不要继续、怎么继续"。前者调整状态,后者决定终止。
  • getSteeringMessages + getFollowUpMessages:管"用户的新消息什么时候进来"。前者是中途插话 (内层还在转),后者是追加任务(内层已经停了)。

一句话:prepareNextTurn 管"怎么转下一圈",shouldStopAfterTurn 管"还转不转",getSteeringMessages 管"中途加料",getFollowUpMessages 管"结束后续杯"。

Q3:是否可以在基础的四个工具的基础上增加其他工具?

可以,pi 的工具体系设计来就是可扩展的。

pi 内置 7 个工具(coding-agent/src/core/tools/index.ts:83):

集合 工具 用途
coding tools(4 个) read / bash / edit / write 核心写代码能力
read-only tools(4 个,read 重叠) read / grep / find / ls 只读检索能力

但工具不止这 7 个------pi 有三条扩展路径:

1. 用 pi 提供的工具工厂自定义

createCodingTools / createReadOnlyTools / createAllTools 接受 ToolsOptions,可以配置每个工具的行为(如 BashToolOptions 可以加 spawn hook 限制命令)。

2. 自己实现 AgentTool 接口

typescript 复制代码
const myTool: AgentTool = {
  name: "search_web",
  label: "Search Web",
  description: "Search the web and return results",
  parameters: {
    type: "object",
    properties: { query: { type: "string" } },
    required: ["query"],
  },
  executionMode: "sequential",
  async execute(toolCallId, params) {
    const results = await fetch(...);
    return {
      content: [{ type: "text", text: JSON.stringify(results) }],
      details: { query: params.query },
    };
  },
};

把它加到 context.tools 数组里,agent loop 自动把它传给 LLM,LLM 就能调用它。文章 4 会展开 AgentTool 接口的设计和工具注册机制。

3. 通过 Extension API 注入

pi 的 Extension API 允许不改源码、在 .pi/extensions/ 目录下声明扩展,扩展可以注册自定义工具。这是 pi "扩展无需 fork" 理念的体现。

关键约束:工具定义里的 name 必须全局唯一(agent-harness.ts:212Map<string, AgentTool> 持有,重名会覆盖)。parameters 必须是合法 JSON Schema------LLM 根据它生成参数。

六、下一章预告

下一篇文章将进入 pi 的工具体系------从 AgentTool 接口定义、工具注册机制、到 executeToolCalls 的完整执行链(sequential / parallel / beforeToolCall / afterToolCall 钩子),并以 read 工具为例,展示一个真实工具从定义到被 agent loop 调用的完整路径。其余 6 个工具(bash / edit / write / grep / find / ls)将逐个介绍。

相关推荐
咸鱼老弟1 小时前
多 Agent 协作翻车实录:为什么「单个 Agent 好使,三个就乱套」
agent·ai编程
殷紫川1 小时前
AI Agent 自我进化实战:让智能体从经验里持续成长的工程闭环
agent
樊小肆2 小时前
DeepSeeker-Code源码导读10-代码智能tsHost
人工智能·agent
武子康2 小时前
262K 跑过,128K 却被脚本拦下:A6000 跑分里的三种“失败”不能混为一谈
人工智能·llm·agent
修远客2 小时前
质检系统:Agent的自我审查 — 不自检的Agent就像没有编辑的报社
llm·agent
Aloudata2 小时前
企业级 AI 问数安全指南:如何兼顾可用性、权限与审计?
大数据·人工智能·数据分析·agent·语义编织
zzz海羊2 小时前
2026全平台移动办公远控助手横测:从鸿蒙到工作站,ToDesk、向日葵、TeamViewer、AnyDesk谁更适配?
人工智能·华为·agent·harmonyos·teamviewer
leeyi3 小时前
Eino Callbacks 回调机制:在 Agent 执行每个环节注入自定义逻辑(第95篇-E81)
aigc·agent·ai编程
YakProject3 小时前
如何让 AI Agent 跑得更快???
人工智能·测试工具·网络安全·agent·yakit·yaklang