🎯一句话论点:在会产生不可逆后果的 Agent 里,“要不要执行”这个决策不能交给 LLM。 本文以一个生产环境的营销 Agent(一个“邀请返利活动”助手)为例,讲清为什么要在 LLM 之外加一道“确认拦截”、双阶段提交怎么落地,以及背后四道确实在跑的防线。为避免泄露实现细节,下文用角色化的名字代替真实模块名,代码也只保留点睛的几段伪代码。

一、先想一个 bug

商家在 IM 上跟机器人聊了几轮,调出一个“邀请返利活动”的预览。机器人问:“要不要再调一下第二档的奖励?”商家回:“好的。”

这句“好的”是什么意思?是“好,我要改第二档”,还是“好,就这样提交吧”?

如果你把这个判断交给 LLM,它有相当概率猜错。而且猜错的代价不对称:猜成“改”,大不了多问一句;猜成“提交”,就把一个商家还没想好的活动直接发上线——这是个不可逆的写操作。

这就是本文的核心问题:人机协作(HITL, Human-in-the-Loop)里的“提交”决策,到底该不该交给 LLM?

二、背景:这是个什么 Agent

一个跑在 IM(Telegram / LINE)上的营销助手,商家用纯自然语言跟它说话,它帮商家创建 / 编辑 / 启用一个“邀请返利”活动。技术上是一个基于 LangGraph create_react_agent 的 ReAct Agent,挂了 6 个工具(按职责):

工具(按职责) 读 / 写
推荐方案(基于历史活动 + 可用券)
查券库(挂券只能来自这里,杜绝 LLM 编造券 ID)
查会员类型 / 等级
创建活动
查已有活动(拿状态决定可编辑范围)
修改 / 启用活动(一个工具三操作)

关键点:“创建”和“修改 / 启用”是写操作,会真的在后端生成 / 改动一个线上活动。这跟“查个天气”“总结一段话”这种纯读 Agent 完全不同——读错了重试就好,写错了是事故。

整个 Agent 严格分三层,确定性代码与 LLM 各管一段:

商家 · IM(Telegram / LINE)

意图路由层
分类 · 粘性会话 · 命令短路

① I/O 编排层
加锁 · 恢复会话 · 确认拦截 · 收尾

有待确认预览
且这轮像确认?

确认拦截(确定性)
4 分类 → 直接提交

② ReAct 编排层
create_react_agent

③ 业务工具(6 个)
预览:组装/校验/暂存

后端 · 营销活动写接口

会话存储(Redis)

🎨配色(下文各图统一):🟩 绿 = 确定性代码(编排 / 拦截 / 闸门)|🟨 黄 = LLM / Agent / 工具|🟪 紫 = 状态存储|⬜ 灰 = 外部后端|🟦 蓝 = 入口

一个需要说明的实现细节:主推理是 LangGraph create_react_agent;判断“是不是确认”用的是同一套 LLM 配置、只是把输出 token 上限压到很小的“轻量调用”——不是另一个更便宜的模型,只是限了输出长度。

三、朴素做法为什么不行

最直觉的做法:给 LLM 一个“提交”工具,让它自己判断商家什么时候“确认了”,然后调用。三个问题:

  1. 语义模糊。 “好 / ok / 可以 / 就这样”在不同上下文意思完全相反。同一句“确认”,跟在“要不要改 X”后面是“改”,跟在“这是预览,确认吗”后面才是“提交”。
  2. 代价不对称。 误提交远比漏提交严重(凭空在后端创建一个线上活动)。但 LLM 的损失函数里没有这个不对称——它只是“尽量猜对”。
  3. 不可复现。 同一句话下次可能猜另一个结果,出事难复现、难归因。

一句话:把一个“错一次就出事故”的决策,交给一个概率性黑盒,不是好主意。

四、核心设计:把“提交”从 LLM 手里拿走(dry_run / commit 双阶段)

