AI Agent 真正写进数据库后,我才发现:最危险的不是幻觉,而是重复执行

让大模型查询数据并不难。

用户问一句:

帮我找一下最近没完成的待办。

模型调用查询工具,服务端返回几条记录,再由模型组织成自然语言。即使回答不够准确,最坏的结果通常也只是“答错了”。

但当需求变成:

帮我创建一条今晚 9 点提交周报的高优先级待办。

问题就完全不同了。

此时 Agent 不再只是回答问题,而是在修改真实业务数据。

最开始我以为最大的风险是模型把时间理解错、标题写错,后来真正把 Agent 接进项目后才发现,最危险的问题其实是:

同一个写操作,在用户只确认一次的情况下,被执行了两次。

它可能来自用户连续点击、网络超时重试、前端刷新、服务端响应丢失,也可能来自“操作已经成功,但模型为了生成最终回复又重新跑了一轮工具”。

如果只是多返回一句话,问题不大。

如果是多创建一条笔记、多扣一次额度、多上传一份文件,甚至多删除一次资源,后果就完全不同了。

这篇文章复盘一套 Vue 3 + Node.js + Redis 项目中的 Agent 写操作链路,重点讨论五个问题:

  1. 为什么确认弹窗并不等于安全确认
  2. 为什么模型输出的 Tool Call 不能直接执行
  3. 如何原子消费确认令牌,阻止重复写入
  4. 操作成功后,为什么不能重新开放写工具生成回复
  5. 如何让“已成功”这句话必须有服务端回执支撑

在这里插入图片描述

一、最容易实现,也最危险的方案

很多 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,而是让它即使理解错了、请求重试了、连接中断了,也没有机会绕过服务端规则把错误放大成真实数据。

Logo

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

更多推荐