返回首页
03
AgentLoopstopReasonReActsteering流式响应

Agent Loop:让模型转动起来的引擎

循环为什么存在、靠什么信号决定停转、内核与叠加怎么分层

带着问题读

读完本章,你应该能回答这三个问题:

  1. 1如果模型在一次循环里连续调用工具,框架怎么知道什么时候该停?答案见正文中对应的「面试题 1」气泡
  2. 2stopReason 有五种取值,但其中两种并不是模型 API 返回的——它们是谁、在哪里注入的?答案见正文中对应的「面试题 2」气泡
  3. 3用户在 Agent 工作期间又输入了新指令,Pi 的 steering 机制是怎么做到不打断当前 Turn 又能插队的?答案见正文中对应的「面试题 3」气泡

第 3 章:Agent Loop —— 让模型转动起来的引擎

架构只是"骨架",Agent 真正的生命力来自"循环"。本章回答三个问题:为什么需要循环?循环怎么转?什么时候停?

大模型的三种用法

在聊 Agent Loop 之前,先看"使用大模型"有几种模式:

维度 直接调用 Workflow Agent Loop
决策者 用户 你的代码 模型
模型调用次数 1 次 N 次(代码控制) 不确定(模型控制)
核心工作 写提示词 设计流程 定义工具和循环
模型角色 执行者 流水线环节 自主决策者
典型场景 翻译、摘要 文档流水线、RAG 编程助手、自动化任务

Agent 模式的关键区别:步骤之间的流转不再由你写死,而是由模型的输出内容驱动。 你的代码只做两件事——把用户输入和工具结果喂给模型;如果输出里有工具调用请求就执行,没有就认为任务完成。至于"什么时候该停",那是人类定义的规则:模型一次输出中不再包含工具调用时,循环结束。

两个必须分清的概念:Trace 与 Turn

Trace 是从用户按下回车到 Agent 发出 agent_end 的整个过程,包含多个 Turn。Turn 的定义非常精确:一次模型调用 + 这次调用触发的所有工具执行,由一对 turn_start / turn_end 事件包裹。

Trace(agent_start → agent_end)
├── Turn 1:调模型 → 返回 toolUse(读文件)→ 执行 read
├── Turn 2:带结果再调模型 → 返回 toolUse(改文件)→ 执行 edit
└── Turn 3:带结果再调模型 → 返回 stop(无工具调用)→ 结束

关键点:一个 Turn 只有一次模型调用。 模型一口气要求 3 个工具(read + grep + find),它们都在同一个 Turn 里执行——都是同一次模型调用的产物;但把结果喂回去再调模型的那一刻,已经进入下一个 Turn 了。

实现细节:首轮 Turn 的 turn_start 在 runAgentLoop() 入口就发出,runLoop() 内用 firstTurn 标志跳过首圈的 turn_start,避免重复。

Trace 与 Turn 的嵌套结构

stopReason:唯一的信号灯

整个循环的"油门和刹车"集中在一个字段上:stopReason。但先澄清一个关键认知:模型不会说"我要停了"。 模型只是 token 预测器,不"知道"任务做完没有。stopReason 的值来自两个不同的地方:

模型 API 真正返回的三种:

stopReason 含义
"toolUse" 模型输出了工具调用 JSON,API 检测到后返回
"stop" 生成自然终止,没有工具调用
"length" token 数达到 maxTokens 上限,被截断

框架流式层注入的两种(模型 API 本身不会返回):

stopReason 含义 谁注入的
"error" 调用过程异常(网络断了、API 报错) 流式层 catch 块:output.stopReason = "error"
"aborted" 用户主动中止(AbortSignal 触发) 流式层 catch 块:output.stopReason = "aborted"

代码证据(packages/ai/src/):streamSimple 内部 API 调用抛异常时,catch 块执行 output.stopReason = options?.signal?.aborted ? "aborted" : "error"。这不是模型说的,是框架替它"兜底"的。

一条规则驱动整个循环

Loop 实际上只看一件事——模型输出里有没有工具调用:

// 简化逻辑(实际见 agent-loop.ts:202-216)
const toolCalls = message.content.filter(c => c.type === "toolCall");
hasMoreToolCalls = false;
if (toolCalls.length > 0) {
  const executedToolBatch = await executeToolCalls(...);
  hasMoreToolCalls = !executedToolBatch.terminate;  // 全部工具 terminate 才停
}

