Harness Engineering 深度解析

Agent Loop 解剖学

它是每个 AI Agent 的核心引擎 —— 让无状态的 LLM 能持续行动、获取反馈、完成任务。
本文从定义到实现、从工程挑战到两家解法,彻底讲透 Agent Loop。

"The most successful implementations aren't using complex frameworks or specialized libraries. Instead, they are using simple, composable patterns." —— Anthropic, "Building Effective Agents", Dec 2024

01 Agent Loop 到底是什么

在开始对比之前,先把最基本的概念彻底钉死

LLM 的本质:一个无状态的纯函数

LLM 本身只做一件事 —— 接收一段文本,输出一段文本。它没有记忆、没有行动能力、没有持久化。每次调用完就"死"了。下一次调用和上一次没有任何关系。

这意味着 LLM 天然不是 Agent。它是一个极其聪明的顾问,但每次叫它来开会,都要把所有背景从头讲一遍。

text_in ──→ [ LLM (纯函数) ] ──→ text_out 没有记忆。没有行动。没有副作用。 每次调用相互独立,互不知晓。

Agent Loop 赋予 LLM 三个原本不具备的能力

能力LLM 本身Agent Loop 赋予
持久性 Persistence每次调用互相独立跨多次调用维持状态(对话历史)
行动力 Agency只能输出文本可以调用工具、执行命令、修改文件
接地性 Grounding可能产生幻觉工具返回真实世界反馈,不可伪造
一句话定义:Agent Loop 是一个执行循环,它将无状态的文本预测函数转化为有状态的、能行动的、能从环境获取反馈的系统。它是 LLM 从"能说"到"能做"的桥梁。

概念厘清:Turn、Iteration、Prompt、Context Window

用户输入 "修复登录 bug" │ ▼ ┌─── Turn(轮次)──────────────────────────────────┐ │ │ │ Iteration 1: 推理 → 调用 Grep 搜索代码 │ │ Iteration 2: 推理 → 调用 Read 读取文件 │ │ Iteration 3: 推理 → 调用 Edit 修改代码 │ │ Iteration 4: 推理 → 调用 Bash 运行测试 │ │ Iteration 5: 推理 → 返回最终结果 ✓ │ │ │ └────────────────────────────────────────────────────┘ │ ▼ "已修复登录 bug,测试通过"
  • Turn(轮次):用户输入 → Agent 完成所有工作 → 返回最终结果。一个 turn 内部可能包含很多次 iteration。
  • Iteration(迭代):一次推理 + 一次工具执行。上面的例子中有 5 次 iteration。
  • Prompt(提示):每次推理的完整输入 —— 系统指令 + 工具定义 + 完整对话历史。每次 iteration 的 prompt 都在增长。
  • Context Window(上下文窗口):模型单次能处理的最大 token 数。这是硬限制,所有优化策略都围绕这个约束。

02 核心循环:两家写法对比

把 Claude Code 和 OpenAI Codex 的核心循环放在一起,你会发现一个惊人的事实

Anthropic Claude Code — query.ts

while (true) {

  // 1. 调用 Claude API(流式)
  for await (const msg of
    deps.callModel({
      messages,
      systemPrompt,
      tools,
      toolChoice: undefined,
    })
  ) { /* 收集响应 */ }

  // 2. 模型没有调用工具?结束
  if (!needsFollowUp) break

  // 3. 执行工具,追加结果
  yield* runTools(toolUseBlocks)
  // → 回到步骤 1
}
VS

OpenAI Codex — Agent Loop

while (true) {

  // 1. 调用 Responses API(SSE)
  response = POST(
    "/v1/responses",
    {
      instructions,
      tools,
      input: conversationItems,
    }
  )

  // 2. 收到 assistant message?结束
  if (response.done) break

  // 3. 执行工具,追加结果
  result = execute(response.toolCall)
  conversationItems.push(result)
  // → 回到步骤 1
}
核心发现:两个循环的逻辑结构完全相同。
推理 → 判断是否结束 → 执行工具 → 追加结果 → 继续推理。

这不是巧合。Anthropic 在 Building Effective Agents 中明确说过:"An agent is not a special architecture — it is a model that repeatedly calls tools until a task is done."

Agent Loop 的核心是平凡的。两行伪代码就能写完。但上面展示的是 happy path —— 真实的 Agent Loop 还需要处理各种异常情况。

循环的退出与保护机制

一个只有 happy path 的 while(true) 在生产环境中会失控。两家都在循环中内置了保护机制:

什么时候退出循环?

退出条件Claude CodeCodex
正常结束模型返回文本、不再调用工具模型返回 assistant message(done event)
达到上限max turns 限制(可配置)token 超过阈值时自动 compact
用户中断用户随时可以打断循环并修正方向用户可取消当前 turn

