跳到主要内容

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 servertonic 服务端,接 Redact 请求,编排三阶段流水线pii-redactor/src/main.rs
proto 绑定.proto 生成的 prost 类型pii-redactor/src/proto.rs + proto/pii_redactor.proto
JSON walker把 JSON 里的字符串抽出来渲染成自然文本;再把脱敏结果写回 JSONpii-redactor/src/json_walker.rs
推理引擎ONNX 推理 + 动态批处理 + BIOES→字符区间还原pii-redactor/src/engine.rs
标签表config.jsonid2label,把模型输出的类别 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 把区间落回、序列化。

一句话记住整体

「JSON 进去 → 抽成自然文本 → 模型标 PII → 区间落回 JSON → JSON 出来」。中间那步之所以要「抽成自然文本」,是下一节的核心。


3. 核心原理(逐个机制,由浅入深)

3.1 为什么要把 JSON「渲染成自然文本」再喂模型

它要解决的小问题: NER 模型是在自然文本(邮件、表单、签名档)上训练的。如果直接把 {"email":"a@b.com","role":"user"} 这坨带引号、花括号、转义符的 JSON 喂进去,模型的准确率会被这些语法噪声毁掉。

思路: 只把 JSON 里的字符串值抽出来,拼成模型熟悉的样子——key: value 一行、行间空一行:

email: a@b.com

note: call me

README 点破了直觉:这种 key: value\n\n 的排版正是模型在训练数据里(表单转储、邮件签名、系统输出)见过几千遍的结构线索。

关键细节:这次渲染不只是拼字符串,还要记住每个值落在文档的哪段字节区间,因为阶段 2 标出来的 PII 位置得能反查回是哪个叶子。walk 在写入每个值前后记下 value_start / value_end(json_walker.rs:218-226):

// json_walker.rs:214-227(真实源码,略节选)
if let Some(k) = parent_key {
rendered.push_str(k);
rendered.push_str(": "); // key 前缀不算进 value 区间
}
let value_start = rendered.len(); // 值的起始偏移
rendered.push_str(s);
let value_end = rendered.len(); // 值的结束偏移
leaves.push(LeafRef { pointer, original: s.clone(), value_start, value_end });
rendered.push_str(LEAF_SEPARATOR); // "\n\n"

LeafRef(json_walker.rs:58-70)里还存了一个 RFC 6901 JSON Pointer(pointer 字段),记录这个叶子在树里的精确路径,阶段 3 靠它把脱敏后的值写回原位。

三个刻意的取舍(都在 walk,json_walker.rs:136-233):

  • 对象的 key 永不脱敏、数字/布尔/null 直接跳过——它们是结构,不是内容(json_walker.rs:229-231_ => {})。
  • 数组元素继承父级的 key 当上下文,于是 messages: hello / messages: world 各成一行(json_walker.rs:168-179)。
  • 空字符串跳过(json_walker.rs:209),不占叶子也不占区间。

3.2 递归展开「字符串化的 JSON」

它要解决的小问题: agent 的 payload 里经常有字符串里套 JSON——比如一个工具结果的 text 字段,它的值本身是一段 "{\"account_id\":\"apn_1KhW56n\"}"。如果只把它当普通字符串,那段里的 PII 就漏了。

思路: 遍历到字符串叶子时,先试着把它再解析一次;如果解析出来是对象或数组(标量不算,免得把 "42" 当数字),就原地替换成解析后的 Value 继续往下走,并记一个「这里原来是字符串化 JSON」的标记(StringifiedMarker),留待阶段 3 由内向外重新字符串化。

// json_walker.rs:186-206(真实源码,节选核心)
if depth < MAX_RECURSION_DEPTH { // 封顶 8 层防病态嵌套
if let Ok(parsed) = serde_json::from_str::<Value>(s) {
if parsed.is_object() || parsed.is_array() { // 只展开对象/数组
*value = parsed;
markers.push(StringifiedMarker { pointer: pointer.clone(), depth: ... });
walk(value, ..., depth + 1, ...); // 对替换后的值再遍历一遍
return;
}
}
}

MAX_RECURSION_DEPTH = 8(json_walker.rs:31)是防线:现实 payload 一般 1-2 层就到底,封顶是让病态输入「吵闹地停」而不是无限递归。

3.3 skip_keys——哪些字段的值不算「内容」