把流程切成两次调用,走不同的代码路径

  • dry_run(预览)交给 LLM。 LLM 每次调“创建”工具都是 dry_run:把自然语言理解成结构化配置、组装、校验、生成预览(文本 + PDF),把待提交内容暂存进会话。这一阶段错了也只是预览不准,重生成即可。
  • commit(提交)交给确定性代码。 只有它能把暂存内容真正发给后端。LLM 永远不拿提交权。

这条铁律落在工具入参的一个布尔开关上:默认关闭,并在它的字段说明里直接写给 LLM 看——“你永远别动这个字段;只有系统的确认拦截会把它置真”。工具内部据此分流:只有这个开关为真、且暂存存在、名称与暂存一致,才真正调后端写接口;否则一律走预览、绝不碰后端。

最反直觉、也最关键的一点:拦截发生在 LLM 之前。 每条消息进来,先看会话里有没有“待确认预览”;若有,先判这轮是不是确认,是就直接提交,根本不进 ReAct 循环

# 消息入口:拦截在 ReAct 之前(伪代码)
intercepted = confirm_intercept(session, text)
if intercepted is not None:
    result = intercepted          # 命中确认 → 直接提交,不进 LLM
else:
    result = run_react_turn(state)  # 否则才走 ReAct

五、“是不是确认”怎么判:白名单 + 4 分类 + 安全偏置

等一下——“是不是确认”这个判断,不还是得用 LLM?

是,但用法不同:我们不让 LLM “决定要不要提交”,只让它做一个窄而明确的分类,并把“拿不准时倒向哪边”写死。

第一步:硬规则 fast-path。 严格肯定词(白名单 + 长度护栏),命中直接判 SUBMIT,连 LLM 都不调:

# 伪代码
STRICT_CONFIRM = {"确认创建", "确认提交", "confirm create", ...}

def is_strict_confirm(text):
    t = text.strip().lower().rstrip("。.!?~ ")
    if not t or len(t) > 25:     # 超 25 字一定不是“干脆的确认”
        return False
    return t in STRICT_CONFIRM

第二步:模糊情况才过一个轻量 LLM 分类器,四选一:

分类 含义 动作
SUBMIT 确认提交已展示的预览 走提交
PREVIEW_AGAIN 带最新改动重生成预览 解锁,重生成
CHANGE 又提了新修改 锁住,累积不出预览
OTHER 问候 / 取消 / 提问 / 跑题 锁住,正常回复

分类器拿上一条机器人回复消歧——“确认”跟在“修改方向提议”后面是 PREVIEW_AGAIN,跟在“已展示预览的 review 请求”后面才是 SUBMIT。而最关键的一条原则被写进了分类 prompt,把“代价不对称”显式编码进偏好:

⚖️拿不准时,宁可判 PREVIEW_AGAIN 也别判 SUBMIT —— 误提交的代价远大于多生成一次预览。

分类结果只有 SUBMIT 才提交;只有 PREVIEW_AGAIN 才解锁重生成,CHANGE / OTHER 一律锁住累积。这把“代价不对称”从一句口号变成了代码里的硬偏置。

确认拦截的完整判定:

SUBMIT

PREVIEW_AGAIN

CHANGE / OTHER

修改

创建

新消息进来

有待确认预览?

走正常 ReAct

严格肯定词?
确认创建 / confirm create

判定 = SUBMIT

轻量 LLM 4 分类
带上一条回复消歧

分类结果

解锁 → 重生成预览

锁住 → 累积 / 闲聊

哪个待确认不空?
修改优先

提交:修改 / 启用

提交:创建

六、不止拦截:四道确实在跑的防线

光有拦截还不够。生产里还有几种“旧状态被误提交”的场景,所以叠了多道防御。这里只列代码里确实在执行的:

防线 ① 确认拦截 + 4 分类(上面讲的)——防“模糊回复被当提交”。唯一用到 LLM 的一道,且是“受限的 LLM”。

防线 ② closure gate(收口闸门)+ 累积锁。 两道都是确定性闸,且在任何写 / 状态变更之前就 return。预览已存在后,商家每改一句不立刻重生成,先累积:只要“还没明确收口”或“这轮被判成改动 / 闲聊”,预览生成就被直接挡回,既不出新预览、更不碰后端,直到商家明说“就这样”。

