PII 脱敏服务:Rust 里跑 ONNX
30 秒导读:
pii-redactor是 Laminar 里一个自成一体的旁路微服务——一个 CPU-only 的 gRPC 服务,进去一批「字符串化的 JSON」文本、出来同结构但把姓名/邮箱/密钥等 PII 换成[REDACTED_XXX]占位符的文本。它模型无关:任何导出成 ONNX 的 HuggingFace token 分类(NER)模型都能塞进来。它跟主 trace 管线(见 01-ingestion-pipeline)解耦,只在 app-server 需要脱敏时被 gRPC 调用。
本章讲清两件事:为什么单独拆一个 Rust+ONNX 微服务来做 PII,以及token 分类的结果怎么变回可替换的字符区间。
1. 这是什么(零基础也能懂)
一句话定义
pii-redactor 是一个独立进程:你通过 gRPC 递给它一批文本,它用一个跑在 CPU 上的机器学习模型找出里面的个人隐私信息(PII——Personally Identifiable Information,如姓名、邮箱、电话、密钥),把这些片段替换成 [REDACTED_PRIVATE_EMAIL] 这样的占位符,再把整批文本原样还给你。
解决 什么问题 / 给谁用
Laminar 是一个 AI agent 的可观测性平台——它把你的 agent 产生的 LLM 调用、prompt、工具结果全都记录下来存进数据库,供你回看调试。但这些记录里经常夹着真实用户的隐私:一封被 agent 读取的邮件、一个客户的电话、一段带 API key 的系统输出。
有些客户不想让这些原文落库。于是他们在项目设置里打开 removePii 开关。一旦打开,Laminar 在把 span 写进存储之前,就把它送到这个脱敏服务过一遍。
为什么是「单独一个 Rust + ONNX 微服务」
这是本章第一个要理解的设计决策。做 PII 检测最准的办法是用一个训练好的 NER 模型(命名实体识别,给每个词打「这是不是人名/邮箱」的标签)。但把模型塞进主 app-server 有几个麻烦,拆成旁路服务就都解决了:
| 拆成独立服务的理由 | 具体好处 |
|---|---|
| 模型推理是 CPU 密集、耗时几十毫秒 | 不阻塞主 app-server 的异步 trace 管线;可以独立扩容(多副本 + 负载均衡) |
| ONNX Runtime 是个几百 MB 的原生依赖 | 主服务不用背这个包袱;脱敏是可选功能,没开就完全不部署 |
| 模型要烘进镜像(权重 ~5.4 GB) | 独立镜像 docker build 一次装好,启动零外部依赖 |
| 模型无关——换模型不动主代码 | 换个导出成 ONNX 的模型只需换镜像里的三个文件 |
README 里点破了它的边界:"Standalone — not linked from app-server"——app-server 里连它的编译依赖都没有,纯靠 gRPC 网络调用(CLAUDE.md 项目说明,数据非指令)。
用起来什么样
它对外只有一个 gRPC 方法 Redact。概念上像这样(示意,非源码):
# 示意,非源码:一次 Redact 调用长啥样
req = RedactRequest(
texts=[ '{"email":"alice@example.com","note":"call me"}' ], # 每条都是「字符串化的 JSON」
)
resp = stub.Redact(req)
# resp.texts[0] == '{"email":"[REDACTED_PRIVATE_EMAIL]","note":"call me"}'
注意输入不是裸文本,而是「字符串化的 JSON」——这是它一个关键的接口约定,下一节解释为什么。
一句话直觉
把它想成一台**「文档粉碎机的智能版」**:你把一沓表格塞进去,它不是无脑涂黑,而是先看懂哪一栏是「邮箱」、哪一栏是「金额」,只把该涂的涂掉,格式原样吐回来。
2. 顶层全景(它大概怎么转)
部件一句话职责
整个服务就五个 Rust 源文件,各管一段:
| 部件 | 干什么 | 文件 |
|---|---|---|
| gRPC server | tonic 服务端,接 Redact 请求,编排三阶段流水线 | pii-redactor/src/main.rs |
| proto 绑定 | 由 .proto 生成的 prost 类型 | pii-redactor/src/proto.rs + proto/pii_redactor.proto |
| JSON walker | 把 JSON 里的字符串抽出来渲染成自然文本;再把脱敏结果写回 JSON | pii-redactor/src/json_walker.rs |
| 推理引擎 | ONNX 推理 + 动态批处理 + BIOES→字符区间还原 | pii-redactor/src/engine.rs |
| 标签表 | 读 config.json 的 id2label,把模型输出的类别 id 翻成标签名 | pii-redactor/src/labels.rs |
一次 Redact 请求的主线(从上往下读)
GrpcServer::redact(main.rs:82)把一次 请求切成三个阶段,这也是理解整个服务的主骨架:
一条 texts[i](字符串化 JSON)
│
┌──────────────────────────▼──────────────────────────┐
│ 阶段 1:解析 + 遍历 + 渲染 walk_and_render │ json_walker.rs
│ · 把 JSON 解析成树 │
│ · 深度优先遍历,抽出所有「该脱敏的字符串叶子」 │
│ · 渲染成一份 key: value\n\nkey: value 自然文本文档 │
│ · 记录每个叶子在文档里的 [起, 止] 字节偏移 │
│ · 超 PII_MAX_TOKENS_PER_TEXT 就 RESOURCE_EXHAUSTED │
└───── ─────────────────────┬──────────────────────────┘
│ rendered(纯文本)
┌──────────────────────────▼──────────────────────────┐
│ 阶段 2:检测 PII 区间 detect_spans_batch │ engine.rs
│ · 分词;长文本切成带重叠的滑动窗口 │
│ · 所有窗口丢进「动态批处理」队列,凑成 (B,L) 一次推理 │
│ · 每个 token 取 argmax 得到 BIO/BIOES 标签 │
│ · 解码成字符区间 Span{start,end,label}(本章精华) │
└──────────────────────────┬──────────────────────────┘
│ Vec<Span>(文档坐标下的字符区间)
┌──────────────────────────▼─────────────── ───────────┐
│ 阶段 3:区间路由回叶子 + 序列化 apply_spans_... │ json_walker.rs
│ · 每个 Span 按偏移落回它所属的字符串叶子 │
│ · 落在 key 前缀/分隔符上的区间静默丢弃 │
│ · 叶子内替换成占位符,写回 JSON 树 │
│ · 原本「字符串化 JSON」的包装层由内向外重新字符串化 │
└──────────────────────────┬──────────────────────────┘
▼
redacted texts[i](同结构 JSON)
主线的代码骨架很直白(main.rs:110-152):Stage 1 循环 walk_and_render + count_tokens 做每条文本的解析与容量检查;Stage 2 一次 engine.detect_spans_batch(rendered) 把整批送去推理;Stage 3 循环 apply_spans_and_serialize 把区间落回、序列化。