我弃了“全自主 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 协议,转载请注明出处。

相关推荐
m0_739312872 分钟前
【自动驾驶/机器人控制】之坐标系&运动学仿真代码工程分析(一)
人工智能·机器人·自动驾驶
桃西西呀4 分钟前
用 AI 写得更快,上线却容易炸?拆解 AI 编码生产力悖论的 5 个机制,附 9 个坑的自检清单
人工智能·llm·ai编程
derekwang856 分钟前
驾驭 AI · AI Harness Engineering · 契约层设计
人工智能·ai编程
月华路6 分钟前
《模型不玄学》第14章 标签、损失与样本权重
人工智能·算法·机器学习
DPS普轩·真空灌胶机专家14 分钟前
普轩科技亮相第四届西安军工展 | 真空灌胶专家,展位 2-003
大数据·人工智能·科技·机器人·pcb工艺
台风护盾15 分钟前
视频怎么自动翻译成中文字幕?AI 视频翻译的三道坎和一条捷径
人工智能·机器翻译·音频处理·ai工具·视频翻译
@嵌入式扫地僧16 分钟前
嵌入式环境下麦克风阵列远场语音降噪的快速实现
人工智能·语音识别·嵌入式语音降噪·麦克风阵列·ai 降噪轻量化
行者全栈架构师17 分钟前
WorkBuddy 实战:把一份 200 页年报变成可上台的投资分析 PPT
人工智能·算法·全栈
ReleaseU18 分钟前
数据库 MCP:让 Agent 自己查数据、写报表
人工智能·大模型
deepseek2320 分钟前
Tenable联合OpenAI做AI Inspector:第三方Agent、Skill与MCP组件如何过供应链验收
人工智能·ai agent·mcp