AI Agent 真正写进数据库后,我才发现:最危险的不是幻觉,而是重复执行
AI Agent 真正写进数据库后,我才发现:最危险的不是幻觉,而是重复执行
让大模型查询数据并不难。
用户问一句:
帮我找一下最近没完成的待办。
模型调用查询工具,服务端返回几条记录,再由模型组织成自然语言。即使回答不够准确,最坏的结果通常也只是“答错了”。
但当需求变成:
帮我创建一条今晚 9 点提交周报的高优先级待办。
问题就完全不同了。
此时 Agent 不再只是回答问题,而是在修改真实业务数据。
最开始我以为最大的风险是模型把时间理解错、标题写错,后来真正把 Agent 接进项目后才发现,最危险的问题其实是:
同一个写操作,在用户只确认一次的情况下,被执行了两次。
它可能来自用户连续点击、网络超时重试、前端刷新、服务端响应丢失,也可能来自“操作已经成功,但模型为了生成最终回复又重新跑了一轮工具”。
如果只是多返回一句话,问题不大。
如果是多创建一条笔记、多扣一次额度、多上传一份文件,甚至多删除一次资源,后果就完全不同了。
这篇文章复盘一套 Vue 3 + Node.js + Redis 项目中的 Agent 写操作链路,重点讨论五个问题:
- 为什么确认弹窗并不等于安全确认
- 为什么模型输出的 Tool Call 不能直接执行
- 如何原子消费确认令牌,阻止重复写入
- 操作成功后,为什么不能重新开放写工具生成回复
- 如何让“已成功”这句话必须有服务端回执支撑

