企业级大模型应用中的数据脱敏与保护:基于DLP和LangChain的实践
做一个真的要上线的企业级大模型应用最让我睡不踏实的从来不是模型选型而是提示词里到底装了多少不该出现的数据。跑 Demo 的时候你不会觉得这条链路有什么问题顶多就是在终端里看到几段包含姓名和手机号的对话日志。等真正进入内部客服、文档问答、人事助手这类场景你会发现每个用户提问背后都挂着一堆姓名、证件号、银行卡信息。模型效果再惊艳一条未经保护的敏感数据也可能让整个项目一夜回到解放前。这篇项目复盘写的是我实际搭过的一套数据保护链路用 Google Cloud 的 DLP 服务做敏感数据探查和脱敏用 Cloud KMS 管加密密钥用 Secret Manager 管服务凭据再用 LangChain 把检查、脱敏、调用模型、还原结果这四个动作编成一条标准的处理管道。这套方案主要面向正在做 RAG 问答、客服机器人、文档解析助手又绕不开用户隐私数据的团队如果你手上有类似场景可以直接把这里的流程拿回去改。下面我先把整体思路讲清楚再给关键代码最后把踩过的坑列出来。1. 先从需求和选型说起1.1 大模型应用里的敏感数据到底从哪漏出去很多人以为数据泄露只会发生在“把用户输入直接拼进 Prompt”这一种情况实际开发中我发现至少有三个入口同时开着。第一是用户输入本身。客服机器人接到的消息里常常直接带手机号、订单号、收件地址这些内容一旦进入模型上下文就等于把明文交给了外部推理服务。你无法控制模型服务端如何使用这些上下文哪怕只是暂时的缓存也可能构成泄露。第二是 RAG 的文档库。给大模型建知识库的时候大家通常会把企业内部文档一股脑切块、向量化。问题在于业务文档里嵌套了大量个人信息比如客户合同里的身份证号、工资表里的员工姓名。文档一旦被切进向量库检索阶段就可能把整段敏感内容召回并拼进 Prompt这比用户直接提问更容易被忽视。第三是对话日志和调试输出。LangChain 默认的 stdout 输出、开发阶段的日志打印、甚至错误堆栈都可能把完整的原始 Prompt 带出来。生产环境里如果接了日志采集等于把敏感数据又复制了一份。所以我在做这个项目时定的基线是敏感数据不允许以明文形式进入模型上下文必须在调用链路的边界处做一次强制检查同时所有日志、缓存、向量库一律只保存脱敏后的形态。1.2 为什么选 Google Cloud 和 LangChain 这套组合选 Google Cloud 不是因为它“名气大”而是它的 DLP数据防泄露服务刚好解决了传统方案里最费人工的部分。自己写敏感数据识别需要维护正则库、号码规则、命名实体词典还要不断适配新的造假模式。DLP 内置了几百种 InfoType 探测器从姓名、手机号、邮箱地址到信用卡号都在覆盖范围内并且每个识别结果会附带一个置信度等级likelihood这让我可以按业务风险调节拦截尺度而不是一刀切地全量打码。Cloud KMS 负责的是另一件事密钥管理。脱敏需要密钥数据库加密需要密钥向量库加密也需要密钥。KMS 直接把密钥生命周期托管起来支持自动轮转和审计比自己在配置中心里放一串环境变量要正规得多。Secret Manager 则用来存 DLP 服务账号、模型 API Key 这类敏感凭据避免把密钥写进代码仓库。LangChain 在这套方案里的角色是调度层。它的链式抽象允许我把 DLP 检查、脱敏、模型调用、还原这几个环节封装成一个个标准节点然后用一套统一方式串联。后续要换底层模型或者调整脱敏策略只需要替换链路里的对应模块不需要动整个业务代码。1.3 这套方案适用的场景和影响范围从实际落地来看最容易见效的是下面几类应用企业内部文档问答、客服工单自动回复、简历初筛与 HR 数据助手、金融领域的文档审阅。这些场景的共同点是既要借助大模型理解大量文本又必须遵守严格的数据保护要求。这套方案的影响范围不只是“给 Prompt 加一层过滤”它会改变整个数据链路的设计方式。比如文档入库前需要跑一遍 DLP 预处理检索阶段要按脱敏后的特征匹配模型输出后还要做还原校验。团队的安全意识、运维脚本、监控告警都要跟着升级。换句话说它把数据保护从“上线前补个洞”变成了“链路各处都在做防护”这也是我觉得它值得写成一篇文章的原因。2. 保护管道的整体设计2.1 核心流程检查、脱敏、调用、还原整个系统可以简化成四个阶段。检查阶段对进入系统的每一段文本执行 DLP inspect拿到所有命中的敏感片段、类型和位置。脱敏阶段按检查结果把所有命中片段替换成固定占位符比如把手机号替换成[PHONE_NUMBER]同时把“占位符到原始值”的映射表保存在内存缓存里。调用阶段用脱敏后的文本拼 Prompt、检索文档、调用模型所有下游组件只看见占位符。还原阶段模型输出里如果引用了占位符再把映射表里的原始值替换回去并做一轮二次校验确保没有未授权的敏感片段进入最终响应。这里有个容易被忽略的点还原不是无条件的。如果用户本身没有权限查看某个敏感字段即使占位符出现在模型输出里也应该保持脱敏状态。所以我额外加了一个权限判断层只有对应当前用户有权看到的字段才做还原。2.2 敏感数据的分类与检测策略DLP 把检测单元叫 InfoType不同的数据类型对应不同的 InfoType。我在生产里把数据分成四类来看待。数据类型典型信息对应 InfoType 示例行业场景个人身份信息姓名、手机号、邮箱、住址PERSON_NAME、PHONE_NUMBER、EMAIL_ADDRESS、STREET_ADDRESS客服、人事、政务金融财务银行卡号、信用卡、账号CREDIT_CARD_NUMBER、FINANCIAL_ACCOUNT_NUMBER金融、电商证件与身份编码身份证、护照、税号各地区身份证号码类型、PASSPORT注册、审核企业机密与内部标识项目代号、合同编号、员工工号CUSTOM自定义 InfoType法务、研发、财务对于表格里前两类直接用 DLP 内置 InfoType 就能覆盖。企业内部的代号和工号这类信息我采用自定义 InfoType 的方式用一个正则模板告诉 DLP 怎么识别。自定义规则的好处是可以把“公司内部格式”这种外部工具完全识别不出来的数据也纳入保护范围代价是需要自己维护规则模板并定期根据新出现的编号格式更新。2.3 脱敏策略选型遮罩、替换还是标记化DLP 的 deidentify 配置里提供了几种转换方式我分别测试后留下了三种可用策略。第一种是直接删除redact把命中片段整段移除。适合不需要保留任何信息的场景比如日志脱敏。第二种是掩码显示mask只保留部分字符其余用*代替适合手机号、邮箱这类希望保留格式可读性的场景。第三种是替换成 InfoType 名称比如把“张三”替换成[PERSON_NAME]把“13800138000”替换成[PHONE_NUMBER]这是我在模型调用链路里最常用的一种因为占位符语义清晰模型能理解这是一个被隐藏的字段。严格来说还有第四种叫标记化tokenization用随机生成的票据代替原始值原始值只存在加密存储里。这种方案安全性最高但实现成本也最大因为还原链路需要查询票据库。如果团队刚开始做数据保护我建议先用替换成 InfoType 名称的方式跑通后续再按需要升级到标记化。3. 核心模块的实现细节3.1 DLP 探查模块的接入方式接入 DLP 做敏感数据探查本质是调用一次 inspect_content 接口。每次调用传入一段文本返回所有命中的 finding包括类型、位置、原文引用和置信度。调用之前最重要的准备工作是设置 Service Account 的最小权限。我给 DLP 探查模块建了独立的服务账号只授予DLP User角色没有给其他云资源的读写权限。这样即使密钥泄露攻击者也无法顺着服务账号去访问存储桶或数据库。这一点在云上开发时经常被人忽视很多人图省事直接用一个能操作所有资源的大权限账号一旦泄露就是全盘风险。DLP 调用本身是同步的。为了不阻塞主流程我在实际项目中把它包成了一个异步任务用户消息进队列后先做 DLP 检查再进 LangChain 链路检查耗时对用户体验基本无感。3.2 脱敏与还原映射的实现要点脱敏不能只依赖 DLP 返回的 quote 字段直接替换因为 quote 可能和原文不完全一致。我在实现时是严格按照 DLP 返回的 byte range 位置信息去做切割代码里先把所有命中按起始位置排序然后从尾部向前替换避免前面的替换导致后面的偏移量全部失效。这个细节我把绊倒过刚开始没按位置切割直接用字符串 replace 去替换原文结果一个“张三”在正文里出现两次时DLP 只返回了一个命中replace 却把两处都换掉了导致还原映射错乱。所以后来统一用位置切割每个命中只替换一次。映射表同时也需要有个过期机制。脱敏映射存在内存里设置 TTL 为 15 分钟用户一次会话结束就清除。原因很简单占位符到原始值的映射本身就是敏感数据长时间留着只会增加泄露面。如果业务确实需要更长的还原窗口我会把映射表用 KMS 生成的密钥加密后存到 Redis而不是裸存。3.3 LangChain 管道改造的三个层次LangChain 侧我按三个层次做改造方便团队里不同水平的人理解和维护。最底层是工具函数层封装 DLP 检查和脱敏还原的调用这部分只依赖 google-cloud-dlp 客户端不依赖 LangChain 的 API。中间层是链式节点层把工具函数包装成 LangChain 的 RunnableLambda这样可以用 LCEL 语法把保护模块和模型调用串起来。最上层才是业务代码业务开发不需要关心 DLP 怎么调用只需要声明链路的顺序。之所以要分三层是因为 LangChain 自身 API 迭代很快函数式封装如果跟业务代码混在一起版本升级时会有大量地方需要跟着改。把依赖 LangChain API 的范围压缩到中间层升级时的影响面就小很多。4. 实操代码与部署要点4.1 环境准备与项目初始化我的项目使用 Python 3.11依赖包版本如下google-cloud-dlp3.15.0 google-cloud-kms2.20.0 google-cloud-secret-manager2.17.0 langchain0.1.20 langchain-google-vertexai1.0.0 python-dotenv1.0.0环境变量集中在.env文件里用 python-dotenv 加载。项目初始化分四步创建独立项目或独立分区、启用 DLP / KMS / Secret Manager 三个 API、创建最小权限服务账号、把服务账号密钥文件放到只有 CI 能访问的存储位置。还有一个实践建议在开发环境里不要直接设置GOOGLE_APPLICATION_CREDENTIALS指向本机密钥文件而是用 gcloud 的临时凭据做本地调试生产环境则用云上的服务账号。这样可以避免开发机上的密钥文件被误提交到代码仓库。4.2 DLP 检查与脱敏的完整实现检查模块的核心代码如下from google.cloud import dlp_v2 def inspect_sensitive_data(text: str, project_id: str) - list: client dlp_v2.DlpServiceClient() parent fprojects/{project_id}/locations/global response client.inspect_content( request{ parent: parent, inspect_config: { info_types: [ {name: PERSON_NAME}, {name: PHONE_NUMBER}, {name: EMAIL_ADDRESS}, {name: CREDIT_CARD_NUMBER}, {name: DATE_OF_BIRTH}, {name: STREET_ADDRESS}, ], min_likelihood: LIKELIHOOD_UNSPECIFIED, limits: {max_findings_per_item: 20}, }, item: {value: text}, } ) findings [] for finding in response.result.findings: start finding.location.byte_range.start end finding.location.byte_range.end findings.append({ info_type: finding.info_type.name, start: start, end: end, likelihood: finding.likelihood.name, quote: text[start:end], }) return findings需要注意min_likelihood这个参数。我把默认值设成了LIKELIHOOD_UNSPECIFIED意思是所有置信度都要返回然后在业务层再按场景过滤。这样做的理由很直接客服场景里手机号检测置信度很高直接拦截没问题但姓名检测在不带姓名的文本里偶尔会误报如果接口层就过滤掉了后面想查证就没机会了。宁可全量返回也别在源头把数据丢了。脱敏模块我用位置切割实现def deidentify_with_positions(text: str, findings: list) - tuple[str, dict]: mask_map {} # 按起始位置倒序替换避免偏移量变化 findings.sort(keylambda f: f[start], reverseTrue) for f in findings: left text[: f[start]] right text[f[end] :] placeholder f[{f[info_type]}] mask_map[placeholder] f[quote] text left placeholder right return text, mask_map这段代码每次替换一个命中并把原文和占位符的对应关系存进 map。倒序替换是关键的细节谁先正序替换谁后悔。4.3 把保护逻辑编入 LangChain 链下面的代码展示了如何用 LCEL 把保护模块、模型调用、还原模块串成一条链from langchain_core.runnables import RunnableLambda from langchain_google_vertexai import VertexAI class DataProtectionGuard: def __init__(self, project_id: str): self.project_id project_id self._mask_map {} def mask(self, payload: str) - str: findings inspect_sensitive_data(payload, self.project_id) masked_text, mask_map deidentify_with_positions(payload, findings) self._mask_map.update(mask_map) return masked_text def unmask_safe(self, payload: str, allowed_keys: set) - str: # 只还原当前用户有权限看到的字段 for placeholder, original in self._mask_map.items(): if placeholder in payload and placeholder in allowed_keys: payload payload.replace(placeholder, original) return payload guard DataProtectionGuard(project_idyour-project-id) llm VertexAI(model_namegemini-pro) chain ( RunnableLambda(guard.mask) | llm | RunnableLambda(lambda out: guard.unmask_safe(out, allowed_keys{allowed})) ) result chain.invoke(我的手机号是13800138000请帮我查询订单状态。)这条链表面上看起来简单实际做的时候有一个重要取舍unmask_safe里的allowed_keys不能写死必须来自当前会话的权限上下文。我自己一开始图简单写了个“全部还原”的逻辑结果测试模型输出里出现占位符时全部被还原如果模型把占位符以外的敏感字段也趁机写出来就等于绕过了脱敏防线。后来改成权限校验后这个问题才真正堵住。另外模型输出的还原还有一个边界情况如果模型没有原样保留占位符比如把它改成了[PHONE]或者干脆忽略掉映射就匹配不上。这种情况我的处理是不强行还原宁可让用户看到占位符也不能凭模糊猜测去还原错误的值。4.4 密钥管理和凭据管理KMS 的使用场景主要是三处DLP 映射表加密、向量库加密、以及需要落盘的日志加密。代码里最常用的是直接调用 encrypt 方法from google.cloud import kms_v1 def kms_encrypt(plaintext: bytes, key_name: str) - bytes: client kms_v1.KeyManagementServiceClient() response client.encrypt( request{name: key_name, plaintext: plaintext} ) return response.ciphertextSecret Manager 则用来读取模型 API Key 和服务账号凭据from google.cloud import secretmanager_v1 def read_secret(secret_id: str, project_id: str, version: str latest) - str: client secretmanager_v1.SecretManagerServiceClient() name fprojects/{project_id}/secrets/{secret_id}/versions/{version} response client.access_secret_version(request{name: name}) return response.payload.data.decode(UTF-8)这里我想专门提醒一点KMS 的密钥轮转并不难配置难的是业务代码如何优雅配合轮转。如果直接在代码里写死密钥 ID轮转一次就要重新发布一次。我后来的做法是密钥别名机制业务代码只认别名KMS 侧把别名指向当前生效的密钥轮转时业务无感。这属于上线时才摸到痛点的经验。5. 常见问题与排查实录5.1 DLP 误报和漏报怎么缓解交付测试阶段最常被问到的就是“这个字段为什么不识别”和“这一段怎么被打码了”。先说不识别的问题。内置 InfoType 对常规数据效果很好但遇到公司自定义格式就无能为力。解决办法是加自定义 InfoType用正则描述业务编号的格式再结合一个词典文件提升识别率。比如员工工号的格式是EMP加八位数字正则写好后测试两三周没误报就可以正式上线。再说误报打码的问题。有一次用户反馈正文里的“小王”被识别成 PERSON_NAME 并打码导致模型理解出错。排查后确认是 likelihood 阈值设得太低。建议对模糊度高的类型单独设置阈值而不是全量用一个标准。生产环境里我的配置是手机号、银行卡这类高置信度类型用 POSSIBLE 就拦截姓名、地址这类容易混淆的类型要 LIKELY 以上才拦截。实际排查时还发现一个常见坑DLP 检查对整段长文本的耗时和费用会随长度上升。生产里我给单次检查的文本长度设了上限超过上限先截断再检查并在截断前先做一次简单关键字预检命中可疑关键字才走完整 DLP。这样能把 90% 以上的请求挡在开销大的检查之外。5.2 模型输出还原时的几个“翻车”现场还原这个环节看似简单实际翻车最多。第一个翻车点是模型输出里占位符被改写。模型可能把[PHONE_NUMBER]改成[手机号]或(PHONE)映射表完全匹配不上。我的对策是两阶段还原第一次按精确占位符匹配匹配不到再用模糊匹配规则匹配相似写法两次都不匹配就直接返回占位符。第二个翻车点是占位符在输入中出现多次。比如用户说“我手机号和同事手机号一样”DLP 会返回两个位置但映射表用 InfoType 作为 key 时会互相覆盖。我后来把 key 改成了“位置信息加类型”的组合字符串比如[PHONE_NUMBER_0]和[PHONE_NUMBER_1]虽然可读性差一些但不会丢数据。第三个翻车点是 long context 提前截断。模型上下文过长时后面的还原提示可能被丢掉。这属于上下文管理和还原逻辑的协同问题我的建议是还原逻辑不要依赖模型的总结能力而是依赖结构化输出解析让模型在特定 JSON 字段里返回占位符引用解析失败就走降级路径。5.3 性能、成本与运行可靠性DLP 的每次调用都有网络开销。实测下来单次 inspect_content 对一个 200 字以内的用户消息大约增加 80 到 150 毫秒延迟对几十KB的长文本会到 1 秒上下。在客服场景里这个延迟是可以接受的但要对数据库和向量检索场景单独做压测不能直接套用。成本方面DLP 按处理的字节数计费。如果每天有十万条消息每条约 300 字节成本并不高但如果文档入库时全量跑 DLP可能一次导入就把月度预算用掉。所以我的建议是文档入库阶段只对新增文档做 DLP 检查存量文档分批处理并加上去重逻辑别每次全量跑。运行可靠性上我给 DLP 调用加了三重保护超时设置为 3 秒、失败自动降级跳过脱敏但返回错误提示、以及针对周期性失败比如限流错误的退避重试。大模型链路可以容忍偶尔返回失败但绝对不能容忍“失败时静默放行明文”这两种策略要分清楚。6. 个人实操体会把整套东西跑通之后我再看最初设想的“接一个 DLP 库就行”的想法觉得确实天真了。真正值钱的不是那几次 API 调用而是你愿意在多少细节上跟安全较劲。最后分享一个我体会最深的小点脱敏后的内容也要做缓存设计。早期我直接在链路里硬编码了“先脱敏再缓存”的逻辑后来发现多个服务都映射表放到缓存里一旦服务重启缓存里残留的映射会和新进来的请求冲突。后来我把缓存键设计成“请求 ID 加用户 ID”每次请求独立读取这个问题才彻底消失。可能听起来是个很小的细节但在真实生产环境里这类边界情况往往比核心功能更容易让你半夜被电话叫起来。