2024 年我做一个 AI 客服应用,用户把自己遇到的问题描述发进来,我把它丢给第三方的大模型 API,让模型生成回答。把用户的话发给模型这件事,我压根没多想。第一版我做得很省事:不就是把用户输入的文字,拼进一个 prompt,发给大模型 API,再把模型吐回来的回答原样展示给用户?为了排查问题方便,我还顺手把每一次完整的 prompt 都打进了日志。本地开发时——真不错:我自己编几句话测试,模型答得有模有样,日志里 prompt、回答清清楚楚,排查起来一目了然。我心里很踏实:"调大模型嘛,不就是把用户的话发过去、把回答拿回来?"可等这个应用真正上线、被真实用户用起来,一串问题冒了出来。第一种最先把我打懵:真实用户描述问题时,话里夹着大量敏感信息——"我手机号 138 某某某收不到验证码""我身份证 4401 某某某实名认证过不了""我那张尾号某某的银行卡扣款失败"——这些话被我原样拼进 prompt、原样发给了第三方的大模型 API。也就是说,我的用户的手机号、身份证号、银行卡号,成批成批地出了我的系统边界,进了一家第三方公司的服务器。第二种最隐蔽:模型生成回答时,经常会复述用户的话——"关于您手机号 138 某某某收不到验证码的问题"——于是这些敏感信息又被原样写进了回答、原样展示了出去。第三种最要命:我那个"顺手"打进日志的完整 prompt,意味着每一条用户消息里的每一个手机号、身份证号,都被原原本本地写进了我的日志系统——而日志系统是会被很多人、很多工具访问的。第四种最容易被忽略:就算我后来想起来要处理,我发现自己根本不知道,过去这些天到底有多少 PII 已经流出去了——我没有任何记录、任何统计。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"调大模型,就是把用户的话发过去、把回答拿回来"。这句话把"用户的输入"和"模型的输出"都当成了可以原样搬运的、无害的纯文本。可它们不是。用户的输入和模型的输出,都不是"无害的纯文本"——它们是可能携带敏感个人信息(PII)的数据流。而你的 LLM 应用,恰恰处在一个特殊的位置:它是一道"边界"。用户的数据进入你的系统,是在你的边界之内,这块地方归你管;可一旦你把它发给第三方的大模型 API,这些数据就越过了你的边界,进入了一个你不再能控制、不再能撤回的地方——它会被记录在第三方的服务器上、可能被用于训练、可能出现在它们的日志里。所以"调用大模型"这件事,本质上是一次"数据出境":数据离开你的掌控,交付给第三方。你必须在数据跨过这道边界之前,清点它、处理它——把其中的 PII 识别出来、替换掉;模型回答返回时,再做一次还原与兜底检查;而你自己的日志,作为数据会流向的另一个地方,同样要脱敏。真正做好 LLM 应用的敏感信息防护,核心不是"把话发给模型",而是把每一次 LLM 调用看成一次数据出境,在入站时识别并脱敏 PII、在出站时还原与兜底、给日志也接上脱敏、想清楚哪些 PII 可逆哪些必须不可逆。这篇文章就把 LLM 应用的敏感信息防护梳理一遍:为什么"把用户的话原样发给模型"是错的、入站怎么脱敏、出站怎么还原与兜底、日志怎么脱敏、PII 怎么识别,以及可逆与不可逆、误伤、性能、合规边界这些把防护真正做扎实要避开的坑。
问题背景
先把那串问题的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:一个"把用户的话原样发给模型"的 LLM 应用,在面对真实用户后冒出一串问题:用户输入里的手机号、身份证号、银行卡号,原样发给了第三方大模型 API、出了系统边界;模型回答复述了这些信息,又原样展示了出去;完整 prompt 打进日志,PII 灌满了日志系统;事后想补救,却发现根本不知道已经流出了多少。
我当时的错误认知:"调大模型,就是把用户的话发过去、把回答拿回来。"
真相:这个认知错在它把 LLM 调用看成了一次"系统内部"的操作。"把话发过去、把回答拿回来"——这个描述,听起来就像调用自己系统里的一个函数一样自然、安全。可它根本不是内部操作:你调用的大模型 API,是另一家公司的服务,它在你的系统边界之外。这就意味着,每一次 LLM 调用,都是一次实实在在的数据跨境传输:用户托付给你的数据,被你送出了你能负责的范围。一旦你看清了这一点,那一串问题就都有了解释:用户输入是流出边界的数据,所以它里面的 PII 必须在流出之前被处理掉;模型输出是流回边界的数据,它可能带回了还原不干净的、或模型自己生成的 PII,所以它进来时也要检查;日志是数据流向的另一个出口,它会被很多人很多工具访问,所以它同样不能裸奔。问题的根子清楚了:这不是"要不要加个脱敏函数"的小修补,而是要建立一个"边界意识"——你的 LLM 应用是一道关口,所有跨过这道关口的数据,进来要查、出去要查,你得清清楚楚知道什么东西、以什么形态、流去了哪里。
要把 LLM 应用的敏感信息防护做对,需要几块认知:
- 为什么"把用户的话原样发给模型"是错的——LLM 调用是数据出境,不是内部操作;
- 入站脱敏——PII 在离开你的边界之前,替换成占位符;
- 出站处理——模型回答的占位符还原,以及残留 PII 的兜底检查;
- 日志脱敏——别让完整 prompt 把 PII 灌进日志和监控;
- PII 识别——正则、校验、上下文这些检测手段怎么组合;
- 可逆与不可逆、误伤、性能、合规边界这些工程坑怎么处理。
一、为什么"把用户的话原样发给模型"是错的
先把这件最根本的事钉死:"把用户的话原样发给模型"这个做法,错在它默认了一件不成立的事——它默认你的系统和大模型 API 之间,没有"边界"。在它的想象里,数据从用户那里来、到模型那里去、再回来,是一条畅通无阻的内部管道,全程都在你的掌控之内。可现实是:大模型 API 是第三方的服务,你的系统和它之间,横着一条清清楚楚的边界。数据一旦越过这条边界,就发生了三件你无法撤销的事——它被传输到了你管不着的服务器、它可能被对方记录和留存、它脱离了你对它的任何控制。所以"原样发过去"这个动作,真实的含义是"把用户托付给我的敏感数据,亲手交付给了一个第三方"。这件事的严重性,在本地开发时是完全感知不到的:本地你自己编的测试输入里没有真实 PII,所以"数据出境"出去的是一堆无害的假数据,你看不出任何问题。可一旦真实用户进来,他们的输入里塞满了真实的手机号、身份证、银行卡——这时候,你那条"畅通无阻的内部管道"的幻觉才会破灭:它从来不是内部管道,它是一道你一直没设防的边境关口。
下面这段代码,就是我那个"把边界当成内部管道"的第一版:
# 反面教材:用户输入原样拼进 prompt,发给第三方,还打进日志
def ask_llm(user_question):
prompt = f"请回答用户的问题:{user_question}"
log.info("LLM 请求 prompt=%s", prompt) # 破绽 3:完整 prompt 进日志
resp = third_party_llm.chat(prompt) # 破绽 1:用户输入里的 PII 出了边界
return resp.content # 破绽 2:回答里若复述了 PII 也原样返回
这段代码在本地开发时表现不错,因为本地我自己编的测试输入,里面根本没有真实的 PII——"我登录不上""验证码收不到",全是无害的假话。所以那三个破绽,在本地一个都不会发作:出境的是假数据、回答里没真 PII 可复述、日志里也只有假话。它的问题不在某一行语法上——拼 prompt、发请求、打日志,语法都对——而在它对"数据会流向哪里"这件事的彻底无知:third_party_llm.chat(prompt) 这一行,把数据送出了边界,它却不知道;log.info 这一行,把数据写进了日志这个出口,它也不知道;返回 resp.content 时,它没有检查这条流回来的数据里有什么。这三个破绽对应的,正是开头那四类问题(第四类——"不知道流出了多少"——则是这三个破绽共同的、必然的结果:你从没把它当回事,自然也从没记过账)。问题的根子清楚了:做对这件事,第一步不是写一个脱敏函数,而是先在脑子里画出那道边界——看清数据从哪里进、到哪里出,然后在每一个"出口"上设防。下面四节,就是这道边界上的四处设防:入站、出站、日志,以及它们都依赖的 PII 识别。
二、入站脱敏:PII 在离开边界前替换成占位符
第一处设防,设在数据出境的那个出口——补的是破绽 1:用户输入里的 PII 原样出了边界。原则很简单:在 prompt 离开你的系统、发给第三方之前,把里面的 PII 找出来,替换成占位符。这样真正出境的,是一段不含真实 PII 的脱敏文本。要做替换,先得能"找到"——一个 PII 检测器,用正则去匹配各类敏感信息:
import re
PII_PATTERNS = {
"PHONE": re.compile(r"1[3-9]\d{9}"), # 手机号
"ID_CARD": re.compile(r"\d{17}[\dXx]"), # 身份证号
"BANK": re.compile(r"\d{16,19}"), # 银行卡号
"EMAIL": re.compile(r"[\w.\-]+@[\w\-]+\.[\w.\-]+"), # 邮箱
}
def detect_pii(text):
"""扫出文本里所有疑似 PII,返回 (类型, 起, 止, 原值) 的列表。"""
hits = []
for kind, pat in PII_PATTERNS.items():
for m in pat.finditer(text):
hits.append((kind, m.start(), m.end(), m.group()))
return hits
找到之后做替换。这里有个关键:替换的同时,要留一份"占位符到原值"的映射——因为模型的回答里可能会带回这些占位符,到时候得还原。还有一个细节:替换要从后往前做,否则前面的替换会打乱后面 PII 的位置下标:
def redact(text):
"""入站脱敏:把 PII 替换成占位符,同时留一份"占位符到原值"的映射。"""
hits = sorted(detect_pii(text), key=lambda h: h[1], reverse=True)
mapping = {}
for i, (kind, start, end, raw) in enumerate(hits):
token = f"[{kind}_{i}]" # 占位符,如 [PHONE_0]
mapping[token] = raw
text = text[:start] + token + text[end:] # 从后往前替换,不打乱前面的下标
return text, mapping
def ask_llm(user_question):
"""脱敏后再发给模型:出边界的 prompt 里只有占位符,没有真实 PII。"""
safe_question, mapping = redact(user_question)
prompt = f"请回答用户的问题:{safe_question}"
resp = third_party_llm.chat(prompt) # 真正出境的已是脱敏文本
return restore(resp.content, mapping) # 回答里的占位符再还原回来
这里的认知要点是:入站脱敏的精髓,是用"占位符"换走"真实值",而不是简单地把 PII 删掉或打码成一串星号。为什么一定要用占位符?因为 prompt 里的 PII,往往是模型理解用户意图的一部分。用户说"我手机号 138 某某某和身份证上的不一致",如果你把两处都抹成 ******,模型就看不出这是"两个东西不一致"的问题了——它甚至分不清你抹掉的是一个手机号还是一个名字。而占位符 [PHONE_0] 和 [ID_CARD_0] 不一样:它既不含任何真实信息(出境是安全的),又保留了"这里有一个手机号、那里有一个身份证号、它们是两个不同的东西"这个结构。模型完全能理解"[PHONE_0] 和 [ID_CARD_0] 不一致"的意思,并据此作答。占位符是一个精心设计的平衡点:对外,它脱去了敏感性;对模型,它保住了语义。还有一点,留那份 mapping 不是可有可无的——它是后面"出站还原"这一步的依据,没有它,模型回答里带回的占位符就再也换不回真实值了。数据安全地出了边界,可它还会从模型那里流回来——这要靠第二处设防。
三、出站处理:占位符还原与残留 PII 兜底
第二处设防,设在数据流回边界的那个入口——补的是破绽 2:模型回答里的 PII 原样返回。模型的回答流回来时,有两种需要处理的 PII。第一种是占位符:模型常常会复述用户的话,于是回答里带回了 [PHONE_0] 这样的占位符——这种要用 mapping 还原成真实值,用户才看得懂:
def restore(text, mapping):
"""出站还原:模型回答里若带回了占位符,换回真实值再展示给用户。"""
for token, raw in mapping.items():
text = text.replace(token, raw)
return text
第二种更棘手:裸的、未经占位符化的 PII。它可能是模型自己"幻觉"生成的(编了一个手机号),也可能是某种边角情况下没被入站检测器抓到、又被模型原样吐回来的。这种 PII 不在你的 mapping 里,还原这一步管不到它。所以出站还要加一道兜底检查:
def assert_no_pii_leak(text):
"""出站兜底:模型回答里若仍残留未脱敏的裸 PII,拦下来,绝不直接展示。"""
leaks = detect_pii(text)
if leaks:
kinds = {k for k, *_ in leaks}
log.warning("模型输出残留 PII 类型=%s", kinds)
# 残留的裸 PII 再脱敏一次,绝不把它原样吐给前端
return redact(text)[0]
return text
这里的认知要点是:出站处理要分清两件事——"还原"和"兜底",它们针对的是两种来源完全不同的 PII。还原,针对的是"你自己脱敏出去的占位符":你出境前把真实值换成了 [PHONE_0],模型把 [PHONE_0] 原样带了回来,你要凭 mapping 把它换回真实值——这是一次确定的、一一对应的逆操作,目的是让用户看到他自己说过的话。兜底,针对的是"不在你 mapping 里的裸 PII":它要么是模型幻觉编出来的,要么是入站时漏网的——总之它不是你脱敏出去的,你没有它的 mapping。对这种 PII,你不能还原(没东西可还原),只能再脱敏一次。把这两件事分清楚很重要,因为它们顺序还不能错:必须先还原、再兜底检查。如果你先做兜底检查,那些合法的、即将被还原的占位符背后的真实值还没出现,你检查不到;而还原之后,占位符变回了真实值,这时候做兜底检查,才能把"还原出来的真实值"和"模型自己带的裸 PII"放在一起,统一过一遍——确保最终展示给用户的文本里,不会有任何一个本不该出现的 PII。一句话:还原是"把该给用户看的还给他",兜底是"把不该流出去的再拦一道"。入站和出站都设防了,可数据还会流向第三个地方——日志。
四、日志脱敏:别让 PII 灌进日志和监控
第三处设防,设在日志这个出口——补的是破绽 3:完整 prompt 打进日志,PII 灌满日志系统。这个坑特别容易被忽略,因为大家的注意力都在"发给第三方"上,却忘了日志本身也是一个数据会大量沉淀、而且被很多人很多工具访问的地方。你为了排查方便,把完整 prompt 打进日志——这一下,用户的每一个手机号、身份证号,就永久地、明文地躺在了你的日志系统里。解法不是"以后记得手动脱敏",手动一定会漏;而是给日志框架挂一个过滤器,让所有日志在写出之前,自动过一遍脱敏:
import logging
class PiiRedactFilter(logging.Filter):
"""日志过滤器:任何日志在写出之前,先把消息里的 PII 抹成占位符。"""
def filter(self, record):
if isinstance(record.msg, str):
record.msg = redact(record.msg)[0] # 日志里只留占位符
record.args = () # 防止 %s 参数把 PII 绕进来
return True
# 给 logger 挂上过滤器,从此这个 logger 写的每一条日志都自动脱敏
log.addFilter(PiiRedactFilter())
这里的认知要点是:日志脱敏要做对,关键是想清楚"脱敏应该发生在哪一层"。一个很自然、但是错的想法是:在每一处 log.info 的调用点,手动把 PII 处理掉再打。这错在两点:一是它依赖"每个写日志的人都记得",而人一定会忘——尤其是几个月后来加新功能的同事;二是它依赖"你能想到所有会打到 PII 的地方",而你想不全。正确的做法,是把脱敏下沉到日志框架这一层,做成一个对所有调用点都生效的过滤器。这样一来,脱敏就从一件"靠自觉的事"变成了一件"系统保证的事"——不管谁在哪里写日志,数据在真正落盘之前,都必经过滤器这道关。这背后是一个更普遍的原则:任何安全防护,只要它依赖"人记得去做",它就早晚会失效;只有把它做成"不做就过不去"的强制管道,它才真正可靠。还有个细节别漏了:logging 的 record.args 也要清空——很多人写 log.info("x=%s", pii) 把 PII 放在参数里,过滤器如果只洗了 record.msg、没管 args,PII 照样会从参数那条路漏出去。三处设防齐了,它们都依赖一个底层能力——把 PII 准确地识别出来。
五、PII 识别:正则、校验、上下文的组合
前面三处设防,全都建立在 detect_pii 之上——识别得准不准,直接决定整套防护牢不牢。而纯靠正则的识别,有一个绕不开的毛病:它只看"长得像不像",不看"是不是真的"。一串 18 位数字,正则会判定它是身份证号,可它也可能只是个订单号。识别漏了(漏报)是 PII 泄露,识别多了(误报)则会把正常内容也脱敏掉、破坏语义。降低误报的办法,是在正则的"形似"之上,再加一层"校验"——比如身份证号有一位校验码,算一下就能滤掉一大批"形似而非"的误报:
def is_valid_id_card(s):
"""身份证校验位:滤掉那些"看着像身份证、其实是随便一串数字"的误报。"""
if len(s) != 18:
return False
weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
check = "10X98765432"
total = sum(int(c) * w for c, w in zip(s[:17], weights))
# 前 17 位加权求和,对 11 取模,查出的校验位要和第 18 位一致
return check[total % 11] == s[17].upper()
这里的认知要点是:PII 识别没有"完美"可言,它永远是漏报和误报之间的一场权衡,你要做的是清醒地选择往哪边偏。漏报,是该脱敏的没脱——后果是 PII 泄露,这是安全事故。误报,是不该脱敏的被脱了——后果是正常内容被替换成占位符、语义受损,这是体验问题。这两类错误的代价不对等:一次泄露可能是合规事故,而一次误伤顶多是某句话答得不够好。所以识别策略的总基调,应该是"宁可误报、不可漏报"——正则要写得宽一点,先尽量多抓。但"宁可误报"不等于"放任误报",你要用分层的手段去消化误报:正则负责"形似"这一层,尽量多召回;校验码、长度、上下文关键词负责"实锤"这一层,把明显的误报滤掉。身份证的校验位就是典型——正则先把所有 18 位数字串都捞上来(高召回),校验位再把不合法的踢掉(降误报),两层配合,既不容易漏、又不会脱敏掉一堆订单号。还要承认一个现实:无论你怎么组合,都不可能做到 100% 准确——所以前面那道"出站兜底检查"才必须存在,它就是为"识别这一层总会有漏网之鱼"而准备的最后一张网。主干的设防都齐了,最后是几个把这套防护真正用到生产里才会撞见的工程坑。
六、工程坑:可逆与不可逆、误伤、性能、合规
四处设防之外,还有几个工程坑,不处理就会让你的防护在边角上出问题。坑 1:想清楚 PII 该"可逆"还是"不可逆"脱敏。前面的占位符方案是可逆的(凭 mapping 能换回原值),适合"模型回答需要带回真实值"的场景。但有些场景根本不需要还原——比如你只想统计"有多少不同的用户问过这个问题",那就该用不可逆的脱敏,从源头上断掉还原的可能:
import hashlib, hmac
SECRET = get_secret("pii_hash_key")
def irreversible_mask(raw):
"""不可逆脱敏:用于"只需识别是不是同一个值、不需要还原"的场景。"""
# 加盐 HMAC:同一个输入永远得到同一串哈希,但无法从哈希反推原值
return hmac.new(SECRET, raw.encode(), hashlib.sha256).hexdigest()[:16]
def partial_mask(raw):
"""部分遮挡:展示用,保留头尾、中间打码 —— 既可读又不泄露完整值。"""
if len(raw) <= 4:
return "*" * len(raw)
return raw[:3] + "*" * (len(raw) - 6) + raw[-3:]
有了三种处理手段(可逆占位符、不可逆哈希、部分遮挡),具体某类 PII 用哪种,不该散落在代码里硬编码,而该集中成一份策略配置:
# 脱敏策略配置化:不同 PII 类型按需用不同手段,集中一处、一目了然
REDACTION_POLICY = {
"PHONE": "placeholder", # 手机号:可逆占位符,回答可能要带回
"ID_CARD": "placeholder",
"BANK": "irreversible", # 银行卡号:不可逆哈希,绝不还原
"EMAIL": "partial", # 邮箱:部分遮挡即可
}
def apply_policy(kind, raw):
strategy = REDACTION_POLICY.get(kind, "placeholder") # 默认走可逆占位符
if strategy == "irreversible":
return irreversible_mask(raw)
if strategy == "partial":
return partial_mask(raw)
return None # placeholder 策略交给 redact 统一处理
坑 2:脱敏有性能成本,别无脑全开。每一次调用都对全文跑一遍多个正则,文本很长时不便宜。对明显不可能含 PII 的内部 prompt(比如纯系统指令),可以跳过;对用户输入这种必查的,正则要编译好复用,别每次现编译。坑 3:别脱敏过头、把模型答懵。占位符虽然保留了语义,但同一类 PII 出现很多个时,满屏的 [PHONE_0][PHONE_1][PHONE_2] 也会干扰模型——必要时可在 prompt 里简单交代一句"方括号内是脱敏占位符",帮模型理解。坑 4:脱敏不等于合规,别把它当万能挡箭牌。脱敏是技术手段,而"哪些数据算敏感、能不能发给境外服务、要不要用户授权"是合规与法律问题。技术上做了脱敏,不代表业务上就合规了——涉及真实用户数据出境时,该走的法务评估、用户告知、数据处理协议,一样都不能省。坑 5:给脱敏留审计。每次脱敏命中了哪些类型、各多少处,记一笔统计——这样你才能回答"这段时间到底处理了多少 PII"(也就是开头那第四个问题),也能在事后复盘"有没有什么类型一直没被脱到"。坑 6:测试用例里别放真 PII。解决了线上的 PII,可别让测试代码、示例文档里写满真实的手机号身份证——测试数据要用专门构造的、明显是假的样本。
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| LLM 调用是数据出境 | 调第三方模型即数据越过系统边界,不是内部操作 |
| 边界意识 | 识别数据从哪进、往哪出,在每个出口设防 |
| 入站脱敏 | PII 在 prompt 离开边界前替换成占位符 |
| 占位符而非删除 | 占位符脱去敏感性又保留语义,模型仍能理解 |
| 出站还原 | 凭 mapping 把回答里的占位符换回真实值 |
| 出站兜底 | 检测回答里不在 mapping 的裸 PII,再脱敏一次 |
| 日志脱敏过滤器 | 下沉到日志框架层,所有日志写出前自动脱敏 |
| 漏报与误报权衡 | 识别宁可误报不可漏报,再用校验层消化误报 |
| 可逆与不可逆 | 需还原用可逆占位符,不需还原用不可逆哈希 |
| 脱敏不等于合规 | 脱敏是技术手段,数据出境的法务评估不能省 |
避坑清单
- 别把 LLM 调用当内部操作,它是把用户数据交付给第三方的数据出境。
- 用户输入在发给模型之前必须入站脱敏,PII 替换成占位符再出境。
- 脱敏用占位符而非删除或星号,既脱去敏感性又给模型保留语义。
- 留一份占位符到原值的映射,出站才能把回答里的占位符还原回去。
- 出站先还原占位符,再做残留裸 PII 的兜底检测,顺序不能反。
- 日志脱敏下沉到框架层做成过滤器,别依赖每个调用点手动处理。
- 日志过滤器要连 record.args 一起清,防止 PII 从参数那条路漏出。
- PII 识别宁可误报不可漏报,再用校验码等手段把误报滤掉。
- 想清楚每类 PII 该可逆还是不可逆脱敏,策略集中配置不要硬编码。
- 脱敏是技术手段不是合规挡箭牌,数据出境的法务评估一样要做。
总结
回头看那串"用户 PII 原样出境、回答复述 PII、日志灌满 PII、还不知道流出了多少"的问题,以及我后来在敏感信息防护上接连踩的坑,最该记住的不是某一个脱敏函数的写法,而是我动手前那个想当然的判断——"调大模型,就是把用户的话发过去、把回答拿回来"。这句话错在它把一次"数据出境",当成了一次"内部操作"。我以为数据从用户来、到模型去、再回来,全程都在我的掌控之内。可我忽略了一件最要紧的事:大模型 API 是第三方的服务,我的系统和它之间,横着一道清清楚楚的边界。每一次调用,都是把用户托付给我的数据,实实在在地送出了这道边界——送到了一个我不能控制、不能撤回、不知道它会被怎样记录和使用的地方。"原样发过去"这个动作,轻飘飘的,可它的真实含义是"把用户的手机号、身份证号,亲手交给了一家第三方公司"。这件事本地开发时完全感知不到,因为本地的测试输入里没有真 PII;它只会在真实用户进来、他们的话里塞满真实敏感信息的那一刻,才露出狰狞的样子。
所以做好 LLM 应用的敏感信息防护,真正的工程量不在"写一个脱敏函数"那几行上。脱敏函数本身不难。真正的工程量,在于你要先在脑子里建立起那道"边界",然后在每一个数据跨越边界的出口上,都设好防护:用户输入要出境,你就在它离开之前做入站脱敏,把 PII 换成占位符;模型回答要回流,你就先把占位符还原、再做一道残留 PII 的兜底检查;日志是数据沉淀的另一个出口,你就把脱敏下沉到日志框架层、做成不可绕过的过滤器;而这一切都依赖识别,你就用正则保召回、用校验降误报。这篇文章的几节,其实就是顺着这道边界展开的:先想清楚"原样发给模型"为什么错、为什么它是一次数据出境,再讲入站怎么脱敏、出站怎么还原与兜底、日志怎么脱敏、PII 怎么识别,最后是可逆与不可逆、误伤、性能、合规这几个把防护守扎实的工程细节。
你会发现,这件事和现实里"把一份写满了私人信息的材料,送到外面的代办机构去帮忙办事"完全相通。一个没有边界意识的人会怎么做?他抓起材料原件就送了出去——材料上的身份证号、银行卡号、家庭住址,全都明明白白地摊在那家外部机构的桌上;他顺手还复印了一份塞进自己办公室那个谁都能翻的文件柜;办完事对方把材料退回来,他看都不看就归了档;到头来你问他"你这些天到底把多少人的隐私送出去过",他一脸茫然,因为他从来没记过。而一个有边界意识的人怎么做?他清楚地知道"我的办公室"和"外面的机构"之间有一条线:材料出门之前,他先用记号笔把身份证号、卡号一处处涂掉,只留下办事必需的信息(这就是入站脱敏);他自己留一张小小的对照表,记着涂掉的地方原本是什么(这就是那份 mapping);对方办完退回来,他对照着把该填的填回去,再仔仔细细检查一遍,确认上面没有任何不该露出来的信息,才拿给当事人(这就是出站还原与兜底);连存进文件柜的那份副本,他也一样先涂干净(这就是日志脱敏)。同样是把材料送出去办事,没有边界意识的人,把别人的隐私撒得到处都是;有边界意识的人,数据走到哪一步、以什么面貌示人,他心里都有一本清账——差别不在"办的什么事"本身,只在他脑子里有没有那道边界,以及他有没有在边界的每一个出口,都守住一道关。
最后想说,敏感信息防护做没做对,差距永远不会在"本地开发、自己编几句话测试"时暴露——本地你输入的"登录不上""收不到验证码"全是无害的假话,里面一个真实 PII 都没有,数据"出境"出去的是一堆假数据,日志里记的也是假数据,那道边界从头到尾没被任何真实敏感信息触碰过,你自然觉得"把话发过去、把回答拿回来"一点问题都没有。它只在真实用户涌进来、他们的每一句话里都裹着手机号身份证银行卡的时候才显形。那时候它会用最难堪的方式给你结账:做不好,你会因为入站不脱敏,让成千上万条用户 PII 出了边界、进了第三方的服务器,会因为出站不检查,把 PII 又原样吐回给前端,会因为日志不脱敏,让自己的日志系统变成一个 PII 的明文仓库,还会因为从没记过账,事后连"流出了多少"都答不上来;而做对了,用户的每一个手机号身份证在跨出你的边界前都已被换成无害的占位符,模型的回答回流时被还原又被兜底检查,日志里只剩占位符,每一笔脱敏都有据可查。所以别等"一次 PII 泄露事故"那一刻找上门,在你写下每一行调用大模型的代码时就该想清楚:这次调用会让什么数据出境、PII 入站脱敏了吗、出站还原与兜底了吗、日志脱敏了吗、这笔账记下了吗,这道边界上的每一处关口,我是不是都守住了?这些问题有了答案,你交付的才不只是一个"本地能问能答"的 LLM 应用,而是一套真实用户的隐私在每一个边界出口都被妥善守护的、让人放心的 AI 系统。
—— 别看了 · 2026