一、最容易实现,也最危险的方案
很多 Function Calling 示例采用下面这条链路:
用户消息
↓
模型输出 Tool Call
↓
后端执行工具
↓
把执行结果发回模型
↓
模型生成最终回答
伪代码大概是:
const response = await requestModel({
messages,
tools,
});
if (response.toolCalls?.length) {
const results = [];
for (const call of response.toolCalls) {
results.push(await executeTool(call.name, call.arguments));
}
return requestModel({
messages: [
...messages,
response,
...results.map(toToolMessage),
],
tools,
});
}
对于只读工具,这套流程通常够用。
但写工具至少有四个明显问题。
1. 模型同时拥有“提出操作”和“批准操作”的能力
模型决定调用什么工具,又直接触发真实写入,相当于把意图理解和最终授权放在了同一个不稳定组件里。
即使 Prompt 中写了:
执行危险操作前必须征得用户同意。
它也只是模型的一条文本指令,不是服务端强制规则。
Prompt 可以帮助模型表现得更好,但不能代替权限控制。
2. 前端弹窗可能只是视觉效果
有些实现会先让模型返回:
{
"tool": "create_todo",
"args": {
"title": "提交周报",
"dueAt": "今晚 9 点"
}
}
前端拿它渲染一个确认框,用户点击确认后,再把同一份参数传回后端执行。
问题在于:后端怎么知道这份参数真的是刚才展示给用户的那一份?
攻击者完全可以绕过页面,直接请求执行接口:
{
"tool": "delete_note",
"args": {
"id": "another-resource-id"
}
}
所以确认卡不能只是 UI 状态,它必须对应一份由服务端保存、不可被客户端替换的执行草稿。
3. 用户点击一次,请求可能到达多次
前端禁用按钮只能减少普通双击,无法覆盖:
- 浏览器或 App 自动重试
- 代理层重试
- 请求发送成功但响应丢失
- 多个标签页同时点击
- 前端超时后再次发起
- 用户刷新后恢复操作
只要服务端没有原子认领机制,两次请求都可能认为自己有权执行。
4. 操作成功后,模型可能再次调用工具
假设数据库写入已经成功,接下来需要模型生成一句自然回复:
已为你创建“提交周报”,截止今晚 21:00。
如果这一步重新进入完整 Agent 循环,并再次携带写工具,模型有可能看到原始用户问题后,再调用一次 create_todo。
于是,一次确认产生两条待办。
这不是传统意义上的模型幻觉,而是执行链路没有把“写阶段”和“回复阶段”隔离。
二、先定义五条不可破坏的不变量
在写代码前,我先把系统必须保证的结果写成了五条不变量。
不变量 1:模型只能提出写操作,不能批准写操作
模型可以生成:
建议调用 create_todo
但能否进入执行阶段,只能由服务端策略和用户确认共同决定。
不变量 2:用户确认的必须是服务端准备后的确定参数
模型可以提供原始参数,但服务端需要完成:
- 别名归一化
- Schema 校验
- 时间格式解析
- 资源归属检查
- 业务状态检查
- 风险等级判断
- 确认预览生成
用户看到的确认卡,必须来自这份服务端准备结果。
不变量 3:同一张确认卡最多只有一个执行者
两个请求同时确认时,只能有一个请求获得执行权。
其余请求只能看到:
正在执行
或:
已经执行完成,这是原结果
不变量 4:工具执行必须进入真实业务 Service
Agent 工具不应该自己拼接一套简化版 SQL。
页面接口、Agent、后台任务应该尽量复用同一业务 Service,让以下规则只有一个实现:
- 权限
- 资源归属
- 事务
- 版本冲突
- 唯一性
- 额度
- 审计
- 副作用
不变量 5:没有可信回执,就不能声称操作成功
模型不能因为“自己刚刚调用过工具”,就输出:
已经创建成功。
只有服务端真正产生成功回执后,最终回复才能使用“已完成”“已创建”“已删除”等确定性表述。
三、最终链路:Planner 不直接碰数据库
完整流程可以拆成六个阶段:
用户请求
↓
1. 语义规划 Planner
↓
2. 工具策略 Tool Policy
↓
3. 参数准备 + 确认预览
↓
4. 用户确认 + 原子认领
↓
5. 业务 Service + 事务 + 成功回执
↓
6. 回执续答 Final Reply
更详细一点:
User
│
▼
Planner
│ 只提出 toolName + rawArgs
▼
Capability Registry
│ 判断能力是否 enabled / planned / forbidden
▼
Tool Policy
│ 权限、Schema、角色、确认策略
▼
prepareArgs()
│ 服务端归一化真实参数
▼
preview()
│ 生成用户真正看到的影响说明
▼
Redis Confirmation
│ 绑定用户、会话、工具和参数哈希
▼
User Confirm
│
▼
Atomic Claim
│ ready → running,只允许一个执行者
▼
Domain Service
│ 事务写库
▼
Receipt
│ running → settled
▼
Continuation
│ 根据可信结果生成最终回答,不重跑写工具

