AI 应用的数据脱敏——什么东西不能发给大模型
AI 应用的数据脱敏——什么东西不能发给大模型
我先说一个真实发生的事情,这件事让我们整个团队都出了一身冷汗。
某天,我们的安全同事在做日志审计,在 AI 调用的请求日志里发现了用户的真实姓名。原来是这么回事:我们有个功能,用户上传一份 Word 文档,AI 帮你分析和总结。有个用户传了一份含有多个人员信息的合同,里面有姓名、电话、身份证号。这些信息被完整地拼进了 Prompt,发给了 OpenAI,同时也完整地落到了我们的请求日志里。
这份日志后来被截图传到了内部群里,讨论"这个功能好不好用"。截图里,有个用户的真实姓名和手机号清晰可见。
没有泄露给外部,但这也是一次严重的内部数据处理不当。
更深层的问题是:用户上传的个人信息,我们有没有权利直接发给 OpenAI?GDPR、《个人信息保护法》对这件事有明确的限制。如果用户不知情,这就是违规的。
从那之后,我把数据脱敏提到了 AI 系统的核心安全问题来处理。
什么数据不能直接发给大模型
先搞清楚风险边界,然后才能设计解决方案。
第一类:个人身份信息(PII)
- 姓名、身份证号、护照号
- 手机号、电子邮件
- 家庭住址、邮政编码
- 生日、年龄(结合其他信息可关联)
第二类:金融敏感信息
- 银行卡号、信用卡号
- 账户密码、交易密码
- 财务数据(收入、资产)
第三类:企业商业机密
- 内部系统架构图、服务器配置
- 未公开的产品规划、定价策略
- 合同条款、客户名单
- 内部代码(尤其是有商业价值的核心逻辑)
第四类:法律敏感信息
- 诉讼记录、法律意见书中的当事人信息
- 医疗记录、诊断信息
风险维度有两个:
- 隐私合规风险:把 PII 发给第三方 AI 服务,可能违反 GDPR/PIPL
- 数据安全风险:敏感信息出现在日志里,可能被内部人员或外部攻击者获取
技术方案:三层防护
第一层:正则规则识别脱敏
对于格式固定的敏感信息(手机号、身份证号、银行卡号),正则规则准确率高、速度快、成本低。这是第一道防线。
@Component
public class RegexSensitiveDataMasker {
private static final List<MaskingRule> RULES = Arrays.asList(
// 中国手机号
new MaskingRule("PHONE_CN",
Pattern.compile("(?<![\\d])(1[3-9]\\d{9})(?![\\d])"),
match -> maskMiddle(match, 3, 4)), // 保留前3后4,中间4位替换
// 身份证号(18位)
new MaskingRule("ID_CARD",
Pattern.compile("(?<![\\d])([1-9]\\d{5}(?:19|20)\\d{2}(?:0[1-9]|1[012])(?:0[1-9]|[12]\\d|3[01])\\d{3}[0-9Xx])(?![\\d])"),
match -> maskMiddle(match, 6, 4)),
// 银行卡号(16-19位数字)
new MaskingRule("BANK_CARD",
Pattern.compile("(?<![\\d])(\\d{4})[\\s-]?(\\d{4})[\\s-]?(\\d{4})[\\s-]?(\\d{4,7})(?![\\d])"),
match -> match.substring(0, 4) + " **** **** " + match.substring(match.length() - 4)),
// 电子邮件
new MaskingRule("EMAIL",
Pattern.compile("[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}"),
match -> maskEmail(match)),
// IPv4 地址(内网)
new MaskingRule("INTERNAL_IP",
Pattern.compile("(?:10|172\\.(?:1[6-9]|2\\d|3[01])|192\\.168)\\.\\d{1,3}\\.\\d{1,3}"),
match -> "[INTERNAL_IP]"),
// 密码字段(key=value 或 key: value 格式)
new MaskingRule("PASSWORD_FIELD",
Pattern.compile("(?i)(?:password|passwd|pwd|secret|token|api[_-]?key)\\s*[=:]\\s*\\S+"),
match -> {
int colonIdx = match.indexOf('=');
if (colonIdx < 0) colonIdx = match.indexOf(':');
return match.substring(0, colonIdx + 1) + " [REDACTED]";
})
);
public MaskingResult mask(String text) {
if (text == null || text.isBlank()) {
return new MaskingResult(text, Collections.emptyList());
}
String result = text;
List<MaskingRecord> records = new ArrayList<>();
for (MaskingRule rule : RULES) {
Matcher matcher = rule.getPattern().matcher(result);
StringBuffer sb = new StringBuffer();
while (matcher.find()) {
String original = matcher.group();
String masked = rule.getMasker().apply(original);
matcher.appendReplacement(sb, Matcher.quoteReplacement(masked));
records.add(new MaskingRecord(rule.getRuleName(), original, masked));
}
matcher.appendTail(sb);
result = sb.toString();
}
return new MaskingResult(result, records);
}
private static String maskMiddle(String text, int prefixLen, int suffixLen) {
if (text.length() <= prefixLen + suffixLen) {
return "*".repeat(text.length());
}
return text.substring(0, prefixLen)
+ "*".repeat(text.length() - prefixLen - suffixLen)
+ text.substring(text.length() - suffixLen);
}
private static String maskEmail(String email) {
int atIndex = email.indexOf('@');
if (atIndex <= 1) {
return "*@" + email.substring(atIndex + 1);
}
return email.charAt(0) + "***" + "@" + email.substring(atIndex + 1);
}
@Data
@AllArgsConstructor
public static class MaskingRule {
private String ruleName;
private Pattern pattern;
private Function<String, String> masker;
}
@Data
@AllArgsConstructor
public static class MaskingRecord {
private String ruleName;
private String original;
private String masked;
}
@Data
@AllArgsConstructor
public static class MaskingResult {
private String maskedText;
private List<MaskingRecord> records;
public boolean hasSensitiveData() {
return !records.isEmpty();
}
}
}第二层:NLP 命名实体识别
正则能处理格式固定的信息,但对于人名、地名这类没有固定格式的信息无能为力。这时候需要 NLP 的命名实体识别(NER)。
我们使用 HanLP 做中文 NER,识别人名(PER)、地名(LOC)、机构名(ORG)等实体,然后做替换或标注。
@Component
@Slf4j
public class NlpEntityMasker {
// HanLP 模型路径
@Value("${hanlp.model.path}")
private String modelPath;
private HanLPClient hanLPClient;
@PostConstruct
public void init() {
// 初始化 HanLP 客户端(使用 RESTful API 模式,避免大模型包嵌入)
this.hanLPClient = new HanLPClient("https://www.hanlp.com/api", null, "zh");
}
public NerMaskingResult maskEntities(String text, Set<EntityType> typesToMask) {
try {
// 调用 NER
List<List<String[]>> result = hanLPClient.parse(text);
// 找到所有需要脱敏的实体
List<EntitySpan> entities = extractEntities(text, result, typesToMask);
// 按位置从后往前替换(避免位置偏移)
entities.sort(Comparator.comparingInt(EntitySpan::getStart).reversed());
StringBuilder masked = new StringBuilder(text);
Map<String, String> entityMapping = new LinkedHashMap<>();
Map<String, Integer> typeCounters = new HashMap<>();
for (EntitySpan entity : entities) {
String original = text.substring(entity.getStart(), entity.getEnd());
String placeholder = generatePlaceholder(entity.getType(), typeCounters);
entityMapping.put(placeholder, original);
masked.replace(entity.getStart(), entity.getEnd(), placeholder);
}
return new NerMaskingResult(masked.toString(), entityMapping);
} catch (Exception e) {
log.warn("NER masking failed, returning original text: {}", e.getMessage());
return new NerMaskingResult(text, Collections.emptyMap());
}
}
/**
* 假名化:用编号替换实体,可以在收到响应后还原
* 比如:"张三" -> "[PERSON_1]",响应里的 "[PERSON_1]" 再还原成 "张三"
*/
private String generatePlaceholder(EntityType type, Map<String, Integer> counters) {
int count = counters.merge(type.name(), 1, Integer::sum);
return "[" + type.name() + "_" + count + "]";
}
@Data
@AllArgsConstructor
public static class NerMaskingResult {
private String maskedText;
private Map<String, String> entityMapping; // placeholder -> original
/**
* 把 AI 响应里的 placeholder 还原为真实内容
*/
public String restoreInResponse(String aiResponse) {
String restored = aiResponse;
for (Map.Entry<String, String> entry : entityMapping.entrySet()) {
restored = restored.replace(entry.getKey(), entry.getValue());
}
return restored;
}
}
}第三层:Spring AI Advisor 自动脱敏
把脱敏逻辑封装成 Spring AI 的 RequestResponseAdvisor,所有经过该 Advisor 的请求自动脱敏,完全透明,业务代码无需修改。
@Component
@Slf4j
public class DataMaskingAdvisor implements RequestResponseAdvisor {
@Autowired
private RegexSensitiveDataMasker regexMasker;
@Autowired
private NlpEntityMasker nlpMasker;
@Value("${ai.security.masking.enabled:true}")
private boolean maskingEnabled;
@Value("${ai.security.masking.ner-enabled:false}")
private boolean nerEnabled;
@Override
public AdvisedRequest adviseRequest(AdvisedRequest request, Map<String, Object> context) {
if (!maskingEnabled) {
return request;
}
// 获取用户消息
String userMessage = request.userText();
if (userMessage == null || userMessage.isBlank()) {
return request;
}
// 第一层:正则脱敏
RegexSensitiveDataMasker.MaskingResult regexResult = regexMasker.mask(userMessage);
String processedText = regexResult.getMaskedText();
if (regexResult.hasSensitiveData()) {
log.warn("Sensitive data detected and masked in AI request: {} fields",
regexResult.getRecords().size());
// 记录发现了敏感数据(但不记录具体内容)
context.put("maskingRecords", regexResult.getRecords().size());
}
// 第二层:NER 脱敏(可选,因为有性能开销)
if (nerEnabled) {
Set<EntityType> typesToMask = Set.of(EntityType.PER, EntityType.ORG);
NlpEntityMasker.NerMaskingResult nerResult = nlpMasker.maskEntities(processedText, typesToMask);
processedText = nerResult.getMaskedText();
// 把 entityMapping 存到 context,用于后续还原
if (!nerResult.getEntityMapping().isEmpty()) {
context.put("entityMapping", nerResult.getEntityMapping());
}
}
// 返回修改后的请求
return AdvisedRequest.from(request)
.withUserText(processedText)
.build();
}
@Override
public ChatResponse adviseResponse(ChatResponse response, Map<String, Object> context) {
// 如果有 NER 实体映射,还原响应中的实体
@SuppressWarnings("unchecked")
Map<String, String> entityMapping = (Map<String, String>) context.get("entityMapping");
if (entityMapping == null || entityMapping.isEmpty()) {
return response;
}
// 还原响应内容(如果用了假名化方案)
String originalContent = response.getResult().getOutput().getContent();
String restoredContent = restoreEntities(originalContent, entityMapping);
if (!restoredContent.equals(originalContent)) {
log.debug("Restored {} entities in AI response", entityMapping.size());
}
// 构建还原后的响应(Spring AI 的响应是不可变的,需要重新构建)
return new ChatResponse(
List.of(new Generation(new AssistantMessage(restoredContent),
response.getResult().getMetadata())),
response.getMetadata()
);
}
private String restoreEntities(String text, Map<String, String> entityMapping) {
String result = text;
for (Map.Entry<String, String> entry : entityMapping.entrySet()) {
result = result.replace(entry.getKey(), entry.getValue());
}
return result;
}
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE; // 最高优先级,在所有 Advisor 之前执行
}
}注册 Advisor 到 ChatClient
@Configuration
public class AiClientConfig {
@Autowired
private DataMaskingAdvisor dataMaskingAdvisor;
@Bean
public ChatClient chatClient(ChatModel chatModel) {
return ChatClient.builder(chatModel)
.defaultAdvisors(
dataMaskingAdvisor, // 数据脱敏(最高优先级)
new MessageChatMemoryAdvisor(chatMemory), // 对话记忆
new SimpleLoggerAdvisor() // 日志
)
.build();
}
}日志里也不能有敏感数据
很多团队做了 AI 调用的脱敏,但忘了处理日志问题。我们那次事故就是这么来的。
日志里不能有:
- 用户输入的原始内容(可能含 PII)
- AI 完整的输出(可能含 PII,比如用户问"帮我分析这份合同",AI 输出里可能原文引用了合同里的人名)
- API Key(这个是基本常识,但还是有人犯错)
我们的做法:
@Component
public class AiAuditLogger {
private static final Logger AUDIT_LOG = LoggerFactory.getLogger("AI_AUDIT");
/**
* 记录 AI 调用审计日志(脱敏版本)
*/
public void logAiCall(AiCallAuditRecord record) {
// 对输入做摘要,不记录原文
String inputSummary = summarize(record.getUserInput(), 100);
// 对输出做摘要,不记录原文
String outputSummary = summarize(record.getAiOutput(), 100);
AUDIT_LOG.info("AI_CALL userId={} featureKey={} model={} " +
"inputTokens={} outputTokens={} costUsd={} " +
"inputSummary=[{}] outputSummary=[{}] latencyMs={}",
record.getUserId(),
record.getFeatureKey(),
record.getModel(),
record.getInputTokens(),
record.getOutputTokens(),
record.getCostUsd(),
inputSummary,
outputSummary,
record.getLatencyMs());
}
private String summarize(String text, int maxLength) {
if (text == null) return "null";
// 先脱敏,再截断
String masked = basicMask(text);
if (masked.length() <= maxLength) {
return masked;
}
return masked.substring(0, maxLength) + "...[" + masked.length() + " chars total]";
}
private String basicMask(String text) {
// 对日志里的内容做基础脱敏(手机号、身份证)
return text
.replaceAll("1[3-9]\\d{9}", "1**********")
.replaceAll("[1-9]\\d{16}[0-9Xx]", "***ID***");
}
}业务场景的特殊处理
场景1:用户有意要 AI 处理含 PII 的文档
比如 HR 系统用 AI 分析员工信息表,这本来就是业务需求,不可能把人名都脱掉。
解决方案:区分数据处理场景,对于"用户明确授权、数据处理在企业内部闭环"的场景,可以使用私有部署的模型(Ollama + 本地模型),数据不出公司网络。
场景2:用户上传的文档里有他人的 PII
比如用户上传了一份合同,合同里有对方公司员工的信息。用户没有对方的授权。
解决方案:在文件上传环节做 PII 扫描,发现 PII 时提示用户:"检测到文档中可能包含他人个人信息,建议先脱敏后再使用 AI 分析"。让用户自己决定。
场景3:企业内部系统代码不能发给外部 AI
有些团队会把内部代码 Copilot,但如果代码里有数据库连接字符串、内网地址、业务逻辑算法,发给 OpenAI 有泄密风险。
解决方案:使用本地代码助手(如 Continue + 本地模型),或者配置严格的 gitignore-like 规则,禁止包含特定文件类型的代码发给外部 AI。
最后说几句
数据脱敏这件事,技术层面并不复杂。最难的是建立意识:不是所有数据都适合发给外部 AI 服务。
很多团队的 AI 功能开发,安全审查是缺失的。产品急着上线,开发忙着实现功能,没人想到"这段用户输入里会不会有隐私"。
我们那次内部事故之后,我推动在 AI 功能的代码审查中增加了一个强制检查项:"这个功能会把用户输入传给外部 API 吗?如果是,是否做了脱敏?"
这个 checklist 加进去之后,发现了好几个遗漏的脱敏点。有些开发同学表示:不是不想做,是真的没想到这个场景里会有 PII。
所以意识和流程,有时候比技术更重要。
