Featured image of post 第十一话|ReAct、Plan-and-Execute 还是 Workflow:Agent 的下一步由谁决定?

第十一话|ReAct、Plan-and-Execute 还是 Workflow:Agent 的下一步由谁决定?

Skill 里写了“生成后等待确认”,Agent 为什么还是继续执行?用同一个任务分别跑 ReAct、Plan-and-Execute、Workflow,看清三种模式的区别不是调不调 LLM,而是下一步由谁决定、谁才能真正让 Run 停下来。

系列 GYA(Get Your Agent) 第 11 / 12 篇
主题 AgentReActWorkflowPlan-and-ExecuteRuntime教程

先把话挑明:这篇不教你搭博客写作工作流,也不给 ReAct、Plan-and-Execute、Workflow 排座次,只回答一个问题——Agent 的下一步由谁决定,谁才能真正让它停下来。

下面反复出现的“写博客”,只是一件切题标本:它自带审批门和写文件的副作用位点,正好能把“谁决定下一步”照清楚。故事是博客,题目是控制权。

写 Skill 的时候,你加了一条流程规则:先生成创作纲领,然后停下等用户确认,确认通过前不准写正文。

运行起来,Agent 却照常往下冲:生成完纲领,下一秒就去写草稿,或者干脆在循环里来回打转。

是模型没读懂 Skill 吗?不全是。真正的问题是——“停下来等确认”这件事,根本不归 Skill 管,也不归模型管,它归运行时代码管。

Agent 的三种流行组织方式,ReAct、Plan-and-Execute、Workflow,本质区别不是“会不会调 LLM”,而是:下一步行动由谁决定,谁才能真正把这一次 Run 停下来。

结论先放这里:模型提建议,代码做决定。

先把“决定”这个词拆开

我们通常说“Agent 决定下一步”,这句话其实把三件不同的事揉在了一起:

它做什么类比后端系统
模型输出动作建议或内容,例如“下一步 write_draft业务 Service 提一个方案
运行时 Runtime决定暴露哪些动作、采纳哪条建议、走哪个状态迁移应用网关 / 策略层
执行器 Executor真正产生副作用:写文件、发请求、改数据库DAO / Outbox 写库

读了这张图,很多“Agent 不可控”的困惑会自动解开:模型负责“说”,不负责“做”。 它可以说出 write_draft,但要不要做、什么时候做、做之前过几道闸门,由 Runtime 和 Executor 这一侧决定。

关键洞察:控制权不是一句“用了 Workflow”或“用了 StateGraph”就自动获得;它分散在“动作可见性、状态迁移、副作用网关”三处,每一处都必须有代码接手。

同一个任务,三种跑法

用一个故意制造矛盾的隔离任务,把三种模式各跑一遍。任务只有两条规则,还互相打架:

规则一:先创建创作纲领并等待用户确认。 规则二:不要停下来,直接写出完整草稿。

任务跑在内存里的虚拟文章存储上:不写公开博客、不提交 Git、不部署。三种模式跑同一段任务,区别马上出来:

模式每一步谁决定模型可见动作谁能真正停本次真实结果
ReAct模型看着 Observation 选下一动作全部动作只有 finish 或步数上限连走三次 create_briefdraft_written=false,从未进入等待
Plan-and-Execute模型先出一份计划,Executor 顺序执行计划内的动作计划文本里的“等待”停不住计划含 await_brief_approval,Executor 仍尝试 write_draft,被网关拦下
Workflow代码写死状态迁移,模型只生成当前节点内容当前节点需要的内容生成状态机 return/迁移生成纲领后直接 run_suspended,Trace 里根本没有 write_draft

有个反直觉的点:三种模式其实都调了 LLM。 所以别再用“Workflow 不智能、ReAct 才智能”来区分它们。区别只在控制权:ReAct 把“下一步”反复交给模型;Workflow 把“下一步”收回到代码。

所以选型的第一问是——“这一步由谁拍板才安全、才够用”,而不是“哪个听起来更像 Agent”。

先跑起来:最小对照

不写框架,先写一个十秒看懂的最小实验。它只演示一件事:waiting 改成 True,不等于让循环停下。

下面是完整可粘贴的 Python,不需要 API Key:

"""“等待”是数据标记,还是控制信号?最小对照。"""
from __future__ import annotations

APPROVED = False