它要解决的小问题: payload 里有一堆结构性元数据字段——roletypetool_use_idmodel……它们的值("user""toolu_bdrk_01K8...")不是用户内容,却会被渲染进文本干扰模型,甚至被误标脱敏。

思路: 维护一份「跳过这些 key 的值」的名单。遍历对象时,key 命中名单就整个跳过(json_walker.rs:149-151)。

名单来源(build_skip_keys,json_walker.rs:361-367)有个**「替换而非合并」**的语义:

  • 调用方没传 skip_keys → 用内置默认名单 DEFAULT_SKIP_KEYS(json_walker.rs:40-54,含 type/role/id/tool_use_id/name/model/$summary/stash_id 等,针对 Anthropic/OpenAI/LangChain 的常见 payload 形状调过)。
  • 调用方传了非空名单 → 完全替换默认名单(不是叠加)。

3.4 ONNX 推理与动态批处理(为什么快)

它要解决的小问题: BERT 家族的编码器在 CPU 上,单条 (1, L) 张量推理时算力根本喂不饱——瓶颈在内存带宽。x86 上从 batch=1 到 batch=8,吞吐能涨 4-6 倍(engine.rs:1-31 的模块注释)。

思路:整个服务只跑一个 ONNX session,架在一条专用 OS 线程上,前面挂一个动态批处理器(dynamic batcher)。每个 gRPC 请求把自己的文本分词、切窗口,把每个窗口当一个 Job 通过 mpsc channel 丢给批处理器;批处理器凑够 max_batch_size 个、等到 max_queue_delay 超时(两者先到为准),就把这一批凑成一个 (B, L) 张量做一次 forward。

多个并发 RPC 的窗口 ──┐
├──► [mpsc] ──► pii-batcher 线程(独占一个 ONNX session)
同一条文本的多窗口 ──┘ │
凑批:满 max_batch_size 或 max_queue_delay 超时
按长度降序排(把 padding 浪费集中到短行)

一次 session.run((B, max_len))

每行按真实长度切片 → 每 token argmax → 回传 oneshot

几个关键工程点(都在 engine.rs):

  • 为什么用独立 OS 线程而非 tokio task(engine.rs:214-269):session.run 会阻塞线程几十毫秒,放 tokio task 里会把同 worker 上的其它 future 全卡住。
  • 健康信号 batcher_alive(engine.rs:129is_healthy287):批处理器线程一旦异常退出(panic、runtime drop),这个 AtomicBool 翻 false,RAIIAliveGuard(engine.rs:230-253)负责翻它并打 FATAL 日志。gRPC 的 redact 入口第一件事就是 is_healthy() 检查(main.rs:91),不健康直接回 UNAVAILABLE 让 k8s 重启 pod——而不是让每个请求都变成难以区分的 INTERNAL 错误。
  • panic 兜底(engine.rs:517-531):session.runcatch_unwind 包住,单条病态输入只让当前这批失败(调用方的 oneshot 收到 Err,重试),不会掀翻整条批处理线程。
  • 它不做什么(engine.rs:23-31):没有 vLLM 式的 PagedAttention / 连续批处理——这是纯编码器,没有 KV cache、没有自回归解码,动态批处理才是对应物。

3.5 长文本切窗口(滑动窗口 + 重叠)

它要解决的小问题: 模型的位置编码有上限(chunk_size 默认 512 token)。超长文本必须切;但简单切会把跨边界的实体(一个横跨两窗口的长邮箱)切碎。

思路:滑动窗口带重叠(split_into_windows,engine.rs:391-451)。步长 stride = effective_chunk_size - chunk_overlap(engine.rs:419),相邻窗口重叠 chunk_overlap(默认 64)个 token,尺寸按「最长的预期实体」定,保证跨边界实体在某个窗口里是完整的。

两个易忽略的细节:

  • effective_chunk_size 要给特殊 token 留位(engine.rs:110-117144-162):推理时会加 CLS/SEP,所以切窗口的预算要先扣掉这些开销,否则喂进去 chunk_size 个内容 token 会溢出位置编码。它是在 load 时用空串探测分词器实际加了几个特殊 token 算出来的。
  • 切片按字符边界安全对齐(clamp_char_boundary,engine.rs:663-683):按 token 偏移换算回原文字节偏移时,可能落在 UTF-8 多字节字符中间,得向下/向上取整到合法边界,免得 &text[a..b] panic。

