一、为什么需要 ReWOO?ReAct 的两个痛点
上一节的 ReAct 模式很优雅:思考 → 行动 → 观察 → 思考 → 行动 → 观察。但它有两个工程上的痛:
痛点 1:token 用量随步数二次增长
ReAct 每一步的 prompt 都要带上完整历史,例如:
text
[原始 prompt]
[Thought 1] [Action 1] [Observation 1]
[Thought 2] [Action 2] [Observation 2]
[Thought 3] [Action 3] [Observation 3]
...
到第 10 步时,光是原始 prompt 就被重复发了 10 遍。token 成本不是线性而是接近二次增长。
痛点 2:失败位置在循环中间,恢复路径混乱
如果第 3 步工具失败:
- ReAct 必须在流的中间根据错误观察重新推理整条计划;
- 模型经常因为前文太长,「忘了"自己原来的计划是什么」,乱改方向。
ReWOO 的下注
ReWOO(Xu 等人,2023)的核心洞察是:
大多数任务的计划在执行前就能写出来。 真正需要"反应式调整"的步骤其实很少。
于是它把流程拆成三个独立角色:
text
Planner: question → plan_dag
Workers: plan_dag → evidence (按依赖跑工具,可并行)
Solver: question + plan + evidence → final_answer
代价 :计划是静态的,灵活性下降。
收益:token 暴降、失败定位清晰、可以蒸馏出小规划器。
二、ReWOO 的三个角色
Planner(规划器)
只看 user_question,吐出一个计划 DAG(有向无环图)。每个节点指明:
- 用哪个工具
- 参数是什么
- 依赖哪些更早节点(用
#E1、#E2这种"证据引用")
关键:Planner 看不到任何 Observation。它产出的是"该怎么做"的草图,不是"做完了什么"的复盘。
Worker(执行器)
按拓扑序执行节点:
- 先跑没有依赖的;
- 再跑依赖已经完成的;
- 同一层的节点可以并行。
Solver(求解器)
拿到原问题 + 计划 + 所有 evidence,做最后一次 LLM 调用产出答案。
三、为什么 token 少 5 倍?
| 阶段 | ReAct | ReWOO |
|---|---|---|
| Planner | ------ | 1 个大 prompt(看问题,输出计划) |
| Workers | 每步都带完整前文(O(n²) 字符) | 每步只带这个工具的入参,不带链 |
| Solver | ------ | 1 个 prompt,把 evidence 缝起来 |
ReAct 的"每个中间步骤还冗余地把原始 prompt 带一遍"是主要 token 来源。ReWOO 把规划与执行分开后,worker prompt 几乎是空的,自然就省下来了。
ReWOO 论文在 HotpotQA 上测得 token ≈ 少 5 倍,准确率绝对值 +4。
四、为什么它更稳健?
| 失败情况 | ReAct 表现 | ReWOO 表现 |
|---|---|---|
| 第 3 步工具报错 | 必须在中途重新推理整条链 | Worker 3 写一个 error 字符串,Solver 在带原计划的上下文里优雅降级 |
| Observation 异常 | 容易污染后续思考链 | 错误被隔离在 evidence 字典里,Planner 完全没看见 |
失败粒度从"按步骤"变成"按节点",调试和重跑都容易得多。
五、规划器蒸馏(生产里很常用)
- Planner 看不到 Observation,输入空间小;
- 所以可以把 175B 大模型产出的 Planner 轨迹,拿来微调一个 7B 小模型;
- 推理时小模型规划 + 大模型求解,或者反过来。
这是 2026 年生产 Agent 的常见架构:用便宜模型做"路由 / 规划",把贵模型留给真正需要长推理的步骤。
六、四种衍生模式 & 选型表
| 模式 | 什么时候用 |
|---|---|
| ReAct | 短任务、环境未知、需要反应式异常处理 |
| ReWOO | 工具已知、结构化、对 token 敏感、证据可并行 |
| Plan-and-Execute(LangChain,2023) | 像 ReWOO,但执行后允许 重规划器(replanner) 修正 |
| Plan-and-Act(Erdogan 等,ICML 2025) | 长跨度(>30 步)网页 / 移动端 / computer-use;用合成计划数据微调规划器 |
| Tree of Thoughts | 搜索值得为之买单(下一节展开) |
Anthropic 2024 年 12 月的建议很实在:
从最简单的开始。
- 任务就是"一次工具调用 + 一段汇总" → 别搭 ReWOO;
- 任务是 40 步的研究作业 → 别只用 ReAct。
七、关键术语速查表
| 术语 | 通俗叫法 | 它实际是什么 |
|---|---|---|
| ReWOO | "无观察推理" | Planner 看不到观察,先规划→并行取证→求解 |
| Plan-and-Execute | "LangChain 那套" | ReWOO + 执行后可选 replanner |
| Plan-and-Act | "扩展版 plan-execute" | 配合成计划训练数据,适配长跨度任务 |
| Evidence Reference | "#E1、#E2 ......" | 计划节点占位符,分派时替换为上一步 worker 输出 |
| Planner Distillation | "小规划器,大执行器" | 用大模型轨迹微调小模型做规划 |
| DAG Executor | "拓扑分派器" | 按依赖跑节点,每层内并行 |
代码精讲:纯标准库实现的 ReWOO 玩具
1. 用 @dataclass 表达计划节点
python
@dataclass
class PlanStep:
id: str
tool: str
args: dict[str, Any]
@dataclass
class Plan:
steps: list[PlanStep]
入门补课
@dataclass:上一节讲过,自动生成__init__/__repr__/__eq__。这里把"计划节点"和"计划"做成两个纯数据类,只承载结构,不带行为 ------ 这是经典的 DTO(Data Transfer Object)写法。id不是 Python 内置同名变量 :id在 Python 是个内置函数(取对象地址),但作为字段名完全合法,不会冲突。
设计意图
把 Plan 拆成 steps: list[PlanStep] 而不是直接 list[PlanStep],是为了未来扩展 :以后可以加 meta、version、max_parallel 等字段,不破坏调用方。
2. ToolRegistry:和第 1 节几乎一样
python
class ToolRegistry:
def __init__(self) -> None:
self._tools: dict[str, Callable[..., str]] = {}
def register(self, name: str, fn: Callable[..., str]) -> None:
self._tools[name] = fn
def dispatch(self, name: str, args: dict[str, Any]) -> str:
fn = self._tools.get(name)
if fn is None:
return f"error: unknown tool {name!r}"
try:
return fn(**args)
except Exception as e:
return f"error: {type(e).__name__}: {e}"
注意签名变了:第 1 节 dispatch(call: ToolCall),这一节 dispatch(name, args)。因为这一节没有"轮"的概念,工具是被 Worker 直接按节点分派的,不再需要包一层 ToolCall。这是个很小但很重要的"奥卡姆剃刀"细节。
3. 正则 + 引用解析
python
REFERENCE_RE = re.compile(r"#E(\d+)")
def resolve_references(value: Any, evidence: dict[str, str]) -> Any:
if not isinstance(value, str):
return value
return REFERENCE_RE.sub(
lambda m: evidence.get(f"E{m.group(1)}", m.group(0)),
value,
)
语法点详解
re.compile(r"#E(\d+)"):r"..."是原始字符串字面量 ,反斜杠不再被 Python 解析,正则才能拿到完整\d。#E是字面字符。(\d+)是捕获组,匹配一段连续数字。
isinstance(value, str):类型守卫,避免对非字符串值(比如数字)做正则替换。REFERENCE_RE.sub(repl, value):把所有匹配替换掉。repl可以是字符串,也可以是函数,这里传了 lambda。m.group(1):第 1 个捕获组,例如匹配#E2时 group(1) 是"2"。m.group(0):整个匹配文本,例如"#E2"。evidence.get(key, default):取不到时返回 default ------ 这里返回原始#E2,意思是"没解析到就保留原样"。
设计意图
这是 ReWOO 的胶水 :Planner 输出 {"query": "population of #E1"},Worker 执行前把字符串里的占位符换成上一步输出 Paris,最终调用的是 search("population of Paris")。
4. 拓扑排序:按依赖跑节点
python
def topological(plan: Plan) -> list[PlanStep]:
resolved: list[PlanStep] = []
known: set[str] = set()
pending = list(plan.steps)
while pending:
progress = False
rest: list[PlanStep] = []
for step in pending:
refs = REFERENCE_RE.findall(str(step.args))
if all(f"E{r}" in known for r in refs):
resolved.append(step)
known.add(step.id)
progress = True
else:
rest.append(step)
if not progress:
raise RuntimeError("cyclic plan or unresolved reference")
pending = rest
return resolved
算法思路(这是面试常考)
经典 Kahn 算法的简化版:
pending:还没排完的节点;- 一轮内,把"所有依赖都已 known"的节点丢进
resolved; - 如果一整轮都没新增任何节点 → 存在环,抛错;
- 重复直到 pending 空。
语法点
REFERENCE_RE.findall(str(step.args)):findall 返回所有捕获组的列表,例如["1", "2"]。all(... for r in refs):生成器表达式 +all(),相当于"所有依赖都已 known"。- 入门提醒:
all([])返回True(空集合所有元素都满足任何条件,逻辑学叫"空真")。 - 所以没有依赖的节点会立刻被排出来,这正是我们想要的。
- 入门提醒:
set():用 set 做"已完成"查询是 O(1),比 list 的 O(n) 快。
设计意图
返回的是线性序列 ,但拓扑保证了"每层内可以并行"------ 课后练习 1 就是把这个 for 循环改成并行(asyncio.gather 或 concurrent.futures)。
5. Worker 执行:解析引用 + 分派工具
python
def run_workers(plan: Plan, tools: ToolRegistry) -> dict[str, str]:
evidence: dict[str, str] = {}
for step in topological(plan):
bound_args = {k: resolve_references(v, evidence) for k, v in step.args.items()}
evidence[step.id] = tools.dispatch(step.tool, bound_args)
return evidence
语法点
- 字典推导式
{k: f(v) for k, v in d.items()}:把字典里的每个 value 过一遍f,key 不变。这是 Python 里最 Pythonic 的"map 一个字典"写法。 d.items():返回(key, value)二元组迭代器。evidence[step.id] = ...:用 step.id("E1"/"E2"/"E3")当 key 存证据。
整体流程
每个节点执行三件事:
- 绑参 :把字符串参数里的
#E1替换成真实值; - 分派:调用工具;
- 存证:把结果丢进 evidence 字典,供后续节点引用。
6. 脚本化 Planner / Solver
python
class ScriptedPlanner:
def __init__(self, plan: Plan) -> None:
self.plan = plan
def plan_for(self, question: str) -> Plan:
return self.plan
class ScriptedSolver:
def __init__(self, answer_template: str) -> None:
self.template = answer_template
def solve(self, question: str, plan: Plan, evidence: dict[str, str]) -> str:
return self.template.format(**evidence)
设计意图
和第 1 节的 ToyLLM 思路一致:离线、确定性地把循环跑通。把这两个类换成真 LLM:
plan_for(question)→ 一次 LLM 调用,输出结构化 JSON 计划;solve(question, plan, evidence)→ 一次 LLM 调用,给出最终答案。
控制流不变。 这是教学型代码的精华。
语法点:str.format(**evidence) 的字典解包
python
template = "capital is {E1}; population is {E3}"
evidence = {"E1": "Paris", "E2": "11.2", "E3": "11"}
template.format(**evidence)
# -> "capital is Paris; population is 11"
**evidence 把字典展开成关键字参数 E1="Paris", E2="11.2", E3="11",刚好对应 {E1}、{E3} 占位符。多余的 E2 不会报错。
7. 两个示例工具
python
def fake_search(query: str) -> str:
if "capital of france" in query.lower():
return "Paris"
if "population of paris" in query.lower():
return "11.2 million metro"
if "capital of germany" in query.lower():
return "Berlin"
return f"no result for {query!r}"
def rounded_million(text: str) -> str:
m = re.search(r"([0-9]+\.?[0-9]*)", text)
if not m:
return "unknown"
return f"{round(float(m.group(1)))} million"
语法点
query.lower():转小写做大小写不敏感匹配,避免"Capital of France"不命中。re.search(r"([0-9]+\.?[0-9]*)", text):[0-9]+:至少一位数字;\.?:可选小数点;[0-9]*:可选小数部分;- 整体捕获一个数字(含可选小数)。
round(float("11.2"))→11:先转 float 再四舍五入。f"{x!r}":!r让 f-string 调用repr(),把字符串带上引号,便于调试。
设计意图
fake_search 是个字典查表式假搜索 ,让 demo 离线可复现;真实环境换成 Google 搜索 / RAG 即可。rounded_million 演示了"工具可以做纯数据加工",不止是 IO。
8. 包装一次完整 ReWOO 运行
python
@dataclass
class ReWOORun:
question: str
plan: Plan
evidence: dict[str, str] = field(default_factory=dict)
answer: str = ""
planner_chars: int = 0
worker_chars: int = 0
solver_chars: int = 0
def run_rewoo(question: str, planner: ScriptedPlanner,
tools: ToolRegistry, solver: ScriptedSolver) -> ReWOORun:
plan = planner.plan_for(question)
planner_chars = len(question) + sum(len(s.tool) + len(str(s.args))
for s in plan.steps)
evidence = run_workers(plan, tools)
worker_chars = sum(len(str(s.args)) + len(v) for s, v in zip(plan.steps,
evidence.values()))
answer = solver.solve(question, plan, evidence)
solver_chars = len(question) + worker_chars + len(answer)
return ReWOORun(question=question, plan=plan, evidence=evidence,
answer=answer,
planner_chars=planner_chars, worker_chars=worker_chars,
solver_chars=solver_chars)
语法点
field(default_factory=dict):和上一节default_factory=list同理 ------ 避免可变默认值被所有实例共享。sum(... for s in plan.steps):生成器表达式,比 list 推导式更省内存(不构建中间 list)。zip(plan.steps, evidence.values()):并行迭代两个序列。注意:Python 字典从 3.7 起保证插入顺序 ,所以evidence.values()的顺序和plan.steps一致。
设计意图:为什么要数字符?
ReWOO 论文比较的是 token 数 ,但 token 数依赖 tokenizer,玩具里用字符数做近似:
planner_chars:question + 计划描述worker_chars:每个工具的参数 + 输出solver_chars:question + 全部 evidence + 答案
加起来就是 ReWOO 的"总上下文体积"。
9. 假的 ReAct 对照:让 token 节省看得见
python
def run_react_mock(question: str, tools: ToolRegistry,
trajectory: list[tuple[str, dict[str, Any]]]) -> int:
prompt_chars = len(question)
total = 0
history_chars = 0
for name, args in trajectory:
total += prompt_chars + history_chars + len(name) + len(str(args))
obs = tools.dispatch(name, args)
history_chars += len(name) + len(str(args)) + len(obs) + 40
total += prompt_chars + history_chars
return total
设计意图(重点理解)
这段没真的跑 ReAct 循环,而是手算它的 token 增长曲线:
- 每一步 都要花
prompt_chars + history_chars + (新动作),模拟"每次都带原始 prompt + 全部历史"; history_chars单调递增 ------ 这就是二次增长的来源;- 末尾再加一次"最终汇总"。
把它和 rewoo_chars 一比,就能直观看到论文说的 5x 比例从哪来。
语法点
list[tuple[str, dict[str, Any]]]:嵌套泛型 ------ 一个 list,元素是二元组,二元组是(工具名, 参数字典)。for name, args in trajectory:元组解包,把每个二元组拆成两个变量。
10. main 函数:一次完整跑通
python
def main() -> None:
print("=" * 70)
print("REWOO --- Planner, Workers, Solver (Phase 14, Lesson 02)")
print("=" * 70)
tools = ToolRegistry()
tools.register("search", fake_search)
tools.register("round_million", rounded_million)
plan = Plan(steps=[
PlanStep("E1", "search", {"query": "capital of France"}),
PlanStep("E2", "search", {"query": "population of #E1"}),
PlanStep("E3", "round_million", {"text": "#E2"}),
])
planner = ScriptedPlanner(plan)
solver = ScriptedSolver(
"The capital of France is {E1}; rounded population is {E3}."
)
run = run_rewoo("What is the population of the capital of France, rounded?",
planner, tools, solver)
print("\nPLAN")
for step in run.plan.steps:
print(f" {step.id}: {step.tool}({step.args})")
print("\nEVIDENCE")
for k, v in run.evidence.items():
print(f" {k} -> {v}")
print(f"\nFINAL: {run.answer}")
react_chars = run_react_mock(
run.question, tools,
[("search", {"query": "capital of France"}),
("search", {"query": "population of Paris"}),
("round_million", {"text": "11.2 million metro"})])
rewoo_chars = run.planner_chars + run.worker_chars + run.solver_chars
print("\nTOKEN INTUITION (chars, approximate)")
print(f" react total : {react_chars}")
print(f" rewoo total : {rewoo_chars}")
print(f" ratio : {react_chars / max(rewoo_chars, 1):.2f}x")
计划 DAG 长这样
text
E1: search(query="capital of France")
↓
E2: search(query="population of #E1") ← 依赖 E1
↓
E3: round_million(text="#E2") ← 依赖 E2
执行序列:
text
E1 → search("capital of France") → "Paris"
E2 → search("population of Paris") → "11.2 million metro" (#E1 被替换)
E3 → round_million("11.2 million metro") → "11 million" (#E2 被替换)
Solver → "The capital of France is Paris; rounded population is 11 million."
语法点
"=" * 70:字符串乘法,重复 70 次,得到一条分隔线。max(rewoo_chars, 1):防止除零。f"{x:.2f}":保留 2 位小数的浮点格式化。
八、本节小结 & 易错点回顾
核心知识点
- ReWOO = Planner + Worker + Solver,三段解耦。
- Planner 看不到 Observation → 输入空间小 → 可被蒸馏成小模型。
- token 节省的本质:worker prompt 不再携带全历史。
- 失败粒度从"步骤"变成"节点",重跑只需重跑那个节点。
- 不是所有任务都适合 ReWOO ------ 环境未知、需要反应式调整的任务还是 ReAct 更合适。
Python 入门易错点
| 坑 | 正确做法 |
|---|---|
正则字符串忘了 r"..." 前缀 |
re.compile(r"\d+") 而不是 "\d+" |
用 eval 替换占位符 |
用 re.sub + 回调,安全可控 |
all([]) 返回什么没搞清楚 |
返回 True(空真),这正是"无依赖节点立刻被排出来"的原因 |
| 用 list 做"已完成"集合 | 用 set,O(1) 查询 |
dict 默认值踩可变陷阱 |
field(default_factory=dict) |
format(**d) 时 key 不在模板里 |
不会报错,多余 key 被忽略;少 key 才会 KeyError |
参考项目 https://github.com/fancyboi999/ai-engineering-from-scratch-zh