注意:实际驱动循环的不是 stopReason === "toolUse",而是 toolCalls 数组长度 > 0 && !terminate。即使 stopReason === "length"(被截断),只要 content 里有 toolCall 块,循环仍会执行工具;反之即使 stopReason === "toolUse",如果所有工具结果都设了 terminate: true,循环也会停。

内层循环条件是 while (hasMoreToolCalls || pendingMessages.length > 0),退出路径一共四条:

退出路径 触发条件 说明
正常退出 stop / length + 无 followUp + 无 pendingMessages 最常见
硬停止 error / aborted 立即退出整个循环,不检查 followUp(fail fast)
外部钩子停 shouldStopAfterTurn() 返回 true 上下文快满、达到最大 Turn 数等
工具终止 一批工具的结果全部 terminate: true 用 every 不是 some——保守策略

stopReason 驱动的循环决策流程

为什么不让代码更智能地判断"任务完成没"? 这正是 Agent 和 Workflow 的本质区别。Workflow 里你知道流程有几步,可以判断进度;Agent 模式下你不知道模型要读几个文件,唯一能稳定依赖的信号就是"输出里有没有工具调用"。这既是局限,也是优雅——不需要任何"完成度"判断逻辑。

源码详解:内核 + 叠加

最简 Loop:所有 Agent 的最小公约数

// 最简 Agent Loop(伪代码)
async function simpleLoop(messages, model, tools) {
    while (true) {
        const response = await callModel(model, messages, tools);
        messages.push(response);
        if (response.stopReason !== "toolUse") return messages;  // 没要工具 → 结束
        for (const toolCall of response.toolCalls) {
            messages.push(await executeTool(toolCall));          // 执行并喂回去
        }
    }
}

十几行,这就是内核。Pi 的 coding-agent 作为交互式编程助手,在内核上叠加了四个设计:

真实需求 叠加的设计
用户在 Agent 工作时又输入新指令 steering 消息注入:紧急消息在 Turn 之间插队
系统在 Agent 完成后想追加任务 外层 followUp 循环:内层停了外层可重启内层
不同复杂度任务想用不同档次模型 prepareNextTurn 钩子:每 Turn 结束可换模型/上下文
上下文窗口快满需触发压缩 shouldStopAfterTurn 钩子:外部安全阀

关键认知:这些都是 coding-agent 的功能选择,不是 Agent 的通用法则。做"一问一答带工具"的简单 Agent,整张表都是多余的。

runLoop() 骨架

async function runLoop(currentContext, newMessages, config, signal, emit, streamFn) {
    // ① 首次 steering 检查(在进入内层循环之前!)
    let pendingMessages = (await config.getSteeringMessages?.()) || [];

    while (true) {                              // ===== 外层循环(followUp 续命)
        let hasMoreToolCalls = true;
        let firstTurn = true;                   // 首轮跳过 turn_start(入口已发)

        while (hasMoreToolCalls || pendingMessages.length > 0) {  // 内层循环
            if (!firstTurn) emit({ type: "turn_start" });
            firstTurn = false;

            // A: 注入 pendingMessages(steering)   ← 叠加
            // B: 调 LLM → streamAssistantResponse() ← 内核
            // C: 检查 stopReason                    ← 内核
            // D: 执行工具                            ← 内核
            // E: emit turn_end                      ← 内核
            // F: prepareNextTurn → shouldStopAfterTurn → 再查 steering ← 叠加
        }

        const followUpMessages = (await config.getFollowUpMessages?.()) || [];
        if (followUpMessages.length > 0) {
            pendingMessages = followUpMessages;
            continue;                           // 内层循环重开
        }
        break;                                  // 两个队列都空,真正退出
    }
}

为什么 steering 要在进入循环之前先查一次?因为用户在等 LLM 首次响应时可能已输入了内容——消息已从外部排队但循环还没开始,不提前取出来这批消息就漏掉了。

步骤 B 的四个关键阶段

阶段 A:上下文预处理。 配了 transformContext(如压缩算法)就先处理消息,不配就跳过。

阶段 B:convertToLlm——两层消息的边界。 Agent 内部历史里不只有标准消息,还有它自己的"内部语言"(CompactionSummaryMessage、BashExecutionMessage、BranchSummaryMessage 等),LLM 根本不认识——它只认 UserMessage / AssistantMessage / ToolResultMessage 三种。convertToLlm 是站在边界上的翻译官,默认实现就是一个 filter:

function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
    return messages.filter(
        (m) => m.role === "user" || m.role === "assistant" || m.role === "toolResult",
    );
}

