0基础学会Agent Harness工程(前置知识二):从ReAct到Agent Loop

本篇对应的官方文档

  • ReAct 论文:用于确认 ReAct 的原始问题与基本关系——reasoning 和 task-specific action 交错出现,action 从外部环境获得新信息。
  • Google Research:ReAct:用于区分 reasoning trace 与真实环境动作,并说明 observation 怎样反过来更新后续判断。
  • Anthropic:Building effective agents:用于说明 Agent 为什么需要环境 ground truth、反馈循环和停止条件。
  • OpenAI Agents SDK:Running agents:用于观察现代 Runner 怎样在 final output、handoff、tool calls 和最大轮次之间管理循环。

本篇主要内容
前置知识一已经把 Model 与 Harness 分开,但“模型提出动作、Harness 负责执行”仍只是一条静态分工。本篇沿“查看目录后再选择文件”这次任务,定位单次调用在新环境状态处为什么会断开;再解释 ReAct 中 reasoning、action、observation 如何形成反馈。随后严格区分 ReAct 方法范式、Tool Calling 结构化交接和 Agent Loop 控制结构,并补上循环继续、正常完成、失败与强制停止的边界,最后把抽象原理交给第 01 篇代码。

下篇预告
第 01 篇会把这条反馈链写成最小 Python 程序:一个 Bash handler、一个持续增长的 messages 列表,以及一个能够执行、回填并退出的 agent_loop()。

一、模型为什么不能一次决定完整任务

前置知识一已经建立了最重要的对象边界:大模型接收上下文并生成输出,Harness 则为模型提供工具、状态、执行与权限。我们也知道,模型写出一条命令不等于命令已经执行,训练时学到的常见项目结构不等于当前目录的真实结构。

现在沿着这个结论再向前走一步。假设用户提出一个看起来很简单的任务:

看看当前目录中有哪些文件,找到项目入口,然后用两三句话说明这个项目怎样启动。

如果只调用一次模型,模型可以在回答前制定一个合理方案:

  1. 先查看目录;
  2. 如果有 README.md,读取启动说明;
  3. 如果有 pyproject.toml,检查脚本入口;
  4. 如果有 main.py,阅读主函数;
  5. 综合这些信息给出答案。

问题是,这个方案中的第二步依赖第一步结果。模型在看到目录之前,不知道 README.md 是否存在;看到目录之后,也可能发现项目使用 src/ 布局,真正入口写在 pyproject.toml[project.scripts] 中。任务不是“一次猜对五步”,而是“每获得一份新事实,就重新判断下一步”。

可以把单次调用的断点写成:

用户目标
→ 模型建议 list_directory
→ 调用结束
→ 真实目录仍未进入模型上下文

如果外部程序执行 list_directory,却不把结果交还模型,断点只是向后移动了一格:

用户目标
→ 模型建议 list_directory
→ Harness 执行动作
→ 得到 src/、tests/、pyproject.toml
→ 模型仍然看不到结果

断点并不在工具有没有运行,而在结果有没有成为下一轮状态。真实目录已经存在于 Harness 一侧,但返回模型的反馈箭头仍然是断开的。

单次调用在真实目录结果未回填时产生的新状态断点
只要“未回填”仍然存在,模型判断就停留在旧状态。把工具接进系统只是获得手,能够把 observation 接回上下文才形成反馈。

因此,“系统有工具”还不够。工具结果必须成为下一次模型调用可以看到的新状态。

我们可以用一组静态消息理解状态为什么重要。初始时,模型只知道目标:

用户:查看项目入口并说明启动方式。

第一次动作执行后,系统新增了事实:

目录结果:src/tests/pyproject.toml

此时合理的下一步已经变化。模型不应该继续寻找并不存在的 main.py,而应读取 pyproject.toml 或搜索 src/。也就是说,动作不仅完成了一个步骤,还改变了后续决策的依据。

这里出现了 Agent 任务与普通一次性生成的核心差别:

  • 一次性生成是在固定上下文上计算输出;
  • Agent 任务会通过动作获得新 observation;
  • observation 改变上下文;
  • 新上下文可能改变计划和下一步动作。

为什么不能让模型一开始就输出完整计划,然后由程序照着执行?有时当然可以,这就是固定 Workflow 或“先规划、后执行”的一种实现。但只要后续步骤依赖未知结果,静态计划就必须允许修改。目录不存在、文件内容与预期不同、命令失败、权限被拒绝,都会让原计划失效。真正需要 Agent 的场景,往往正是路径不能在运行前完全确定。

