联系我们
首页 › 技术博客 › 12 类格式化 PII 自动脱敏:从正则到 ONNX NER 的分层防御

12 类格式化 PII 自动脱敏:从正则到 ONNX NER 的分层防御

✍️ Attune Privacy 团队 📅 ⏱️ 15 分钟阅读 📊 中级 隐私

TL;DR

层触发条件成本准确率覆盖范围
L0 正则 + 校验码所有出网~0.1ms / 1KB100%(精确)12 类格式化 PII
L1 用户字典所有出网~0.05ms / 1KB100%(精确)用户自定义敏感词
L3 LLM 语义Tier T3+ 设备,opt-in~2s / 1KB90%自然语言隐私

占比目标: 80% 场景 L0 解决 / 15% L1 / 5% L3。

为什么不用纯 NER?

预训练 NER(BERT-NER / spaCy)在长尾自然语言隐私上很强(”小明的舅舅” / “我家住朝阳”),但有两个根本问题:

  1. 每个 token 都要算 — bge-m3 inference 一段 1KB 文本要 ~80ms(GPU),CPU 要 ~500ms。所有出网都过 NER 会让 chat 响应延迟翻倍。
  2. 校准困难 — 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}\bLuhn (mod-10)
3中国手机号1[3-9]\d{9}段前缀白名单
4邮箱RFC 5322 简化版域名格式
5IPv4\d{1,3}(\.\d{1,3}){3}每段 0-255
6IPv6RFC 4291长度 + 段格式
7URLhttps?://...协议白名单
8中国银行卡\b62\d{14,17}\bLuhn
9OpenAI API keysk-[A-Za-z0-9]{48}长度精确
10Anthropic API keysk-ant-api\d{2}-[A-Za-z0-9_-]{93}长度精确
11GitHub tokenghp_[A-Za-z0-9]{36}长度精确
12AWS access keyAKIA[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 即销毁:zeroize crate 保证内存擦除

出网审计日志

每次出网写一行到 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:30a3f2…12401198PERSON, ORGopenai
2026-04-22 14:35b7c1…850850(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 only76%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 模块的具体投影。

相关阅读

· 12 分钟

Attune 设计哲学:为什么我们不做又一个云端 AI SaaS

过去三年我们看过 20+ 款 "AI 知识库" 产品,全部走相同范式:上传文档到云端 → 服务端 chunk + embed → 云端 LLM。Attune 选择反向:本地 vault + AES-256-GCM 字段加密 + 显式出网 + 12 类 PII 自动脱敏。代价是工程复杂度 3 倍,回报是"原文不出网"成为可证明的事实而非空头承诺。

阅读全文 →