## Agent 为什么需要循环

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

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

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

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

本文是《[把 Harness 讲清楚：Agent 背后的运行系统](https://www.ittt.cc/articles/15)》八个核心部分的第三篇。

## 最小循环只有几行代码

最容易理解的版本是：

```python
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 中的一次具体环境操作，例如读取文件、搜索网页或执行测试。

它们的关系是：

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

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

## 一个稳健循环的完整流程

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

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

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

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

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

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

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

## 防止 Agent 原地打转

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

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

```python
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 需要完成一套有序退出：

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

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

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

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

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

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

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

## 更完整的 Loop 伪代码

```python
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 不以轮数多为荣。它应该让每一步都有新信息或真实进展，在需要行动时安全行动，在证据不足时继续验证，在预算耗尽或风险上升时及时停下。

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