第 51 章

ReAct 架构精讲与代码实现

第51章:ReAct 架构精讲与代码实现

导语

ReAct(Reasoning + Acting)是现代 AI Agent 框架的基石。2022年由 Shunyu Yao 等人提出的这篇论文,改变了人们对 LLM 能力边界的认知:通过将"思考"和"行动"交织在一起,LLM 可以解决原本需要专门训练才能应对的复杂推理任务。Hermes Agent 的核心推理引擎正是基于 ReAct 范式。本章从论文原理出发,逐步剖析实现细节,并带你从零用 Python 实现一个完整的 ReAct Agent。


51.1 ReAct 论文核心思想

背景:单纯推理与单纯行动的局限

在 ReAct 出现之前,LLM 有两种主要使用方式:

Chain-of-Thought(CoT):让 LLM 逐步推理,但所有信息必须在提示词中预先提供,无法访问外部信息。

工具调用(早期 Agent):让 LLM 直接生成行动,但缺乏中间推理步骤,行动选择缺乏依据,容易陷入错误路径。

ReAct 的关键洞察是:思考和行动应该交替进行,相互增益

flowchart LR
    subgraph CoT["仅 CoT(推理)"]
        direction TB
        T1[思考1] --> T2[思考2] --> T3[思考3] --> A1[最终答案]
    end
    
    subgraph Act["仅行动(早期 Agent)"]
        direction TB
        AC1[行动1] --> AC2[行动2] --> AC3[行动3] --> A2[答案]
    end
    
    subgraph ReAct["ReAct(推理+行动)"]
        direction TB
        RT1[思考1] --> RA1[行动1] --> RO1[观察1]
        RO1 --> RT2[思考2] --> RA2[行动2] --> RO2[观察2]
        RO2 --> RT3[思考3] --> A3[最终答案]
    end

ReAct 的优势

原论文在 HotpotQA、FEVER、AlfWorld、WebShop 等多个 Benchmark 上验证了 ReAct 的优势:

对比维度 仅 CoT 仅行动 ReAct
可解释性
信息获取 无(依赖训练数据)
错误纠正 有限 强(观察反馈驱动)
幻觉风险
任务成功率 基准 接近 最高

51.2 Thought → Action → Observation 循环详解

ReAct 的核心循环由三个阶段构成:

Thought(思考)

LLM 生成内部推理,分析当前情况,制定下一步计划。Thought 不直接产生输出,而是为 Action 提供依据。

Thought: 用户问的是 2024 年诺贝尔物理学奖得主。我知道 2024 年的信息可能
不在我的训练数据中,需要搜索最新信息。我应该搜索"2024 Nobel Prize Physics winner"。

Action(行动)

LLM 生成具体的工具调用指令。Action 格式通常是结构化的(JSON 或特定语法)。

Action: search["2024 Nobel Prize Physics winner"]

Observation(观察)

工具执行结果被注入回上下文,成为 LLM 下一轮推理的输入。

Observation: The 2024 Nobel Prize in Physics was awarded to John Hopfield and 
Geoffrey Hinton for foundational discoveries and inventions that enable machine 
learning with artificial neural networks.

然后循环继续:LLM 基于 Observation 再次 Thought,决定是继续行动还是给出最终答案。

stateDiagram-v2
    [*] --> Thought: 用户输入
    Thought --> Action: 需要工具
    Thought --> FinalAnswer: 信息充足
    Action --> Observation: 工具执行
    Observation --> Thought: 继续推理
    FinalAnswer --> [*]: 返回答案
    
    note right of Thought: LLM 内部推理
    note right of Action: 工具调用
    note right of Observation: 外部信息注入

51.3 Hermes 的 ReAct 实现特点

Hermes Agent 在标准 ReAct 基础上做了以下增强:

1. 函数调用格式(Function Calling)

Hermes 使用结构化的 JSON 函数调用,而非原论文的文本格式,更可靠且易于解析:

{
  "thought": "需要查询当前比特币价格",
  "tool_calls": [
    {
      "id": "call_001",
      "type": "function",
      "function": {
        "name": "get_price",
        "arguments": {"symbol": "BTC", "currency": "USD"}
      }
    }
  ]
}

