Agent Skills敏感数据脱敏架构:从Web安全到AI安全的四层防线

📅 发布时间:2026/10/21 15:20:58
Agent Skills敏感数据脱敏架构:从Web安全到AI安全的四层防线
最近帮某公司搭客服工单Agent的时候我碰到了一件让整个项目组后背发凉的事一个只读工单查询Skill在联调阶段跑得好好的结果日志采集一开测试同事随手把一个真实客户的手机号和身份证号写进了对话里日志系统原样落盘链路追踪里也全量记录了。更麻烦的是这个Skill会把工单备注拉进上下文再交给大模型做摘要摘要把备注里的敏感信息又复述了一遍。那一刻我意识到Web开发者转型AI安全传统的输入校验、输出编码、日志脱敏三板斧在Agent场景下根本不顶用必须重新设计一套专门针对Agent Skills的敏感数据脱敏架构。这篇文章我就按自己的实操过程来讲从为什么Web那套防泄漏思路会失效到数据到底从哪些口子漏出去再到我最终落地的一套四层脱敏架构以及最折磨人的一致性脱敏设计。很多细节是踩坑踩出来的希望能给同样从Web后端转向AI安全的同学一条能直接参考的路径。1. 为什么Web开发者的传统防泄漏思路在Agent场景下会失效1.1 Web时代的三板斧为什么到Agent这里就断了做Web后端的人对数据防泄漏都有自己的一套惯性打法入口做参数校验业务层做权限控制出口统一封装响应日志框架里配好脱敏拦截器。这套组合拳在传统请求-响应模型里非常成熟核心思想是在数据跨信任边界的地方设卡。你对接的用户输入再脏只要在Controller入口和数据库访问层之间把该滤的滤掉、该转义的转义基本就稳了。但Agent智能代理应用的链路完全不是这个形态。一次对话任务里用户输入不是直接打到你的业务接口而是先进入大模型的上下文窗口。大模型通过ReAct循环推理行动自主决定调用哪些Agent Skills每个Skill拿到的参数可能是模型从用户话里抽取出来的也可能是上一个工具返回结果里继续加工出来的。数据在这个循环里被反复搬运、改写、拼装你根本没法用入口和出口这种线性模型去框它。我习惯用一个类比来解释这个差异Web应用的防泄漏像是给一条河流装闸门水从源头流向大海你在关键节点拦一下就行Agent的数据流更像一锅反复煮沸的汤料在同一个锅里被舀起来、倒回去、调味、再舀起来你光在锅边放个滤网是罩不住的。因为敏感数据一旦进了上下文它就可能以各种变形出现还可能作为某个工具调用的参数被带出去。1.2 真正棘手的三类泄漏场景我在设计和复盘这个架构时把Agent场景下的敏感数据泄漏归纳成了三类这三类基本涵盖了绝大多数翻车事故第一类是上下文递归污染。用户在一开始输入了手机号大模型为了保证后续任务连贯会把这个手机号原文牢牢攥在上下文里。接下来Agent要查订单、查物流、填售后表单这个手机号就被反复复制到各个Skill的入参里。更隐蔽的是即使第一层脱敏把手机号替换成了占位符模型也可能在后边的推理过程中靠语义猜测重新脑补出真实值——如果原始值残留在某次工具返回里的话。第二类是工具间拼接泄漏。一个Skill拿到的字段单独看一点不敏感比如一个纯数字ID、一个城市名、一个时间戳但两个Skill的数据拼在一起就能定位到一个具体的人。这类泄漏最阴险因为传统Web防泄漏只看单个请求字段的正则规则根本不会去拼装检测。第三类是模型复读机效应。很多AI安全培训材料都会强调大模型在生成摘要、回复邮件草稿时会倾向于原样复述输入里的电话号码、身份证片段这在客服Agent场景里极其常见。模型不知道自己该不该保留这些信息它只知道忠实回答。所以只在输入侧做脱敏是不够的输出侧必须再做一次校验。1.3 从拦水到改造水思维必须升维我之前带过的几个从Web转过来的同事最容易犯的错是照搬OWASP里的输入校验清单。他们会写一堆正则来匹配手机号、身份证在Agent入口处把匹配到的内容全部打码然后觉得任务完成了。结果一上线就发现两类新问题一是脱敏后的占位符被模型当成普通文本来润色变成了***导致下游Skill没法还原二是某些Skill本身目的就是查真实数据比如按账号查余额把真实参数脱敏了业务根本跑不通。这个教训让我想明白一件事Agent场景下的脱敏不能等价于隐藏它更像是对数据进行一次语义安全的翻译。你在系统内部维护一套可靠的映射关系让模型在脱敏数据上完成推理等真正需要执行外部操作时再还原成真实值——这就是后面要展开的一致性脱敏架构的核心思想。Web开发者的价值在于我们已经很习惯数据在哪里流动、哪里是信任边界这套思考方式缺的只是把线性管道换成循环系统的架构能力。2. Agent Skills在哪个环节会漏数据——先摸清敌情2.1 一次Skill调用里数据到底经过哪些位置动手写脱敏代码之前我先干了一件事把Agent的一次完整调用链路的每个环节掰开揉碎给每个可能出数据的地方编号。很多团队一上来就急着写脱敏函数结果漏掉了关键出口属于典型的敌情不明、火力全开。我以一个典型的工单详情查询Skill为例完整链路大致这么走的用户输入原始文本进入Agent编排层会话上下文记录原始内容。大模型对用户意图做解析从文本里抽取结构化参数比如工单号、客户手机号生成调用Skill的请求。Skill执行器收到请求做参数校验然后构造对外部业务系统的API调用这个调用包含URL路径、Query参数、请求头和请求体。外部系统返回业务JSONSkill执行器解析结果把关键字段回填到上下文交给大模型。大模型基于上下文生成回复回复内容输出到用户界面。全程的调用链日志、Token用量、输入输出快照被异步采集到日志平台。这六个环节里几乎每一个都是敏感数据的出口。但它们的泄漏风险并不一样对脱敏策略的要求也完全不同。2.2 六类数据出口的风险等级评估我把这六个环节按风险做了个分级表这也是我当时给团队做安全评审时用的底稿环节位置典型敏感数据泄漏风险脱敏策略复杂度用户原始输入进入上下文手机号、身份证、住址、情感化自述高源头污染中先识别再替换为结构化占位符模型抽取出的Skill入参账号、手机号、姓名极高参数直接打到外部系统高必须保证同值同占位符且必要时可还原外部系统API请求报文真实账号、鉴权头、业务字段极高跨系统泄漏高按接口契约区分必真字段与可脱敏字段外部系统返回的业务JSON数据库原始字段、关联用户信息极高模型会二次引用高需要递归遍历JSON做字段级脱敏模型生成的回复内容模型可能复述敏感原文中面向用户可见低输出侧做同规则二次匹配日志与链路追踪快照全部入参、返回body、中间态极高运维可视中必须延迟到日志侧顺序不能错这个表里最关键的一行是第三行外部系统API请求报文。因为很多业务场景下Agent必须用真实数据去调接口比如查余额时账号必须是真实账号这就意味着我们不可能在源头把所有数据都打码。这里要引入一个新的概念脱敏不是全有或全无而是要区分模型可见的上下文和外部系统可执行的真实参数。2.3 每个出口需要什么强度的脱敏根据出口的用途我把脱敏强度分成三档这是整个架构设计的基础第一档是不可逆脱敏强度用于日志、调试快照、数据分析场景。这些数据一旦离开系统就不该再有还原能力直接用单向散列或者随机化替换配上盐值防止碰撞猜测。第二档是条件可逆脱敏强度用于模型上下文与Skill参数回填。系统内部维护映射表模型在整个推理周期内看到的都是占位符占位符之间保持一致性。只有当某个Skill真正需要执行外部操作时执行器才通过映射表把必要字段还原成真实值。第三档是仅展示脱敏强度用于模型最终回复。这层要注意的是模型可能在回答里复述原始敏感数据也可能复述占位符需要有一套相对宽容的匹配规则把两种形态都识别出来再打码同时不能影响回复的正常阅读。这三个强度必须分开落地混在一起就会出现为了日志安全把业务接口也搞挂了或者为了业务可用让日志裸奔的问题。后面章节的四层架构其实就是把这三种强度用可配置的方式分布到链路不同位置上去了。3. 分层脱敏架构从输入清洗到日志收口的四道防线3.1 第一道防线输入侧预处理先分类再替换整个架构的第一层在Agent编排器的入口也就是用户消息进入上下文之前。这一层要完成的最核心任务是识别并分类敏感字段。识别用三种手段叠加正则表达式匹配常见格式手机号、身份证、银行卡、邮箱、开源PII识别框架做命名实体识别、自定义词典匹配内部业务标识符比如工号前缀数字。我提供的思路是用一个统一的预处理管道每个字段先做类型判断再按类型选择替换策略。这里给出一个核心的Python实现片段重点不在具体包名而在于识别-分类-替换-映射这条链路你应该自己掌控import re import hashlib from typing import Dict, Tuple # 注意这里只演示核心逻辑实际要引入PII识别框架与自定义词典做增强 PHONE_RE re.compile(r(?!\d)1[3-9]\d{9}(?!\d)) IDCARD_RE re.compile(r[1-9]\d{5}(?:18|19|20)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])\d{3}[\dXx]) EMAIL_RE re.compile(r[a-zA-Z0-9._%-][a-zA-Z0-9.-]\.[a-zA-Z]{2,}) class SensitiveMasker: def __init__(self, salt: str): self.salt salt self.mapping: Dict[str, str] {} self.counter 0 def _tokenize(self, matched: str, field_type: str) - str: # 同一敏感值在本次会话中映射到同一个占位符保证一致性 if matched in self.mapping: return self.mapping[matched] self.counter 1 raw_digest hashlib.sha256( (self.salt matched field_type).encode(utf-8) ).hexdigest()[:10] token f{field_type.upper()}_{raw_digest} self.mapping[matched] token return token def mask_text(self, text: str) - Tuple[str, Dict[str, str]]: # 按优先级替换手机号 邮箱 身份证避免子串冲突 masked EMAIL_RE.sub(lambda m: self._tokenize(m.group(), EMAIL), text) masked PHONE_RE.sub(lambda m: self._tokenize(m.group(), PHONE), masked) masked IDCARD_RE.sub(lambda m: self._tokenize(m.group(), IDCARD), masked) return masked, self.mapping这段代码里隐藏着一个非常容易踩坑的细节正则的替换顺序。身份证正则和手机号正则如果互换顺序当文本里同时出现一串相似的连续数字时先匹配的手机号逻辑可能吞掉身份证片段。所以我在实现里先做邮箱前缀最明确再做手机号最后做身份证。实际项目里你还会遇到地址、地名、发票号这类模糊实体必须靠词典和模型识别兜底不能只信正则。3.2 第二道防线工具调用侧参数级别的最小化脱敏如果说入口侧是大扫除工具调用侧就是精准打击。因为当大模型已经把参数抽取好准备调用Skill时这些参数是直接要去拼接外部请求的。这里我做了两件事第一件事是参数级二次校验。对模型抽取出来的结构化参数逐个跑一遍同样的识别规则。为什么要二次校验实战中发现大模型在路由到Skill时可能把上下文中残留的原始手机号直接塞进参数里即便入口侧已经替换过。一次典型的翻车是入口侧明明把138xxxx0001换成了PHONE_1但模型在Function Call参数里写的是从系统提示词里翻出来的另一段原始文本。这种模型自我考古的行为防不胜防只能在参数出口再堵一次。第二件事是必真字段与可脱敏字段的区分。我维护了一份Skill接入清单每个Skill声明哪些字段必须真实传输、哪些可以脱敏。比如查询余额这个Skill账号字段必须真实生成客户日报这个Skill客户姓名完全可以用占位符替代。这份清单相当于传统Web开发里的接口白名单只是对象从URL变成了Agent Skill的入参声明。3.3 第三道防线输出侧检查防止模型复读机模型生成回复之后很多人以为万事大吉直接就把文本推给前端了。我在实际测试中发现模型在复述用户原话时会带入敏感内容尤其是当用户问我刚才报的手机号是多少这种问题时模型会忠诚地从上下文里取出原文。第三层做的事就是在模型输出原文推到用户面前之前跑一遍和入口侧完全相同的匹配规则。注意一定要用同一套占位符表否则会出现一种很滑稽的情况模型原文里的PHONE_1被输出侧正则当成一种无意义标签而忽略用户就直接看到了带占位符的回复体验很差。正确的做法是先把已存在的占位符还原成展示用掩码格式再把识别到的原始敏感值按同一规则变成掩码。def mask_output(text: str, mapping: Dict[str, str]) - str: # 先处理占位符再处理遗漏的原始值 for raw_value, token in mapping.items(): text text.replace(token, mask_display(raw_value)) text PHONE_RE.sub(lambda m: mask_display(m.group()), text) text EMAIL_RE.sub(lambda m: mask_display(m.group()), text) return text def mask_display(value: str) - str: if len(value) 3: return * * len(value) return value[:3] * * (len(value) - 3)这层还有一个容易被忽略的细节模型可能不是原样复述而是换了一种格式比如把138xxxx0001报成138 xxxx 0001或者把身份证号分段念出来。输出侧检查需要加一层轻量的归一化逻辑把常见分隔符空格、横线、换行先去掉再做匹配否则等于漏防。3.4 第四道防线日志与链路追踪脱敏顺序错了全白搭日志这层是整个架构里最让我后怕的环节。很多Agent框架默认会做全链路Token统计把每次请求的输入输出快照都记下来这些快照里包含了最原始的上下文。如果你在日志采集中转层之前没做脱敏那前面三道防线全白做了因为运维、研发、数据分析师全都能在日志平台里看到原始敏感数据。第四层的关键不是怎么脱敏而是在哪里脱敏。有一个血泪教训是我在接入日志时让日志采集器直接监听消息总线而消息总线上的消息体在入口脱敏层之前就已经发布了。结果就是入口侧脱敏做得再漂亮日志里还是有原文。正确的架构顺序是消息体只有在经过入口脱敏、参数校验、输出校验这三层处理之后才允许被发布到日志总线。并且日志侧的脱敏策略必须使用最高强度的不可逆散列不能复用条件可逆的那张映射表——日志系统里面一旦存在可以还原的映射关系就等于给数据泄漏留了一扇后门。按我的落地经验这一步在架构评审时会被安全团队反复拷问你最好提前想清楚回答。4. 一致性脱敏让Agent在大模型和业务系统之间对得上话4.1 为什么不能一次性随机替换如果只是写一个打码函数这篇内容早该结束了。真正让我花了最多时间的是一致性这三个字。假设用户说完手机号之后Agent需要连续调用两个Skill第一个Skill用手机号查用户ID第二个Skill用用户ID查订单列表。如果脱敏模块对同一个手机号每次生成不一样的随机掩码那第一个Skill在映射表里记录的是PHONE_A第二个Skill拿到的是PHONE_B两边根本对不上业务直接断链。你会说那把映射表共享不就行了对但共享映射表也会出问题如果两个不同的原始值被映射到了同一个占位符模型就分不清谁是谁推理结果全乱套。所以一致性脱敏的第一原则是同一个原始值在整个会话生命周期内必须且只能映射到同一个占位符不同原始值之间占位符碰撞概率必须降到零。4.2 三种一致性映射方案怎么选我在设计时对比了三种方案各有适用场景第一种是单次会话内存映射表。适合短对话、Skills之间依赖较强的场景。优点是实现简单、查询快缺点是Agent重启或对话上下文切换后映射就丢了无法支撑跨会话场景比如用户第二天回来继续追问昨天的上下文。第二种是HMAC散列确定性映射。用HMAC-SHA256(盐 原始值)生成定长Token天然满足同值同Token、不可逆、无碰撞隐患。这是我推荐的方案。它的好处是不需要维护分布式映射存储任何脱敏节点只要拿到同一个盐就能算出和入口侧一致的Token缺点是Token是定长的不可读且无法还原。第三种是加密可逆映射本质是给敏感值做字段级加密密钥由密钥管理服务统一托管。适用于必须还原真实值的场景比如某个Agent内部需要真实调用支付接口。但加密值很长也很丑不适合直接作为模型上下文的占位符所以通常的做法是加密值和短Token同时存在模型上下文里放短Token执行器真正调API时才通过Token查短Token到密文的映射再解出真实值。我最终在项目里采用的是HMAC散列为主、加密映射为辅的混合方案。模型上下文和日志里是HMAC Token既保持一致性又不可逆只有极少数必须执行真实操作的Skill才走一张独立配置的Token-Vault映射表做临时解密。4.3 占位符格式不能随便定模型要看得懂脱敏后的占位符不仅仅是给系统内部看的Ticket它本身会进入大模型的输入模型要能从占位符中推断出字段语义。我用的是类型_哈希格式比如PHONE_abc123def4。这种做法有两个好处一个是语义清晰。模型看到PHONE_xxx知道这段内容代表一个被脱敏的手机号占位符在生成回答时不会把它当成乱码去修改。另一个是便于追踪。哈希前缀可以和映射表里的记录做关联调试时能快速定位到具体脱敏链路。不建议直接用***这种纯星号因为它没有任何语义模型在后续推理中大概率会补全或改写造成下游解析失败。这里给出HMAC方案的实现片段方便你直接抄import hmac import hashlib SALT byour-project-salt-change-in-prod def build_token(field_type: str, raw_value: str) - str: msg f{field_type}:{raw_value}.encode(utf-8) digest hmac.new(SALT, msg, hashlib.sha256).hexdigest()[:12] return f{field_type.upper()}_{digest}这个函数是纯函数不依赖任何状态所以无论部署多少个Skill执行节点任何一个节点都能对同一个手机号产生同一个Token。这也是我强烈建议用HMAC而不是用随机序列的原因——分布式环境下共享映射表的维护成本会高到你怀疑人生。4.4 还原的边界什么时候必须解密什么时候绝不能解密最后再聊聊一致性脱敏里最敏感的还原边界问题。脱敏是手段还原是能力能力意味着责任。我把还原分成了两类必须还原的场景某个Skill对外发起真实请求之前执行器通过Skill清单上的真实字段白名单把HMAC Token临时映射回真实值用真实值构造请求。这里的关键词是临时也就是说解密动作必须在内存里瞬间完成不允许落盘、不允许进日志、不允许进链路追踪。绝不能还原的场景日志系统、审计系统、调试快照。我在架构里强制做了一个隔离日志链路拿到的Token直接在HMac层就截断了还原能力Token-Vault服务不向日志系统开放任何读取接口。换句话说一个有日志平台权限的开发人员即便看到了所有Token也无法反查出对应的真实手机号。这个边界在答辩时一定要讲透。很多方案一上来就是全量可逆加密看起来很安全实际上等于把保险柜钥匙挂在保险柜旁边。真正可靠的架构必须按场景收窄还原能力宁可牺牲一点点调试便利也要确保敏感数据只在真正需要的地方存在真实形态。5. 从Web安全迁移过来的实操清单与踩坑记录5.1 能直接迁移的经验信任边界、数据分级与最小权限转型AI安全并不意味着Web安全经验作废。恰恰相反我觉得Web后端出身的人做Agent安全有天然优势因为我们早就习惯了信任边界的思考方式。在Web里浏览器是不可信端API网关是边界在Agent里大模型是一个能力很强但不可信的执行者Skill边界、上下文边界、日志边界都需要重新画。数据分类分级可以直接照搬。我给项目组定的规范很简单凡是能唯一定位到自然人的字段全部视为L3级包括手机号、身份证、银行卡、详细住址凡是能缩小范围但无法单独定位的字段视为L2级比如工单号、设备型号、城市名其余是L1级。脱敏策略的强度完全由分级决定L3级在日志里强制不可逆散列L2级在上下文中允许结构化泛化L1级正常透传。最小权限原则也直接能用。每个Skill接入时声明自己的数据需求能传ID的绝不传手机号能传手机号的绝不传身份证。我在一次架构评审里就抓出过一个反面案例某开发为了方便把客户完整通信档案整体塞进了Skill入参只是为了生成一句客户反馈已收到的自动回复。这种大而全的传输习惯在Agent场景里是灾难因为模型可能为了一个简单任务在内部反复检索所有字段。5.2 必须重新学的模型行为不可控脱敏链路的顺序就是生命线转型过程中最需要重新建立的一个认知是大模型的行为不是确定性的。传统Web安全里一个过滤器只要写好正则和逻辑它的输出是可预测的但Agent里同一个输入模型可能这次正常抽取参数下次就给你整出个意外格式。这意味着脱敏链路必须要能容忍模型的自由发挥。我在测试中就踩过一个典型坑入口侧把手机号换成了PHONE_a1b2c3d4e5但模型在做Function Call参数填充时把占位符直接当作普通文本传给了SkillSkill没做还原就去查数据库查了个寂寞。后来我改了策略Skill执行器在调用外部系统之前只要发现参数是Token格式就先去Token-Vault做一次强制还原。这样模型传错格式不要紧执行器兜底修正链路依然能跑通。另一个要重新学的点是脱敏链路的顺序就是生命线。我前面说过日志采集器的顺序问题这里再补一个输入侧脱敏和输出侧脱敏必须共享同一套规则和映射表但两者的执行时机绝对不能耦合。有人在入口脱敏成功后顺手把映射表清空了结果模型输出侧想还原占位符时找不到映射直接给用户展示了PHONE_xxx这种鬼东西。这种低级错误藏得很深一个简单的并发请求就能触发建议在集成测试里专门覆盖入口脱敏-长对话-输出展示的完整流程。5.3 实测中反复踩过的四个坑具体踩坑的记录我整理成了一张清单每条都是我或队友用线上教训换来的坑根因最终处理方式正则把京州路10号中的10关联成了手机号片段正则边界没写严谨缺少对完整连续号码的要求引入词边界断言并用PII识别框架做二次确认模型把PHONE_a1b2改写成PHONE 123模型对占位符格式做了润色强制在系统提示词里声明占位符是不可修改的保留字段外部API返回的JSON嵌套深层仍有原始手机号只对顶层字段做了脱敏递归遍历遗漏输出侧改用递归JSON遍历逐层匹配脱敏日志平台能看到脱敏前快照日志采集器接的是脱敏前的消息总线把日志采集点移到所有脱敏层之后并加广播开关第一条和第三条是纯技术细节第二和第四条则涉及Agent链路的架构顺序。细看你会发现这四个坑如果只靠写代码时的注意根本防不住必须靠端到端的自检清单在发布前逐项过一遍。我后来把这套清单固化成了自动化集成测试的一部分每次Agent有新的Skill上线就跑一遍全链路泄漏扫描。5.4 分级上线的建议与灰度策略把这个架构第一次落地到真实业务时我不建议直接铺到所有Agent Skills上。合理的做法是分级灰度先挑一个只读类、无真实写操作、敏感数据密度低的Skill试点跑通全链路脱敏和日志隔离之后再逐步扩展到写操作型Skill。写操作型Skill的脱敏还原链路要比只读型复杂得多因为还原时效性要求高还需要考虑幂等和审计。我当时的试点选择是工单查询Skill因为它的特征是参数单一、返回字段固定、不触发外部写操作。试点期间单独开一台Agent实例出口流量标注灰度标签日志单独脱敏入仓。两周跑下来专门盯三类指标脱敏耗时的P99、占位符还原失败率、日志扫描的敏感值命中率。这个阶段能暴露至少一半的架构问题。等到写操作型Skill接入时还有一个建议所有真实值还原动作必须加审计事件。因为一旦Skill执行器具备还原能力就意味着内部人员理论上可以通过伪造Token请求还原出真实数据所以每一次还原都要记录操作者身份、Skill名称、时间戳和还原字段类型。这段审计日志本身不允许存储真实值只存谁在什么时候对什么Token做过还原请求。最后再说几句掏心窝的话如果你也是从Web后端转AI安全我最大的建议是别把AI安全想得太玄它只是把信任边界换了个更复杂的形状。Web时代你防的是用户输入到服务器之间这条线AI时代你要防的是用户在对话、模型在推理、工具在执行这三方之间不断循环的那张网。你现在掌握的输入校验、权限最小化、数据分级、日志隔离这些基本功到了Agent场景全部用得上只是需要给它们换一套编排载体。我自己踩过的最重要的一课是脱敏方案在架构图里再完美也不如把它跑在一个真实Skill上然后把日志打开拿真实数据的影子样本扫一遍。你会发现那些预想不到的泄漏出口往往不是模型太聪明而是你的链路顺序和边界划分在某一个节点上偷了懒。早点跑通一条完整链路比憋一个大而全的安全平台有意义得多。