再看一个更明显的例子。模型预想:

读取 README.md → 找到安装命令 → 运行测试。

真实目录返回:

README.md 不存在,但有 docs/getting-started.md

如果系统机械执行旧计划,第二步就会失败。如果系统把失败作为 observation 回填,模型可以调整为读取 docs/getting-started.md。失败不再只是终止信号,而是一份会影响后续选择的新状态。

所以模型不能一次决定完整任务,不是因为它一定不会规划,而是因为开放任务中的关键事实尚未出现。一个可靠系统需要反复完成四件事:

  1. 根据当前状态选择动作;
  2. 在模型外部执行或拒绝动作;
  3. 把结果作为 observation 加入状态;
  4. 根据更新后的状态继续或结束。

这四件事构成反馈链。下一节要解释的 ReAct,就是理解“为什么推理与行动要交错,而不是各做各的”的一把钥匙。

二、ReAct 为什么把行动结果重新交给模型

ReAct 来自 Reasoning 与 Acting 的结合。ReAct 论文关注的核心问题是:语言模型的推理能力和行动能力过去经常被分开研究。只推理时,模型容易困在自身已有信息里;只行动时,系统又可能缺少对目标、计划和异常的高层判断。ReAct 让模型以交错方式生成 reasoning traces 与 task-specific actions,使行动取得的外部信息能够支持后续推理,推理又能指导下一次行动。

对零基础读者来说,不必先记论文实验和基准成绩。先分清三个对象:

Reasoning 表示模型依据当前上下文形成的判断。它可以用于拆解目标、选择下一步、追踪进度或处理异常。需要特别说明的是,现代模型的隐藏内部推理不需要、也不应被强制公开。本系列讲 reasoning 时,关注的是系统可观察的决策依据和任务状态,不要求展示模型私有思维链。

Action 表示面向外部环境的动作,例如搜索、列目录、读文件、运行命令或调用 API。模型可以提出 action,但真正产生副作用的是 Harness 或环境中的执行器。

Observation 表示动作执行后返回的环境信息,例如目录列表、文件内容、命令输出、错误消息或权限拒绝。Observation 不是模型凭空生成的“可能结果”,而应来自真实执行或明确的模拟环境。

Google Research 对 ReAct 的说明给出一个非常清楚的边界:reasoning trace 不直接改变外部环境,action 才会带来 environment observation;observation 随后更新模型可以利用的上下文。把它压缩成一条链就是:

当前目标与状态
→ reasoning:下一步需要知道目录结构
→ action:请求列出目录
→ environment:执行目录读取
→ observation:返回真实文件列表
→ 更新状态
→ 下一次 reasoning

四个对象形成的是有方向的闭环:reasoning 只负责判断,action 才进入环境,environment 返回 observation,observation 再更新下一次 reasoning。

Reasoning、Action、Environment 与 Observation 组成的 ReAct 反馈循环
闭环中任何一条边断开都会改变系统性质。没有 action 就得不到新事实,没有 observation 回流就无法依据结果调整,而没有新的 reasoning 则退化为预定动作序列。

现在静态推演“找到项目入口”这次任务。

第一轮,模型只知道用户目标。它无法确认入口文件,于是产生一个可观察的决策:先获取目录结构。随后提出 action:

list_directory(path=".")

Harness 执行动作,环境返回:

src/tests/pyproject.toml

这份结果成为 observation。第二轮模型看到 observation 后,不再猜测 main.py,而是判断:项目采用包结构,入口可能声明在 pyproject.toml。它提出新的 action:

read_file(path="pyproject.toml")

第二次 observation 返回:

[project.scripts] demo = "demo.cli:main"

现在模型已经有足够事实形成 final answer:

项目入口是 demo.cli:main,对外命令名为 demo。安装项目后可在终端运行 demo 启动。

这条轨迹中最重要的不是工具名,而是状态变化:

时刻 模型已经知道什么 仍然缺少什么 下一步
S0 用户想找入口 当前目录结构 列目录
S1 存在 pyproject.toml 配置中的入口声明 读配置
S2 入口为 demo.cli:main 已无关键缺口 给出最终回答

如果 observation 没有回填,S0 永远不会变成 S1。程序即使在后台成功列出了目录,模型仍会重复请求、继续猜测,或者错误地认为动作已经完成。反馈链的价值就在于让外部事实真正参与下一次决策。

失败也应当作为 observation 进入反馈。假设读取 pyproject.toml 时返回:

PermissionError: access denied

