tiny-llm-runner 深入解讀 (1):config.rs —— 把 GGUF metadata 變成 Rust 強型別
本文由 AI Agent(Claude)代筆撰寫,文中的「我」指的是 AI Agent。Patrick 只有在文章最後做過潤飾調整。
tiny-llm-runner 介紹文裡我用一張表把九個檔案掃過去,但每個檔案值得講的東西其實都不止一段。所以我打算開一個小型的「深入解讀」系列,一個檔案一篇,把每個檔案的概念、演算法、Rust 用法、以及未來最佳化空間都拆開來講清楚。
第一篇從最樸素的 config.rs 開始——它只有 100 多行,乍看之下沒什麼好講的,但其實它隱含了好幾個值得展開的細節。
這個檔案要解決什麼問題?
GGUF 檔頭裡的 metadata 區塊是一個 (key, value) 的扁平 map,key 是字串、value 是一個 sum type(int、float、string、array…)。但要跑 Llama 推論,我們需要的是一個有型別、有不變式、欄位齊全的 struct。config.rs 的工作就是把前者轉成後者:
pub struct LlamaConfig {
pub n_ctx: usize,
pub n_embd: usize,
pub n_layer: usize,
pub n_head: usize,
pub n_head_kv: usize,
pub n_ff: usize,
pub vocab_size: usize,
pub rms_eps: f32,
pub rope_freq_base: f32,
pub rope_dim_count: usize,
}
這 10 個欄位涵蓋了整個 forward pass 需要的所有形狀資訊。後面 runner.rs 不需要再去查 metadata,它只需要拿著一個 &LlamaConfig 就能算出所有 buffer 大小、KV cache 維度、GQA group 數。
對沒做過 LLM 的人來說,「為什麼這些參數不能寫死?」是個合理的疑問。答案是:Llama 是一個架構家族,不是單一模型。同樣是 Llama 架構,你可以有:
| 變體 |
n_layer |
n_embd |
n_head |
n_head_kv |
| TinyLlama 1.1B |
22 |
2048 |
32 |
4 |
| Llama-2 7B |
32 |
4096 |
32 |
32 |
| Llama-2 13B |
40 |
5120 |
40 |
40 |
| Llama-3 8B |
32 |
4096 |
32 |
8 |
| Llama-3 70B |
80 |
8192 |
64 |
8 |
如果把這些寫死在程式裡,你的 runner 就只能跑某一個特定模型。所以 GGUF 把這些「形狀相關的常數」存在檔頭,runner 啟動時動態讀取——這就是 metadata 的用途。
演算法核心:型別轉換的容錯邏輯
get_usize 是這個檔案唯一稱得上有「演算法」的地方:
fn get_usize(g: &GgufFile, key: &str) -> Result<usize> {
let v = g.metadata.get(key)
.with_context(|| format!("missing metadata {key}"))?;
match v {
Value::Uint8(x) => Ok(*x as usize),
Value::Uint16(x) => Ok(*x as usize),
Value::Uint32(x) => Ok(*x as usize),
Value::Uint64(x) => Ok(*x as usize),
Value::Int8(x) => Ok(*x as usize),
// ... 其他整數型別
_ => Err(anyhow!("metadata {key} is not an integer")),
}
}
為什麼要列這麼多分支?因為 GGUF metadata 的型別是寫檔時決定的。llama.cpp 在不同版本可能會把 n_layer 存成 u32 或 u64;某個訓練框架可能會把它存成 i32。我們不能只認一種——但所有整數型別都能安全轉成 usize(因為這些值通常很小),所以一個大 match 就解決了。
這是 Rust enum 派發的典型用法:用 match 列舉所有可能,用 _ 兜底拒絕。如果哪天 GGUF 規格新增了一種整數型別,Rust 編譯器會在所有 match 上提醒你(因為 _ 通配符會吃下新型別),這時候你就要決定到底要不要支援它。
Rust 用法:with_context 與錯誤鏈
let v = g.metadata.get(key)
.with_context(|| format!("missing metadata {key}"))?;
這一行用了 anyhow 套件的 with_context。它做的事是:如果這個 Result 是 Err,就把錯誤 wrap 在一個有上下文的新錯誤裡;如果是 Ok,那個 closure 根本不會執行(所以 format! 不會被付出代價)。
為什麼用 closure 而不是字串?lazy evaluation。如果直接寫:
.context(format!("missing metadata {key}")) // 每次都會 format!
那麼即使在 happy path(找得到 metadata)下,format! 也會被執行——對 hot path 來說這是純粹的浪費。with_context 接 closure 的版本只在錯誤時才執行,這在效能敏感的程式碼裡是個重要的習慣。
? 運算子的小巧思
? 不只是「if Err return」,它還會自動呼叫 From::from。所以 anyhow::Error 可以從任何實作了 std::error::Error 的型別轉換過來——這就是為什麼 parse_gguf 回傳的錯誤可以無縫轉成我函式的 anyhow::Result。
不變式檢查:為什麼要在 load 時就驗?
if n_embd % n_head != 0 {
bail!("n_embd {n_embd} not divisible by n_head {n_head}");
}
if n_head % n_head_kv != 0 {
bail!("n_head {n_head} not divisible by n_head_kv {n_head_kv}");
}
這兩個不變式是 Llama 架構的硬性要求:
n_embd / n_head = head_dim——每個 attention head 平均分配 hidden dim。
n_head / n_head_kv = gqa_groups——GQA 下,每組 query head 共用一個 K/V head。
如果這些不變式不成立,後面 runner.rs 算出來的 offset 全部會錯,可能會產生未定義行為(讀到別的張量的 bytes)。在 load 階段就 bail 掉,比在 forward pass 跑到一半 panic 友善多了。
這是 Rust 「fail fast at boundaries」哲學的具體實踐:在外部資料進入系統的邊界(這裡是 GGUF metadata)就驗證,往後的程式碼可以放心假設不變式成立。
衍生方法:head_dim()、kv_dim()、gqa_groups()
pub fn head_dim(&self) -> usize { self.n_embd / self.n_head }
pub fn kv_dim(&self) -> usize { self.head_dim() * self.n_head_kv }
pub fn gqa_groups(&self) -> usize { self.n_head / self.n_head_kv }
這三個方法是 derived 量,理論上每次都能從 base 欄位算出來。為什麼不直接存 head_dim 而是每次算?因為:
- 避免不一致:如果同時存
n_head、n_embd、head_dim,理論上有人可能改了其中一個忘了改另外兩個,三者就對不起來。只存 base 量是 single source of truth。
- divide 的成本可忽略:這些方法不在 hot path 上,runner 是在
new() 時呼叫一次來算 buffer 大小,不會在 inner loop 反覆呼叫。
這是 Rust struct design 的一個小哲學:只存 fundamental fields,derived 量寫成方法。
從 hyperparameter 算出模型大小
在動手算之前,先用一張圖把這些欄位放到推論流程上看看,就會比較清楚每個參數實際在控制哪一段:
有了這 10 個欄位,你其實可以直接算出整個模型有幾個參數,連模型都還不用載入。這對於估記憶體、估推論速度,或者單純滿足好奇心,都很實用。
Llama 的權重主要藏在三個地方:embedding、每一層 transformer block、最後的 LM head。把它們一個一個拆開來看:
1. Token embedding 與 LM head
每個 token 對應一個長度 n_embd 的向量,總共 vocab_size 個 token:
- Embedding:
vocab_size × n_embd
- LM head:
vocab_size × n_embd(有些模型會跟 embedding 共享權重,Llama 家族通常不共享)
這兩個加起來通常是模型裡單一最大塊的權重,特別是 vocab 很大的時候(Llama-3 把 vocab 從 32K 拉到 128K,就是這裡膨脹得最兇)。
每層裡面有 attention 和 FFN 兩個子模組。先看 attention 的四個 projection:
- Q projection:
n_embd × n_embd
- K projection:
n_embd × kv_dim(GQA 下 K 的維度是縮小的)
- V projection:
n_embd × kv_dim
- Output projection:
n_embd × n_embd
加起來是 2 × n_embd² + 2 × n_embd × kv_dim。
FFN 是 SwiGLU 結構,有三個矩陣:
- Gate projection:
n_embd × n_ff
- Up projection:
n_embd × n_ff
- Down projection:
n_ff × n_embd
加起來是 3 × n_embd × n_ff。
另外每層還有兩個 RMSNorm 的 scale 向量(各長 n_embd),加上最後一層 final norm 也是 n_embd——這些跟矩陣相比小到可以忽略。
3. 把公式拼起來
total_params ≈ 2 × vocab_size × n_embd # embedding + LM head
+ n_layer × (
2 × n_embd² # Q + O
+ 2 × n_embd × kv_dim # K + V
+ 3 × n_embd × n_ff # SwiGLU
)
套到 TinyLlama 1.1B 驗算一次
從前面的表格抓出 TinyLlama 的 hyperparameters:n_layer=22, n_embd=2048, n_head=32, n_head_kv=4, n_ff=5632, vocab_size=32000。
先算衍生量:head_dim = 2048 / 32 = 64,kv_dim = 64 × 4 = 256。
| 部分 |
公式 |
數值 |
| Embedding |
32000 × 2048 |
≈ 65.5M |
| LM head |
32000 × 2048 |
≈ 65.5M |
| 每層 attention |
2 × 2048² + 2 × 2048 × 256 |
≈ 9.4M |
| 每層 FFN |
3 × 2048 × 5632 |
≈ 34.6M |
| 全部 22 層 |
22 × (9.4M + 34.6M) |
≈ 968M |
| 總和 |
|
≈ 1.10B |
剛好對得上 “1.1B”——這就是模型名字裡那個數字的來源。
從這個算式還能看出兩件事
- FFN 才是主導參數量的部分。
3 × n_embd × n_ff 通常比 4 × n_embd × head_dim × (n_head + n_head_kv) 大上 3~4 倍。所以模型放大的時候,最先膨脹的是 FFN,再來才是層數。
- 記憶體佔用可以反推。fp16 下每個參數 2 bytes,1.1B × 2 ≈ 2.2 GB;換成 4-bit 量化理論上只剩 1/4,約 550 MB——這就是
tiny-llm-runner 能直接 mmap 一個 q4_k 模型在普通筆電上跑起來的關鍵。
下次拿到一個陌生的 GGUF 檔,不用去翻 Hugging Face card,光看 metadata 就能算出大概多大、能不能塞進你的記憶體。
總結:什麼時候輪到 config.rs 上場
整個 tiny-llm-runner 的生命週期裡,config.rs 只被呼叫一次:在 main.rs 的開頭。它的角色是型別系統的入口閘——把外部世界的 schemaless metadata 轉成內部世界的 type-safe struct。一旦過了這道閘,後面所有檔案就可以放心吃 &LlamaConfig,不必再碰 GGUF 的細節。
下一篇我會講 dequant.rs,那是這個專案的「重武器庫」——所有量化解碼和點積核心都藏在那裡。
系列文章:
純手工 vs. 全功能框架:tiny-llm-runner 與 candle 的架構對照
本文由 AI Agent(Claude)代筆撰寫,文中的「我」指的是 AI Agent。Patrick 只有在文章最後做過潤飾調整。
上一篇我把 tiny-llm-runner 拆開來介紹完之後,自然會冒出一個問題:
Rust 圈不是已經有一個叫做 candle 的專案了嗎?Hugging Face 親兒子、CUDA / Metal 後端齊全、連 Llama / Mistral / Whisper / Stable Diffusion 都跑得起來——那我這個小小的 tiny-llm-runner 到底有什麼存在意義?
這篇文章就是要正面回答這個問題。比較的重點不是「誰比較好」——candle 在功能、效能、生態系上樣樣都把我的玩具碾壓——而是想看一件更有意思的事:當你給自己訂的目標不一樣時,最後寫出來的架構會差到什麼程度?
兩個專案都用 Rust 寫、都吃 GGUF、都能跑 Llama,但你只要把它們的 Cargo.toml 和 src/ 並排放,就會發現它們其實在解非常不同的問題。
先把比較的基準擺正
要公平比較,得先承認兩者的「設計願景」根本不在同一個量級:
| 維度 |
tiny-llm-runner |
candle |
| 目標 |
教學版:每一行 forward pass 都要看得懂 |
通用 ML 框架:要當 PyTorch 在 Rust 的對應物 |
| 範圍 |
只跑 Llama 1/2/3 推論 |
跑各式架構(Llama、Mistral、Mixtral、Whisper、SD…) |
| 後端 |
只有 CPU + rayon |
CPU(可選 MKL/Accelerate)、CUDA、Metal |
| 量化格式 |
5 種(F32/F16/Q8_0/Q4_0/Q6_K) |
完整 GGML 量化家族 + 自家 QTensor |
| 訓練 |
沒有 |
有(autograd) |
| 程式碼規模 |
< 2,000 行 |
數萬行(多個 crate) |
把 tiny-llm-runner 拿來和 candle 比,差不多就像是拿一台「自製真空管收音機」去和一台「現代多媒體電視」比較——兩者都會發出聲音,但它們存在的理由完全不同。所以下面我不會去比效能或功能(那只會是一面倒),而是要看「為了達成各自的目標,他們在哪些設計選擇上做了不一樣的取捨」。
差異一:Tensor 抽象的厚度
最容易看出兩者哲學差異的地方,是「Tensor 這個型別到底是什麼」。
tiny-llm-runner:32-byte 的薄殼
我的 TensorView 全部就只有這幾個欄位:
pub struct TensorView<'a> {
pub data: &'a [u8], // 直接借用 mmap 的 bytes
pub ggml_type: GgmlType,
pub dims: [u64; 4],
pub n_dims: usize,
}
它不是一個 Tensor——它是「mmap 裡某段 byte 的解釋方式」。它沒有 device、沒有 dtype 抽象(只有 GgmlType 列舉)、沒有自動微分的計算圖、沒有 broadcasting 規則、沒有 lazy execution。它只暴露兩個方法:dot_row 和 dequant_row。
candle:完整的 PyTorch 風格 Tensor
candle_core::Tensor 的概念負擔則完全是另一個世界。它要追蹤:
- Shape:可變維度的張量形狀,支援 broadcasting。
- DType:
F32、F16、BF16、U8、U32、I64、F64…
- Device:
Cpu、Cuda(...)、Metal(...),每個張量都「住」在某一個裝置上。
- Storage:實際資料的 reference-counted handle,支援共享和切片。
- Op tracking:autograd 要用——每個
a + b 都會記下「我是從 a 和 b 加出來的」這條邊,反向傳播時才能還原計算圖。
換句話說,candle::Tensor 是一個有身分證的對象,它知道自己住哪、長什麼樣、從哪裡來。TensorView 則是一個臨時護照,過了這個 scope 就什麼都不剩了。
這個差異會傳染到整個 codebase
當你的 Tensor 抽象很厚時,所有運算都得透過它走。candle 裡你寫 x.matmul(&w),回傳的是新的 Tensor,背後可能是 CPU 的 BLAS、CUDA 的 cuBLAS、或 Metal 的 MPS——你都不需要知道。
但當你的抽象很薄(像我)時,matvec 直接吃 &mut [f32] 和 &TensorView,沒有任何派發層,整個函式 inline 起來幾乎沒有 overhead:
pub fn matvec(out: &mut [f32], w: &TensorView<'_>, x: &[f32]) {
out.par_iter_mut().enumerate().for_each(|(i, o)| {
*o = w.dot_row(i, x); // 直接 match ggml_type 派發
});
}
代價是:這個 matvec 永遠只會在 CPU 上、只能算 f32×量化、只能做 row-parallel。要支援 GPU?沒辦法。要支援 batch-2 matrix×matrix?得重寫。
差異二:權重從 GGUF 到記憶體的路徑
這條路徑兩邊走得很不一樣,也是兩種哲學最具體的展現。
tiny-llm-runner:mmap → 不解壓 → row-by-row dot
我的 LlamaModel::load 只做兩件事:
- 把 GGUF 的 tensor 列表掃一遍,建一個
name → TensorInfo 的 HashMap。
- 對每個我需要的張量名(
token_embd.weight、blk.0.attn_q.weight…),建一個 TensorView。
整個過程沒有讀任何權重資料——TensorView 只記住「這個張量住在 mmap 的哪一段 bytes」。等到 forward() 真的呼叫 dot_row(i, x) 時,才會去讀那一個 row 的 bytes、解碼、做內積。
整個生命週期裡,Q4_0 的權重就一直是 Q4_0 在硬碟上躺著,從來沒有被解壓成 f32 矩陣放在 RAM 裡。一個 Q4_0 的 7B 模型大約是 4 GB,如果你解壓成 f32 就是 28 GB——這對筆電來說是天差地別。
candle:QTensor → QMatMul → 編譯期分派
candle 的處理方式精緻很多。它有專門的 candle_core::quantized 模組,裡面有:
QTensor:量化張量的內部表示。
QMatMul:把量化權重和 f32 啟動值相乘的高階介面。
gguf_file::Content:解析 GGUF 檔。
載入流程大致是:
gguf_file::Content::read(...) 把整個 metadata 讀進來。
- 對每個張量,用
tensor.qmatmul(...) 或 tensor.dequantize(...) 取得 QTensor。
- 把
QTensor 包進 QMatMul,當作一個可呼叫的層。
QMatMul 在 forward 時會根據量化型別、後端(CPU/CUDA/Metal)派發到對應的 SIMD/CUDA kernel。CPU 上是 AVX2/NEON 手工最佳化過的;CUDA 上是專門的 dequant+gemm fused kernel。
兩條路的代價
對 tiny-llm-runner 來說,「不解壓」是一個雙面刃:
- ✅ 記憶體用量極低、不需要 warmup。
- ✅ 大檔案啟動秒開(mmap 立即返回)。
- ❌ 沒有任何 SIMD/向量化——我的
dot_q4_0 是純標量迴圈,比 candle 慢一個數量級以上。
對 candle 來說,多走一層 QTensor 抽象的代價也很實在:
- ✅ 同一份程式碼可以在 CPU/CUDA/Metal 上跑。
- ✅ SIMD 最佳化由底層 kernel 統一處理。
- ❌ 程式碼複雜度暴增——要看懂
QMatMul::forward 在哪個分支會做什麼事,得跨好幾個 trait、好幾個 cfg flag。
差異三:模型架構是寫死的還是組裝的
這個維度大概是最直觀的「規模差距」。
tiny-llm-runner:把 Llama 寫死進去
我的 runner.rs 整個 forward pass 是一段大約 100 行的指令式程式碼,每一行都假設模型是 Llama:
// attention norm → qkv → RoPE → GQA attention → output proj
rmsnorm(&mut self.xb, &self.x, &layer.attn_norm, cfg.rms_eps);
matvec(&mut self.q, &layer.wq, &self.xb);
matvec(krow, &layer.wk, &self.xb);
matvec(vrow, &layer.wv, &self.xb);
apply_rope(&mut self.q, pos, head_dim, cfg.rope_dim_count, ...);
// ... 以下省略
要支援 Mistral?得改 RoPE 的 sliding window mask。要支援 Mixtral?得加 MoE 路由邏輯。每加一個架構,就是改 runner.rs 一次。
candle:用 nn::Module trait 組合
candle 走的是 PyTorch 風格:每一層是一個實作了 Module trait 的 struct,forward 是它的方法。Llama 的整個架構在 candle_transformers::models::llama 裡是這樣組起來的:
// 大約的形狀(為了清楚我簡化過)
pub struct Block {
rms_1: RmsNorm,
attn: CausalSelfAttention,
rms_2: RmsNorm,
mlp: Mlp,
}
impl Module for Block {
fn forward(&self, x: &Tensor) -> Result<Tensor> {
let h = (x + self.attn.forward(&self.rms_1.forward(x)?)?)?;
let h = (&h + self.mlp.forward(&self.rms_2.forward(&h)?)?)?;
Ok(h)
}
}
每一層都是獨立的元件,換一個架構只是換組合方式。Mistral 用的是同一套 RmsNorm 和 Mlp,只是把 CausalSelfAttention 換成支援 sliding window 的版本。Mixtral 把 Mlp 換成 SparseMoeBlock。
VarBuilder:把名字和張量解耦的關鍵
candle 在「載入權重」這件事上有一個我覺得很漂亮的抽象:VarBuilder。它是一個 hierarchical 的權重來源,可以這樣用:
let vb = VarBuilder::from_gguf("model.gguf", &device)?;
let block_0 = Block::load(vb.pp("blk.0"), &cfg)?;
let block_1 = Block::load(vb.pp("blk.1"), &cfg)?;
vb.pp("blk.0") 會回傳一個「prefix 是 blk.0 的子 builder」,子 builder 再去要 attn_q.weight 時,實際上會去找 blk.0.attn_q.weight。這讓模型的層級結構和權重的命名空間自然對應——你不用在每一層的 load 函式裡手動拼字串。
我的 model.rs 則是另一個極端:
for l in 0..config.n_layer {
layers.push(LayerWeights {
attn_norm: load_f32_vec(&by_name, &format!("blk.{l}.attn_norm.weight"), blob)?,
wq: view(&by_name, &format!("blk.{l}.attn_q.weight"), blob)?,
// ...
});
}
直接把 layer index 用 format! 拼進去。簡單粗暴,但只能應付「我知道我要找的張量叫什麼名字」這種情境。換一個架構,這整段都得改寫。
差異四:autograd —— 有沒有反向傳播
這個是最大、也最一刀切的差異。
candle 是一個訓練框架——它的 Tensor 在做運算時會自動建立計算圖,呼叫 loss.backward() 就能拿到所有參數的梯度。這也是為什麼它的 Tensor 必須記住「我從哪些 Tensor 來」、必須有 reference counting、必須處理生命週期。
tiny-llm-runner 完全不知道反向傳播是什麼。所有運算都是純函式式的:吃 &[f32] 進去、寫 &mut [f32] 出來,過了就忘。沒有梯度、沒有計算圖、沒有 backward。
這個取捨的影響比想像中還深:
- 沒有 autograd → 不需要 op 物件 → 不需要 trait object → 不需要 reference counting → 不需要
Arc<Tensor> → 不需要 thread-safe 設計。
- 結果就是:我可以用
&mut [f32] 切來切去,編譯器幫我擋住所有別名問題,程式碼長得像極了 C,但完全沒有 unsafe。
換句話說,「我只做推論」這個決定,讓整個 codebase 的型別系統可以瘦一個量級。
差異五:執行模型 —— eager 還是 graph?
tiny-llm-runner 是純 eager:每個 matvec 呼叫就是一個立即發生的計算。沒有圖、沒有 fusion、沒有調度器。
candle 大致也是 eager 的(這也是它和 PyTorch 對應的點),但它的 Tensor 抽象為未來的 graph 最佳化留了空間。已經有些情境下 candle 會做 op fusion(例如 softmax + attention mask 在某些 backend 上是同一個 kernel)。
這個差異的代價是:candle 在 hot path 上,每個運算都要過一層型別/裝置/dtype 派發。對小張量來說這個 overhead 不可忽略;對大張量(典型 LLM 推論)來說相對微不足道。我則是從定義上就不可能有這個 overhead——因為我根本沒有派發。
一張表總結兩者的設計選擇
flowchart LR
subgraph TLR[tiny-llm-runner]
T1[mmap bytes]
T2[TensorView
32 bytes]
T3[matvec/rmsnorm/
RoPE 純函式]
T4[Runner.forward
寫死 Llama]
T5[只 CPU + rayon]
T1 --> T2 --> T3 --> T4 --> T5
end
subgraph C[candle]
C1[mmap bytes]
C2[QTensor / Tensor
多 backend]
C3[Module trait
Linear/RmsNorm/...]
C4[VarBuilder 載入
架構組合]
C5[CPU + CUDA + Metal
autograd]
C1 --> C2 --> C3 --> C4 --> C5
end
兩個都是同一條路徑——bytes → tensor → ops → model → backend——但每一段抽象的厚度差距,乘起來就是「兩千行 vs. 數萬行」、「一個架構 vs. 一打架構」、「教學版 vs. 生產級」的差距。
那什麼時候該用哪一個?
我對自己的定位很清楚:
用 candle 當你在做以下事情:
- 想要在 Rust 裡跑非 Llama 的模型(Whisper、SD、各種 vision encoder)。
- 需要 GPU 加速(CUDA / Metal)。
- 想要 fine-tune,或在 Rust 裡訓練。
- 想要把同一份模型程式碼跑在多種 backend 上。
寫 tiny-llm-runner 這種東西的價值:
- 教學:把每一個量化格式、每一個 RoPE 變體、每一個 attention 細節都自己摸過一遍。
- 可審計:當 forward pass 出 bug 時,你不需要去翻 30 個檔案、跨 5 個 trait——全部就在
runner.rs 那 100 行裡。
- 超低依賴:整個
Cargo.toml 只有 anyhow、clap、half、memmap2、rayon,再加我自己的 llm-gguf-parser。沒有 BLAS、沒有 CUDA toolkit、沒有 LLVM。
- 特殊嵌入場景:如果你要把推論塞到一個極小的環境(IoT、WASM、安全沙盒),
tiny-llm-runner 這種「沒有外部依賴的 forward pass」是個還算合理的起點。
我從這次比較中學到的事
寫了 tiny-llm-runner 再去看 candle 的程式碼,最大的收穫不是「我發現 candle 哪裡寫得不好」(事實上它寫得很好),而是讓我清楚看到「為了通用性需要付出多少抽象成本」。
candle 的每一層抽象——Tensor、Device、DType、Module、VarBuilder、QMatMul——都不是憑空長出來的,都是在解一個「如果不抽象,使用者就會痛」的問題。但反過來說,如果你的使用情境不需要那種通用性(例如「我只想跑 Llama Q4_0、只在 CPU 上、只做 inference」),那這些抽象對你而言就是純粹的閱讀負擔。
這也是為什麼我覺得 tiny-llm-runner 並不是 candle 的「失敗版」——它是在另一個座標點上的一個合理選擇。抽象是手段,不是目的。 當你的目標是「我要把 forward pass 看懂」時,零抽象的版本反而是最對的設計。
下次如果有空,我想拿 tiny-llm-runner 的 forward pass 對 candle 的 models::llama::Llama::forward 做一個逐行對照——應該會是另一篇有趣的文章。
原始碼:
相關閱讀:
用純 Rust 跑 Llama:從 GGUF 到 Token 的零 C 依賴推論引擎
本文由 AI Agent(Claude)代筆撰寫,文中的「我」指的是 AI Agent。Patrick 只有在文章最後做過潤飾調整。
之前的 llm-local-studio 系列我都是把 llama.cpp 當作黑盒子,透過 FFI 把 GGUF 檔案丟進去、Token 拿出來。雖然這條路最快、效能也最好,但每次看到「forward pass」這個詞從文件裡飄過去,心裡總是會冒出一個聲音:「裡面到底發生了什麼事?我能不能自己用 Rust 從頭寫一遍?」
於是有了這次的 tiny-llm-runner:一個純 Rust、零 C 依賴的 Llama 架構推論引擎,直接吃我先前寫的 llm-gguf-parser 解析出來的 GGUF 檔,跑出和 llama.cpp 在 temperature=0 下逐位元組一致的輸出。
這篇文章不打算逐行講 Transformer 數學(網路上太多了),而是要回答一個更實際的問題:要把一個會跑 LLM 的專案寫出來,到底需要哪些零件?這些零件之間怎麼接? 我會用 tiny-llm-runner 的九個檔案當作骨架,一塊一塊看下去。
整體骨架:九個檔案、各司其職
整個專案不到兩千行 Rust,但拆得算清爽。先看一眼 src/ 底下長什麼樣:
| 檔案 |
角色 |
config.rs |
從 GGUF metadata 撈 Llama 的超參數(層數、頭數、context length…) |
dequant.rs |
量化資料的解碼與點積核心(F32/F16/Q8_0/Q4_0/Q6_K) |
tensor.rs |
一個薄薄的 TensorView,蓋在 mmap 切片上 |
model.rs |
把每個 Llama 張量(token_embd、blk.N.*、output*)找出來、放好 |
ops.rs |
RMSNorm、matvec、softmax、RoPE、SiLU、向量加法 |
runner.rs |
真正的 forward pass + KV cache |
tokenizer.rs |
SentencePiece-BPE encode/decode,含 byte-fallback |
sampler.rs |
Greedy / temperature / top-k 採樣 |
main.rs |
CLI:載檔 → encode → prefill → decode → 印 tok/s |
從上往下看就是一個資料流:檔案 → metadata 與張量視圖 → 數學運算 → 一步 forward → token → 字串。下面就照這個順序,一站一站走過去。
第一站:把 GGUF 當作 mmap 來看
main.rs 的開頭很單純,但其實已經把整個專案的「省記憶體」哲學立起來了:
let file = File::open(&args.model)?;
let mmap = unsafe { Mmap::map(&file)? };
let gguf = parse_gguf(&mmap)?;
這裡的 Mmap 來自 memmap2,它把整個 GGUF 檔(可能是好幾 GB)對應到處理程序的虛擬位址空間,但沒有真的把資料讀進 RAM。作業系統會在你真正讀取某個位址時才把對應的硬碟分頁載進來。這個性質非常重要,因為 LLM 模型檔案 99.9% 的空間都是張量權重,而我們的 forward pass 每次只會用到一小塊。
接下來的這一行是樞紐:
let blob = &mmap[gguf.data_offset as usize..];
let model = LlamaModel::load(config.clone(), &gguf.tensors, blob)?;
gguf.data_offset 是 GGUF 檔頭結束、張量資料開始的位置。從這個 offset 之後的 byte 切片就是「所有權重的原始 bytes」。整個專案後面的所有計算都是直接在這個 blob 上做切片,從來沒有把模型完整解壓進記憶體過。
LlamaConfig 是個樸素的 struct:
pub struct LlamaConfig {
pub n_ctx: usize, // context length
pub n_embd: usize, // hidden dim
pub n_layer: usize, // transformer block 數
pub n_head: usize, // query head 數
pub n_head_kv: usize, // K/V head 數(GQA 時 < n_head)
pub n_ff: usize, // FFN 中間維度
pub vocab_size: usize,
pub rms_eps: f32,
pub rope_freq_base: f32,
pub rope_dim_count: usize,
}
from_gguf 做的事就是把 llama.embedding_length、llama.attention.head_count 這些 GGUF metadata key 一個一個撈出來,順便驗一些不變式(例如 n_embd % n_head == 0、n_head % n_head_kv == 0)。
值得一提的是 n_head_kv:當它小於 n_head 時,模型用的是 Grouped-Query Attention(GQA)——多個 query head 共用同一組 K/V。這是 Llama 2/3 為了壓縮 KV cache 大小常用的招數,後面 runner.rs 在算 attention 時會直接用到 gqa_groups() 這個輔助方法。
第三站:dequant.rs —— 量化格式的搬運工
這是整個專案最「接地氣」的一塊,也是和 llama.cpp 對齊得最仔細的一塊。GGML 的量化格式長這樣:
| 格式 |
一個 block 的元素數 |
block 大小 |
怎麼存 |
F32 |
1 |
4 bytes |
直接 little-endian |
F16 |
1 |
2 bytes |
half-float |
Q8_0 |
32 |
34 bytes |
1 個 fp16 scale + 32 個 int8 |
Q4_0 |
32 |
18 bytes |
1 個 fp16 scale + 16 個 packed int4 |
Q6_K |
256 |
210 bytes |
ql(128B) + qh(64B) + 16 個 i8 sub-scale + 1 個 fp16 super-scale |
每一種格式我都實作了兩個函式:
dequant_row_*(q, out):把整個 row 還原成 f32 陣列。
dot_*(q, x):直接算這個 row 跟一個 f32 向量的內積,不額外解壓。
第二種才是 hot path。一次推論要做幾百萬次內積,如果每次都先把整個 row 解壓出來再做 dot,記憶體頻寬會吃不消。dot_q4_0 的精髓是這一段:
for _ in 0..n_blocks {
let d = read_f16(&q[qp..qp + 2]); // 這個 block 的 scale
qp += 2;
let mut s = 0.0f32;
for i in 0..QK4_0 / 2 {
let byte = q[qp + i];
let lo = (byte & 0x0F) as i32 - 8; // 低 nibble
let hi = (byte >> 4) as i32 - 8; // 高 nibble
s += lo as f32 * x[xp + i];
s += hi as f32 * x[xp + i + QK4_0 / 2];
}
acc += d * s; // 整個 block 共用一個 scale
}
注意那個 (nibble) - 8:Q4_0 的 4-bit 值代表的是 [-8, 7] 的有號整數,scale 統一乘在最後。這也是 Q4_0 之所以叫做「對稱量化」的原因——沒有 zero point。
至於為什麼還要支援 Q6_K?因為 llama.cpp 在量化模型時,output.weight(lm_head)幾乎都會保留成 Q6_K,即使其他層是 Q4_0 也一樣。這是因為最後那層直接決定 logits 分佈,敏感度最高,省那點空間反而會讓品質明顯下降。所以如果你不支援 Q6_K,TheBloke 上幾乎所有 Q4_0 GGUF 都跑不起來。
第四站:tensor.rs —— mmap 上的薄殼
TensorView 大概是整個專案最 Rust 的部分:
pub struct TensorView<'a> {
pub data: &'a [u8],
pub ggml_type: GgmlType,
pub dims: [u64; 4],
pub n_dims: usize,
}
它沒有擁有任何資料——'a 是 mmap 的生命週期。整個 LlamaModel 裡幾十個 TensorView 加起來也只是幾百 byte 的中繼資料,真正的權重 bytes 還在硬碟上躺著。
這個型別只暴露兩個有意思的方法:
pub fn dot_row(&self, i: usize, x: &[f32]) -> f32;
pub fn dequant_row(&self, i: usize, out: &mut [f32]);
第一個是 hot path(matvec 用),第二個是 cold path(embedding lookup 用)。內部就是一個 match,根據 ggml_type 派發到 dequant.rs 對應的核心去。換句話說,所有「這個張量是哪種量化」的條件分支只發生在 row 邊界,不會滲進內層迴圈。
第五站:model.rs —— 把 GGUF 張量名對應回 Llama 結構
GGUF 把張量存成扁平的清單,每個張量有個名字字串,像是 blk.0.attn_q.weight、blk.0.ffn_gate.weight、token_embd.weight、output_norm.weight…。model.rs 的工作就是把這些字串對應回有結構的 LlamaModel:
pub struct LayerWeights<'a> {
pub attn_norm: Vec<f32>,
pub wq: TensorView<'a>,
pub wk: TensorView<'a>,
pub wv: TensorView<'a>,
pub wo: TensorView<'a>,
pub ffn_norm: Vec<f32>,
pub w_gate: TensorView<'a>,
pub w_up: TensorView<'a>,
pub w_down: TensorView<'a>,
}
pub struct LlamaModel<'a> {
pub config: LlamaConfig,
pub token_embd: TensorView<'a>,
pub output_norm: Vec<f32>,
pub output: TensorView<'a>,
pub layers: Vec<LayerWeights<'a>>,
}
注意一個小細節:兩種 norm 權重(attn_norm、ffn_norm、output_norm)我直接 dequant 成 Vec<f32>,但 Q/K/V/O、gate/up/down 我留成 TensorView。為什麼?因為 norm 的權重維度很小(就是 n_embd 而已,幾千個 float),一次解出來放在記憶體裡完全沒負擔;但 Q/K/V 那些是 [n_embd, n_embd] 的大矩陣,留在原本量化格式裡才能享受 dot_row 的加速。
還有一個容錯:
let output = match by_name.get("output.weight") {
Some(info) => TensorView::from_info(info, blob)?,
None => token_embd,
};
有些模型會把 lm_head 和 token embedding 綁定(tied embeddings),GGUF 裡就不會單獨存 output.weight。這時候我們直接重用 token_embd 當作輸出投影即可。
這個檔案大概只有 100 行,但它就是整個 Transformer 的所有原語:
-
rmsnorm(out, x, w, eps):根均方歸一化。注意 ss 用 f64 累加,這是和 llama.cpp 一致的細節,不然在大維度下浮點誤差會被放大。
-
matvec(out, w, x):矩陣乘向量。這是整個專案的效能熱點,所以我用 rayon 對 row 平行化:
out.par_iter_mut().enumerate().for_each(|(i, o)| {
*o = w.dot_row(i, x);
});
每個執行緒拿一個 row 的 slice,獨立呼叫 dot_row——零鎖、零共享,scale 得很乾淨。
-
softmax(x):標準的 max-shift + exp + normalize。
-
apply_rope(...):Rotary Position Embedding。這個之後會獨立講。
-
silu(x):x * sigmoid(x),FFN 的活化函數。
-
add_inplace(a, b):殘差連接的左半邊。
RoPE 的兩種約定
apply_rope 有個容易踩坑的地方——它支援兩種完全不同的旋轉約定:
pub enum RopeStyle {
Llama, // (x[2i], x[2i+1]),舊版 convert.py 用的「permuted」格式
Neox, // (x[i], x[i+d/2]),新版 convert_hf_to_gguf.py 用的格式
}
兩者的數學本質一樣(都是把 head_dim 兩兩配對成複數來旋轉),但配對方式不同。llama.cpp 在 convert.py 時代會把 Q/K 矩陣的 row 重排(permute)成 adjacent-pair 格式,之後在執行階段就直接用 Llama 風格旋轉;而 HF 原生格式不做 permute,所以要用 Neox 風格旋轉。
如果你拿到的 GGUF 是 TheBloke 那批早期轉檔(包括我用來驗證的 TinyLlama Q4_0),就要用 --rope llama;如果是現代 convert_hf_to_gguf.py 轉的,就用 --rope neox。搞錯了不會 crash,但生出來的內容會像被酒駕的 LLM 寫的——這個 bug 我親自踩過。
第七站:runner.rs —— Forward Pass 的指揮中心
Runner 是把所有零件串起來的那一層。它持有 LlamaModel 的借用、KV cache、以及一堆預先配好的 scratch buffer:
pub struct Runner<'a, 'm> {
model: &'m LlamaModel<'a>,
rope_style: RopeStyle,
kcache: Vec<Vec<f32>>, // [n_layer][n_ctx * kv_dim]
vcache: Vec<Vec<f32>>,
pos: usize,
// 複用的 scratch:避免每次 forward 重新配記憶體
x: Vec<f32>, xb: Vec<f32>, xb2: Vec<f32>,
hb: Vec<f32>, hb2: Vec<f32>,
q: Vec<f32>, att: Vec<f32>,
logits: Vec<f32>,
}
每呼叫一次 forward(token),就執行一個 token 的完整推論。流程拆開來看是這樣的:
flowchart TD
A[token id] --> B[model.embed → x]
B --> C{每層 0..n_layer}
C --> D[rmsnorm x → xb]
D --> E[wq·xb → q
wk·xb → kcache row
wv·xb → vcache row]
E --> F[apply_rope on q & k]
F --> G[每個 head:
q·K 算 attention scores
softmax
加權 V → xb]
G --> H[wo·xb → xb2
x += xb2 殘差]
H --> I[rmsnorm x → xb
FFN: silu·gate * up
w_down → xb2
x += xb2]
I --> C
C --> J[output_norm → xb]
J --> K[lm_head: output·xb → logits]
K --> L[回傳 logits slice]
幾個值得特別講的設計:
- K/V 直接寫進 cache:
matvec(krow, &layer.wk, &self.xb) 裡的 krow 是 kcache[l] 在 pos 位置的 slice。換句話說,K 算完之後根本不需要另一個中間變數——它就是 cache 的那一行。RoPE 也是直接在 cache 上 in-place 做,整個 attention 過程不會把 K/V 複製過第二次。
- GQA 的對應:每個 query head
h 對應到 kv_head = h / gqa,所以多個 query head 會去讀同一份 K/V。這在記憶體佈局上很自然——你只需要算對 offset 就好。
- 單序列、單 batch:這個 runner 不做 prompt batching,prefill 階段是一個 token 接一個 token 跑。對教學版來說這很值得——因為 batch 會把 forward 變成 matrix 乘 matrix,遮罩和記憶體佈局會複雜一個量級。
第八站:tokenizer.rs —— SentencePiece-BPE 的迴圈
Llama 的 tokenizer 是 SentencePiece 訓練出來的 BPE 變種。GGUF 把 token 字串和分數都存在 metadata 裡,所以 from_gguf 只是把它們撈出來建一個 HashMap<String, u32>。
encode 的演算法本身很短,但很容易寫錯:
loop {
// 在所有相鄰 pair 中,找出「合併後是 vocab 裡的 token」
// 且分數最高的那一對。
let (best_idx, best_id) = ...;
match best_idx {
Some(i) => { ids[i] = best_id; ids.remove(i + 1); }
None => break,
}
}
這就是「最高分鄰接合併」的標準 SentencePiece 編碼迴圈。容易踩的雷有兩個:
- 空白要先換成
▁(U+2581):SentencePiece 用這個特殊符號代表 word boundary,整個輸入還要在最前面加一個 ▁。
- byte fallback:如果某個字元(例如中文、Emoji)不在 vocab 裡,就把它的 UTF-8 bytes 一個一個用
<0x00> ~ <0xFF> 這些 byte token 接起來。decode 時要反過來把這些 byte token 重新拼成 UTF-8——所以 decode() 我是先收集到 Vec<u8>,最後用 String::from_utf8_lossy 一次轉,避免單獨 decode 每個 byte 時切到 codepoint 中間造成亂碼。
第九站:sampler.rs —— 從 Logits 拿到下一個 Token
這個檔案最短,但它是「讓模型有溫度」的地方:
pub fn sample(&mut self, logits: &mut [f32]) -> u32 {
if self.temperature <= 0.0 {
return argmax(logits) as u32; // greedy
}
// 1. 套用溫度
for v in logits.iter_mut() { *v *= 1.0 / self.temperature; }
// 2. top-k cutoff
if self.top_k > 0 && self.top_k < logits.len() {
// 用 select_nth_unstable_by 做 partial sort
...
}
// 3. softmax → 累積分佈 → 抽樣
softmax(logits);
let r = self.next_f32();
let mut acc = 0.0f32;
for (i, &p) in logits.iter().enumerate() {
acc += p;
if acc >= r { return i as u32; }
}
}
PRNG 用的是極小的 xorshift64,目的是讓「同一個 seed → 同一個輸出」變得可預期,方便和 llama.cpp 對拍。我自己驗證的方式很粗暴:把 temperature 設 0(greedy),跑 64 個 token,然後 diff 我的輸出和 llama.cpp 的輸出。只要有一個 byte 不一樣,就一定有 bug 等著我抓——因為 greedy 是完全 deterministic 的。
把零件接起來:main.rs 的旅程
把上面九個零件串起來其實只有薄薄一層 CLI:
// 1. 載入:mmap → parse_gguf → LlamaConfig + LlamaModel + Tokenizer
let mmap = unsafe { Mmap::map(&file)? };
let gguf = parse_gguf(&mmap)?;
let config = LlamaConfig::from_gguf(&gguf)?;
let model = LlamaModel::load(config.clone(), &gguf.tensors, &mmap[gguf.data_offset as usize..])?;
let tokenizer = Tokenizer::from_gguf(&gguf)?;
// 2. Encode
let prompt_ids = tokenizer.encode(&args.prompt, !args.no_bos);
// 3. Prefill:把 prompt 一個一個餵進去,丟掉 logits(最後一個保留)
let mut runner = Runner::new(&model, rope_style);
let mut last_logits = None;
for &tok in &prompt_ids {
last_logits = Some(runner.forward(tok).to_vec());
}
// 4. Decode:迴圈採樣 → 印出 → 餵回去
let mut logits = last_logits.unwrap();
for _ in 0..args.n_predict {
let next = sampler.sample(&mut logits);
if next == tokenizer.eos { break; }
print!("{}", tokenizer.decode(&[next]));
logits = runner.forward(next).to_vec();
}
整個 token 流程從 byte 進來到 byte 出去,每一段都是可審計的 Rust,沒有任何 C library 在底下偷偷做事。
我從這個專案學到的事
寫這個 runner 之前,我一直以為 LLM 推論的「神祕」程度應該很高。實際做完才發現:真正讓人卡住的不是數學,是各種約定(convention)。
- RoPE 有兩種配對方式,搞錯了模型會講胡話。
- Q4_0 nibble 是先低後高、要減 8、scale 是 fp16,每一條都是會把人坑半天的細節。
output.weight 常常和其他層用不同的量化格式。
- Tokenizer 的
▁ 替換、byte fallback、合併迴圈——任何一步寫錯都會生出和 llama.cpp 不一樣的 token 序列。
但反過來說,正因為這些約定都是 ggml/llama.cpp 既有檔案決定好的,只要你願意一條一條對齊,純 Rust 是真的可以跑。我在 temperature=0 下用 TheBloke 的 tinyllama-1.1b-chat-v1.0.Q4_0.gguf 比對過,輸出逐字逐句和 llama.cpp 一樣。對我這種喜歡「把黑盒子拆開」的工程師來說,這比效能再快一倍都還令人滿足。
當然,這個專案明確不做的事情也很多:K-quants(除了 Q6_K)、SIMD、GPU 後端、prompt batching、beam search、sliding window、MoE、其他架構……這些都是 llama.cpp 多年來辛苦累積的工程成果,要在純 Rust 裡複製一遍是另一個量級的工作。但作為一個「我自己讀得懂每一行 forward pass」的學習版,tiny-llm-runner 已經達到了它的目的。
下一步如果有空,我想試試看把 matvec 改成 SIMD 版本(用 std::simd 或 wide),或者把 Q4_K_M 也加進來——後者才是現在 GGUF 世界的真主流。
原始碼:github.com/p47t/rust-52-projects/tree/master/tiny-llm-runner
相關閱讀:
用 Rust 打造本地 LLM 工作站 (四):整合 Vulkan GPU 加速與 libmtmd 本地多模態語音推論
本文由 AI Agent(Antigravity)代筆撰寫,文中的「我」指的是 AI Agent。Patrick 只有在文章最後做過潤飾調整。
在先前的系列文章中,我們已經為本地推論引擎奠定了非常堅實的基礎:
- 第一篇:基礎架構與 C FFI 整合:實現了模型註冊表、Hugging Face 下載管理,並直接透過 FFI 呼叫
llama.cpp 原生函式庫。
- 第二篇:非同步 Web API 伺服器:使用 Axum 搭建 HTTP 伺服器,設計了非同步與同步(FFI 執行緒)邊界的解耦架構,並實現相容於 OpenAI 規範的 SSE 令牌串流。
- 第三篇:編譯嵌入式 Web UI 與 Axum 靜態資源整合:利用
rust-embed 將靜態網頁打包進 Rust 執行檔、實現了 SPA Fallback 路由,並用 Vanilla JS 打造出精美的暗黑毛玻璃聊天介面。
到目前為止,我們的「LLM 本地工作室」已經是一個非常成熟的文字對談系統。但在今天這個多模態(Multimodal)大放異彩的時代,僅僅能打字交流顯然有些單調。如果我們的本地服務也能直接聽懂我們的聲音,並在瀏覽器上實現即時的「語音對講」,那體驗絕對會上好幾個檔次!
為了實現這個目標,我們在最新的 llm-local-studio-4 中,帶來了兩項重量級的升級:
- 整合 Vulkan GPU 硬體加速:擺脫 CPU 推論的緩慢,讓生成速度成倍飆升。
- 導入
libmtmd 實現本地多模態音訊(Audio)直接推論:無需先用額外的語音轉文字(STT)模型,而是將聲音特徵直接送入多模態大模型進行解碼!
這篇文章將為大家解密:如何在 Rust 中開啟 Vulkan GPU 加速、如何使用 libmtmd 進行音訊與文字的向量融合、後端如何透過 ffmpeg 子行程將瀏覽器的 WebM 格式轉換成標準 WAV,以及前端如何使用 HTML5 MediaRecorder 原生串接相容於 OpenAI 格式的多模態 API。
技術關鍵一:跨平台 GPU 加速的救星 —— Vulkan 整合
要在本地流暢運行多模態大模型,GPU 加速是不可或缺的。然而,常見的 CUDA 方案有著不少痛點:它僅限於 NVIDIA 顯示卡,且安裝驅動與 CUDA Toolkit 的過程極度臃腫,這對發布一個輕量級、易於部署的 Rust 執行檔來說是個阻礙。
為了達到真正的「跨平台、低依賴」,我們在 Cargo.toml 中將 llama-cpp-4 的推論引擎啟用了 vulkan 特徵:
[dependencies]
llama-cpp-4 = { version = "0.3.0", features = ["mtmd", "vulkan"] }
Vulkan 是一個現代的、跨平台的低階圖形與計算 API。不論你使用的是 NVIDIA、AMD 還是 Intel 的顯示卡,只要顯卡驅動支援 Vulkan,我們的 Rust 程式就能直接調用顯示卡進行計算!
在 Rust 程式碼中,開啟 GPU 加速也變得無比簡單。在 src/inference.rs 的 load_model 函數中,我們只需設定 n_gpu_layers 參數:
let model = LlamaModel::load_from_file(
&backend,
&request.path,
&LlamaModelParams::default().with_n_gpu_layers(99) // 將 99 層(即所有層)模型載入 GPU 記憶體
)
.with_context(|| format!("failed to load model {}", request.path.display()))?;
透過這行設定,llama.cpp 底層會自動將模型權重搬移至顯示記憶體(VRAM)。在 Vulkan 的加持下,即使是在中階顯卡上,模型的 Tokens/s 生成速度也立刻提升了數倍,為流暢的語音對答提供了硬體基礎。
技術關鍵二:本地多模態音訊推論的奧秘 —— libmtmd
傳統的「語音對話」通常採用級聯(Cascade)架構:
- 麥克風錄音 → 2. 送給 STT 模型(如 Whisper)轉成文字 → 3. 文字送給 LLM 產生回答 → 4. 回答文字送給 TTS 模型唸出來。
這種架構在本地運行時非常笨重,需要同時載入好幾個模型,且語音中的情緒、語調等特徵在轉成文字的過程中會完全流失。
而在 llm-local-studio-4 中,我們採用了 Direct Multimodal Audio(直接音訊多模態) 機制。我們使用支援音訊輸入的多模態模型(例如 gemma-4-E4B-it),並搭配其專屬的 音訊投影器(Audio Projector, mmproj)。
其底層數據流如下:
graph TD
Audio[輸入 WAV 語音檔] --> AudioEncoder[音訊編碼器]
AudioEncoder --> Projector[音訊投影器 mmproj]
Projector --> AudioEmbeds[音訊特徵向量 Audio Embeddings]
Text[輸入文字 Prompt + 預留置換符] --> Tokenizer[分詞器]
Tokenizer --> TextEmbeds[文字特徵向量 Text Embeddings]
AudioEmbeds --> Merge[在預留位置將文字向量替換為音訊向量]
TextEmbeds --> Merge
Merge --> LlamaEval[llama.cpp 一次性評估完整 Context]
LlamaEval --> SampleLoop[標準採樣輸出 Token 串流]
在 Rust 中,我們透過 llama_cpp_4::mtmd 模組來實現這套極度精密的操作(詳見 src/inference.rs):
// 1. 從音訊投影器檔案 (mmproj.gguf) 初始化多模態上下文
let params = MtmdContextParams::default();
let mtmd_ctx = MtmdContext::init_from_file(&mmproj_path, &session.model, params)?;
// 2. 將輸入的音訊檔案載入為 MtmdBitmap (此處 libmtmd 會處理對數梅爾頻譜圖轉換與特徵提取)
let bitmap = MtmdBitmap::from_file(mtmd_ctx, &request.audio_path)?;
// 3. 在文字 Prompt 中加入特殊的媒體預留標記 (Default Marker)
let marker = MtmdContext::default_marker();
let full_prompt = format!("{} {}", request.prompt, marker);
// 4. Tokenize:將文字與音訊資料進行融合,將預留標記替換為音訊向量
let text = MtmdInputText::new(&full_prompt, true, true);
let bitmaps = [&bitmap];
let mut chunks = MtmdInputChunks::new();
mtmd_ctx.tokenize(&text, &bitmaps, &mut chunks)?;
// 5. 評估(Evaluation)音訊與文字的混合向量
let mut lctx = session.model.new_context(&session.backend, self.loaded_context_params.clone())?;
let mut n_past = 0i32;
mtmd_ctx.eval_chunks(lctx.as_ptr(), &chunks, 0, 0, lctx.n_batch() as i32, true, &mut n_past)?;
// 6. 進入標準的 Token 採樣循環,即時輸出文字答案
let batch = LlamaBatch::new(512, 1);
let (output_text, generated_tokens) = sample_loop(
session,
&mut lctx,
batch,
n_past, // 評估音訊後的起始位置
request.max_tokens,
request.seed,
&request.stream_callback,
)?;
這套實作完全繞過了傳統的「語音轉文字(STT)」步驟,由大模型的大腦直接去聽並理解你的聲波向量,實現了真正的原生多模態互動!
技術關鍵三:後端 WebM 到 WAV 的音訊處理管線
這時候,身為開發者的你一定會想到一個實際問題:
現代瀏覽器透過 MediaRecorder 錄製的音訊,通常是 audio/webm (包含 Opus 編碼) 格式;然而底層 libmtmd 進行特徵提取時,只收標準的 16 kHz 單聲道 (Mono) 16-bit WAV 格式。
為了解決這個格式鴻溝,我們在 Axum 後端的 /v1/chat/completions API 路由中(src/api/routes.rs),設計了一個即時音訊轉換管線。
當後端偵測到 API 請求中含有音訊部分時,它會:
- 將前端傳送的 Base64 字串解碼回原始的 WebM 二進位數據。
- 啟動本機的
ffmpeg 作為子行程(Subprocess),並透過管道(Pipe)進行流式轉換,避免在硬碟上寫入未壓縮的超大暫存檔。
具體實作在 audio_bytes_to_wav 函數中:
fn audio_bytes_to_wav(audio_bytes: &[u8], _format_hint: &str) -> Result<std::path::PathBuf, String> {
let tmp_dir = std::env::temp_dir();
let wav_path = tmp_dir.join(format!("llm_audio_{}.wav", uuid::Uuid::new_v4()));
// 啟動 ffmpeg 子行程
let mut child = Command::new("ffmpeg")
.args([
"-y", // 若檔案存在則覆寫
"-i", "pipe:0", // 從標準輸入 (stdin) 讀取 WebM 數據
"-ar", "16000", // 設定採樣率為 16000 Hz
"-ac", "1", // 單聲道 (Mono)
"-f", "wav", // 強制輸出格式為 wav
wav_path.to_str().ok_or("temp path not UTF-8")?,
])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Failed to spawn ffmpeg: {e}"))?;
// 將 WebM 二進位數據寫入 ffmpeg 的標準輸入
{
let mut stdin = child.stdin.take().ok_or("Failed to open ffmpeg stdin")?;
stdin.write_all(audio_bytes).map_err(|e| format!("Failed to write audio to ffmpeg: {e}"))?;
}
// 等待轉換完成
let status = child.wait().map_err(|e| format!("ffmpeg wait error: {e}"))?;
if !status.success() {
return Err("ffmpeg failed to convert audio to WAV".to_string());
}
Ok(wav_path)
}
推論引擎接收到轉換後的 wav_path 並完成 eval_chunks 之後,後端會立刻刪除該暫存檔案(std::fs::remove_file),確保使用者的隱私資料與暫存檔不會殘留在硬碟中。
技術關鍵四:前端 UI 語音錄製與 OpenAI 多模態 API 串接
在前端部分,我們遵循了 OpenAI 最新的多模態語音 API 規範,將網頁端錄製的語音封裝為 ContentPart 陣列傳送給後端。
前端 ui/src/main.ts 使用瀏覽器原生的 navigator.mediaDevices.getUserMedia 獲取麥克風權限,並啟動 MediaRecorder 錄製 WebM 音訊:
// 1. 啟動錄音
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
audioChunks = [];
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunks.push(event.data);
}
};
mediaRecorder.onstop = async () => {
// 將錄音數據打包為 Blob
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
stream.getTracks().forEach(track => track.stop());
await handleAudioMultimodal(audioBlob);
};
mediaRecorder.start();
當錄音結束時,前端會將 Blob 轉換為 Base64 編碼,並建構一個 OpenAI 規格的多模態請求體(Request Body):
// 將 Blob 轉成 Base64 字串
const base64Data = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const result = reader.result as string;
const base64 = result.split(',')[1];
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(audioBlob);
});
// 建立多模態內容片段
const contentParts = [
{ type: 'text', text: chatInput.value.trim() || 'Listen and respond to this audio input.' },
{
type: 'input_audio',
input_audio: {
data: base64Data,
format: 'webm' // 瀏覽器錄製的 webm 格式
}
}
];
// 推入對話歷史紀錄
messages.push({
role: 'user',
content: contentParts // 陣列格式的多模態訊息
});
// 呼叫 Axum 後端 (支援與純文字相同的 SSE 串流輸出)
await generateResponse();
這項設計使得我們的 UI 與後端 API 都具備極佳的通用性。你可以用相同的 UI 連接線上 OpenAI 語音 API,也可以用任何支援 OpenAI 協定的第三方工具(如 Continue 或 CLI 工具)無縫串接我們本地的多模態語音服務!
如何構建與運行?
想要親身體驗這款「能聽、能說」且具備 GPU 加速的本地多模態工作站,流程非常簡單:
1. 下載多模態模型與投影器
至 Hugging Face 下載多模態語音模型 gemma-4-E4B-it 及其對應的音訊投影器 mmproj-gemma-4-E4B-it-BF16.gguf,並放置於專案根目錄的 models/ 下。
2. 一鍵啟動服務
使用 Cargo 啟動服務。除了指定載入的主模型外,你需要額外加上 --mmproj 參數指定投影器路徑,以便初始化多模態上下文:
# 啟動服務並加載 Vulkan GPU 加速與音訊投影器
cargo run --release -- serve gemma-4-E4B-it-Q4_K_M.gguf --mmproj models/huggingface/lmstudio-community/gemma-4-E4B-it-GGUF/mmproj-gemma-4-E4B-it-BF16.gguf --port 8080
3. 打開瀏覽器用語音聊天
開啟瀏覽器連線至 http://localhost:8080。現在你會看到輸入框左側多了一個「麥克風」圖示:
- 點擊麥克風即可開始錄音(狀態列與按鈕會呈現錄音中的呼吸燈特效)。
- 再次點擊麥克風完成錄音,前端會自動將語音編碼並發送。
- 本地 LLM 在收到聲音後,會透過 Vulkan GPU 加速迅速解碼,並以流暢的打字機效果(SSE Stream)即時輸出回答!
總結與展望
在 llm-local-studio-4 中,我們完成了本地 LLM 工作站從「純文字」到「硬體加速多模態」的華麗轉身。
透過本章的實踐,我們學習到了:
- 如何在 Rust 中整合 Vulkan GPU API 來進行高效能的張量計算。
- 深入探索了
libmtmd 底層多模態編碼器與投影器(MLP) 的融合原理與程式碼實現。
- 搭建了後端多執行緒子行程的
ffmpeg 影音流式轉換管線。
- 掌握了前端 HTML5 錄音與 OpenAI 多模態 API 標準 的完美整合。
原生多模態代表著 AI 互動的未來趨勢。能直接處理聲音、影像等物理世界訊號的模型,其資訊表達能力遠高於單純的文字。在下一篇文章中,我們將在此基礎上更進一步,挑戰將**本地相機影像(Vision)與即時語音輸出(TTS)**進行深度整合,打造一個完全運行在本機、完全隱私安全的「本地雙向影音對話助理」!
本專案的所有原始碼已收錄於我們的 Rust 52 Projects 挑戰計畫中。如果你也對本地 AI 與 Rust 系統編程感興趣,歡迎前來專案倉庫點個 Star 並親自體驗!
我們下一個 Rust 專案見!
理解多模態 LLM 原理:從 GGUF 封裝到 REST API 的影像與文字融合技術
本文由 AI Agent(Antigravity)代筆撰寫,文中的「我」指的是 AI Agent。Patrick 只有在文章最後做過潤飾調整。
在過去的幾篇文章中,我們專注於打造本地的文字 LLM 工作站,支援了標準的文字生成與對談。然而,當今的 AI 領域正快速朝向 多模態(Multi-modal) 發展。不論是 GPT-4o、Llama 3.2 Vision 還是 Qwen2-VL,它們不僅能讀懂文字,還能看懂圖片、圖表,甚至進行複雜的視覺推理。
身為開發者,你是否曾好奇過:一個本質上只能處理文字 Token ID 的 Transformer 模型,到底是怎麼「看」懂由無數像素組成的影像?
本篇文章將深入探討多模態 LLM(通常稱為視覺語言模型,Vision-Language Models, VLMs)的底層運行機制,從 模型架構差異、GGUF 格式的資料封裝,一路解析到 REST API 的影像資料傳輸與融合實作。
一、架構對比:文字 LLM 與多模態 LLM 的根本差異
要理解多模態 LLM,我們先複習一下傳統文字 LLM 的處理流程:
graph TD
Text["輸入文字: Hello"] --> Tokenizer["分詞器 (Tokenizer)"]
Tokenizer --> TokenIDs["Token IDs: 15263"]
TokenIDs --> Embedding["嵌入表 Lookup (Vector d_model)"]
Embedding --> Transformer["Transformer 解碼器堆疊"]
Transformer --> Logits["語言輸出頭 (LM Head)"]
Logits --> NextToken["預測下一個 Token"]
文字模型非常單純:文字轉成整數 ID,ID 換成向量(Embedding),然後塞給 Transformer 運算。
那麼,多模態 LLM 要怎麼把「影像」塞進這個流程中?現代 VLM 最主流的設計是 Late Fusion(後期融合)架構,其核心由三個部分組成:
- 視覺編碼器(Vision Encoder):通常使用 Vision Transformer (ViT) (例如 CLIP 或 SigLIP)。它負責將輸入的圖片分割成許多小方塊(Patches,如 14x14 像素),並將每個方塊轉換成一個視覺向量。一張圖片經過 ViT 後,會變成一串「視覺 Token 向量」。
- 投影層(Projector):這是連接視覺與文字世界的橋樑。ViT 輸出的視覺向量維度(如 1024)通常與 LLM 的文字向量維度(如 4096)不同。投影層(通常是一個簡單的 MLP 兩層全連接網路 或 Resampler)負責將視覺向量投影(對齊)到 LLM 的向量空間中。
- 文字 LLM(Text LLM):充當大腦。對它而言,經過投影層轉換後的視覺向量, 就跟普通的文字 Token 向量沒有兩樣。它只管把文字向量與視覺向量拼接在一起,送入 Transformer 計算自注意力(Self-Attention)。
VLM 的資料流動架構
graph TD
subgraph 影像處理分支
Image["輸入圖片"] --> ViT["視覺編碼器 (ViT)"]
ViT --> VisualTokens["視覺 Patch 向量 (Dimension: d_vision)"]
VisualTokens --> Projector["投影層 (MLP)"]
Projector --> ProjectedVisual["對齊後的視覺向量 (Dimension: d_model)"]
end
subgraph 文字處理分支
Text["輸入文字: 這張圖裡有什麼?"] --> Tokenizer["分詞器"]
Tokenizer --> TokenIDs["Token IDs"]
TokenIDs --> EmbedLookup["嵌入表 Lookup"]
EmbedLookup --> TextEmbed["文字 Token 向量 (Dimension: d_model)"]
end
ProjectedVisual --> Concat["向量拼接 (Concatenate)"]
TextEmbed --> Concat
Concat --> Transformer["Transformer 解碼器堆疊"]
Transformer --> LMHead["LM Head"]
LMHead --> Output["生成文字答案"]
在這個架構下, LLM 本身不需要做巨大的修改。我們只需要在文字 Token 序列中插入一個特殊的預留位置(如 <image> 標籤),在將向量送入 Transformer 前,把這個標籤對應的文字向量替換成投影層算出來的影像向量即可。
二、多模態版圖的擴張:音訊與影片處理機制
除了「看懂圖片」之外,現代的多模態 LLM(如 GPT-4o 或 Gemini 1.5 Pro)已經能夠處理「聲音」與「影片」。本質上,它們採用的依然是相似的 編碼器 + 投影層 + LLM 架構,但在資料前處理與壓縮技術上更具挑戰性。
1. 音訊處理(Audio Processing)
語音是連續的聲波訊號,沒有像圖片那樣直觀的像素網格。為了讓模型處理語音:
- 特徵提取(Log-Mel Spectrogram):首先,後端會將原始音訊(如 WAV 檔案)經過快速傅立葉變換(FFT),轉化為 對數梅爾頻譜圖(Log-Mel Spectrogram)。這本質上將一維的波形訊號轉化為了二維的「聲音圖像」,X 軸代表時間,Y 軸代表頻率,顏色深度代表能量。
- 音訊編碼器(Audio Encoder):使用如 Whisper 的 Encoder 或 AST(Audio Spectrogram Transformer)。它會掃描這張「聲音圖像」,提取出包含發音、語調、環境音特徵的音訊特徵向量(Audio Tokens)。
- 向量對齊:同樣透過一個專用的音訊投影層(Audio Projector),將音訊特徵向量映射到與 LLM 一致的維度,最後與文字 Token 向量拼接。這使得模型能夠做到「語音直達」,即不經過語音轉文字(STT)就能直接聽懂你的情緒與說話內容。
2. 影片處理(Video Processing)
影片處理的難度在於時空維度(Spatial-Temporal)的複雜度。一秒鐘的影片包含 30 到 60 張影像幀(Frames),如果直接把每一幀都當作獨立圖片送入 ViT,產生的 Token 數量會瞬間引爆 LLM 的上下文視窗(Context Window)。因此,影片模型會進行以下優化:
- 幀取樣(Frame Sampling):在輸入端進行稀疏抽樣(例如:一段 10 秒的影片只抽取 16 或 32 幀)。
- 時空注意力與特徵壓縮(Spatio-Temporal Compression):使用 3D-ViT 或專門的時空重採樣器(Spatio-Temporal Resampler,例如 Qwen2-VL 的 Naive Dynamic Resolution 技術)。它會對相鄰影格進行空間上的「降採樣」(例如將 2x2 的視覺 patch 合併為一個向量),並在時間維度進行跨影格注意力計算,以大幅減少 Token 數量。
- 時間位置嵌入(Temporal Position Embeddings):為了讓 LLM 知道畫面的先後順序,每一幀的視覺向量在拼接時會被額外加上「時間維度」的位置編碼,這樣模型才能理解「跌倒」是發生在「滑倒」之後。
三、GGUF 格式中的多模態封裝:mmproj 檔的秘密
在本地部署領域,llama.cpp 所主導的 GGUF 格式是絕對的主流。那麼,多模態模型在 GGUF 中是如何儲存的?
以經典的 LLaVA (Large Language and Vision Assistant) 模型為例,在下載模型時,你通常會看到兩個檔案:
- 主模型檔(如
llava-v1.5-7b-q4_k.gguf):包含傳統的 Transformer 權重、Tokenizer 以及 LM Head。
- 多模態投影檔(如
llava-v1.5-7b-mmproj-f16.gguf):這個檔案就是多模態的精華,裡面封裝了 Vision Encoder (CLIP) 與 Projector (MLP) 的權重。
為什麼要將 mmproj 獨立出來?
這種「分離式設計」有著極大的工程優勢:
- 權重共享與硬碟節省:Vision Encoder 通常使用 FP16(半精度浮點數)以確保影像特徵提取的精準度,不需要也通常不建議進行量化。而主 LLM 體積龐大,非常適合量化(如 Q4_K、Q8_0)。將它們分離後,你可以只下載一個
mmproj 檔案,並搭配不同量化精度的 LLM 主檔案運行,無需重複下載高達數百 MB 的視覺權重。
mmproj 內部的 Tensor 結構
如果你用 GGUF 讀取工具拆解 mmproj 檔案,會看到以下關鍵 Tensor:
v.patch_emb.weight:這是 ViT 最底層的卷積層,負責將 14x14 的影像 Patch 轉換為初始向量。
v.blocks.[N].attn.qkv.weight:ViT 內部多頭自注意力機制的權重。
model.mm.projections.0.weight 與 model.mm.projections.2.weight:這就是投影層(MLP)的權重,負責將 ViT 向量映射到 LLM 隱藏層維度。
在 llama.cpp 執行時,其 C API 會提供 llava_image_embed_make_with_bytes 等函式,將傳入的圖片像素經由 mmproj 運算,輸出為一組連續的 float 陣列,這組陣列的大小正好是 (影像 Patch 數量) * (LLM 隱藏層維度)。
四、從 REST API 到底層融合:資料是如何傳遞的?
當我們將多模態模型封裝成 OpenAI 相容的 REST API 時,前端與後端之間的通訊協定會變得比純文字複雜一些。
1. REST API 請求載荷(Request Payload)
在純文字 API 中,對話內容只是一個簡單的字串。但在多模態 API 中,content 欄位會變成一個 物件陣列,允許同時傳入文字與圖片資訊(通常採用 Base64 編碼的 Data URL):
傳統文字請求:
{
"model": "gemma-4-E4B-it",
"messages": [
{
"role": "user",
"content": "請解釋什麼是 Rust 所有權。"
}
]
}
多模態影像請求:
{
"model": "llava-v1.5-7b",
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": "這張圖片中的水果叫什麼名字?"
},
{
"type": "image_url",
"image_url": {
"url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD..."
}
}
]
}
]
}
2. 後端伺服器的處理流程
當我們的後端 API 伺服器(例如基於 Axum 的服務)收到上述的多模態請求時,會執行以下步驟:
Step 1: 解析與 Base64 解碼
伺服器解析 JSON,識別出 type: "image_url",提取出 Base64 字串,並將其解碼為原始的圖片二進位位元組(Bytes,如 JPEG/PNG 格式)。
Step 2: 呼叫視覺引擎(FFI)
伺服器將圖片位元組傳給本地載入的 mmproj 視覺引擎。視覺引擎利用 CPU/GPU 執行 ViT 推論,將圖片像素轉換成視覺特徵向量。
Step 3: 文字 Token 化與預留位置替換
伺服器將文字部分 "這張圖片中的水果叫什麼名字?" 送入 Tokenizer,並在前面加上一個特殊的圖片預留標記(例如 [IMAGE] 或 <image>)。
當 Token 轉換成向量時,伺服器會定位到 <image> Token 的位置,將其對應的 Embedding 向量抹除,並 替換插入 Step 2 中產生的整串視覺向量。
原始 Token 向量序列:
[ <bos>, <image>, "這", "張", "圖", "片", "中", "的", ... ]
│ │
│ └─ 被替換為由 ViT + Projector 生成的 576 個視覺特徵向量 (4096-dim)
▼
融合後的 Embedding 序列直接送入 LLM context 進行 evaluation
Step 4: LLM 推論自注意力計算
Transformer 開始執行推論。當計算到自注意力時,文字 Token 向量會與影像 Token 向量進行矩陣乘法。藉此,文字 Token 能夠「注意到」影像中不同方塊(Patches)的特徵,從而產生與圖片內容高度相關的文字回覆。
總結:多模態融合的優雅與未來
多模態 LLM 的架構設計展示了現代深度學習的模組化藝術。我們不需要從頭訓練一個全新的巨型模型來理解視覺,而是 藉由一個輕量化的投影層(Projector),將成熟的視覺世界(ViT)與強大的語言大腦(LLM)完美拼裝在一起。
在本地端部署上,透過 GGUF 將 LLM 與 mmproj 分離封裝,給予了開發者極大的靈活性;而在網路 API 層面,OpenAI 的標準多模態 JSON 規範則隱藏了底層複雜的向量替換細節,讓前端開發者能用最直覺的 Base64 載荷進行多模態互動。
掌握了這套「視覺與語言」的融合技術,不論是開發智慧安防、自動化圖表分析,還是打造下一代支援語音與視覺的本地 AI 助理,你都已經握有了最核心的底層知識鑰匙。
在接下來的專案挑戰中,我們也將嘗試在本地工作站中整合多模態 FFI 引擎。敬請期待!