四、第一道门:能力注册表,而不是让模型随便选函数
当工具逐渐增多后,仅靠一个 tools 数组很难回答下面的问题:
- 这个功能已经可执行,还是只在规划中?
- 它是创建、修改还是删除?
- 风险等级是什么?
- 每次都必须确认,还是低风险操作按默认策略确认?
- 某个模型误报了一个未支持能力时,系统应该怎么回复?
我把写能力单独放进能力注册表,状态分为三类:
const CAPABILITIES = [
{
id: 'todo.create',
status: 'enabled',
toolName: 'create_todo',
riskLevel: 'low',
confirmationPolicy: 'default',
},
{
id: 'todo.status.set',
status: 'enabled',
toolName: 'set_todo_status',
riskLevel: 'low',
confirmationPolicy: 'always',
},
{
id: 'note.delete',
status: 'planned',
guidance: '当前请前往笔记页面手动删除。',
},
{
id: 'account.delete',
status: 'forbidden',
guidance: 'Agent 不允许执行账号注销。',
},
];
这三个状态非常重要。
enabled
服务端已经存在正式工具,可以进入确认协议。
planned
产品上可能支持,但当前没有执行工具。
命中后应确定性告诉用户“尚未执行”,而不是让模型自由发挥。
forbidden
无论模型如何请求,都不能由 Agent 执行。
这让系统不再依赖模型“记住哪些事情不能做”。
注册工具时还要反向校验:
function registerTool(tool) {
const capability = getCapabilityByToolName(tool.name);
if (tool.isWrite && !capability) {
throw new Error('写工具必须在能力注册表中声明');
}
if (tool.riskLevel !== capability.riskLevel) {
throw new Error('工具风险等级与注册表不一致');
}
if (tool.confirmationPolicy !== capability.confirmationPolicy) {
throw new Error('工具确认策略与注册表不一致');
}
if (tool.isWrite && tool.confirmationPolicy === 'none') {
throw new Error('写工具禁止关闭确认');
}
}
这样新增工具时,如果开发者忘了配置风险和确认策略,应用会在启动或测试阶段直接失败,而不是默默暴露一个无确认写入口。
五、第二道门:统一 Tool Policy
模型给出的参数不能直接交给工具执行。
所有入口——普通 Tool Call、快捷操作、确认执行——都应该经过同一个策略函数。
async function enforceToolPolicy({
registry,
toolName,
args,
context,
phase,
confirmed,
}) {
const tool = registry.get(toolName);
if (!tool) {
throw new ToolError('TOOL_NOT_FOUND');
}
assertActorSubjectBoundary(context);
assertRoleAllowed(tool, context.userRole);
validateAgainstClosedSchema(tool.parameters, args);
if (tool.isWrite && phase === 'execute' && !confirmed) {
throw new ToolError('TOOL_CONFIRMATION_REQUIRED');
}
const normalizedArgs = normalizeArguments(tool, args);
const preparedArgs = await tool.prepareArgs(
normalizedArgs,
context,
);
return {
tool,
preparedArgs,
requiresConfirmation: tool.isWrite,
riskLevel: tool.riskLevel,
};
}
这里至少做四件事。
1. 使用闭合 Schema
Function Calling 的参数定义最好关闭额外字段:
{
"type": "object",
"additionalProperties": false,
"properties": {
"title": {
"type": "string",
"maxLength": 200
},
"priority": {
"type": "integer",
"enum": [0, 1, 2]
}
},
"required": ["title"]
}
否则模型或客户端可能附带:
{
"title": "提交周报",
"userId": "another-user",
"skipConfirmation": true
}
即使工具暂时没有读取这些字段,也不应该把未知输入带进后续流程。
2. 分离操作者和资源主体
后台代管、管理员查看用户数据时,经常存在两个身份:
actor:真正发起请求的管理员
subject:当前被查看或维护的用户
普通会话中,这两个身份不应该无故不同。
管理员只读模式也不能准备或执行写操作。
所以权限判断不能只看一个 userId,而要明确校验:
{
billingUserId,
resourceUserId,
adminContext,
adminMode,
}
3. 执行阶段强制要求 confirmed
即使调用方绕过前端,直接请求工具执行:
phase: 'execute',
confirmed: false,
服务端也必须返回:
TOOL_CONFIRMATION_REQUIRED
确认不是前端约定,而是后端状态机的硬门槛。
4. 所有别名先归一化
模型可能输出:
{
"taskTitle": "提交周报",
"deadline": "2026-08-18 21:00:00",
"importance": 2
}
真正进入工具内部前,应统一转换为:
{
"title": "提交周报",
"dueAt": "2026-08-18 21:00:00",
"priority": 2
}
这样确认卡、执行函数和日志都使用同一种规范格式。
六、为什么确认卡必须来自 prepareArgs
以创建待办为例,模型可能传来:
{
"title": "提交周报",
"dueAt": "今天晚上九点",
"priority": 2
}
服务端不能把这段自然语言直接存进确认令牌。
它需要先进行业务准备:
async function prepareCreateTodoArgs(input) {
const title = normalizeTitle(input.title);
const priority = normalizePriority(input.priority);
const dueAt = parseConcreteLocalTime(input.dueAt);
if (!title) {
throw new ToolError('TODO_TITLE_REQUIRED');
}
if (dueAt.invalid) {
throw new ToolError('TODO_DUE_AT_INVALID');
}
if (dueAt.moreThanTenYearsLater) {
throw new ToolError('TODO_DUE_AT_SUSPICIOUS');
}
if (dueAt.moreThanOneYearAgo) {
throw new ToolError('TODO_DUE_AT_SUSPICIOUS');
}
return {
title,
priority,
dueAt: dueAt.value,
overdue: dueAt.isPast,
};
}
然后,确认卡由这份准备结果生成:
function buildPreview(args) {
return {
title: '创建待办',
target: args.title,
impact: args.dueAt
? `确认后将创建一条截止于 ${args.dueAt} 的待办。`
: '确认后将创建一条没有截止时间的待办。',
details: [
{
key: 'dueAt',
value: args.dueAt || '未设置',
},
{
key: 'priority',
value: priorityLabel(args.priority),
},
],
};
}
这里有一个关键区别:
用户确认的不是模型原始输出,而是服务端已经解析完成、准备实际执行的参数。
如果模型把“今晚”算成了错误日期,确认卡会把具体时间展示出来,用户有机会在执行前发现。
如果服务端根本无法解析,则不应该生成确认卡。
七、确认令牌到底要绑定什么
用户点击确认时,前端不应重新提交完整工具参数。
更安全的方式是只提交一个高熵令牌:
{
"confirmationToken": "a-long-random-token"
}
服务端在 Redis 中保存对应确认记录:
{
id: "confirmation-id",
ownerHash: "hash-of-owner",
sessionId: "session-id",
toolName: "create_todo",
capabilityId: "todo.create",
args: {
title: "提交周报",
dueAt: "2026-08-18 21:00:00",
priority: 2
},
argsHash: "sha256-of-args",
riskLevel: "low",
preview: {
title: "创建待办",
target: "提交周报"
},
resourceUserId: "user-id",
adminContextId: null,
adminMode: null,
originRequestId: "request-id",
createdAt: "...",
expiresAt: "..."
}
至少需要绑定以下信息:
| 字段 | 作用 |
|---|---|
ownerHash |
防止确认令牌被其他用户使用 |
sessionId |
防止令牌跨会话使用 |
toolName |
固定操作类型 |
capabilityId |
固定产品能力语义 |
argsHash |
防止保存参数被篡改 |
resourceUserId |
固定资源主体 |
adminContextId |
固定管理员代管上下文 |
riskLevel |
保留确认时的风险信息 |
originRequestId |
串联发卡、处置和结果 |
expiresAt |
限制确认有效期 |
Redis Key 最好不要直接使用原始令牌,而使用其摘要:
const key = `agent:confirm:${sha256(token)}`;
这样即使 Redis Key 被监控系统或运维工具记录,也不会直接暴露可提交的确认令牌。
八、最关键的一步:原子认领,而不是“先 GET 再 DELETE”
一种错误实现是:
const confirmation = await redis.get(key);
if (!confirmation) {
throw new Error('已失效');
}
await redis.del(key);
await executeTool(confirmation);
两个请求并发到达时,可能发生:
请求 A:GET,读到确认记录
请求 B:GET,也读到确认记录
请求 A:DEL
请求 B:DEL
请求 A:执行
请求 B:执行
删除动作本身是原子的,但“读取后判断再删除”不是一个原子操作。
更稳妥的方式是使用 Lua 脚本完成状态转换:
local confirmation = redis.call('GET', KEYS[1])
if confirmation ~= ARGV[1] then
if redis.call('EXISTS', KEYS[2]) == 1 then
return 2
end
return 0
end
if redis.call('EXISTS', KEYS[2]) == 1 then
return 2
end
redis.call('DEL', KEYS[1])
redis.call(
'SETEX',
KEYS[2],
tonumber(ARGV[3]),
ARGV[2]
)
return 1
它将确认状态从:
ready
原子转换成:
running
返回值可以表达:
1:当前请求成功认领执行权
2:已有请求正在执行或已经生成执行记录
0:令牌不存在、状态不匹配或已经失效
因此,同一张确认卡的并发请求中,只有一个请求能够真正调用业务 Service。
其他请求不会再次执行,而是去查询运行中或已完成结果。
九、为什么执行结果也要继续保留
确认令牌被消费后,不能立即让所有状态消失。
否则会出现:
请求 A 成功认领并开始执行
请求 B 同时到达
请求 B 发现确认令牌不存在
请求 B 返回“确认已过期”
对用户来说,这很奇怪:他明明刚刚点击确认,却看到“已过期”。
因此,原确认记录被消费后,需要建立一条短期执行记录:
{
state: 'running',
binding: {
confirmationId,
ownerHash,
sessionId,
toolName,
argsHash
}
}
执行结束后再更新为:
{
state: 'settled',
binding: {
confirmationId,
ownerHash,
sessionId,
toolName,
argsHash
},
outcome: {
ok: true,
receipt: {
actionId: '...',
toolName: 'create_todo',
status: 'succeeded',
summary: '待办已创建'
}
}
}
于是重复请求可以得到三种确定结果:
ready:可以尝试认领
running:已有请求正在处理
settled:返回原成功或失败结果
这比简单的“一次性 Token”更完整。
一次性 Token 只能阻止第二次执行,却无法告诉第二个请求第一次到底发生了什么。
十、工具只负责适配,真实写入交给业务 Service
确认通过后,也不能让模型生成 SQL,或者让每个工具自己实现一套业务逻辑。
推荐的工具结构是:
export default {
name: 'create_todo',
isWrite: true,
riskLevel: 'low',
confirmationPolicy: 'default',
normalizeArgs(input) {
return normalizeCreateTodoArgs(input);
},
async prepareArgs(input, context) {
return prepareCreateTodoArgs(input, context);
},
preview(args) {
return buildCreateTodoPreview(args);
},
async execute(args, context) {
return withTransaction((connection) =>
createTodo(connection, context.userId, {
title: args.title,
description: args.description,
priority: args.priority,
dueAt: args.dueAt || null,
}),
);
},
transform(result) {
return `待办「${result.title}」已创建。`;
},
};
其中真正的创建逻辑位于:
createTodo(connection, userId, data)
而不是藏在 Agent 工具里。
这样页面直接创建待办和 Agent 创建待办,可以共享:
- 字段约束
- 默认值
- 用户归属
- 数据库事务
- 成长事件
- 审计
- 后续业务副作用
Agent 工具只是一个受控适配层。
十一、事务失败不等于“肯定没有提交”
这里还有一个很容易忽略的问题。
看下面的事务代码:
await connection.beginTransaction();
const result = await createTodo(connection, userId, data);
await connection.commit();
return result;
如果 commit() 抛出网络错误,程序通常会进入 catch,随后尝试 rollback()。
但此时不能简单断言:
事务没有成功。
数据库可能已经完成提交,只是客户端没有收到确认。
这就是典型的:
commit outcome unknown
如果此时系统直接重跑创建逻辑,可能产生重复数据。
可以在“已经尝试提交”后发生异常时,显式标记结果未知:
async function withTransaction(callback) {
let connection;
let transactionStarted = false;
let commitAttempted = false;
try {
connection = await pool.getConnection();
await connection.beginTransaction();
transactionStarted = true;
const result = await callback(connection);
commitAttempted = true;
await connection.commit();
return result;
} catch (error) {
if (transactionStarted) {
await connection.rollback().catch(() => {});
}
if (commitAttempted) {
error.commitOutcomeUnknown = true;
}
throw error;
} finally {
connection?.release();
}
}
这里的 rollback() 只是尽力清理连接状态,它不能证明之前的 commit() 没有成功。
遇到这类错误时,系统不应盲目重新执行,而应进入专门的恢复流程:
- 查询确定性业务 ID
- 查询幂等键
- 检查目标资源是否已经存在
- 恢复原执行回执
- 无法确认时向用户明确显示“结果暂时无法核验”
对于创建笔记、保存附件等操作,还可以根据稳定业务负载生成幂等键:
function createActionIdempotencyKey({
toolName,
userId,
sessionId,
args,
}) {
const payload = {
toolName,
userId,
sessionId,
title: normalize(args.title),
content: normalize(args.content),
parentId: normalize(args.parentId),
};
return `agent-write-v1:${sha256(JSON.stringify(payload))}`;
}
再由该键派生稳定资源 ID。
这样即使写请求在结果未知时重试,也会命中同一个业务对象,而不是新建第二份。
不过需要注意:
不是所有工具都能只靠“参数相同”判断为同一个意图。
例如用户确实可能连续创建两条标题相同的待办。
所以幂等范围必须根据具体业务设计,不能把所有同参数写入永久去重。
十二、操作成功后,为什么还要再设计一套“续答状态机”
写操作执行成功后,通常还需要一段更自然的回复:
已为你创建高优先级待办“提交周报”,截止今天 21:00。
简单方案是把原对话、工具结果再次发给模型,让它继续生成。
但如果仍然提供写工具,可能重复执行。
如果完全不提供上下文,模型又无法结合前面的查询结果回答。
因此可以建立一份独立的 Action Continuation:
{
state: 'pending',
policy: 'final_reply',
ownerHash: '...',
sessionId: '...',
action: {
kind: 'confirmation',
id: 'confirmation-id'
},
snapshot: {
question: '帮我创建一条今晚九点提交周报的待办',
locale: 'zh-CN',
leadIn: '',
tools: [
{
name: 'query_todos',
status: 'succeeded',
summary: '查询到 3 条待办'
}
]
}
}
它与确认执行使用不同 Token,也拥有独立状态机:
pending
↓ 写操作产生可信成功回执
ready
↓ 原子认领回复生成权
running
↓ 生成并保存最终回答
settled
只有满足下面条件时,续答才能从 pending 进入 ready:
receipt.status === 'succeeded'
并且:
receipt.actionId === continuation.action.id
也就是说,一份不相关的成功回执不能被拿来激活当前回复。