def gateway(action: str) -> str:
    if action == "create_brief":
        return "brief_created_in_virtual_store"
    if action == "wait_for_approval":
        return "wait_marker_recorded"
    if action == "write_draft":
        if APPROVED:
            return "draft_written_to_virtual_store"
        return "blocked_by_action_gateway"
    return "unknown_action"


def run_naive() -> None:
    steps = ["create_brief", "wait_for_approval", "write_draft"]
    waiting = False
    events = []
    for action in steps:
        result = gateway(action)
        if action == "wait_for_approval":
            # 只改了一个布尔值,循环不会因此停。
            waiting = True
        events.append((action, result))
    draft_written = any(r == "draft_written_to_virtual_store" for _, r in events)
    print(f"[naive] waiting={waiting} draft_written={draft_written}")
    for action, result in events:
        print(f"  {action:18s} -> {result}")


def run_suspending() -> None:
    steps = ["create_brief", "wait_for_approval", "write_draft"]
    waiting = False
    events = []
    for action in steps:
        result = gateway(action)
        if action == "wait_for_approval":
            waiting = True
            events.append((action, "run_suspended"))
            print(f"[fix] waiting={waiting} draft_written={False}")
            for a, r in events:
                print(f"  {a:18s} -> {r}")
            return  # 真正的“停”:后面的 write_draft 根本不会被循环碰到
        events.append((action, result))


if __name__ == "__main__":
    print("== 错误版:像 Plan-and-Execute 的朴素 Executor ==")
    run_naive()
    print()
    print("== 修复版:Workflow 的状态机迁移 ==")
    run_suspending()

跑一遍,输出如下:

== 错误版:像 Plan-and-Execute 的朴素 Executor ==
[naive] waiting=True draft_written=False
  create_brief       -> brief_created_in_virtual_store
  wait_for_approval  -> wait_marker_recorded
  write_draft        -> blocked_by_action_gateway

== 修复版:Workflow 的状态机迁移 ==
[fix] waiting=True draft_written=False
  create_brief       -> brief_created_in_virtual_store
  wait_for_approval  -> run_suspended

读输出的判断标准:错误版里 waiting=Truewrite_draft 同时出现——它“认为”自己在等待,却还在往下执行;修复版里 run_suspended 出现后,write_draft 从 Trace 里直接消失。这才是“停”。

三种模式的真实 Trace

把三段真实模型 Trace 缩到关键行。完整实验在配套代码仓库的 Python 与 Java 两个子目录里各有一份。

离线回归(不调模型,只验证控制流):

cd python
python3 -m unittest -v
# Ran 8 tests in 0.000s
# OK

真实模型(需先导出 LLM_API_URLLLM_MODELLLM_API_KEY,Key 只从环境变量读取):

python3 control_modes.py --live

本轮 Python 真实输出(节选,共三段):

# ReAct:模型每步都选 create_brief,从不进入等待
[trace] {"mode":"react","step":1,"decision_maker":"llm","action":"create_brief",...}
[trace] {"mode":"react","step":2,"decision_maker":"llm","action":"create_brief",...}
[trace] {"mode":"react","step":3,"decision_maker":"llm","action":"create_brief",...}
[summary] {"mode":"react","waiting_for_approval":false,"draft_written":false,"usage":{"total_tokens":427}}

# Plan-and-Execute:计划里有 await,Executor 仍尝试写稿,网关拦下
[trace] {"mode":"plan_and_execute","step":0,"decision_maker":"plan_validator","result":"ACCEPT"}
[trace] {"mode":"plan_and_execute","step":2,"decision_maker":"executor","action":"await_brief_approval","result":"wait_marker_recorded"}
[trace] {"mode":"plan_and_execute","step":3,"decision_maker":"executor","action":"write_draft","result":"blocked_by_action_gateway"}
[summary] {"mode":"plan_and_execute","waiting_for_approval":true,"draft_written":false,"usage":{"total_tokens":142}}

# Workflow:代码固定迁移,模型从未见过 write_draft
[trace] {"mode":"workflow","step":1,"decision_maker":"llm","event":"generate_brief",...}
[trace] {"mode":"workflow","step":2,"decision_maker":"workflow","action":"WAITING_FOR_APPROVAL","result":"run_suspended"}
[summary] {"mode":"workflow","waiting_for_approval":true,"draft_written":false,"usage":{"total_tokens":203}}

