ReWOO 与 Plan-and-Execute:解耦的规划

一、为什么需要 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],是为了未来扩展 :以后可以加 metaversionmax_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 算法的简化版:

  1. pending:还没排完的节点;
  2. 一轮内,把"所有依赖都已 known"的节点丢进 resolved
  3. 如果一整轮都没新增任何节点 → 存在环,抛错;
  4. 重复直到 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.gatherconcurrent.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 存证据。

整体流程

每个节点执行三件事:

  1. 绑参 :把字符串参数里的 #E1 替换成真实值;
  2. 分派:调用工具;
  3. 存证:把结果丢进 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 位小数的浮点格式化。

八、本节小结 & 易错点回顾

核心知识点

  1. ReWOO = Planner + Worker + Solver,三段解耦。
  2. Planner 看不到 Observation → 输入空间小 → 可被蒸馏成小模型。
  3. token 节省的本质:worker prompt 不再携带全历史
  4. 失败粒度从"步骤"变成"节点",重跑只需重跑那个节点。
  5. 不是所有任务都适合 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

相关推荐
金融RPA机器人丨实在智能1 小时前
工程线索工具合规避坑指南:使用开源爬虫抓取数据会触犯法规吗?实在Agent给出了安全答案
人工智能·爬虫·安全·ai·开源
去码头整点薯条ing1 小时前
某红书笔记接口逆向【x-s参数】
javascript·爬虫·python
xxie1237941 小时前
参数Parameter,形参Formal Parameter,实参Actual Argument
开发语言·python
love530love1 小时前
Hermes-Agent 本地化部署与详细交互式配置实战指南 [LM Studio + QQ ]
人工智能·windows·python·aigc·agent·hermes·hermes-agent
高洁011 小时前
人人可用的智能体来了
python·深度学习·机器学习·数据挖掘·知识图谱
装不满的克莱因瓶1 小时前
NLP中的卷积神经网络CNN——从图像卷积到文本特征提取的跨界应用
人工智能·pytorch·python·深度学习·神经网络·自然语言处理·cnn
Java知识技术分享1 小时前
node安装新版本,并解决opencode和claude code不能用问题
ai·个人开发·ai编程
在放️1 小时前
Python 爬虫 · XML、xpath 与 lxml 模块基础
开发语言·爬虫·python
天涯明月19931 小时前
vibe-coding核心方法论
人工智能·大模型·agent·研发流程