Agent Harness 的双层循环:为什么你的 AI Agent 总是“假装完成”?

agent2026年10月13日13

Agent Harness 的双层循环:为什么你的 AI Agent 总是“假装完成”?

——从 LangGraph 和 Claude Code 看循环架构的设计哲学


1. 一个让所有 Agent 开发者头疼的问题

如果你用过任何 AI 编程助手——Claude Code、Cursor、Copilot——你一定经历过这样的场景:你让 Agent“修复这个失败的测试”,它读了几个文件,改了代码,然后自信地说:“测试应该通过了。”但当你真正运行测试时,依然是一堆红色。

Agent 说它“做完了”,但它没有。

这个问题的根源不在于模型不够聪明。根源在于:大多数 Agent 只有一个循环,而这个循环把“模型说完成”当成了“任务完成”。模型可以在读完代码后判断“这个改动应该能修复问题”,然后就退出了循环——它从未真正验证过。Claude Code 团队对此的描述很直接:Agent 会“判断它已经完成了任务或需要额外上下文”,而这判断经常是错的。

双层循环(Nested Loops) 就是解决这个问题的架构方案。它的核心思想很朴素:用一个内层循环管“怎么做事”,用一个外层循环管“事有没有做成”。


2. 什么是双层循环:用生活比喻理解

在深入代码之前,先用一个非技术的比喻。

2.1 内层循环:厨师做一道菜

想象你在餐厅点了一道“宫保鸡丁”。厨师的工作流程是这样的:

  1. 看菜谱(获取上下文)
  2. 切菜、下锅、调味(执行动作)
  3. 尝一口,判断咸淡是否合适(观察结果)
  4. 如果不够咸,再加点酱油;如果太甜,加点醋(根据观察调整)
  5. 重复 3-4,直到他认为这道菜可以出锅了

这就是内层循环。它的特点是:快速、具体、以“动作完成”为终止条件。厨师不需要问你“你觉得够咸吗”,他用自己的味觉判断。在 Agent 的世界里,内层循环就是:调用模型 → 执行工具 → 观察结果 → 再调用模型……直到模型认为“这一步做完了”。

2.2 外层循环:餐厅经理确保客人满意

但厨师说“可以了”不等于客人真的满意。餐厅经理的工作是:

  1. 菜上桌后,观察客人的反应(有没有皱眉?有没有叫服务员?)
  2. 如果客人不满意,把菜端回去,告诉厨师“太辣了,重做”(重新规划)
  3. 如果客人满意,结账、送客(任务完成)
  4. 如果客人一直不满意,经理需要决定:是换一个厨师(换策略),还是告诉客人“这道菜我们今天做不了”(失败退出)

这就是外层循环。它的特点是:慢、抽象、以“目标达成”为终止条件。经理不关心厨师切了几刀、放了几粒花椒,他只关心一件事:客人满意了吗?

2.3 双层循环的核心区别

维度 内层循环(Agent Loop) 外层循环(Harness Loop)
终止条件 模型说“这步做完了” 客观验证说“目标达成了”
关注粒度 单次工具调用 / 单轮推理 整个任务的生命周期
谁来判断 模型自己 外部验证器 / 人类
出错了怎么办 在循环内重试或换工具 重新规划、换策略、降级

GitHub 上一位技术作者对这两层循环的描述很精准:agent loop 是“智能体内部:调工具、读文件、跑测试”,而 harness loop 是“外部:决定工作何时算完成,让任务活过模型自己说‘我做完了’的时刻”。

这句话的关键在最后半句:让任务活过模型自己说“我做完了”的时刻。模型说“完成了”不是终点,外层循环会追问:“真的吗?验证一下。”


3. Claude Code 的双层循环:从一次真实的 Bug 修复说起

Claude Code 是目前双层循环设计最成熟的商业产品之一。让我们用一个具体的 Bug 修复场景,追踪它的双层循环是如何运转的。

3.1 内层循环:Agentic Loop 的五阶段

当你对 Claude Code 说“修复登录接口的空指针异常”时,内层循环启动。Claude Code 官方文档描述了它的核心结构:收集上下文、执行动作、验证结果,这三个阶段相互融合,循环推进。

具体到代码层面,LangChain 的 create_react_agent 给出了一个经典的、可运行的实现参考。它的内层循环本质上是一个条件边驱动的状态图:

from langgraph.graph import StateGraph, START, END
from langgraph.prebuilt import ToolNode
from langchain_core.messages import AIMessage, ToolMessage

# 内层循环的核心逻辑(简化版)
def should_continue(state):
    """判断是否继续内层循环"""
    last_message = state["messages"][-1]
    # 如果模型还想要调用工具,就继续循环
    if last_message.tool_calls:
        return "tools"
    # 否则,内层循环结束,返回结果给外层
    return END

# 构建内层循环图
graph = StateGraph(AgentState)
graph.add_node("agent", call_model)  # 模型推理节点
graph.add_node("tools", ToolNode(tools))  # 工具执行节点