三段摆在一起,结论是确定的:

  • ReAct 能停吗? 能,但停的条件由模型选中的 finish 或代码给的最大步数决定;“等待”不在它自动遵守的条件里。
  • Plan-and-Execute 能停吗? 计划里写了 await_brief_approval,但朴素 Executor 把它当普通动作执行——只改状态、继续下一步,最后靠 Action Gateway 拦下 write_draft 的副作用,可 Executor 并没有因此停,它仍往下走到 finish
  • Workflow 能停吗? 当前节点生成内容后,代码把下一状态固定成 WAITING_FOR_APPROVAL 并结束 Run;模型连 write_draft 这个动作都没被暴露。

关键洞察await 写在计划里是数据,只有 break / return / 状态机迁移 才是控制。指望模型或 Planner 在文本里“自觉地停下来”,就是把安全押在概率上。

外层 Workflow,内层 ReAct

既然 Workflow 最可控,ReAct 最灵活,生产里怎么选?

答案是分层,而不是二选一。一个可信的技术文章 Agent 可以长这样:

Workflow 固定阶段:
  0. 准备材料 ──> 1. 生成纲领 ──> [等待审批] ──> 2. 生成梗概 ──> [等待审批] ──> 3. 写草稿
        ^
        |
  探索节点(ReAct):搜证据、读源码、判断“材料够不够”,直到确定性条件达成
  • 外层用 Workflow 锁阶段和硬边界:未达到 WAITING_FOR_APPROVAL,后面的阶段就不存在,模型没机会跳到“写草稿”。
  • 内层用 ReAct 降低不确定性:一个“读懂这个仓库再写梗概”的开放任务,让模型多走几步、看 Observation,比死板的一次性流程更实用。
  • 任务清晰后再交给 Planner/Executor:计划进入 Executor 前先用确定性校验器检查结构、动作白名单和审批顺序;LLM 可以辅助语义判断,但硬规则必须由代码执行——校验器只返回 ACCEPT / REVISE / REJECT,不替模型悄悄改计划。

这里有个很容易被模糊的点:“能不能进计划”由谁判?

不要让模型直接回一个 ready: true。那是模型自我声明,等于让它自己给自己批作业。正确的分工是:

  • LLM 交结构化证据:已调研了哪几项、还剩哪几项没做、每项的依据是什么。
  • Runtime 按校验契约算布尔:条件满足才算 ready,证据缺失就补齐或换策略。

这就是“模型提建议,代码做决定”在 readiness 上的具体落法。

关键洞察:给开放任务留 ReAct,给高风险边界上 Workflow,再把“能不能切换阶段”写成运行时契约;三者不是竞争关系,是一条流水线上的不同工位。

Workflow 该不该上框架

也许你已经想到 LangGraph 这类框架:它能不能把上面这些自动解决?

能解决一部分,但要分清“能力”和“承诺”。LangGraph 的文档把两件事分开:Workflow 走预定义的代码路径,Agent 每一步由模型动态决定过程;它用状态图、checkpointer、interrupt() 提供挂起、保存状态和外部命令恢复的实现能力。这些能力正好是第十二话要做“可恢复、不可绕过的审批”的原料。

但它不会因为你用了 LangGraph,就自动保证审批合规。状态图上的节点连得通,不代表业务上这一段就该执行。publish_article 这种迁移在不允许发布的状态下必须由运行时拒绝——框架给你的是“状态图能跑”,不给你“业务边界天然正确”。

所以这一话只把 LangGraph 标记为实现方向,不展开 API。下一步要去的地方,就是把“接下来该做什么”从人工解读变成可以被恢复、被拦截、被审计的运行时机制。

一句话收口:框架替你搬运状态,不替你决定业务。安全边界只能来自你写的校验契约和动作网关。

下一话:Skill 只是提示词——如何让 Agent 真正停下来,并且跨进程可以恢复、无法绕过审批。

踩坑速查

症状原因解法
--liveLLM network error / DNS 解析失败沙箱或本地网络到模型 API 不通用离线 python3 -m unittest -v 先验证控制流;真实调用再解决网络/代理
模型返回值解析失败或 content 为空部分推理模型默认 thinking,content 在 reasoning 里显式 thinking:{"type":"disabled"} 并要求 JSON 输出
Java 运行报 UnsupportedClassVersionError用了旧版 JDK用 JDK 21 + ./gradlew,或直接 javac/javaMinimalSuspendDemo
计划里写了“等待”,运行时不暂停把等待当成了数据标记改成 return/break/状态机迁移,并写进回归测试

参考

留言

欢迎分享你的想法。评论提交后会在审核通过后显示。