Agent 为什么需要循环

普通大模型调用像一次咨询:给出问题,得到回答,调用结束。

Agent 面对的却常常是另一类任务:先查看项目结构,再定位代码,修改文件,运行测试,根据失败结果继续调整,最后验证是否完成。模型不可能在第一次调用前就知道所有工具结果,因此必须在“观察—判断—行动—再观察”之间循环。

驱动这个过程的就是 Harness 第三个核心部分:Agent Loop

它看起来像一个 while 循环,但工程上的 Agent Loop 更接近一台有预算、有状态、有错误语义、可以被用户打断的状态机。

本文是《把 Harness 讲清楚:Agent 背后的运行系统》八个核心部分的第三篇。

最小循环只有几行代码

最容易理解的版本是:

messages = [user_request]

while True:
    response = model.generate(messages, tools)
    messages.append(response)

    if response.has_no_tool_calls():
        return response.text

    for call in response.tool_calls:
        result = execute_tool(call)
        messages.append(result)

这段代码已经具备 Agent 的基本形状:模型决定动作,环境返回观察,模型再根据新信息继续判断。

但它没有回答更困难的问题:

  • 模型连续调用同一个失败工具怎么办?
  • 多个工具可以并行吗?
  • 工具执行一半时用户取消怎么办?
  • 模型已经给出答案,但任务真的完成了吗?
  • 上下文超长、预算耗尽或模型服务失败怎么办?
  • 新消息在运行中到达,应该进入当前步骤还是下一轮?

这些问题决定了 Loop 能不能从 Demo 走向真实系统。

Turn、Step 和 Tool Call

理解 Agent Loop,最好先区分三个层级。

Turn

一次由用户输入或系统事件唤醒的完整处理过程。它从领取任务开始,到本轮不再有待处理工作为止。

Step

一次模型调用,以及这次调用要求执行的工具。模型读取工具结果后再次请求模型,就进入下一个 Step。

Tool Call

某个 Step 中的一次具体环境操作,例如读取文件、搜索网页或执行测试。

它们的关系是:

Turn 1
├─ Step 1
│  ├─ 调用模型
│  ├─ Tool Call:搜索文件
│  └─ Tool Call:读取配置
├─ Step 2
│  ├─ 调用模型
│  └─ Tool Call:修改代码
├─ Step 3
│  ├─ 调用模型
│  └─ Tool Call:运行测试
└─ Step 4
   └─ 调用模型并生成最终答复

分层以后,预算、日志、重试和恢复才能说清楚。例如模型请求失败可以重试当前 Step,工具失败可能让模型进入下一 Step 自我修正,而用户发来新任务通常应该开启新的 Turn。

一个稳健循环的完整流程

领取输入
   ↓
检查任务契约与当前状态
   ↓
组装上下文和可用工具
   ↓
请求模型
   ├─ 模型服务错误 → 按策略重试、降级或终止
   ↓
解析模型输出
   ├─ 无工具调用 → 进入完成验证
   └─ 有工具调用
          ↓
     权限检查与工具调度
          ↓
     记录结果并更新状态
          ↓
     检查预算、取消和停止条件
          ├─ 可以继续 → 下一 Step
          └─ 不可继续 → 终止或请求用户介入

Loop 本身不应包办上下文检索、安全策略和工具实现。它更像交通调度中心:按顺序调用各个模块,并确保状态转换完整发生。

停止条件比循环条件更重要

很多 Agent 问题不是不会开始,而是不知道什么时候停。

一个 Loop 至少需要考虑这些停止条件:

  • 模型没有请求工具,并给出最终答复;
  • 任务完成标准已被验证;
  • 达到最大 Step 数、token、时间或费用预算;
  • 用户取消任务;
  • 策略层阻止继续执行;
  • 同类错误重复出现,继续尝试价值很低;
  • 缺少必须由用户提供的信息;
  • 外部服务不可恢复地失败。

“模型说完成了”只能是候选停止信号,不能代替结果验证。代码任务至少应检查文件确实变化、相关测试确实运行并通过。

防止 Agent 原地打转

循环最常见的退化是重复:反复读取同一个文件、用相同参数调用失败工具,或者在两个方案之间来回切换。

可以为最近动作建立指纹:

def action_fingerprint(tool_name, arguments, state_revision):
    return hash(tool_name, normalize(arguments), state_revision)

if fingerprint in recent_failed_actions:
    repeated_count += 1

if repeated_count >= 3:
    inject_feedback("相同动作已连续失败,请改变方案或请求帮助")

if repeated_count >= 5:
    stop(reason="repeated_no_progress")

不能只比较工具名称。修改文件后再次运行同一个测试是合理行为,因为状态已经变化;在状态不变时重复同一失败调用,才更像死循环。

预算不是只有 token

