从一行 stop 参数出发,理解模型、工具与运行程序的边界。
我学习 ReAct 时,最先卡住的地方不是代码,而是一张流程图:任务交给模型,模型生成动作,环境返回结果,然后模型继续生成动作。
这不就是普通 Agent 的交互循环吗?如果它已经会根据反馈行动,ReAct 到底增加了什么?
后来,我又遇到了两个问题:为什么论文里 ReAct 的 HotpotQA 成绩低于 CoT?为什么一段很短的 Python 代码,要特意阻止模型生成 Observation:?
这篇文章沿着这些问题展开。前半部分理解方法,后半部分拆解实现,再回过头判断它适合什么任务。代码中的轨迹使用教学模拟数据;论文数字来自原文,不是我的复现实验。
1. 有交互循环,就算 ReAct 吗?
先把三件事分开:模型可以推理,可以选择动作,也可以通过工具获得外部信息。它们并不天然等价。

图 1:概念示意。蓝色表示模型侧处理,绿色表示环境信息;Act-only 没有显式语言推理轨迹,不意味着模型内部没有计算。
在原论文的比较中,CoT 保留推理,Act-only 保留行动与观察,ReAct 则将语言推理轨迹和任务动作交错起来。推理可以整理信息、跟踪进度、修改计划;行动让模型接触到当前上下文之外的信息。[1]
我最初把区别理解成“有没有打印 Thought”。这个判断太表面。原论文确实使用显式语言轨迹,但在较长的决策任务里,Thought 可以只出现在关键位置,不要求每个动作前都写一段。
同时,不能看到今天一个系统连续调用工具,就断言它严格复现了原论文。更稳妥的说法是:它采用了类似 ReAct 的动态交互方式;是否使用显式推理、如何组织轨迹,还要看具体实现。
我现在理解的核心是:推理指导行动,行动带回的信息又改变后续推理。 这个过程不是简单地把一份预先生成的行动清单执行到底。
2. 用一次失败的查询,看懂控制权交接
设想一个教学任务:核实虚构论文《ExampleAgent》的会议年份。我们准备一个本地资料库,让第一次宽泛查询返回同名提示,第二次精确查询返回会议记录。
下面的“决策说明”是为了讲解而写的简短示例,不是某个模型真实内部思维的记录。
| 轮次 | 模型侧的决策说明与动作 | 运行程序取得的结果 |
|---|---|---|
| 1 | 先查询论文名称;search[ExampleAgent] | 同名记录不止一条,请补充完整标题 |
| 2 | 当前结果不足以确定年份;search[ExampleAgent: A Toy Study] | 本地模拟会议记录:ExampleConf 2024 |
| 3 | 信息已足够;finish[2024] | 程序结束,不再执行搜索 |

图 2:三条泳道分别表示模型、运行程序和工具。模型输出动作后,控制权必须回到程序,才会发生真实调用。
这个例子里最重要的动作,其实是第二次查询。它不是第一轮提前确定的,而是由新反馈触发的。
但模型写出 search[...] 并不会自动连接搜索引擎。运行程序还要解析名称和参数,找到对应函数,执行它,再将返回值加入上下文。文本中出现工具调用,和工具确实执行,是两件事。
这也是排查 Agent 时应该保存调用日志的原因。只看一段流畅的回答,无法确认其中的“搜索结果”是否真的来自搜索。
3. ReAct 与 Reflexion:一个执行循环,可以被放进更大的循环
我最初的讨论从 Reflexion 开始,因此也需要交代它和 ReAct 的关系。
ReAct 关注任务进行中怎样交错推理与行动;Reflexion 则利用反馈形成文字反思,并将经验保留给后续尝试。它们可以组合:执行任务的 Actor 采用 ReAct 式循环,外层再加入评估、反思和记忆。[2]

图 3:组合关系的教学示意,不表示所有 Reflexion 实现都必须采用相同的组件连接方式。
| 对比项 | ReAct | Reflexion |
|---|---|---|
| 主要关注 | 当前尝试的下一步行动 | 怎样利用反馈改善后续尝试 |
| 典型信息 | 当前轨迹和工具结果 | 评估反馈、文字反思和保留的经验 |
| 需要避免的误解 | 有工具就自动等于 ReAct | 多生成一句“我反思一下”就完成了学习 |
二者都不能保证成功。错误反思也可能被保留下来;当前历史和跨尝试记忆都需要明确管理。
4. 为什么能搜索,HotpotQA 成绩反而更低?
原论文的 PaLM-540B 提示实验给出了下面的结果。[1]

