Agent系列(二):ReAct——Agent 的"思考-行动"循环

你以为 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平方公里。
════════════════════════════════════════════════════════════

注意这里发生了什么:

  1. Agent 自己决定先搜北京,再搜上海,然后计算------没有任何硬编码的执行顺序
  2. 每次搜索的结果(Observation)都被模型读取,并用来决定下一步
  3. 最终的计算直接用了从真实搜索中提取的数字

这就是 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,还考虑过生产环境的稳定性。


总结

三件事:

  1. ReAct = Reasoning + Acting:通过 Thought → Action → Observation 循环,让 Agent 能根据真实反馈动态调整推理路径。核心区别于 CoT:行动产生真实结果,结果反哺推理。

  2. 工具设计是 ReAct 的隐形接口:工具的 docstring 质量直接决定 LLM 的调用准确率;工具的安全实现(如 AST 替代 eval)决定系统边界能否守住。

  3. recursion_limit 是生产环境必设项 :Agent 自主决定停止,这本身就是风险。recursion_limit 是最后一道防线,建议设为正常完成步骤数的 2 倍。


下一篇 :Agent 系列第三篇------Plan-and-Solve:当 ReAct 不够用时,Agent 如何先规划再执行。我们会看到,ReAct 的贪心策略在复杂任务上的瓶颈,以及如何引入显式规划层来突破它。


参考资料


欢迎访问我的个人主页,获取更多有用的知识和有趣的产品

相关推荐
解局易否结局1 小时前
ops-transformer 仓库核心能力解析:FlashAttention 在昇腾 NPU 上的融合实现
人工智能·深度学习·transformer
沅柠-AI营销1 小时前
AI 浪潮席卷当下,品牌如何破局前行?新时代品牌经营生存与增长策略
人工智能·搜索引擎·品牌营销·商业思维·ai营销·商业增长
FlagOS智算系统软件栈1 小时前
众智FlagOS完成腾讯混元MT2多语翻译模型全系列多芯片适配:英伟达/华为/平头哥三芯开箱即用
开发语言·人工智能·开源
SOC罗三炮1 小时前
Hermes Agent 源码深度解构:一个“自进化“AI Agent的完整架构拆解
大数据·人工智能·架构
JAVA学习通1 小时前
Sub2API + CCSwitch 实现 Codex 反向代理:多账号流量分发实战(解决codex手机号验证)
人工智能·codex·反代
qq_452396231 小时前
第十篇:《软件测试的未来:AI测试、DevOps与测试左移》
运维·人工智能·devops
青云计划1 小时前
多智能体路由:从场景定义到Agent解析的工程实践
人工智能
IPHWT 零软网络1 小时前
从选型角度看语音网关国产化:以MX8G-A为列的架构与价值分析
人工智能·架构·信创·国产化·语音网关
武子康1 小时前
调查研究-142 全球机器人产业深度调研报告【04篇】机器人产业利润池全景:谁最容易赚钱与十大判断指标
大数据·人工智能·ai·机器人·具身智能·openclaw