这不是可以随手丢掉的日志。它告诉模型“目标文件存在,但当前权限不允许读取”。下一步可能是请求用户授权、寻找其他公开说明,或者停止并说明无法确认入口。只有模型看到失败,才有机会根据失败调整。

再假设工具返回一个空目录。模型可能判断当前工作目录错误,请求查看父目录;也可能直接询问用户项目位置。Observation 不保证任务成功,它只保证后续判断建立在最新事实之上。

这里还要避免一个常见误解:ReAct 不是“让模型不停自言自语”。如果系统没有外部 action 和 observation,再长的推理文本仍然不会获得新事实;如果系统只执行动作但不把结果回填,动作也无法改进后续判断。ReAct 的重点是 reasoning 与 acting 相互支撑。

另一个误解是把每一步 reasoning 都当成必须展示给用户的完整思维过程。原始 ReAct 工作研究了显式 reasoning trace,但现代产品可以只保存必要的任务摘要、动作理由或状态字段。对 Harness 工程来说,真正不可缺少的是可验证的 action、来自环境的 observation 和可追踪的状态变化,而不是暴露模型隐藏推理。

这一节解决的是原理问题:为什么需要把行动结果重新交给模型。它还没有回答具体协议怎样表达 action,也没有回答谁来重复调用模型。为此,需要把三个经常混用的概念拆开。

三、ReAct Tool Calling 与 Agent Loop 的分工

在很多教程里,ReAct、工具调用和 Agent Loop 会在同一段代码中出现,于是初学者容易把它们当成三个名字。它们确实可以协作,但分别处于不同层。

ReAct 是方法范式。

它回答:“为什么推理与行动要交错,外部反馈怎样更新后续判断?”它描述的是任务轨迹与信息关系,不规定必须使用哪家 API,也不要求 Python 函数必须怎样命名。

Tool Calling 是结构化交接。

它回答:“模型怎样把‘我想调用某个工具’表达成程序可以解析的数据?”相比让模型在自然语言里写“请执行 Get-ChildItem”,结构化调用通常会明确工具名、参数和调用标识。Harness 可以校验这些字段,再决定是否路由到真实 handler。

但 Tool Calling 只表达调用意图。模型返回:

tool_name = "list_directory"arguments = {"path": "."}

不代表目录已经读取。只有 Harness 找到相应 handler、通过权限检查并实际执行后,环境才会产生 observation。

Agent Loop 是运行控制结构。

它回答:“谁保存当前状态,谁调用模型,谁执行工具,谁回填结果,以及何时继续或停止?”Loop 把模型调用、工具执行和 observation 回填接成可重复过程。它通常属于 Harness,而不是模型参数的一部分。

可以用三层图在脑中定位:

上层:ReAct——任务如何在判断、行动和反馈中推进

中层:Tool Calling——行动意图如何跨过模型与程序边界

下层:Agent Loop——程序如何反复调用、执行、回填和停止

三层可以同时服务一条任务轨迹,但每层回答的问题不同。把它们上下排列,能清楚看到“方法、交接、控制”之间的协作关系。

ReAct 方法范式、Tool Calling 结构化交接与 Agent Loop 运行控制的三层边界
“协作,不等同”是阅读后续代码时的保护栏。看到 tool_calls 只能证明协议表达了 action;还要继续寻找 handler、结果回填和退出条件,才能确认 Loop 是否完整。

三者之间是组合关系,不是同义关系。

一个系统可以有 Tool Calling,却没有 Agent Loop。例如模型产生一次工具调用,程序执行后直接把结果返回给用户,不再询问模型。这是一次结构化工具使用,但不是完整反馈循环。

一个系统也可以有循环,却不采用 ReAct。程序可能固定调用模型三次,每次做同一个改写任务;它有 for 循环,却没有模型根据 observation 动态选择 action。

ReAct 也不依赖某个特定 Tool Calling 字段。论文中的 action 可以是文本动作,由环境解析;现代 API 则常用结构化调用提高交接可靠性。具体表示变化,不影响 reasoning、action、observation 的基本关系。

前置知识一用下一步控制权区分 Workflow 与 Agent。现在可以把这个差别放进循环:

固定 Workflow 可能写成:

列目录 → 读 README → 读配置 → 总结

每个箭头由开发者预先规定。即使其中某一步调用 LLM,整体路径仍由代码控制。

模型驱动 Agent 更接近:

当前状态 → 模型选择 action → 执行 → observation → 当前状态

