21 · ReAct:思考---行动---观察循环的完整实现
「AI-Agent 面试深度指南」· 模块六 · 范式、框架与多智能体 · 第 21 篇 / 共 32 篇
引言
ReAct 是 Agent 的心跳。几乎所有 Agent 系统------无论包装得多复杂------内核都能还原成一个"思考 → 行动 → 观察"的循环。
它也是面试中最常见的手撕题:给你一个 llm() 和几个工具,写出能跑的循环。评分点不在主流程(那是十行代码的事),而在边界处理:轮数上限、解析失败、工具不存在、参数错误、重复调用、以及最后如何收尾。本文从原理讲到生产级实现,并给出一份完整的调试清单。
一、原理:为什么这个循环有效
1.1 三步循环
Thought(思考):模型用自然语言写下当前判断("我需要先查一下这个订单的状态")。
Action(行动) :选择工具并给出参数(get_order(order_id="..."))。
Observation(观察):工具返回结果,被追加进上下文。
然后回到 Thought,直到模型输出最终答案。
1.2 三个有效性来源
外部反馈纠偏 。模型第一轮的假设可能是错的("这个订单应该是已发货"),但工具返回的真实状态("待付款")会立刻把它拉回正轨。这是 ReAct 相比纯 CoT 最本质的优势:CoT 只在内部推理,错了就一路错到底;ReAct 用真实观察打断错误链条。
思考显式化。把隐式推理变成可读文本,既提升了准确率(更多计算步数),也让调试成为可能------你能看到模型在第几步想错了。
动态路径。不需要预先枚举流程,边走边决定。这让它可以应对开放任务。
1.3 与 CoT 的关系
CoT 是"想清楚再答",ReAct 是"想一步做一步再看"。二者不是替代关系,而是可以叠加:ReAct 的 Thought 部分本身就是 CoT。
二、两种实现方式
2.1 文本解析式
约定输出格式:
Thought: 我需要查询订单状态
Action: get_order
Action Input: {"order_id": "202609071234567890"}
应用侧用正则解析。优点:兼容任何 LLM,不依赖工具调用能力。缺点:极其脆弱------少个冒号、输出里编造 Observation、JSON 不合法,都会崩。
2.2 原生 Function Calling 式
用结构化工具调用接口(第 9 篇)。格式由模型保证,支持并行,参数可直接反序列化。
现代实现应优先选它,文本解析只作为不支持工具调用的模型的兜底。
2.3 一个健壮的文本解析兜底
如果必须用文本解析,至少做到:
- 宽松正则(允许可选空格与换行、非贪婪匹配);
- 多格式兼容(
Action:/Action Input:可能合并在一行); - 解析失败时不静默跳过,而是回传错误要求模型重新输出;
- 对
Action Input做 JSON 修复(处理尾随逗号、单引号等常见问题)。
三、生产级实现骨架
python
def run_react(question, registry, llm, max_steps=8, token_budget=100_000):
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": question},
]
trace, seen, spent = [], {}, 0
for step in range(max_steps):
reply = llm(messages, tools=registry.specs())
spent += reply.usage.total_tokens
if spent > token_budget:
return fallback_answer(messages, trace) # 预算耗尽:降级
call = reply.tool_call or parse_text_action(reply.content)
if call is None:
return reply.content, trace # 模型决定收尾
key = (call.name, json.dumps(call.args, sort_keys=True))
if seen.get(key, 0) >= 1: # 重复调用检测
messages.append(tool_msg(call.id,
"你已用相同参数调用过该工具。请换一种方法,或向用户说明无法完成。"))
continue
seen[key] = 1
messages.append(tool_msg(call.id, execute(call, registry)))
trace.append({"step": step, "call": call, "result": result})
return "达到最大步数仍未完成,以下是已知信息:", trace # 步数耗尽:部分回答
3.1 六个必须有的边界处理
- 步数上限:防止无限循环(通常 5--15 步,按任务定);
- 重复动作检测:同一工具 + 相同参数出现两次,强制换策略;
- token 预算:总成本超过阈值时降级,而不是跑到底;
- 结构化错误回传:工具失败时回传"类型 + 原因 + 建议",让模型自我纠正;
- 结果裁剪 :
truncate()防止一次工具返回吃掉整个窗口; - 轨迹记录 :返回
trace,供调试、评测与回放使用。
3.2 步数耗尽时的处理很重要
不要返回空或报错。正确做法是:用已获得的信息给出部分回答,并明确说明未完成的部分。例如:
我已确认订单状态为"待付款",但未能获取到预计发货时间(接口超时)。建议您稍后重试或联系客服。
这比"任务失败"的体验好得多,也是生产系统与 demo 的区别。
3.3 提示词中的三条关键约定
在系统提示里明确写:
- 连续两次失败必须换方法(防循环);
- 信息不足时向用户提问,而不是猜测(防幻觉);
- 完成后必须基于实际工具结果作答,不得凭记忆(防编造)。
四、成本模型与优化
4.1 为什么是二次方增长
每轮请求都要携带(并可能被重新读取)越来越长的上下文。若平均上下文随步数线性增长,累计读取量约为 O(n²)。
这意味着:步数从 5 增到 10,成本可能涨 4 倍而不是 2 倍。
4.2 四条优化路径
- 稳定前缀提高缓存命中(第 7 篇)------收益最大的一项;
- 减少无效轮次:更好的规划、更精准的工具描述、首次成功率;
- 裁剪工具返回:这是单轮上下文增长的主要来源;
- 历史压缩与子 Agent 隔离(第 8 篇)。
五、调试清单
当 ReAct 循环出问题时,按顺序查:
| 症状 | 排查点 |
|---|---|
| 不调用工具 | 工具描述是否清晰?系统提示是否说明了何时该用? |
| 调用错误的工具 | 是否存在相似工具未区分?描述是否缺少"不适用场景"? |
| 参数错误 | Schema 是否明确?示例是否缺失?是否该开约束解码? |
| 重复调用 | 是否有重复检测?工具返回是否清晰说明"我已经查过了"? |
| 卡在某一步 | 工具是否返回了可执行建议?还是只说了"失败"? |
| 过早结束 | 是否要求"必须实际执行并展示结果"?验证环节是否缺失? |
| 无限循环 | 步数上限、重复检测、token 预算是否都到位? |
调试的第一手段永远是打印完整轨迹。没有轨迹,你只能猜。
六、面试考点与答题框架
6.1 高频真题
Q1:ReAct 与 CoT 的区别?
答:CoT 只在模型内部推理,不与外部交互,错了会一路错到底;ReAct 把工具返回的真实观察纳入推理,能用外部反馈纠正错误假设。代价是成本与延迟。
Q2:如何防止 Agent 陷入无限循环?
答:四道防线------最大步数、重复动作检测(同工具同参数)、token/时间预算、以及提示词中要求"连续两次失败必须换方法或求助"。达到上限时给出部分回答而非直接失败。
Q3:什么时候不该用 ReAct?
答:路径完全确定(用 workflow)、纯推理不需要外部信息(用 CoT 或直接答)、延迟与成本极敏感(用固定流程)。ReAct 的价值在于"路径未知需要探索"。
6.2 加分点
- 能手写出带边界处理的完整循环(步数、重复检测、预算、错误回传、裁剪、trace);
- 能说出成本随步数近似二次方增长及优化方向;
- 能给出步数耗尽时的"部分回答"策略。
小结
ReAct 的实现难点不在主循环,而在边界与降级:步数上限、重复检测、预算控制、结构化错误、结果裁剪、轨迹记录,以及耗尽时的部分回答。把这六项做齐,你的 Agent 才敢上线;把一个只跑通 happy path 的循环拿去面试,基本会被追问到露馅。