位置:首页 > 进阶教程 > WebAssembly浏览器端AI数据脱敏方案:隐私处理后再上传

WebAssembly浏览器端AI数据脱敏方案:隐私处理后再上传

时间:2026-08-13  |  作者:深海捕梦者  |  阅读:0

WebAssembly AI 数据脱敏:在浏览器端先跑模型去隐私再上传的方案

一、问题背景:隐私合规的困局

大家好,我是一铭。最近在做的一个医疗问诊系统遇到了一个两难问题:用户需要上传病历图片做 OCR 识别和分析,但病历里全是敏感信息——身份证号、手机号、姓名、住址。直接上传到服务器?隐私合规根本过不了。不上传?AI 服务又没法分析。

WebAssembly AI 数据脱敏:在浏览器端先跑模型去隐私再上传的方案

有没有一种方案,在数据离开用户浏览器之前,先用 AI 把敏感信息给脱敏了?

答案是:WebAssembly + 本地 AI 模型的端侧脱敏。

二、技术方案对比

2.1 传统方案 vs WASM 方案

维度传统方案(服务端脱敏)WASM 方案(浏览器端)
隐私风险原始数据经过网络传输数据不出浏览器
延迟上传 + 处理 + 返回本地处理,毫秒级
服务器成本需要大量 GPU 实例零服务端成本
合规需要复杂的数据处理协议天然合规
模型精度可用大模型受限于端侧模型大小

2.2 核心技术栈

Rust:编写核心脱敏逻辑,编译到 WASMwasm-bindgen:Rust 与 Ja vaScript 互操作本地 NER 模型:命名实体识别,识别隐私字段wasm-pack:一键打包 Rust → npm 包

三、Rust 端实现

3.1 项目初始化

# 创建 Rust 库项目cargo new --lib privacy-guardcd 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 = "1"

3.2 隐私字段检测(规则引擎 + 轻量模型)

use wasm_bindgen::prelude::*;use regex::Regex;use once_cell::sync::Lazy;/// 身份证号码正则(18位)static ID_CARD_RE: Lazy = Lazy::new(|| {Regex::new(r"d{17}[dXx]").unwrap()});/// 手机号码正则static PHONE_RE: Lazy = Lazy::new(|| {Regex::new(r"1[3-9]d{9}").unwrap()});/// 电子邮箱正则static EMAIL_RE: Lazy = 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: ®ex::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: ®ex::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: ®ex::Captures| {detected_count += 1;details.push(serde_json::json!({"type": "邮箱","original": &caps[0]}));let parts: Vec<&str> = 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-packcargo 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.ts:TypeScript 类型定义

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,邮箱:zhangsan@hospital.com。诊断:上呼吸道感染。`;// 🛡 在浏览器端完成脱敏const result = sanitize_text(input);console.log(result.sanitized_text);// 患者:张*,身份证号:110101****1234,// 联系电话:138****5678,邮箱:z***n@hospital.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 深度识别。

有什么问题欢迎在评论区一起讨论!

免责声明:文中图文均来自网络,如有侵权请联系删除,心愿游戏发布此文仅为传递信息,不代表心愿游戏认同其观点或证实其描述。

相关文章

更多

精选合集

更多

大家都在玩

热门话题

大家都在看

更多