图 4:HotpotQA 答案 EM,单位为百分比。CoT-SC 使用 21 条采样轨迹,不能视为与单轨迹方法等预算。这里只转录原论文 Table 1,不代表当前模型的表现。
我一开始容易把“能查资料”理解成“应该答得更准”。但增加信息来源,也增加了查询、执行和理解结果的失败机会。
论文的人工错误分析观察到了无信息搜索、重复动作和推理错误等问题。因此,外部证据更充分与最终答案更准确,并不是同一个指标。作者也研究了 ReAct 与 CoT-SC 的回退组合,而不是要求每个问题都始终使用一种方式。[1]
这里有三个值得修正的直觉。
第一,EM 不是原始字符串一字不差地比较。 HotpotQA 的官方脚本会进行大小写、标点、英文冠词和空白归一化,但归一化后仍要求答案匹配;它不是开放式的语义评分。[3]
第二,可能的解释不等于实验已经证明的原因。 “模型本来就知道”“搜索过多把答案带偏”可以帮助理解失败机制,但不能凭想象认定它们解释了这组数字。
第三,多步交互不是简单的独立概率连乘。 有些步骤失败后可以恢复,有些路径可以替代,有些错误彼此相关。用每步成功率直接相乘,只能解释非常受限的假设场景。
我会把这个结果记成一个工程提醒:工具扩展能力,也引入新的失败位置。评价时应同时记录任务成功率、工具次数、耗时、成本和失败原因。
5. 教学版 ReAct 循环:约 50 行的实现骨架
这是我学习时读到的代码。保留它是因为结构足够直接,但它不是复制后就能完成真实任务的程序:llm 未提供,三个工具函数也是占位符。下面修正了工具表注释,其余核心控制逻辑保留。
import re
from typing import Callable, Dict
# 占位接口:真实使用时必须实现
# python 工具需要真正的隔离与资源限制,注释不等于实现
def search_engine(q: str) -> str: ...
def kb_lookup(key: str) -> str: ...
def run_python(code: str) -> str: ...
# 工具名称 -> 执行函数;这里尚无参数 schema 或工具说明
TOOLS: Dict[str, Callable[[str], str]] = {
"search": lambda q: search_engine(q),
"lookup": lambda key: kb_lookup(key),
"python": lambda code: run_python(code),
"finish": lambda ans: ans,
}
REACT_PROMPT = """You are a ReAct agent. At each step, output:
Thought: <brief decision rationale>
Action: <tool>[<arg>]
Tools: search, lookup, python, finish.
End with Action: finish[<answer>].
Question: {question}
"""
ACTION_RE = re.compile(r"Action:\s*(\w+)\[(.+?)\]", re.DOTALL)
def react_loop(llm, question, max_steps=8):
history = REACT_PROMPT.format(question=question)
for step in range(max_steps):
out = llm(history, stop=["Observation:", "Question:"])
history += out
m = ACTION_RE.search(out)
if not m:
return None, history, "parse_fail"
name = m.group(1).strip().lower()
arg = m.group(2).strip()
if name == "finish":
return arg, history, "ok"
if name not in TOOLS:
obs = f"[Error] Unknown tool {name}."
else:
try:
obs = str(TOOLS[name](arg))[:512]
except Exception as e:
obs = f"[Error] {type(e).__name__}: {e}"[:256]
history += f"\nObservation: {obs}\n"
return None, history, "max_steps"5.1 历史是输入,不是自动存在的记忆
history 最开始只有协议和问题。随后,程序把模型输出与工具返回不断追加进去。下一轮调用时,模型才能看到前面的过程。
因此这里的历史可以作为当前尝试的上下文状态,但不自动构成持久记忆。进程结束后保存在哪里、下一次是否读取、长历史怎样筛选,这段代码都没有处理。
5.2 stop 停止生成,不是停止整个程序
我反复追问的是这一行:
out = llm(history, stop=["Observation:", "Question:"])history 决定输入;stop 请求模型接口在遇到指定字符串时结束本次生成。它是否受支持、匹配字符串是否保留,要看具体接口契约。
本次模型调用结束后,Python 仍然继续执行解析、工具调用和下一轮循环。这里交接的是控制权。
在文本续写的实现里,我们希望模型返回动作后就交还控制权,避免把自己生成的 Observation 当成工具结果。但是,“没有 stop 就几乎一定崩”太绝对。结构化工具调用也可以明确这个边界;而仅有 stop,也不能保证输出合法、答案正确或外部内容可信。
5.3 解析之后,动作才变成函数调用
如果模型返回 Action: search[ExampleAgent],正则提取出 name="search" 与 arg="ExampleAgent"。随后 TOOLS[name](arg) 才真正执行函数。
这份正则有明显限制。例如:
Action: python[items[0]]非贪婪匹配会在第一个右方括号停止,提取的参数不是完整的 items[0]。它也不接受空参数,并且只取第一个匹配动作。给代码工具传参时,这些都不是小问题。
5.4 回填 Observation,循环才闭合
history += f"\nObservation: {obs}\n"这句把工具返回加入下一轮输入。没有它,程序即使执行了搜索,模型也不会自动知道结果。
Observation 可以是资料,也可以是可恢复的错误。但来自工具不意味着内容必然正确,更不意味着其中的文字拥有修改任务和权限的资格。
5.5 finish 代表结束声明,不代表通过验收
finish 在分支里直接返回,所以工具表中的 finish 函数实际上不会执行。它在这里是终止动作。
返回状态 "ok" 容易造成误解:它只表示模型按照协议结束了。答案正确吗?任务要求的文件存在吗?修改后的测试通过了吗?这些需要另外验证。
6. 再看这段代码:哪些地方还不能直接用于实际服务?