真实任务的成本来自多个维度:

预算 防止的问题
最大 Step 数 无限推理循环
最大工具调用数 高频无效操作
token 上限 上下文与生成成本失控
挂钟时间 任务长期占用资源
金额上限 模型和外部 API 费用超标
外部配额 搜索、邮件、数据库被打爆

预算耗尽时,Harness 应清楚告诉模型或用户哪个预算已用完、已经完成什么、还差什么,而不是只返回“任务失败”。

模型错误与工具错误要分开

模型服务超时、限流和内容被截断,属于请求层错误。命令退出码非零、文件不存在和接口返回 403,属于工具层结果。

两者的处理方式不同:

  • 临时模型限流可以指数退避后重试同一 Step;
  • 参数错误应该反馈给模型,让它修正调用;
  • 权限拒绝不应自动重试;
  • 有副作用工具的结果未知时,不能盲目重放;
  • 预算错误通常需要调整任务或请求用户决定。

如果所有问题都被包装成一条普通文本“执行失败”,模型和运维人员都无法采取正确行动。

取消不是抛出一个异常就结束

用户点击停止时,Loop 需要完成一套有序退出:

收到取消信号
   ↓
停止发起新的模型请求和工具调用
   ↓
向正在执行的能力传播取消
   ↓
等待已启动操作结算或进入明确未知状态
   ↓
持久化已获得的结果
   ↓
关闭当前 Step 和 Turn
   ↓
向用户报告已完成、已取消和结果未知的部分

尤其是外部写入,网络连接中断不等于操作没有发生。Harness 必须把“未执行”“执行失败”和“结果未知”区分开。

运行中到达的新消息怎么处理

用户可能在 Agent 工作时补充:“不要修改数据库。”这条消息不能等任务全部完成后才读取。

常见设计会提供不同入口:

  • 当前步骤注入:只加入不改变安全边界的补充上下文;
  • 下一步骤消息:当前工具结束后,让模型立即看到;
  • 下一轮消息:当前 Turn 完整收尾后处理;
  • 取消并重开:新要求推翻原任务时停止当前运行。

选择哪一种取决于消息语义。新的禁止条件应尽快生效,而一句“完成后顺便总结”可以排入后续步骤。

更完整的 Loop 伪代码

async def run_turn(agent, task):
    state.open_turn()

    try:
        while not state.should_stop():
            state.check_budgets()
            cancel_signal.throw_if_cancelled()

            context = context_engine.build(task, state)
            response = await llm.call(context, tool_gateway.visible_tools(task))
            state.record_assistant(response)

            if not response.tool_calls:
                verdict = evaluator.check(task, state, response)
                if verdict.passed:
                    return response

                state.inject(verdict.feedback)
                continue

            results = await scheduler.execute(response.tool_calls, task)
            state.record_tool_results(results)
            state.detect_no_progress()

    except Cancelled:
        await scheduler.drain_started_calls()
        state.mark_cancelled()
    finally:
        state.close_turn()

这里每个模块都有独立职责,Loop 只负责编排和状态推进。

常见的失败方式

把 Loop 写成无法观察的大函数

上下文、模型调用、工具执行和错误处理混在一起,任何策略变化都要修改核心循环。应该通过明确事件或接口连接各模块。

自动重试所有错误

权限拒绝、参数校验失败和未知副作用都不适合无脑重试。重试策略必须理解错误类型和动作语义。

只有最大轮数,没有进展检测

Agent 可以在预算内重复几十次无效操作。除了硬上限,还应判断状态是否发生有意义变化。

取消后立即丢弃所有结果

已经完成的工具结果仍然是事实,应当落盘;否则恢复时系统不知道外部世界发生了什么。

用最终文本代替完成验证

“测试已通过”是一句话,退出码为 0 的真实测试记录才是证据。

Agent Loop 检查清单

  • 是否明确区分 Turn、Step 和 Tool Call?
  • 每个状态转换是否可以记录和恢复?
  • 是否有 Step、token、时间、金额和调用次数预算?
  • 是否能识别重复失败和无进展循环?
  • 模型错误、工具错误和策略拒绝是否分类处理?
  • 取消是否会停止新工作并妥善结算已启动操作?
  • 运行中新消息是否有明确的进入时机?
  • 停止前是否检查任务完成标准?
  • 失败时能否说明已经完成什么、还缺什么?

最后理解 Agent Loop

Agent Loop 不是让模型“一直想”,而是让推理与现实反馈有秩序地交替发生。

好的 Loop 不以轮数多为荣。它应该让每一步都有新信息或真实进展,在需要行动时安全行动,在证据不足时继续验证,在预算耗尽或风险上升时及时停下。

一句话总结:循环的价值不在于不停运行,而在于每一轮都让任务更接近可验证的完成。