最终回复阶段只读取:
- 原始问题快照
- 已完成的只读工具事实
- 当前写操作的可信回执
而不重新开放写工具。
这样就把:
决定执行什么
和:
如何描述已经发生的结果
彻底分开。
为什么回复生成也要支持重试
模型生成最终回复本身也可能超时。
如果第一次已经扣除了模型额度,但响应丢失,第二次重试不应再次触发写操作,也不应该由多个请求同时生成多份回答。
所以续答同样需要:
ready → running → settled
以及原子比较更新。
成功生成的回答保存后,相同续答请求直接返回缓存结果。
十三、最后一道防线:拦截没有回执的“已成功”
即使前面的链路已经很严格,模型仍可能在普通回复中说:
已经帮你创建好了。
尤其是当用户语气非常明确时,模型容易把“我理解你想创建”说成“我已经创建”。
因此,可以在最终文本输出前增加一个真实性传感器。
它不负责理解用户意图,也不决定调用哪个工具,只检查:
当前请求与写操作相关
+
最终回答包含高置信成功声明
+
服务端没有可信成功回执
例如检测:
已经帮你创建
操作已完成
待办已添加
笔记已经删除
✅ 已成功保存
一旦命中,就替换为确定性文案:
该操作尚未执行:服务端没有生成可核验的确认或成功回执。
这层机制不能代替前面的确认和回执,但可以防止最糟糕的界面结果:
数据库什么都没发生,AI 却非常自信地告诉用户已经成功。
十四、两个状态机放在一起看
完整状态可以表示为:
确认记录
────────────────────────────
创建确认
↓
ready
├─ 用户拒绝 → rejected
├─ 超过 TTL → expired
└─ 原子认领 → running
↓
业务执行
↓
settled
├─ succeeded
└─ failed
操作续答
────────────────────────────
创建续答
↓
pending
├─ 操作失败 → 不激活
└─ 收到 succeeded receipt
↓
ready
↓
原子认领
↓
running
↓
生成最终回复
↓
settled
两套状态机不应该合成一个超大的状态对象。
它们解决的问题不同:
| 状态机 | 解决的问题 |
|---|---|
| Confirmation | 用户是否批准,以及写操作是否只能执行一次 |
| Continuation | 已完成操作如何生成一次可信、可重试的最终回复 |
拆开后,任意一侧故障都不会让另一侧重新写数据库。
十五、确认卡为什么不能保存太久
确认令牌通常应该设置较短 TTL,例如 5 分钟。
原因不是节省 Redis 空间,而是写操作依赖的业务状态可能变化。
假设用户看到确认卡时:
待办状态:未完成
五十分钟后才点击:
标记为已完成
此时资源可能已经:
- 被其他页面修改
- 被删除
- 被其他设备完成
- 失去权限
- 版本发生变化
短 TTL 可以减少陈旧确认。
但即使令牌未过期,执行时仍然应该重新检查业务状态。
确认卡表示:
用户批准了这个意图。
它不表示:
数据库状态从发卡到执行期间永远没有变化。
十六、需要重点覆盖的边界情况
场景 1:用户连续点击两次确认
预期:
第一次请求获得执行权
第二次返回 running 或 settled
业务只执行一次
场景 2:确认令牌被另一个账号拿到
预期:
ownerHash 不匹配
返回 403
不泄露确认内容
场景 3:同账号但换了另一个会话
预期:
sessionId 不匹配
拒绝执行
场景 4:Redis 中的参数被异常修改
预期:
argsHash 校验失败
确认记录判定无效
场景 5:用户确认时资源已被修改
预期:
业务 Service 返回版本或状态冲突
不使用确认时的陈旧状态强行覆盖
场景 6:提交数据库后连接中断
预期:
标记 commitOutcomeUnknown
优先查询或恢复原结果
禁止立即盲目重跑
场景 7:操作执行成功,但最终回复超时
预期:
写操作不再次执行
续答令牌保持 ready 或恢复为 ready
用户重试时只重新生成回复
场景 8:模型在没有回执时声称成功
预期:
真实性守卫拦截成功声明
返回“尚未执行”
场景 9:管理员处于只读代管模式
预期:
连确认卡都不能生成
服务端拒绝准备写操作
场景 10:未支持的删除能力被模型识别出来
预期:
根据能力注册表返回 planned / forbidden
不允许模型自由拼接不存在的工具
十七、自动化测试应该测什么
这类系统最重要的测试不是“接口返回 200”,而是验证状态和顺序。

