☰
AI Agent安全:工具调用如何真正“刹得住车”?
2026/10/3 15:35:06 网站建设 项目流程

1. 一次实打实的“刹车无响应”事故

1.1 从“查一下”到“已经查完”只隔了一个回车

我在维护一个用来做公开信息聚合的检索Agent,它要定期从各类官方网站抓取公告、名单、目录,然后整理成结构化表格。我们内部叫它“信息助理”,主要跑的是完整链路:Query理解、工具调用、网页抓取、解析、写回样例库。说白了,这就是个上了真实网络、但暂时没有接生产数据的试验品。

那天我想测试一套新的提示词流程,输完最后一段:“把某个机构官网上的最新公示名单取下来,按表格整理出来”,点击运行。跑到第三步时我瞄了一眼日志,发现传给查询工具的ID参数里带了个多余空格,按这个参数肯定查不到正确结果。我下意识敲了终止键,嘴里喊了一句:刹车!

Agent的反应速度比我手指快得多。鼠标点上“停止生成”那一刻,卡片上其实已经发出了工具调用。服务端日志显示:GET请求出去了,状态码200,页面抓完了,解析器甚至已经按关键词筛出了十来条记录。参数错了,但请求没拦住,页面该缓存缓存,该解析解析,一点没耽误。

标题里那句“喊完刹车,AI已经溜出去查政府网站了”,在我这里就是日志截图上的真实台词。注意,这不是模型输出“偏了”这么简单。终止键只停止了大模型继续吐字,可工具调用一旦离开进程边界,就变成了外部世界里的真实事务,你这边喊停,那边该发生的照常发生。尤其是当目标站点是那种“你查询一下”就默认返回大量公开数据的网站——包括政务公开、公示公告这类入口时,一次操作引起的连锁反应远比一句“之前忘了删空格”要严重。

1.2 事故扩散开来是什么样

有人觉得,抓个网页而已,又不是下单发货,至于吗?单独一次,确实不至于。我那次事故的副作用也就是多了一个缓存文件,清掉就好。可麻烦就麻烦在Agent的工作方式不是单点操作,而是拆成一长串连续动作:

  • 工具调用不会只来一次。一个检索任务往往是“搜索关键词→打开候选页面→翻页→解析表格→再把这些中间结果喂给下一轮LLM”。只要第一轮请求没有被拦截,后面整条链路都会基于错误数据继续跑。
  • 多从属很容易放大错误。如果这个Agent跑在多Agent协作架构里,前面有编排者,后面有执行者,下游还会根据上游产出去触发更多动作。一次越权访问会像扔进池子里的石子,波纹传导到整个协作网络。
  • 被访问的网站那边可不在乎你是Agent还是人。请求进站就会打日志、占带宽,敏感一点还会触发风控和封IP。你把访问频率和并发提上去,对方管理员看到的就不是“一个AI在查资料”,而是一次不大不小的异常流量。

所以在Agent工程实践里,工具调用永远不等于纯文本生成。它不是模型吐字吐歪了那种可羞可改的问题,而是需要当作“进入真实系统的动作”来管理。你要给AI装刹车,不能只在聊天窗口装一个按钮,要在调度链路的每一层门上都装一个制动器。

2. 刹车为什么拦不住:Agent执行链路上的三块“盲区”

2.1 “停止生成”只停在了文本这一层

这里把Agent内部的运行模型讲透。绝大多数LLM Agent的核心循环长这样:

  1. 用户把请求变成上下文;
  2. 模型根据上下文生成回复,回复里同时包含普通文本和结构化的工具调用;
  3. 运行时解析出工具调用,交给执行器;
  4. 执行器拿到外部系统返回的结果,再作为新消息回填给模型;
  5. 模型基于新消息继续生成下一轮,直到满足停止条件。

用户点“停止”时,标准前端做的是中断步骤2的流式输出,或者给步骤5发取消标记。看起来整个生成过程停了,可执行器在步骤3里发出去的请求早就过了网络边界。更要命的是,执行器的线程能不能感知到取消标记,完全取决于你有没有在代码里做好配合协作的检查点。很多Agent开发者习惯在工具函数里做阻塞式网络请求,或者干脆sleep几秒等外部接口返回,中断信号来了也不一定能立刻打断。

这不是技术上的“残缺”,而是不同职责被封装在一起后天然产生的缝隙。思考环和控制环是两个物种:前者会听“停”,后者更忠于“已经在跑”。你让大脑刹车,脚已经踩下油门了,物理上就停不下来。

2.2 客观竞态:用户和Agent在抢同一秒

