楼层: 首页/ 软件技术/ Rust + AI 全栈/ Tokenizer 与推理服务化:从 .onnx 到 /predict
13

Tokenizer 与推理服务化:从 .onnx 到 /predict

Tokenizers & Model Serving

第 11 章你拿到了一个能跑的 Session,但那只覆盖了整条链路的后半段。真实的服务是这样一条流水线:HTTP 请求 → 校验 → 分词(tokenizer)→ 组张量 → 模型推理 → 后处理 → 响应。中间任何一环错了,表现都是"结果不对",而且很难查。这一章把这条流水线补全,并且解决生产上的三个硬问题:批量推理怎么在延迟和吞吐之间取舍、模型常驻内存怎么设计、空闲时怎么把几百 MB 的模型卸掉。

加载 tokenizer.json:模型和分词器必须同源同版本

Cargo.toml 与基本加载

[dependencies] # tokenizers 是 HuggingFace 官方 Rust 实现,Python 的 transformers 底层就是它 tokenizers = { version = "0.21", features = ["http"] } # 生产上强烈建议把 tokenizer.json 一起打进镜像/包,不要运行时去 HF 下载 // src/tokenize.rs use tokenizers::Tokenizer; use std::path::Path; pub fn load_tokenizer(path: &Path) -> anyhow::Result<Tokenizer> { // from_file 会自动识别 tokenizer.json(HF 的"自包含"格式,含词表+规则+后处理) let tokenizer = Tokenizer::from_file(path) .map_err(|e| anyhow::anyhow!("加载 tokenizer 失败 {}: {e}", path.display()))?; // 加载后必须做的自检:模型真正需要的那几个 special token 在不在 // 缺 CLS/SEP/PAD 时不会报错,只会让结果悄悄变差 —— 这是最恶心的坑 for name in ["[CLS]", "[SEP]", "[PAD]", "[UNK]"] { if tokenizer.token_to_id(name).is_none() { anyhow::bail!("tokenizer 缺少 special token {name},检查是否用了配套的词表"); } } Ok(tokenizer) } // 顺手把关键信息打出来,部署时对不上版本第一时间就能发现 pub fn describe(t: &Tokenizer) { println!("vocab_size = {}", t.get_vocab_size(true)); println!("cls = {:?}, sep = {:?}, pad = {:?}", t.token_to_id("[CLS]"), t.token_to_id("[SEP]"), t.token_to_id("[PAD]")); // 一个非常实用的"指纹":对固定文本分词,把 id 序列打印出来 // 换模型/换分词器时对比这一串,能立刻发现不匹配 if let Ok(e) = t.encode("版本自检", true) { println!("probe ids = {:?}", e.get_ids()); } }
文件内容要不要进镜像
tokenizer.json词表 + 归一化规则 + 预分词 + 后处理模板(special token 的插入方式)必需。Rust 侧只需要这一个文件
tokenizer_config.jsonPython 侧用:默认 max_length、pad_token、模型类型可以不进,但里面的 max_length 约定值得抄进你的配置
vocab.txt / vocab.json纯词表不需要(已包含在 tokenizer.json 里)
config.json模型结构超参(层数、hidden size、id2label)建议进:id2label 常从它读,别在代码里硬编码类别名
special_tokens_map.jsonspecial token 的别名映射一般不需要
坑:tokenizer 与模型版本不匹配,结果"能跑但错了"

这是最隐蔽的一类 bug:模型能加载、推理能跑完、接口返回 200,但预测结果就是不准。原因通常是词表版本差异——比如模型是 bert-base-chinese(中文词表 21128),而你用了 bert-base-uncased 的 tokenizer.json(英文词表 30522)。同一个汉字在两边映射到完全不同的 id,对模型来说输入是乱码。