建议至少覆盖以下测试。
工具注册测试
it('写工具没有能力声明时拒绝注册');
it('写工具风险等级与注册表不一致时拒绝注册');
it('写工具试图关闭确认时拒绝注册');
参数策略测试
it('拒绝 additionalProperties');
it('执行写工具但 confirmed=false 时返回确认错误');
it('管理员 readonly 模式不能准备写操作');
it('资源用户与操作者异常错位时拒绝');
确认令牌测试
it('确认令牌绑定用户和会话');
it('参数哈希不一致时拒绝');
it('过期令牌返回 410');
it('同一令牌并发认领时只有一个成功');
it('执行完成后重复确认返回原 settled 结果');
业务执行测试
it('确认卡展示的是 prepare 后的具体时间');
it('异常年份在生成确认前被拒绝');
it('真实写入调用业务 Service 而不是复制 SQL');
it('commit 后连接异常被标记为结果未知');
续答测试
it('没有 succeeded receipt 时不能激活续答');
it('回执 actionId 与续答动作不匹配时拒绝');
it('同一 ready 续答只能被一个请求认领');
it('生成失败后可以安全释放回 ready');
it('settled 后重复请求返回同一回答');
真实性测试
it('没有回执时拦截“已经帮你创建”');
it('普通只读回答不触发成功声明守卫');
it('存在可信成功回执时允许输出完成结果');
十八、为什么不直接使用数据库保存全部确认状态
确认和续答状态也可以放在 MySQL 中,但 Redis 在这类短生命周期控制状态上更合适:
- TTL 原生支持
- Lua 脚本可以原子比较并修改
- 高频读取成本较低
- 不需要长期保留所有草稿
- Token 摘要可以直接作为 Key
不过,Redis 不能成为业务事实的唯一来源。
它适合保存:
待确认
执行中
短期结果缓存
回复续答状态
真正的业务资源、流水和长期审计仍然应该写入数据库。
一旦 Redis 不可用,写操作应失败关闭:
安全确认服务暂不可用,请稍后重试。
而不是降级为:
那就跳过确认直接执行。
对写操作而言,可用性暂时下降通常比安全边界消失更能接受。
十九、确认并不等于所有操作都要弹一次窗
文章到这里,容易得到一个极端结论:
所有 Agent 行为都必须弹窗。
其实不是。
应该区分:
只读操作
例如:
- 查询笔记
- 搜索书签
- 查看待办
- 读取文件摘要
只要权限和查询范围正确,通常可以直接执行。
低风险写操作
例如创建一条普通待办。
可以使用默认确认策略,未来在产品层根据明确上下文优化交互,但服务端仍需保留确认协议。
中高风险操作
例如:
- 修改已有状态
- 恢复资源
- 批量写入
- 删除
- 覆盖知识库内容
应始终明确确认,并展示目标、数量和影响。
真正的目标不是“弹窗越多越安全”,而是:
每一次真实状态变化,都必须有可验证的授权来源和服务端执行依据。
二十、这套方案的工程代价
1. 写一个工具不再只是实现 execute()
还需要:
- 能力声明
- 风险等级
- 参数 Schema
- 参数归一化
- 服务端准备
- 确认预览
- 业务 Service
- 结果转换
- 并发和失败测试
开发成本明显上升。
2. Redis 变成关键安全依赖
确认和续答依赖 Redis 后,需要考虑:
- 主从切换
- TTL
- Lua 支持
- Key 数量
- 失败关闭
- 敏感信息大小
- 监控日志脱敏
3. 最终回复链路更复杂
执行成功后不能简单“再问一次模型”,而要保存问题快照、工具事实和成功回执。
但这份复杂性是必要的,因为它把“写数据库”和“说一句好听的话”分成了两个不同可靠性等级的问题。
4. 幂等不能一刀切
创建笔记可以根据稳定负载派生确定性 ID。
创建待办却可能允许用户连续创建两条同名记录。
删除、修改、上传和批量操作也需要不同的幂等设计。
所以系统需要统一原则,但不能强迫所有工具使用同一种业务去重键。
结语
Function Calling 解决的是:
模型如何表达“我想调用某个函数”。
它没有自动解决:
这个函数是否允许调用
参数是否可信
用户是否确认
并发时谁有执行权
数据库到底有没有提交
结果能否安全重放
模型是否有资格声称成功
当 Agent 只做查询时,我们主要关心召回率、上下文和回答质量。
当 Agent 开始修改真实数据后,系统重点应该迅速转向:
权限
确认
状态机
事务
幂等
回执
恢复
审计
我现在更倾向于把一个可执行 Agent 理解成三部分:
模型负责理解意图
服务端负责裁决和执行
回执负责证明事实
其中任何一部分都不能替代另外两部分。
本文中的实现思路来自我正在维护的开源知识工作区 LightNote。项目使用 Vue 3、Node.js、Express、MySQL 和 Redis,Agent 可以在确认后创建笔记、书签、标签、待办以及执行部分受控操作。
GitHub:
https://github.com/VeteranBoLuo/light-note
真正让 Agent 变得可靠的,不是再写十条 Prompt,而是让它即使理解错了、请求重试了、连接中断了,也没有机会绕过服务端规则把错误放大成真实数据。
更多推荐



所有评论(0)