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

51 阅读12分钟

一、引言: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 已经持有三样东西:

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):

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/user/work_dir/...

它的结构分五块:

  • 角色定义:告诉模型"你是 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 在每个阶段怎么累积:

初始状态
┌─────────────────────────────────────┐
│ 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)的最小骨架,用伪代码省略非核心逻辑:

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)
        #   ↑ [上一章](https://juejin.cn/post/7660409307755675648) 已讲:消费 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)
            #   ↑ [下一章](https://juejin.cn/post/7677961696515031075)展开:查工具 → 校验参数 → 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 消息(上一章已讲,这里不再展开,签名是 (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 两种调用形态:

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 之前。

# 在 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 之后。

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 之后。

# 内层循环开头
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——本来要停了但有后续消息

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

# 内层循环退出(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 继续跑,不用重新启动。

钩子调用时序

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

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
shouldStopAfterTurnprepareNextTurn 之后判断"该不该停"——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 接口

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 就能调用它。下一章会展开 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)将逐个介绍。