返回技术文章

别让 Agent 直接动手:我用"草稿-确认"双阶段管住高风险操作

一次越权事故的完整复盘

约 5 分钟读完 LLM 应用
别让 Agent 直接动手:我用"草稿-确认"双阶段管住高风险操作 封面

事故复盘:一次"贴心"的越权

Gym-Agent 内测第二周的周二晚上,测试群里炸了。一个同学的原始诉求是"帮我看看明天下午有没有羽毛球场"——注意,是看看。Agent 表现得很勤快:调用 queryVenues 查到了空场,然后顺手调用 createBooking,预约成功,还贴心地回复了一句"已为您预约明天 15:00-16:00 的羽毛球场,请准时到场"。

用户懵了,我也懵了。因为系统提示词里白纸黑字写着:"涉及预约操作时,必须先与用户确认,不得直接执行"。

翻工具调用日志,链路清清楚楚:queryVenues → createBooking → bookingResult,中间没有任何一次面向用户的停顿。模型"理解"了用户意图(想打球)之后,把"帮用户达成目的"优化到了极致——它觉得帮用户订好才是完成任务的标志。

这件事给我上的第一课是:提示词是概率性的约束,不是安全边界。我们当时 temperature 已经压到 0.1 了,绝大多数对话里模型确实会先问一句"需要我帮您预约吗",但概率性的东西就有漏网的概率,而副作用操作漏一次就够喝一壶。长对话里更明显:聊到十几轮之后,模型的注意力在"任务完成度"上,对"安全规则"的服从性肉眼可见地下滑。

错误的修复:把工具下架

第一反应很直男:既然模型管不住手,那就把它绑起来。v2 方案把 createBooking 和 cancelBooking 从 .tools(...) 列表里移除,Agent 只能查、不能约。

结果更糟。用户说"帮我订周五的场",Agent 一板一眼地回答"抱歉,我无法执行预约操作,请您手动预约"。用户:那我要你干嘛?

这个版本活了不到一天就回滚了。它暴露了另一个本质问题:Agent 的价值恰恰在于它能替你动手,把动手能力没收,等于把 Agent 退化为聊天机器人。我们要的不是"不能动手",是"动手之前过一道闸门"。

正确的修复:草稿-确认双阶段

v3 方案的核心思想一句话能说清:Agent 只能生成"意图",执行"意图"的钥匙握在用户手里。

具体拆成三个设计决策:

决策一:副作用操作只产生草稿。 createBooking 改名为 createBookingDraft,它不再写预约表,只往 Redis 里塞一条带 30 分钟过期时间的草稿记录——场馆、时段、价格,一个完整的"要约"。

决策二:执行工具单独暴露,且描述极其保守。

@Tool("确认并提交预约草稿。仅当用户看到草稿内容后明确表示同意(如回复\"确认\")时才允许调用,禁止在同一次回复中自行调用")
public BookingResult confirmPendingBooking(
        @P("待确认的预约草稿ID") String draftId) { ... }

注意工具描述里那句"禁止在同一次回复中自行调用"。这是把一部分安全规则从系统提示词下沉到了工具描述里——实践下来,工具级描述对模型行为的约束力比全局提示词强,猜测是因为它在模型决策"调哪个工具"的那一刻就在上下文里,注意力权重高。

决策三:确认的合法性在代码层校验,不在提示词层。 confirmPendingBooking 的 executor 里做三重检查:草稿存在且未过期、草稿归属人等于当前登录用户、当前用户确实在同一会话里收到过草稿摘要。任何一条不过,直接拒绝,模型怎么恳求都没用。

public ToolResult execute(ToolContext ctx, Map<String, Object> params) {
    BookingDraft draft = draftStore.get(params.get("draftId"));
    if (draft == null || draft.expired()) {
        return ToolResult.error("草稿不存在或已过期,请重新生成");
    }
    if (!draft.getUserId().equals(ctx.currentUserId())) {
        return ToolResult.error("无权确认他人的预约草稿");  // 也顺手挡了越权
    }
    return bookingService.submit(draft);
}

这三个决策落地之后,事故里那种"一气呵成"的调用链就断了:模型生成草稿后必须停下来等用户说话,下一轮才可能拿到"确认"信号。

两个没想到的延伸问题

取消预约也是高风险操作。 我一开始的直觉是:取消 = 撤销 = 安全。直到看到一条测试对话——用户说"明天那个场地的事算了",模型把"那个"指代成了另一个人(聊天上下文里提到过朋友约球)的预约,生成了取消草稿。如果当时直接执行,取消的是别人的约。从此 GymCancelBookingTools 也走完整的草稿-确认流程,riskLevel 标记为 IRREVERSIBLE。副作用操作没有"安全的",只有"还没出过事的"。

"确认"这个词比想象中模糊。 内测里收集到的真实确认语料:确认、可以、行吧、就这样、嗯、第二个吧、不对,改到周五再确认。最后两个最麻烦:

  • "第二个吧"——用户连续生成过多个草稿时,模型必须能按序号定位到正确的那个。所以草稿在对话里必须显式编号,并且 createBookingDraft 的返回值里带序号,让模型有据可依。
  • "改到周五再确认"——这是一个"修改草稿 + 暂缓确认"的复合意图,模型偶尔会把它执行成"直接确认周五的新草稿"。目前的缓解办法是在草稿工具的返回模板里追加一句固定的提示文案:"如需修改时间或场地,请直接告诉我,我会生成新的草稿"。不完美,但显著降低了误确认率。

Job-Agent:从"工具级确认"到"行动确认中心"

后来做 Job-Agent 时,这套模式被推到了更彻底的形态。求职场景里的高风险动作比订场地严重得多:给 HR 发消息、批量投递简历、改简历内容——这些动作做错了不是"取消预约"能补救的,是真实的社交成本。

所以 Job-Agent 没有在每个工具里各自实现确认,而是做了一个统一的行动确认中心:所有 needConfirm = true 的工具产生的"建议",一律进入一张待确认表;用户在确认中心逐条审批(批准 / 修改后批准 / 拒绝),审批通过的动作由工作流引擎异步执行,执行结果走邮件和站内信通知。Agent 在整个链路里的角色被严格限定为"建议生成者"。

这个演进背后是一个认知升级:**单工具的二次确认解决的是"这一次操作"的安全,行动确认中心解决的是"这个用户把多少自主权交给 AI"的治理。**前者是技术设计,后者是产品设计。

写在最后

这一篇没有讲任何高深的技术,但可能是这个系列里我最想让人看到的一篇。总结成三句话:

  1. 提示词约束是概率,代码校验是确定性,安全边界必须放在确定性的一侧。
  2. Agent 的产品体验可以激进,执行边界必须保守——宁可让它多问一句,不能让它错做一步。
  3. 用户对 Agent 的信任是一次性的:它正确执行一百次,越权执行一次,信任就清零了。

下一篇聊一个更"软"但同样要命的话题:Agent 的记忆。