事故还有一个更隐形的层面——竞态。Agent在执行计划时,和用户并不是严格同步的。用户在界面上看到“正在调用xx查询工具”,那已经是服务端把状态推出来的结果,现实里请求可能已经完整往返了一次。信息从网络传到屏幕上需要时间,人类读完这句话再决定“要停”也需要时间。两个时间差叠在一起,你说“停”的时候,系统时钟往往已经往前跑了小半秒。

我们后来专门在QA环境里给Agent加了访问日志时间戳,对比“用户点击终止”和“外部请求返回”两个事件。结果好几次都是:请求完成比用户点击终止早了600到800毫秒。也就是说,在用户的认知里“刚喊出口”,在系统里“事情已经办完”。这就像短跑比赛里,你听到发令枪响才起身,但别人在枪响之前已经跑出去了。刹车指令发出的时候,车已经在路上,这不是刹车失灵,是刹车踩晚了。

2.3 “只读操作”不等于“零副作用”

再来聊一个工程上很容易被忽略的点:把类型标注为GET的网页抓取当成“只读操作”。开发的时候直接写requests.get、fetch的人太多了,总觉得GET是安全的,不修改服务端资源就不会有问题。

但从自动化系统的角度看,“安全方法”只是HTTP协议语义上不修改服务端状态,并不代表“网络上没有副作用”:

  • 一次GET会给目标站点留下访问日志和来源IP;
  • 会触发服务端埋点、统计、通知、动态渲染任务;
  • 会占用第三方带宽,有可能违反站点robots.txt或服务条款;
  • 如果页面里带CF清洗、cookie校验,你的一次访问还可能提前消耗掉会话状态。

所以设计审批策略时,不能只看工具名里是read还是write,要看它作用的领域边界:本地内存是安全区,测试样例库是受控区,真实第三方网站是风险区。对风险区里的“读”,一样要有克制力,一样要能随时叫停。

3. 给Agent装上真正能停住的“三级刹车”

吹完事故,直接上解决方案。我们改造的目标不是“所有操作都要人工确认”,那样Agent就彻底失去了自动化的价值。目标是把动作按副作用分级,让低风险动作继续自动通过,高风险动作强制过一道人工闸门,同时保证取消信号能真正穿透到执行链路的内部。

3.1 第一级刹车:给所有工具打上副作用标记

我们在动作调度层加了一个审计器,内部维护一张副作用分级表。工具注册的时候,开发者必须标明自己的副作用等级,没登记的默认拒绝执行,宁可不干活也不能出去乱跑。

等级典型工具默认策略
S0本地缓存读取、向量库查询、纯计算自动执行
S1对受控环境的外部只读请求(测试站、mock、内网文档)自动执行并全量记日志
S2对真实第三方站点的访问、抓取(公开网页、公示平台)进入人工审批队列
S3提交表单、发送通知、购买、部署、写生产库人工审批+双人确认+完整审计

这表看着简单,却是整个改造里最花时间的地方。因为老代码里很多工具注释只写了“查询”,概念上差不多,真实作用域完全不一样。“查询企业名”可能是查本地向量库,也可能是去公开公示网站抓。改的时候不能按函数名分类,必须按“工具最终会碰什么资源”分类。所以后来每个工具登记时都强制附一段边界声明:可能触达的网络域、数据敏感度、是否可能诱发后续动作、请求频率上限。这个信息同时会进审批队列,作为用户判断“要不要放行”的依据。

3.2 第二级刹车:在任务编排层引入门控状态机

工具标记只能解决“能不能自动”,还没解决“喊停后怎么停下”。我们给每个Agent任务加了一个轻量状态机:

pending -> approved -> running -> done pending -> cancelled running -> paused -> resumed -> running running -> cancelled

所有S2及以上请求在进入执行器之前,必须先挂在pending状态。挂在pending的请求会展示给用户一张审批卡,卡上包含目标地址、访问方式、URL参数预览、预计影响。用户可以选择批准、拒绝、延迟。延迟等于让Agent先干别的,这个请求整体挂起,不占执行线程。

这里最关键的一点是,门控不能只设在“工具执行前”那一瞬间,还要设在“生成计划之后、执行工具之前”的整个段落。也就是说,当Agent输出一长串行动计划而不是单次动作时,调度器先整体看看计划里有没有高危调用。有的话,就把涉及的调用单独拆出来,先求确认再往下走。这相当于在“计划”和“执行”之间加了一道调节阀,而阀门后才是状态机控制室。

我们给调度器写了一个简单的守卫逻辑,核心思路是这样的:日常查询任务秒回不打扰;当检测到计划里出现副作用等级大于等于S2的动作时,立刻从链式执行切换到“先列影响清单、再请求确认”的执行模式。这个模式切换在代码里就是一行分支判断,但对使用者体感而言,是从“一个闷头往前冲的Agent”变成了“一个知道分寸的合作伙伴”。

