目录
[一、LLM 给出的答案,究竟是"查到的"还是"写出来的"?](#一、LLM 给出的答案,究竟是“查到的”还是“写出来的”?)
[二、为什么需要 ReAct:模型的两个天然短板](#二、为什么需要 ReAct:模型的两个天然短板)
[(三)传统 Chain 与 ReAct 的关键差异](#(三)传统 Chain 与 ReAct 的关键差异)
[三、核心原理:Reasoning → Action → Observation](#三、核心原理:Reasoning → Action → Observation)
[(五)从概念到代码:LangGraph 为什么适合实现 ReAct](#(五)从概念到代码:LangGraph 为什么适合实现 ReAct)
[(一)全链路真实调用:不是 Mock 出来的"Agent 感"](#(一)全链路真实调用:不是 Mock 出来的“Agent 感”)
[(二)真实 LLM 如何做决策:工具菜单 + JSON 协议](#(二)真实 LLM 如何做决策:工具菜单 + JSON 协议)
[1. 为什么工具描述必须像 API 文档一样清楚](#1. 为什么工具描述必须像 API 文档一样清楚)
[2. 模型输出永远不能被无条件信任](#2. 模型输出永远不能被无条件信任)
[1. 在线计算](#1. 在线计算)
[2. 实时天气](#2. 实时天气)
[(十)max_iterations:ReAct 的命门为什么是防死循环](#(十)max_iterations:ReAct 的命门为什么是防死循环)
[1. 两层终止防线](#1. 两层终止防线)
[2. 不同任务不应共用一个固定上限](#2. 不同任务不应共用一个固定上限)
[(一)真实接入踩坑:Mock 环境不会教你的事](#(一)真实接入踩坑:Mock 环境不会教你的事)
[1. HTTP 客户端指纹与 403](#1. HTTP 客户端指纹与 403)
[2. CA 证书链问题](#2. CA 证书链问题)
[3. 免费公开 API 没有 SLA](#3. 免费公开 API 没有 SLA)
[4. 多源降级与自适应排序](#4. 多源降级与自适应排序)
[1. 坑 1:没有硬上限,Agent 一直循环](#1. 坑 1:没有硬上限,Agent 一直循环)
[2. 坑 2:工具描述含糊,模型选错工具或参数格式](#2. 坑 2:工具描述含糊,模型选错工具或参数格式)
[3. 坑 3:Observation 原样堆回上下文](#3. 坑 3:Observation 原样堆回上下文)
[4. 坑 4:把模型输出当成绝对可靠的 JSON](#4. 坑 4:把模型输出当成绝对可靠的 JSON)
[5. 坑 5:只保存最终答案,线上问题无法复盘](#5. 坑 5:只保存最终答案,线上问题无法复盘)
[1. 终止与预算](#1. 终止与预算)
[2. 分级超时](#2. 分级超时)
[3. 结果缓存与重复调用检测](#3. 结果缓存与重复调用检测)
[4. 外部依赖降级](#4. 外部依赖降级)
[5. 可观测性](#5. 可观测性)
[六、什么时候该用 ReAct,什么时候不该用](#六、什么时候该用 ReAct,什么时候不该用)
[(一)适合 ReAct 的任务](#(一)适合 ReAct 的任务)
[(二)更适合普通 Chain 的任务](#(二)更适合普通 Chain 的任务)
[(三)更适合 Planning Agent 的任务](#(三)更适合 Planning Agent 的任务)
[八、总结:ReAct 的灵魂、价值与边界](#八、总结:ReAct 的灵魂、价值与边界)
干货分享,感谢您的阅读!
这是「LangGraph Agent Engineering Mastery」系列 Stage 4 推理 Agent · 第 1 篇。

读完你将获得:
-
看懂 ReAct(Reasoning + Acting)为什么不是"多调几次 API",而是一套由观察结果驱动的动态决策机制;
-
用 LangGraph 手写一个完整的 Reasoning → Action → Observation 循环;
-
理解真实 LLM、真实公开 API、错误恢复、协议解析与条件边之间的配合关系;
-
掌握
max_iterations、recursion_limit、超时、上下文截断、多源降级等工程化护栏。
一、LLM 给出的答案,究竟是"查到的"还是"写出来的"?
你有没有遇到过这种场景:你问一个大模型"今天北京天气怎么样""帮我计算一串复杂表达式""某个刚发生的新闻有什么进展",它很快给出一段看起来完整、措辞也很自然的回答。
问题是:它真的查了吗?
在最传统的 Chain 模式里,模型往往只是接收输入,然后一次性生成输出。它可能依靠训练语料、上下文和语言概率,续写出一段"听起来合理"的文本,却没有真正访问天气服务、计算服务或知识库。
如果你是后端工程师,可以把传统 Chain 想象成这样一个方法:
python
result = llm.invoke(user_input)
return result
它有输入,有输出,但中间没有动态分支、没有外部调用、没有基于返回值的重试,也没有"这一步失败后该怎么办"的决策过程。
ReAct 改变的正是这一点。它给 LLM 加上的不是一个固定工具按钮,而是一套循环能力:
先判断当前缺什么信息,再选择行动;拿到行动结果后重新判断,直到信息足够或触发终止条件。
2022 年提出的 ReAct(Reasoning + Acting)模式,把"推理"和"行动"交替组织起来,使模型不再只是一次性回答,而是能够在外部世界中获取证据、修正路径并逐步收敛。
二、为什么需要 ReAct:模型的两个天然短板
LLM 很擅长语言组织、语义理解和模式归纳,但它有两个无法靠"再多想一会儿"彻底解决的短板。
(一)知识不是实时数据库
模型无法天然知道"今天北京的实时气温""当前时间""刚刚发生的科技新闻"。即使它训练时见过相关知识,也不代表这些知识在今天仍然有效。
(二)语言生成不等于精确执行
对于 15 * 8 + 42 这样的简单表达式,模型通常能答对;但当表达式更复杂、步骤更多或涉及严格格式时,纯语言生成并不是最可靠的计算引擎。
因此,真正可靠的 Agent 需要两类能力同时存在:
-
判断能力:现在应该直接回答,还是调用工具?调用哪个工具?传什么参数?
-
执行能力:真正访问外部服务,拿到可验证的结果。
只有工具,没有判断,系统会退化成固定工作流;只有判断,没有工具,系统仍然只是"在脑中推演"。ReAct 的核心,就是把两者连接成一个可以迭代的闭环。
(三)传统 Chain 与 ReAct 的关键差异
| 维度 | 传统 Chain | ReAct Pattern |
|---|---|---|
| 推理方式 | 一次性生成 | 多轮判断与迭代 |
| 工具使用 | 固定流程或不使用 | 根据当前状态动态选择 |
| 外部证据 | 通常没有 | 工具返回结果进入上下文 |
| 错误恢复 | 很弱 | 可根据 Observation 改参数、换工具或停止 |
| 可观测性 | 主要看到最终答案 | 可记录决策、调用、返回与轮次 |
| 终止机制 | 天然一次结束 | 必须设计主动结束与强制熔断 |
| 成本 | 较低 | 每轮都可能产生新的 LLM 与工具调用 |
这里最容易被忽略的是最后两行:循环能力既是 ReAct 的力量,也是它的风险来源。
三、核心原理:Reasoning → Action → Observation
ReAct 可以概括为三个连续阶段。
(一)Reasoning:判断下一步,而不是立即给答案
模型读取:
-
用户问题;
-
当前迭代轮次;
-
已经拿到的观察结果;
-
可用工具及其参数协议。
然后只做一个决策:
-
信息已经足够,进入
final; -
还缺外部信息,进入
tool_call。
(二)Action:真正执行工具
Action 节点根据模型给出的工具名与参数,执行真实函数。例如:
-
search_knowledge:查询百科知识; -
calculate:调用在线计算 API; -
get_current_info:查询天气、时间或新闻。
(三)Observation:把结果写回状态
工具结果不会直接变成最终答案,而是先作为 Observation 写回图状态。下一轮 Reasoning 看到这些结果后,再判断:
-
结果是否有效?
-
是否需要改关键词重试?
-
是否需要换数据源?
-
是否已经足够回答?
-
是否达到最大迭代次数?
这就是 ReAct 与"固定调用工具"最本质的区别:工具结果会改变后续决策。
(四)两种终止路径
一个可靠的 ReAct 循环必须同时具备两种终止方式:
-
主动结束 :LLM 判断信息已经足够,选择
final; -
强制结束 :达到
max_iterations,无论模型还想不想继续,都转入final。
如果只依赖模型主动停止,系统就可能因为不断换关键词、反复调用失败工具而持续消耗 Token 与时间。
(五)从概念到代码:LangGraph 为什么适合实现 ReAct

很多人第一次看到 ReAct,会直觉地写一个 while 循环。这样当然能跑,但一旦流程变复杂,状态、分支、追踪、重试和终止条件就会混在一起。
LangGraph 的优势是把这个循环拆成显式的图结构:
-
状态由
State承载; -
每个阶段是一个独立节点;
-
分支由条件边控制;
-
循环是图上的回边;
-
终止是通向
END的边。
状态不是"聊天记录"这么简单
一般代码中的状态定义如下,代码内容保持不变:
python
class ReActState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
current_step: str # "reasoning" | "action" | "observation" | "final"
iteration: int
max_iterations: int
reasoning_trace: list[str] # 完整推理链
tool_calls: list[dict] # 待执行的工具调用
observations: list[str] # 工具返回的观察结果
这里最关键的不是 messages,而是另外几组字段:
-
current_step:告诉条件边当前应该走向哪里; -
iteration/max_iterations:组成业务层的循环计数器与硬上限; -
tool_calls:保存本轮待执行动作; -
observations:积累外部工具返回; -
reasoning_trace:保留可审计的决策轨迹,便于定位"为什么选了这个工具、为什么重试、为什么停止"。
生产系统中,建议把这里的"推理链"理解为外显决策摘要与操作轨迹,而不是依赖不可控的内部思维过程。真正需要记录的是可复盘信息:工具名、参数、返回摘要、耗时、错误类型和终止原因。
四、核心代码深入拆解与分析
首先,我们先前置准备我们本次教学的主要代码内容:
python
"""Demo 01: ReAct Pattern --- Reasoning-Action-Observation 循环。
演示 ReAct 推理模式的核心机制:
1. Reasoning(思考):LLM 分析当前状态,决定下一步
2. Action(行动):调用工具获取信息
3. Observation(观察):处理工具返回结果
4. 循环控制:条件边判断是否继续,max_iterations 防死循环
本 Demo 全链路真实调用(需联网):
- LLM 推理:通过 shared.get_llm() 调用 .env 配置的真实在线模型(fallback_to_mock=False)
- search_knowledge : 百度百科 / 维基百科公开 API(主备自动降级)
- calculate : math.js 公开计算 API
- get_current_info : Open-Meteo 天气 / worldtimeapi 时间 / Hacker News 新闻
运行方式:
python stages/stage4_reasoning/01_react_pattern/main.py
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
from typing import Annotated, TypedDict
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage
from langgraph.graph import END, START, StateGraph
from langgraph.graph.message import add_messages
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent))
from shared import get_llm, get_logger, log_step, log_success, log_warning
logger = get_logger("demo.04_01_react_pattern")
MAX_ITERATIONS = 5
# ============================================================
# 真实工具集(全部为公开、免 API Key 的在线服务)
# ============================================================
_HTTP_TIMEOUT = 10
# Wikimedia 等公开 API 要求请求携带可识别的 User-Agent(否则 403);
# 百度百科 API 要求携带 Accept 头(否则可能返回 errno 2)
_HTTP_HEADERS = {
"User-Agent": "zyf-langgraph-demo/0.1 (learning project; contact: local)",
"Accept": "*/*",
}
# WMO Weather interpretation codes(Open-Meteo 使用的天气代码)
_WMO_WEATHER = {
0: "晴", 1: "大致晴朗", 2: "局部多云", 3: "阴",
45: "雾", 48: "霜雾",
51: "小毛毛雨", 53: "中毛毛雨", 55: "大毛毛雨",
61: "小雨", 63: "中雨", 65: "大雨",
71: "小雪", 73: "中雪", 75: "大雪",
80: "阵雨", 81: "中阵雨", 82: "强阵雨",
95: "雷暴", 96: "雷暴伴小冰雹", 99: "雷暴伴大冰雹",
}
def _clean_search_query(query: str) -> str:
"""从用户原始问题中提取搜索关键词(去掉疑问词与标点)。"""
cleaned = query
for noise in ["什么是", "介绍一下", "介绍", "解释一下", "解释", "请问", "?", "?", "。"]:
cleaned = cleaned.replace(noise, "")
return cleaned.strip() or query
def _ssl_context():
"""构建带 certifi 证书链的 SSL context。
部分 Python 发行版(如 conda)的 urllib 默认找不到系统 CA 证书,
会报 CERTIFICATE_VERIFY_FAILED: unable to get local issuer certificate。
显式使用 certifi 的证书包(httpx 的依赖,必定已安装)可避免该问题。
"""
import ssl
import certifi
return ssl.create_default_context(cafile=certifi.where())
def _http_get_json(url: str, params: dict | None = None, timeout: int = _HTTP_TIMEOUT) -> dict:
"""通过标准库 urllib 请求 JSON API。
注意:这里特意用 urllib 而非 httpx------Wikimedia 边缘节点会按
TLS 指纹拦截部分 HTTP 客户端(httpx 会收到 403),urllib 可正常访问。
"""
import urllib.parse
import urllib.request
if params:
url = f"{url}?{urllib.parse.urlencode(params)}"
req = urllib.request.Request(url, headers=_HTTP_HEADERS)
with urllib.request.urlopen(req, timeout=timeout, context=_ssl_context()) as resp:
return json.loads(resp.read().decode("utf-8"))
def _search_wikipedia(keyword: str) -> str | None:
"""维基百科搜索:找到返回摘要文本,词条不存在返回 None,网络失败抛异常。
维基百科在部分网络环境下访问不稳定,超时设短一些,便于快速降级到备用源。
"""
import urllib.parse
# 第一步:搜索最相关的词条
search_data = _http_get_json(
"https://zh.wikipedia.org/w/rest.php/v1/search/page",
params={"q": keyword, "limit": 1},
timeout=6,
)
pages = search_data.get("pages", [])
if not pages:
return None
page = pages[0]
title = page["title"]
# 相关性校验:维基搜索是模糊匹配,词条不存在时可能返回无关结果。
# 要求关键词出现在标题或摘要片段中,否则视为未找到。
haystack = (title + (page.get("excerpt") or "") + (page.get("description") or "")).lower()
if keyword.lower() not in haystack:
return None
# 第二步:获取词条摘要
summary_data = _http_get_json(
"https://zh.wikipedia.org/api/rest_v1/page/summary/"
+ urllib.parse.quote(title, safe=""),
timeout=6,
)
extract = summary_data.get("extract", "").strip()
if not extract:
return None
return f"维基百科[{title}]: {extract[:200]}"
def _search_baike(keyword: str) -> str | None:
"""百度百科开放 API(国内可稳定访问的备用源):找到返回摘要,否则返回 None。
该 API 对同 IP 连续请求会间歇性返回 {"errno": 2}(限流),因此带一次重试。
"""
import time
for attempt in range(2):
if attempt > 0:
time.sleep(1.0)
data = _http_get_json(
"https://baike.baidu.com/api/openapi/BaikeLemmaCardApi",
params={
"scope": 103,
"format": "json",
"appid": 379020,
"bk_key": keyword,
"bk_length": 600,
},
)
abstract = (data.get("abstract") or "").strip()
if abstract:
return f"百度百科[{data.get('title', keyword)}]: {abstract[:200]}"
return None
# 数据源列表:成功的源会被动态提到最前,适应不同网络环境
# (国内直连时百度百科更稳,可访问维基百科的网络下维基质量更高)
_KNOWLEDGE_SOURCES = [("百度百科", _search_baike), ("维基百科", _search_wikipedia)]
def search_knowledge(query: str) -> str:
"""知识搜索工具:多数据源自动降级,成功的源在后续调用中优先。"""
keyword = _clean_search_query(query)
errors: list[str] = []
for index, (source_name, search_fn) in enumerate(list(_KNOWLEDGE_SOURCES)):
try:
result = search_fn(keyword)
if result:
if index > 0: # 把成功的源提到最前,后续调用少走弯路
_KNOWLEDGE_SOURCES.insert(0, _KNOWLEDGE_SOURCES.pop(index))
return result
except Exception as e: # noqa: BLE001
logger.warning(f"{source_name}访问失败,尝试下一个数据源: {e}")
errors.append(f"{source_name}: {e}")
if len(errors) == len(_KNOWLEDGE_SOURCES):
return f"知识搜索失败: {';'.join(errors)}"
return f"未找到关于'{keyword}'的信息"
def calculate(expression: str) -> str:
"""计算工具:调用 math.js 公开计算 API(无需 API Key),避免本地 eval。"""
import httpx
expr = expression.replace("^", "^").strip() # math.js 原生支持 ^ 幂运算
try:
resp = httpx.get(
"https://api.mathjs.org/v4/",
params={"expr": expr},
timeout=_HTTP_TIMEOUT,
)
if resp.status_code != 200:
return f"计算失败: 无法求解 '{expression}'({resp.text.strip()})"
return f"计算结果: {expression} = {resp.text.strip()}"
except httpx.HTTPError as e:
return f"计算失败(网络/接口错误): {e}"
except Exception as e: # noqa: BLE001
return f"计算失败: {e}"
def _get_weather(topic: str) -> str:
"""调用 Open-Meteo 公开 API 查询实时天气。"""
import httpx
# 从话题中粗提取城市名:"今天北京天气怎么样?" → "北京"
city = topic.split("天气")[0]
for noise in ["今天", "现在", "当前", "的", "查一下", "请问"]:
city = city.replace(noise, "")
city = city.strip() or "北京"
geo_resp = httpx.get(
"https://geocoding-api.open-meteo.com/v1/search",
params={"name": city, "count": 1, "language": "zh", "format": "json"},
timeout=_HTTP_TIMEOUT,
)
geo_resp.raise_for_status()
results = geo_resp.json().get("results")
if not results:
return f"未找到城市'{city}'的地理位置信息"
loc = results[0]
weather_resp = httpx.get(
"https://api.open-meteo.com/v1/forecast",
params={
"latitude": loc["latitude"],
"longitude": loc["longitude"],
"current": "temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code",
},
timeout=_HTTP_TIMEOUT,
)
weather_resp.raise_for_status()
cur = weather_resp.json().get("current", {})
condition = _WMO_WEATHER.get(cur.get("weather_code"), "未知天气")
return (
f"{loc.get('name', city)}实时天气: {condition},"
f"气温 {cur.get('temperature_2m')}°C,"
f"湿度 {cur.get('relative_humidity_2m')}%,"
f"风速 {cur.get('wind_speed_10m')} km/h"
)
def _get_time() -> str:
"""调用 worldtimeapi 公开 API 查询当前时间(失败时回退本地时钟)。"""
import httpx
try:
resp = httpx.get(
"https://worldtimeapi.org/api/timezone/Asia/Shanghai",
timeout=_HTTP_TIMEOUT,
)
resp.raise_for_status()
dt = resp.json().get("datetime", "")
return f"当前时间(Asia/Shanghai,来自 worldtimeapi): {dt[:19].replace('T', ' ')}"
except Exception: # noqa: BLE001
from datetime import datetime
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
return f"当前时间(本地时钟): {now}"
def _get_news() -> str:
"""调用 Hacker News 公开 API 获取科技新闻头条。"""
import httpx
top_resp = httpx.get(
"https://hacker-news.firebaseio.com/v0/topstories.json",
timeout=_HTTP_TIMEOUT,
)
top_resp.raise_for_status()
story_ids = top_resp.json()[:3]
titles = []
for sid in story_ids:
item_resp = httpx.get(
f"https://hacker-news.firebaseio.com/v0/item/{sid}.json",
timeout=_HTTP_TIMEOUT,
)
item_resp.raise_for_status()
title = (item_resp.json() or {}).get("title")
if title:
titles.append(title)
if not titles:
return "暂无科技新闻数据"
return "Hacker News 科技头条: " + ";".join(titles)
def get_current_info(topic: str) -> str:
"""实时信息工具:按话题分发到不同的公开 API(天气/时间/新闻)。"""
import httpx
try:
if "天气" in topic:
return _get_weather(topic)
if "时间" in topic:
return _get_time()
if "新闻" in topic:
return _get_news()
return f"暂无'{topic}'的实时信息"
except httpx.HTTPError as e:
return f"实时信息查询失败(网络/接口错误): {e}"
except Exception as e: # noqa: BLE001
return f"实时信息查询失败: {e}"
TOOLS = {
"search_knowledge": search_knowledge,
"calculate": calculate,
"get_current_info": get_current_info,
}
TOOL_DESCRIPTIONS = """可用工具(均为真实公开 API):
1. search_knowledge(query) - 百科知识搜索(百度百科/维基百科),查询概念、人物、事物;args 传搜索关键词
2. calculate(expression) - math.js 在线数学计算;args 传数学表达式,如 "15 * 8 + 42"
3. get_current_info(topic) - 获取实时信息;args 需包含"天气"(可带城市名)、"时间"或"新闻"关键字"""
# ============================================================
# 真实 LLM(通过 shared.get_llm 获取 .env 配置的在线模型)
# ============================================================
_LLM = None
def _get_react_llm():
"""获取真实在线 LLM 实例(模块内复用,fallback_to_mock=False 确保真实调用)。"""
global _LLM
if _LLM is None:
_LLM = get_llm(fallback_to_mock=False)
return _LLM
def _parse_llm_json(text: str) -> dict | None:
"""从 LLM 输出中解析 JSON 决策(容忍 Markdown 代码块包裹等格式噪音)。"""
text = text.strip()
if text.startswith("```"):
text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text, flags=re.S).strip()
try:
return json.loads(text)
except json.JSONDecodeError:
match = re.search(r"\{.*\}", text, re.S)
if match:
try:
return json.loads(match.group())
except json.JSONDecodeError:
return None
return None
_REASONING_SYSTEM_PROMPT = f"""你是一个严格遵循 ReAct(Reasoning + Acting)模式的推理 Agent。
{TOOL_DESCRIPTIONS}
每一轮你只输出一个 JSON 对象(不要输出任何其他文字):
{{"thought": "本轮的思考过程", "action": "tool_call 或 final", "tool": "工具名,action=tool_call 时必填", "args": "工具入参字符串,action=tool_call 时必填"}}
决策规则:
1. 需要外部信息(百科知识、数学计算、天气/时间/新闻)时,action 填 tool_call 并选择合适工具
2. 已有观察结果足以回答,或问题无需工具即可回答时,action 填 final
3. 工具返回"未找到"或"失败"时可调整 args 重试,但不要重复完全相同的调用"""
# ============================================================
# ReAct State 定义
# ============================================================
class ReActState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
current_step: str # "reasoning" | "action" | "observation" | "final"
iteration: int
max_iterations: int
reasoning_trace: list[str] # 完整推理链
tool_calls: list[dict] # 待执行的工具调用
observations: list[str] # 工具返回的观察结果
# ============================================================
# ReAct 节点实现
# ============================================================
def reasoning_node(state: ReActState) -> dict:
"""Reasoning 节点:真实 LLM 分析状态,决定是否需要工具调用或直接回答。"""
iteration = state["iteration"]
max_iter = state["max_iterations"]
observations = state.get("observations", [])
reasoning_trace = list(state.get("reasoning_trace", []))
log_step(logger, "Reasoning", f"第 {iteration + 1}/{max_iter} 轮思考(真实 LLM)")
last_user_msg = ""
for msg in reversed(state["messages"]):
if isinstance(msg, HumanMessage):
last_user_msg = str(msg.content)
break
obs_text = (
"\n".join(f"- 观察{i + 1}: {obs}" for i, obs in enumerate(observations))
if observations
else "(暂无观察结果)"
)
user_prompt = (
f"用户问题: {last_user_msg}\n"
f"当前第 {iteration + 1}/{max_iter} 轮推理。\n"
f"已获得的观察结果:\n{obs_text}\n"
f"请输出本轮决策 JSON。"
)
llm = _get_react_llm()
response = llm.invoke([
SystemMessage(content=_REASONING_SYSTEM_PROMPT),
HumanMessage(content=user_prompt),
])
raw_output = str(response.content)
decision = _parse_llm_json(raw_output)
if decision is None:
thought = f"[思考] LLM 输出无法解析为决策 JSON,转入最终回答。原始输出: {raw_output[:100]}"
tool_call = None
else:
thought = f"[思考] {decision.get('thought', '(LLM 未给出思考内容)')}"
tool_name = decision.get("tool")
if decision.get("action") == "tool_call" and tool_name in TOOLS:
tool_call = {"tool": tool_name, "args": str(decision.get("args", ""))}
thought += f"\n[决定] 调用工具 {tool_name}('{tool_call['args']}')"
else:
tool_call = None
thought += "\n[决定] 信息足够,生成最终回答。"
reasoning_trace.append(thought)
print(f" {thought}")
tool_calls = [tool_call] if tool_call else []
next_step = "action" if tool_calls else "final"
return {
"current_step": next_step,
"reasoning_trace": reasoning_trace,
"tool_calls": tool_calls,
}
def action_node(state: ReActState) -> dict:
"""Action 节点:执行工具调用。"""
tool_calls = state.get("tool_calls", [])
observations = list(state.get("observations", []))
reasoning_trace = list(state.get("reasoning_trace", []))
for call in tool_calls:
tool_name = call["tool"]
tool_args = call["args"]
log_step(logger, "Action", f"调用工具 {tool_name}({tool_args})")
action_log = f"[行动] 调用 {tool_name}('{tool_args}')"
reasoning_trace.append(action_log)
print(f" {action_log}")
tool_fn = TOOLS.get(tool_name)
if tool_fn:
result = tool_fn(tool_args)
else:
result = f"错误: 工具 '{tool_name}' 不存在"
obs_log = f"[观察] {result}"
reasoning_trace.append(obs_log)
observations.append(result)
print(f" {obs_log}")
return {
"current_step": "observation",
"observations": observations,
"reasoning_trace": reasoning_trace,
"tool_calls": [],
}
def observation_node(state: ReActState) -> dict:
"""Observation 节点:处理观察结果,更新迭代计数,决定下一步。"""
iteration = state["iteration"] + 1
log_step(logger, "Observation", f"完成第 {iteration} 轮,检查是否需要继续")
return {
"current_step": "reasoning",
"iteration": iteration,
}
def final_node(state: ReActState) -> dict:
"""Final 节点:真实 LLM 综合推理链与观察结果,生成最终回答。"""
observations = state.get("observations", [])
reasoning_trace = state.get("reasoning_trace", [])
log_success(logger, f"生成最终回答(共 {state['iteration'] + 1} 轮推理,真实 LLM)")
last_user_msg = ""
for msg in reversed(state["messages"]):
if isinstance(msg, HumanMessage):
last_user_msg = str(msg.content)
break
if observations:
obs_text = "\n".join(f"- {obs}" for obs in observations)
prompt = (
f"用户问题: {last_user_msg}\n"
f"以下是通过工具调用获得的观察结果:\n{obs_text}\n"
f"请综合这些信息,用中文简洁、准确地回答用户问题。"
f"若观察结果显示未找到有效信息,请如实说明。"
)
else:
prompt = f"请用中文简洁地回答用户问题: {last_user_msg}"
llm = _get_react_llm()
response = llm.invoke([HumanMessage(content=prompt)])
answer = str(response.content).strip()
trace_summary = "\n".join(reasoning_trace)
final_msg = f"{answer}\n\n--- 推理链 ---\n{trace_summary}"
return {
"messages": [AIMessage(content=final_msg)],
"current_step": "done",
}
# ============================================================
# 条件边:控制循环
# ============================================================
def should_continue_after_reasoning(state: ReActState) -> str:
"""Reasoning 后的条件判断:去 Action 还是 Final。"""
if state["current_step"] == "action":
return "action"
return "final"
def should_continue_after_observation(state: ReActState) -> str:
"""Observation 后的条件判断:继续循环还是结束。"""
if state["iteration"] >= state["max_iterations"]:
log_warning(logger, f"达到最大迭代次数 {state['max_iterations']},强制结束")
return "final"
return "reasoning"
# ============================================================
# 构建 ReAct Graph
# ============================================================
def build_react_graph(max_iterations: int = MAX_ITERATIONS):
"""构建 ReAct Pattern 图。
图结构:
START → reasoning → [action → observation → reasoning ...] → final → END
"""
graph = StateGraph(ReActState)
graph.add_node("reasoning", reasoning_node)
graph.add_node("action", action_node)
graph.add_node("observation", observation_node)
graph.add_node("final", final_node)
graph.add_edge(START, "reasoning")
graph.add_conditional_edges(
"reasoning",
should_continue_after_reasoning,
{"action": "action", "final": "final"},
)
graph.add_edge("action", "observation")
graph.add_conditional_edges(
"observation",
should_continue_after_observation,
{"reasoning": "reasoning", "final": "final"},
)
graph.add_edge("final", END)
return graph
# ============================================================
# 对比:传统 Chain(无推理)
# ============================================================
class SimpleChainState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
def build_simple_chain():
"""构建传统 Chain(无 ReAct 推理,单次真实 LLM 调用),用于对比演示。"""
llm = _get_react_llm()
def chat_node(state: SimpleChainState) -> dict:
response = llm.invoke(state["messages"])
return {"messages": [response]}
graph = StateGraph(SimpleChainState)
graph.add_node("chat", chat_node)
graph.add_edge(START, "chat")
graph.add_edge("chat", END)
return graph
# ============================================================
# 运行演示
# ============================================================
def demo_react_basic():
"""演示 1:基本 ReAct 循环。"""
print("\n--- 演示 1: 基本 ReAct 循环 ---\n")
graph = build_react_graph(max_iterations=5)
app = graph.compile()
test_queries = [
"什么是Python?",
"计算 15 * 8 + 42",
"今天北京天气怎么样?",
]
results = []
for query in test_queries:
print(f"\n 用户: {query}")
print(f" {'─' * 50}")
result = app.invoke({
"messages": [HumanMessage(content=query)],
"current_step": "reasoning",
"iteration": 0,
"max_iterations": 5,
"reasoning_trace": [],
"tool_calls": [],
"observations": [],
})
final_msg = result["messages"][-1]
answer_part = str(final_msg.content).split("\n\n--- 推理链 ---")[0]
print(f"\n 最终回答: {answer_part}")
print(f" 总迭代次数: {result['iteration'] + 1}")
print()
results.append(result)
return results
def demo_react_vs_chain():
"""演示 2:ReAct vs 传统 Chain 对比。"""
print("\n--- 演示 2: ReAct vs 传统 Chain 对比 ---\n")
query = "什么是机器学习?"
print(f" 问题: {query}\n")
print(" [传统 Chain]:")
simple_graph = build_simple_chain()
simple_app = simple_graph.compile()
simple_result = simple_app.invoke({
"messages": [HumanMessage(content=query)],
})
print(f" 回答: {simple_result['messages'][-1].content}\n")
print(" [ReAct Pattern]:")
react_graph = build_react_graph(max_iterations=3)
react_app = react_graph.compile()
react_result = react_app.invoke({
"messages": [HumanMessage(content=query)],
"current_step": "reasoning",
"iteration": 0,
"max_iterations": 3,
"reasoning_trace": [],
"tool_calls": [],
"observations": [],
})
final_msg = react_result["messages"][-1]
answer_part = str(final_msg.content).split("\n\n--- 推理链 ---")[0]
print(f"\n 回答: {answer_part}")
print(f" 推理轮次: {react_result['iteration'] + 1}\n")
return {"simple": simple_result, "react": react_result}
def demo_max_iterations_guard():
"""演示 3:死循环防护(max_iterations 限制)。"""
print("\n--- 演示 3: 死循环防护 ---\n")
graph = build_react_graph(max_iterations=2)
app = graph.compile()
query = "解释一个不存在的概念 zzxyqk123"
print(f" 用户: {query}")
print(" max_iterations = 2")
print(f" {'─' * 50}")
result = app.invoke({
"messages": [HumanMessage(content=query)],
"current_step": "reasoning",
"iteration": 0,
"max_iterations": 2,
"reasoning_trace": [],
"tool_calls": [],
"observations": [],
})
print(f"\n 最终迭代次数: {result['iteration']}")
guard_status = "触发(强制终止)" if result["iteration"] >= 2 else "未触发(LLM 提前收敛,主动停止重试)"
print(f" 防护机制: {guard_status}")
print()
return result
def run_demo() -> dict:
"""运行 ReAct Pattern 全部演示。"""
print("=" * 60)
print(" Demo 01: ReAct Pattern --- Reasoning-Action-Observation 循环")
print("=" * 60)
basic_results = demo_react_basic()
comparison = demo_react_vs_chain()
guard_result = demo_max_iterations_guard()
print()
print("=" * 60)
print(" 关键概念回顾")
print("=" * 60)
print(" 1. Reasoning : LLM 分析问题,决定行动策略")
print(" 2. Action : 调用工具获取外部信息")
print(" 3. Observation: 处理工具返回结果")
print(" 4. 条件边 : 控制循环流转(继续/结束)")
print(" 5. 防死循环 : max_iterations 强制终止")
print()
return {
"basic_results": basic_results,
"comparison": comparison,
"guard_result": guard_result,
}
if __name__ == "__main__":
run_demo()
(一)全链路真实调用:不是 Mock 出来的"Agent 感"
这个 Demo 的价值,在于每一环都是真实发生的:

|--------|------------------------------------------------------------|
| 环节 | 实现 |
| LLM 决策 | shared.get_llm(fallback_to_mock=False),使用 .env 配置的在线模型 |
| 知识搜索 | 百度百科 / 维基百科公开 API,主备自动降级 |
| 数学计算 | math.js 公开计算 API |
| 实时天气 | Open-Meteo |
| 当前时间 | worldtimeapi,失败时回退本地时钟 |
| 科技新闻 | Hacker News 公开 API |
这类设计有一个很重要的工程含义:工具的返回值不仅是数据,也是给下一轮 LLM 看的接口协议。
例如:
-
返回"未找到",模型可能尝试换关键词;
-
返回"访问失败",模型应该考虑备用源或如实说明;
-
返回有效摘要,模型可能直接结束;
-
返回一大段无关 JSON,模型既浪费上下文,也更容易误判。
因此,工具设计不能只考虑"函数能不能返回",还要考虑:
-
返回信息是否明确;
-
错误类型是否可区分;
-
长度是否受控;
-
是否做了相关性校验;
-
是否方便模型在下一轮做正确决策。
(二)真实 LLM 如何做决策:工具菜单 + JSON 协议
Reasoning 节点没有使用关键词规则,而是给模型一份明确的工具说明和输出协议。原始代码如下:
python
_REASONING_SYSTEM_PROMPT = f"""你是一个严格遵循 ReAct(Reasoning + Acting)模式的推理 Agent。
{TOOL_DESCRIPTIONS}
每一轮你只输出一个 JSON 对象(不要输出任何其他文字):
{{"thought": "本轮的思考过程", "action": "tool_call 或 final", "tool": "工具名,action=tool_call 时必填", "args": "工具入参字符串,action=tool_call 时必填"}}
决策规则:
1. 需要外部信息(百科知识、数学计算、天气/时间/新闻)时,action 填 tool_call 并选择合适工具
2. 已有观察结果足以回答,或问题无需工具即可回答时,action 填 final
3. 工具返回"未找到"或"失败"时可调整 args 重试,但不要重复完全相同的调用"""
这个提示词做了三件事:
-
限制输出格式:每轮只能输出一个 JSON 对象;
-
限制动作空间 :
action只能是tool_call或final; -
定义失败行为:允许调整参数重试,但禁止无意义地重复完全相同的调用。
1. 为什么工具描述必须像 API 文档一样清楚
模型不会"天然知道"某个函数该怎么用。它对工具的理解,完全来自你提供的文字说明。
如果描述只写"计算工具",模型可能传入"请帮我计算一下十五乘八再加四十二";而如果描述明确写出"args 传数学表达式,如 15 * 8 + 42",调用成功率会显著提高。
可以把 LLM 看成一个根据自然语言 OpenAPI 文档调用下游服务的客户端。文档模糊,调用就会模糊。
2. 模型输出永远不能被无条件信任
即使提示词明确要求"只输出 JSON",模型仍可能:
-
使用 Markdown 代码块包裹 JSON;
-
在 JSON 前后补充说明;
-
漏字段;
-
输出不存在的工具名;
-
返回格式错误的 JSON。
我对代码在解析决策时做了防御性处理:
python
llm = _get_react_llm()
response = llm.invoke([
SystemMessage(content=_REASONING_SYSTEM_PROMPT),
HumanMessage(content=user_prompt),
])
raw_output = str(response.content)
decision = _parse_llm_json(raw_output)
if decision is None:
thought = f"[思考] LLM 输出无法解析为决策 JSON,转入最终回答。原始输出: {raw_output[:100]}"
tool_call = None
else:
thought = f"[思考] {decision.get('thought', '(LLM 未给出思考内容)')}"
tool_name = decision.get("tool")
if decision.get("action") == "tool_call" and tool_name in TOOLS:
tool_call = {"tool": tool_name, "args": str(decision.get("args", ""))}
thought += f"\n[决定] 调用工具 {tool_name}('{tool_call['args']}')"
else:
tool_call = None
thought += "\n[决定] 信息足够,生成最终回答。"
这里有两道重要兜底:
-
JSON 无法解析时,不让图直接崩溃,而是转入最终回答;
-
工具名不在
TOOLS中时,不执行未知函数,同样转入final。
这和后端服务解析上游响应的原则完全一致:协议要严格,解析要宽容,失败要可控。
(三)条件边:真正控制循环的地方
ReAct 的循环不是写在节点内部,而是通过条件边显式表达。
代码中,Reasoning 后决定去 Action 还是 Final;Observation 后决定继续循环还是强制结束:
python
def should_continue_after_reasoning(state: ReActState) -> str:
"""Reasoning 后的条件判断:去 Action 还是 Final。"""
if state["current_step"] == "action":
return "action"
return "final"
def should_continue_after_observation(state: ReActState) -> str:
"""Observation 后的条件判断:继续循环还是结束。"""
if state["iteration"] >= state["max_iterations"]:
log_warning(logger, f"达到最大迭代次数 {state['max_iterations']},强制结束")
return "final"
return "reasoning"
第二个函数是整个系统的安全带。
只要满足:
python
state["iteration"] >= state["max_iterations"]
无论模型是否还想继续搜索,图都会转入 final。这不是"优化项",而是任何循环 Agent 都必须具备的硬性约束。
(四)图是如何拼起来的

原始图构建代码如下:
python
graph.add_edge(START, "reasoning")
graph.add_conditional_edges(
"reasoning",
should_continue_after_reasoning,
{"action": "action", "final": "final"},
)
graph.add_edge("action", "observation")
graph.add_conditional_edges(
"observation",
should_continue_after_observation,
{"reasoning": "reasoning", "final": "final"},
)
graph.add_edge("final", END)
把它翻译成自然语言,就是:
-
从
START进入reasoning; -
如果模型决定调用工具,走向
action; -
Action 执行后进入
observation; -
未达到上限,回到
reasoning; -
模型主动结束或达到上限,进入
final; -
最终答案生成后进入
END。
(五)一次真实错误恢复:为什么"搜不到"不等于失败
ReAct 最有价值的能力,不是第一次调用就成功,而是第一次失败后仍能继续做出合理决策。
原始运行示例中,用户问:
什么是 Python?
第一轮,模型判断这是知识性问题,于是调用:
python
search_knowledge('Python 编程语言')
但百科搜索没有精确命中,工具返回"未找到"。在固定流水线里,程序可能就此失败;在 ReAct 中,这个结果会成为新的 Observation。
下一轮模型看到"未找到",自行把关键词调整为:
python
search_knowledge('Python')
第二次搜索命中后,模型判断信息足够,转入 Final。

这段过程可以拆成五步:
-
判断问题类型:知识查询;
-
选择工具:
search_knowledge; -
读取失败结果:未找到;
-
修改参数:缩短并泛化关键词;
-
拿到有效信息后主动结束。
这正是规则代码很难覆盖的部分。你可以写死"Python 编程语言失败后改搜 Python",但现实里问题无穷无尽,不可能为每个关键词预先写重试规则。ReAct 把"如何调整"交给模型,把"能不能继续、最多继续几次"交给工程约束。
(六)计算与实时信息:工具调用不应该只是装饰
知识查询只是其中一类场景。Demo 还展示了计算和实时信息调用。
1. 在线计算
用户: 计算 15 * 8 + 42
[行动] 调用 calculate('15 * 8 + 42')
[观察] 计算结果: 15 * 8 + 42 = 162
最终回答: 15 * 8 + 42 = 162
这里的意义不只是"模型也能算出 162",而是计算过程交给了专门工具,结果可验证、可复现,也避免使用本地 eval 带来的注入风险。
2. 实时天气
用户: 今天北京天气怎么样?
[行动] 调用 get_current_info('天气 北京')
[观察] 北京实时天气: 晴,气温 32.6°C,湿度 55%,风速 9.9 km/h
最终回答: 今天北京天气晴,气温32.6°C,湿度55%,风速9.9公里/小时。
这里最重要的是数据来源路径:模型没有凭空补全天气,而是根据工具返回组织语言。
在真实产品中,最终答案还应补充数据时间、来源和失败提示,避免用户把缓存数据或过期数据误认为"此刻实时值"。
(十)max_iterations:ReAct 的命门为什么是防死循环
循环 Agent 的风险并不抽象。只要外部工具不稳定,模型就可能做出这样的行为:
-
第一次搜不到,换关键词;
-
第二次仍搜不到,再换关键词;
-
第三个数据源超时,换数据源;
-
新数据源返回无关结果,再搜索;
-
模型一直觉得"还差一点信息"。
如果没有上限,程序会持续运行、Token 持续消耗、接口不断被请求,最终可能触发框架递归错误、业务超时或费用异常。
Demo 用 max_iterations 作为业务层限制。即使某次演示中模型第一轮就识别出虚构概念并主动停止,也不能因此省略防护。
防护机制不是为平均情况准备的,而是为最坏情况准备的。
1. 两层终止防线
生产环境建议至少保留两层:
-
业务层计数器 :
iteration+max_iterations,可以按业务类型设置; -
框架层限制:例如图执行的递归/步数上限,防止业务逻辑遗漏。
业务层上限更适合输出可理解的结束原因;框架层限制则是最后一道兜底。
2. 不同任务不应共用一个固定上限
-
简单知识问答:通常 2~3 次工具调用已经足够;
-
多源验证:可能需要 4~6 轮;
-
复杂研究任务:应该使用 Planning、子任务拆分和总预算,而不是无限提高 ReAct 上限。
max_iterations 不是越大越好。它本质上是质量、成本、延迟和失败风险之间的预算控制。
五、排坑与生产快速提醒说明
(一)真实接入踩坑:Mock 环境不会教你的事
一旦从 Mock 切换到真实 API,问题的性质会完全变化。原始实践中遇到的坑,几乎都是"外部世界不可靠"的具体表现。
1. HTTP 客户端指纹与 403
同一个 Wikimedia 请求,httpx 可能返回 403,而 curl 或标准库 urllib 可以正常访问。排查这类问题时,不要只盯着 URL 和 Header;同一请求在不同 HTTP 客户端中的表现差异,可能指向 TLS 指纹、边缘风控或连接栈差异。
排查方法:用多个客户端交叉验证,把问题快速定位到"请求参数""网络环境"还是"客户端特征"。
2. CA 证书链问题
某些 Python 发行环境中,urllib 找不到系统 CA 证书,会出现:
CERTIFICATE_VERIFY_FAILED: unable to get local issuer certificate
这不一定代表网络不可达,而可能只是证书链配置缺失。原始代码显式使用 certifi 构建 SSL context,避免依赖系统默认配置。
3. 免费公开 API 没有 SLA
公开 API 可能有很多隐性规则:
-
缺少某个 Header 就返回错误;
-
同 IP 连续请求被限流;
-
模糊搜索返回表面相似、实际无关的内容;
-
网络质量不同导致某个源长期超时;
-
返回结构随服务变化。
因此,公开 API 的防御性编程不是锦上添花,而是最低要求。
4. 多源降级与自适应排序
代码为知识搜索设置了百度百科与维基百科两个数据源,并在某个源成功后把它移动到列表前面,使后续调用优先访问当前网络环境下更可靠的源:
python
# 数据源列表:成功的源会被动态提到最前,适应不同网络环境
# (国内直连时百度百科更稳,可访问维基百科的网络下维基质量更高)
_KNOWLEDGE_SOURCES = [("百度百科", _search_baike), ("维基百科", _search_wikipedia)]
def search_knowledge(query: str) -> str:
"""知识搜索工具:多数据源自动降级,成功的源在后续调用中优先。"""
keyword = _clean_search_query(query)
errors: list[str] = []
for index, (source_name, search_fn) in enumerate(list(_KNOWLEDGE_SOURCES)):
try:
result = search_fn(keyword)
if result:
if index > 0: # 把成功的源提到最前,后续调用少走弯路
_KNOWLEDGE_SOURCES.insert(0, _KNOWLEDGE_SOURCES.pop(index))
return result
except Exception as e: # noqa: BLE001
logger.warning(f"{source_name}访问失败,尝试下一个数据源: {e}")
errors.append(f"{source_name}: {e}")
if len(errors) == len(_KNOWLEDGE_SOURCES):
return f"知识搜索失败: {';'.join(errors)}"
return f"未找到关于'{keyword}'的信息"
这里有一个非常值得借鉴的细节:代码区分了"未找到"和"访问失败"。
-
所有源都报错,才返回"知识搜索失败";
-
至少有一个源正常响应但没有结果,返回"未找到"。
两者对下一轮 Reasoning 的意义完全不同。前者更适合降级或如实说明服务不可用,后者更适合调整关键词。
(二)五个高频坑与排查方法
1. 坑 1:没有硬上限,Agent 一直循环
现象:日志里 Reasoning / Action 不断重复,程序迟迟不返回,Token 与接口调用持续增长。
原因:模型始终判断信息不足,图中没有强制终止条件。
处理:业务层 max_iterations 与框架层执行上限同时启用,并设置任务总超时。
2. 坑 2:工具描述含糊,模型选错工具或参数格式
现象:天气问题被拿去搜百科,计算工具收到整句自然语言,工具参数缺失。
原因:模型只能根据工具说明做选择,说明不清就等于接口文档不清。
处理:在工具描述中明确适用场景、参数格式、正例与限制条件。
3. 坑 3:Observation 原样堆回上下文
现象:工具返回几千行 JSON,几轮后上下文迅速膨胀,触发长度限制或成本飙升。
原因:没有对返回结果做截断、摘要、字段筛选和去重。
处理:只保留做下一步决策真正需要的信息;大结果先结构化提取或摘要,再写入状态。
4. 坑 4:把模型输出当成绝对可靠的 JSON
现象:偶发 JSONDecodeError,整个图执行失败。
原因:模型可能输出代码块、前后说明、格式错误或未知工具名。
处理:剥离代码块、尝试提取 JSON、校验字段、校验工具白名单,彻底失败时走安全兜底。
5. 坑 5:只保存最终答案,线上问题无法复盘
现象:用户看到一个奇怪答案,但工程团队不知道模型调用了什么、拿到了什么结果、在哪一步偏离。
原因:缺少决策轨迹和工具调用日志。
处理:记录每轮动作摘要、工具名、参数、返回摘要、耗时、异常、轮次和终止原因;必要时接入全链路追踪平台。
(三)生产级方案:五层工程护栏

1. 终止与预算
-
业务
max_iterations; -
框架步数/递归上限;
-
总 Token 预算;
-
总工具调用次数;
-
单类工具调用次数限制。
2. 分级超时
不要只设一个总超时。建议至少区分:
-
单轮 LLM 思考超时;
-
单次工具调用超时;
-
单数据源超时;
-
整个任务总超时。
这样才能避免某个慢接口拖垮整条链路。
3. 结果缓存与重复调用检测
对于相同工具、相同参数,在短时间内应优先读取缓存;同时记录历史调用,拒绝完全相同的无意义重试。
4. 外部依赖降级
-
多数据源;
-
失败分类;
-
相关性校验;
-
熔断与退避;
-
成功源动态优先;
-
工具不可用时回退到保守回答。
5. 可观测性
生产日志至少应回答这些问题:
-
用户问题是什么;
-
每轮选择了什么动作;
-
调用了哪个工具、参数是什么;
-
工具耗时与返回摘要是什么;
-
是否发生重试、降级或解析失败;
-
为什么结束;
-
总轮次、总耗时和成本是多少。
可观测性不是为了展示"模型有多聪明",而是为了在它做错时快速定位原因。
六、什么时候该用 ReAct,什么时候不该用
ReAct 并不是所有问题的默认最优解。
(一)适合 ReAct 的任务
-
需要动态选择一个或多个工具;
-
工具结果会影响下一步;
-
可能需要重试、换参数或换数据源;
-
需要实时信息或外部证据;
-
任务步骤无法在编码时完全确定。
(二)更适合普通 Chain 的任务
-
改写、润色、翻译、摘要;
-
已知上下文内的一次性回答;
-
固定格式生成;
-
没有外部工具需求;
-
延迟和成本极其敏感。
(三)更适合 Planning Agent 的任务
-
需要先拆成多个子任务;
-
子任务之间有依赖关系;
-
任务周期长、步骤多;
-
需要进度跟踪和分阶段产出;
-
单纯"走一步看一步"容易迷失方向。
一个实用策略是先做查询分类:简单问题走 Chain;需要动态工具与纠错的问题走 ReAct;复杂长任务进入 Planning + Execution。
七、运行方式与阅读代码的建议顺序
运行原始 Demo 前,需要联网,并在 .env 中配置好 LLM Provider:
bash
source .venv/bin/activate
python stages/stage4_reasoning/01_react_pattern/main.py
建议按照以下顺序阅读 main.py:
-
先看
ReActState,理解状态里保存了什么; -
再看
TOOLS与工具描述,理解动作空间; -
看
_REASONING_SYSTEM_PROMPT,理解决策协议; -
看
reasoning_node,理解模型如何产出动作; -
看
action_node与observation_node,理解结果如何写回; -
看两个条件函数,理解循环与熔断;
-
最后看
build_react_graph,把所有节点和边拼成完整流程。
这样阅读,比从文件第一行一路往下看,更容易先建立整体模型。
八、总结:ReAct 的灵魂、价值与边界
ReAct 把 LLM 从"一次性文本生成器"升级为一个能够在外部世界中逐步行动的 Agent。
它的灵魂是:
Reasoning → Action → Observation → 再 Reasoning。
它的实际价值是:
-
能够获取实时或可验证的信息;
-
能够根据工具失败调整路径;
-
能够把复杂问题拆成多轮决策;
-
能够留下清晰的调用与决策轨迹。
它的安全底线是:
任何循环都必须有硬上限。
真实接入后你会发现,模型确实能做出规则代码很难覆盖的自适应调整;同时,网络、证书、限流、客户端差异和数据源质量也会带来 Mock 环境中不存在的问题。
成熟的 ReAct 系统从不依赖"模型永远正确",而是依靠:
-
清晰的工具协议;
-
严格的解析与白名单;
-
多源降级;
-
上下文控制;
-
双层终止机制;
-
全链路可观测。
理解这套机制,你就不仅能"调用一个现成 Agent",还能够判断它为什么有效、何时失控,以及应该在什么位置加上工程护栏。
下一篇可以自然进入 Planning Agent:当任务不再适合"走一步看一步",如何先生成结构化计划,再分步执行、追踪进度,并用 max_steps 控制整体任务终止。