3.6 本章精华:BIO/BIOES token 标注怎么变回字符区间

这是「token 分类结果怎么变回可替换的字符区间」的核心答案,分三跳:模型 token → 标签跨度 → 字符区间。

第 0 跳:标签方案是什么(labels.rs)

模型对每个 token 输出一个类别 id(整数)。要知道 id 对应什么标签,读 config.json 里的 id2label(LabelMap::from_config_json,labels.rs:21)。这些标签是 BIOBIOES 方案:

前缀含义
OOutside,不是实体普通词
B-XBegin,X 类实体的开头 tokenB-PERSON
I-XInside,X 类实体的中间/后续 tokenI-PERSON
E-XEnd,X 类实体的结尾(仅 BIOES)E-PERSON
S-XSingle,单 token 独立成一个实体(仅 BIOES)S-EMAIL

lookup(labels.rs:54-64)有个关键的兜底:越界或「在界内但没映射到的空洞 id」都回退成 "O"。注释点破了原因——空洞 id 的默认值是空串 "",如果不兜底,split_bioes 会把空串当成「无前缀的 PII 标签」,最终脱敏成 [REDACTED_]

第 1 跳:token 标签 → 标签跨度(bioes_spans,engine.rs:708-778)

拿到每个 token 的标签后,走一个状态机把连续的 token 合并成一个个 LabelSpan{start_token, end_token, label}。核心规则:

  • 遇到 O:结束当前实体(如果有)。
  • 遇到 BS:先结束旧的;B 开一个新的、S 直接自成一个。
  • 遇到 E:如果和当前实体同标签,收尾;否则容错处理(单独成实体)。
  • 遇到 I(及其它):同标签就延长,否则另起。

这个状态机对不规整的标注序列有容错——真实模型偶尔会吐出 I 开头没有 B、或 E 标签跟前面对不上的序列,状态机都尽量兜住而不是崩。split_bioes(engine.rs:780-797)负责把 "B-PERSON" 拆成 ("B", "PERSON");认不出前缀的落到 ("X", label),当作 I 类处理。

第 2 跳:标签跨度 → 字符区间(map_to_char_spans,engine.rs:802-833)

这是最妙的一跳。分词器的 Encoding 自带一张 offsets 表——每个 token 对应原文的 (字节起, 字节止)。于是把 token 跨度换成字符区间只需查表:取跨度内第一个非特殊 token 的起、最后一个的止:

// engine.rs:806-821(真实源码,节选)
for t in s.start_token..s.end_token.min(offsets.len()) {
if special.get(t).copied().unwrap_or(0) == 1 { continue; } // 跳过 CLS/SEP/PAD
let (a, b) = offsets[t];
if a == 0 && b == 0 { continue; } // 无字符贡献的 token
if start.is_none() { start = Some(a); }
end = Some(b);
}

特殊 token(CLS/SEP/PAD)不贡献字符,跳过。结果就是一批 Span{start, end, label},坐标是该窗口原文的字节偏移。

收尾:跨窗口平移 + 合并

每个窗口的区间还要加回它的 text_byte_offset(engine.rs:378-384),平移到整份文档的坐标系;然后 merge_spans(engine.rs:837-854)把重叠或相邻的同标签区间合并——正是这一步把「被滑动窗口切碎、又在重叠区各标一次」的同一个实体拼回一个区间。

一句话记住这条链:token id →(查 id2label)→ BIO/BIOES 标签 →(状态机)→ token 跨度 →(查 offsets 表)→ 字符区间 →(平移+合并)→ 文档级 Span

3.7 区间落回叶子(apply_spans_and_serialize,json_walker.rs:243-305)

阶段 2 给的是文档坐标下的字符区间;现在要把它们分派回各个叶子再替换。

核心是区间求交(json_walker.rs:263-273):每个 Span 跟每个叶子的 [value_start, value_end) 求重叠。这天然处理三种情况:

  • 落在某一个叶子内 → 一段局部区间。
  • 落在 key 前缀或 \n\n 分隔符上 → 跟任何叶子都不重叠 → 静默丢弃(模型偶尔会把 key 或分隔符也标进实体)。
  • 横跨相邻两个叶子(模型把实体标过了 \n\n 边界)→ 裁成两段,分别落到两个叶子。