3.3 第三级刹车:幂等、补偿和可撤销

刹车之后,车已经溜出去的情况依然防不胜防。这时就需要三种补救设计,它们和前面的闸门配合,形成最后一道防线。

第一,让动作尽可能幂等。S2级别里像网页抓取这类的动作,天然幂等,抓两次结果差不多,可以随便重试。S3级别要用幂等令牌,比如提交同一份报告时带上request_id,目标服务端收到相同ID直接忽略,避免重复下单、重复发消息、重复写库。

第二,准备补偿操作。比如Agent发出了一封通知邮件,补偿方案就是撤销信或者自动再发一封更正;它写错了测试数据库,补偿方案就是导回原快照。补偿动作本身也是个工具,也要挂到审批链上,不然就会出现“Agent自动把错误覆盖成另一个错误”的连环事故。

第三,要支持取消令牌的传播。我们在实现里做了一个cancel token,除了在进程内各协作线程间传递,还会持久化到分布式队列。当用户在UI上按终止,调度器除了把任务标记成cancelled,还会去查这个任务名下所有正在执行的对外请求。只要请求里提前带了traceId,就能区分哪些是“正准备出去的”、哪些已经到第三方站点了。对于前者,直接丢弃;对于后者,尽量保存现场,不让它继续往下游触发更多动作。那些底层框架里叫interrupt、checkpoint、conductor的东西,本质都是一回事:让用户中断穿透抽象层,而不是只停留在界面上。

4. 改造实录:一个可审可撤销的信息检索Agent

这一节放的是项目落到地上的记录。项目背景是多Agent协作环境里的一个信息检索Agent(带RAG),用来辅助整理公开信息,要求能自动抓取,也必须保证每一次对外请求都可追溯、可中止。

4.1 架构与开发顺序

系统拆成三个模块:Orchestrator(编排器)、Dispatcher(派发器)、ApprovalService(审批服务)。Orchestrator还是原来那套LLM循环,负责理解任务、产出调用计划。Dispatcher不再直接执行工具,而是接收编排器发来的“待执行动作列表”,先去副作用表查等级,需要审批的就提交给ApprovalService,由它把审批卡推到前端去等用户反馈。

为了做到“人喊停时能停正在跑的动作”,我们把动作放进线程池执行,并在每个执行函数里加了协作信号。这里的关键是:停止信号必须传进handler内部,让它在读网络流的每个chunk时都检查一次。不这样设计,网络请求会死不回头地把整个响应读完,然后才想起原来自己早就该停了。

4.2 编排器里的“先审后跑”

编排器增加了一个判断环节,核心流程用Python伪代码表示大概是这样的:

while not task.finished: plan = planner.generate_next_actions(task) high_risk = [act for act in plan if severity_of(act) >= 2] if high_risk: agreed = await approval_gate(high_risk) if not agreed: task.cancel() break for act in plan: result = await dispatcher.dispatch(act, task.cancel_token) if task.cancel_token.is_set(): return partial_result(task) task.memory.append(result)

两个细节值得展开说。其一,approval_gate不是简单发一个“同意/拒绝”按钮就完事,它把影响渲染成一个侧面面板:要访问的URL、代理环境、抓取深度、是否跟随重定向、预计耗时、甚至目标站点的robots约束,全摆上去。用户看到的是一个能辅助判断的三维视图,而不是被逼在信息不足的情况下做决定。

其二,取消令牌以任务为单位,从planner生成第一个动作到最后一个工具执行完毕,全程跟着跑。外层一旦发现cancel_token被置位,就不允许再发起任何新的外部请求。旧请求仍在栈里的,由handler的协作退出点自己决定在那结束。两者配合,才算是真正把刹车装到了执行链路的心脏位置。

4.3 回归验证:故障注入与中断实验

针对这次改造,我连续做了三种回归测试:

  • 普通检索不被打断。S2请求进入审批队列,用户五秒内批准,Agent顺利完成,总延迟从原来“无审批”的3秒涨到“拉起人批”的8秒。这个增幅对检索类任务完全可接受。
  • 用户拒绝高风险动作。Agent经历拒绝事件后不会原地崩溃,而是把“用户拒绝了什么、原因大概是什么”写进memory。下一轮生成计划时它会自动避开那个方向,而不是执着地反复试“同一条死路”。
  • 极速中断。启动一个抓取两千个分页的S2任务,跑到第四个分页时模拟终止信号。日志显示当前分页请求在收到cancel信号后最多用了400毫秒退出,外部没有残留请求,已抓下来的部分结果按照“可丢弃”标记做了清理。