graph.add_edge(START, "agent")
# 核心:条件边决定是否继续循环
graph.add_conditional_edges("agent", should_continue, {
    "tools": "tools",  # 继续循环
    END: END          # 退出循环
})
graph.add_edge("tools", "agent")  # 工具执行后回到模型

inner_loop = graph.compile()

这个循环的终止条件是:模型不再请求调用工具。当模型的输出中 tool_calls 为空时,should_continue 返回 END,内层循环退出。

在 Claude Code 的实际运行中,这个循环可能迭代数十次:模型读文件 → 执行 grep → 读更多文件 → 编辑代码 → 运行测试 → 读错误输出 → 再编辑 → 再运行测试……直到模型认为“修好了”。

3.2 外层循环:Harness 的验证与恢复

但“模型认为修好了”只是内层循环的退出条件,不是任务的完成条件。Claude Code 的 Harness 层在外层做几件事:

第一,客观验证。 Claude Code 支持通过 SKILL.md 定义验证步骤。官方文档给出的前端变更验证示例非常直观:不要仅凭编辑成功就报告 UI 完成,而要“启动开发服务器,在浏览器中打开页面,点击新控件,截图前后状态,检查控制台错误”。这些检查是确定性的——不是模型“觉得”没有错误,而是浏览器控制台“确认”没有错误。

第二,上下文压缩(Compaction)。 内层循环跑多了,上下文会爆炸。Claude Code 在上下文使用率达到约 80-90% 时触发压缩:保留最近 4 条消息原文,更早的内容被摘要。这个动作发生在外层,因为内层循环“不应该知道自己的历史被压缩了”——它只关心当前上下文是否足够继续工作。

第三,子代理隔离。 当内层循环需要做大量探索性阅读时(比如“调查认证系统如何处理 token 刷新”),Claude Code 会启动一个子代理。子代理在独立的上下文中运行,完成探索后只返回一个摘要给主循环。这相当于外层循环派出了一个“调查员”,调查员的内层循环在独立空间里跑,不会污染主循环的上下文。

第四,错误恢复。 如果内层循环在一个方向上反复失败(比如模型连续 5 次尝试修复同一个测试但都失败了),外层循环的 ResultMessage 会携带 error_max_turns 或 error_max_budget_usd 状态。此时 Harness 可以选择:重新规划(换一个修复思路)、请求人类介入、或安全退出。

3.3 代码视角:LangGraph 中的“外层循环”

LangGraph 的设计哲学天然支持双层循环:一个编译后的 StateGraph 本身就是一个循环引擎,而它可以被嵌入到另一个更大的 StateGraph 中作为“节点”或“子图”。

LangChain 官方文档明确说明了这个模式:“你可以把整个 agent(包含中间件和所有内容)作为一个节点或子图放入更大的 StateGraph 中”。

下面是一个外层循环的示意:

from langgraph.graph import StateGraph, START, END

# 假设 inner_agent 是一个已经编译好的内层循环
# 它包含了 agent → tools → agent 的完整 ReAct 循环
inner_agent = create_react_agent(model, tools)

# 外层循环:更宏观的任务编排
def verify_result(state):
    """外层循环的验证节点:客观检查任务是否真的完成"""
    # 这里放确定性检查,比如:
    # - 运行测试套件,检查是否全绿
    # - 检查文件是否真的被修改
    # - 调用外部 API 确认状态
    test_passed = run_tests()
    if test_passed:
        return "complete"
    return "replan"

def replan(state):
    """外层循环的重规划节点"""
    # 分析失败原因,生成新的策略
    # 可能:换一种修复思路、缩小范围、请求人类帮助
    return {"messages": [HumanMessage("测试仍然失败,换一种思路")]}

# 外层循环图
outer_graph = StateGraph(OuterState)
outer_graph.add_node("agent", inner_agent)       # 内层循环作为节点
outer_graph.add_node("verify", verify_result)     # 验证节点
outer_graph.add_node("replan", replan)            # 重规划节点

outer_graph.add_edge(START, "agent")
outer_graph.add_edge("agent", "verify")
outer_graph.add_conditional_edges("verify", 
    lambda s: s["verdict"], 
    {"complete": END, "replan": "replan"}
)
outer_graph.add_edge("replan", "agent")  # 重新跑内层循环

outer_loop = outer_graph.compile()

关键区别:内层循环的 should_continue 问的是“模型还想调工具吗?”;外层循环的 verify_result 问的是“客观事实说明任务完成了吗?”前者是模型的主观判断,后者是环境的客观回答。


4. 为什么需要双层:单一循环的三个致命缺陷

4.1 缺陷一:模型的“完成感”不可信

Claude Code 团队在自己的博客中承认:“当 Claude 试图停止时,一个评估器模型检查你的条件,把它送回去继续工作,直到目标达成”。