防住它有三个动作:① 用导出模型的那份 tokenizer(optimum-cli export 会把两个文件一起产出,别手动拼凑);② 做版本指纹自检——对固定文本 "版本自检" 分词,把 id 序列写进单元测试,换文件就会红;③ 在 Python 侧用同一份文件和同一段文本跑一遍,与 Rust 侧逐 id 对比。这三步花十分钟,能省掉后面几天的排查。

encode 与 decode:special tokens、attention mask、type ids

一次 encode 产出的不只是一串 id,还有一个 Encoding 结构,里面装着模型需要的全部信息。

单条编码与各个输出的含义

use tokenizers::Tokenizer; let tokenizer: Tokenizer = load_tokenizer(Path::new("models/bert/tokenizer.json"))?; // 第二个参数 add_special_tokens = true 会按后处理模板自动加 [CLS] / [SEP] // 千万不要自己手动拼 special token,交给 tokenizer 的 post_processor 做 let enc = tokenizer.encode("这家餐厅的服务态度非常好", true)?; // 1) input_ids:送进模型的主输入 let ids: &[u32] = enc.get_ids(); // 2) attention_mask:1 = 真实 token,0 = padding。缺了它模型会把 pad 当内容算 let mask: &[u32] = enc.get_attention_mask(); // 3) token_type_ids:用于"句子对"任务区分第一句/第二句;单句任务全 0 // BERT 类模型需要它,RoBERTa / 很多新模型不需要 let type_ids: &[u32] = enc.get_type_ids(); // 4) tokens:还原成人能看懂的 token 串,调试分词结果时必看 println!("tokens = {:?}", enc.get_tokens()); // 5) special_tokens_mask:标出哪些位置是 [CLS]/[SEP]/[PAD],后处理时用得到 let special = enc.get_special_tokens_mask(); // 6) offsets:每个 token 对应原文的字节区间,做实体抽取(NER)时必需 let offsets = enc.get_offsets(); // decode:把 id 还原成文本。skip_special_tokens 一般要开,否则会带出 [CLS] let text = tokenizer.decode(ids, true)?; println!("还原 = {text}"); // 一段可直接放进单元测试的自检代码 #[test] fn tokenizer_fingerprint_is_stable() { let t = load_tokenizer(Path::new("models/bert/tokenizer.json")).unwrap(); let e = t.encode("版本自检", true).unwrap(); // 换 tokenizer 文件时这个断言会失败,逼你确认"是故意换的还是搞错了" assert_eq!(e.get_ids(), &[101, 2345, 6789, 102]); }

论为什么 attention_mask 不能省,以及 padding 的真正危害

① Transformer 的自注意力是"每个 token 看所有 token"。如果不告诉模型哪些位置是 padding,它会把 pad 当作真实内容参与注意力计算,结果就是"同一句话在不同 batch size 下得到不同答案"——这是极难排查的诡异 bug:单条测试通过,批量上线就飘。

② 更隐蔽的是 padding 位置的选择。BERT 用 [PAD](id 0,非特殊 embedding 但也参与计算),而某些模型要求右侧 padding。右侧 padding 还会影响"取哪个位置做分类"——如果模型用"最后一个 token 的 hidden state"做分类,而你 padding 加在了右边,那取到的就是 pad 的位置。这类问题只能靠"与 Python 侧逐值对比"发现。

③ 所以工程上的办法是"把 mask 当作模型的一部分":只要模型声明了 attention_mask 输入,就必须传,且必须由同一份 tokenizer 产出。不要自己用 vec![1; len] 造 mask——你迟早会忘了 padding 的存在。

padding 与 truncation:两个必须显式配置的策略

全局默认策略(with_padding / with_truncation)