确切的数字不重要,重要的是行为模式:Agent可以在不牺牲自动化的前提下逐步收敛风险,而不是靠把网络访问权整个关掉来换取安全。

4.4 审批卡之后的后处理链路

审批通过只是开始,后面还有一堆细节。我们自己实现的ApprovalService在后端会给每个审批动作生成唯一的action_id,并把审批结果回填给LLM上下文。这样做有一个好处:Agent知道自己哪一个具体动作被批准了,哪一个是拒绝的,不会把上一次的批准误当成全场通行证。

审批卡上还带一个“来源类型”字段,比如“政府网站公开页”“行业协会名录”“企业官网公告”,来源类型不同,被允许的抓取频率也不同。普通公告页我们限制一分钟内不超过三次请求;大型公开数据页希望拆成多个子任务分批抓取,而不是单个Action里一次性并发几十个连接。这里所有限制参数都记录在审计表里,出了事能直接定位到是哪一次调用、哪个参数、哪条审批链。

5. 事故背后的通用启示:Agent安全不是加一个if这么简单

5.1 别把“同意”设计成一次性许可

改造过程中我犯过一个印象深刻的错误:用户批准了一个动作后,Agent把同样的带参数动作批量执行了十几次。因为审批只做了“动作级别”的判断,没做“会话级别”和“模式级别”的管控。批准一次翻页没问题,可Agent把整个分页遍历都当成一次动作了,本质上就是越权。

后来我们把审批粒度拆成三种:单次动作批准、动作模板批准、任务级批准。单次批准默认只对当前action_id有效;模板批准允许Agent在同一个任务中按相同参数模式的请求自动通过;任务级批准要求人类给整个计划背书。粒度越多,出事概率越低,操作者心理负担也越重。最终选了模板批准作为默认,因为它和“查某一系列页面然后汇总”这种最常见的检索任务自然对齐。

5.2 对外部服务的边界保持敬畏

Agent要去访问别人的服务,就要接受别人的约束。robots.txt不是摆设,站点限流不是刁难。我们再把半自动抓取落到“公开网页”这个场景时,把并发调低到了三以下,同时在小流量延迟,既保证Agent跑得通,也避免触发风控。这些措施已经不属于控制链路里的东西,但它们是Agent跟外部世界打交道时该有的基本礼貌。

另一条让我记了很久的经验是:公开数据不等于随意数据。哪怕一个网页不需要登录就能看,也不代表我们可以批量抓走、存进自己的知识库。尤其当页面里包含个人姓名、联系方式、证件编号这类信息时,抓取和使用都可能超出预期边界。我们在信息检索Agent的审批卡里,专门加了“是否进入RAG知识库”和“是否包含个人敏感字段”两个开关。点批准的人看得清清楚楚,不是闭眼放行。

5.3 多Agent协作时的刹车传播

我们项目里真正让人头皮发麻的问题,耦是“多Agent协作”场景下的中断。主Agent拆出子任务给副Agent,副Agent接到任务后去执行外部访问。如果用户在主Agent界面按了停止,子Agent那边完全不知道,可能还在继续抓取。这不是代码没写对,而是取消令牌没有跨进程、跨Agent传递。

我们的方案是给每个子任务下发时带上parent_cancel_token,子Agent的内部循环每执行一个工具前都检查一下令牌状态。父级任务一旦被取消,子Agent会收到信号并决定是否回滚已拿到部分结果。这套逻辑不一定适合所有框架,但给我提了个醒:在“多AI协作”的架构里,所有Agent共享同一个“刹车通行证”,比给每个Agent单独装刹车重要得多。

5.4 给Agent工程实践的一份自查清单

最后整理一份适合大多数Agent团队的自查清单,贴合这次案例再絮叨一遍:想让AI跑得比别人快,先让它听得见刹车。

  • 每个工具是否登记了副作用等级?
  • 审批队列是否按动作粒度和会话粒度拆开了?
  • 终止信号能不能传到执行器慢操作内部(网络流读取、轮询循环、worker线程)?
  • 补偿动作是不是也走了审批流程?
  • 外部请求日志是否包含task_id、action_id、trace_id,出了乌龙能不能在一分钟内复原全链路?
  • 取消令牌是否跨了多Agent边界?子Agent是否继承了上级的撤销状态?
  • 是否处理过“用户已批准但任务已经完成”的竞态,避免用户批准了一个早该取消的动作?

按这张单子过一遍,即使不能让Agent完全杜绝“溜出去”,你至少能把这种事故从不可控的“惊吓”变成一条带完整审计记录的“插曲”。Agent跑得快是本事,但让你喊停时它真能停下,才是真正能上线的本钱。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询