落回时用 LeafRef.pointer(JSON Pointer)定位树里的槽位写回(pointer_mut,json_walker.rs:282-286)。叶子内的实际替换在 redact_string(json_walker.rs:311-356):先按同标签合并,再从左到右逐段替换成 fmt.replace("{LABEL}", &lbl.to_uppercase()),{LABEL} 换成大写的基础标签名(得到 [REDACTED_PRIVATE_EMAIL])。

最后的「由内向外重新字符串化」(json_walker.rs:291-302):3.2 里被展开的「字符串化 JSON」现在要还原。按 depth 降序(深的先)重新 to_string,保证内层先字符串化、再被外层包进去——否则外层先序列化会把还没还原的内层子树冻住。


4. 它如何被 app-server 调用 / 装配

这是理解「旁路」二字的关键:app-server 侧的集成代码全在 app-server/src/pii_redactor/,通过 gRPC 客户端连过去,跟脱敏服务本身零代码耦合。

客户端装配(启动时,app-server/src/main.rs:963-989)

只有当 PII_REDACTOR_URL(app-server/src/env/connections.rs:8)设了、Feature::PiiRedaction 开启时,才建客户端。连不上不致命——connect 失败只打 warning 并把功能置 None,不阻塞 app-server 启动:

// app-server/src/main.rs:968-986(真实源码,节选)
let pii_redactor = if is_feature_enabled(Feature::PiiRedaction) {
let url = std::env::var(env::connections::PII_REDACTOR_URL).expect(...);
match runtime_handle.block_on(PiiRedactorServiceClient::connect(url.clone())) {
Ok(client) => Some(PiiRedactorClient::new(client)),
Err(e) => { log::warn!("... PII redaction disabled ...: {e:?}"); None }
}
} else { None };

调用点(消费者管线里,app-server/src/traces/processor.rs:308-320)

脱敏跑在 span 消费者里,位置很讲究:build_dedup_batch 去重之后、在 shared_content 写 ClickHouse / Quickwit 索引之前(processor.rs:296-307 注释),这样每个存储层拿到的都是脱敏后的内容。

redact_spans_in_place(app-server/src/pii_redactor/mod.rs:150)做三件事:

  1. 解析哪些项目开了开关(resolve_opted_in_projects,mod.rs:88-111):走缓存的 billing-info 路径查 settings.remove_pii,重复批次几乎零成本;没有任何项目开启就直接返回。
  2. 收集要脱敏的文本 + 记住每段回写去处:用一个 Target 枚举(mod.rs:57-77)标记每段文本该写回哪儿——整条 span.input/span.outputshared_content 的某一行、还是某条给 Quickwit 索引的 trace-new 内容。因为去重管线把内容拆到了好几个 buffer,脱敏要同步覆盖所有副本
  3. 一次 gRPC 批量 redact(PiiRedactorClient::redact,mod.rs:37-53),再按 Target 把结果逐段写回。

最重要的契约:尽力而为,绝不阻断 ingest(mod.rs:137-138)。gRPC 失败、响应条数对不上、解析出错——全部记日志后原样返回、不动这批数据(mod.rs:260-274)。脱敏失败宁可让原文落库,也不能把整条 trace 摄取搞崩。

关于这条 app-server 侧管线(去重、shared_content、trace-new 索引)的全貌,见 03-dedup-content-storage04-storage-realtime;span 数据模型见 02-span-llm-extraction。本章只讲脱敏服务本身。


5. 巧妙之处(可借鉴的技术)

  • 用「渲染成 key: value」把 JSON 适配到 NER 模型的训练分布(json_walker.rs:1-19)——不改模型、不重训,靠输入侧的排版就把准确率救回来。这是「模型无关」得以成立的关键。
  • 偏移驱动的往返映射(LeafRef.value_start/end + JSON Pointer)——渲染时记字节偏移,回写时用区间求交自动处理「落在结构区/横跨叶子」,无需任何特殊分支硬编码。
  • offsets 表把 token 坐标白嫖成字符坐标(map_to_char_spans,engine.rs:802)——不用自己重算 token 到字符的映射,分词器的 Encoding 已经带了。
  • 动态批处理 + 按长度降序排(engine.rs:499-509)——把 padding 浪费集中到短行(短行本来 FLOPs 就少),padding_ratio 指标(engine.rs:560-570)让批处理效率可观测。
  • AliveGuard + is_healthy 让「批处理线程死了」变成可观测的 UNAVAILABLE(engine.rs:230-289main.rs:91)——而不是一堆无法区分的 INTERNAL,让 k8s 能正确地重启而非空转。
  • FP32 而非 int8 是 CPU 上的深思取舍(README「Build & run」):MLAS(ORT 的 CPU GEMM 后端)对 int8 图的 GatherBlockQuantized 布局没有 kernel,反而更慢且刷屏日志;fp16 在 x86 也会隐式 Cast 回 fp32。