2. 并行工具调用

标准 ReAct 每轮只能调用一个工具。Hermes 支持在同一轮 Action 中并行调用多个工具,显著提升效率。

3. 自定义停止条件

Hermes 支持配置 max_stepstimeoutstop_tokens 等多种停止条件,防止无限循环。

4. 上下文窗口管理

当对话历史超过模型最大上下文长度时,Hermes 自动进行摘要压缩,保持核心信息。


51.4 从零实现 ReAct Agent(完整代码)

# react_agent.py
"""
从零实现的 ReAct Agent
约 120 行核心代码,支持工具调用、循环推理、错误处理
"""

import json
import re
import asyncio
from typing import Any, Callable, Optional
from dataclasses import dataclass, field
from openai import AsyncOpenAI  # 兼容 Hermes 的 OpenAI 格式 API

# ─── 数据结构 ──────────────────────────────────────────────────

@dataclass
class Tool:
    """工具定义"""
    name: str
    description: str
    parameters: dict          # JSON Schema 格式
    func: Callable            # 实际执行函数
    
    def to_openai_format(self) -> dict:
        return {
            "type": "function",
            "function": {
                "name": self.name,
                "description": self.description,
                "parameters": self.parameters
            }
        }


@dataclass
class ReActStep:
    """单步 ReAct 执行记录"""
    step_number: int
    thought: str
    action: Optional[dict] = None    # 工具调用请求
    observation: Optional[str] = None  # 工具执行结果
    final_answer: Optional[str] = None
    
    def __str__(self) -> str:
        parts = [f"=== Step {self.step_number} ==="]
        parts.append(f"Thought: {self.thought}")
        if self.action:
            parts.append(f"Action: {json.dumps(self.action, ensure_ascii=False)}")
        if self.observation:
            parts.append(f"Observation: {self.observation}")
        if self.final_answer:
            parts.append(f"Final Answer: {self.final_answer}")
        return "\n".join(parts)


@dataclass
class ReActResult:
    """ReAct 执行结果"""
    success: bool
    answer: str
    steps: list[ReActStep]
    total_steps: int
    error: Optional[str] = None


# ─── ReAct Agent 核心实现 ──────────────────────────────────────

