给大模型加一道脱敏闸门:用 Presidio 让客户信息不出机房
前言
Presidio 是一套开源的 PII 识别与脱敏工具。它现在最热的一个用法,是给大模型应用做前置脱敏。
先说场景。你们上线了一个 AI 客服助手,客服人员这样用它:
「客户张伟(手机 13800138000)投诉订单迟迟未发货,帮我起草一个回复。」
这句话会原封不动地发到 OpenAI 或者别的云端模型。真实姓名和手机号,就这样离开了你的机房。
这不是假想的风险。一旦启用了 AI 助手,员工往对话框里粘贴的东西会远超你的预期——完整的工单、数据库查询结果、甚至整段客户资料。而这些内容送到哪去了、被留存多久、会不会进训练集,很多时候不在你的控制范围内。
Presidio 能在中间加一道闸门。
一、整体思路
核心就一句话:在数据离开你的边界之前脱敏,在结果回来之后还原。
云端模型自始至终看到的都是占位符,它照样能完成「起草一封道歉回复」这个任务——因为写回复这件事根本不需要知道客户真名。
二、先做一个决定:需不需要还原
这是设计上的第一个岔路口,选错了后面全是麻烦。
| 不可逆 | 可逆 | |
|---|---|---|
| 用什么 | replace / redact / hash |
encrypt / 映射表 |
| 结果 | 原文永远拿不回来 | 能还原成原文 |
| 适合 | 数据分析、日志、内部问答 | 要把结果给客户看的场景 |
| 风险 | 低——密文都没有,泄不了 | 高——密钥或映射表一旦泄漏就全完了 |
判断标准很简单:模型的输出会不会直接送到最终用户面前?
- 「帮我总结这批工单的共同问题」→ 不可逆。总结里根本不该出现具体某个人。
- 「帮我起草回复这位客户」→ 可逆。回复要发出去,抬头得是真名。
默认选不可逆。 只有确实需要还原时才用可逆方案——因为可逆意味着你要开始管密钥了,那是另一套麻烦。
三、可逆方案:用 encrypt / decrypt
Presidio 内置了 AES 加密的 operator,加密和解密用同一个密钥:
from presidio_analyzer import AnalyzerEngine
from presidio_anonymizer import AnonymizerEngine, DeanonymizeEngine
from presidio_anonymizer.entities import OperatorConfig
# 密钥长度必须是 128 / 192 / 256 位,也就是 16 / 24 / 32 个字符
crypto_key = "WmZq4t7w!z%C&F)J"
analyzer = AnalyzerEngine()
anonymizer = AnonymizerEngine()
text = "Please draft a reply to James Bond about his delayed order."
# ---- 出境前:识别 + 加密 ----
findings = analyzer.analyze(text=text, language="en")
masked = anonymizer.anonymize(
text=text,
analyzer_results=findings,
operators={"DEFAULT": OperatorConfig("encrypt", {"key": crypto_key})},
)
print(masked.text)
# Please draft a reply to <一串密文> about his delayed order.
# 这一步很关键:记下每个被加密实体的位置,还原时要用
encrypted_entities = masked.items
# ---- 把 masked.text 发给大模型,拿到回复 ----
llm_reply = call_your_llm(masked.text)
# ---- 入境后:还原 ----
deanonymizer = DeanonymizeEngine()
final = deanonymizer.deanonymize(
text=llm_reply,
entities=encrypted_entities,
operators={"DEFAULT": OperatorConfig("decrypt", {"key": crypto_key})},
)
print(final.text)
两个必须注意的点:
1. 密钥长度有硬性要求。 必须是 16、24 或 32 个字符,对应 AES-128/192/256。长度不对会直接抛异常。而且这个密钥绝对不能硬编码进代码——放 Azure Key Vault 或者你们用的任何密钥管理服务里。
2. items 一定要留着。 anonymize() 返回的不只是文本,还有一份「哪个位置是什么实体」的清单。还原的时候必须把它传进去——它不是可选的调试信息,是还原的必要输入。
四、现成方案:LiteLLM 代理
如果你不想自己写这一层,LiteLLM 已经把 Presidio 做成了内置回调。它是一个 LLM 网关,你的应用调它,它再去调真正的模型。
export PRESIDIO_ANALYZER_API_BASE="http://localhost:5002"
export PRESIDIO_ANONYMIZER_API_BASE="http://localhost:5001"
# config.yaml
model_list:
- model_name: my-openai-model
litellm_params:
model: gpt-4o
litellm_settings:
callbacks: ["presidio"]
output_parse_pii: true # 让它自动把返回结果里的占位符换回原值
litellm --config /path/to/config.yaml
output_parse_pii: true 这一行做的事情正是我们上面手写的那套:
- 用户输入:
hello world, my name is Jane Doe. My number is: 034453334 - 送给模型的:
hello world, my name is [PERSON]. My number is: [PHONE_NUMBER] - 模型返回的:
Hey [PERSON], nice to meet you! - 还给用户的:
Hey Jane Doe, nice to meet you!
这个方案最大的好处是对应用无侵入——你的代码还是按 OpenAI 的接口调,只是把 base URL 指向 LiteLLM。想给多个应用统一加脱敏,这是最省事的路子。
它还支持在请求里带 ad-hoc 识别器:不用改镜像,直接在配置里挂一个 JSON 文件补充自定义规则。
五、真正的难点:脱敏会让模型变笨
这是整件事里最容易被低估的地方,也是各种教程最少提到的部分。
把 PII 换成占位符,是在删除信息。而信息删多了,模型就答不好了。
问题 1:占位符会让模型分不清人
原文:张伟向李娜转账 5000 元,李娜确认收到。
脱敏:<PERSON> 向 <PERSON> 转账 5000 元,<PERSON> 确认收到。
模型现在完全无法判断谁转给谁、谁确认了——三个不同的人被压成了同一个符号。
解决办法:给占位符编号,让不同的人对应不同的记号:
<PERSON_1> 向 <PERSON_2> 转账 5000 元,<PERSON_2> 确认收到。
Presidio 的内置 operator 不会自动做这件事,需要你在中间那一步自己处理(识别结果在送去脱敏之前是可以被你干预的,正好用在这种地方)——按原文值去重,给每个不同的值分配一个编号,同时维护一张编号到原值的映射表用于还原。
问题 2:占位符会破坏语气
脱敏后:尊敬的 <PERSON> 您好……
模型不知道这是个人名还是公司名,写出来的回复会很别扭。更好的做法是用假名替换——把「张伟」换成「王强」而不是 <PERSON>。
模型看到的是一个正常的中文姓名,行文自然流畅;而真实姓名依然没有出境。还原时按映射表换回来即可。Presidio 支持通过 custom operator 接入任意替换逻辑,配合 Faker 这类假数据库就能做到。
代价是你要维护一张映射表,而映射表本身就是敏感数据——它需要和加密密钥同等级别的保护。
问题 3:流式输出很难还原
如果你用的是流式响应(打字机效果),占位符可能被切成两半分批送达:<PER 一个包,SON_1> 下一个包。逐块做字符串替换会失败。
处理办法是在还原层维护一个缓冲区,遇到疑似占位符开头就先攒着,凑完整了再替换。这部分工程量比想象中大,如果你的场景对首字延迟不敏感,直接关掉流式会省很多事。
问题 4:RAG 场景要想清楚在哪脱敏
检索增强(RAG)里有两个时机可以脱敏,选择完全不同:
- 入库时脱敏:向量库里存的就是脱敏后的内容。最安全,但检索质量会下降——用户搜「张伟的订单」,库里根本没有「张伟」。
- 检索后脱敏:库里存原文,取出来送给模型之前才脱敏。检索质量不受影响,但向量库本身成了敏感数据存储,得按敏感级别来保护。
没有标准答案,取决于你的向量库归谁管、在哪部署。 但这个决定必须在设计阶段就做,事后改动成本极高。
六、几个容易被忽略的点
1. 别忘了它不保证找全
这一点在这个场景下后果最严重。
Presidio 漏掉一个手机号,意味着这个手机号真的被发到云端去了。所以:
- 不要把 Presidio 当作唯一的防线。 该有的访问控制、数据分级、员工培训一样都不能少。
- 要做审计。 把每次识别的命中记录下来,定期抽查有没有明显该识别却没识别的模式。
- 要限制入口。 与其指望脱敏兜住一切,不如从产品设计上就不让员工能随手粘贴整段客户资料。
2. 成本和延迟
每次调用多了两次 HTTP 往返(analyze + anonymize)。相比大模型本身几百毫秒到几秒的耗时,这点开销通常可以忽略——但前提是 Presidio 服务和你的应用在同一个网络里。跨区域部署会让这个开销变得很显眼。
如果只关心少数几类实体,记得用 entities 参数缩小范围,这在高频调用场景下能省不少。
3. 什么时候不需要这一层
如果模型是私有部署、跑在你自己的机房里,这一层就没有存在的必要。 数据根本没有离开边界,脱敏只会白白降低模型效果。
同样,如果你用的是有明确合规承诺、且部署在你所在区域的服务(比如某些企业版云服务的数据不出境条款),先看清楚合同——有可能你需要的不是技术方案,而是一份已经签好的协议。
别为了上技术而上技术。 这一层的价值完全建立在「数据确实要出境」这个前提上。
总结
- 核心思路:出境前脱敏,回来后还原,云端模型看到的永远是占位符。
- 第一个决定是要不要还原:输出直接给最终用户看的才需要可逆方案,其余一律用不可逆的,默认选不可逆。
- 可逆方案用
encrypt/decrypt:密钥必须是 16/24/32 字符且不能硬编码,anonymize()返回的items是还原的必要输入。 - 不想自己写,用 LiteLLM 代理:配置里加一行
callbacks: ["presidio"],对应用无侵入。 - 最大的难点不是脱敏,是脱敏之后模型会变笨:占位符要编号区分不同的人,用假名替换比用占位符更保语气,流式输出的还原要额外处理,RAG 要提前想清楚在哪一步脱敏。
- 它依然不保证找全——这一层是纵深防御的一环,不是免责声明。