手写 ReAct 智能体

第 6 章的函数调用依赖服务端支持 tools 参数。若模型或服务不支持,或你想完全掌控模型的思考格式,就用 ReAct 范式:不让模型「结构化声明调用」,而是让它在回复文本里写出 Thought(思考)→ Action(动作)→ Action Input(参数),程序解析文本、执行工具,再把 Observation(观察结果)拼进对话继续循环,直到出现 Final Answer。ReAct 这个名字来自论文 Reasoning and Acting in Language Models,是目前主流智能体的范式之一。

1. 两种范式怎么选

维度函数调用(第 6 章)ReAct 文本式(本章)
服务要求模型须支持 tools 参数任意对话模型都行
解析稳定性服务端生成结构化 tool_calls,很稳靠文本格式纪律,可能解析失败
可读性看不到推理过程Thought 明文可见,方便调试
Token 开销较低较高(思考也要写出来)
定制空间参数格式被协议限制可自定义动作格式、加「先列计划」等指令
简单说:模型强、服务支持 tools 时用函数调用;模型弱、服务不支持,或想观察/控制推理过程时用 ReAct。两种循环骨架完全一样,只差「模型怎么表达动作」。

2. 完整代码 main.py

"""main.py —— 第 7 章:手写 ReAct 智能体(替换第 6 章的 main.py)。"""
import json
from typing import Dict, List, Optional

from llm_client import LLMClient
from tools import TOOL_SCHEMAS, call_tool

llm = LLMClient()

# 给模型看的一行一个工具简介:名字:用途(参数名列表)
_TOOL_LINES = []
for t in TOOL_SCHEMAS:
    fn = t["function"]
    params = ", ".join(fn["parameters"]["properties"]) or "无"
    _TOOL_LINES.append(f"- {fn['name']}: {fn['description']}(参数:{params})")

REACT_SYSTEM = f"""你是一个能调用工具解决问题的智能体。可用工具:
{chr(10).join(_TOOL_LINES)}

请严格按下面的格式输出,每行一个字段、不要使用代码块:
Thought: 你的思考
Action: 要调用的工具名
Action Input: 传给工具的一行 JSON

系统会返回 Observation: 工具结果,请据此继续思考。当你不再需要工具时,最后输出:
Thought: 我已经得到答案
Final Answer: 最终答案"""


def parse_react(text: str) -> Dict[str, Optional[str]]:
    """解析模型输出:提取 Thought / Action / Action Input / Final Answer。"""
    parsed: Dict[str, Optional[str]] = {
        "thought": None, "action": None, "action_input": None, "final": None,
    }
    lines = text.strip().splitlines()
    for i, line in enumerate(lines):
        if line.startswith("Thought:") and parsed["thought"] is None:
            parsed["thought"] = line[len("Thought:"):].strip()
        elif line.startswith("Action:") and parsed["action"] is None:
            parsed["action"] = line[len("Action:"):].strip()
        elif line.startswith("Action Input:") and parsed["action_input"] is None:
            parsed["action_input"] = line[len("Action Input:"):].strip()
        elif line.startswith("Final Answer:"):
            parsed["final"] = "\n".join(lines[i:])[len("Final Answer:"):].strip()
    return parsed


def run_react(user_input: str, max_steps: int = 8) -> str:
    messages: List[dict] = [
        {"role": "system", "content": REACT_SYSTEM},
        {"role": "user", "content": user_input},
    ]
    for step in range(1, max_steps + 1):
        print(f"\n===== 第 {step}/{max_steps} 步 =====")
        reply = llm.chat_text(messages)
        print("模型输出:\n" + reply)
        messages.append({"role": "assistant", "content": reply})

        parsed = parse_react(reply)
        if parsed["final"]:                     # 给出最终答案,循环结束
            return parsed["final"]

        if not parsed["action"] or not parsed["action_input"]:
            # 格式不对:回灌一句要求,让模型按格式重来
            messages.append({"role": "user", "content": "格式错误:请按 Thought / Action / "
                             "Action Input 格式继续,或直接输出 Final Answer。"})
            continue

        try:
            args = json.loads(parsed["action_input"])
        except json.JSONDecodeError as e:
            messages.append({"role": "user",
                             "content": f"Action Input 不是合法 JSON:{e},请修正后重试。"})
            continue

        print(f"  -> 调用工具 {parsed['action']}({args})")
        observation = call_tool(parsed["action"], args)
        print(f"  <- Observation:{observation}")
        messages.append({"role": "user", "content": f"Observation: {observation}"})

    return "达到最大步骤数,任务未完成。"


if __name__ == "__main__":
    answer = run_react("现在几点了?北京的天气如何?")
    print("\n===== 最终答案 =====")
    print(answer)

3. 运行与轨迹

python main.py

一次真实运行的轨迹大致如下(输出随模型与时间变化):

===== 第 1/8 步 =====
模型输出:
Thought: 用户想知道当前时间和北京的天气,我需要调用两个工具。
Action: get_current_time
Action Input: {}

  -> 调用工具 get_current_time({})
  <- Observation:2026-01-01 12:00:00

===== 第 2/8 步 =====
模型输出:
Thought: 时间拿到了,继续查北京的天气。
Action: get_weather
Action Input: {"city": "北京"}

  -> 调用工具 get_weather({"city": "北京"})
  <- Observation:晴,24℃

===== 第 3/8 步 =====
模型输出:
Thought: 我已经得到答案
Final Answer: 现在是 2026-01-01 12:00:00,北京天气晴朗,气温 24℃。

===== 最终答案 =====
现在是 2026-01-01 12:00:00,北京天气晴朗,气温 24℃。

注意轨迹里每轮只做一个动作:文本式模型通常一次只写一个 Action,所以调用次数比函数调用轮次多,这正是它 token 开销更高的原因。

4. 要点拆解

  • 格式纪律靠提示词 + 纠错回灌:模型可能漏写 Action Input 或写出代码块,解析不到就追加一条 user 消息要求按格式重来,与第 4 章「校验失败重发」是同一种思路。
  • Observation 用 user 角色回填:OpenAI 兼容接口没有 observation 角色,惯例是把 Observation: 结果 作为普通 user 消息发给模型。
  • 解析要宽容parse_react 容忍行首空白、Thought 换行等情况;多行内容只取首个出现的关键词,所以提示词要求一行一个字段。
  • 变体:也有让模型直接输出单个 JSON 动作 {"thought": "...", "action": "...", "action_input": {...}} 的做法,解析改成 json.loads 即可;第 8 章的 run_agent 是更通用、可复用的循环外壳。 小结:ReAct 用文本约定代替服务端协议——模型按 Thought/Action/Action Input 输出,程序解析执行并把 Observation 回灌,直到出现 Final Answer。它与函数调用共享「循环调用模型 + 执行工具」的骨架,适合不支持 tools 或想控制推理过程的场景;从第 8 章起,两种循环都会被收敛到一个带保护机制的 run_agent 里。
笔记加载中…