手写 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 里。