Agent Loop 源码拆解:模型为什么会连续调用工具,又在何时停下

我是安徽最忧郁程序员无隅

目录

前言

大模型本身只会生成下一段内容,为什么接入工具后,却能连续读文件、改代码、运行命令,最后再给出答案?关键不在于模型突然拥有了"执行能力",而在于外部程序构造了一个可重复的 Agent Loop。本文以 @earendil-works/pi-agent-core 0.84.3 的实现为依据,从 Trace、Turn、ToolCall、停止条件和消息队列五个角度,拆开这个循环真正的驱动力。

一、从一次调用到自主循环:差别究竟在哪里

先把三种常见的大模型使用方式放在一起看。

模式 下一步由谁决定 模型调用次数 适合的任务
直接调用 用户 1 次 翻译、摘要、单轮问答
Workflow 程序预先写好的流程 固定或可预测 RAG、审核流水线、分阶段生成
Agent Loop 模型输出的 ToolCall 运行时才知道 编程助手、开放式自动化任务

直接调用只有一条直线:输入交给模型,拿到输出后结束。Workflow 虽然会多次调用模型,但"先检索、再生成、最后校验"这些步骤已经由程序员写死。

Agent Loop 的不同之处是:程序不预先知道下一步是什么,只负责解释模型这一次输出的结构。

例如,用户要求"分析入口文件为什么启动失败",模型可能先输出一个 read 工具调用;看到文件内容后,再输出 grep;找到配置后,又调用 edit;直到某一次响应里不再出现 ToolCall,程序才进入结束判断。

这与 ReAct 思路一致:模型在"推理(Reason)---行动(Act)---观察(Observe)"之间循环。但工程实现并不神秘,核心只需要维护消息历史并重复三步:

  1. 把当前上下文交给模型;
  2. 执行模型返回的工具调用,把结果追加到上下文;
  3. 再次调用模型,直到没有新的工具调用。

因此,模型负责选择动作,Loop 负责执行、记录和控制边界。两者缺一不可。

二、Trace 与 Turn:先分清两层生命周期

读源码前最容易混淆的是 Trace 和 Turn。它们描述的是不同时间尺度。

  • Trace :从 agent_start 到 agent_end 的一次完整运行。
  • Turn:一次模型响应,以及该响应触发的一批工具执行。

假设一次任务经历下面三步:

text 复制代码
Turn 1:模型调用 → read + grep → 两个工具结果
Turn 2:模型调用 → edit        → 一个工具结果
Turn 3:模型调用 → 最终文本     → 无工具调用

这仍然只是一个 Trace,因为从用户提交任务到最终回答,中间没有发生新的 agent_start。

这里有两个容易误判的点。

第一,模型一次返回三个 ToolCall,它们仍属于同一个 Turn。Turn 的边界由模型调用划分,而不是由工具数量划分。

第二,把工具结果重新交给模型时,才进入下一个 Turn。因为此时发生了新的一次模型调用。

Pi 在入口处先发出 agent_start 和首个 turn_start。进入 runLoop() 后,firstTurn 会避免重复发送第一轮的 turn_start;后续每次内层循环开始,再发出新的 turn_start。因此,事件序列和实际执行边界保持一致:

text 复制代码
agent_start
  turn_start
    message_start / message_update / message_end
    tool_execution_start / tool_execution_end
  turn_end
  turn_start
    ...
  turn_end
agent_end

这套事件不只是日志。终端 UI 正是依靠它们显示流式文本、工具状态和一轮任务是否完成。

三、最小内核:十几行代码如何让模型转起来

抛开队列、钩子、动态模型切换等产品能力,一个最小 Agent Loop 可以写成下面这样:

typescript 复制代码
async function simpleLoop(messages: Message[]) {
  while (true) {
    const response = await callModel(messages, tools);
    messages.push(response);

    if (response.stopReason === "error" || response.stopReason === "aborted") {
      return messages;
    }

    const toolCalls = response.content.filter(
      (item) => item.type === "toolCall",
    );

    if (toolCalls.length === 0) {
      return messages;
    }

    for (const toolCall of toolCalls) {
      const result = await executeTool(toolCall);
      messages.push(result);
    }
  }
}

真正驱动循环的不是一句抽象的"模型认为任务还没完成",而是一个可以被程序检查的事实:本次响应中是否存在 ToolCall。

一条消息的完整旅程

以"读取入口文件并解释"为例,数据会经历下面的变化:

text 复制代码
UserMessage
  ↓
第一次模型调用
  ↓
AssistantMessage(content: [ToolCall(read)])
  ↓
执行 read,生成 ToolResultMessage
  ↓
第二次模型调用(上下文已包含 ToolResultMessage)
  ↓
AssistantMessage(content: [TextContent])
  ↓
没有 ToolCall,进入停止判断