6. 边界与局限

  • 它只认「字符串化的 JSON」:输入不是合法 JSON 直接回 INVALID_ARGUMENT(json_walker.rs:104-106)。想脱敏一段裸文本,得先自己包成 JSON 字符串。
  • 对象 key 永不脱敏:如果 PII 出现在 key 名里(如 {"john@x.com": ...}),不会被处理——设计上假定 PII 只在值里。
  • 准确率上限就是那个模型:服务本身只做「适配 + 解码 + 路由」,漏标/误标取决于烘进镜像的 NER 模型;默认是 OpenAI privacy-filter(BIOES),也测过 Piiranha(BIO)。
  • 计费按脱敏前的字节数算(mod.rs:144-149):span_content_bytes 在脱敏前算,开了开关的项目会按原文大小(而非脱敏后)计费,过收的量被脱敏缩水率上界约束(通常几个百分点),为设计简单而接受。
  • 每文本有硬上限:渲染后分词超 PII_MAX_TOKENS_PER_TEXT(默认 24576)回 RESOURCE_EXHAUSTED(main.rs:123-128);一次 RPC 的文本条数超 PII_MAX_TEXTS_PER_REQUEST(默认 1024)也拒(main.rs:97-103)。
  • storage-miss 内容会被送两次(mod.rs:125-128):同一段内容同时在 shared_contentspan_trace_new_contents 里,两份各自独立脱敏(各发一次 RPC),为求正确性接受这点冗余。

7. 代码地图(导航索引)

主题文件路径符号名
gRPC 服务端 + 三阶段编排pii-redactor/src/main.rsGrpcServer::redact
gRPC 接口定义pii-redactor/proto/pii_redactor.protoPiiRedactorService / RedactRequest / RedactResponse
启动参数 / 性能旋钮pii-redactor/src/main.rsArgs
JSON 遍历 + 渲染成自然文本pii-redactor/src/json_walker.rswalk_and_render / walk
叶子引用(偏移 + JSON Pointer)pii-redactor/src/json_walker.rsLeafRef
字符串化 JSON 递归展开pii-redactor/src/json_walker.rswalk(StringifiedMarker)
skip_keys 名单pii-redactor/src/json_walker.rsDEFAULT_SKIP_KEYS / build_skip_keys
区间落回叶子 + 反向序列化pii-redactor/src/json_walker.rsapply_spans_and_serialize / redact_string
引擎加载 + effective_chunk_sizepii-redactor/src/engine.rsEngine::load
批量检测入口pii-redactor/src/engine.rsdetect_spans_batch / detect_spans_for_text
滑动窗口切分pii-redactor/src/engine.rssplit_into_windows / clamp_char_boundary
动态批处理循环pii-redactor/src/engine.rsbatcher_loop / run_batch
健康信号pii-redactor/src/engine.rsis_healthy(AliveGuard)
BIO/BIOES 状态机pii-redactor/src/engine.rsbioes_spans / split_bioes
token 跨度 → 字符区间pii-redactor/src/engine.rsmap_to_char_spans / merge_spans
标签表解析(id2label)pii-redactor/src/labels.rsLabelMap::from_config_json / lookup
app-server 客户端app-server/src/pii_redactor/mod.rsPiiRedactorClient
app-server 脱敏编排app-server/src/pii_redactor/mod.rsredact_spans_in_place / Target
客户端装配(启动)app-server/src/main.rs(PII_REDACTOR_URL + Feature::PiiRedaction)
调用点(消费者管线)app-server/src/traces/processor.rsredact_spans_in_place 调用
权重烘入镜像pii-redactor/DockerfileHF_MODEL / ORT_VERSION build-args