我弃了“全自主 Agent“:一次误退款,让我给所有危险工具加了道人工闸

发布时间:2026/7/26 15:57:48
我弃了“全自主 Agent“:一次误退款,让我给所有危险工具加了道人工闸 你有没有过这种体验把一个刚写完的 Agent 接上线看它自己查数据、自己调接口心里一边觉得真香一边又有点发毛它现在动手改的可是真金白银啊。我那个 Agent 一开始能干的活儿不少查订单、发退款、给用户打标签。我图省事一开始就让它全自动跑模型说要调哪个工具我就原样执行从没想过中间该拦一道。然后就出事了。那天我在测试环境敲了句把测试用户的订单退掉Agent 很听话地去查、去退。麻烦出在我当时在好几个会话里来回切有一条指令不知怎么混进了生产会话。等我发现的时候一笔真实订单已经被退了连带一个真实用户的标签也改了。我盯着那条退款成功的日志后背全是冷汗。说白了我当时就是把 Agent 当成不会犯错的实习生在用。这想法本身就很蠢。LLM 分不清你是在测试还是在生产也扛不住一句模棱两可的指令它好心替你做了决定烂摊子得你自己收拾。发现的过程也挺丢人。是对账时系统弹了个告警说退款笔数跟预期对不上。我点进去一条条翻翻到那笔才反应过来是 Agent 干的。那几分钟我手心全是汗脑子里反复转的是这要是退给一个大客户客服电话明天就爆了。出了这事我第一反应不是加闸而是嫌确认环节太重、太不智能。我当时想能不能在 system prompt 里加一句涉及退款、改标签这类操作要谨慎靠模型自觉就拦住。结果你猜怎么着那句话跟没写一样。模型该调还是调偶尔还在调用前客客气气回你一句我将为您处理退款然后照退不误。我后来才想明白自然语言提醒从来不是护栏它只是个建议你指令写得含糊的时候它直接忽略。真正的边界得用代码画不是用话术求。当初就是下面这段代码闯的祸执行工具前对风险没有任何区分// 最初的实现Agent 返回什么工具调用就原样执行不区分危险程度interfaceChatMessage{role:string;content:string;}interfaceTool{name:string;parameters:Recordstring,unknown;}asyncfunctionrunAgentNaive(messages:ChatMessage[],tools:Tool[],invokeTool:(name:string,args:Recordstring,unknown)Promisestring){for(letstep0;step20;step){constresawaitllmChat({messages,tools});constcallres.toolCalls[0];if(!call)returnres.content;// 没有工具调用任务结束// 致命点refund / updateTag 这类有副作用的工具执行前没有任何拦截constoutputawaitinvokeTool(call.name,call.arguments);messages.push({role:tool,content:output});}returnstep limit reached;}从那以后我改了主意危险操作Agent 不准自己动手先打报告。具体做法不复杂。给每个工具打上风险标签read 是只读比如查订单write 是写入比如改标签destructive 是破坏性的比如退款。write 和 destructive 一律进待确认队列Agent 只能生成一段计划说明要调哪个工具、传什么参数、影响谁然后停住等我点确认才真正执行read 类没副作用直接放行。typeRiskread|write|destructive;interfaceSafeTool{name:string;description:string;risk:Risk;requiresApproval:boolean;// read 类自动放行其余必须等人工确认parameters:Recordstring,unknown;}consttools:SafeTool[][{name:getOrder,risk:read,requiresApproval:false,parameters:{}},{name:updateTag,risk:write,requiresApproval:true,parameters:{}},{name:refund,risk:destructive,requiresApproval:true,parameters:{}},];执行循环也跟着拆成两段没风险的直接跑有风险的先攒成计划交出去interfacePendingAction{tool:string;args:Recordstring,unknown;risk:Risk;}asyncfunctionrunAgentWithApproval(messages:ChatMessage[],tools:SafeTool[],askHuman:(a:PendingAction)Promiseboolean,invokeTool:(name:string,args:Recordstring,unknown)Promisestring){for(letstep0;step20;step){constresawaitllmChat({messages,tools});constcallres.toolCalls[0];if(!call)returnres.content;constdeftools.find(tt.namecall.name)!;if(!def.requiresApproval){// read 类直接执行constoutawaitinvokeTool(call.name,call.args);messages.push({role:tool,content:out});continue;}constpending:PendingAction{tool:call.name,args:call.args,risk:def.risk};constokawaitaskHuman(pending);// 危险操作先等确认if(!ok){messages.push({role:tool,content:[已拒绝] 用户取消了${call.name}的执行});continue;}constoutawaitinvokeTool(call.name,call.args);messages.push({role:tool,content:out});}returnstep limit reached;}确认环节怎么落地都行真实项目里我推一条带确认 / 拒绝按钮的消息。给个最小实现destructive 我默认先拦掉write 放行你按自己业务调functionaskHuman(action:PendingAction):Promiseboolean{consttagaction.riskdestructive?【高危】:【写操作】;console.log(${tag}待确认:${action.tool}(${JSON.stringify(action.args)}));returnPromise.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 协议转载请注明出处。