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 则为模型提供工具、状态、执行与权限。我们也知道,模型写出一条命令不等于命令已经执行,训练时学到的常见项目结构不等于当前目录的真实结构。
现在沿着这个结论再向前走一步。假设用户提出一个看起来很简单的任务:
看看当前目录中有哪些文件,找到项目入口,然后用两三句话说明这个项目怎样启动。
如果只调用一次模型,模型可以在回答前制定一个合理方案:
- 先查看目录;
- 如果有
README.md,读取启动说明; - 如果有
pyproject.toml,检查脚本入口; - 如果有
main.py,阅读主函数; - 综合这些信息给出答案。
问题是,这个方案中的第二步依赖第一步结果。模型在看到目录之前,不知道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。失败不再只是终止信号,而是一份会影响后续选择的新状态。
所以模型不能一次决定完整任务,不是因为它一定不会规划,而是因为开放任务中的关键事实尚未出现。一个可靠系统需要反复完成四件事:
- 根据当前状态选择动作;
- 在模型外部执行或拒绝动作;
- 把结果作为 observation 加入状态;
- 根据更新后的状态继续或结束。
这四件事构成反馈链。下一节要解释的 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。
闭环中任何一条边断开都会改变系统性质。没有 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——程序如何反复调用、执行、回填和停止
三层可以同时服务一条任务轨迹,但每层回答的问题不同。把它们上下排列,能清楚看到“方法、交接、控制”之间的协作关系。
“协作,不等同”是阅读后续代码时的保护栏。看到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 改变。
因此,“用了模型”不能直接判断是不是 Agent。真正要检查的是模型是否获得了新状态,并据此控制后续过程。
接着看 Loop 为什么不能只是一个裸while True。一个可控循环至少要识别几类出口。
第一类是正常完成。模型认为已有信息足够,返回 final answer,不再请求工具。Loop 保存最终输出并结束。
第二类是需要继续。模型产生一个或多个工具调用。Harness 校验并执行,把每份结果与原调用配对,追加到状态后再次调用模型。
第三类是动作失败。参数无效、工具不存在、权限拒绝、进程报错都可能发生。生产系统不应把所有失败都吞掉后继续,也不应让进程无说明崩溃。失败需要被分类,并决定回填给模型、重试、请求人工处理或直接结束。
第四类是强制停止。即使模型持续请求工具,Harness 也必须能够依据最大轮次、时间、Token、费用或风险阈值终止。OpenAI Agents SDK 当前 Runner 文档明确列出max_turns;Anthropic 的工程建议同样强调最大迭代和受控环境。停止条件不是对模型不信任,而是任何自动化系统都需要的控制面。
四类出口围绕同一个“本轮模型输出”分叉。只有 Tool Call 分支会在 observation 回填后回到下一轮;完成、失败和达到上限都必须进入明确的结束或处理路径。
这使 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 协议中的messages、tools、tool_calls、function.arguments和tool_call_id。原因是这些字段回答“某种接口怎样传递对象”,而本篇先回答“为什么系统需要这些对象”。第 01 篇进入代码时,字段将各自找到位置,不会变成要死记的陌生 JSON。
四、怎样从抽象循环走到第一个程序
理解原理后,下一步不是立刻堆出完整生产平台,而是找到最小可运行骨架。我们需要把前面出现的抽象对象映射到代码责任:
| 抽象对象 | 程序中的责任 |
|---|---|
| goal | 接收用户输入,形成第一条状态 |
| available actions | 向模型描述可请求的工具 |
| model decision | 调用模型并读取普通回答或工具调用 |
| action execution | 根据工具名找到 handler 并执行 |
| observation | 把执行结果写回消息状态 |
| feedback loop | 带着扩展后的状态再次调用模型 |
| stop | 在最终回答、失败或限制条件下退出 |
从原理走向代码时,每个抽象对象都应找到唯一责任。第 01 篇会把 Goal 放进messages,把 Action 读成tool_calls,由 Bash handler 产生返回结果,再用退出条件收口。
这组映射不是完整实现,却提供了阅读顺序:先追踪状态,再看调用意图,随后确认真实执行与结果回填,最后检查退出分支。
为了让后面 20 个案例形成连续学习,我们还会使用一张六层 Harness 代码地图。
第一层是模型交互层。它负责把 system instructions、用户目标、历史状态和工具说明提交给模型,再取得本轮响应。这里回答“模型看见了什么,返回了什么”。
第二层是循环与状态层。它负责保存每一轮消息、判断本轮是 final answer 还是 action、把 observation 加回状态并控制继续或退出。这里回答“任务为什么没有在第一次调用后结束”。
第三层是工具执行层。它把模型提出的结构化 action 映射到真实 handler。第 01 篇只有一个 Bash handler,后续才会扩展成多个原子工具和分发表。
第四层是上下文与知识层。它处理历史不断增长、知识按需加载、记忆持久化和上下文压缩。最小循环暂时没有这些能力,但工具结果越来越多后,它们会成为新问题。
第五层是任务与协作层。它处理计划、子智能体、团队消息、依赖图和并发。单个循环能完成小任务,复杂任务则需要把工作拆开又重新汇合。
第六层是安全与扩展层。它包含权限、审批、Hook、沙箱、审计和 MCP 等外部能力路由。模型能提出什么与系统允许执行什么必须保持分离。
第 01 篇只进入前三层的一小部分:
- 用一个
messages列表保存用户输入、assistant 消息和工具结果; - 调用一次 OpenAI-compatible Chat Completions 接口;
- 向模型提供一个 Bash Tool Schema;
- 读取模型返回的
tool_calls; - 用 Bash handler 执行命令;
- 通过匹配的调用 ID 把结果追加为工具消息;
- 如果模型不再请求工具,就输出最终回答并退出。
这些结构会第一次出现在真实 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 怎样被调用、工具结果怎样回填,以及模型最终不再请求工具时程序怎样结束。那时看到的每一行代码,都会落回本篇已经建立的反馈链。