章节07 / 14
本文目录12 节
有副作用的工具要先设计刹车
本教程引导开发者在为大模型 Agent 接入写数据库、发邮件、支付等高风险“有副作用”的工具时,如何通过工具风险分级、幂等键设计、人工确认流(HITL)及 MCP 信任边界,构建可控的、带安全刹车机制的生产级系统。
- 完成前序关于 Tool Use 与 RAG 基础概念的学习
- 具备 TypeScript 或 Node.js 后端开发基础
- 构建工具风险分级表以划分操作权限
- 设计并实现基于 Redis / 内存存储的工具幂等键校验机制
- 在应用层实现阻断式的双重人工确认流程(HITL)
- 为 MCP(Model Context Protocol)工具制定边界审计策略
大模型在理解意图并调用工具(Tool Use / Function Calling)时,展现出了强大的自动化能力。然而,在真实生产环境里,工具并不都是无害的只读查询。一旦你允许 Agent 执行写数据库、发送邮件、扣款支付或操作内部系统等具有“副作用”的行为,稍有不慎,Agent 的一次“幻觉”或者一句诱导性的提示注入,就可能演变成直接给用户发送成百上千条垃圾信息、在数据库中产生大量脏数据,甚至是产生重复划扣资金的严重生产事故。
我们将基于业界的安全标准与工业界实践,探讨如何为具有副作用的工具箱拉起手刹,构建一套安全、可控、能防重、能审计的工具调用拦截机制。
1. 事故场景:重复扣款背后的“幻觉”与网络抖动
本节操作锚点:围绕“1.事故场景:重复扣款背后的“幻觉”与网络抖动”记录步骤、样例、诊断、风险、检查清单和验收结果。
想象这样一个经典场景:你为电商 AI 助理提供了一个 refund_user_balance 工具,用于在核实售后情况后直接退款给用户。大模型在处理一笔退款请求时,生成了工具调用参数:{ "userId": "user_9083", "amountCents": 10000 }。
在执行过程中,网络发生了抖动,工具调用超时。由于应用层没有限制,大模型没有得到预期的返回结果,认为退款未成功。它的推理循环开始运转:
“上一次调用由于某种原因失败了,我应该再试一次,确保用户能拿到退款。”
于是,大模型第二次调用了 refund_user_balance。然而,上一次调用其实已经到达了支付后台并被处理,只是网络响应超时了。这就导致了同一笔交易退款了两次。这就是典型的由于大模型自主决策重试、缺乏去重机制所造成的资金事故。
根据 OWASP Top 10 for LLM Applications (2025) 中关于 LLM08: Excessive Agency (过度代理) 的风险定义,如果赋予 Agent 过宽的权限,或者允许其在没有监督和限制的情况下自主执行有副作用的操作,极易导致系统遭到破坏或造成财务损失。为了防止大模型在复杂场景下失控,我们必须建立起严格的“刹车”机制,把绝对的控制权重新拿回到我们的应用层手里。
2. 工具分级:在你的工具箱拉一张风险分级表
本节操作锚点:围绕“2.工具分级:在你的工具箱拉一张风险分级表”记录步骤、样例、诊断、风险、检查清单和验收结果。
控制工具副作用的第一步,是对应用能够调用的所有工具进行严格的安全风险评级。我们不能采用一刀切的方式阻碍所有工具的执行,应该根据其可能引发的安全风险、财务风险和数据破坏程度进行隔离。
参考 NIST GenAI Profile (NIST AI 600-1) 的风险治理指南,对生成式 AI 输出的治理应当匹配其可能产生的负面影响。我们建议你在工具定义之初,就建立如下的“工具风险分级表”:
| 风险等级 | 典型操作 | 潜在危害 | 核心防御策略 |
|---|---|---|---|
| L1 (只读/无害) | 查询库存、获取天气、检索公共文档、格式化时间 | 无直接数据修改风险,仅消耗 API 额度 | 基础并发限制(Rate Limiting) |
| L2 (轻微副作用) | 更新用户昵称、创建临时草稿箱、标记待办已完成 | 存在脏数据风险,但通常可轻松回滚或不涉及财务与隐私 | 基础鉴权、日志审计、幂等键校验 |
| L3 (高危/外部触达) | 发送外发邮件、向飞书/Slack 频道广播通知 | 易遭提示注入攻击,变成大流量垃圾邮件发送源 | 严格的输入内容格式化检查、内容安全审核(Moderation) |
| L4 (灾难级/敏感数据) | 转账、退款、物理删除数据库记录、调用核心业务接口 | 造成直接资金损失、严重的合规和安全生产事故 | 强制人工确认(HITL)、严格鉴权、细粒度审计、全链路幂等机制 |
安全决策准则: 如果工具执行涉及到财务划扣或数据库敏感字段更新,必须引入人工确认(HITL)机制,因为大模型可能由于提示注入(OWASP LLM01)或幻觉在非预期场景下自动触发该工具。
3. 防范重复执行:Stripe 启发的幂等键策略
本节操作锚点:围绕“3.防范重复执行:Stripe启发的幂等键策略”记录步骤、样例、诊断、风险、检查清单和验收结果。
当涉及 L2 至 L4 级的工具时,我们必须引入强幂等设计。Stripe 的幂等请求文档(Stripe Idempotent requests) 指出,幂等键(Idempotency Key)可以通过确保多次相同的请求只被执行一次,来防止由于网络超时或客户端盲目重试导致的重复扣款。
在 Anthropic 的 Tool Use 机制中,大模型的运行周期是:
- 客户端发送用户 query 给模型。
- 模型识别并返回一个
tool_use指令。 - 客户端在本地执行工具,并将执行结果作为
tool_result反馈给模型。 - 模型根据工具结果继续推理。
因为工具是在你的客户端应用层执行的,而不是在模型端。因此,你有绝对的机会在执行具体工具代码前拦截并打上幂等标记。
幂等键生成策略
要让 Agent 具备幂等安全性,你不能期望大模型自己生成一个安全的 UUID 作为幂等键,因为它极易在重试时产生新的随机 UUID 导致校验失效。幂等键必须由客户端根据上下文静态生成:
$$\text{Idempotency Key} = \text{SHA256}(\text{SessionId} + \text{ToolName} + \text{SerializedArguments} + \text{MessageRound})$$
- SessionId: 区分当前的对话生命周期。
- ToolName: 区分具体的工具类型。
- SerializedArguments: 序列化后的参数(通过严格排序参数名以确保序列化结果一致)。
- MessageRound: 对话的轮次。在一轮对话中,如果因为网络抖动重试,相同的工具调用参数应当映射为同一个幂等键。
如果调用外部服务失败,不要让 AI 助理自行进行无限次的盲目重试,除非每次重试时工具函数都带有强绑定的幂等键,否则极易造成下游系统的脏数据或资金受损。
4. 人工确认防线:阻断高风险调用的拦截机制
本节操作锚点:围绕“4.人工确认防线:阻断高风险调用的拦截机制”记录步骤、样例、诊断、风险、检查清单和验收结果。
针对 L4 级别的灾难级工具(例如:退款、向关键数据库写入),我们必须设置人工确认(Human-in-the-loop, HITL)的断路器机制。大模型可以产生调用的“意图”,但实际执行必须挂起(Suspended),直到具有操作权限的人类管理员在管理后台或交互界面点击“同意”。
基于 Anthropic Tool Use API 规范,我们可以把工具调用的返回分为三个阶段,来实现这个挂起机制:
┌──────────┐ 1. User Query ┌───────────┐
│ User ├────────────────────────>│ App Agent │
└──────────┘ └─────┬─────┘
│ 2. Tool Call Request
▼
┌──────────┐ ┌───────────┐
│ Human │ 4. Approve │ Interceptor│
│ Reviewer ├────────────────────────>│ Buffer │
└────┬─────┘ └─────┬─────┘
│ 3. UI Prompt │ 5. Tool Result
▼ ▼
┌──────────┐ ┌───────────┐
│ Admin UI │ │ LLM Tool │
└──────────┘ └───────────┘
挂起-确认设计路径
- 拦截阶段:在工具分发器(Dispatcher)中,如果检测到调用的工具属于 L4 级,拦截器暂停执行,并生成一条临时的“待审批”工单存入数据库,将当前对话状态置为
AwaitingApproval。 - 前端抛出:API 返回给客户端一个特殊的挂起标识,前端界面渲染出“大模型正在申请执行退款操作,确认执行吗?”的确认弹窗,并提供对应的金额和账号详情。
- 执行与恢复:用户点击“确认”后,前端向审核端点(Approve Endpoint)发送请求,释放挂起状态,工具才在服务器端被安全执行,并将
tool_result送回大模型,让大模型继续其后续推理。
5. 最小特权与数据追溯:构建不可篡改的审计日志
本节操作锚点:围绕“5.最小特权与数据追溯:构建不可篡改的审计日志”记录步骤、样例、诊断、风险、检查清单和验收结果。
为了符合 NIST AI 生成式风险控制中的安全审计标准,AI 助理的所有行为必须在安全的沙箱内运行,且每一次工具调用必须保留完整的调用链日志,这被称为 AI 原生审计(AI Native Auditing)。
审计日志不应该简单记录一句 工具 refund 执行成功。一个合格的安全审计日志格式应当捕获这四个关键实体:
- Triggered By: 哪一个用户输入导致了此工具调用(保存 Prompt 引用,预防注入漏洞溯源)。
- Model Identity: 调用工具的模型版本、温度等超参(如
claude-3-5-sonnet-20241022)。 - Arguments Diff: 模型传进来的 JSON 原始参数,与安全拦截器(如数据校验层)清洗后的参数比对差异。
- Executor Privilege: 运行此工具的实际系统服务账号权限。确保遵循“最小特权原则”——即使大模型在 Prompt 里要求删除所有库,工具绑定的数据库连接用户也只有
UPDATE/INSERT的特定行权限,无法执行DROP TABLE。
6. MCP 边界安全:别让不受信的 MCP 接口穿透内网
本节操作锚点:围绕“6.MCP边界安全:别让不受信的MCP接口穿透内”记录步骤、样例、诊断、风险、检查清单和验收结果。
Model Context Protocol (MCP) 是 Anthropic 推出的一项用于将大模型客户端与外部数据源和工具生态标准化的协议。然而,正如协议官方文档(modelcontextprotocol.io)所指出的,MCP 协议定义了严格的传输边界,但 MCP Server 并不默认可信。
由于 MCP Server 可能由第三方开发者编写,并通过网络或本地进程提供服务,直接将其引入你的生产环境将直接带来极高风险,极易触发 OWASP LLM05 (供应链漏洞)。
如果要引入外部公开的 MCP 插件服务,必须在应用层配置硬编码的沙箱网关和权限白名单,只有这样才能避免不受信的 MCP Server 越权读取内网敏感资产。
MCP 信任边界清单(Trust Boundary Checklist)
在将任何 MCP 工具暴露给生产模型前,必须对 MCP 边界进行四步核查:
- 传输层硬编码审计:外部 MCP Server 是否通过了加密安全的 transport 连接(如 https / SSE)?本地进程运行的 MCP server 是否有受限的操作权限,禁止读取配置文件?
- 方法白名单机制:禁止直接将 MCP 提供的所有 tools 无脑绑定给模型。必须显式通过配置数组
allowed_mcp_tools: ["postgres_query_restricted_view"]过滤掉高风险方法(如run_terminal_command)。 - Schema 强制断言:对于任何接收的
tool_result,不要直接透传给模型。必须在应用层再次通过Zod或JSON Schema做强类型、白名单和长度断言,避免外部 MCP Server 返回过大的恶意提示词注入 Payload。
7. 实战演练:为自动化退款工具装上双重刹车
本节操作锚点:围绕“7.实战演练:为自动化退款工具装上双重刹车”记录步骤、样例、诊断、风险、检查清单和验收结果。
现在,我们将在 TypeScript(Node.js)中,基于 Anthropic 官方 Tool Use 规范与 Stripe 幂等理念,亲手实现一个集成了幂等防重机制和人工确认挂起流的高危工具安全网关。
基础依赖环境与组件定义
import crypto from 'crypto';
// 工具分级枚举
export enum RiskLevel {
L1 = 'L1',
L2 = 'L2',
L3 = 'L3',
L4 = 'L4'
}
// 定义工具的基础结构
export interface AgentTool {
name: string;
description: string;
riskLevel: RiskLevel;
handler: (args: any) => Promise<any>;
}
// 极简内存模拟 Redis,用于存储幂等状态与挂起任务
const idempotencyStore = new Map<string, { status: 'processing' | 'completed', result?: any }>();
const pendingApprovals = new Map<string, { toolName: string; args: any; sessionId: string; step: number }>();
安全拦截网关的实现
export class ToolSecurityGateway {
// 生成幂等键函数
public static generateIdempotencyKey(
sessionId: string,
toolName: string,
args: any,
step: number
): string {
const serializedArgs = JSON.stringify(args, Object.keys(args).sort());
const rawString = `${sessionId}:${toolName}:${serializedArgs}:${step}`;
return crypto.createHash('sha256').update(rawString).digest('hex');
}
// 执行工具的安全入口
public async executeWithBrakes(
tool: AgentTool,
args: any,
context: { sessionId: string; step: number; userConfirmed?: boolean }
): Promise<{ status: 'success' | 'pending' | 'duplicate'; result?: any; approvalId?: string }> {
// 1. 生成唯一的幂等键
const idKey = ToolSecurityGateway.generateIdempotencyKey(
context.sessionId,
tool.name,
args,
context.step
);
// 2. 幂等去重检测 (Stripe 策略)
const existingRequest = idempotencyStore.get(idKey);
if (existingRequest) {
if (existingRequest.status === 'processing') {
throw new Error('相同操作正在处理中,请勿重复发起。');
}
// 已有处理成功的结果,直接幂等返回,避免重复执行
return { status: 'duplicate', result: existingRequest.result };
}
// 3. 安全等级拦截:对于 L4 级且未确认的请求进行人工确认拦截
if (tool.riskLevel === RiskLevel.L4 && !context.userConfirmed) {
const approvalId = `approve_${crypto.randomUUID()}`;
pendingApprovals.set(approvalId, {
toolName: tool.name,
args,
sessionId: context.sessionId,
step: context.step
});
// 返回挂起状态,不执行底层函数
return {
status: 'pending',
approvalId,
result: { message: '此操作属于高危行为,已挂起等待人工确认。' }
};
}
// 4. 正式执行前,加锁幂等状态
idempotencyStore.set(idKey, { status: 'processing' });
try {
// 执行具体业务逻辑
const executionResult = await tool.handler(args);
// 执行成功,落盘结果
idempotencyStore.set(idKey, { status: 'completed', result: executionResult });
return { status: 'success', result: executionResult };
} catch (error) {
// 执行失败,移出状态锁定,允许后续重试
idempotencyStore.delete(idKey);
throw error;
}
}
}
业务逻辑:退款工具及流程测试
现在我们定义一个具体的退款操作 refundTool,并模拟大模型发起高危调用时的拦截流程。
// 退款高危工具 (L4)
const refundTool: AgentTool = {
name: 'refund_user_balance',
description: '将资金退回用户钱包余额中',
riskLevel: RiskLevel.L4,
handler: async (args: { userId: string; amountCents: number }) => {
// 模拟写入生产数据库的操作
return {
success: true,
transactionId: `tx_mock_${crypto.randomBytes(4).toString('hex')}`,
refundedAmount: args.amountCents
};
}
};
// 模拟执行流
async function runTest() {
const gateway = new ToolSecurityGateway();
const sessionId = "session_customer_support_992";
console.log("=== 步骤 1:大模型识别用户退款意图,自动请求执行 L4 工具 ===");
const toolArgs = { userId: "user_1029", amountCents: 5000 };
let response = await gateway.executeWithBrakes(refundTool, toolArgs, {
sessionId,
step: 1 // 当前对话轮次
});
console.log("执行状态:", response.status);
console.log("拦截响应:", response.result);
console.log("生成审批 ID:", response.approvalId);
console.log("--------------------------------------------------");
// 如果挂起,用户审批后继续
if (response.status === 'pending' && response.approvalId) {
console.log("=== 步骤 2:管理员审核该笔退款,点击同意,传入审批 ID ===");
const approvalId = response.approvalId;
const pendingTask = pendingApprovals.get(approvalId);
if (pendingTask) {
// 释放任务并附带 userConfirmed = true
const finalResponse = await gateway.executeWithBrakes(refundTool, pendingTask.args, {
sessionId: pendingTask.sessionId,
step: pendingTask.step,
userConfirmed: true // 开启绿灯放行
});
console.log("执行状态:", finalResponse.status);
console.log("底层执行成功结果:", finalResponse.result);
pendingApprovals.delete(approvalId);
}
}
console.log("--------------------------------------------------");
console.log("=== 步骤 3:模拟网络抖动,模型重复发出同一笔请求时触发幂等机制 ===");
try {
// 重复请求,相同的 sessionId, step, 相同的参数,userConfirmed 为已授权状态
const duplicateResponse = await gateway.executeWithBrakes(refundTool, toolArgs, {
sessionId,
step: 1,
userConfirmed: true
});
console.log("执行状态:", duplicateResponse.status);
console.log("直接返回的缓存结果(未二次扣款):", duplicateResponse.result);
} catch (err: any) {
console.error("异常:", err.message);
}
}
runTest();
常见失败与诊断清单
- 失败场景:网络确实重试了,但幂等键没有撞上,后端发生了二次扣款。
- 诊断原因:检查序列化参数时,字段顺序是否不一致(例如大模型第一次传
{ "userId": "12", "amount": 100 },第二次重试传{ "amount": 100, "userId": "12" })。如果序列化时未做 Key 排序排序,相同的 JSON 会产生不同的 SHA256 签名。 - 解决方案:必须在生成哈希签名时,对对象的 Key 进行规范化排序(即如上面示例中的
Object.keys(args).sort())。
- 诊断原因:检查序列化参数时,字段顺序是否不一致(例如大模型第一次传
- 失败场景:人工确认功能启动后,Agent 在下一步不知道继续干什么,陷入了死循环。
- 诊断原因:在执行拦截时,未将标准的
tool_use返回给大模型,导致模型上下文缺失或中断了 Agent 原生的提示词编排周期。 - 解决方案:在发生 L4 拦截挂起时,客户端向模型发送的
tool_result不应该是空白,应当是格式化的拦截通知(例如{"status": "AWAITING_HUMAN_APPROVAL", "message": "Action registered. Awaiting admin manual confirmation. Do not retry yet."})。这可以让大模型能够流畅地理解由于外部刹车暂停执行的现实状况,并友好地告知前端用户“我已经把申请提交给管理员了,请在手机上点击确认”。
- 诊断原因:在执行拦截时,未将标准的
8. 交付物验收说明与时效复核指南
本节操作锚点:围绕“8.交付物验收说明与时效复核指南”记录步骤、样例、诊断、风险、检查清单和验收结果。
本课交付物:
- 工具风险分级表:对当前项目中的数据库写入、外发邮件等接口进行了逐一分级,明确了哪些是 L3/L4 级别。
- 幂等策略实现:使用了对客户端参数进行强排序、配合 SessionID 和 Step 计算幂等 Key 的机制。
- 人工确认流(HITL):实现了当工具风险为 L4 且未通过人工确认时自动拦截、缓存并返回 Await 信号的流程。
- MCP 隔离清单:限定了连接本地与第三方 MCP 时的白名单,并配置了 schema 转换防御网关。
时效复核指南
本课程基于 OWASP LLM Top 10 (2025 年版) 以及 Anthropic 与 Stripe 的官方 API 设计基准编写。
在开发或运维时,应持续留意以下事件的演进,若发生改变,需要重新复核安全配置及架构设计:
- 复核触发器一:当外部 MCP 协议规范定义了官方原生(Native)的人工审批流(HITL 规范)时,应从应用层手写拦截迁移到 MCP 原生协议层执行。
- 复核触发器二:Stripe 针对幂等 API 出现重大的协议更新。若您在开发涉及金流的 Agent,必须每 90 天对系统的支付幂等键清除、超时及异常捕获流进行安全渗透审计。
有副作用的工具要先设计刹车:把判断写成可复查证据
读到这里要停下来做一次复盘:产物是否可复现,风险是否可解释,来源是否可追踪。本课交付物是 工具风险分级表、幂等键策略、人工确认流程和 MCP 信任边界清单,它要服务于 AI 应用 的下一步,而不是只证明你读过这一节。
如果你现在还没有真实输入,先用一个最小样例完成 有副作用的工具要先设计刹车,因为没有输入就无法判断产物是否可用。 如果本课产物会影响后续交付,应该写明适用条件和不适用条件,否则下一课会把不确定性误当成基础。 如果检查结果只剩一句“看起来可以”,必须补一条反例或失败样例,只有能解释失败原因时,这个判断才站得住。
如果检查清单全是“完成/未完成”,还不够;它必须写出失败时下一步查哪里。围绕 有副作用的工具要先设计刹车 做检查时,至少保留步骤、样例、风险、修复和验收五项。
Anthropic 的《Anthropic Tool use overview》说明:支撑 tool_use / tool_result 流程、工具执行权留在应用层和代理边界。;这意味着 有副作用的工具要先设计刹车 不能只写经验结论,要把来源变成检查动作。OWASP 的《OWASP Top 10 for Large Language Model Applications》提醒:支撑提示注入、敏感信息泄露、过度代理、供应链和输出处理风险分类。;因此本课方案必须写清边界。NIST 的《Artificial Intelligence Risk Management Framework: Generative Artificial Intelligence Profile》提供的证据是:支撑 NIST AI 600-1 生成式 AI 风险画像、治理活动、风险类别和复核触发条件。;所以当前结论按 2026-05-28 的来源状态使用。
复核触发条件要写具体:如果官方 API/SDK、软件工具版本、版权政策、安全合规要求、行业规范、平台发布规则或关键来源页面发生变化,需要重新检查 有副作用的工具要先设计刹车 的步骤、样例、风险和验收清单。
练习验收:把 有副作用的工具要先设计刹车 应用到一个自己的任务,交付一份包含输入、步骤、样例、失败诊断、来源证据和复测结果的记录。缺少其中任何一项,都先标记为 needs_review。