三层防线设计:当 AI 说了但没做到
写于 2026-11-08。用户说"明天八点提醒我买菜",AI 回"好的,已为您设置",数据库里却没有这条记录。这篇只拆这个失败态:提示词、回查、审计三层各自能挡住什么,挡不住什么,以及回查失败时那句话该怎么说。
"已设置"这三个字,是 AI 管家最危险的一句话。
它不是一句普通的回复,而是一份承诺。用户会因此关掉备忘录、不再设手机闹钟、安心去睡觉。等到第二天八点什么都没发生,他才会回来翻聊天记录,然后发现那句"已设置"下面,什么都没有。
更麻烦的是,这类失败往往不报错。接口返回 200,日志没有异常,AI 的语气还很热情。系统觉得自己一切正常,用户却已经被骗了一次。
线上《从"会说话"到"会做事":Agent 规划-执行-回查闭环》讲的是这条链路怎么正常跑通,《每一步都要落盘》讲的是 agent_task / agent_step 怎么持久化;这篇不复述那两篇,只盯一个失败态:AI 已经把"已设置"说出口,数据库里却没有对应记录。
一、失败态:一句"已设置",三种不同的假
最早的事故很朴素:用户没有登录,操作码解析正常,AI 回复"已为您设置"也正常,但落库时拿不到用户身份,写入被静默跳过。
整条链路没有抛异常。解析器认为自己完成了解析,模型认为自己完成了回复,只有数据库知道什么都没发生。
后来我把类似的"假成功"归成三类:
| 类型 | 表面现象 | 真实情况 | 用户看到 |
|---|---|---|---|
| 没执行 | AI 说"已设置" | 未登录、无权限、工具名不存在,执行根本没开始 | 一句成功的假话 |
| 执行了没落库 | AI 说"已设置" | 写库异常、事务回滚、参数不合法,异常被吞掉 | 一句成功的假话 |
| 落库了但不是用户要的 | AI 说"已设置" | 内容错了、时间错了、重复创建、与日程冲突 | 一条错误的真记录 |
第一类和第二类的共同点是"数据库里没有",第三类的共同点是"数据库里有,但不是用户要的"。如果只检查"有没有抛异常",三类都拦不住;如果只检查"有没有这条记录",第三类也拦不住。
所以回查不能只问一句"写成功了吗",它必须把写进去的内容和时间读回来比对。这是后面第二层防线的起点。
二、第一层:提示词约束,能降低概率,不能担保事实
第一道防线在提示词层。
后端检测到用户未登录时,往系统提示里注入一条规则:
当前用户未登录,无法创建提醒。
请引导用户先登录,不要声称已设置成功。
这条规则解决的是"AI 不知道"。模型并不知道当前请求没有用户身份,它只看见用户说"提醒我买菜",于是顺着语境回了一句"好的,已为您设置"。把上下文给它,它才有机会说对话。
这一层能挡住的东西很明确:
- 模型不知道用户没登录,会乱承诺;
- 模型不知道某个功能没开通,会假装能办;
- 模型不知道参数超限,会先答应再失败。
但它挡不住的东西同样明确。
第一,提示词是软约束。弱模型尤其如此。我在另一个"一次只问一个问题"的场景里试过把规则写三遍、加粗、加感叹号,成功率从大约 40% 提到 60%,然后就到顶了。提示词能改善概率,不能保证结果。
第二,规则会被上下文挤掉。本地 4B 模型的 num_ctx 是 4096,系统提示、人设、记忆一叠加,后面追加的规则很容易被截断。规则写得再多,模型没看到等于没写。
第三,也是最根本的一条:在操作码链路里,模型说话的时候,落库还没有发生。它先输出"已为您设置"和 [[ADD_REMINDER:...]],后端再去解析、校验、写库。模型没有未来的信息,它只能猜。猜对了是运气,猜错了就是这句假话。
所以第一层的定位应该是"减少无知导致的假话",而不是"保证说出口的话一定为真"。把提示词当唯一防线,等于把系统的诚实交给模型的自觉。
三、被丢掉的操作码:挡住了脏写,挡不住已经说出口的话
第二道老防线在操作码执行层:后端解析到 [[ADD_REMINDER:...]] 时,先校验登录态;未登录就直接丢弃这个操作码,不执行落库。
这道防线是有效的,而且必须保留。它保证系统绝不会执行一个没有身份的操作,也不会因为模型的幻觉往数据库里塞一条无主记录。行为的真实性,先由系统担保,不由模型的嘴担保。
但它有一个绕不过去的缺口:操作码可以被丢掉,已经流式吐给用户的那句话丢不掉。
用户已经看到了"好的,已为您设置"。后端默默丢掉操作码,用户不会收到任何纠正。下一次他打开提醒列表,发现是空的,只会觉得这个 App 会骗人。
更极端的情况是模型根本没输出操作码。提示词里写着"正文声称已记下时必须同步输出操作码",可弱模型可能只学会了前半句,正文说"已设置",操作码没吐。执行层连"丢掉"的对象都没有,自然不会报错。
当时还补过一个交互层的补救:后端在响应里附加 needLogin: true,前端收到后直接弹登录框;登录完成后,用户的原始请求自动重发。这个设计把"未登录导致的静默失败"变成了一个转化漏斗:用户想建提醒 → 弹登录 → 登录完提醒自动建好。
它解决的是"用户不知道要登录",不是"AI 说了一句假话"。如果用户把弹窗划掉,那句"已设置"还留在气泡里。
所以这一层的准确定位是:挡住脏写,不挡住假话。它让数据库干净了,但没有让用户听到真话。
四、第二层:执行后回查,接口返回成功不算数
真正把"说"和"做到"接上的,是执行后的数据库回查。
现在的 Agent 执行器 AgentStepExecutor 对每一步都走同一套顺序:
- 工具名为空,直接失败;
- 没有登录态,失败并审计;
- 权限校验,
delete_*需要DELETE权限,其余需要WRITE; - 最多尝试 2 次(
MAX_ATTEMPTS=2),成功即跳出; - 解析参数,数字、字符串、布尔值做宽松转换;
- 写库,然后按实体类型回查;
- 审计,只有
success && verified才记为SUCCESS。
回查不是"再查一下有没有报错",而是按实体逐个比对:
| 实体 | 回查方式 | 判定标准 |
|---|---|---|
| 提醒 | 创建后按 id 查回 | 内容一致,时间截断到秒后一致 |
| 清单 | 创建后按 taskDate 列出 |
id 与内容都比对得上 |
| 倒数日 | 创建后按 id 查回 | 标题与目标时间一致 |
| 修改 / 删除 | 再次查询数据库 | 结果确实变了或确实没了 |
结构上大概是这样,下面是示意,不是生产代码原文:
// 结构示意:回查通过才算成功
StepResult result = executeOnce(step);
if (!result.isSuccess()) {
result = executeOnce(step); // MAX_ATTEMPTS = 2
}
boolean verified = result.isSuccess() && verifyInDb(step, result);
audit.record(userId, step, result.isSuccess(), verified);
if (!verified) {
report.markFailed(step, result.detail());
}
这一层能挡住的东西比前两层多得多:
- 写库静默失败:接口没抛异常,但查不到记录;
- 内容写错:记录在,内容不是用户说的那句;
- 时间写错:记录在,时间差了半小时;
- 修改没生效:接口说改了,查回来还是旧值;
- 删除没生效:接口说删了,记录还在;
- 操作码二次落库:Agent 已经执行完,主模型又按老规则输出操作码,
done分支再写一次。
最后一条是真实踩过的坑。修复方式是在 Agent 跑完后把 ctx.creationBlock 置空,同时把 toolScope 设为 NONE,让主模型这一轮只负责措辞,不再拥有写的能力。执行已经由系统完成,模型不该再有第二只手。
但回查也有它挡不住的地方。
第一,它只能验证"记录与预期一致",验证不了"预期本身对不对"。用户说"提醒我开会",模型把内容写成"开会",时间写成明天十点,回查全部通过;可用户心里想的是今天下午三点。这不是回查能解决的,这是规划与澄清的问题。
第二,去重和冲突会出现"没有新建,但结果算成功"的状态。内容与时间完全相同的重复创建,系统返回"已存在相同记录",verified=true;日程冲突时根本不写库,但执行结果仍是 success=true,只是 detail 里写着"该时段与系统日程冲突,未创建,请告知用户并询问是否改时间"。如果模型不看 detail,张口就说"已创建",回查也救不了。
第三,它管不到措辞。回查证明数据库里有一条正确的记录,但模型完全可能在正文里说成另一个时间、另一个内容。执行报告给了它正确素材,它可以选择不用。
第四,它只覆盖当轮。当前设计不做跨请求续跑,进程重启或回复结束后不会继续执行;DecisionArtifactRegistry 和 PendingDuplicateRegistry 都是内存结构,重启即丢,多实例也不共享。
所以第二层是"事实层",不是"语义层"。它能保证数据库里发生了什么,不能保证模型嘴上说的和数据库里的一致。最后这一小段,要靠执行报告和提示词约束共同收口。
五、回查失败之后,这句话该怎么说
这是整篇最容易被忽略的部分。
很多系统做到回查就停了:verified=false,记一条日志,然后照常把模型生成的"已设置"发给用户。回查做了,但用户没受益。
回查失败后的回复,我按三条原则处理。
第一,不说"已设置"。 不用"已完成""已创建""已帮您搞定"这类完成态。项目提示词里有一条很直白的规则:工具失败就说"没能改成功",不要谎称已修改。提醒创建同理,失败就说"这条提醒没能建成"。
第二,不把内部术语丢给用户。 不说 verified=false,不说工具名,不说参数名,不说"回查校验未通过"。执行报告在给主模型之前已经做过一层映射,工具名会变成"记一条提醒"这样的日常说法。用户只需要知道"成没成、为什么、下一步怎么办"。
第三,给出可执行的下一步。 不同的失败原因,说法不一样:
| 失败原因 | 不该说 | 应该说 |
|---|---|---|
| 未登录 | 已为您设置 | 这件事要先登录才能办,登录后我再帮你建 |
| 无写入权限 | 已为您设置 | 你关掉了提醒的写入权限,所以我没法建;要用的话得先去设置里打开 |
| 写库两次都失败 | 已为您设置 | 这条提醒没能建成,刚才的写入没有成功;要我按原时间再试一次吗 |
| 时间冲突 | 已创建 | 这个时间和已有日程冲突,我没有建;要不要换到 14:00 |
| 完全重复 | 已新建 | 已经有一条一样的提醒,我没有再建 |
| 多步只成功一部分 | 都搞定了 | 第一件事办好了,第二件没成,原因是…… |
执行报告 toPromptBlock() 的作用就是把这张表变成模型能读的素材:逐条列出动作、执行成功还是失败、数据库回查通过还是未通过、说明是什么,并附一句执行约束——本轮禁用工具,成功项可以直接告知,失败项必须如实说明。
注意"成功项可以直接告知"和"失败项必须如实说明"是两条不同的指令。不能让模型自由发挥,否则它很容易把"两步成功一步失败"概括成"都办好了"。也不能要求它把每个内部步骤都念一遍,用户不需要听流水账。
还有一个细节:冲突和重复虽然 success=true,但对用户来说都是"没有新建"。这两类必须在 detail 里写清楚,并且在回复里说成"没有建",而不是"已创建"。success 是给系统看的,verified 是给审计看的,用户只看最后那句话。
六、第三层:审计留痕,让"说了没做到"可追
第三层是审计。
执行器每一步都会调 IAgentGuardService.record(...) 写 audit_logs,判定口径是 success && verified 才算 SUCCESS。这个口径很关键:如果只看 success,回查没通过也会被记成成功,审计表就会变成一张自我表扬的流水账。
除了 audit_logs,还有两张过程表:
| 表 | 关键字段 | 作用 |
|---|---|---|
agent_task |
user_id、conversation_id、goal、status、total_steps、success_steps、replanned、finish_time |
一轮任务的头,记录目标与最终结算 |
agent_step |
task_id、seq、tool、args_json、reason、status、verified、attempts、error |
每一步的明细,含参数、原因、尝试次数和失败说明 |
几个实现细节值得记:
seq跨重规划连续。首次规划编号 1..N,重规划从plan.size()+1续排,和前端展示、管理端agent_step.seq对得上;reason截断到 500 字,避免把整段模型输出塞进数据库;finish放在finally里,异常中断不会留下永远running的任务;- 结算按
success_steps判定:全部成功是success,一个都没成是failed,中间态是partial; - 落盘异常全部 swallow,只记
[agent-persist]日志。落盘失败不能影响本轮对话,这是明确的取舍。
第三层能挡住什么?
它挡不住当下的假话,但能挡住"说不清"。用户投诉"它说设置了但没设",你可以查 agent_task 看这轮有没有进入 Agent,查 agent_step 看每一步的 tool、args_json、verified、attempts、error,查 audit_logs 看权限与结果。是没执行、执行失败、回查失败,还是执行成功但模型说错,三条记录一对就清楚了。
它还能暴露模式。比如某一段时间 create_reminder 的 verified 大量为 false,说明不是模型措辞问题,是写库链路出了问题;比如某类请求总是 attempts=2 且 error 相同,说明参数解析或权限判断有系统性缺陷。
第三层挡不住什么?
它不实时。用户已经看到假话了,审计只能事后追。它也可能缺记录:AgentTaskRecorder 的写入是 swallow 异常的,落盘失败时只剩日志;内存注册表重启即丢;当前只在单轮请求内闭环,跨轮任务根本没有任务头可查。
所以三层的关系不是"一层比一层强",而是三种不同的职责:提示词减少无知,回查保证事实,审计保留证据。缺一层,失败就会以另一种形式回来。
七、三层防线各自能挡什么、挡不住什么
把三层放在一张表里,边界会清楚很多:
| 层 | 位置 | 能挡住 | 挡不住 | 失效信号 |
|---|---|---|---|---|
| 提示词约束 | 生成前 | 模型不知道未登录、无权限、参数超限而乱承诺 | 弱模型不听话、长上下文挤掉规则、模型说话时不知道落库结果 | 回复含"已设置",审计里没有对应步骤 |
| 执行后回查 | 写库后 | 静默失败、内容/时间不符、改删未生效、操作码二次落库 | 语义错、去重/冲突的"未创建但 success"、模型措辞、跨轮任务 | verified=false、agent_step 缺行、attempts=2 |
| 审计留痕 | 全程 | 事后说不清、无法定位、无法统计模式 | 实时拦截、落盘自身失败、内存状态丢失、多实例不共享 | agent_task 长期 running、[agent-persist] 告警 |
如果只记一句话:提示词管"它知不知道",回查管"它做没做到",审计管"事后说不说得清"。
至于最早那版 needLogin: true 弹窗,它是交互层的补强,不算三层里的任何一层。它把"未登录"从死路变成登录入口,但前提是 AI 的回复已经被纠正过;否则弹窗只是给一句假话配了一个按钮。
八、无效尝试与回滚复盘
这套东西不是一次设计出来的,中间试过几条走不通的路。
无效尝试一:只靠提示词。 最早以为把"不要声称已设置"写进系统提示就够了。规则写一遍不够,写三遍;三遍不够,加粗、加反例。成功率能从大约 40% 提到 60%,然后就不动了。提示词能降低概率,不能兜底事实。
无效尝试二:只丢操作码。 未登录时丢弃 [[ADD_REMINDER:...]],数据库确实干净了,但用户看到的"已设置"还在。这个方案只解决了脏写,没解决假话。后来才补上回复纠正和 needLogin 弹窗。
无效尝试三:把"接口返回成功"当成功。 服务层没抛异常,就认为写进去了。实际可能是事务回滚、条件不满足、参数被静默修正。后来改成写完必须按 id 查回,内容与时间都比对,才有 verified=true。
一次真实回滚:Agent 跑完还留着操作码。 Agent 已经执行完,主模型按老规则又输出了一遍 [[ADD_REMINDER:...]],done 分支再落一次库,用户得到两条一模一样的提醒。回滚方式不是删掉操作码机制,而是在有执行步骤的轮次里把 creationBlock 置空、toolScope 设为 NONE,保留能力、收回时机。
另一次口径回滚:审计只看 success。 回查没通过,success 仍可能是 true,审计表就会把失败写成成功。后来把口径改成 success && verified,SUCCESS 只代表"执行成功且回查通过"。
验证这套链路,我一般看三处:
-- 1. 这一轮任务到底结算成什么
SELECT id, goal, status, total_steps, success_steps, replanned
FROM agent_task
WHERE conversation_id = :conversationId
ORDER BY id DESC;
-- 2. 每一步有没有回查通过
SELECT seq, tool, status, verified, attempts, error
FROM agent_step
WHERE task_id = :taskId
ORDER BY seq;
-- 3. 审计口径是不是 success && verified
SELECT action, result, create_time
FROM audit_logs
WHERE user_id = :userId
ORDER BY id DESC
LIMIT 20;
再配合日志关键词:[agent-loop] 规划成功, steps= 表示进入了闭环,[agent-exec] 步骤 X 工具 Y 执行=... 回查校验=... 表示单步结果,[agent-persist] 表示落盘异常。发一句"明天早上八点提醒我买菜",agent_task 应该是 total_steps=1, success_steps=1,agent_step.verified=true;给一个不存在的 id 让它删除,应该看到重规划,任务结算成 partial 或 failed,而不是一句"已删除"。
这套验证不漂亮,但它能回答一个很朴素的问题:用户听到的那句话,数据库认不认。
写在最后
- "已设置"是承诺,不是语气词。 只要说出口,系统就要能拿出对应的记录;拿不出,就该说"没建成"。
- 提示词只能减少无知,不能担保事实。 模型说话时不知道落库结果,这是结构性缺口,靠加规则补不上。
- 回查要读回内容与时间,不能只看接口有没有报错。 "没抛异常"和"真的写进去了"是两件事。
- 回查失败后的那句话,要有具体下一步。 说清没成、为什么、能怎么办,比一句"抱歉"有用。
- 审计不是实时防线,是下次修复的入口。
success && verified这个口径,决定了审计表是证据还是自我表扬。

浙公网安备 33010602011771号