为什么 AI Agent 的“提交”决策不能交给 LLM——生产级 HITL 双阶段拦截与四道防线
🎯一句话论点:在会产生不可逆后果的 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 各管一段:
🎨配色(下文各图统一):🟩 绿 = 确定性代码(编排 / 拦截 / 闸门)|🟨 黄 = LLM / Agent / 工具|🟪 紫 = 状态存储|⬜ 灰 = 外部后端|🟦 蓝 = 入口
一个需要说明的实现细节:主推理是 LangGraph create_react_agent;判断“是不是确认”用的是同一套 LLM 配置、只是把输出 token 上限压到很小的“轻量调用”——不是另一个更便宜的模型,只是限了输出长度。
三、朴素做法为什么不行
最直觉的做法:给 LLM 一个“提交”工具,让它自己判断商家什么时候“确认了”,然后调用。三个问题:
- 语义模糊。 “好 / ok / 可以 / 就这样”在不同上下文意思完全相反。同一句“确认”,跟在“要不要改 X”后面是“改”,跟在“这是预览,确认吗”后面才是“提交”。
- 代价不对称。 误提交远比漏提交严重(凭空在后端创建一个线上活动)。但 LLM 的损失函数里没有这个不对称——它只是“尽量猜对”。
- 不可复现。 同一句话下次可能猜另一个结果,出事难复现、难归因。
一句话:把一个“错一次就出事故”的决策,交给一个概率性黑盒,不是好主意。
四、核心设计:把“提交”从 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 一律锁住累积。这把“代价不对称”从一句口号变成了代码里的硬偏置。
确认拦截的完整判定:
六、不止拦截:四道确实在跑的防线
光有拦截还不够。生产里还有几种“旧状态被误提交”的场景,所以叠了多道防御。这里只列代码里确实在执行的:
防线 ① 确认拦截 + 4 分类(上面讲的)——防“模糊回复被当提交”。唯一用到 LLM 的一道,且是“受限的 LLM”。
防线 ② closure gate(收口闸门)+ 累积锁。 两道都是确定性闸,且在任何写 / 状态变更之前就 return。预览已存在后,商家每改一句不立刻重生成,先累积:只要“还没明确收口”或“这轮被判成改动 / 闲聊”,预览生成就被直接挡回,既不出新预览、更不碰后端,直到商家明说“就这样”。
防线 ③ 提交三重条件 + 名称校验。 即使 LLM 越权打开那个提交开关,也必须“暂存存在 + 名称与暂存一致”才提交,否则降级当预览。合法触发提交的唯一入口是确定性的确认拦截。
防线 ④ 切换冷启动 guard。 从别的场景切回来时清掉残留的待确认预览,否则商家随口一句“重启动词”可能被误判,把上个场景遗留的旧预览提交上线。
外加一条状态完整性约束:“创建”与“修改”两套待确认内容互斥——写任一新预览时清掉另一个,否则确认拦截不知道该提交哪条(拦截里修改优先、其次创建)。
会话状态机(状态存外部缓存,跨多轮记住“上一轮预览了什么”):
七、两个“防错”细节:同一种确定性哲学
把“容易出错的地方”收敛到确定性代码的单点,而不是散落各处、更不是交给 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
如果用普通的 = 默认值,就分不清“没说”和“说了不要”。
八、完整的一轮
补一个真实细节:“创建”的提交永远只存草稿态,AI 不直接把活动设为上线;要上线得走“修改 / 启用”路径,等于又过一次“预览 → 确认”。多一层不可逆,就多一道确认。
九、可迁移的设计原则
- 不可逆动作要有一道不过 LLM 的闸门。 LLM 管“理解意图、生成内容”;“要不要真的执行”留给确定性代码。
- 让 LLM 做分类,别让它做决策。 把开放问题(该不该提交)收敛成窄分类,并把“拿不准时倒向安全边”写进 prompt。
- 状态用外部存储 + 确定性检查兜底。 跨轮的“待确认预览”放外部缓存,配合闸门 / 互斥 / 切换清理,比“指望 LLM 记住上下文”稳得多。
- 防御要看“代码有没有真的读它”。 一个字段名带个 guard 不代表它在防御——本文那个未接线的“消息序号”就是提醒:声明防御和落地防御是两回事。
💡一句话总结:Agent 不是“LLM 包一切”,而是“LLM 管模糊、代码管不可逆”的分工。越是生产级的 Agent,这道分工线越清晰。
更多推荐


所有评论(0)