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_startagent_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:立即硬停止

当模型响应的 stopReasonerroraborted 时,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;

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

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

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

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

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

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

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

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

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

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

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

steeringfollowUp 都能让 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 才真正结束。

参考资料

相关推荐
csdn_aspnet2 小时前
用Claude Code重构遗留系统,老项目自动化重构实践,提示词与效果验证
ai·ai编程·claude·anthropic
赛博仓鼠13 小时前
秋叶ComfyUI 3.2整合包实测:Python 3.13+Torch 2.13全栈升级,一键跑通MiniMax H3(附完整部署)
ai·ai作画·aigc·音视频
todoitbo13 小时前
本地图库语义搜索实战:接上蓝耘元生代,让“傍晚的海边“能搜到图
人工智能·ai·api·工具实战
天远API16 小时前
零信任架构实战:基于天远车信盟出险构建自动化车险评估网关
人工智能·ai·工具分享
JavaPub-rodert16 小时前
Codex 里的 GPT-6 Astra、GPT-5.6 Sol、Terra、Luna 怎么选?
ai·codex·skill
梅梅绵绵冰16 小时前
大模型应用开发-SpringAI框架
spring·ai
蜡台18 小时前
向量数据库完全解析:原理、核心指标、RAG工作流 \+ Milvus实战Demo
ai·agent
云烟成雨TD18 小时前
LlamaIndex 系列【32】检索增强策略:自问自答(Self Ask)
ai·agent·rag·llamaindex
slacker-kian18 小时前
[实践]-让 SAP 工程 Skill 脱离 opencode跑在自定义Agent 上
ai·llm·sap·agent·abap·adt·opencode