工具调用失败怎么办?

工具执行出错时,错误信息会作为工具返回值追加到对话历史中,模型在下一次推理时能看到错误内容并自行决定如何处理 —— 重试、换一种方式、或报告给用户。这也是"接地性"的体现:真实的错误反馈让模型不会在幻觉中打转。

循环卡死(Doom Loop)怎么办?

模型可能陷入反复执行同一操作的死循环。应对手段包括:

  • 最大迭代次数 — 硬性上限,超过即强制退出
  • 重复检测 — 检测到连续相同的工具调用时介入
  • 用户可中断 — 人在环中,随时可以打断并调整

03 循环内部:真实的数据流

知道循环的代码结构还不够 —— 你需要亲眼看到数据是怎么在循环中流动的

一次完整的 Iteration 长什么样

用户输入 "修复登录 bug",看看第一次 iteration 中发生了什么:

Step 1:组装 Prompt 发给模型

发送给 API 的 messages 数组(简化)
[
  { "role": "system", "content": "You are Claude Code, an agentic assistant...
    To read files use Read instead of cat...
    Don't add features beyond what was asked..."  // ← ~700 行系统指令
  },
  { "role": "user", "content": "修复登录 bug" }
]
// + tools: [Grep, Read, Edit, Bash, Glob, ...共 40 个工具定义]
// 每个工具定义包含名称、描述、参数 schema —— 这是模型的"菜单"

Step 2:模型返回一个 tool_use 请求

模型的响应 — 不是直接回答,而是请求调用工具
{
  "role": "assistant",
  "content": [
    { "type": "text", "text": "让我先搜索登录相关的代码" },
    {
      "type": "tool_use",
      "name": "Grep",
      "input": { "pattern": "login|auth", "path": "src/" }
    }
  ]
}
模型是怎么"决定"调用 Grep 的?
它读了 40 个工具的描述(description),根据用户意图选择最合适的。比如 Grep 的描述写了 "search for keywords in code",模型判断"要修 bug 应该先找到相关代码",所以选了 Grep。工具描述就是模型的决策菜单 —— 描述越精准,模型选择越正确。

Step 3:循环执行工具,获得真实结果

工具执行结果 — 来自真实文件系统,不可伪造
{
  "role": "tool",
  "name": "Grep",
  "content": "src/auth/login.ts:23: async function handleLogin(credentials) {
src/auth/login.ts:45:   if (!token) throw new AuthError('Token expired')
src/auth/login.ts:52:   // BUG: should refresh token before checking
src/api/session.ts:11: import { handleLogin } from '../auth/login'"
}
这就是"接地性"(Grounding)的含义。
模型现在看到了真实的搜索结果,而不是自己编造的代码。它知道 bug 在 login.ts:52,有一条注释说"should refresh token before checking"。这个真实反馈让模型能做出正确的下一步判断 —— 而不是幻觉"我已经找到了问题"。

Step 4:追加到 messages,进入下一次 iteration

第 2 次 iteration 的 messages 数组 — 注意它在增长
[
  { "role": "system", ... },                     // 系统指令(不变)
  { "role": "user", "content": "修复登录 bug" },    // 用户输入(不变)
  { "role": "assistant", ... "tool_use": "Grep" },  // ← 新增:第 1 次的模型响应
  { "role": "tool", "content": "src/auth/..." },    // ← 新增:第 1 次的工具结果
]
// 模型看到完整历史后,决定下一步:Read src/auth/login.ts

多次 Iteration 后 —— 数组的膨胀

继续这个例子。5 次 iteration 后,messages 数组变成了这样:

messages = [ system prompt ~3,000 tokens (不变) user: "修复登录 bug" ~10 tokens (不变) ──────────── iteration 1 ──────────── assistant: tool_use Grep ~100 tokens tool result: 搜索结果 ~500 tokens ──────────── iteration 2 ──────────── assistant: tool_use Read login.ts ~80 tokens tool result: 文件内容(120行) ~2,000 tokens ──────────── iteration 3 ──────────── assistant: tool_use Edit login.ts ~200 tokens tool result: 编辑确认 ~50 tokens ──────────── iteration 4 ──────────── assistant: tool_use Bash "npm test" ~80 tokens tool result: 测试输出(3 fail → 0 fail) ~800 tokens ──────────── iteration 5 ──────────── assistant: "已修复,token 过期时..." ~300 tokens (最终回答) ] 第 1 次 iteration 发送: ~3,110 tokens 第 5 次 iteration 发送: ~7,220 tokens ← 是第 1 次的 2.3 倍
这就是"二次方增长"的体感。才 5 次 iteration,发送量就翻倍了。想象 100 次工具调用 —— 每次都要把前面所有结果重发一遍。这不是理论问题,而是 Agent 在处理大型任务时会实际遇到的成本和 context 瓶颈。

