ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

大模型应用开发中的敏感数据脱敏:Agent Skills架构设计实战

大模型应用开发中的敏感数据脱敏:Agent Skills架构设计实战

干过几年Web后端的人,转去做大模型应用开发,最先撞上的墙往往不是什么模型调优、Prompt技巧,而是数据安全。我之前接手过一个客服工单智能助手的需求,说白了就是让 Agent 去读工单数据库,然后自动帮客户查进度、解答疑问。刚开始觉得这事儿不难,查询结果拼进 Prompt 给大模型就完事了。结果联调的时候翻车了——大模型在回复里把客户的完整手机号、家庭住址一字不差地复述了出来。那一刻我才意识到,过去我们在 Web 应用里攒下的那套数据防护经验,放在 Agent Skills 面前几乎全部失效。

这篇文章就想聊聊我做 Agent Skills 敏感数据脱敏架构设计的全过程。从 Web 开发者的视角出发,讲清楚 Agent 场景下数据链路到底发生了什么变化、敏感数据暴露在哪几个环节、脱敏架构怎么分层落地,以及我在这个过程中踩过的坑和最终的排查经验。如果你也是从 Web 开发转向 AI 应用开发,正在做 Agent、做 Skill 封装、做 RAG 这类方向,这篇文章应该对你有用。

1. 转型视角下的Agent数据链路,和我们熟悉的Web链路到底差在哪

1.1 Web开发者心里那套“祖传”的数据安全打法

老一代 Web 后端对数据安全的直觉,基本围绕“接口鉴权、字段过滤、前端展示控制”这几板斧。比如查询客户信息,后端接口严格校验登录态,返回 JSON 之前把敏感字段摘掉;前端表格里手机号中间四位打星号;数据库查询全部参数化,防注入。这套逻辑成立的前提是:数据从后端到前端是一条单向、短命的传输链路。请求来了,后端查库,把该给的字段给出去,响应结束,这条数据路径就断开了。你只要保证接口不进黑名单字段、前端不显示明文字段,问题基本就控制住了。

到了 Agent 场景,这个前提直接崩了。Agent 不是简单地“请求—响应”,而是会把外部数据取回来之后放进上下文(Context)里,供模型推理、多轮对话、工具调用反复使用。数据在推理链路中的生命周期被拉长了好几倍,而模型必须真正“看懂”这些数据才能干活。这就意味着你不能简单地把敏感字段藏起来——藏起来模型就看不懂了。

1.2 Agent Skills把数据链路拉长成了什么样子

先解释一下 Agent Skills 是什么。可以把 Skill 理解成 Agent 的一项专项能力封装:一个 Skill 内部通常包含技能描述、参数定义、对应的工具调用逻辑(比如查数据库、调外部 API),以及返回数据的处理方式。Agent 会根据用户意图自主决定调用哪个 Skill,然后把 Skill 返回的内容拼进上下文继续推理。

问题就出在这个“拼进上下文”。我在做客服工单助手时,Skill 返回的数据结构大概是这样的:

{ "ticket_id": "TK20250113002", "customer_name": "张三", "customer_phone": "13812345678", "customer_address": "某某市某某区某某街道88号", "issue_desc": "客户反馈订单未按时送达,多次联系客服未果", "handler": "李四" }

在 Web 时代,这段 JSON 返回给前端之前,我可以直接把customer_phone摘掉。前端不需要手机号,页面只显示工单标题和状态就行。但在 Agent 场景里,模型需要根据工单内容去判断“这个客户是不是在问物流问题”“要不要给他升级处理”,甚至需要核对“这个手机号对应的客户是不是本人”。你如果直接把手机号码干掉,模型碰上复杂一点的对话就懵了。

更麻烦的是上下文滞留。Agent 的对话是多轮的,客户先说“我要查订单”,然后又提供了自己的手机号,模型把手机号记在上下文里。哪怕你 Skill 返回的数据做了脱敏,只要对话历史里曾经出现过明文,后续某轮模型就可能把明文"回忆"出来。

1.3 敏感,需要重新定义

Web 时代对“敏感数据”的理解基本是静态的:身份证、手机号、银行卡、住址,这些字段打上标签,统一加密脱敏。到了 Agent 场景,敏感数据的口径必须做动态扩展。因为模型可以结合多条信息推断出敏感结论。

