
WebAssembly AI 数据脱敏在浏览器端先跑模型去隐私再上传的方案一、问题背景隐私合规的困局大家好我是一铭。最近在做的一个医疗问诊系统遇到了一个两难问题用户需要上传病历图片做 OCR 识别和分析但病历里全是敏感信息——身份证号、手机号、姓名、住址。直接上传到服务器隐私合规根本过不了。不上传AI 服务又没法分析。有没有一种方案在数据离开用户浏览器之前先用 AI 把敏感信息给脱敏了答案是WebAssembly 本地 AI 模型的端侧脱敏。二、技术方案对比2.1 传统方案 vs WASM 方案维度传统方案服务端脱敏WASM 方案浏览器端隐私风险原始数据经过网络传输数据不出浏览器延迟上传 处理 返回本地处理毫秒级服务器成本需要大量 GPU 实例零服务端成本合规需要复杂的数据处理协议天然合规模型精度可用大模型受限于端侧模型大小2.2 核心技术栈Rust编写核心脱敏逻辑编译到 WASMwasm-bindgenRust 与 JavaScript 互操作本地 NER 模型命名实体识别识别隐私字段wasm-pack一键打包 Rust → npm 包三、Rust 端实现3.1 项目初始化# 创建 Rust 库项目 cargo new --lib privacy-guard cd privacy-guard # 配置为 wasm 项目 # Cargo.toml 关键配置[lib] crate-type [cdylib, rlib] [dependencies] wasm-bindgen 0.2 serde { version 1, features [derive] } serde_json 1 regex 1 # 轻量级 NER 或规则引擎 once_cell 13.2 隐私字段检测规则引擎 轻量模型use wasm_bindgen::prelude::*; use regex::Regex; use once_cell::sync::Lazy; /// 身份证号码正则18位 static ID_CARD_RE: LazyRegex Lazy::new(|| { Regex::new(r\d{17}[\dXx]).unwrap() }); /// 手机号码正则 static PHONE_RE: LazyRegex Lazy::new(|| { Regex::new(r1[3-9]\d{9}).unwrap() }); /// 电子邮箱正则 static EMAIL_RE: LazyRegex Lazy::new(|| { Regex::new(r[a-zA-Z0-9._%-][a-zA-Z0-9.-]\.[a-zA-Z]{2,}).unwrap() }); /// 脱敏结果结构体 #[wasm_bindgen] #[derive(Clone, Debug)] pub struct PrivacyResult { /// 脱敏后的文本 pub sanitized_text: String, /// 检测到的隐私字段数量 pub detected_count: u32, /// JSON 格式的详细脱敏记录 pub details_json: String, }3.3 核心脱敏函数/// 对传入文本执行隐私脱敏 /// /// # 流程 /// 1. 正则匹配已知格式手机号、身份证、邮箱等 /// 2. 对于无法正则匹配的字段姓名、地址用关键词 上下文识别 /// 3. 替换为脱敏标记 #[wasm_bindgen] pub fn sanitize_text(input: str) - PrivacyResult { let mut detected_count 0u32; let mut details Vec::new(); let mut text input.to_string(); // 第一步正则匹配脱敏身份证号 // ID_CARD_RE 是全局的 Lazy 正则编译一次多次使用 text ID_CARD_RE .replace_all(text, |caps: regex::Captures| { detected_count 1; details.push(serde_json::json!({ type: 身份证号, original: caps[0], position: caps.get(0).unwrap().start() })); // 保留前6位 后4位中间用 * 替代 let original caps[0]; format!({}****{}, original[..6], original[original.len()-4..]) }) .to_string(); // 第二步脱敏手机号 text PHONE_RE .replace_all(text, |caps: regex::Captures| { detected_count 1; details.push(serde_json::json!({ type: 手机号, original: caps[0] })); // 保留前3后4中间4位隐藏 let original caps[0]; format!({}****{}, original[..3], original[7..]) }) .to_string(); // 第三步脱敏邮箱 text EMAIL_RE .replace_all(text, |caps: regex::Captures| { detected_count 1; details.push(serde_json::json!({ type: 邮箱, original: caps[0] })); let parts: Vecstr caps[0].split().collect(); if parts.len() 2 { let name parts[0]; // 邮箱用户名只保留首尾字符 if name.len() 2 { format!({}***{}{}, name[..1], name[name.len()-1..], parts[1] ) } else { format!(***{}, parts[1]) } } else { ***.to_string() } }) .to_string(); // 第四步处理可能存在的姓名关键词匹配 // 对于医疗场景常见的关键词如 姓名、患者、病人 等 let name_patterns [姓名, 患者, 病人, 就诊人]; for pattern in name_patterns { if let Some(pos) text.find(pattern) { let start pos pattern.len(); // 取关键词后的 2-4 个中文字符作为姓名范围 if let Some(end) text[start..].find(|c: char| { c \n || c || c 。 || c }) { let name text[start..start end]; if name.chars().count() 4 { detected_count 1; details.push(serde_json::json!({ type: 姓名, original: name })); // 姓名的姓保留其余用 * 替代 let first_char name.chars().next().unwrap(); let masked: String std::iter::once(first_char) .chain(std::iter::repeat(*).take(name.chars().count() - 1)) .collect(); text.replace_range(start..start end, masked); } } } } PrivacyResult { sanitized_text: text, detected_count, details_json: serde_json::to_string(details).unwrap_or_default(), } }3.4 编译为 WASM# 安装 wasm-pack cargo install wasm-pack # 编译为 WASM JS 绑定 wasm-pack build --target web --out-dir pkg编译后会生成pkg/目录包含privacy_guard_bg.wasm核心逻辑的 WASM 二进制privacy_guard.js自动生成的 JS 绑定privacy_guard.d.tsTypeScript 类型定义3.5 实战踩坑WASM 中正则性能陷阱第一版编译到 WASM 后跑在一个 5000 字长病历上竟用了 200ms。排查发现replace_all每次匹配都会做字符串拷贝而 WASM 内存分配比 native 慢一个数量级。三次replace_all等于三次全量文本拷贝。优化方案用RegexSet一趟扫描完成所有类型的匹配只做一次文本拼接// ❌ 三次 replace_all → 三次全量拷贝 text ID_CARD_RE.replace_all(text, ...).to_string(); text PHONE_RE.replace_all(text, ...).to_string(); // ✅ RegexSet 一趟扫描一次结果拼接 let patterns [ID_PATTERN, PHONE_PATTERN, EMAIL_PATTERN]; let set RegexSet::new(patterns).unwrap(); // 遍历所有匹配位置按类型脱敏后一次性构建结果优化后从 200ms 降到不到 20ms。另一个坑是 Unicode 边界——中文姓名脱敏不能用text[..6]一个中文字符 3 字节6 字节可能切到字符中间导致 panic必须用char_indices()按字符边界遍历。规则引擎放 WASM 里可行但跨 WASM 边界每多一次字符串拷贝性能就掉一个数量级尽量在 Rust 侧做完所有处理。四、前端集成4.1 加载和使用import init, { sanitize_text } from ./pkg/privacy_guard.js; async function main() { // 初始化 WASM 模块 await init(); const input 患者张三身份证号110101199001011234 联系电话13812345678邮箱zhangsanhospital.com。 诊断上呼吸道感染。; // ️ 在浏览器端完成脱敏 const result sanitize_text(input); console.log(result.sanitized_text); // 患者张*身份证号110101****1234 // 联系电话138****5678邮箱z***nhospital.com。 // 诊断上呼吸道感染。 console.log(检测到 ${result.detected_count} 个隐私字段); console.log(JSON.parse(result.details_json)); // ✅ 脱敏后安全上传 await fetch(/api/analyze, { method: POST, body: JSON.stringify({ text: result.sanitized_text, privacy_meta: result.details_json }) }); } main();整个交互流程如下实际项目里还遇到过一个真实的隐私泄漏隐患前端脱敏做得很好但如果网络请求在 Chrome DevTools 里被截获脱敏前的原始文本会暴露在请求的 payload 预览中。后来我们在 JS 层加了一道防护——脱敏完成后的文本用一个临时的 Blob 对象传不让原始数据出现在任何网络请求体里。这个教训其实很简单脱敏不只要脱数据还要脱传输链路。五、总结WASM 让 Rust 代码跑在浏览器零成本抽象 高性能正则在浏览器端执行只需微秒级。数据不出浏览器从根本上解决隐私合规问题。用户看到的脱敏结果可以预览、可编辑体验更好。正则 规则引擎的分层策略常见格式用正则快速匹配复杂场景用关键词上下文。如果需要更强的识别能力可以接一个轻量级的 ONNX NER 模型同样编译到 WASM。wasm-pack 一键打包Rust 代码直接变成 npm 包前端同学用起来和普通 JS 库一样。这套方案的局限在于对于非结构化隐私比如病历中描述性的家庭地址正则无法覆盖。后续可以考虑在 WASM 中加载一个轻量 ONNX 模型来做 NER 深度识别。有什么问题欢迎在评论区一起讨论