第一次模型调用并没有拿到文件内容。模型只根据工具名称、描述和参数 Schema,生成一个结构化请求。真正读取文件的是宿主程序。随后,宿主程序把结果包装成 ToolResultMessage,与原来的用户消息、助手消息一起送入下一轮。

所以工具并不是模型的"手"。更准确地说,模型只会填写操作申请,Loop 才是审核申请、执行动作、记录结果的运行时。

为什么 AgentMessage 还要转换成 Message

Agent 内部可能保存 UI 通知、压缩摘要、分支记录等自定义消息,但模型供应商只理解用户、助手和工具结果等标准消息。streamAssistantResponse() 在调用模型前会先执行:

typescript 复制代码
const llmMessages = await config.convertToLlm(messages);

const llmContext = {
  systemPrompt: context.systemPrompt,
  messages: llmMessages,
  tools: context.tools,
};

convertToLlm 是 Agent 内部状态与模型协议之间的边界。这个边界很重要:内部消息可以按产品需要扩展,但发给模型的内容仍然保持协议合法。

四、循环何时停止:stopReason 不是唯一答案

许多实现会把 Agent Loop 简化为"stopReason === toolUse 就继续"。这可以用于教学,但不足以描述真实系统。

Pi 当前实现会综合检查 stopReason、ToolCall、工具批次的 terminate 标记,以及两个消息队列。

1. error 与 aborted:立即硬停止

当模型响应的 stopReason 是 error 或 aborted 时,Loop 会发送当前 turn_end 和最终 agent_end,然后直接返回。

这两种状态代表模型调用本身失败或用户已经中止。此时继续执行工具、读取 steering 或 followUp 都没有意义,因此采用快速失败策略。

2. 没有 ToolCall:只是"准备停止"

当响应里没有 ToolCall,hasMoreToolCalls 会保持为 false。但 Loop 不能立刻结束,因为外部可能还有新消息:

  • steering:用户在 Agent 工作期间补充的紧急指令;
  • followUp:当前任务自然结束后才处理的追加任务。

只有工具调用和两个消息队列都为空,才会正常发送 agent_end。

这说明"无 ToolCall 即结束"只是最小内核的规则。交互式产品还要把运行期间的新输入纳入判断。

3. length + ToolCall:不能直接执行

这是原始材料与当前 0.84.3 源码之间最值得注意的变化。

length 表示输出达到 token 上限。流式解析可能已经拼出一个形式上合法的 ToolCall,但参数仍可能被截断。假设模型原本要写入一段完整配置,截断后 JSON 恰好还能被修复和校验,直接执行会造成静默的数据损坏。

因此当前实现会调用 failToolCallsFromTruncatedMessage():

text 复制代码
检测到 stopReason === "length"
  ↓
不执行本轮任何 ToolCall
  ↓
为每个调用生成错误 ToolResultMessage
  ↓
让模型在下一 Turn 重新提交完整参数

这不是可选的防御性装饰,而是工具具有副作用时必须守住的安全边界。

4. terminate:必须整批工具都同意

工具结果可以设置 terminate: true,表示它认为当前批次之后不应继续。但 Pi 使用的是 every,而不是 some:

typescript 复制代码
function shouldTerminateToolBatch(finalizedCalls: FinalizedToolCallOutcome[]) {
  return finalizedCalls.length > 0
    && finalizedCalls.every((item) => item.result.terminate === true);
}

如果同一轮有三个工具,只有一个要求终止,另外两个正常完成,Loop 仍会继续。只有整批结果都要求终止,hasMoreToolCalls 才会变为 false,随后进入消息队列检查。

这是一种保守策略:单个工具只能表达自己的局部判断,不能替其他工具决定整个任务已经完成。

五、从能运行到能交互:Pi 在最小内核上加了什么

最小 Loop 解决"模型---工具---模型"的闭环,实际编程助手还需要处理流式 UI、并行工具、用户插队和运行时安全阀。Pi 没有把这些逻辑混进模型调用函数,而是通过事件、队列和钩子叠加。

流式响应:最后一条消息原地长大

收到流式响应的 start 事件时,streamAssistantResponse() 会先把一个尚未完成的助手消息放进 context.messages。后续收到文本、思考或工具调用增量时,它不会不断追加新消息,而是替换数组中的最后一项:

typescript 复制代码
context.messages[context.messages.length - 1] = partialMessage;

直到 done 或 error,最后一项才被完整的 finalMessage 替换。

这样设计解决了两个问题:

  • UI 能通过 message_update 实时刷新,不必等模型生成完毕;
  • 对话历史始终只有一条助手消息,不会把每个 token 误存成独立消息。

可以把它理解为:数组长度不变,最后一条消息的内容持续"长大"。

工具调度:准备阶段串行,执行阶段才并行

