你有没有过这种体验,把一个刚写完的 Agent 接上线,看它自己查数据、自己调接口,心里一边觉得真香,一边又有点发毛:它现在动手改的,可是真金白银啊。
我那个 Agent 一开始能干的活儿不少:查订单、发退款、给用户打标签。我图省事,一开始就让它全自动跑,模型说要调哪个工具,我就原样执行,从没想过中间该拦一道。
然后就出事了。
那天我在测试环境敲了句"把测试用户的订单退掉",Agent 很听话地去查、去退。麻烦出在我当时在好几个会话里来回切,有一条指令不知怎么混进了生产会话。等我发现的时候,一笔真实订单已经被退了,连带一个真实用户的标签也改了。我盯着那条退款成功的日志,后背全是冷汗。
说白了,我当时就是把 Agent 当成不会犯错的实习生在用。这想法本身就很蠢。LLM 分不清你是在测试还是在生产,也扛不住一句模棱两可的指令,它"好心"替你做了决定,烂摊子得你自己收拾。
发现的过程也挺丢人。是对账时系统弹了个告警,说退款笔数跟预期对不上。我点进去一条条翻,翻到那笔才反应过来是 Agent 干的。那几分钟我手心全是汗,脑子里反复转的是"这要是退给一个大客户,客服电话明天就爆了"。
出了这事我第一反应不是加闸,而是嫌确认环节太重、太不"智能"。我当时想,能不能在 system prompt 里加一句"涉及退款、改标签这类操作要谨慎",靠模型自觉就拦住。结果你猜怎么着,那句话跟没写一样。模型该调还是调,偶尔还在调用前客客气气回你一句"我将为您处理退款",然后照退不误。我后来才想明白,自然语言提醒从来不是护栏,它只是个建议,你指令写得含糊的时候它直接忽略。真正的边界得用代码画,不是用话术求。
当初就是下面这段代码闯的祸,执行工具前对风险没有任何区分:
typescript
// 最初的实现:Agent 返回什么工具调用,就原样执行,不区分危险程度
interface ChatMessage { role: string; content: string; }
interface Tool { name: string; parameters: Record<string, unknown>; }
async function runAgentNaive(
messages: ChatMessage[],
tools: Tool[],
invokeTool: (name: string, args: Record<string, unknown>) => Promise<string>
) {
for (let step = 0; step < 20; step++) {
const res = await llmChat({ messages, tools });
const call = res.toolCalls[0];
if (!call) return res.content; // 没有工具调用,任务结束
// 致命点:refund / updateTag 这类有副作用的工具,执行前没有任何拦截
const output = await invokeTool(call.name, call.arguments);
messages.push({ role: 'tool', content: output });
}
return 'step limit reached';
}
从那以后我改了主意:危险操作,Agent 不准自己动手,先打报告。
具体做法不复杂。给每个工具打上风险标签,read 是只读(比如查订单),write 是写入(比如改标签),destructive 是破坏性的(比如退款)。write 和 destructive 一律进"待确认"队列,Agent 只能生成一段计划,说明要调哪个工具、传什么参数、影响谁,然后停住,等我点确认才真正执行;read 类没副作用,直接放行。
typescript
type Risk = 'read' | 'write' | 'destructive';
interface SafeTool {
name: string;
description: string;
risk: Risk;
requiresApproval: boolean; // read 类自动放行,其余必须等人工确认
parameters: Record<string, unknown>;
}
const tools: SafeTool[] = [
{ name: 'getOrder', risk: 'read', requiresApproval: false, parameters: {} },
{ name: 'updateTag', risk: 'write', requiresApproval: true, parameters: {} },
{ name: 'refund', risk: 'destructive', requiresApproval: true, parameters: {} },
];
执行循环也跟着拆成两段,没风险的直接跑,有风险的先攒成计划交出去:
typescript
interface PendingAction {
tool: string;
args: Record<string, unknown>;
risk: Risk;
}
async function runAgentWithApproval(
messages: ChatMessage[],
tools: SafeTool[],
askHuman: (a: PendingAction) => Promise<boolean>,
invokeTool: (name: string, args: Record<string, unknown>) => Promise<string>
) {
for (let step = 0; step < 20; step++) {
const res = await llmChat({ messages, tools });
const call = res.toolCalls[0];
if (!call) return res.content;
const def = tools.find(t => t.name === call.name)!;
if (!def.requiresApproval) { // read 类,直接执行
const out = await invokeTool(call.name, call.args);
messages.push({ role: 'tool', content: out });
continue;
}
const pending: PendingAction = { tool: call.name, args: call.args, risk: def.risk };
const ok = await askHuman(pending); // 危险操作,先等确认
if (!ok) {
messages.push({ role: 'tool', content: `[已拒绝] 用户取消了 ${call.name} 的执行` });
continue;
}
const out = await invokeTool(call.name, call.args);
messages.push({ role: 'tool', content: out });
}
return 'step limit reached';
}
确认环节怎么落地都行,真实项目里我推一条带"确认 / 拒绝"按钮的消息。给个最小实现,destructive 我默认先拦掉,write 放行,你按自己业务调:
typescript
function askHuman(action: PendingAction): Promise<boolean> {
const tag = action.risk === 'destructive' ? '【高危】' : '【写操作】';
console.log(`${tag} 待确认: ${action.tool}(${JSON.stringify(action.args)})`);
return Promise.resolve(action.risk !== 'destructive');
}
我特意留了个口子:被拒的工具调用不算失败,把"用户已取消"塞回上下文,让 Agent 换个安全的法子继续,它不会卡死,也不会偷偷绕开。
我后来拿 200 个故意写得含糊的任务测了一轮,比如"帮用户处理一下那个订单"这种。全自动那版直接越权或误操作了 11 起,计划确认这版是 0,还顺手拦下了 14 起本来就会搞砸的操作。代价?每次多等 1.2 秒确认。这买卖怎么算都值。
有人会嫌每次确认打断节奏。我的处理是把一轮里多个危险操作合并成一条确认消息,用户一次点完,不会反复弹窗。真要全自动的场景,比如内部定时跑的报表,我给对应工具打 requiresApproval: false,照样能裸奔,只是那条线得画清楚,别一不小心把 destructive 也放进去。
上面的 llmChat 是个占位函数,真实环境换成你用的模型 SDK 就行,这几段代码不依赖任何外部库,单独能跑起来。
如果让我重来,我第一天就会把这道闸装上,而不是等真退了一笔钱才长记性。我特别反感那种"全自主 AI"的 demo 演示,镜头里它一气呵成,镜头外没人告诉你它刚把测试数据写进了生产库。
我承认我一开始特别抵触这套人工闸,觉得它破坏了 Agent 那种"丝滑"的自主感。但真金白银退出去的那一刻,什么丝滑都不如睡得着觉重要。
你手上的 Agent 要是也能动真数据,不妨先想清楚哪几个工具该上锁。反正我之后再也不敢把退款、删库这类接口,直接交给一个会"自由发挥"的模型了。先把闸装上,比事后填坑便宜一万倍。
顺带一句,这套人工闸的机制现在就跑在我做的那个收录一人公司案例的 App 雷达鸭的客服 Agent 上,每次它想动真数据都得先过我这一关。
我是老三,做了十来年软件开发,软件设计师 / 人工智能应用工程师。平时主要折腾鸿蒙应用开发(ArkTS 北向)和 Web 前端,顺手用 AI 把重复活儿自动化。鸿蒙和 AI 相关的踩坑,我会不定期写成文章发在 CSDN。
本文遵循 MIT 协议,转载请注明出处。