举个例子:工单里没有客户的完整姓名,只有“张先生”三个字,加上地址“某某小区 12 栋”,再结合客户自述“我住 12 栋 3 楼”,模型可能直接推算出门牌号。这个时候,单纯对字段脱敏是不够的,你得有一套内容级识别和分级策略,让模型在推理过程中用到的数据“够用但不过分”。

提示:转型 AI 应用开发后,请把“敏感数据”从字段清单升级为“识别—分级—改写—审计”一条链路的架构设计。这不是过度设计,是 Agent 推理特性决定的底线要求。

2. 设计脱敏架构前,先盯住Agent Skills的四个暴露点

脱敏架构设计的第一步,不是写代码,而是把 Agent 调用 Skill 时数据可能经过的所有路径盘一遍。我梳理下来,Agent Skills 的敏感数据暴露点主要有四个。

2.1 暴露点一:入站参数

Agent 调用 Skill 时,需要在参数里带上用户信息。客户说“帮我查一下我 13812345678 这个号码的订单”,Agent 就会把手机号作为入参传给查询 Skill。如果 Skill 内部直接拿这个参数查库,那么明文手机号至少会出现在:

  • Agent 的对话上下文里;
  • Skill 的请求日志里;
  • 底层数据库查询日志里。

这个环节一般容易被忽略,因为大家关注点都在“返回数据的脱敏”,但入站参数同样会泄露敏感信息。

2.2 暴露点二:上下文滞留

这是 Agent 场景和 Web 场景最大的差异。Web 后端处理完请求,响应结束,数据生命周期就结束了。但 Agent 的上下文是多轮累积的,明文数据一旦进入上下文,除非你主动清理,否则它会一直待在那儿。

我在测试中发现,模型在第三轮对话时复述了第一轮出现过的完整身份证号,而整个过程中我并没有把身份证号当作任何 Skill 的返回值。原因就是第一轮用户在咨询时自己发了一段文字,里面有身份证号,模型把它存进了上下文,后面又被“调用”了出来。

2.3 暴露点三:第三方工具返回结果

Agent 通常不会只调自己的 Skill,还会调用搜索工具、天气工具、地图工具,甚至外部知识库。这类第三方返回的内容不受你控制,里面很可能夹带个人隐私。比如搜索结果里出现某用户的社交账号主页,里面就有手机号;地图工具返回某个地址详情,附带联系人电话。这些数据进入上下文后,同样面临泄漏风险。

2.4 暴露点四:模型出站复述

模型有一种让人头疼的能力,就是一字不差地复述输入中的字符串。明明 Skill 只需要返回“密码重置链接已发送”,但模型在给用户解释时,可能会把短信验证码、银行卡号、家庭住址这些原文完整说出来。这个暴露点可以说是整个链路里最容易被忽略但后果最直接的。

针对这四个暴露点,我最终把脱敏架构拆成了四层:识别层、策略层、改写层、审计层。每一层解决一个特定方向的问题,下面详细展开。

3. 分层脱敏架构落地:识别、分级、改写、审计

3.1 第一层:敏感数据识别器

脱敏的前提是知道哪些内容是敏感的。Web 时代常用的做法是配置黑名单字段,Agent 场景下这个粒度远远不够。我的方案是"多渠道识别":

  • 规则识别:用正则识别手机号、身份证号、银行卡号、邮箱等模式固定的数据;
  • 实体识别:用命名实体识别(NER)模型识别姓名、地址、组织名等无固定格式的实体;
  • 字段标注:对于自研 Skill 的返回结果,在字段定义阶段就给敏感字段打标签。

代码层面,一个轻量识别器的做法大概是这样的:

import re SENSITIVE_PATTERNS = { "phone": r"\b1[3-9]\d{9}\b", "id_card": r"\b\d{17}[\dXx]\b", "bank_card": r"\b\d{16,19}\b", "email": r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b", } def scan_sensitive(text: str): hits = [] for name, pattern in SENSITIVE_PATTERNS.items(): for match in re.finditer(pattern, text): hits.append({ "type": name, "value": match.group(), "start": match.start(), "end": match.end(), }) return hits

在实际项目里,我建议不要只依赖正则。因为客户地址这种信息格式极其自由,“某某市某某区某某路 88 号 3 栋 201”这种长串,正则写不出来。我当时接了一个 NER 服务,专门识别PER(人名)、LOC(地点)、ORG(机构名),识别精度八九成,配合规则识别基本够用。

3.2 第二层:分级与策略引擎

识别出敏感内容之后,不能一刀切全脱敏。不同场景下,模型对数据的需求强度不一样。我习惯把敏感数据分成三级:

级别定义示例处理方式
L1模型推理必须的、不构成隐私风险的内容工单状态、物流节点、商品名称原样保留
L2非必要但模型偶尔会引用的个人信息姓名、城市级别地址变形脱敏,保留语义
L3一旦泄露后果严重的强隐私数据手机号、身份证号、银行卡号、详细门牌地址可逆加密/完全隐藏

分级策略要外置成配置,不要写死在代码里。我当时用了一个 JSON 配置文件,业务方可以随时调整:

{ "field_policy": { "customer_name": "MASK_KEEP_LAST", "customer_phone": "REPLACE_RANDOM", "customer_address": "MASK_RETAIN_CITY", "issue_desc": "KEEP_ALL" }, "type_policy": { "phone": "REPLACE_RANDOM", "id_card": "REPLACE_ENCRYPTED", "email": "MASK_KEEP_PREFIX" } }

策略引擎根据这个配置,决定对某一类型/字段的数据执行哪种脱敏策略。常见策略包括:

  • MASK_KEEP_LAST:保留后几位,其余打码,比如张*;
  • MASK_RETAIN_CITY:保留城市名,隐藏详细门牌地址;
  • REPLACE_RANDOM:替换成随机生成的假数据,保持格式一致性;
  • REPLACE_ENCRYPTED:用格式保留加密生成可逆的密文。

这里特别说一下格式保留可逆加密。它是脱敏方案里一个比较优雅的工具——加密后的结果在格式上和原文保持一致。比如身份证号加密后还是一串 18 位的数字,从“长得像身份证号”这个角度,模型完全可以正常理解这个字段的含义。但密文无法还原出真实内容,只有持有密钥的后端服务才具备解密的权限。这样既保证了模型对数据结构的理解,又实现了强隐私保护。日常做客服工单场景时,后台人工审计如果需要看真实数据,可以通过安全网关单独解密查看,而 Agent 推理链路里拿到的永远是密文。

3.3 第三层:上下文改写器

识别和分级完成之后,接下来是核心环节:在数据从 Skill 返回准备进入上下文之前,插入一个改写器。改写器的作用是对即将进入模型上下文的内容做清洗和替换。

改写器要处理的不仅是 Skill 返回的 JSON,还包括用户自己发来的文本消息里的敏感内容。我实现的上下文改写器,核心逻辑是:先对文本做敏感扫描,再按策略执行脱敏,最后把脱敏后的文本放进上下文。

class MaskingRewrite: def __init__(self, policy_engine): self.policy_engine = policy_engine def rewrite_text(self, text: str) -> str: hits = scan_sensitive(text) if not hits: return text # 按命中位置倒序替换,避免破坏索引 result = list(text) for hit in sorted(hits, key=lambda x: x["start"], reverse=True): policy = self.policy_engine.get_policy(hit["type"]) masked_value = self.policy_engine.mask(hit["value"], policy) result[hit["start"]:hit["end"]] = masked_value return "".join(result)

这里有一个关键设计:缺省禁止。也就是说,如果一段文本里出现了敏感内容,但策略引擎没有明确配置对应的脱敏策略,默认行为是拦截而不是放行。宁可让模型多问一轮“请提供必要信息”,也不能让明文溜进上下文。

再展开说一个实操细节:替换算法不能破坏索引位置。我第一次实现时用的是str.replace(),结果出现了问题——当一段文本里有两个完全相同的手机号时,replace会把两个都替换掉,但我的代码逻辑只期望替换第一个,导致后续位置偏移。后来我才改成“倒序切片重建法”,先把文本转成字符数组,从后往前替换,索引永远不乱。

3.4 第四层:输出审计

脱敏架构还有一个容易被遗漏的出口:Agent 的最终回复。模型在生成回复时,可能会把上下文里被脱敏的内容“重新生成”出来。比如脱敏后的手机号是138****1234,模型看多了这个格式,回复时有可能编一个类似的号码出来;更危险的是,如果某一轮操作失误导致明文进了上下文,模型可能在回复中原样复述。

输出审计层要做的事情是:在 Agent 回复发给用户之前,再扫描一遍,发现任何符合敏感模式的内容,就用统一的脱敏模板替换掉,或者直接拦截回复要求重新生成。

我在输出审计层还做了一个轻量级的"泄露检测"功能:把所有命中敏感模式的文本片段记录到审计日志里,附带上命中的信息来源(是哪一轮对话、哪个 Skill 返回的)。这样一旦出现问题,排查链路会容易很多。

4. 一套可抄作业的参考实现:从识别到审计的完整编排

前面把分层架构讲完了,这一节我直接给出一套能够跑通的核心编排代码,大家可以按这个结构快速在自己项目里搭一个雏形。

class DataMaskingPipeline: def __init__(self, policy_engine, rewriter, auditor): self.policy_engine = policy_engine self.rewriter = rewriter self.auditor = auditor def process_skill_response(self, skill_name: str, raw_data: str, user_id: str) -> str: # 第一步:识别 hits = scan_sensitive(raw_data) if not hits: return raw_data # 第二步:分级与改写 rewritten = self.rewriter.rewrite_text(raw_data) # 第三步:记录审计信息 self.auditor.log( skill_name=skill_name, user_id=user_id, hit_count=len(hits), hit_types=[h["type"] for h in hits], timestamp=datetime.now(), ) return rewritten

实际项目中,这个 Pipeline 会被挂在 Skill 调用链的外层。我推荐的做法是做一个统一的数据安全网关,所有 Skill 的对外 API 都通过这个网关进出。网关负责入站参数清洗、出站响应改写、日志审计三件事。

从执行顺序上看,Agent 每调用一次 Skill,数据流的完整链路是:

  1. 用户消息进入 Agent 编排层;
  2. 编排层决定调用某个 Skill;
  3. Skill 请求先过安全网关,入站参数里的敏感信息被脱敏/标记;
  4. Skill 执行业务逻辑,返回原始结果;
  5. 原始结果再过安全网关,敏感识别器扫描,改写器执行脱敏;
  6. 脱敏后的结果进入模型上下文;
  7. 模型生成回复前,输出审计层扫描;
  8. 回复返回给用户。

不要小看中间多出来的几层调用,它们带来的性能损耗在可接受范围内。我实测下来,一次正则扫描加脱敏改写大约在 5 到 10 毫秒,对一个对话交互来说这个开销完全可以接受。

注意的是,Skill 返回的数据如果是结构化的 JSON,不能简单地把 JSON 转成字符串再做脱敏。那样做会让字段和策略的映射关系丢失。正确做法是遍历 JSON 的每个字段,依据字段名匹配策略。

def process_json(obj, field_policy): if isinstance(obj, dict): clean = {} for k, v in obj.items(): if k in field_policy: clean[k] = mask_value(v, field_policy[k]) else: clean[k] = process_json(v, field_policy) return clean elif isinstance(obj, list): return [process_json(item, field_policy) for item in obj] else: return obj

这里还要提醒一个容易踩坑的地方:字段策略要尽量用“字段名 + 内容类型”双重匹配。因为有些字段虽然叫note,但里面的内容可能是用户留言,包含手机号;有些字段叫name,但可能存的是公司全称,不含个人隐私。所以不管是字段策略还是内容扫描,建议都过一遍识别器,宁可多扫一次,不要漏一次。

5. 常见问题与排查技巧实录

脱敏架构上线之后,问题并不会消失,只会换个姿势出现。我整理了几个在模拟项目X和实际客服工单助手开发中反复遇到的问题和对应的排查思路,希望对你有参考价值。

5.1 脱敏后模型答非所问

现象:把客户姓名替换成张*之后,模型在回复里开始频繁出现“关于您的姓名信息,系统暂时无法识别”这样的傻话。

原因:脱敏粒度过大,把模型做语义理解所需的关键信息也抹掉了。

排查思路:先确认模型回答的质量问题是不是只出现在含有敏感字段的对话中。如果是,把策略从MASK_KEEP_LAST换成REPLACE_RANDOM,也就是用假名替代真名,比如“张三”变成“李四”。模型只需要知道“这是一个人的名字”,并不需要知道具体的真实姓名,随机替换比打星号更能保留语义。这个技巧在客服工单场景中非常实用。

5.2 明文从检索结果里绕过脱敏层

现象:某轮对话里出现了明文手机号,但我明明已经在 Skill 返回的数据上挂了脱敏组件。

原因:这个手机号不是来自 Skill 的返回值,而是来自 RAG 检索召回的文档内容。RAG 链路里的文档片段走的是另一条数据通道,没有经过安全网关。

排查思路:把安全网关从“Skill 专用”升级为“所有数据出口强制走网关”,包括 RAG 检索器、外部知识库调用、甚至系统提示词里动态拼接的用户资料,都必须统一过一遍识别和改写。我用了一个很笨但有效的办法:把数据安全网关做成了项目里所有外部数据源的唯一出口,任何数据要进入模型上下文,必须经过这个出口。这样至少不会出现旁路绕过。

5.3 日志里出现明文

现象:安全扫描显示对话记录和模型输出都没有明文,但运维查日志时发现,Skill 的调用日志里完整记录了入参手机号。

原因:脱敏只作用在“上下文数据链路”上,但调试日志、监控日志记录的是原始 API 请求,是独立的一条数据通道。

排查思路:日志脱敏必须作为一个独立组件来对待。所有日志输出之前,统一走日志脱敏过滤器,对手机号、身份证、地址这类字段打码。我建议在日志框架层面全局挂一个过滤器,而不是每个业务模块手动打码。因为人工手动打码必然会有遗漏,公共组件处理才能做到全覆盖。

5.4 明明脱敏了,模型还是猜出原始号码

现象:手机号已经被替换成随机号码,模型对话中也没有出现过明文,但有一次模型在回复中对客户说“这个手机号是上次和您核对的那个号码吧”,而客户上次并没有报过号。

原因:模型基于对话上下文做了推理。前一轮客户说了“我是 138 开头的号”,脱敏后的号码恰好也以 138 开头,模型就自行串起来了。

排查思路:对高敏感字段,脱敏后的格式要与原数据保持足够距离。比如手机号这种强隐私信息,在条件允许的情况下不要用“保留前三位、后四位”这种模式脱敏,而是直接生成一个完全随机的新号码。宁可让模型少一点“熟悉感”,也不能让它有推断空间。

5.5 常见问题速查表

现象可能原因排查方向
脱敏后模型答非所问脱敏粒度过大将打码策略改为随机替换
对话中出现明文数据走了旁路(RAG/外部工具)所有数据出口统一走安全网关
日志出现明文日志链路独立于脱敏链路日志框架挂全局脱敏过滤器
模型猜出原始敏感信息脱敏保留了可推断片段高敏字段用完全随机替换
脱敏后数据格式错乱替换算法破坏索引改用倒序切片重建法

6. 从Web转Agent,安全思维上必须做出的三个转变

6.1 从拦截到改写

Web 时代防数据泄露的主流思路是“不返回、不展示”。敏感字段对前端就是不给,对日志就是不记。到了 Agent 时代,这个思路失灵了,因为模型需要数据来完成推理。你不可能把一个没有地址的工单给模型,却期望它准确判断“客户是不是因为配送地址有误才投诉”。所以安全手段从“拦截删除”变成了“智能改写”——给你必要的信息,但给的不是真实信息。

6.2 从单点防护到全链路审计

Web 场景下,前端展示、后端存储、日志记录各管一摊,每一摊都有单独的防护措施。Agent 场景下,数据要经过入站参数、Skill 返回、上下文滞留、模型输出、日志记录五六个环节,任何一个环节漏了,整个防线就破了。所以脱敏架构必须覆盖全链路,任何一条数据路径都不能绕过安全网关。

6.3 从静态清单到动态策略

Web 时代的敏感字段清单很稳定,基本是上线时配置好就不用动了。Agent 场景下的敏感内容识别会随着业务变化而漂移。比如某天业务方在工单里新增了“客户偏好”字段,里面的内容可能是客户自述的健康状况、家庭情况,这种东西靠正则识别不了,靠字段清单也需要同步更新。所以策略引擎必须做得足够灵活,配置化、可视化,让业务方能随时调整,而不是每次改策略都要发布一次代码。

个人体会是,Web 开发者转 AI 应用开发,最大的优势在于数据库模型、API 设计、系统架构这些基本功扎实,最大的短板是容易把“数据安全”想简单了。Agent 的数据链路远比我们熟悉的 Web 链路复杂,敏感数据脱敏也不只是写几个工具函数,而是要当成一个独立的架构组件来设计。如果让我重新来过,我会在项目启动的第一天就把数据安全网关的骨架搭好,而不是等到联调发现模型开始“复读”客户手机号了才来补窟窿。希望这篇文章能给同样在做 Agent Skills 开发的同行们一些参考,少走一些我走过的弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进