class ReActAgent:
    """
    ReAct Agent 核心实现
    
    支持:
    - 多工具注册
    - 最大步骤限制
    - 工具调用错误处理
    - 详细执行日志
    """
    
    SYSTEM_PROMPT = """你是一个 ReAct Agent,通过交替进行"思考"和"行动"来解决问题。

工作方式:
1. 分析问题,思考需要哪些信息或操作
2. 调用工具获取信息或执行操作
3. 根据工具结果继续思考
4. 当信息足够时,给出最终答案

重要:
- 每次只专注当前最重要的一个子问题
- 观察工具结果后,更新你对问题的理解
- 如果工具调用失败,思考替代方案
- 信息足够时,直接给出最终答案,不要过度使用工具"""
    
    def __init__(
        self,
        model: str = "NousResearch/Hermes-3-Llama-3.1-8B",
        base_url: str = "http://localhost:8000/v1",
        api_key: str = "not-needed",
        max_steps: int = 15,
        verbose: bool = True
    ):
        self.model = model
        self.max_steps = max_steps
        self.verbose = verbose
        self.tools: dict[str, Tool] = {}
        
        self.client = AsyncOpenAI(
            base_url=base_url,
            api_key=api_key
        )
    
    def register_tool(self, tool: Tool) -> 'ReActAgent':
        """注册工具,支持链式调用"""
        self.tools[tool.name] = tool
        return self
    
    def _log(self, message: str) -> None:
        if self.verbose:
            print(message)
    
    async def _execute_tool(self, tool_name: str, arguments: dict) -> str:
        """执行工具调用,处理错误"""
        if tool_name not in self.tools:
            return f"错误:工具 '{tool_name}' 不存在。可用工具:{list(self.tools.keys())}"
        
        tool = self.tools[tool_name]
        try:
            result = await asyncio.coroutine(tool.func)(**arguments) \
                if asyncio.iscoroutinefunction(tool.func) \
                else tool.func(**arguments)
            
            # 限制观察结果长度,防止上下文溢出
            result_str = str(result)
            if len(result_str) > 2000:
                result_str = result_str[:2000] + "\n... [结果已截断,显示前2000字符]"
            return result_str
            
        except Exception as e:
            return f"工具执行错误:{type(e).__name__}: {str(e)}"
    
    async def run(self, task: str) -> ReActResult:
        """
        执行 ReAct 推理循环
        
        Args:
            task: 用户任务描述
            
        Returns:
            ReActResult: 包含答案、执行步骤、成功状态
        """
        messages = [
            {"role": "system", "content": self.SYSTEM_PROMPT},
            {"role": "user", "content": task}
        ]
        
        tools_schema = [t.to_openai_format() for t in self.tools.values()]
        steps = []
        
        self._log(f"\n{'='*50}")
        self._log(f"任务: {task}")
        self._log(f"{'='*50}\n")
        
        for step_num in range(1, self.max_steps + 1):
            # ── 调用 LLM ──────────────────────────────────────
            response = await self.client.chat.completions.create(
                model=self.model,
                messages=messages,
                tools=tools_schema if tools_schema else None,
                tool_choice="auto",
                temperature=0.1,
                max_tokens=1024
            )
            
            msg = response.choices[0].message
            
            # 提取思考内容(从 content 字段或工具调用前的推理)
            thought = msg.content or "(分析中)"
            
            # ── 判断是否有工具调用 ────────────────────────────
            if msg.tool_calls:
                # ReAct 的 Action 阶段
                tool_call = msg.tool_calls[0]  # 处理第一个工具调用
                tool_name = tool_call.function.name
                
                try:
                    arguments = json.loads(tool_call.function.arguments)
                except json.JSONDecodeError:
                    arguments = {"raw": tool_call.function.arguments}
                
                step = ReActStep(
                    step_number=step_num,
                    thought=thought,
                    action={"tool": tool_name, "arguments": arguments}
                )
                
                self._log(f"\n--- Step {step_num} ---")
                self._log(f"Thought: {thought}")
                self._log(f"Action: {tool_name}({json.dumps(arguments, ensure_ascii=False)})")
                
                # ── 执行工具(Observation 阶段)──────────────
                observation = await self._execute_tool(tool_name, arguments)
                step.observation = observation
                
                self._log(f"Observation: {observation[:200]}{'...' if len(observation) > 200 else ''}")
                
                steps.append(step)
                
                # 将工具调用和结果追加到消息历史
                messages.append({
                    "role": "assistant",
                    "content": thought,
                    "tool_calls": [{
                        "id": tool_call.id,
                        "type": "function",
                        "function": {
                            "name": tool_name,
                            "arguments": tool_call.function.arguments
                        }
                    }]
                })
                messages.append({
                    "role": "tool",
                    "tool_call_id": tool_call.id,
                    "content": observation
                })
                
            else:
                # ReAct 循环结束,LLM 给出最终答案
                final_answer = msg.content or "任务完成,但无明确输出。"
                
                step = ReActStep(
                    step_number=step_num,
                    thought=thought,
                    final_answer=final_answer
                )
                steps.append(step)
                
                self._log(f"\n--- Step {step_num} (Final) ---")
                self._log(f"Thought: {thought}")
                self._log(f"Final Answer: {final_answer}")
                self._log(f"\n{'='*50}")
                self._log(f"完成,共 {step_num} 步")
                self._log(f"{'='*50}")
                
                return ReActResult(
                    success=True,
                    answer=final_answer,
                    steps=steps,
                    total_steps=step_num
                )
        
        # 超过最大步骤数
        return ReActResult(
            success=False,
            answer="已达到最大步骤数限制,任务未完成。",
            steps=steps,
            total_steps=self.max_steps,
            error=f"Exceeded max_steps={self.max_steps}"
        )


# ─── 工具实现示例 ──────────────────────────────────────────────

import httpx

