你以为 Agent 在"思考",其实它只是在猜下一个词
先讲一个真实的现象。
你让 Agent 帮你写一份竞品分析报告。它信心十足地给你输出了满满三页。报告看起来很专业,有数据、有结论、有建议。
但有一个问题:所有数据都是它"记忆"里的,截止时间可能是一年前。它没有搜索,没有验证,只是用语言模型的能力生成了听起来合理的内容。
这不是在思考,这是在流畅地瞎编。
Chain-of-Thought(CoT)也是同样的问题。CoT 让模型在回答前先"逐步推理",确实能提升很多推理任务的准确率------但它仍然是在语言空间里打转。模型可以非常流畅地"推理"出一个完全错误的答案,因为它的信息来源只有训练数据。
ReAct 的出现,就是为了解决这个问题。
ReAct:Reasoning + Acting 的结合
2022 年,普林斯顿和谷歌的研究团队发表了论文 ReAct: Synergizing Reasoning and Acting in Language Models。
论文的核心思想极其简洁:让模型交替进行"推理"(Reasoning)和"行动"(Acting),而不是先推理完再行动,或只行动不推理。
具体形式是一个三元组循环:
Thought → Action → Observation
↑ │
└─────────────────────────┘
- Thought:模型在"想"什么------当前的分析、下一步打算做什么、为什么这么做
- Action:模型实际调用的工具和参数
- Observation:工具执行后返回的真实结果
关键在于:Observation 会作为新的上下文喂回给模型,让它根据真实结果继续推理。这就形成了"思考---行动---观察---再思考"的循环。
听起来简单,但这一个循环解决了 CoT 最核心的缺陷:模型不再只是在语言空间里自言自语,它可以与真实世界交互,并根据真实反馈修正推理路径。
一个具体的例子:看 Agent 是怎么"想"的
我写了一个完整的 ReAct Agent 演示,用的是 LangGraph + GLM-4-Flash,两个工具:calculator(安全计算器)和 web_search(Bing 搜索)。
代码在这里:agent-01-react-agent/react_agent.py
我们来看一个真实的执行追踪------Demo 3:搜索北京和上海的面积,然后计算差值。
ini
════════════════════════════════════════════════════════════
Demo 3 ▸ 多轮搜索(同一工具多次调用)
════════════════════════════════════════════════════════════
[用户提问]
先搜索一下北京的面积,再搜索上海的面积,
最后计算北京比上海大多少平方公里。
────────────────────────────────────────────────────────────
[步骤 1] THOUGHT → ACTION
Action : web_search(query='北京面积 平方公里')
Observation : • 北京市面积: 北京市总面积为16410.54平方公里...
────────────────────────────────────────────────────────────
[步骤 2] THOUGHT → ACTION
Action : web_search(query='上海面积 平方公里')
Observation : • 上海市面积: 上海市陆域面积约6340.5平方公里...
────────────────────────────────────────────────────────────
[步骤 3] THOUGHT → ACTION
Action : calculator(expression='16410.54 - 6340.5')
Observation : 10070.04
────────────────────────────────────────────────────────────
[最终答案]
北京的面积约为16410.54平方公里,上海的面积约为6340.5平方公里。
北京比上海大约10070.04平方公里。
════════════════════════════════════════════════════════════
注意这里发生了什么:
- Agent 自己决定先搜北京,再搜上海,然后计算------没有任何硬编码的执行顺序
- 每次搜索的结果(Observation)都被模型读取,并用来决定下一步
- 最终的计算直接用了从真实搜索中提取的数字
这就是 ReAct 的价值:执行路径是模型在运行时动态规划的,而不是开发者事先写死的。
和 Chain-of-Thought 的区别
可以做个直接对比:
| 特性 | Chain-of-Thought | ReAct |
|---|---|---|
| 信息来源 | 仅训练数据 | 训练数据 + 工具返回值 |
| 执行路径 | 语言空间内推理 | 思考 → 真实行动 → 观察结果 |
| 能获取实时信息 | ✗ | ✓(通过工具) |
| 能执行计算/代码 | ✗ | ✓(通过工具) |
| 推理过程可验证 | 难以验证 | 每一步 Observation 都是真实结果 |
| 失控风险 | 低(无副作用) | 高(需要安全边界) |
用一句话总结:CoT 让模型想清楚;ReAct 让模型边想边做。
动手实现:用 LangGraph 构建 ReAct Agent
下面是核心代码。代码使用 LangGraph 的 create_react_agent,这是目前最简洁的 ReAct 实现之一。
1. 安全计算器工具
python
import ast
import operator
from typing import Any
from langchain_core.tools import tool
_SAFE_OPS: dict[type, Any] = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.Pow: operator.pow,
ast.Mod: operator.mod,
ast.USub: operator.neg,
}
def _eval_ast(node: ast.AST) -> float:
if isinstance(node, ast.Constant):
return float(node.value)
if isinstance(node, ast.BinOp):
op_fn = _SAFE_OPS.get(type(node.op))
if op_fn is None:
raise ValueError(f"不支持的运算符:{type(node.op).__name__}")
return op_fn(_eval_ast(node.left), _eval_ast(node.right))
if isinstance(node, ast.UnaryOp):
op_fn = _SAFE_OPS.get(type(node.op))
return op_fn(_eval_ast(node.operand))
raise ValueError(f"不支持的 AST 节点:{type(node).__name__}")
@tool
def calculator(expression: str) -> str:
"""计算数学表达式,支持 + - * / ** % 以及括号。"""
try:
tree = ast.parse(expression.strip(), mode="eval")
result = _eval_ast(tree.body)
if result == int(result):
return str(int(result))
return f"{result:.6g}"
except (ValueError, SyntaxError, ZeroDivisionError) as e:
return f"计算错误:{e}"
**为什么不直接用 `eval()`?**
eval("__import__('os').system('rm -rf /')") ------这行代码会在你的机器上执行删除操作。工具是 Agent 的"手",一旦 LLM 被攻击者通过 prompt injection 操控,eval() 就变成了一个直接通往系统的后门。
AST 解析只允许数学运算节点,其余全部拒绝。这是工具安全设计的基本原则。
2. 网络搜索工具
python
import requests
from bs4 import BeautifulSoup
from urllib.parse import quote
_BING_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
),
"Accept-Language": "en-US,en;q=0.9",
}
@tool
def web_search(query: str) -> str:
"""搜索网络,返回最相关的 3 条摘要。"""
try:
url = f"https://www.bing.com/search?q={quote(query)}&setlang=zh-CN"
resp = requests.get(url, headers=_BING_HEADERS, timeout=10)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
snippets = []
for li in soup.find_all("li", class_="b_algo")[:4]:
h2 = li.find("h2")
title = h2.get_text(strip=True) if h2 else ""
p = li.find("p")
body = p.get_text(strip=True) if p else ""
if title or body:
snippets.append(f"• {title}: {body}"[:200])
return "\n".join(snippets[:3]) if snippets else "未找到相关结果。"
except requests.RequestException as e:
return f"搜索请求失败:{e}"
3. 构建 Agent
python
import os
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
# LangGraph V1.0 将 create_react_agent 迁移到了 chat_agent_executor 子模块
from langgraph.prebuilt.chat_agent_executor import create_react_agent
load_dotenv()
llm = ChatOpenAI(
base_url="https://open.bigmodel.cn/api/paas/v4",
api_key=os.getenv("LLM_API_KEY"),
model="glm-4-flash",
temperature=0,
)
agent = create_react_agent(
model=llm,
tools=[calculator, web_search],
)
result = agent.invoke(
{"messages": [("user", "北京比上海大多少平方公里?搜索后计算。")]},
config={"recursion_limit": 20},
)
print(result["messages"][-1].content)
三行核心代码:定义工具 → 绑定 LLM → 运行。LangGraph 在背后处理了所有的消息路由、工具调用、结果注入和循环控制。
**关于 `create_react_agent` 的导入路径**
LangGraph V1.0 将这个函数迁移到了 langgraph.prebuilt.chat_agent_executor。如果你从 langgraph.prebuilt 导入,会看到 LangGraphDeprecatedSinceV10 警告。建议直接用新路径:
python
# ✅ 推荐
from langgraph.prebuilt.chat_agent_executor import create_react_agent
# ⚠️ 会有 deprecation warning
from langgraph.prebuilt import create_react_agent
ReAct 的内部机制:消息序列是怎么流转的
理解 ReAct 的本质,要看清楚底层的消息流。每一次循环,实际上是这样的:
scss
第 N 轮开始时,LLM 收到的上下文:
┌─────────────────────────────────────────────┐
│ [System] 你是一个助手,有以下工具可用: │
│ calculator, web_search │
│ │
│ [Human] 问题:北京比上海大多少? │
│ │
│ [AI] (工具调用) web_search("北京面积") │ ← 第1轮的 Action
│ [Tool] 北京面积 16410 平方公里 │ ← 第1轮的 Observation
│ │
│ [AI] (工具调用) web_search("上海面积") │ ← 第2轮的 Action
│ [Tool] 上海面积 6340 平方公里 │ ← 第2轮的 Observation
│ │
│ ← LLM 在这里决定下一步 → │
└─────────────────────────────────────────────┘
每次循环,整个历史都作为上下文传给 LLM。模型"看到"之前所有的思考和观察,然后决定:
- 继续调用工具(还有信息需要获取)
- 停止循环,给出最终答案(信息足够了)
这就是为什么叫"循环"------模型本身就是循环的终止条件,它自己决定什么时候停下来。
失控场景与防护机制
这个"自主决定何时停止"的设计,同时也引入了一个风险:如果模型判断失误,循环就永远不会终止。
常见的失控场景:
场景 1:工具总是失败,模型不断重试
vbnet
Action: web_search("某个模糊的问题")
Observation: 未找到相关结果
Thought: 我换个关键词再试试
Action: web_search("换个关键词")
Observation: 未找到相关结果
Thought: 再换一个...
(无限循环)
场景 2:模型误解了任务,在错误方向上死磕
vbnet
Thought: 我需要找到 X 的精确值
Action: calculator("...")
Observation: 近似值
Thought: 这不够精确,我需要更多小数位
Action: calculator("...")
(无限追求"精确")
场景 3:工具之间形成依赖循环
makefile
Thought: 我需要先知道 A 才能查 B
Action: search(A)
Observation: 需要先知道 B
Thought: 我需要先知道 B 才能查 A
(循环依赖)
LangGraph 提供了 recursion_limit 参数作为硬性安全网:
python
result = agent.invoke(
{"messages": [("user", question)]},
config={"recursion_limit": 5}, # 超过 5 步强制终止
)
当步骤数超过限制,会抛出 GraphRecursionError:
css
[已触发 recursion_limit]
异常类型:GraphRecursionError
异常信息:Recursion limit of 5 reached without hitting a stop condition...
→ 结论:生产环境务必设置合理的 recursion_limit(建议 15~25)
→ 过低:合法任务被截断;过高:失控 Agent 消耗大量 Token
**recursion_limit 怎么设?**
- 简单任务(单工具调用):5~8 步足够
- 中等任务(多工具多步):10~15 步
- 复杂研究型任务:20~25 步
- 超过 30 步的任务要重新思考架构,可能需要多 Agent 协作(见后续文章)
设置的原则:给任务正常完成所需步骤数的 2 倍左右,既有余量,也有上限。
五个真实场景:从简单到复杂
完整代码包含了 5 个渐进式 Demo,覆盖了 ReAct 的主要使用场景:
Demo 1:纯计算(单工具,单步)
scss
问题:计算 (1024 * 768) + (1920 * 1080) 的结果
步骤:calculator('(1024 * 768) + (1920 * 1080)') → 2860032
验证基础工具调用链路是否通畅。
Demo 2:搜索 + 计算(多工具,多步)
scss
问题:Python 和 JavaScript 各是哪年发布的?计算相差多少年。
步骤:web_search("Python发布年份") → web_search("JavaScript发布年份") → calculator
展示 Agent 如何自主编排不同工具的调用顺序。
Demo 3:多轮搜索(同一工具多次调用)
scss
问题:北京比上海大多少平方公里?
步骤:web_search("北京面积") → web_search("上海面积") → calculator → 10070.04
展示 Agent 可以根据第一次结果决定第二次查什么。
Demo 4:无需工具(直接回答)
问题:用一句话解释什么是 ReAct 范式
步骤:无工具调用,直接回答
展示 Agent 知道什么时候不需要调工具------这和"能不能调"一样重要。
Demo 5:触发 recursion_limit(安全网演示)
ini
问题:搜索 Python/Java/C 的发布年份,计算总和(需要约 10 步)
限制:recursion_limit=5
结果:GraphRecursionError(正确触发)
生产环境安全机制验证。
一个有趣的观察:Agent 也会"将错就错"
Demo 2 中发生了一件值得记录的事。
Agent 搜索 JavaScript 发布年份,Bing 返回的摘要里混入了一篇 2023 年发布的文章,模型误将"2023"识别为 JavaScript 的发布年份(实际是 1995)。计算步骤执行了 2023 - 1991 = 32,返回 32。
但是,最终答案却是正确的:"Python 于 1991 年,JavaScript 于 1995 年,相差 4 年。"
模型用自己的训练知识覆盖了(错误的)计算结果,给出了正确答案。
这个现象揭示了 ReAct 的一个微妙之处:Agent 的推理链和最终答案可以是脱节的。模型可能在工具调用环节犯错,却在最终答案环节用内置知识自动"纠正"。
这在结果上是好事,但从工程角度看是个问题------如果你需要可追溯的、可验证的结论,"结果碰巧对了"并不够。这是 Harness Engineering 要解决的问题之一(见系列后续文章)。
Trace 可视化:让 Agent 的思考过程可观测
线上 Agent 的一个常见痛点:出错了不知道在哪步出的,因为默认只有最终答案可见。
好的实践是把 Thought/Action/Observation 序列打印出来,做成可读的 Trace:
python
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
def print_trace(result: dict) -> None:
for msg in result["messages"]:
if isinstance(msg, HumanMessage):
print(f"[用户提问] {msg.content}")
elif isinstance(msg, AIMessage):
content = msg.content if isinstance(msg.content, str) else ""
if msg.tool_calls:
for tc in msg.tool_calls:
args = ", ".join(f"{k}={repr(v)}" for k, v in tc["args"].items())
print(f"[ACTION] {tc['name']}({args})")
else:
print(f"[最终答案] {content.strip()}")
elif isinstance(msg, ToolMessage):
obs = msg.content if isinstance(msg.content, str) else str(msg.content)
print(f"[OBSERVATION] {obs.strip()[:300]}")
**GLM-4-Flash 的 content 字段污染问题**
使用 GLM-4-Flash 时,偶尔会发现 AIMessage.content 里出现原始 JSON(类似 {"index": 0, "delta": ...})。这是模型内部 streaming delta 数据泄漏到 content 字段的问题。
处理方式:检测到 content 以 { 或 [ 开头且可以被 json.loads() 解析时,直接丢弃。
python
def _clean_thought(text: str) -> str:
stripped = text.strip()
if stripped and stripped[0] in ("{", "["):
try:
json.loads(stripped)
return "" # 是 JSON 泄漏,丢弃
except json.JSONDecodeError:
pass
return text
完整代码已包含这个处理逻辑。
ReAct 的局限性
ReAct 很强大,但不是银弹。了解它的局限,才能用对场景:
1. 上下文窗口消耗快
每次循环都把全部历史塞进上下文。步骤一多,Token 消耗快速上升。复杂任务(20+ 步)在长上下文窗口有限的模型上可能失败。
2. 工具描述写不好,调用就乱
ReAct 完全依赖 LLM 理解工具文档来决定调用哪个、传什么参数。如果 docstring 写得模糊,模型的工具选择就会出错。工具描述是 ReAct 系统的隐形接口------像写 API 文档一样认真对待它。
3. 没有全局规划能力
标准 ReAct 是贪心的:每一步只看当前状态决定下一步,没有"先规划整体再执行"的能力。对于需要长期规划的任务(如写完整代码库),可能陷入局部最优。这是 Plan-and-Solve 范式要解决的问题(见系列第三篇)。
4. 工具失败的容错性差
如果工具返回错误,模型只能根据错误信息推断下一步,没有预定义的重试策略或回退逻辑。这需要在工具设计层面和 Harness 层面额外处理。
面试素材:说清楚你的 Agent 是怎么"想"的
常见面试题:你的 Agent 是如何决定下一步动作的?
很多候选人答"调工具"。但面试官真正想听的是:谁决定调哪个工具、何时停止调用?
清晰的回答框架:
"我们使用 ReAct 范式,核心是 Thought → Action → Observation 的循环。每一步 LLM 根据当前上下文(包括用户问题和所有历史 Observation)决定下一个 Action,工具执行后结果以 ToolMessage 形式注入上下文,触发下一轮推理。
循环的终止条件是 LLM 自主判断'信息已经足够了',不再调用工具,直接输出答案。
为了防止失控,我们设置了
recursion_limit(通常 15~25),超限后抛出异常并走降级逻辑。我们也把 Trace(每步的 Action + Observation)记录到日志里,出问题时可以回放整个推理链。"
关键加分项:提到 Trace 可观测性和 recursion_limit,说明你不只是会跑 Demo,还考虑过生产环境的稳定性。
总结
三件事:
-
ReAct = Reasoning + Acting:通过 Thought → Action → Observation 循环,让 Agent 能根据真实反馈动态调整推理路径。核心区别于 CoT:行动产生真实结果,结果反哺推理。
-
工具设计是 ReAct 的隐形接口:工具的 docstring 质量直接决定 LLM 的调用准确率;工具的安全实现(如 AST 替代 eval)决定系统边界能否守住。
-
recursion_limit 是生产环境必设项 :Agent 自主决定停止,这本身就是风险。
recursion_limit是最后一道防线,建议设为正常完成步骤数的 2 倍。
下一篇 :Agent 系列第三篇------Plan-and-Solve:当 ReAct 不够用时,Agent 如何先规划再执行。我们会看到,ReAct 的贪心策略在复杂任务上的瓶颈,以及如何引入显式规划层来突破它。
参考资料
- Yao et al., ReAct: Synergizing Reasoning and Acting in Language Models, ICLR 2023
- LangGraph 官方文档
- hello-agents 开放教程(第四章)
- 本文配套代码:agent-01-react-agent
欢迎访问我的个人主页,获取更多有用的知识和有趣的产品