use tokenizers::{PaddingParams, PaddingStrategy, TruncationParams, TruncationStrategy, TruncationDirection, Tokenizer}; pub fn configure(tokenizer: &mut Tokenizer, max_len: usize) -> anyhow::Result<()> { // 1) padding 策略:批内最长(动态,省算力)还是固定长度(形状恒定,省重编译) tokenizer.with_padding(Some(PaddingParams { // BatchLongest:补齐到这一批里最长的长度 —— 推荐的默认值 // Fixed(len):永远补齐到固定长度 —— 静态 shape 模型必须用它 strategy: PaddingStrategy::BatchLongest, // 右侧 padding 是 BERT 类模型的默认约定;GPT 类是左侧 direction: tokenizers::PaddingDirection::Right, // 补齐到 8 的整数倍,便于某些硬件/算子做向量化 pad_to_multiple_of: Some(8), // pad_token / pad_id 一般不用改,tokenizer.json 里已经声明好了 ..Default::default() })); // 2) truncation 策略:超长时从哪里砍 tokenizer.with_truncation(Some(TruncationParams { max_length: max_len, // LongestFirst:先砍最长的序列(批处理时能让长度分布更均匀) strategy: TruncationStrategy::LongestFirst, // stride = 0 表示不重叠;做滑窗长文本时要设成 > 0 stride: 0, // 保留开头还是结尾:新闻/长文一般保留开头(Direction::Right) // 但"关键词在结尾"的场景(比如客服对话)就该用 Left direction: TruncationDirection::Right, }))?; Ok(()) } // 配好之后每次 encode 自动生效,不用在调用处重复传参 let enc = tokenizer.encode("很长的文本...", true)?; assert!(enc.get_ids().len() <= max_len);
策略取值什么时候用 / 风险
PaddingBatchLongest动态 shape 模型的默认选择,最省算力;代价是每批形状不同,模型可能反复重编译内核
Fixed(n)静态 shape 模型必须用;形状恒定,硬件利用率稳定;短文本浪费大量算力
pad_to_multiple_of对齐到 8/16 的倍数,配合量化模型和向量化算子有明显收益
TruncationLongestFirst批处理推荐,砍最长的能让批内长度更接近
OnlyFirst只截断第一条序列(句子对任务)
direction = Right保留头部。长文分类、摘要输入常用
stride > 0滑窗切分长文本,相邻窗口重叠 stride 个 token。必须配合"多段结果聚合"逻辑,否则只用第一段仍是静默丢内容
坑:max_length 截断是静默的,内容丢了但没人知道

这是 NLP 服务上线后最常见的"效果不达预期"原因。用户提交一篇 3000 字的产品评论,tokenizer 默默砍到 512,模型只看了前六分之一,输出一个"中评"。日志里没有任何异常,接口耗时还很漂亮(因为输入短了)。

防住它的做法有三层:① 显式检查并记录截断——用 enc.get_ids().len() == max_len 判断"可能被截断了",把原文长度、截断后长度打进日志或指标,出问题时能立刻看出是"模型不行"还是"输入被砍";② 让接口层明确拒绝或提示——超长输入返回 422 并告知上限,或者明确降级为"只分析前 512 token";③ 真的需要全文就用滑窗 + 聚合(分段推理后取平均/取最大/用第二个模型融合),但要把成本算进去。

把"静默丢内容"变成"可观测"

const MAX_LEN: usize = 512; pub struct Prepared { pub ids: Vec<i64>, pub mask: Vec<i64>, // 把这两个数带出去,指标里就能看到"有多少请求被截断" pub raw_len: usize, pub truncated: bool, } pub fn prepare(tokenizer: &Tokenizer, text: &str) -> anyhow::Result<Prepared> { // 先不加 truncation 编一次,拿到"真实长度"用于判断 // 大文本场景下这次额外的编码开销可接受;追求极限性能可以改用 tokenizer.tokenize 估长 let full = tokenizer.encode(text, true)?; let raw_len = full.get_ids().len(); let (ids, mask, truncated) = if raw_len > MAX_LEN { // 走配置好的 truncation 路径 let enc = tokenizer.encode(text, true)?; (enc.get_ids(), enc.get_attention_mask(), true) } else { (full.get_ids(), full.get_attention_mask(), false) }; if truncated { // 关键:截断一定要有日志/指标,否则就是静默丢数据 tracing::warn!( raw_tokens = raw_len, kept_tokens = ids.len(), "输入超过模型上限,已截断" ); } Ok(Prepared { ids: ids.iter().map(|&i| i as i64).collect(), mask: mask.iter().map(|&m| m as i64).collect(), raw_len, truncated, }) }

把模型包成 axum 服务:请求队列 + 常驻内存

到这里零件都齐了:tokenizer + Session。接下来是"怎么把它们组装成一个能扛并发的服务"。核心约束只有一条:Session::run 需要 &mut self,所以不能让多个请求同时持有它。

三种并发方案,选一个

方案做法优劣与适用
Mutex 串行化 Arc<Mutex<Session>> 最简单、最稳。吞吐有上限(一次只跑一条推理),但 GPU 推理通常本来就是串行的,小规模服务推荐先用这个
每 worker 一个 Session 起 N 个 worker 任务,各持有一个 Session,请求分发到队列 吞吐随 N 线性提升(CPU 够的前提下);内存/N 倍增长(每个 Session 有独立权重与缓冲区),N 要按内存预算定
动态批处理 单个 worker 攒批后一次 run 吞吐最高、单位成本最低;实现最复杂,且会抬高 P50 延迟。见第 11 章的 batch_worker

一个完整的 /predict 服务骨架(队列 + 单 worker + 常驻模型)

use std::sync::Arc; use axum::{extract::State, http::StatusCode, routing::post, Json, Router}; use serde::{Deserialize, Serialize}; use tokio::sync::{mpsc, oneshot}; use tokenizers::Tokenizer; #[derive(Deserialize)] pub struct PredictReq { pub text: String } #[derive(Serialize)] pub struct PredictResp { pub label: String, pub score: f32, // 把可观测性信息返回给调用方,排查时非常有用 pub tokens: usize, pub truncated: bool, pub latency_ms: u64, } pub struct ModelWorker { // tokenizer 是 Send + Sync 的,可以直接 Arc 共享给多个请求处理器 pub tokenizer: Arc<Tokenizer>, // 一个 channel 就把"并发请求"收敛成"串行推理" pub tx: mpsc::Sender<Job>, // 用 Semaphore 做背压:队列满了要快速失败,而不是无限堆积 pub inflight: Arc<tokio::sync::Semaphore>, } pub struct Job { pub ids: Vec<i64>, pub mask: Vec<i64>, pub reply: oneshot::Sender<anyhow::Result<(usize, f32)>>, } pub async fn predict( State(w): State<Arc<ModelWorker>>, Json(req): Json<PredictReq>, ) -> Result<Json<PredictResp>, (StatusCode, String)> { let started = std::time::Instant::now(); // 1) 背压:超过并发上限直接 429,别让请求在内存里堆成雪崩 let _permit = w.inflight.clone().try_acquire_owned() .map_err(|_| (StatusCode::TOO_MANY_REQUESTS, "服务繁忙,请稍后重试".to_string()))?; // 2) 分词:这一步是纯 CPU,且 tokenizer 是 Sync 的,可以并发做 let p = prepare(&w.tokenizer, &req.text) .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; // 3) 投递到推理队列,等结果 let (tx, rx) = oneshot::channel(); w.tx.send(Job { ids: p.ids, mask: p.mask, reply: tx }).await .map_err(|_| (StatusCode::SERVICE_UNAVAILABLE, "推理 worker 不可用".to_string()))?; // 4) 给推理加超时:模型卡住时不能让请求无限等 let (class_id, score) = tokio::time::timeout(std::time::Duration::from_secs(10), rx) .await .map_err(|_| (StatusCode::GATEWAY_TIMEOUT, "推理超时".to_string()))? .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "worker 异常退出".to_string()))? .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(PredictResp { label: LABELS[class_id.min(LABELS.len() - 1)].to_string(), score, tokens: p.raw_len, truncated: p.truncated, latency_ms: started.elapsed().as_millis() as u64, })) } // 路由与状态装配 pub fn router(w: Arc<ModelWorker>) -> Router { Router::new() .route("/predict", post(predict)) .route("/healthz", axum::routing::get(|| async { "ok" })) .with_state(w) }

worker 主循环:把 Session 关在一个任务里,别人只能通过 channel 访问

pub async fn run_worker(mut session: ort::session::Session, mut rx: mpsc::Receiver<Job>) { while let Some(job) = rx.recv().await { // 把两个长度不同的输入补齐到同一长度:单条推理也要保证形状一致 let len = job.ids.len().max(1); let ids = ndarray::Array2::from_shape_vec((1, len), { let mut v = job.ids.clone(); v.resize(len, 0); // pad_id = 0 v }).unwrap(); let mask = ndarray::Array2::from_shape_vec((1, len), { let mut v = job.mask.clone(); v.resize(len, 0); // padding 位置的 mask 必须是 0 v }).unwrap(); let result = session .run(ort::inputs![ "input_ids" => ort::value::TensorRef::from_array_view(&ids).unwrap(), "attention_mask" => ort::value::TensorRef::from_array_view(&mask).unwrap(), ]) .map_err(|e| anyhow::anyhow!("推理失败: {e}")) .and_then(|outputs| { let logits = outputs["logits"].try_extract_array::<f32>()?; let row: Vec<f32> = logits.index_axis(ndarray::Axis(0), 0).iter().copied().collect(); let probs = softmax(&row); let best = argmax(&probs); Ok((best, probs[best])) }); // 无论成功失败都要回信,否则调用方会一直等到超时 let _ = job.reply.send(result); } // 通道关闭(发送方全部 drop)时退出循环,Session 在这里被释放 tracing::info!("推理 worker 退出"); }
坑:没有背压的推理服务,一次流量高峰就把自己打挂

推理服务有个残酷的性质:它的处理能力是硬上限,且排队会放大延迟而不是提升吞吐。如果请求进来就无脑入队,队列会无限增长,内存被撑爆,而且每一个请求都要等几秒才开始执行——用户看到的是"全部超时",而不是"一部分被拒绝、其余正常"。

正确做法是三件事:① 用 Semaphore 或定长 channel 做背压,超过上限立刻返回 429(客户端可以重试或降级),而不是让请求堆积;② 给推理加超时(tokio::time::timeout),模型卡住时不要让请求永久挂着;③ 把队列长度和排队时长做成指标——"队列长度持续增长"是扩容的唯一正确信号,看着 CPU 利用率扩容往往已经晚了。

冷启动与模型常驻:1 分钟空闲卸载怎么做

模型加载很贵(几百毫秒到几秒 + 几百 MB 到几 GB 内存),但并不是每个服务都需要 7×24 常驻。典型场景是"多模型路由":一个服务托管十几个模型,每个模型的流量都很稀疏,全常驻内存直接爆。

策略做法适用
启动即常驻进程启动时加载,永不卸载单模型、流量稳定。最简单最稳,先用这个
懒加载 + 永久常驻第一次用到才加载,之后不卸载多模型但每个模型最终都会被反复用;要处理"首次请求慢"和"并发首次加载只加载一次"
懒加载 + 空闲卸载超过 N 秒没人用就释放,下次再懒加载模型多、流量稀疏(本章要讲的方案);代价是"冷启动延迟"会周期性出现
限制并发驻留数最多同时保留 K 个模型,按 LRU 淘汰模型数量和内存都不可控时的兜底;实现复杂度最高

空闲卸载:用"最后使用时间 + 定时扫描"实现,1 分钟回收

use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use tokio::sync::{Mutex, RwLock}; /// 一个可热插拔的模型持有者 pub struct ModelSlot { // 读多写少:用 RwLock,请求路径只需要 read + 更新一下时间戳 inner: RwLock<Option<LoadedModel>>, // 上次使用时刻(Unix 秒)。用原子变量避免请求路径写锁 last_used: AtomicU64, // 加载锁:保证"并发首次请求"只有一个真的去加载 loading: Mutex<()>, model_path: String, idle_ttl_secs: u64, } pub struct LoadedModel { pub session: ort::session::Session, pub tokenizer: Arc<Tokenizer>, } impl ModelSlot { pub fn new(model_path: String, idle_ttl_secs: u64) -> Self { Self { inner: RwLock::new(None), // 初始设为 0,保证第一轮扫描就会把它当作"空闲"(此时本来也没加载) last_used: AtomicU64::new(0), loading: Mutex::new(()), model_path, idle_ttl_secs, } } /// 取模型:已加载就直接用;没加载就(在锁保护下)加载一次 pub async fn acquire(&self) -> anyhow::Result<()> { // 快路径:读锁检查,命中就只更新一下时间戳 { let guard = self.inner.read().await; if guard.is_some() { self.touch(); return Ok(()); } } // 慢路径:加"加载锁"串行化。多个并发请求只会有第一个真的加载, // 其余的拿到锁之后会重新检查(double-check),发现已加载就直接返回 let _g = self.loading.lock().await; { let guard = self.inner.read().await; if guard.is_some() { self.touch(); return Ok(()); } } // 真正的加载。注意这里没有持有任何锁(除了 loading),不会阻塞其他模型 tracing::info!(path = %self.model_path, "开始加载模型(冷启动)"); let started = std::time::Instant::now(); let tokenizer = Arc::new(load_tokenizer(std::path::Path::new(&self.model_path))?); let session = build_session(&format!("{}/model.onnx", self.model_path), 4)?; tracing::info!(elapsed_ms = started.elapsed().as_millis() as u64, "模型加载完成"); *self.inner.write().await = Some(LoadedModel { session, tokenizer }); self.touch(); Ok(()) } fn touch(&self) { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); // Relaxed 就够了:这里只是一个"大致时间",不需要跨线程的严格顺序 self.last_used.store(now, Ordering::Relaxed); } /// 空闲判定 + 卸载 pub async fn maybe_unload(&self) -> bool { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); let last = self.last_used.load(Ordering::Relaxed); // last == 0 表示从未使用过,此时本来就没加载,不用处理 if last == 0 || now.saturating_sub(last) < self.idle_ttl_secs { return false; } let mut guard = self.inner.write().await; if guard.is_some() { // 直接置 None,Session 与 Tokenizer 会在这里被 Drop,内存立刻归还 *guard = None; // 关键:卸载后把时间戳归零,避免下一轮扫描重复触发 self.last_used.store(0, Ordering::Relaxed); tracing::info!(path = %self.model_path, "空闲超时,模型已卸载"); return true; } false } } /// 后台扫描任务:每 10 秒检查一次,超过 idle_ttl_secs 未使用就卸载 pub fn spawn_idle_reaper(slots: Vec<Arc<ModelSlot>>) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { // 扫描间隔要比 TTL 小得多,否则实际空闲时间会长出"一个扫描周期" let mut ticker = tokio::time::interval(std::time::Duration::from_secs(10)); loop { ticker.tick().await; for slot in &slots { if slot.maybe_unload().await { // 卸载动作做成指标,能看出"是不是在被反复加载卸载" tracing::info!("idle_unload_total", "模型空闲卸载计数 +1"); } } } }) }

论空闲卸载的账:省下的内存 vs 付出的冷启动延迟

① 收益是可量化的。一个 BERT-base 的 ONNX fp32 模型约 400MB 常驻,int8 量化后约 100MB。托管 10 个模型全常驻就是 4GB;空闲卸载能让峰值内存降到"同时活跃的模型数 × 单模型大小"。对于"每个模型每天只被调用几十次"的场景,这是唯一能让成本可行的方案。

② 代价也很具体:冷启动延迟。加载 400MB 模型 + 建 Session + 图优化,实测常在 1~3 秒。这个延迟会周期性地出现在"空闲后第一个请求"上,用户感受非常明显。所以 TTL 不能拍脑袋——要做到"把 TTL 设置成远大于典型请求间隔":如果某个模型平均每 2 分钟被调用一次,TTL 设 60 秒就会不停加载卸载,比常驻还费资源。

③ 三个配套措施缺一不可。一是预热:服务启动时对高频模型先跑一次空推理(或直接加载),把首次延迟吃掉;二是加载去做限流:"卸载-加载"震荡时,用一个最小的常驻时间(比如加载后至少存活 5 分钟)来防止抖动;三是把冷启动做成指标,能直接看到"有多少请求吃到了 1.5 秒的冷启动",据此决定该调 TTL 还是该改成常驻。

常见坑清单

现象原因修法
结果能出,但准确率明显低于 Python 侧 tokenizer 与模型不是同源版本(词表不一致),或没传 attention_mask 用导出模型时产出的那份 tokenizer.json;加"固定文本 → 固定 id 序列"的指纹测试;与 Python 侧逐 id、逐值对比
同一句话单条调用与批量调用结果不同 padding 位置没有正确 mask;或取了错误的位置做分类 保证 attention_mask 的 pad 位为 0;确认模型取的是 [CLS] 还是最后一个真实 token 的位置
长文本效果差,但接口没有报错 max_length 静默截断,内容丢了没人知道 显式记录"原文长度 / 截断后长度"并打 warn 日志与指标;接口层对超长输入返回明确提示
编译报 Tokenizers 迁移到别的线程失败 老版本 Tokenizer 不是 Sync 升到 tokenizers 0.19+(已实现 Sync),用 Arc<Tokenizer> 共享;否则每线程 clone 一份
并发压测时偶发崩溃 / 结果错乱 多个请求同时持有同一个 Session 调 run Session::run 需要 &mut self:用 Mutex 串行化、或每 worker 一个 Session、或做批处理
流量高峰后服务 OOM / 全部超时 推理队列无上限,请求无限堆积 用 Semaphore/定长 channel 做背压,超限返回 429;给推理加超时;把队列长度做成指标
内存周期性涨落,CPU 也周期性飙高 空闲 TTL 设得太短,模型被反复加载卸载(震荡) 把 TTL 调到远大于典型请求间隔;加"最小常驻时间";观察 idle_unload_total 指标
首次请求特别慢(1~3 秒) 冷启动:加载权重 + 图优化 启动时预热高频模型;打开 with_optimized_model_path 缓存优化结果;或对首次请求单独放宽容忍时间
换模型后线上结果整体偏移,但没人发现 没有对照验证流程 上线前跑固定的"黄金样例集",对比新旧模型的输出分布;把关键样例的 id 序列与预测结果纳入回归测试
坑:把"分词"当成无所谓的细节

整条推理链路里,模型是唯一一个"输入对了就一定对"的环节(同样的张量必然得到同样的输出)。所以线上结果不对时,第一嫌疑永远是链路上游:分词、special token、mask、维度顺序、截断。

但上游的问题恰恰最容易"看起来正常":分词不会报错,mask 全 1 也能跑,截断会安静地发生。所以必须建立"逐层验证"的习惯:

① 分词层:固定文本的 id 序列写成断言;② 张量层:打印 shape 与 dtype,与模型声明的输入对比;③ 模型层:拿一条样例,用 Python 侧(onnxruntime 或 transformers)跑出 logits,与 Rust 侧逐值比对(容差 1e-3)。只要 ③ 能对上,说明前面两层都对;对不上,就逐层往上找——这个方法能把你从"猜哪里错了"变成"三分钟定位"。

记
本章小结

① tokenizer 与模型必须同源,用 optimum-cli export 一起产出;加载后做 special token 自检 + 固定文本指纹测试。

② 一次 encode 产出四件套:input_ids / attention_mask / token_type_ids / offsets;attention_mask 绝不能省。

③ padding 用 BatchLongest(动态)或 Fixed(静态 shape 模型),可对齐到 8 的倍数;truncation 要注意direction 决定保留头还是尾。

④ 截断是静默的:把"原文长度 / 截断后长度"记录成日志与指标;长文本要真正分析就上滑窗 + 聚合。

⑤ Session::run 需要 &mut self:并发方案是 Mutex 串行化 / 每 worker 一个 Session / 动态批处理,按吞吐与内存预算选。

⑥ 必须有背压(Semaphore + 429)与推理超时;把队列长度作为扩容信号。

⑦ 空闲卸载三要素:TTL 远大于典型请求间隔、加载加锁防并发重复加载、卸载与冷启动都做成指标。

⑧ 逐层验证法:分词断言 → 张量形状比对 → 与 Python 侧逐值对比 logits,能三分钟定位"哪一层错了"。

小练习 · 五道推理服务化自测题(点开看答案)

1.(排错题)Rust 服务加载模型能跑,返回 200,但准确率明显比 Python 低。最可能是什么?

查看答案

tokenizer 与模型不同源,或者没传 attention_mask。前者表现为"输入对模型来说是乱码"(例如用英文词表编中文),后者表现为"padding 被当真实内容参与注意力"。验证方法:对同一段文本,把 Python 侧 tokenizer.encode(...) 的 id 序列与 Rust 侧逐 id 对比;再拿同一条样例,两边分别跑出 logits 逐值比对(容差 1e-3)。只要幂等对上,问题一定在输入侧。

2.(概念题)为什么 attention_mask 不能省?什么时候 pad 位的 mask 特别关键?

查看答案

因为自注意力会让每个 token 看到所有位置,不 mask 的话 pad 会参与计算,导致"同一句话在不同 batch size 下结果不同"(单条测试往往刚好通过,批量上线才暴露)。批处理、变长输入、以及"取最后一个 token 做分类"的模型场景下尤其关键——后者如果 padding 在右边,取到的就是 pad 位置。

3.(设计题)服务要托管 10 个模型,每个模型每天只调用几十次,怎么设计内存策略?

查看答案

懒加载 + 空闲卸载:没加载就加载(用加载锁保证并发首次请求只加载一次),超过 TTL 未使用就 *slot = None 释放;后台每 10 秒扫描一次。关键参数是 TTL——必须远大于该模型的典型请求间隔,否则会不停加载卸载(比常驻更差)。配套:启动预热高频模型、给"加载后最小常驻时间"防抖、把冷启动次数与耗时做成指标。

4.(性能题)压测时发现"吞吐上不去、延迟越来越高、最后服务 OOM",怎么改?

查看答案

典型缺背压。推理能力是硬上限,请求无上限入队只会堆积内存、让每个请求都等到超时。三件事:① 用 Semaphore 或定长 channel 限流,超限立即返回 429;② 给推理加 tokio::time::timeout;③ 把队列长度与排队时长做成指标,把它作为扩容信号。顺带检查 Session 是否被多个请求并发持有(run 需要 &mut self)。

5.(工程题)怎么在 3 分钟内判断"是分词错了、还是模型喂错了、还是后处理错了"?

查看答案

逐层对比法:① 拿固定文本,比较 Python 与 Rust 的 input_ids(含 special token)——不一致就是分词层;② 打印张量 shape/dtype 与模型声明的输入对比——不一致就是组张量层;③ 拿同一份输入,用 Python 的 onnxruntime 跑出 logits,与 Rust 侧的 logits 逐值比对(容差 1e-3)——一致说明模型层没问题,问题在后处理(softmax / argmax / label 映射)。把这三步写成一个调试脚本,比在代码里瞎改快一个数量级。