def make_search_tool() -> Tool:
    """创建网络搜索工具"""
    async def web_search(query: str, num_results: int = 3) -> str:
        # 使用 DuckDuckGo 或其他搜索 API
        async with httpx.AsyncClient() as client:
            resp = await client.get(
                "https://api.duckduckgo.com/",
                params={"q": query, "format": "json", "no_html": 1},
                timeout=10
            )
            data = resp.json()
            
            results = []
            if data.get("AbstractText"):
                results.append(f"摘要:{data['AbstractText']}")
            
            for r in data.get("RelatedTopics", [])[:num_results]:
                if isinstance(r, dict) and r.get("Text"):
                    results.append(f"- {r['Text']}")
            
            return "\n".join(results) if results else "未找到相关结果"
    
    return Tool(
        name="web_search",
        description="搜索互联网获取最新信息",
        parameters={
            "type": "object",
            "properties": {
                "query": {"type": "string", "description": "搜索关键词"},
                "num_results": {"type": "integer", "description": "返回结果数量", "default": 3}
            },
            "required": ["query"]
        },
        func=web_search
    )


def make_calculator_tool() -> Tool:
    """创建计算器工具"""
    def calculate(expression: str) -> str:
        try:
            # 安全计算:只允许数字和基本运算符
            allowed = set('0123456789+-*/()., ')
            if not all(c in allowed for c in expression):
                return "错误:表达式包含不允许的字符"
            result = eval(expression)
            return f"{expression} = {result}"
        except Exception as e:
            return f"计算错误:{str(e)}"
    
    return Tool(
        name="calculator",
        description="执行数学计算",
        parameters={
            "type": "object",
            "properties": {
                "expression": {"type": "string", "description": "数学表达式,如 '2 + 3 * 4'"}
            },
            "required": ["expression"]
        },
        func=calculate
    )


def make_datetime_tool() -> Tool:
    """创建日期时间工具"""
    from datetime import datetime, timezone
    
    def get_datetime(timezone_name: str = "UTC") -> str:
        now = datetime.now(timezone.utc)
        return f"当前 UTC 时间:{now.strftime('%Y-%m-%d %H:%M:%S')} UTC"
    
    return Tool(
        name="get_datetime",
        description="获取当前日期和时间",
        parameters={
            "type": "object",
            "properties": {
                "timezone_name": {"type": "string", "description": "时区名称", "default": "UTC"}
            },
            "required": []
        },
        func=get_datetime
    )


# ─── 主程序 ────────────────────────────────────────────────────

async def main():
    # 构建 ReAct Agent
    agent = (
        ReActAgent(
            model="NousResearch/Hermes-3-Llama-3.1-8B",
            base_url="http://localhost:8000/v1",
            max_steps=10,
            verbose=True
        )
        .register_tool(make_search_tool())
        .register_tool(make_calculator_tool())
        .register_tool(make_datetime_tool())
    )
    
    # 运行任务
    tasks = [
        "今天是什么日期?距离 2025 年元旦还有多少天?",
        "谁是 2024 年诺贝尔物理学奖得主?他们做了什么贡献?",
        "计算:如果每月存 5000 元,年利率 3%,10 年后本息合计多少?"
    ]
    
    for task in tasks:
        print(f"\n{'#'*60}")
        result = await agent.run(task)
        print(f"\n最终结果({result.total_steps} 步):")
        print(result.answer)
        
        if not result.success:
            print(f"警告:{result.error}")


if __name__ == "__main__":
    asyncio.run(main())

51.5 调试 ReAct 循环的常见问题

问题一:无限循环

症状:Agent 不断重复相似的工具调用,永远不给出最终答案。

根本原因

解决方案

# 在系统提示词中明确停止条件
SYSTEM_PROMPT += """
**停止规则**:
- 当你已经有足够信息回答问题时,直接给出答案,不要继续调用工具
- 如果同一工具调用失败两次,换一种方法或直接说明无法获取该信息
- 最多进行 {max_steps} 步推理
"""