图 5:执行前校验、执行中约束、执行后记录与验收。循环不是完整系统,运行程序还要承担明确的控制责任。
| 容易踩的坑 | 为什么会发生 | 改进方向 |
|---|---|---|
| 模型写出假的结果 | 文本续写可能模仿完整轨迹 | 运行程序单独写入工具结果,记录调用来源 |
| 动作解析错误 | 方括号嵌套、多动作、格式漂移 | 明确 schema、严格校验、有限修复 |
| 工具报错后原地重试 | 错误没有分类,策略未改变 | 区分参数、网络、权限与程序缺陷 |
| 无限或重复行动 | 下一步没有带来新信息 | 步数预算,加结果与进展检查 |
| 单次调用一直阻塞 | 步数限制不会中断当前函数 | 工具超时与任务总时限 |
| 长历史超出预算 | 只截断单条结果,历史仍累积 | 总 token 预算、选择相关片段、必要时摘要 |
| 模型过早结束 | 结束动作被当作任务成功 | 根据任务定义检查实际结果 |
[:512] 限制的是字符数,不是 token 数;摘要也可能遗漏关键证据,不必每次都再启动一个 Agent。优先让工具返回相关、紧凑且带来源的字段。
异常处理同样需要边界。临时网络错误可以有限重试,参数错误可以反馈修改;权限错误不应靠反复尝试绕过。程序缺陷需要记录和处理,而不是一律交给模型。直接回传完整异常还可能暴露内部路径或凭证。
涉及有副作用的工具时,超时尤其需要谨慎:没有收到响应,不代表操作没有完成。是否重试,应结合结果查询和幂等机制。重试次数有限,并不能消除重复执行的后果。
7. 从骨架到可运行演示:先验证控制流程
随本文提供的 react_demo.py 使用本地模拟模型与资料库,无需 API Key,也不执行任意 Python。它通过 JSON 表达动作,校验名称和参数,并测试四种路径:正常完成、未知工具后恢复、解析失败达到上限、步数耗尽。
python3 react_demo.py核心交接仍然很短:
raw = model(history)
action = validate_action(raw)
result = tools[action["tool"]](action["arg"])
history.append({"role": "tool", "content": result})完整实现还处理终止和异常。模拟模型按预定步骤输出,因此演示只能证明运行程序按预期流转,不能证明真实模型会规划或从错误中恢复。这里的消息角色也是教学数据结构,不声称兼容某家 API。
接入真实服务时,还需要模型适配器、工具超时、上下文预算、权限限制与任务验收。JSON 降低了动作表达的歧义,但不替代这些工作。
8. 我的反思:ReAct 真正厉害的是什么,什么时候值得用?
读完代码后,我最看重的已经不是三个标签,而是:任务尚未完成、信息尚不充分时,系统可以通过行动获取证据,再改变下一步决策。
排查博客图片不显示就是一个合适的例子。开始时可能怀疑路径、访问控制或者 HTTPS。检查请求后,如果发现证书过期,后续就应该检查证书配置,而不是继续机械修改图片地址。
这不是说 ReAct 发明了反馈控制。它提供的是一种用语言模型组织推理和环境交互的方法。模型不必在开始时猜中整个过程,但每次交互都应该对任务有帮助。
我会用三个问题决定是否采用这种循环。
第一,是否缺少必须从外部取得的信息? 当前运行状态、真实文件和测试结果,不能靠模型自行补全。
第二,反馈是否会改变下一步? 如果步骤完全固定,普通脚本通常更直接。需要外部信息但只需固定查询一次,也未必需要多轮 Agent。
第三,结果是否可观察,试错成本是否可控? 可读取、可测试的任务更容易形成有效反馈;不可逆操作需要额外执行限制。
| 场景 | 我会优先考虑 |
|---|---|
| 材料齐全,只需总结或改写 | 直接模型调用 |
| 固定步骤的数据处理 | 脚本或固定工作流 |
| 路径不确定的故障排查、资料核实 | ReAct 式交互 |
| 有明确测试的代码修改 | 受约束的执行与反馈循环 |
| 后果重大且难以验证的操作 | 严格工作流与必要人工决策 |
所以我不会用“工具调用次数多”评价一个 Agent。更值得检查的是:每次行动有没有减少不确定性?失败后有没有改变策略?证据充分时有没有及时停止?
9. 面试复盘:我应该能讲清楚的六个问题
ReAct 和 function calling 是什么关系?
ReAct 描述推理与行动的组织方式,function calling 描述结构化工具调用接口。工具调用能力本身不构成完整循环,仍需要运行程序执行并回填结果。MCP 等工具连接协议也处于不同层次,不自动负责规划和验收。
追问:只调用一次工具算 ReAct 吗? 不能仅凭次数判断,要看方法定义和实际组织方式;不要把所有工具调用统一贴上 ReAct 标签。
stop 能防止模型幻觉吗?
它可以控制特定字符串处的生成边界,不能保证事实、参数或结果正确。真正的工具结果应由程序记录,输出合法性另行校验。
追问:换成 JSON 就安全了吗? JSON 主要约束结构;参数权限、工具行为和不可信内容仍需分别处理。
为什么把错误作为 Observation?
对于可恢复错误,反馈能让模型调整参数或策略。但程序仍负责重试次数、超时和权限,不是所有异常都适合交给模型。
追问:付款接口超时,直接重试可以吗? 不能先假设付款失败,要查询实际结果并保证幂等性。
为什么 max_steps 不够?
它限制循环次数,不限制单次工具阻塞、单步成本或累计上下文。还需要时间、token 和工具预算。
追问:重复调用一定是死循环吗? 不一定,例如状态轮询可能合理;要结合时间、结果变化和任务目标判断。
ReAct 为什么可能不如直接回答?
交互增加信息来源,也增加查询和执行失败的机会。信息已经充分或流程固定时,额外循环未必有收益。
追问:怎么验证使用价值? 在相同任务集上比较成功率与成本,并分析失败原因;不要仅展示一条成功轨迹。
finish 和任务成功有什么区别?
前者是模型的结束声明,后者要根据任务验收。例如修复代码应检查测试和实际行为,不能只相信“已经修好”。
追问:开放问答怎么验收? 按任务要求检查证据、引用和答案一致性,无法确认时保留不确定性。
读懂这段循环后,我希望自己能同时解释两件事:模型依据什么决定下一步,以及程序如何确保这个动作确实按约束执行。把这两层分开,才更容易定位问题,也更容易判断什么时候不需要 Agent。
参考资料
- Yao et al. ReAct: Synergizing Reasoning and Acting in Language Models. ICLR 2023. 论文 · 项目与代码。本文实验图对应 Table 1。
- Shinn et al. Reflexion: Language Agents with Verbal Reinforcement Learning. 论文。
- HotpotQA 官方 评测实现。
说明:本文由我的学习对话整理,并结合原论文与代码检查修订。示例轨迹为教学模拟,配图为重新绘制;尚未对真实模型进行本文任务的性能复现。