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.json | Python 侧用:默认 max_length、pad_token、模型类型 | 可以不进,但里面的 max_length 约定值得抄进你的配置 |
vocab.txt / vocab.json | 纯词表 | 不需要(已包含在 tokenizer.json 里) |
config.json | 模型结构超参(层数、hidden size、id2label) | 建议进:id2label 常从它读,别在代码里硬编码类别名 |
special_tokens_map.json | special 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);
| 策略 | 取值 | 什么时候用 / 风险 |
| Padding | BatchLongest | 动态 shape 模型的默认选择,最省算力;代价是每批形状不同,模型可能反复重编译内核 |
Fixed(n) | 静态 shape 模型必须用;形状恒定,硬件利用率稳定;短文本浪费大量算力 |
pad_to_multiple_of | 对齐到 8/16 的倍数,配合量化模型和向量化算子有明显收益 |
| Truncation | LongestFirst | 批处理推荐,砍最长的能让批内长度更接近 |
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 映射)。把这三步写成一个调试脚本,比在代码里瞎改快一个数量级。