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

你有没有过这种体验,把一个刚写完的 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 协议,转载请注明出处。

相关推荐
Fluxart.ai4 分钟前
Etsy手工制品换背景,用什么AI能保留手作质感?
前端·javascript·人工智能
xiezhr6 分钟前
开源两天 9.5 万 Star!DeepSeek Harness 到底是个啥?小白安装到实战一篇讲透
ai·github·ai agent·deepseek·deepseek harness
蓝速科技10 分钟前
蓝速科技会议预约屏深度评测:为何安卓系统是智慧办公长期最优解
运维·人工智能·科技
tachibana210 分钟前
怎么让大模型同时给意图节点打分
大数据·人工智能·ai·大模型·llm·prompt
爱学堂IT分享11 分钟前
AI自动化大师课:从零构建企业级工作流程
大数据·人工智能·自动化
她说可以呀15 分钟前
Spring AI 常用 Advisor
人工智能·python·spring
cxr82817 分钟前
HyperMind Lab M1 架构地基 Implementation Plan <二>
开发语言·人工智能·架构
长三角活动观察19 分钟前
苏州独石传媒项目SOP拆解:从苏州智博会到出海大会,千人级活动的流程管控方法论
大数据·人工智能·传媒
sali-tec24 分钟前
C# 基于OpenCv的视觉工作流-章103-空车位识别
人工智能·opencv·计算机视觉
迷迭香yy29 分钟前
大宗交易折溢价因子怎么挖掘本地化Python全流程实战
开发语言·人工智能·python