阶段 C:构建 Context 并调用模型。 每圈内层循环都重建一个全新的 llmContext 对象(systemPrompt / messages / tools 三部分)。注意 tools 是引用赋值、内容字节级稳定,只有 messages 真的在长。每圈重建 wrapper 的成本可忽略,但能避免共享引用导致的状态污染,也保证 prepareNextTurn 换模型、扩展系统动态注册工具后下一轮能生效。

这会不会破坏 prompt cache? 不会。Anthropic 的 cache 是内容寻址的——看的是字节不是对象 identity。Pi 在 anthropic-messages.ts 里显式在三个位置打 cache_control: { type: "ephemeral" }:system prompt 末尾、最后一个 tool、最后一条 user message。第三条最精妙——cache breakpoint 跟着最新 user message 走(rolling cache),旧前缀持续命中、新内容增量写入。OpenAI 体系走另一条路(openai-completions.ts:554):prompt_cache_key: sessionId,后端按 session 自动匹配前缀。

阶段 D:流式响应的"原地替换"。 收到 start 事件时先 push 一个"空壳"消息进 context,之后每个 delta 事件用更新后的部分消息覆盖数组最后一条(不是 push 新条目),done 时用最终完整消息替换:

case "start":
    partialMessage = event.partial;
    context.messages.push(partialMessage);          // 空壳 push
    break;
case "text_delta":
    partialMessage = event.partial;
    context.messages[last] = partialMessage;         // ★ 原地替换
    emit({ type: "message_update", ... });           // UI 逐字渲染
    break;
case "done":
    finalMessage = await response.result();
    context.messages[last] = finalMessage;           // ★ 最终替换
    break;

这样 context 的消息数量不变,但最后一条在"长大"——UI 通过 message_update 事件做到逐字渲染,用户不必盯着空白屏幕等几秒。

流式响应原地替换的时间线

步骤 C/D:硬停止与工具执行

拿到 AssistantMessage 后立即做硬停止判断(agent-loop.ts:196-200):error 或 aborted 直接发 turn_end + agent_end 然后 return,连工具都不执行、followUp 都不检查。

工具执行的调度策略:只要这批工具中任何一个声明了 executionMode: "sequential",整批都串行("一票否决"——判断工具间是否冲突很难,宁可多等不可出错)。并行模式则是三阶段设计:准备阶段始终顺序(验证和 beforeToolCall 不能并行——万一 B 被拦截,C 就不该执行)、只有 execute 并行(Promise.all)、事件有序(result 按调用顺序发,保证 LLM 收到的上下文正确)。edit 工具内部还有第二道防线 withFileMutationQueue,对同一文件的编辑串行化。

steering vs followUp

维度 steering(叠加 1) followUp(叠加 2)
检查时机 runLoop 开始前 + 内层循环每圈 内层循环全部结束后
语义 "紧急插队"——在工具执行间隙插入 "排队等叫号"——等当前任务完成
典型场景 用户在 Agent 工作时输入新指令 系统追加"顺便跑个测试"

生活类比:steering 是开会时有人敲门递纸条——"紧急,先看这个";followUp 是开完会翻信箱——"不急,但需要处理"。followUp 让追加任务在同一个 Trace 内继续跑(同一个 newMessages 数组、同一个事件序列),比重启一个新 Loop 更连续。

steering vs followUp 对比

总结:Loop 的四条核心设计

  1. ReAct 循环模式:Reason → Act → Observe → Reason。模型输出决定"调什么工具",但"什么时候停"是人类定义的规则——模型不再输出工具调用时,本轮结束。
  2. stopReason 驱动:实际驱动循环的不是 stopReason 本身,而是"输出里有没有 toolCall 块且未全部 terminate"。把决策外包给模型输出模式,代码只做最简单的信号判断。
  3. 内核 + 叠加:内核十几行就够;steering、followUp、prepareNextTurn、shouldStopAfterTurn 都是产品层的按需叠加。做自己的 Agent 时,先搭内核,再按场景加叠加。

Agent Loop 内核与叠加设计

下一章,我们拆开 Loop 调用的"模型"层:Pi 怎么用同一套代码调用 30+ 家不同供应商?


本章关键源码索引(v0.80.2):agent-loop.ts:95-118(入口)、:155-269(双层循环)、:196-200(硬停止)、:275-368(流式响应)、:373-516(工具调度)、agent.ts:118-152(PendingMessageQueue)。本文改编自 CC-BY-SA-4.0 许可的开源教程。