先把话挑明:这篇不教你搭博客写作工作流,也不给 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_brief,draft_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=True 和 write_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_URL、LLM_MODEL、LLM_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 真正停下来,并且跨进程可以恢复、无法绕过审批。
踩坑速查
| 症状 | 原因 | 解法 |
|---|---|---|
--live 报 LLM network error / DNS 解析失败 | 沙箱或本地网络到模型 API 不通 | 用离线 python3 -m unittest -v 先验证控制流;真实调用再解决网络/代理 |
模型返回值解析失败或 content 为空 | 部分推理模型默认 thinking,content 在 reasoning 里 | 显式 thinking:{"type":"disabled"} 并要求 JSON 输出 |
Java 运行报 UnsupportedClassVersionError | 用了旧版 JDK | 用 JDK 21 + ./gradlew,或直接 javac/java 跑 MinimalSuspendDemo |
| 计划里写了“等待”,运行时不暂停 | 把等待当成了数据标记 | 改成 return/break/状态机迁移,并写进回归测试 |
参考
- LangGraph Documentation, “Workflows and Agents”, https://docs.langchain.com/oss/python/langgraph/workflows-agents/(Workflow 与 Agent 的路径决定方式,成文时已核对)。
- LangGraph Documentation, “Interrupts / Human-in-the-loop”, https://docs.langchain.com/oss/python/langgraph/interrupts/(
interrupt()+checkpointer 的挂起恢复能力,本篇只作方向标记)。 - 本话演示代码与回归测试:配套代码仓库中的
python/control_modes.py、java/.../Main.java、java/.../MinimalSuspendDemo.java。

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