04 二次方 Token 问题的数学解释

理解了循环怎么跑,接下来看它跑久了会遇到什么问题

问题的数学本质

每次 iteration,都要向 API 发送完整的对话历史。随着工具调用次数增加,prompt 持续膨胀:

假设初始 prompt 长度为 P,每次 iteration 新增 k 个 token: Iteration 1: 发送 P + k 个 token Iteration 2: 发送 P + 2k 个 token Iteration 3: 发送 P + 3k 个 token ... Iteration n: 发送 P + nk 个 token 总发送量 = Σ(P + ik) = nP + k·n(n+1)/2 = O(n²) 示例:100 次工具调用,每次新增 500 token = 100 × P + 500 × 100 × 101 / 2 = 100P + 2,525,000 token
这就是 Agent Loop 的"二次方诅咒":你以为 100 次工具调用只消耗 100 × 单次成本,但实际消耗是 100² 量级。这也解释了为什么 Agent 运行越久越贵 —— 不是线性增长,而是加速增长。OpenAI 在 Unrolling the Codex Agent Loop 中首次公开讨论了这个问题。

这个问题必须被解决

如果不优化,Agent Loop 在实际使用中会迅速变得昂贵且不可持续 —— 几十次工具调用后就可能接近 context window 上限。

Codex 和 Claude Code 分别用了不同的工程手段来应对这个问题。接下来我们逐一拆解。

05 两种解法:Codex 和 Claude Code 如何应对 Token 增长

同一个问题,两种不同的工程选择

OpenAI 解法一:Prefix Caching

核心思想:Prompt 的前缀不变,只有尾部在增长

Prompt 结构(刻意设计的排列顺序): ┌──────────────────────────────────────┐ │ instructions (系统指令,不变) │ ─┐ │ tools (工具定义,不变) │ ├── 静态前缀 → 服务端可缓存 │ system context (环境信息,不变) │ ─┘ ├──────────────────────────────────────┤ │ conversation item 1 │ ─┐ │ conversation item 2 │ ├── 已有历史 → 也是前缀 │ conversation item 3 │ ─┘ ├──────────────────────────────────────┤ │ conversation item 4 (本轮新增) ←── │ ── 只有这部分需要新计算 └──────────────────────────────────────┘

当服务端检测到请求的前缀与上次推理完全一致(exact prefix match),就可以复用上次的 KV Cache,只计算新增部分。

为了保护前缀不被破坏,Codex 有一系列严格约束:

Codex 的 Prefix 保护策略 — "追加而非修改"的不变式
// 权限变更?追加新 developer message,不修改旧的
conversationItems.push({
  role: "developer",
  content: "User approved: allow shell commands"
})

// 目录切换?追加新 user message,不修改旧的
conversationItems.push({
  role: "user",
  content: "Working directory changed to /src/api"
})

// MCP 工具列表?枚举顺序必须稳定
// 因为 tools 定义在 prefix 中,顺序变了 = prefix 变了 = 缓存失效

为什么 Codex 不用 previous_response_id

Responses API 支持有状态模式 —— 传一个 previous_response_id,服务端就能记住上次的上下文,不用重传历史。但 Codex 刻意不用,原因是 Zero Data Retention (ZDR) 合规:企业客户要求 OpenAI 不在服务端保留任何对话数据。

Codex 的选择:每次发全量(保持无状态),靠 prefix caching 避免重复计算。
传输是冗余的,但计算是高效的。用网络带宽换取合规性和计算效率。

Anthropic 解法二:7 层渐进压缩

核心思想:不优化传输,而是直接压缩内容本身

Claude Code 内置了一套由客户端完全控制的多层压缩策略,当 context 接近窗口上限时逐级启用。以下是基于官方文档描述和运行行为的推断(Claude Code 未完全开源压缩模块的具体实现):

L1 温和 截断过长的工具输出

单个工具返回内容太多(如 ls 了一个大目录)→ 截断到合理长度

L2 温和 清除最旧的工具输出

最早的工具执行结果优先被删除 —— 它们对当前任务的参考价值最低

L3 中等 对话摘要

调用模型对历史对话做总结,用摘要替换原文 —— 信息密度提升,token 数下降

L4 中等 优先保留用户消息和关键代码

用户的原始意图 + 核心代码片段 = 最不可压缩的内容

L5 保护 System Prompt 永远保留

CLAUDE.md、auto-memory、系统指令 —— 这些定义了 Agent 的行为准则,绝不被压缩