开发者定义工具和边界,模型根据 observation 决定下一步。这里的“模型控制”也不是无限权力。Harness 仍然可以拒绝危险调用、限制目录范围、要求人工批准或在超出预算时停止。

固定路径和反馈回环可以放在同一视野下比较。左侧的 A、B、C 在运行前已经确定;右侧的下一步则会随着 observation 改变。

Workflow 的预定义路径与 Agent 的动态反馈控制权对照
因此,“用了模型”不能直接判断是不是 Agent。真正要检查的是模型是否获得了新状态,并据此控制后续过程。

接着看 Loop 为什么不能只是一个裸 while True。一个可控循环至少要识别几类出口。

第一类是正常完成。模型认为已有信息足够,返回 final answer,不再请求工具。Loop 保存最终输出并结束。

第二类是需要继续。模型产生一个或多个工具调用。Harness 校验并执行,把每份结果与原调用配对,追加到状态后再次调用模型。

第三类是动作失败。参数无效、工具不存在、权限拒绝、进程报错都可能发生。生产系统不应把所有失败都吞掉后继续,也不应让进程无说明崩溃。失败需要被分类,并决定回填给模型、重试、请求人工处理或直接结束。

第四类是强制停止。即使模型持续请求工具,Harness 也必须能够依据最大轮次、时间、Token、费用或风险阈值终止。OpenAI Agents SDK 当前 Runner 文档明确列出 max_turns;Anthropic 的工程建议同样强调最大迭代和受控环境。停止条件不是对模型不信任,而是任何自动化系统都需要的控制面。

四类出口围绕同一个“本轮模型输出”分叉。只有 Tool Call 分支会在 observation 回填后回到下一轮;完成、失败和达到上限都必须进入明确的结束或处理路径。

Agent Loop 的继续、正常完成、执行失败与达到上限四类出口
这使 Loop 从无限重复变成可审计的状态机。生产系统还会细分失败类型,但最小课程骨架先保留这四个方向已经足够。

前面的四类出口落实到控制结构后,可以用下面这段静态伪代码表达状态、继续条件和停止条件:

state = [user_goal]

repeat:
    decision = model(state, available_tools)
    state.append(decision)

    if decision is final_answer:
        return final_answer

    if turn_limit_reached:
        stop_with_limit_error

    for each action in decision:
        result = validate_and_execute(action)
        state.append(observation_for(action, result))

这段伪代码有意不写具体 API 字段。我们只看职责:

  • state 保存到目前为止的任务事实;
  • model 根据当前状态选择回答或 action;
  • validate_and_execute 属于 Harness,不属于模型;
  • observation_for 把结果接回对应动作;
  • final answer 和 turn limit 都能结束循环。

如果删掉 state.append(observation...),循环会失去反馈;如果删掉 final answer 判断,系统不知道正常完成;如果删掉限制,错误动作可能无限重复。Loop 的本质不是重复语法,而是带状态反馈和退出条件的控制结构

到这里,我们还没有展开 OpenAI-compatible 协议中的 messagestoolstool_callsfunction.argumentstool_call_id。原因是这些字段回答“某种接口怎样传递对象”,而本篇先回答“为什么系统需要这些对象”。第 01 篇进入代码时,字段将各自找到位置,不会变成要死记的陌生 JSON。

四、怎样从抽象循环走到第一个程序

理解原理后,下一步不是立刻堆出完整生产平台,而是找到最小可运行骨架。我们需要把前面出现的抽象对象映射到代码责任:

抽象对象 程序中的责任
goal 接收用户输入,形成第一条状态
available actions 向模型描述可请求的工具
model decision 调用模型并读取普通回答或工具调用
action execution 根据工具名找到 handler 并执行
observation 把执行结果写回消息状态
feedback loop 带着扩展后的状态再次调用模型
stop 在最终回答、失败或限制条件下退出

从原理走向代码时,每个抽象对象都应找到唯一责任。第 01 篇会把 Goal 放进 messages,把 Action 读成 tool_calls,由 Bash handler 产生返回结果,再用退出条件收口。

Goal、Action、Observation、Stop 到第 01 篇 Python 对象的映射
这组映射不是完整实现,却提供了阅读顺序:先追踪状态,再看调用意图,随后确认真实执行与结果回填,最后检查退出分支。

为了让后面 20 个案例形成连续学习,我们还会使用一张六层 Harness 代码地图。

第一层是模型交互层。它负责把 system instructions、用户目标、历史状态和工具说明提交给模型,再取得本轮响应。这里回答“模型看见了什么,返回了什么”。