同一次模型响应可能包含多个 ToolCall。Pi 支持串行和并行两种模式:

  • 配置为 sequential,或批次中任意工具声明 executionMode: "sequential":整批串行;
  • 其他情况:准备阶段保持顺序,通过检查的工具才并发执行。

并行模式不是简单地把所有逻辑塞进 Promise.all()。它分成三段:

text 复制代码
准备:按源码顺序查找工具、整理参数、校验 Schema、运行 beforeToolCall
执行:通过准备的工具并发调用 execute
归档:tool_execution_end 可按完成时间出现,ToolResultMessage 按原调用顺序写回

为什么结果消息必须保持原调用顺序?因为下一轮模型需要把每个结果与对应的 ToolCall 稳定配对。执行可以并发,写入上下文的协议顺序却不能随完成速度漂移。

steering 与 followUp:都是新消息,时机完全不同

steering 和 followUp 都能让 Loop 继续,但语义不同。

机制 检查时机 适合的场景 对循环的影响
steering 进入 Loop 前,以及每个 Turn 结束后 用户中途补充"也检查测试文件" 在下一次模型调用前注入
followUp 内层循环已经自然停止后 系统追加"完成后再运行测试" 让外层循环重新启动内层循环

steering 不会打断正在执行的工具。它的"紧急"指的是在当前 Turn 完整结束后,优先进入下一次模型调用。followUp 则更晚:只有当前工具调用和 steering 都耗尽,才会被读取。

双层循环正是为这两种时机服务:

typescript 复制代码
while (true) {
  while (hasMoreToolCalls || pendingMessages.length > 0) {
    // 模型调用、工具执行、steering 检查
  }

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

如果你的 Agent 没有运行中输入,也没有任务结束后的追加工作,这个外层循环完全可以不要。它是产品能力,不是 Agent 的定义。

两个钩子:下一轮改装与安全停止

每个 Turn 完成后,Pi 还提供两个关键扩展点:

  • prepareNextTurn:替换下一轮的上下文、模型或思考等级;
  • shouldStopAfterTurn:在本轮完整收尾后优雅停止,不再读取队列或发起下一次模型调用。

例如,上下文接近上限时,可以让 shouldStopAfterTurn 返回 true,把压缩或恢复交给更外层的会话管理器。这样停止策略不需要污染通用 Loop。

这体现了一条很实用的架构原则:内核只维护不可缺少的状态转换,产品差异通过窄接口叠加。

六、如何判断自己的 Agent Loop 是否设计过度

实现自己的 Agent 时,可以先回答四个问题:

  1. 模型输出什么结构时需要继续?通常是存在 ToolCall。
  2. 工具结果以什么消息格式回填?它必须能与原 ToolCall 稳定关联。
  3. 哪些状态必须硬停止?至少包括调用错误和用户中止。
  4. 是否真的需要运行中插队、结束后续命、动态换模型?没有实际需求就不要提前加入。

一个健康的设计应当满足:去掉 steering、followUp、动态模型切换等外层能力后,最小 Loop 仍然可以独立运行。

最终可以把 Agent Loop 归纳成一句话:

模型用 ToolCall 提议下一步,运行时安全地执行并回填结果;当没有可执行动作、没有排队消息,也没有异常需要处理时,这次 Trace 才真正结束。

参考资料

相关推荐
风花一世月8 分钟前
[特殊字符] 我把一套分布式消防方案做成了能跑的 3D 沙盘(三架无人机共用一根水带,在线直接玩)
分布式·3d·ai·无人机
GalaxyMeteor10 分钟前
Windows 本地部署 Sam3 模型笔记(含Demo)
ai
秋饼19 分钟前
Java 后端企业级 LLM 网关实战:Spring Cloud Gateway 构建统一大模型接入层
java·ai·技术分享·后端开发
赛博仓鼠2 小时前
Vidu Q4 Preview实测:15张参考图+3段音频驱动,2K/4K输出,国产视频生成新标杆
ai·aigc·音视频
粥里有勺糖2 小时前
Windows 搭建 ComfyUI 环境 | 小白也能直接上手
ai·经验总结·comfyui
袖清暮雨4 小时前
机器学习之其他聚类(层次聚类+DBSCAN算法)
人工智能·机器学习·ai·聚类
ofoxcoding4 小时前
Gemini TTS 与 ElevenLabs 语音合成实测:同一段视频旁白效果对比
ai·ffmpeg·音视频
谢亮_vipxieliang4 小时前
多智能体系统的“TCP/IP时刻”:MCP与A2A协议如何定义Agent时代
网络·tcp/ip·ai·php
知了一笑5 小时前
AI卷王们,从窄路走向宽阔?
人工智能·程序人生·ai
杜子不疼.5 小时前
我给 Claude Code 写了个 MCP Server,让它自己上蓝耘挑模型干活
人工智能·ai·机器人