加载中...
  • Agent Loop:模型、工具与消息如何形成闭环 loading

    吃透 AI Agent 开发 · 第 06 篇 · 第二章 · Agent Loop

    模型看一眼、决定下一步、系统执行,再把结果交回模型。真正的难点在消息演进和停止条件。

    把 Agent Loop 翻译成中文,就是“模型看一眼,决定下一步,系统执行,再让模型看一眼”。它很像维修师傅处理漏水:先观察,拧一个阀门,再观察水还漏不漏。区别在于,模型不会自动看到现实变化,工具结果必须被系统写回消息。

    看起来只是循环,真正实现时却有不少细节:一轮可能同时返回文字和多个工具调用;工具会失败;用户可能中途取消;模型还可能连续十次做同一件事。

    先看一条完整消息链

    假设用户说:“读取 package.json,告诉我用了哪个测试框架。”

    消息可能这样变化:

    1. user: 读取 package.json,告诉我用了哪个测试框架
    2. assistant: tool_call read_file({ path: "package.json" })
    3. tool: { content: "... vitest ..." }
    4. assistant: 项目使用 Vitest 作为测试框架
    

    第三条工具消息不能省。只在程序变量里保存文件内容,模型下一轮并不知道工具执行过什么。

    工具调用也不是普通文字。SDK 往往把一次模型响应拆成多个 part:文本、reasoning、tool call、引用或其他 Provider 特有字段。Agent Loop 要保留协议结构,而不是把所有内容拼成一个字符串。

    一个够用的循环骨架

    async function runAgent(input: RunInput): Promise<string> {
      const messages = [...input.messages]
    
      for (let step = 0; step < input.maxSteps; step++) {
        const response = await input.model.generate({
          system: input.system,
          messages,
          tools: input.tools.toModelTools(),
          signal: input.signal
        })
    
        messages.push(response.message)
    
        if (response.toolCalls.length === 0) {
          return response.text
        }
    
        for (const call of response.toolCalls) {
          const result = await input.tools.execute(call, input.signal)
          messages.push(toToolResultMessage(call, result))
        }
      }
    
      throw new Error('Agent reached the step limit')
    }
    

    这段代码比第一篇多了消息记录、步数和中断信号,但仍只是骨架。生产实现还要回答下面几个问题。

    什么情况下应该停止

    最自然的停止条件是:模型没有再请求工具,并给出了最终文本。但只靠这一条不够。

    • 用户主动取消,应立即停止模型请求和正在运行的工具。
    • 单步请求超过总超时,应结束或进入明确的恢复流程。
    • 达到局部步数预算,应返回可诊断错误,而不是无限运行。
    • 连续重复相同工具和参数,说明可能陷入循环。
    • 工具返回不可恢复错误时,不应假装成功继续。

    步数限制是最后一道保险,不是任务管理方式。正常任务应该因为完成目标而停止,而不是撞上 maxSteps

    重试要分对象

    “失败就重试三次”听起来稳妥,实际可能制造更大问题。

    模型请求遇到 429、临时网络中断,可以指数退避后重试。参数校验失败说明输入有问题,原样重试没有意义。写文件、发消息、创建订单等带副作用的工具,如果没有幂等键,自动重试可能执行两遍。

    function canRetry(error: unknown): boolean {
      return error instanceof RateLimitError
        || error instanceof TemporaryNetworkError
    }
    

    重试策略应该靠错误类型和操作语义决定,而不是只看“有没有抛异常”。

    reasoning 为什么要保留但不一定展示

    一些模型会返回独立的 reasoning part。它可能是后续请求协议的一部分,需要跟 assistant 消息一起回传。如果系统把它丢掉,下一步可能报协议错误或失去推理连续性。

    但“需要回传给模型”不等于“需要原样展示给用户”。运行消息和界面消息是两个边界。界面可以只显示最终文本、工具状态和必要的过程摘要。

    工具可以并行吗

    如果模型同时请求读取三个互不相关的文件,并行通常没问题。如果它先创建目录再写文件,并行就会打乱依赖。判断依据不是“模型一次返回了几个调用”,而是工具是否只读、是否存在数据依赖、是否会竞争同一资源。

    一种保守做法是:只读工具允许并发,写工具默认串行;需要更高并发时,再显式声明资源和依赖。

    如何发现“忙了很久但没进展”

    下面这些轨迹都值得警惕:

    • 连续读取同一个文件,却没有新的用户输入或文件变化。
    • 工具 A 报错,模型不调整参数,原样再次调用。
    • 两个工具来回切换,但最终状态没有变化。
    • 模型只输出“我继续检查”,没有文字结论也没有工具行动。

    可以为每次调用生成一个指纹:工具名、规范化参数和结果摘要。如果相同指纹连续出现,就提醒模型重新评估;达到阈值后终止并返回轨迹摘要。

    别把长结果直接塞回去

    Shell 打印十万行日志时,全部回填会迅速挤满上下文。工具层可以把完整输出写入文件,只返回前后片段、总行数、内容哈希和恢复路径。模型需要更多内容时,再按范围读取。

    这不是 Agent Loop 自己裁剪字符串,而是 Loop 与工具结果协议共同约定“哪些内容内联,哪些内容句柄化”。

    用 Final Text 做个小练习

    为循环增加重复调用检测:

    1. 使用 toolName + JSON.stringify(args) 生成调用指纹。
    2. 连续出现两次时,向消息中加入一次警告。
    3. 连续出现三次时终止循环。
    4. 用假模型依次返回三次相同调用,验证只执行两次还是三次,并写清你的选择。

    最后一步没有唯一答案,关键是终止规则必须清晰且可测试。

    User Message 到 Final Text 的小结

    Agent Loop 管理的不是一个 while,而是一条消息账本。模型响应、工具调用和工具结果都必须按协议进入账本;超时、重试、并发和终止则保护这条链不会失控。

    下一篇讨论每次请求最容易被低估的部分:究竟要把哪些信息放进模型上下文,以及为什么一股脑塞进去通常不是好主意。

    解剖一份消息账本

    假设模型第一次返回 read_file,工具读取成功,第二次模型给出总结。正确 transcript 里至少有 user、assistant tool call、tool result、assistant final 四个部分。少了 assistant tool call,工具结果会变成没有来源的消息;少了 tool result,第二轮模型只能猜执行是否成功。

    user: 请读取 package.json 并说明 scripts
    assistant: tool_call(read_file, { path: "package.json" })
    tool: tool_result(call_01, { status: "ok", preview: "..." })
    assistant: 项目提供 build、test 和 serve 三类脚本……
    

    这份账本既是模型的下一轮上下文,也是恢复和测试的输入。UI 可以把工具消息折叠成一行,但不能把协议级记录压成最终文字。

    Loop 的源码落点

    追踪序号 路径 验证点
    1. 主循环 src/agent/loop.ts 每次模型响应如何决定继续或停止
    2. 重试策略 src/agent/retry.ts provider 请求和工具副作用是否分开重试
    3. 循环检测 src/agent/loop-detection.ts 重复调用怎样被识别成无进展

    “循环没报错”不代表任务完成

    执行工具后只把结果拼成字符串,没有保留模型协议要求的 assistant tool call。这样的实现可能在单轮 demo 中回答正确,却无法继续第三轮,也无法在会话恢复时还原调用关系。

    停止条件要同时看模型完成原因、待执行工具、取消信号、循环检测和任务状态。任何一个分支决定结束时,都应该写明 whyStopped;否则 Eval 只能看到最终文本,无法区分正常完成和提前放弃。

    练习:制造两次无进展

    让假模型连续两次调用同一个只读工具,并返回完全相同的参数。第一次允许执行,第二次记录重复信号,第三次必须停止或改变策略。断言 transcript 中的消息顺序、工具次数和停止原因,不能只断言最终回答包含某个词。

    多工具调用时,账本顺序不能靠完成速度决定

    模型一次返回两个只读调用:读取 package.jsontsconfig.json。前者磁盘缓存未命中,后者先完成。如果应用按完成先后直接 push tool result,消息顺序就可能每次不同;有的 provider 还要求 tool result 与原始 callId 一一对应。

    一种做法是并发执行、按声明顺序入账:

    const calls = response.toolCalls
    const settled = await Promise.allSettled(
      calls.map((call) => tools.execute(call, signal))
    )
    
    for (let index = 0; index < calls.length; index++) {
      messages.push(toToolResultMessage(calls[index], settled[index]))
    }
    

    界面仍可以在每个工具完成时立即收到进度事件,transcript 则保持确定顺序。这再次说明 UI 事件和模型账本不是同一个数组。若两个调用存在依赖,就不应该仅因为它们出现在同一响应中而并行;工具元数据或上层计划必须能表达只读性、资源范围和依赖。

    sequenceDiagram
      participant M as Model
      participant L as Loop Ledger
      participant A as read package
      participant B as read tsconfig
      M->>L: call A + call B
      par 并行执行
        L->>A: execute A
        L->>B: execute B
      end
      B-->>L: B 先完成
      A-->>L: A 后完成
      L->>L: 按 call 声明顺序记 A、B
      L->>M: assistant calls + results A、B
    

    一轮请求里至少有三种“结束”

    模型返回 final text,只说明模型步骤结束;工具进程退出,只说明某个动作结束;用户任务完成,则需要目标和副作用都达到要求。这三种结束混成一个 done,会出现很典型的假完成:模型说“已经修改并测试”,实际 transcript 中没有写工具或测试退出码。

    可以让停止记录携带原因和证据:

    type StopRecord =
      | { reason: 'final-text'; step: number; messageId: string }
      | { reason: 'user-cancelled'; step: number; pendingCalls: string[] }
      | { reason: 'loop-detected'; fingerprint: string; repeats: number }
      | { reason: 'request-timeout'; step: number; elapsedMs: number }
      | { reason: 'budget-exhausted'; step: number; usage: Usage }
    

    final-text 并不自动等于 Eval 通过,它只是 Loop 的正常终态。任务层还会检查输出、文件和测试;二者分开后,Loop 不需要理解每个业务目标,Eval 也不需要猜循环为什么停。

    取消要沿一棵树传播

    用户按下 Ctrl+C 时,当前模型请求、正在执行的工具、工具启动的子进程和后台子任务可能同时存在。只在最外层设一个 cancelled = true,无法停止已经拿到引用的工作。

    运行时可以为本轮创建一个根 AbortController,模型和前台工具共享它;后台任务若被定义为“离开本轮也继续”,则拥有独立 controller 和持久 taskId。这个选择要在启动任务时决定,不能取消时才猜。

    取消后的结果也要入账。模型请求收到 abort 可以记为 cancelled-before-response;Shell 收到信号但未确认退出,则记为 cancellation-requested,保留 jobId 供后续查询。只有确认没有副作用继续运行,界面才显示“已取消”。

    崩溃恢复不是从最后一句继续生成

    进程在 tool result 写入会话前崩溃,磁盘文件却已经修改。恢复时若只读取 transcript,会看到最后状态是 assistant 请求写文件,于是可能再次执行。可靠恢复需要把执行小票与副作用证据放在调用边界上。

    恢复流程可以按 callId 查三件事:会话里是否已有 tool result;审计里是否有执行完成事件;外部状态是否能验证。三者一致时补写缺失账本,三者冲突时进入 unknown 并要求检查。不能为了让 transcript 看起来完整而伪造一个成功结果。

    对于本地写文件,写前快照、写后 hash 和 callId 能帮助确认;对于远端发消息,最好使用幂等键或查询 API;对于无法查询的动作,系统只能诚实报告不确定。这是 Loop 与领域工具的共同合同。

    用不变量测试 Loop,而不是测试某个回答

    假模型非常适合验证账本不变量。无论文本内容是什么,下面几条都应该成立:

    • 每个 tool result 都能找到此前同 callId 的 assistant tool call。
    • 同一个 callId 最多产生一份终态结果。
    • 模型下一轮不会看到尚未完成的半个调用。
    • 用户取消后不再发起新的模型步骤。
    • 达到循环阈值时留下最后一次不同结果和停止原因。
    • 长工具输出以内联摘要加恢复句柄表示,不把全文复制进历史。

    再随机生成工具成功、拒绝、超时、取消和流中断的序列,让 reducer 处理数百次。比起断言“回答包含 Vitest”,这些不变量更能保护 Loop 的长期行为。

    一次手工走查应该打印什么

    调试时可以为每步打印一行摘要,不打印 Prompt 和文件正文:

    run=r18 step=1 model.finish=tool-calls calls=2 ttft=812ms
    run=r18 call=c1 tool=read_file policy=allow status=ok bytes=1420
    run=r18 call=c2 tool=read_file policy=allow status=ok bytes=980
    run=r18 step=2 model.finish=stop output=356t
    run=r18 stop=final-text total=3.2s tools=2
    

    这五行足以判断执行骨架,又不会把项目内容泄露到普通日志。需要深挖时,再通过 runId、callId 找本地 trace 和受控 artifact。Loop 因此不只是能跑,还能在失败后还原“为什么跑到了这里”。

    本文目录
    本文目录