第五章:ReAct
5.1 为什么需要循环
先看不用循环会怎样。
旅游助手接到一个请求:"帮我安排明天的杭州一日游,我喜欢喝茶和吃小吃,告诉我带什么。"
One-shot 调用:
from agent_patterns_lab.runtime.mock_model import MockLLM
from agent_patterns_lab.runtime.types import Message
model = MockLLM([
"上午去西湖散步,中午在楼外楼吃饭,下午去龙井村喝茶,晚上逛河坊街。"
"明天天气晴朗,穿轻便衣服即可。"
])
messages = [
Message(role="system", content="你是杭州旅游助手。"),
Message(role="user", content="帮我安排明天的杭州一日游,我喜欢喝茶和吃小吃,告诉我带什么。"),
]
answer = model.complete(messages)
print(answer)
输出:
上午去西湖散步,中午在楼外楼吃饭,下午去龙井村喝茶,晚上逛河坊街。明天天气晴朗,穿轻便衣服即可。
这个回答有三个问题:
- "明天天气晴朗"是编的。 模型没有查天气,它不知道明天下不下雨。如果明天下午有雨,下午安排户外的龙井村就是坑人。
- 路线顺序是猜的。 西湖到龙井村到河坊街的路线是否合理?需要多长时间?模型没查。
- "穿轻便衣服"没有依据。 如果明天降温到 12°C,这条建议就是错的。
这三个问题的共同根源:模型在一次调用里就给了最终答案,没有先查信息、再根据信息调整。
在第四章我们知道了怎么写好 prompt,但好 prompt 不能凭空制造事实。模型需要一种机制:先行动(查天气),看到结果(下午有雨),再决定下一步(把下午改成室内)。
这就是循环的意义。
5.2 ReAct:Reason + Act
5.2.1 核心思路
ReAct 出自 2022 年的论文 "ReAct: Synergizing Reasoning and Acting in Language Models"。论文里的格式是:
Thought: 我需要先查明天杭州的天气
Action: get_weather[Hangzhou, tomorrow]
Observation: 下午15点后有小雨,气温18-23°C
Thought: 下午有雨,应该安排室内活动
Action: search_places[tea, indoor]
Observation: 中国茶叶博物馆(室内,免费)
...
论文版本的 Thought 是自由文本,Action 是 工具名[参数] 格式,Observation 是工具返回的文本。
工程版本做两个改动:
- Action 用 JSON 代替自由文本。
{"type": "tool", "tool": "get_weather", "args": {"city": "Hangzhou"}}比get_weather[Hangzhou, tomorrow]更容易 parse、validate、trace。 - Thought 不暴露给用户。 论文需要 Thought 来分析推理过程。工程实践中,我们关心的是可审计的 action 和 observation,不是模型的内心独白。
核心循环:
Model 选择一个 Action → Python 执行这个 Action → 得到 Observation → 放回 messages → 重复
直到 Model 认为信息够了,返回 {"type": "final", "answer": "..."} 终止循环。
flowchart TD
S["当前 state: messages"] --> M["模型返回一个 Action"]
M -->|type=tool| T["Python 调用工具"]
T --> O["Observation 追加到 messages"]
O --> S
M -->|type=final| F["返回最终答案,循环结束"]
S --> L["步数/超时/停滞检测"]
L -->|超限| X["强制停止"]
5.2.2 完整实现
下面是一个完整的 ReActAgent 类。代码先完整贴出,然后逐段拆解。
from __future__ import annotations
import json
import re
import time
from dataclasses import dataclass, field
from typing import Any, Sequence
from agent_patterns_lab.runtime.types import Message
from agent_patterns_lab.runtime.mock_model import MockLLM
from agent_patterns_lab.runtime.tools import Tool, ToolRegistry
from agent_patterns_lab.runtime.tracing import Tracer
# ── Action 类型 ────────────────────────────────────────
@dataclass(frozen=True)
class ToolAction:
tool: str
args: dict[str, Any]
@dataclass(frozen=True)
class FinalAction:
answer: str
Action = ToolAction | FinalAction
# ── Prompt 模板 ────────────────────────────────────────
REACT_SYSTEM_TEMPLATE = """你是一个 ReAct 风格的旅游助手。
每一步,你必须返回一个 JSON action。格式二选一:
调用工具:
{{"type": "tool", "tool": "<工具名>", "args": {{"参数": "值"}}}}
给出最终答案:
{{"type": "final", "answer": "你的回答"}}
可用工具:
{tool_descriptions}
规则:
- 每次只返回一个 JSON,不要附加其他文字
- 如果不确定某个事实,先用工具查
- 不要编造天气、路线、票价等实时信息
"""
# ── JSON 解析器 ────────────────────────────────────────
def parse_action_json(text: str) -> Action:
"""从模型输出中提取 JSON 并解析为 Action。"""
# 先尝试直接 parse
cleaned = text.strip()
# 去掉 markdown code fence
if cleaned.startswith("```"):
lines = cleaned.splitlines()
# 去掉首尾的 ``` 行
inner_lines = []
for line in lines:
if line.strip().startswith("```"):
continue
inner_lines.append(line)
cleaned = "\n".join(inner_lines).strip()
try:
data = json.loads(cleaned)
except json.JSONDecodeError:
# 尝试在文本中找第一个 JSON 对象
match = re.search(r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}', cleaned)
if match:
data = json.loads(match.group())
else:
raise ValueError(f"无法从模型输出中提取 JSON: {text[:200]}")
action_type = data.get("type")
if action_type == "tool":
return ToolAction(tool=data["tool"], args=data.get("args", {}))
elif action_type == "final":
return FinalAction(answer=data["answer"])
else:
raise ValueError(f"未知 action type: {action_type}")
# ── ReAct Agent ────────────────────────────────────────
@dataclass
class ReActAgent:
"""完整的 ReAct Agent,包含循环控制和停滞检测。"""
tools: ToolRegistry
max_steps: int = 10
timeout_seconds: float = 120.0
stall_window: int = 3 # 连续 N 步调用同一工具视为停滞
def run(
self,
model,
task: str,
tracer: Tracer | None = None,
) -> str:
# 构建 system prompt
tool_lines = "\n".join(
f"- {t.name}: {t.description}" for t in self.tools.list()
)
system = REACT_SYSTEM_TEMPLATE.format(tool_descriptions=tool_lines)
messages: list[Message] = [
Message(role="system", content=system),
Message(role="user", content=task),
]
start_time = time.time()
recent_tools: list[str] = [] # 用于停滞检测
for step in range(self.max_steps):
# ── 超时检查 ──
elapsed = time.time() - start_time
if elapsed > self.timeout_seconds:
if tracer:
tracer.emit("react.timeout", step=step, elapsed=elapsed)
return f"[超时] 已运行 {elapsed:.1f}s,强制返回当前信息。"
# ── 调用模型 ──
if tracer:
tracer.emit("react.step", step=step)
raw = model.complete(messages, tracer=tracer)
action = parse_action_json(raw)
# ── Final:循环结束 ──
if isinstance(action, FinalAction):
if tracer:
tracer.emit("react.final", step=step, answer=action.answer)
return action.answer
# ── Tool:执行工具 ──
if tracer:
tracer.emit("react.tool_call", step=step, tool=action.tool, args=action.args)
tool_result = self.tools.call(action.tool, action.args, tracer=tracer)
messages.append(Message(role="assistant", content=raw))
messages.append(Message(role="tool", name=action.tool, content=tool_result))
# ── 停滞检测 ──
recent_tools.append(action.tool)
if len(recent_tools) >= self.stall_window:
window = recent_tools[-self.stall_window:]
if len(set(window)) == 1:
if tracer:
tracer.emit("react.stall_detected", step=step, tool=action.tool)
return f"[停滞] 连续 {self.stall_window} 次调用 {action.tool},强制停止。"
# ── 超过 max_steps ──
if tracer:
tracer.emit("react.max_steps", max_steps=self.max_steps)
return f"[超步数] 已执行 {self.max_steps} 步,未得出最终答案。"
逐段拆解:
Action 类型
@dataclass(frozen=True)
class ToolAction:
tool: str
args: dict[str, Any]
@dataclass(frozen=True)
class FinalAction:
answer: str
Action = ToolAction | FinalAction
只有两种 action。ToolAction 表示"调用工具",FinalAction 表示"我知道答案了"。用 frozen=True 是因为 action 一旦创建就不应该被修改——它是历史记录的一部分。
Prompt 模板
REACT_SYSTEM_TEMPLATE = """你是一个 ReAct 风格的旅游助手。
...
可用工具:
{tool_descriptions}
"""
{tool_descriptions} 在运行时被替换为实际注册的工具列表。工具描述是 prompt 的一部分——写得越像函数签名文档,模型选工具越准。写得像市场宣传语,模型就会乱选。
JSON 解析器
def parse_action_json(text: str) -> Action:
cleaned = text.strip()
if cleaned.startswith("```"):
# 去掉 markdown code fence
...
try:
data = json.loads(cleaned)
except json.JSONDecodeError:
match = re.search(r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}', cleaned)
...
模型的输出不总是干净的 JSON。它可能在 JSON 前面加一句"好的,我来查天气",或者用 markdown 的 code fence 包裹。解析器做两层 fallback:先 json.loads 直接解析,失败了就用正则找第一个 JSON 对象。
这个正则 \{[^{}]*(?:\{[^{}]*\}[^{}]*)*\} 能匹配一层嵌套的 JSON 对象,覆盖 {"type": "tool", "args": {"city": "Hangzhou"}} 这种情况。
循环控制
for step in range(self.max_steps):
elapsed = time.time() - start_time
if elapsed > self.timeout_seconds:
return f"[超时] ..."
raw = model.complete(messages, tracer=tracer)
action = parse_action_json(raw)
if isinstance(action, FinalAction):
return action.answer
tool_result = self.tools.call(action.tool, action.args, tracer=tracer)
messages.append(Message(role="assistant", content=raw))
messages.append(Message(role="tool", name=action.tool, content=tool_result))
每轮做四件事:
- 检查超时——
time.time() - start_time > timeout_seconds就强制返回。 - 调用模型——把当前 messages 发给模型,拿到 action。
- 判断是否结束——
FinalAction就返回答案。 - 执行工具并更新 messages——
ToolAction就调用工具,把 action 和 observation 都追加到 messages。
messages.append 这两行是整个循环的关键。每追加一次,下一轮模型看到的 context 就多了一组"我做了什么 + 工具返回了什么"。这就是 ReAct 的"Act → Observe → Reason"循环。
停滞检测
recent_tools.append(action.tool)
if len(recent_tools) >= self.stall_window:
window = recent_tools[-self.stall_window:]
if len(set(window)) == 1:
return f"[停滞] 连续 {self.stall_window} 次调用 {action.tool},强制停止。"
如果模型连续 3 次(stall_window=3)调用同一个工具,说明它陷入了循环——可能是工具返回的结果它不理解,或者它的 prompt 理解出了问题。检测到停滞就强制停止,比等到 max_steps 更快止损。
5.2.3 旅游助手:杭州一日游
完整运行示例,包含每一轮的 Thought/Action/Observation:
import json
def get_weather(args: dict) -> str:
return json.dumps({
"city": args.get("city", "杭州"),
"date": args.get("date", "明天"),
"forecast": "上午多云,15点后小雨",
"temperature_c": "18-23",
"packing_hint": "带伞和薄外套",
}, ensure_ascii=False)
def search_places(args: dict) -> str:
return json.dumps({
"places": [
{"name": "西湖·断桥", "type": "户外", "best_time": "上午", "note": "避开下午雨"},
{"name": "中国茶叶博物馆", "type": "室内", "best_time": "下午", "note": "免费,适合雨天"},
{"name": "河坊街", "type": "半室内", "best_time": "傍晚", "note": "小吃集中"},
]
}, ensure_ascii=False)
def estimate_route(args: dict) -> str:
places = args.get("places", [])
return json.dumps({
"route": " → ".join(places),
"total_transit_minutes": 55,
"suggestion": "打车或公交均可,路线紧凑",
}, ensure_ascii=False)
# 注册工具
tools = ToolRegistry([
Tool(name="get_weather", description="查询指定城市和日期的天气预报", handler=get_weather),
Tool(name="search_places", description="根据城市、兴趣和约束条件搜索旅游景点", handler=search_places),
Tool(name="estimate_route", description="估算多个景点之间的交通时间", handler=estimate_route),
])
# 用 MockLLM 脚本化模型的 4 轮响应
scripted_responses = [
# 第 1 轮:先查天气
'{"type": "tool", "tool": "get_weather", "args": {"city": "杭州", "date": "明天"}}',
# 第 2 轮:根据天气搜索景点
'{"type": "tool", "tool": "search_places", "args": {"city": "杭州", "interests": ["茶", "小吃"], "weather": "下午有雨"}}',
# 第 3 轮:估算路线
'{"type": "tool", "tool": "estimate_route", "args": {"places": ["西湖·断桥", "中国茶叶博物馆", "河坊街"]}}',
# 第 4 轮:给出最终答案
'{"type": "final", "answer": "明天杭州一日游安排:上午去西湖断桥(趁没下雨),下午转中国茶叶博物馆品龙井(室内躲雨),傍晚逛河坊街吃小吃。全程交通约55分钟。带伞和薄外套,穿舒适的走路鞋。"}',
]
model = MockLLM(scripted_responses)
tracer = Tracer()
agent = ReActAgent(tools=tools, max_steps=10, timeout_seconds=60.0, stall_window=3)
answer = agent.run(model, task="帮我安排明天的杭州一日游,我喜欢喝茶和吃小吃,告诉我带什么。", tracer=tracer)
print("=" * 60)
print("最终答案:")
print(answer)
print("=" * 60)
print(f"\n运行轨迹({len(tracer.events)} 个事件):")
for event in tracer.events:
print(f" [{event.name}] {json.dumps(event.data, ensure_ascii=False)}")
输出:
============================================================
最终答案:
明天杭州一日游安排:上午去西湖断桥(趁没下雨),下午转中国茶叶博物馆品龙井(室内躲雨),傍晚逛河坊街吃小吃。全程交通约55分钟。带伞和薄外套,穿舒适的走路鞋。
============================================================
运行轨迹(11 个事件):
[react.step] {"step": 0}
[llm.complete] {"model": "mock", ...}
[react.tool_call] {"step": 0, "tool": "get_weather", "args": {"city": "杭州", "date": "明天"}}
[tool.call] {"tool_name": "get_weather", "args": {"city": "杭州", "date": "明天"}}
[tool.result] {"tool_name": "get_weather", "output": "{\"city\":\"杭州\",...}"}
[react.step] {"step": 1}
[react.tool_call] {"step": 1, "tool": "search_places", "args": {"city": "杭州", ...}}
[react.step] {"step": 2}
[react.tool_call] {"step": 2, "tool": "estimate_route", "args": {"places": [...]}}
[react.step] {"step": 3}
[react.final] {"step": 3, "answer": "明天杭州一日游安排:..."}
四轮循环的完整日志:
| 轮次 | 模型决策 | 工具返回 | 为什么下一步变了 |
|---|---|---|---|
| 0 | get_weather → 查杭州明天天气 |
15点后小雨,18-23°C | 下午有雨,不能全安排户外 |
| 1 | search_places → 搜景点,约束"下午有雨" |
断桥(户外/上午)、茶博(室内/下午)、河坊街(傍晚) | 拿到了具体地点和时间安排 |
| 2 | estimate_route → 估算三个点的交通 |
总计55分钟,路线紧凑 | 确认路线可行 |
| 3 | final → 给出最终答案 |
— | 信息够了,不需要继续查 |
关键观察:轮次 1 的景点搜索是因为轮次 0 看到了下午有雨才加了"室内"约束。如果没有循环,模型不可能先查天气再调整景点——它只能在一次调用里猜一个答案。
5.3 优势与局限
优势
可解释性好。 每一轮的 action 和 observation 都有记录。出了问题,看 trace 就能定位是哪一步的工具返回了错误信息,或者模型在哪一步做了错误决策。
动态调整。 第一轮发现下雨,第二轮自动搜室内景点。不需要提前写 if-else 分支来处理所有天气情况。
工具组合灵活。 不需要预定义工具调用顺序。模型可以根据情况决定先查天气还是先搜景点。
局限
~50 步后性能退化。 每轮都往 messages 里追加 action + observation,context 越来越长。经验上,超过 50 步后:
- 模型对早期 observation 的记忆明显下降
- token 成本线性增长
- 延迟线性增长
- 幻觉概率上升(context 太长,模型开始"忘记"已经查过的信息)
这不是一个精确的数字——取决于模型、context window 大小、每轮追加的 token 量。但"几十步"是一个实际的软上限。
调试不直观。 虽然 trace 有记录,但当循环跑了 8 轮时,找到出错的那一轮需要耐心。建议:
- 给每步加编号——trace 里的
step: 0, 1, 2...就是为了这个。 - 用 JSONL 格式保存 trace——每行一个事件,可以用
jq过滤。 - 在停滞检测时记录 context 长度——如果 context 超过 80% window,说明快到极限了。
# 调试时有用的 context 长度估算
def estimate_context_tokens(messages: list[Message], chars_per_token: float = 2.5) -> int:
total_chars = sum(len(m.content) for m in messages)
return int(total_chars / chars_per_token)
# 示例
sample_messages = [
Message(role="system", content="x" * 1000),
Message(role="user", content="x" * 200),
Message(role="assistant", content="x" * 150),
Message(role="tool", name="get_weather", content="x" * 500),
]
print(f"估算 context token: {estimate_context_tokens(sample_messages)}")
输出:
估算 context token: 740
成本不可预测。 固定 workflow 的成本是确定的(3 步就是 3 次 API 调用)。ReAct 的成本取决于模型决定跑几轮。设 max_steps=10 不意味着每次都跑 10 轮,但你必须按最坏情况做预算。
本章回顾
| 概念 | 一句话 |
|---|---|
| One-shot 的问题 | 没查就答,天气、路线、票价全靠猜 |
| ReAct 核心 | Action → Observation → 下一个 Action,直到信息够了 |
| 工程 vs 论文 | JSON action 代替自由文本;不暴露 Thought |
| 循环控制 | max_steps + 超时 + 停滞检测,三道刹车 |
| 性能退化 | ~50 步后 context 过长,记忆和准确率下降 |
附录: 附录 5A:ReAct 工程优化 详细分析每轮 token 增长、工具结果缓存、JSONL trace 格式和调试技巧。
下一章: 第六章:Workflow 与 Agent Loop —— 不是二选一,而是一个光谱。