单一循环的问题在于:模型是在同一次推理中做出“做完了”的判断的。它刚写完代码,紧接着就判断“这代码应该能行”。这是一种确认偏误——模型倾向于认为自己刚做的修改是正确的。外层循环打破了这种偏误,因为它引入了一个独立的验证步骤,这个步骤不接受模型的自我报告。

4.2 缺陷二:上下文会爆炸

内层循环每迭代一次,消息就多一批。工具调用、工具结果、模型的推理过程,全部累积在同一个上下文里。如果只有一个循环,这个上下文会无限增长,直到超出窗口限制,然后整个 Agent 崩溃或者开始产生幻觉。

双层循环的解决方案是上下文分离。Claude Code 的子代理机制是典型代表:主循环保持在“当前任务”的焦点上,而探索性的、上下文密集的工作被交给子代理,子代理完成后只返回一个简短的摘要。外层循环管理这个“上下文生命周期”——什么时候压缩、什么时候派子代理、什么时候重置。

4.3 缺陷三:没有“重新开始”的能力

内层循环是一个局部搜索。它在当前的思路、当前的工具集、当前的上下文约束下寻找解。如果这个搜索空间里根本没有解——比如模型误解了问题,或者需要的信息不在当前上下文中——内层循环会一直撞墙,直到达到步数上限。

外层循环提供了全局重启的能力。当验证失败时,外层可以:

  • 重新规划:换一个完全不同的解题思路
  • 补充上下文:告诉内层循环“你之前没看到的是……”
  • 请求人类:这是最常见也最重要的——当自动化无法收敛时,让人类接管

一位技术作者在讨论嵌套循环时提到了 Andrew Ng 的三层框架:内层是“秒到分钟”的机械验证,中层是“分钟到小时”的人类审查,外层是“小时到周”的真实世界反馈。每一层循环的“时钟”不同,越往外,验证越依赖人类判断。这恰好对应了双层循环的设计直觉:越靠内,越机械;越靠外,越需要判断力。


5. 设计你自己的双层循环:从简单开始

5.1 不要一开始就做两层

一个容易被忽视的建议来自 Anthropic 自己的工程实践:“从最简单能工作的方案开始,只在测量结果表明需要时才增加复杂度”。

如果你在构建一个 Agent,第一步应该是一个单层循环:模型调工具,工具返回结果,循环直到模型说停。这个循环能处理很多简单任务——查询信息、执行单个操作、回答基于上下文的问题。

5.2 什么时候加第二层

以下是需要外层循环的信号:

信号一:你需要“验证”而不只是“执行”。 如果任务的完成标准是模糊的(“代码质量好”),或者模型经常“以为做完了但其实没做”,你需要外层验证。Claude Code 的 /goal 指令就是为此设计的:你定义完成条件(“Lighthouse 分数达到 90 以上”),外层循环会反复把模型送回内层循环,直到条件满足或达到最大尝试次数。

信号二:上下文成为瓶颈。 如果你的 Agent 经常在长任务中“忘记”之前的信息,或者 Token 成本失控,考虑把探索性工作外包给子代理。主循环只保留决策所需的“压缩状态”,细节留在子代理的独立上下文中。

信号三:你需要“人类在环”。 如果某些操作需要审批(发送邮件、部署代码、删除数据),外层循环是插入人类审批点的天然位置。LangGraph 的 HumanInTheLoopMiddleware 就是为这个场景设计的:在工具执行前触发中断,等待人类批准或拒绝。

5.3 一个最小可行外层循环的清单

如果你决定加外层循环,从这四个组件开始:

  1. 验证器:一个确定性的检查函数。运行测试、检查文件是否存在、调用 API 确认状态。不要用模型来做这个验证。
  2. 终止条件:最大尝试次数或 Token 预算。防止无限循环。
  3. 状态持久化:用 LangGraph 的 checkpointer 或类似机制保存中间状态。这样外层循环可以在中断后恢复,而不是从头开始。
  4. 升级路径:当验证反复失败时,是重新规划、降低目标、还是请求人类?定义清楚。

6. 结语:循环的哲学

双层循环的本质,是承认一个事实:模型不知道它不知道什么。

模型可以很自信地输出“测试通过”,但它没有运行测试。模型可以很肯定地说“这个函数不会被调用”,但它没有做全局搜索。内层循环让模型高效地“做事”,但它需要一个外部的、客观的声音来回答最根本的问题:“真的吗?”

Claude Code 的官方文档里有一句话很值得玩味:“Claude 是自主工作的,但对你保持响应”。双层循环就是这个“响应性”的架构实现——外层循环是那个始终保持警惕的监督者,它不参与具体的思考,只负责一件事:在模型说“完成”的时候,它说“等一下,让我看看。”

这可能是 Agent 工程中最重要的一课:信任模型的能力,但不要信任模型的自我评估。双层循环,就是用架构来承载这个不信任的方式。