L6 保护 最近几轮对话完整保留

近期上下文对当前任务至关重要

L7 激进 紧急压缩

如果以上仍不够 —— 激进裁剪,确保循环不会因为 context 溢出而崩溃

为什么是这个顺序?背后有一条原则:越新的、越关键的、越定义行为准则的内容越晚删。工具输出是"过程数据",用完即弃价值最低;用户消息是"意图数据",删了就不知道在做什么;系统指令是"身份数据",删了 Agent 就不知道自己是谁。

另一个关键差异:这套压缩由客户端完全控制。Claude Code 自己决定什么留什么删,API 端不参与。而 Codex 的 compaction 由服务端加密处理 —— 客户端看不到也无法控制被压缩的内容。

两种策略对 Token 成本的影响

网络传输成本计算(推理)成本
无优化O(n²) — 每次发全量O(n²) — 每次全量计算
Prefix CachingO(n²) — 仍然发全量O(n) — 缓存命中,只计算新增
内容压缩O(n) — 压缩后发送量受控O(n) — 发送量小,计算量也小

需要注意:这两种策略不是互斥的。Anthropic 的 API 同样支持 prompt caching,Claude Code 可以同时享受缓存和压缩的双重优化。两家的差异在于工程重心不同。

两种解法的对比

维度OpenAI CodexClaude Code
工程重心计算效率 — 不重复计算已有内容内容质量 — 有限窗口塞更多有用信息
Compaction 控制权服务端持有 — 返回加密内容客户端持有 — Harness 完全控制压缩
Prompt 构造约束前缀不可变(为缓存命中服务)更灵活,可重组(API 也支持缓存)
核心策略"发全量 + 缓存避免重算""压缩内容 + 缓存也能用"
Trade-off网络带宽 → 换 → 计算效率压缩信息损失 → 换 → 窗口利用率

06 什么时候该用 Agent Loop

Agent Loop 不是万能的 —— 知道什么时候不该用,才算真正理解它

Agent Loop 的三个固有代价

理解什么时候不该用 Agent Loop,才算真正理解了它:

代价原因
成本高多轮推理 = 多次 API 调用 = 前文所述的 O(n²) token 消耗
错误累积每一步的小偏差在后续步骤被放大 —— 第 3 步基于第 2 步的错误结论继续推理
不可预测你无法预知 Agent 会做几步、走什么路径,调试困难

更简单的替代方案

Anthropic 在 Building Effective Agents 中指出,在使用 Agent Loop 之前,应该先考虑更简单的 Workflow 模式:

  • Prompt Chaining(串联)— 任务可分解为固定步骤时,串联调用即可,每步之间可加验证门
  • Routing(路由)— 输入有明确分类时,路由到不同的专用 prompt
  • Parallelization(并行)— 子任务相互独立时,并行执行再合并结果
判断标准很简单:如果任务的步骤数可以预先确定 → 用 Workflow。如果任务是 open-ended、步骤数无法预判(比如"修复这个 bug")→ 用 Agent Loop。Claude Code 和 Codex 都是 coding agent,编码任务天然是 open-ended 的,所以两家都选择了 Agent Loop。

07 综合:深度理解 Agent Loop

读完本文,你应该能回答这些问题

1. Agent Loop 是什么?
一个执行循环,将无状态的 LLM 变成有状态的、能行动的、能从环境获取反馈的系统。核心逻辑:推理 → 判断是否结束 → 执行工具 → 追加结果 → 继续推理。

2. 循环里流动的是什么?
messages 数组。每次 iteration 追加两项:模型的 tool_use 请求 + 工具执行的真实结果。模型通过阅读工具描述("菜单")决定调用哪个工具,通过工具返回的真实结果("接地")纠正自己的判断。这个"决策→行动→反馈→纠正"的循环才是 Agent Loop 的灵魂。

3. 它面临什么核心挑战?
messages 数组持续膨胀,总 token 消耗以 O(n²) 增长。5 次 iteration 发送量就翻倍,100 次就是百万级 token。

4. 两家怎么解决?
Codex:Prefix Caching — prompt 结构刻意排列(静态前、动态后),靠精确前缀匹配复用计算。代价是 prompt 结构受约束(追加而非修改)。
Claude Code:渐进压缩 — 客户端按"过程数据→意图数据→身份数据"的优先级逐层压缩历史。代价是压缩有信息损失。
两种策略不互斥,差异在于工程重心。

5. 什么时候该用 Agent Loop?
只有任务是 open-ended、步骤数无法预判时才用。它有三个固有代价:成本高(O(n²))、错误累积、不可预测。能用更简单的 workflow 解决的,不要用 Agent Loop。