LangGraph 深入理解 ReAct:让 AI Agent 真正学会「边想边做」

目录

[一、LLM 给出的答案,究竟是"查到的"还是"写出来的"?](#一、LLM 给出的答案,究竟是“查到的”还是“写出来的”?)

[二、为什么需要 ReAct:模型的两个天然短板](#二、为什么需要 ReAct:模型的两个天然短板)

(一)知识不是实时数据库

(二)语言生成不等于精确执行

[(三)传统 Chain 与 ReAct 的关键差异](#(三)传统 Chain 与 ReAct 的关键差异)

[三、核心原理:Reasoning → Action → Observation](#三、核心原理: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_iterationsrecursion_limit、超时、上下文截断、多源降级等工程化护栏。

一、LLM 给出的答案,究竟是"查到的"还是"写出来的"?

你有没有遇到过这种场景:你问一个大模型"今天北京天气怎么样""帮我计算一串复杂表达式""某个刚发生的新闻有什么进展",它很快给出一段看起来完整、措辞也很自然的回答。

问题是:它真的查了吗?

在最传统的 Chain 模式里,模型往往只是接收输入,然后一次性生成输出。它可能依靠训练语料、上下文和语言概率,续写出一段"听起来合理"的文本,却没有真正访问天气服务、计算服务或知识库。

如果你是后端工程师,可以把传统 Chain 想象成这样一个方法:

python 复制代码
result = llm.invoke(user_input)
return result

它有输入,有输出,但中间没有动态分支、没有外部调用、没有基于返回值的重试,也没有"这一步失败后该怎么办"的决策过程。

ReAct 改变的正是这一点。它给 LLM 加上的不是一个固定工具按钮,而是一套循环能力:

先判断当前缺什么信息,再选择行动;拿到行动结果后重新判断,直到信息足够或触发终止条件。

2022 年提出的 ReAct(Reasoning + Acting)模式,把"推理"和"行动"交替组织起来,使模型不再只是一次性回答,而是能够在外部世界中获取证据、修正路径并逐步收敛。

二、为什么需要 ReAct:模型的两个天然短板

LLM 很擅长语言组织、语义理解和模式归纳,但它有两个无法靠"再多想一会儿"彻底解决的短板。

(一)知识不是实时数据库

模型无法天然知道"今天北京的实时气温""当前时间""刚刚发生的科技新闻"。即使它训练时见过相关知识,也不代表这些知识在今天仍然有效。

(二)语言生成不等于精确执行

对于 15 * 8 + 42 这样的简单表达式,模型通常能答对;但当表达式更复杂、步骤更多或涉及严格格式时,纯语言生成并不是最可靠的计算引擎。

因此,真正可靠的 Agent 需要两类能力同时存在:

  1. 判断能力:现在应该直接回答,还是调用工具?调用哪个工具?传什么参数?

  2. 执行能力:真正访问外部服务,拿到可验证的结果。

只有工具,没有判断,系统会退化成固定工作流;只有判断,没有工具,系统仍然只是"在脑中推演"。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_callfinal

  • 定义失败行为:允许调整参数重试,但禁止无意义地重复完全相同的调用。

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)

把它翻译成自然语言,就是:

  1. START 进入 reasoning

  2. 如果模型决定调用工具,走向 action

  3. Action 执行后进入 observation

  4. 未达到上限,回到 reasoning

  5. 模型主动结束或达到上限,进入 final

  6. 最终答案生成后进入 END

(五)一次真实错误恢复:为什么"搜不到"不等于失败

ReAct 最有价值的能力,不是第一次调用就成功,而是第一次失败后仍能继续做出合理决策。

原始运行示例中,用户问:

什么是 Python?

第一轮,模型判断这是知识性问题,于是调用:

python 复制代码
search_knowledge('Python 编程语言')

但百科搜索没有精确命中,工具返回"未找到"。在固定流水线里,程序可能就此失败;在 ReAct 中,这个结果会成为新的 Observation。

下一轮模型看到"未找到",自行把关键词调整为:

python 复制代码
search_knowledge('Python')

第二次搜索命中后,模型判断信息足够,转入 Final。

这段过程可以拆成五步:

  1. 判断问题类型:知识查询;

  2. 选择工具:search_knowledge

  3. 读取失败结果:未找到;

  4. 修改参数:缩短并泛化关键词;

  5. 拿到有效信息后主动结束。

这正是规则代码很难覆盖的部分。你可以写死"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

  1. 先看 ReActState,理解状态里保存了什么;

  2. 再看 TOOLS 与工具描述,理解动作空间;

  3. _REASONING_SYSTEM_PROMPT,理解决策协议;

  4. reasoning_node,理解模型如何产出动作;

  5. action_nodeobservation_node,理解结果如何写回;

  6. 看两个条件函数,理解循环与熔断;

  7. 最后看 build_react_graph,把所有节点和边拼成完整流程。

这样阅读,比从文件第一行一路往下看,更容易先建立整体模型。

八、总结:ReAct 的灵魂、价值与边界

ReAct 把 LLM 从"一次性文本生成器"升级为一个能够在外部世界中逐步行动的 Agent。

它的灵魂是:

Reasoning → Action → Observation → 再 Reasoning。

它的实际价值是:

  • 能够获取实时或可验证的信息;

  • 能够根据工具失败调整路径;

  • 能够把复杂问题拆成多轮决策;

  • 能够留下清晰的调用与决策轨迹。

它的安全底线是:

任何循环都必须有硬上限。

真实接入后你会发现,模型确实能做出规则代码很难覆盖的自适应调整;同时,网络、证书、限流、客户端差异和数据源质量也会带来 Mock 环境中不存在的问题。

成熟的 ReAct 系统从不依赖"模型永远正确",而是依靠:

  • 清晰的工具协议;

  • 严格的解析与白名单;

  • 多源降级;

  • 上下文控制;

  • 双层终止机制;

  • 全链路可观测。

理解这套机制,你就不仅能"调用一个现成 Agent",还能够判断它为什么有效、何时失控,以及应该在什么位置加上工程护栏。

下一篇可以自然进入 Planning Agent:当任务不再适合"走一步看一步",如何先生成结构化计划,再分步执行、追踪进度,并用 max_steps 控制整体任务终止。

相关推荐
~央千澈~1 小时前
从“拟声”到“生成”:AI音效背后的技术原理·优雅草AI音乐·AI音乐技术研究
大数据·人工智能·ai·音频
梦想很大很大1 小时前
如果有一个本地优先的 Workflow 工具,你们团队会愿意用吗?
python·agent·workflow
啾啾Fun1 小时前
【AI原生组织】6-AI Native团队组建与基础设施搭建
人工智能·chatgpt·ai-native·ai agent·ai原生组织·人机混编
IT_陈寒1 小时前
Redis集群这个坑,差点让我通宵
前端·人工智能·后端
Elastic 中国社区官方博客1 小时前
Elasticsearch:使用 AI Agent 来创建 workflow
大数据·运维·人工智能·elasticsearch·搜索引擎·自动化·全文检索
阿图灵1 小时前
Agentic AI 架构入门(九):Agent 通信协议全景——ACP/A2A/AG-UI/MCP
人工智能·ui·架构·ai agent·智能体·mcp·agentic ai
阿里云大数据AI技术2 小时前
AI Search+ES 9.4.X最佳实践:“更快、更准、更安全的企业级搜索引擎”"为AI Agent提供坚实底座”
人工智能·elasticsearch·agent
575772 小时前
AI搜索品牌曝光怎么做?从结构化数据到可引用内容的工程路径
人工智能
Hrain-AI2 小时前
2026 编码智能体三强对比:Trae/Qoder CN/CodeBuddy 安全护栏
人工智能·安全