12 类格式化 PII 自动脱敏:从正则到 ONNX NER 的分层防御
TL;DR
| 层 | 触发条件 | 成本 | 准确率 | 覆盖范围 |
|---|---|---|---|---|
| L0 正则 + 校验码 | 所有出网 | ~0.1ms / 1KB | 100%(精确) | 12 类格式化 PII |
| L1 用户字典 | 所有出网 | ~0.05ms / 1KB | 100%(精确) | 用户自定义敏感词 |
| L3 LLM 语义 | Tier T3+ 设备,opt-in | ~2s / 1KB | 90% | 自然语言隐私 |
占比目标: 80% 场景 L0 解决 / 15% L1 / 5% L3。
为什么不用纯 NER?
预训练 NER(BERT-NER / spaCy)在长尾自然语言隐私上很强(”小明的舅舅” / “我家住朝阳”),但有两个根本问题:
- 每个 token 都要算 — bge-m3 inference 一段 1KB 文本要 ~80ms(GPU),CPU 要 ~500ms。所有出网都过 NER 会让 chat 响应延迟翻倍。
- 校准困难 — NER 的 false positive(把”特斯拉 Model Y”错判为人名)和 false negative(漏判”ABC1234567″是身份证)都很难调到生产可用。
正则 + 校验码完全相反:
- 不是”AI 能识别”,是数学上必然成立 — 18 位身份证不通过 mod-11-2 校验就绝对不是身份证
- 速度快 ~1000 倍
所以策略是:把能用确定性算法解决的全部用确定性算法,把 ML 留给真正需要语义理解的 5% 场景。
L0:12 类格式化 PII + 校验码
类目清单
| # | 类型 | 正则 | 校验码 |
|---|---|---|---|
| 1 | 中国身份证 (18 位) | \d{17}[\dXx] | ISO 7064 mod-11-2 |
| 2 | 信用卡 (13-19 位) | \b\d{13,19}\b | Luhn (mod-10) |
| 3 | 中国手机号 | 1[3-9]\d{9} | 段前缀白名单 |
| 4 | 邮箱 | RFC 5322 简化版 | 域名格式 |
| 5 | IPv4 | \d{1,3}(\.\d{1,3}){3} | 每段 0-255 |
| 6 | IPv6 | RFC 4291 | 长度 + 段格式 |
| 7 | URL | https?://... | 协议白名单 |
| 8 | 中国银行卡 | \b62\d{14,17}\b | Luhn |
| 9 | OpenAI API key | sk-[A-Za-z0-9]{48} | 长度精确 |
| 10 | Anthropic API key | sk-ant-api\d{2}-[A-Za-z0-9_-]{93} | 长度精确 |
| 11 | GitHub token | ghp_[A-Za-z0-9]{36} | 长度精确 |
| 12 | AWS access key | AKIA[0-9A-Z]{16} | 前缀 + 长度 |
身份证 ISO 7064 mod-11-2 实现
// attune-core/src/pii/patterns.rs
const ID_CARD_WEIGHTS: [u32; 17] =
[7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
const ID_CARD_CHECK_CODES: [char; 11] =
['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'];
pub fn validate_id_card(s: &str) -> bool {
if s.len() != 18 { return false; }
let bytes = s.as_bytes();
let mut sum = 0u32;
for i in 0..17 {
if !bytes[i].is_ascii_digit() { return false; }
sum += (bytes[i] - b'0') as u32 * ID_CARD_WEIGHTS[i];
}
let expected = ID_CARD_CHECK_CODES[(sum % 11) as usize];
let last = bytes[17].to_ascii_uppercase() as char;
last == expected
}
#[test]
fn test_id_card_validation() {
// GB 11643-1999 标准示例
assert!(validate_id_card("110101199003078514"));
// 错误校验位
assert!(!validate_id_card("110101199003078515"));
// 长度错误
assert!(!validate_id_card("11010119900307851"));
}
校验码二次确认非常关键 — 一个 18 位数字串如果 mod-11-2 失败,就绝对不是身份证,不脱敏。这避免了”看起来像但其实不是”的误判(比如某个产品 SKU 恰好 18 位)。
Luhn 算法(信用卡)
pub fn validate_luhn(s: &str) -> bool {
let digits: Vec<u32> = s.chars()
.filter_map(|c| c.to_digit(10))
.collect();
if digits.len() < 13 || digits.len() > 19 { return false; }
let sum: u32 = digits.iter().rev().enumerate()
.map(|(i, &d)| {
if i % 2 == 1 {
let doubled = d * 2;
if doubled > 9 { doubled - 9 } else { doubled }
} else { d }
})
.sum();
sum % 10 == 0
}
Luhn 不证明这是有效信用卡,只证明”通过了 Luhn 校验”。但绝大多数随机 13-19 位数字都不会通过 Luhn(概率 ~10%),所以做为 PII 识别已经足够。
可逆 [KIND_N] 占位符
所有脱敏走 [KIND_N] 格式:
// attune-core/src/pii/mod.rs
pub struct RedactionMap {
forward: HashMap<String, String>, // "张三" → "[PERSON_1]"
reverse: HashMap<String, String>, // "[PERSON_1]" → "张三"
counters: HashMap<PiiKind, usize>,
}
impl RedactionMap {
pub fn redact(&mut self, text: &str) -> String {
// ... 对每类 PII 用对应正则 + 校验码识别
// 替换时维护 counter,相同原文 → 相同占位符
for (kind, regex, validator) in &PII_PATTERNS {
for mat in regex.find_iter(text) {
let original = mat.as_str();
if !validator(original) { continue; }
if !self.forward.contains_key(original) {
let count = self.counters.entry(*kind).or_insert(0);
*count += 1;
let placeholder = format!("[{}_{}]", kind.as_str(), count);
self.forward.insert(original.to_string(), placeholder.clone());
self.reverse.insert(placeholder, original.to_string());
}
}
}
// 应用替换 ...
}
pub fn restore(&self, redacted_text: &str) -> String {
let mut result = redacted_text.to_string();
for (placeholder, original) in &self.reverse {
result = result.replace(placeholder, original);
}
result
}
}
关键点:
- 同一原文映射到同一占位符:
[PERSON_1]始终指代”张三”,云 LLM 看到的是一致的指代 reverse表只在内存里:Drop时清零,永不持久化- Drop 即销毁:
zeroizecrate 保证内存擦除
出网审计日志
每次出网写一行到 outbound_audit 表:
// attune-core/src/store/audit.rs
pub struct OutboundAuditEntry {
pub timestamp: DateTime<Utc>,
pub payload_sha256_prefix: String, // SHA256[:16] 哈希,不存原文
pub chars_before: usize,
pub chars_after: usize,
pub pii_kinds_triggered: Vec<PiiKind>,
pub session_id: Uuid,
pub destination: String, // "openai" / "anthropic" / "ollama-local"
}
用户从 Settings → Privacy 一键 CSV 导出。对法务/合规:
| 时间 | hash | 原始字符数 | 脱敏后字符数 | 脱敏类别 | 目的地 |
|---|---|---|---|---|---|
| 2026-04-22 14:30 | a3f2… | 1240 | 1198 | PERSON, ORG | openai |
| 2026-04-22 14:35 | b7c1… | 850 | 850 | (none) | ollama-local |
“ollama-local” 这条本质没出网(本地推理),但仍记录用作完整性。
L1:用户字典
用户在 Settings → Privacy → User Dictionary 添加自定义敏感词:
# 存到 wp_options.attune_user_dict(JSON)
- kind: PROJECT_CODE
terms: ["Project Aurora", "Op Tigerlily"]
replacement: "[PROJECT_CODE_*]"
- kind: CLIENT
terms: ["客户 X", "客户 Y"]
replacement: "[CLIENT_*]"
L1 100% 用户控制,准确率取决于用户。这层覆盖 L0 正则覆盖不到的”领域专属术语”。
L3:LLM 语义脱敏(Tier T3+ 才启用)
只在 ≥16 GB RAM 设备启用,opt-in。用本地 LLM (qwen2.5-1.5b/3b) 识别自然语言隐私:
系统 prompt:
"提取下面文本中的所有 PII(姓名、地名、组织、日期)。
返回 JSON 列表 [{kind, value, start, end}]。"
用户 prompt:
"我家住朝阳区某街道,舅舅在国贸三期上班"
模型输出:
[
{"kind": "ADDRESS", "value": "朝阳区某街道", "start": 3, "end": 10},
{"kind": "RELATION", "value": "舅舅", "start": 11, "end": 13},
{"kind": "ORG", "value": "国贸三期", "start": 16, "end": 20}
]
性能:本地 qwen2.5-1.5b 处理 1KB 约 2 秒(CPU)/ 0.4 秒(NVIDIA RTX 4060)。所以默认关闭,用户在律师 / 医生场景手动开启。
数据:覆盖率 vs 误判率
在 attune golden set(500 条人工标注的 PII 样本)上:
| 配置 | Recall (找全率) | Precision (准确率) | 平均延迟 |
|---|---|---|---|
| L0 only | 76% | 100% | 0.3 ms |
| L0 + L1 (典型用户字典) | 85% | 100% | 0.4 ms |
| L0 + L1 + L3 (Tier T3+) | 94% | 97% | 2.1 s |
| 纯 ML NER (BERT-base-zh) | 91% | 88% | 80 ms (GPU) |
L0+L1+L3 在保持 97% 精确率的同时,召回率达到 94%。纯 ML NER 召回 91% 但精确率只有 88%,意味着会”误判”12% 的非 PII 内容(比如把产品名当组织名)。对脱敏而言,精确率比召回率更重要 — 多脱掉一些不要紧,错脱(漏脱)则数据出域。
为什么这是”成本感知”的具体应用
- 80% 场景 = 格式化 PII(身份证 / 邮箱 / API key)→ 用零成本正则 + 校验码
- 15% 场景 = 用户自定义术语(项目代号 / 客户名)→ 用本地字典
- 5% 场景 = 自然语言隐私(”我舅舅”)→ 用本地 LLM(仅在 Tier T3+ 启用)
没有”全部塞 LLM”的傲慢,也没有”只用正则”的偷懒。这是 Attune 整体成本感知设计 在 PII 模块的具体投影。
相关阅读
Attune 设计哲学:为什么我们不做又一个云端 AI SaaS
过去三年我们看过 20+ 款 "AI 知识库" 产品,全部走相同范式:上传文档到云端 → 服务端 chunk + embed → 云端 LLM。Attune 选择反向:本地 vault + AES-256-GCM 字段加密 + 显式出网 + 12 类 PII 自动脱敏。代价是工程复杂度 3 倍,回报是"原文不出网"成为可证明的事实而非空头承诺。
阅读全文 →