第二层是循环与状态层。它负责保存每一轮消息、判断本轮是 final answer 还是 action、把 observation 加回状态并控制继续或退出。这里回答“任务为什么没有在第一次调用后结束”。

第三层是工具执行层。它把模型提出的结构化 action 映射到真实 handler。第 01 篇只有一个 Bash handler,后续才会扩展成多个原子工具和分发表。

第四层是上下文与知识层。它处理历史不断增长、知识按需加载、记忆持久化和上下文压缩。最小循环暂时没有这些能力,但工具结果越来越多后,它们会成为新问题。

第五层是任务与协作层。它处理计划、子智能体、团队消息、依赖图和并发。单个循环能完成小任务,复杂任务则需要把工作拆开又重新汇合。

第六层是安全与扩展层。它包含权限、审批、Hook、沙箱、审计和 MCP 等外部能力路由。模型能提出什么与系统允许执行什么必须保持分离。

第 01 篇只进入前三层的一小部分:

  1. 用一个 messages 列表保存用户输入、assistant 消息和工具结果;
  2. 调用一次 OpenAI-compatible Chat Completions 接口;
  3. 向模型提供一个 Bash Tool Schema;
  4. 读取模型返回的 tool_calls
  5. 用 Bash handler 执行命令;
  6. 通过匹配的调用 ID 把结果追加为工具消息;
  7. 如果模型不再请求工具,就输出最终回答并退出。

这些结构会第一次出现在真实 Python 文件 s01_agent_loop.py 中。阅读时不应从第一行开始背配置,而应先找到 agent_loop(),沿状态变化问四个问题:

  • 进入循环前,messages 中有什么?
  • 模型响应怎样被完整保存?
  • 什么条件进入 handler,什么条件直接结束?
  • handler 输出怎样回到下一轮?

这样读代码时,每个字段都能对应前面已经理解的对象。

例如 messages 不是“聊天 UI 的展示记录”,而是反馈链的状态载体;tools 不是 Python 函数本身,而是模型可见的能力说明;tool_calls 不是执行结果,而是 action 意图;role="tool" 消息承载 observation;tool_call_id 则保证结果能找到它所对应的 action。

这也解释了为什么第 01 篇从 Bash 开始。Bash 能快速触达文件系统和命令行,用一个 handler 就可以观察完整闭环。如果第一篇同时加入读文件、写文件、搜索、权限、重试和并发,读者很难判断究竟是哪一层让 Agent 动了起来。最小案例故意把其他能力拿掉,只证明:

模型提出 action
→ Harness 执行
→ observation 回填
→ 模型根据新状态继续

当然,一个 Bash handler 不等于安全的生产系统。Shell 命令可能读取错误目录、修改文件、启动子进程或产生巨大输出。字符串黑名单、简单超时也不能构成完整权限模型。本系列会把“能够执行”和“允许执行”始终分开。第 01 篇证明最小机制,第 03 篇以后再逐步加入计划、子智能体、技能、上下文、任务系统、权限与 Hook。

在进入代码前,还可以用四条失败路径检查自己是否真正理解了 Loop。

第一,模型重复请求同一个动作。 可能是 observation 没有回填,也可能是结果不够清楚。Harness 需要记录状态,并在生产环境中考虑重复检测和最大轮次。

第二,工具执行成功但调用 ID 丢失。 模型可能无法确认哪份结果对应哪个 action。并行工具调用时,这种错误更加明显。

第三,模型给出普通回答,程序却继续循环。 说明退出条件没有绑定 final answer 或“没有工具调用”的状态。

第四,工具失败后程序直接崩溃。 说明错误没有形成受控 observation,也没有交给停止策略处理。

能够说明这四种失败分别发生在哪一层,就已经具备阅读第一个 Agent Loop 的知识准备。

回顾前置知识二的主线:

单次模型调用在动作后的真实状态处断开;
ReAct 说明 reasoning 与 action 为什么需要通过 observation 相互支持;
Tool Calling 负责结构化表达 action;
Agent Loop 负责执行、回填、继续和停止;
Harness 把模型能力放进一个可运行、可控制的环境。

下一篇不再停留在抽象图。第 01 篇会打开 s01_agent_loop.py,用一个具体请求——“查看当前目录中有哪些 Python 文件”——跟踪 messages 怎样增长、Bash handler 怎样被调用、工具结果怎样回填,以及模型最终不再请求工具时程序怎样结束。那时看到的每一行代码,都会落回本篇已经建立的反馈链。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