防线 ③ 提交三重条件 + 名称校验。 即使 LLM 越权打开那个提交开关,也必须“暂存存在 + 名称与暂存一致”才提交,否则降级当预览。合法触发提交的唯一入口是确定性的确认拦截。

防线 ④ 切换冷启动 guard。 从别的场景切回来时清掉残留的待确认预览,否则商家随口一句“重启动词”可能被误判,把上个场景遗留的旧预览提交上线。

外加一条状态完整性约束:“创建”与“修改”两套待确认内容互斥——写任一新预览时清掉另一个,否则确认拦截不知道该提交哪条(拦截里修改优先、其次创建)。

会话状态机(状态存外部缓存,跨多轮记住“上一轮预览了什么”):

创建预览(dry_run)

修改预览(dry_run)

CHANGE 累积·重建预览

CHANGE 累积·重建预览

确认拦截 SUBMIT

确认拦截 SUBMIT

清待确认·记“最近提交”

切换清除

切换清除

Idle

PendingCreate

PendingModify

Committed

创建 / 修改两套待确认互斥:
写新预览会清掉另一个

七、两个“防错”细节:同一种确定性哲学

把“容易出错的地方”收敛到确定性代码的单点,而不是散落各处、更不是交给 LLM——这套思路在数据建模上也有两处体现:

① 单位换算只做一次。 后端积分以 0.01 为单位,前端按“输入值 ×100”存。所以内部全程用商家视角的整数,只在出站组装的那一处统一 ×100,从后端读回时统一 //100。好处:理解、预览、校验全用人类可读的整数,不会到处 ×100 漏改。

None vs 0 的 sentinel。 可选数值用 None 区分“没传”和“传了 0”:None = “LLM 没传”→ 兜底默认值;0 = “商家明确说不要这侧奖励”→ 保留 0。所以判断要用 is None 而不是 not x

# 伪代码:兜底只在“没传”时发生,显式 0 被尊重
if cfg.get("points") is None:
    cfg["points"] = DEFAULT_POINTS

如果用普通的 = 默认值,就分不清“没说”和“说了不要”。

八、完整的一轮

后端 ReAct Agent(LLM) 编排层(确定性) 后端 ReAct Agent(LLM) 编排层(确定性) 4 分类 = CHANGE → 锁住累积 收口 → 重生成预览(旧的丢弃) 严格肯定词 → SUBMIT(绕开 LLM) 商家 "满 3 人送 50 积分" 1 dry_run:组装 + 校验 + 暂存 2 预览 + PDF(请确认) 3 "第二档加到 300" 4 "收到,还要改别的吗?" 5 "就这样" 6 dry_run(closure=True) 7 新预览 8 "确认创建" 9 提交 10 ok 11 已创建 ✅(草稿态) 12 商家

补一个真实细节:“创建”的提交永远只存草稿态,AI 不直接把活动设为上线;要上线得走“修改 / 启用”路径,等于又过一次“预览 → 确认”。多一层不可逆,就多一道确认。

九、可迁移的设计原则

  1. 不可逆动作要有一道不过 LLM 的闸门。 LLM 管“理解意图、生成内容”;“要不要真的执行”留给确定性代码。
  2. 让 LLM 做分类,别让它做决策。 把开放问题(该不该提交)收敛成窄分类,并把“拿不准时倒向安全边”写进 prompt。
  3. 状态用外部存储 + 确定性检查兜底。 跨轮的“待确认预览”放外部缓存,配合闸门 / 互斥 / 切换清理,比“指望 LLM 记住上下文”稳得多。
  4. 防御要看“代码有没有真的读它”。 一个字段名带个 guard 不代表它在防御——本文那个未接线的“消息序号”就是提醒:声明防御和落地防御是两回事。

💡一句话总结:Agent 不是“LLM 包一切”,而是“LLM 管模糊、代码管不可逆”的分工。越是生产级的 Agent,这道分工线越清晰。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