# 检测重复行动
class LoopDetector:
    def __init__(self, window: int = 3):
        self.recent_actions = []
        self.window = window
    
    def is_looping(self, action: dict) -> bool:
        action_str = json.dumps(action, sort_keys=True)
        self.recent_actions.append(action_str)
        if len(self.recent_actions) > self.window:
            self.recent_actions.pop(0)
        # 如果最近 3 个行动完全相同
        return len(set(self.recent_actions)) == 1 and len(self.recent_actions) == self.window

问题二:工具调用参数解析失败

症状json.JSONDecodeError,LLM 生成了非法 JSON。

解决方案

def safe_parse_arguments(raw: str) -> dict:
    """容错的参数解析"""
    # 尝试直接解析
    try:
        return json.loads(raw)
    except json.JSONDecodeError:
        pass
    
    # 尝试提取 JSON 子串
    json_pattern = re.search(r'\{.*\}', raw, re.DOTALL)
    if json_pattern:
        try:
            return json.loads(json_pattern.group())
        except json.JSONDecodeError:
            pass
    
    # 最后退路:作为字符串参数
    return {"input": raw}

问题三:上下文溢出

症状:长任务执行到后期,LLM 开始"遗忘"早期信息,或 API 返回 context_length_exceeded 错误。

解决方案

async def compress_messages(messages: list, max_tokens: int = 4096) -> list:
    """当消息历史过长时,摘要压缩早期内容"""
    total_estimated_tokens = sum(len(m.get('content', '') or '') // 4 for m in messages)
    
    if total_estimated_tokens <= max_tokens:
        return messages
    
    # 保留系统提示词和最近 6 条消息
    system_msgs = [m for m in messages if m['role'] == 'system']
    recent_msgs = messages[-6:]
    middle_msgs = messages[len(system_msgs):-6]
    
    if not middle_msgs:
        return messages
    
    # 对中间消息进行摘要
    summary_prompt = "请用3-5句话总结以下对话的关键信息和已知事实:\n\n"
    for m in middle_msgs:
        if isinstance(m.get('content'), str):
            summary_prompt += f"{m['role']}: {m['content'][:500]}\n"
    
    # 调用 LLM 生成摘要(简化示例)
    summary = f"[早期对话摘要] {summary_prompt[:200]}..."
    
    return system_msgs + [{"role": "user", "content": summary}] + recent_msgs

常见问题速查表

问题 诊断方法 解决方案
Agent 循环不停 打印每步 action,检查重复 添加循环检测器,强化停止指令
工具参数错误 打印 raw arguments 实现容错解析,使用 JSON Schema 验证
上下文溢出 监控 token_count 实现摘要压缩,限制观察结果长度
最终答案质量差 检查 observation 质量 优化工具输出格式,过滤噪音
步骤数过多 统计平均步骤数 调整系统提示词,鼓励高效推理

小结

本章深入讲解了 ReAct 架构的理论与实现:

  1. 论文原理:ReAct 通过交织 Thought(推理)和 Action(行动)克服了 CoT 与纯 Agent 各自的局限。
  2. 三阶段循环:Thought → Action → Observation 形成闭合反馈,每步观察都更新 LLM 的世界模型。
  3. Hermes 增强:结构化函数调用、并行工具、上下文压缩是 Hermes 对标准 ReAct 的主要改进。
  4. 完整代码:120 行实现了一个生产可用的 ReAct Agent,支持工具注册、错误处理、详细日志。
  5. 常见陷阱:无限循环、参数解析失败、上下文溢出是最常见的三类问题,均有对应解决方案。

思考题

  1. ReAct 中的 Thought 是给 LLM 自己看的,还是给用户看的?如果想在生产中隐藏 Thought,应该如何处理?
  2. 当工具调用失败时,Agent 应该立即重试、换工具还是向用户报告失败?如何设计这个策略?
  3. 如果要支持并行工具调用(多个工具同时执行),ReAct 的消息历史格式需要如何调整?
  4. ReAct 与普通 CoT 的本质区别是什么?对于不需要外部信息的纯推理任务,ReAct 是否仍然有优势?
本章评分
4.8  / 5  (3 评分)

💬 留言讨论