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

相关推荐
Python私教1 小时前
前端转 AI 全栈:别只做聊天框,SSE 与审批流才是分水岭
前端·人工智能
机器人落地派1 小时前
机器人项目变更闭环检查表:别只写“已经改了”
人工智能·机器人·人形机器人·机器人落地·评审
倔强的石头_1 小时前
我把 22 篇 RAG 论文喂给了 RAG,让 AI 自己给我讲明白什么是 RAG
aigc
小园子的小菜1 小时前
从Prompt到Loop:AI工程化四层范式完全指南(Prompt/Context/Harness/Loop)
人工智能·prompt
canonical-entropy1 小时前
Mission Driver:Loop Engineering 的一种通用参考实现
大数据·人工智能·ai-agent·可逆计算·nop平台·harness
cn分享汇1 小时前
启锐H20照片打印机,随时实地轻松打印
人工智能
Python私教1 小时前
AI Agent 为什么总在最后一步失败?从启动异常到可恢复工作流
人工智能
钱多多_qdd1 小时前
Mac本地部署大模型,Ollama+Qwen3.5
ai·大语言模型
武子康1 小时前
生产环境的模型路由不是一次难度分类:从硬约束可行域到状态检查点升级
人工智能·llm·agent