Simply Patrick

用純 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.cpptemperature=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_embdblk.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 上做切片,從來沒有把模型完整解壓進記憶體過。


第二站:config.rs —— 把 metadata 變成型別

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_lengthllama.attention.head_count 這些 GGUF metadata key 一個一個撈出來,順便驗一些不變式(例如 n_embd % n_head == 0n_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.weightblk.0.ffn_gate.weighttoken_embd.weightoutput_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_normffn_normoutput_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 當作輸出投影即可。


第六站:ops.rs —— Transformer 的六種樂高

這個檔案大概只有 100 行,但它就是整個 Transformer 的所有原語:

  • rmsnorm(out, x, w, eps):根均方歸一化。注意 ssf64 累加,這是和 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.cppconvert.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]

幾個值得特別講的設計:

  1. K/V 直接寫進 cachematvec(krow, &layer.wk, &self.xb) 裡的 krowkcache[l]pos 位置的 slice。換句話說,K 算完之後根本不需要另一個中間變數——它就是 cache 的那一行。RoPE 也是直接在 cache 上 in-place 做,整個 attention 過程不會把 K/V 複製過第二次。
  2. GQA 的對應:每個 query head h 對應到 kv_head = h / gqa,所以多個 query head 會去讀同一份 K/V。這在記憶體佈局上很自然——你只需要算對 offset 就好。
  3. 單序列、單 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::simdwide),或者把 Q4_K_M 也加進來——後者才是現在 GGUF 世界的真主流。


原始碼:github.com/p47t/rust-52-projects/tree/master/tiny-llm-runner

相關閱讀:


用 Rust 打造本地 LLM 工作站 (四):整合 Vulkan GPU 加速與 libmtmd 本地多模態語音推論

featured.svg

本文由 AI Agent(Antigravity)代筆撰寫,文中的「我」指的是 AI Agent。Patrick 只有在文章最後做過潤飾調整。

在先前的系列文章中,我們已經為本地推論引擎奠定了非常堅實的基礎:

  1. 第一篇:基礎架構與 C FFI 整合:實現了模型註冊表、Hugging Face 下載管理,並直接透過 FFI 呼叫 llama.cpp 原生函式庫。
  2. 第二篇:非同步 Web API 伺服器:使用 Axum 搭建 HTTP 伺服器,設計了非同步與同步(FFI 執行緒)邊界的解耦架構,並實現相容於 OpenAI 規範的 SSE 令牌串流。
  3. 第三篇:編譯嵌入式 Web UI 與 Axum 靜態資源整合:利用 rust-embed 將靜態網頁打包進 Rust 執行檔、實現了 SPA Fallback 路由,並用 Vanilla JS 打造出精美的暗黑毛玻璃聊天介面。

到目前為止,我們的「LLM 本地工作室」已經是一個非常成熟的文字對談系統。但在今天這個多模態(Multimodal)大放異彩的時代,僅僅能打字交流顯然有些單調。如果我們的本地服務也能直接聽懂我們的聲音,並在瀏覽器上實現即時的「語音對講」,那體驗絕對會上好幾個檔次!

為了實現這個目標,我們在最新的 llm-local-studio-4 中,帶來了兩項重量級的升級:

  1. 整合 Vulkan GPU 硬體加速:擺脫 CPU 推論的緩慢,讓生成速度成倍飆升。
  2. 導入 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.rsload_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)架構

  1. 麥克風錄音 → 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 請求中含有音訊部分時,它會:

  1. 將前端傳送的 Base64 字串解碼回原始的 WebM 二進位數據。
  2. 啟動本機的 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 的影像與文字融合技術

featured.svg

本文由 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(後期融合)架構,其核心由三個部分組成:

  1. 視覺編碼器(Vision Encoder):通常使用 Vision Transformer (ViT) (例如 CLIP 或 SigLIP)。它負責將輸入的圖片分割成許多小方塊(Patches,如 14x14 像素),並將每個方塊轉換成一個視覺向量。一張圖片經過 ViT 後,會變成一串「視覺 Token 向量」。
  2. 投影層(Projector):這是連接視覺與文字世界的橋樑。ViT 輸出的視覺向量維度(如 1024)通常與 LLM 的文字向量維度(如 4096)不同。投影層(通常是一個簡單的 MLP 兩層全連接網路Resampler)負責將視覺向量投影(對齊)到 LLM 的向量空間中。
  3. 文字 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) 模型為例,在下載模型時,你通常會看到兩個檔案:

  1. 主模型檔(如 llava-v1.5-7b-q4_k.gguf):包含傳統的 Transformer 權重、Tokenizer 以及 LM Head。
  2. 多模態投影檔(如 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.weightmodel.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 引擎。敬請期待!


用 Rust 打造本地 LLM 工作站 (三):編譯嵌入式 Web UI 與 Axum 靜態資源整合

featured.svg

本文由 AI Agent(Antigravity)代筆撰寫,文中的「我」指的是 AI Agent。Patrick 只有在文章最後做過潤飾調整。

在之前的系列文章中,我們逐步建立起本機推論引擎的核心:

  1. 第一篇:基礎架構與 C FFI 整合:實現了模型註冊表、Hugging Face 下載管理,並直接透過 FFI 呼叫 llama.cpp 原生函式庫。
  2. 第二篇:非同步 Web API 伺服器:使用 Axum 搭建 HTTP 伺服器,設計了非同步與同步(FFI 執行緒)邊界的解耦架構,並實現相容於 OpenAI 規範的 SSE 令牌串流。
  3. API 串接篇:多語言 SDK 實戰:介紹了如何用 Python、Node.js、Rust 及 VS Code 外掛(Continue)無縫對接我們本地的服務。

到目前為止,我們的專案都還是一個純粹的「後端 API 服務」。雖然功能健全,但對於不想動手寫程式串接、只想安靜與本地模型聊天的使用者來說,沒有一個開箱即用的圖形介面(GUI)實在有些美中不足。

為了徹底解決這個問題,我們在最新的 llm-local-studio-3 中,開發了一個精美的單頁網頁應用(SPA)聊天介面,並將其直接編譯並嵌入到 Rust 二進位檔案中

現在,你不需要下載繁雜的前端依賴,不需要執行 npm run dev,更不需要處理惱人的跨來源資源共用(CORS)問題——只要執行一個編譯好的 Rust 執行檔,就能立刻在瀏覽器打開一個功能完備、視覺效果極佳的本地 LLM 聊天室!

這篇文章將為大家解密:如何使用 rust-embed 將靜態網頁打包進 Rust 執行檔、如何在 Axum 中實現 SPA 路由重導向(Fallback Routing)、以及前端如何用 Vanilla JS 原生串接 SSE 串流並打造極具質感的毛玻璃(Glassmorphism)暗黑風格介面。


為什麼選擇「嵌入式 Web UI」?

常見的 Web App 開發架構中,前端與後端通常是分離運行的。前端透過 Vite 等開發伺服器啟動於 http://localhost:5173,而後端 API 運行在 http://localhost:8080

這種架構在開發時非常方便,但在部署與發布給一般使用者時,會帶來不少痛點:

  1. 依賴環境複雜:使用者電腦必須安裝 Node.js 才能運行前端,對非網頁開發者門檻過高。
  2. CORS 跨網域阻擋:因為連接埠(Port)不同,瀏覽器會因為安全性考量阻擋請求,後端伺服器必須配置特別的 CORS 允許規則。
  3. 多檔案分發麻煩:你需要同時打包前端編譯產出的 dist 資料夾與後端的執行檔,一旦路徑出錯就無法運行。

而將網頁靜態資源**打包嵌入(Embedded)**到 Rust 二進位檔中,有著壓倒性的優勢:

  • 單一執行檔(Zero Dependencies):打包編譯後只產生一個執行檔(如 llm-local-studio.exe),所有網頁圖示、HTML、CSS、JS 程式碼全部都存在於這個二進位檔的唯讀資料段(ReadOnly Data Segment)中。
  • 同源政策(Same-Origin):因為網頁和 API 都是由同一個監聽連接埠(例如 8080)的 Axum 服務提供,瀏覽器視其為同源,完全不需要擔心 CORS 問題。
  • 極佳的部署體驗:使用者只需下載該執行檔,下達一條指令,點開網址就能直接使用。

技術關鍵一:使用 rust-embed 封裝靜態資源

在 Rust 中要將整個資料夾嵌入到二進位檔,最方便的工具莫過於 rust-embed

它的用法極度直覺,只需要定義一個結構體,並掛上 RustEmbed 的屬性巨集(Attribute Macro),指定前端打包輸出的目錄:

use rust_embed::RustEmbed;

#[derive(RustEmbed)]
#[folder = "ui/dist/"]
struct Asset;

在編譯期,Rust 編譯器會掃描 ui/dist/ 目錄下的所有檔案,將它們的二進位內容讀入,並作為靜態陣列嵌入到二進位檔中。

兼顧開發效率的「雙重模式」

你可能會擔心:「如果每次修改網頁 CSS 或 JS,都得重新編譯 Rust 專案,那開發效率不就低落到無法接受?」

這就是 rust-embed 設計非常精妙的地方!

  • 偵錯模式(Debug Mode):在執行 cargo runcargo build 時,它不會真正嵌入檔案。相反地,它會在運行時動態去讀取你硬碟上的 ui/dist/ 目錄。因此,你只需要在前端資料夾進行 Vite 編譯,Rust 伺服器端就能立刻看到更新,無需重啟 Rust 服務!
  • 發布模式(Release Mode):當執行 cargo build --release 時,它才會真正把檔案寫死在二進位檔中,達到零外部依賴。

技術關鍵二:Axum 中的 SPA Fallback 路由與 MIME 處理

嵌入資源後,我們必須讓 Axum 伺服器知道如何讀取這些檔案並回傳給瀏覽器。

因為現代前端單頁應用(SPA)的路由是由前端 JS(例如網頁瀏覽器內部的 History API)控制的,當使用者在瀏覽器網址列輸入 http://localhost:8080/settings 並重新整理時,後端伺服器會收到對 /settings 的請求。如果後端沒有配置對應的路由,就會回傳 404 錯誤。

因此,後端必須實現 SPA Fallback 機制:只要請求的路徑不是已註冊的 API 端點(如 /v1/chat/completions/health),就一律將 ui/dist/index.html 的內容丟回給瀏覽器,讓前端 JS 接手後續的路由渲染。

我們在 src/api/assets.rs 中實現了這個資源處理器:

use axum::{
    http::{header, StatusCode, Uri},
    response::IntoResponse,
};
use rust_embed::RustEmbed;

#[derive(RustEmbed)]
#[folder = "ui/dist/"]
struct Asset;

pub async fn static_handler(uri: Uri) -> impl IntoResponse {
    // 1. 去除請求路徑開頭的斜線,符合 rust-embed 的鍵值格式
    let mut path = uri.path().trim_start_matches('/').to_string();

    // 2. 如果是首頁請求,預設尋找 index.html
    if path.is_empty() {
        path = "index.html".to_string();
    }

    // 3. 嘗試從嵌入資源中取得檔案
    match Asset::get(path.as_str()) {
        Some(content) => {
            // 使用 mime_guess 根據副檔名判斷正確的 Content-Type (例如 text/css, application/javascript)
            let mime = mime_guess::from_path(path).first_or_octet_stream();
            (
                [(header::CONTENT_TYPE, mime.as_ref())],
                content.data.into_owned(),
            )
                .into_response()
        }
        None => {
            // 4. 找不到檔案時,Fallback 回傳 index.html(讓前端 SPA 處理路由)
            if let Some(index_content) = Asset::get("index.html") {
                (
                    [(header::CONTENT_TYPE, "text/html")],
                    index_content.data.into_owned(),
                )
                    .into_response()
            } else {
                // 如果連 index.html 都沒有(例如忘記先 build 前端),則回傳 404
                (StatusCode::NOT_FOUND, "404 Not Found").into_response()
            }
        }
    }
}

接著,在 src/api/mod.rs 中,我們透過 .fallback() 將這個 handler 註冊到 Axum 路由表的最後:

let app = Router::new()
    .route("/health", get(routes::health))
    .route("/v1/models", get(routes::list_models))
    .route("/v1/chat/completions", post(routes::chat_completions))
    // 當以上路由都沒配對成功時,交給靜態資源處理器
    .fallback(get(assets::static_handler))
    .layer(cors)
    .with_state(engine);

如此一來,我們的伺服器就完美具備了 serving 靜態資源以及引導 SPA 路由的能力!


前端介面設計:極簡暗黑毛玻璃美學

在 UI 設計上,我們拒絕了粗糙簡陋的陽春畫面。取而代之的是,我們以極簡主義為出發點,選用了符合現代審美標準的深色質感色系:

  • 背景與版面:採用極深色漸層底色(#0f1115#171923),避免純黑帶來的死板感。
  • 側邊欄 (Sidebar):使用半透明的白色背景,結合 backdrop-filter: blur(12px) 實現高質感的毛玻璃(Glassmorphism)視覺效果,並用細緻的半透明邊框細線畫出分界。
  • 狀態指示燈:在側邊欄上方顯示「Connected」連線狀態,並利用 CSS 關鍵影格(@keyframes)製作綠色光暈的呼吸燈脈衝動畫(Pulse Animation),讓介面顯得栩栩如生。
  • 聊天對話框:使用帶有漸層藍紫色的氣泡背景(linear-gradient(135deg, #3b82f6, #4f46e5))代表使用者訊息,並加上柔和的陰影;AI 訊息則使用低調的半透明灰,形成強烈的視覺對比與易讀性。
  • 自適應輸入框:對話輸入框會監聽輸入字數,動態調整輸入框高度(最大 150px),並客製化了滾動條(Scrollbar)樣式。
「好的軟體不只功能要強大,外觀也必須讓人賞心悅目。」

原生 JavaScript SSE 令牌串流串接

為保持整個應用的輕量與極致速度,我們沒有引入 React 或 Vue 等重型框架,而是直接用 vanilla JavaScript 撰寫互動邏輯。

前端最核心的技術,就是利用網頁的 ReadableStream 讀取並即時解析後端傳回的 SSE(Server-Sent Events)事件流,並實現「打字機」效果:

// 建立一個空的 AI 訊息元素,並附加閃爍的輸入游標 (cursor-blink)
const msgEl = document.createElement('div');
msgEl.className = 'message ai';
const contentEl = document.createElement('div');
contentEl.className = 'message-content';

const cursor = document.createElement('span');
cursor.className = 'cursor-blink';

msgEl.appendChild(contentEl);
msgEl.appendChild(cursor);
chatHistory.appendChild(msgEl);

let aiContent = '';

// 發送請求並讀取串流
const response = await fetch('/v1/chat/completions', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    model: selectedModel || 'default',
    messages: messages,
    stream: true,
    max_tokens: 512
  })
});

const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');

while (true) {
  const { done, value } = await reader.read();
  if (done) break;

  // 解碼串流 byte 資料為文字
  const chunk = decoder.decode(value, { stream: true });
  const lines = chunk.split('\n');

  for (const line of lines) {
    if (line.startsWith('data: ')) {
      const dataStr = line.replace('data: ', '').trim();
      if (dataStr === '[DONE]') continue;
      
      if (dataStr) {
        try {
          const data = JSON.parse(dataStr);
          if (data.choices && data.choices[0].delta.content) {
            // 累加 token 片段並即時渲染到介面
            aiContent += data.choices[0].delta.content;
            contentEl.textContent = aiContent; // 使用 textContent 防範 XSS 攻擊
            chatHistory.scrollTop = chatHistory.scrollHeight; // 自動滾動到底部
          }
        } catch (e) {
          console.error('Error parsing SSE JSON:', e);
        }
      }
    }
  }
}

// 結束後移除閃爍游標
cursor.remove();
messages.push({ role: 'assistant', content: aiContent });

這段 vanilla JS 的實作展現了 SSE 協議的輕巧。只用了不到 50 行程式碼,就完美複製出了 ChatGPT 原生的打字流暢感,甚至還帶有復古的終端機閃爍游標。


如何構建與運行?

想要親身體驗這款完全整合的本地 LLM Studio,編譯與啟動流程比你想像的還要簡單很多!

因為在 llm-local-studio-3 中,我們寫了一個強大的 build.rs 構建指令碼(Build Script)。當你執行 cargo buildcargo run 時,Rust 的編譯系統會自動檢查前端 ui 目錄並執行 npm installnpm run build

同時,它也利用 cargo:rerun-if-changed 監聽了前端的檔案變更。當你修改前端 HTML、CSS 或 JS 時,Cargo 會自動重新觸發前端打包與 Rust 編譯整合,讓開發體驗如絲般順滑。

一鍵編譯與啟動

因此,你不需要手動進入 ui 目錄去執行 npm 指令。只要確保你的電腦中安裝了 Node.js / npm,然後直接在專案根目錄下達 Cargo 啟動命令,並傳入你已下載或掃描註冊的模型識別名稱即可:

# 直接啟動服務(Rust 會自動接管 npm 前端編譯打包)
cargo run -- serve gemma-4-E4B-it --port 8080
 Tip

如果你想手動編譯前端,也可以在 ui 目錄下執行:

cd ui
npm install
npm run build

這會在 ui/dist 下生成靜態網頁檔案。而 Cargo 在編譯時,無論是自動還是手動,最終都會藉由 rust-embed 將該目錄的內容編譯並嵌入到最終的 Rust 執行檔中。

3. 打開瀏覽器享受對話

當你在終端機看到:

Starting llm-local-studio server (axum)...
  Base URL: http://127.0.0.1:8080
  Endpoints:
    GET  /health
    GET  /v1/models
    POST /v1/chat/completions
    GET  / (Web UI)

現在,直接打開瀏覽器瀏覽: 👉 http://localhost:8080

你就能看到精美的暗黑毛玻璃介面!在左側選擇已載入的模型,就能立即開始體驗完全處於本機、安全且快速的 LLM 對談!


總結與展望

藉由 llm-local-studio-3,我們成功地把原本複雜的 LLM 本地部署流程,精簡到只需要一個 Rust 執行檔就能搞定。

透過這三個章節的探討,我們從最底層的 GGUF 檔案解析、Rust FFI 原生 llama.cpp 運算,一路寫到 Axum 多執行緒異步 Web API、以及最終的嵌入式前端 UI 整合。這不僅是一個完整的實用工具,更是學習 Rust 系統級程式設計、異步 Web 開發與跨語言整合的絕佳實踐範例。

本專案 llm-local-studio-3 的完整原始碼已收錄於我們的 Rust 52 Projects 挑戰計畫中。如果你也對本地 AI 隱私安全、極致效能與 Rust 技術感興趣,歡迎至專案倉庫點個 Star,並嘗試下載編譯它!

我們下一篇 Rust 挑戰見!


本地 LLM 工作站:用 Python、Node.js、Rust 輕鬆串接 OpenAI 相容 API

featured.svg

本文由 AI Agent(Antigravity)代筆撰寫,文中的「我」指的是 AI Agent。Patrick 只有在文章最後做過潤飾調整。

上一篇文章中,我們為本機 LLM 推理引擎加上了以 Axum 為核心的 HTTP 伺服器,並實現了相容於 OpenAI Chat Completions 規範的 API 端點,支援 Server-Sent Events (SSE) 令牌串流輸出。

標準化的最大好處就是互操作性(Interoperability)。因為 API 格式與 OpenAI 完全一致,你不需要為本機的 llm-local-studio 重新撰寫任何客製化的 HTTP 請求解析程式,直接使用官方提供的 SDK 或現成的開源工具,僅需修改 伺服器端點位址(Base URL) 並將金鑰設為任意值,即可無縫對接。

這篇文章整理了如何使用 PythonNode.jsRustcurl 串接本機伺服器的實用程式碼範例,並介紹如何將它對接到 VS Code 等日常開發工具中。


本機 API 服務端點回顧

在開始寫程式之前,先確認你的本機伺服器已啟動並監聽 8080 連接埠:

cargo run --release -- serve gemma-4-e4b --port 8080

此時,你的本機服務對外提供以下相容於 OpenAI 的端點:

  • API 根路徑 (Base URL)http://localhost:8080/v1
  • 模型清單GET http://localhost:8080/v1/models
  • 對談補完POST http://localhost:8080/v1/chat/completions
 Tip

因為本機服務不需要驗證,你的 api_key 可以傳入任意字串(例如 "local-studio""noop")。許多 SDK 要求必須設定該值,否則會拋出未配置金鑰的錯誤。


深入協議:OpenAI API 與 SSE 串流運作原理

在我們開始看各語言的 SDK 程式碼之前,先來了解這個「OpenAI 相容協議」在 HTTP 底層到底是怎麼傳遞資料的。這能幫助我們理解為什麼各大 SDK 只需要換個 URL 就能直接對接本機服務。

1. 聊天補完請求 (Chat Completions Request)

當客戶端向本機服務發送請求時,發起的是一個標準的 HTTP POST 請求:

  • URL: http://localhost:8080/v1/chat/completions
  • Headers: Content-Type: application/json
  • JSON 欄位:
    • model (String): 本機載入的模型識別代號(如 gemma-4-e4b)。
    • messages (Array): 對話歷史紀錄,每個物件包含:
      • role (String): 角色,可為 system(系統提示詞)、user(使用者提問)或 assistant(AI 回答)。
      • content (String): 對話的文字內容。
    • stream (Boolean): 是否開啟串流模式(逐字生成)。
    • max_tokens (Integer, 選填): 限制生成的 Token 最大數量。

2. 非串流模式的響應 (Non-Streaming Response)

如果 stream: false,伺服器會阻塞等待推理完全結束後,一次性返回 HTTP 200 與完整的 JSON 響應:

{
  "id": "chatcmpl-c513296e-7eb2-4f66-9af7-045fbae2fa36",
  "object": "chat.completion",
  "created": 1779608333,
  "model": "gemma-4-e4b",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "Hello! I am a local assistant."
      },
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 12,
    "completion_tokens": 8,
    "total_tokens": 20
  }
}

3. 串流模式與 Server-Sent Events (SSE) 協議

stream: true 時,底層協議會切換為 Server-Sent Events (SSE)。這是一種基於 HTTP 的單向持久化連線技術,非常適合 LLM 逐字輸出的場景:

  • HTTP 響應標頭:
    • Content-Type: text/event-stream (告訴瀏覽器/客戶端這是一個事件流)
    • Cache-Control: no-cache (停用快取)
    • Connection: keep-alive (保持連線不中斷)
  • 傳輸格式: 伺服器會保持連接,每生成一個 token 片段,就向 TCP 通道寫入一段以 data: 開頭、\n\n 結尾的文字資料,內容是 JSON 格式的增量區塊(Delta Chunk):
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}

data: {"id":"chatcmpl-...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}

data: {"id":"chatcmpl-...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"!"},"finish_reason":null}]}
  • 結束信號: 當模型生成結束或達到最大 token 限制時,伺服器會先傳送最後一個帶有 finish_reason 的 chunk(通常內容為空),緊接著發送一個特殊的結束標記:
    data: [DONE]
    客戶端收到 [DONE] 後,便會主動關閉這個 HTTP 連線。

這套簡潔的文本串流規範就是 OpenAI API 的精髓。接下來我們來看看如何使用各語言的 SDK 包裝這套協議。


1. Python 串接範例

Python 是資料科學與 AI 開發的首選語言。我們可以使用官方的 openai 庫直接串接本機服務。

首先安裝 SDK:

pip install openai

非串流模式 (Non-Streaming)

from openai import OpenAI

# 指向本機伺服器的 Base URL,api_key 填入任意值即可
client = OpenAI(
    base_url="http://localhost:8080/v1",
    api_key="local-studio"
)

response = client.chat.completions.create(
    model="gemma-4-e4b",
    messages=[
        {"role": "user", "content": "Explain ownership in Rust programming"}
    ],
    max_tokens=80
)

print(response.choices[0].message.content)

串流模式 (Streaming)

from openai import OpenAI

client = OpenAI(
    base_url="http://localhost:8080/v1",
    api_key="local-studio"
)

# 啟用 stream=True 進行打字機效果輸出
stream = client.chat.completions.create(
    model="gemma-4-e4b",
    messages=[
        {"role": "user", "content": "Explain ownership in Rust programming"}
    ],
    max_tokens=80,
    stream=True
)

for chunk in stream:
    # 讀取 delta 增量內容並即時印出
    content = chunk.choices[0].delta.content
    if content is not None:
        print(content, end="", flush=True)
print()

2. Node.js / JavaScript 串接範例

如果你正在開發 Web 應用或 Node.js 後端服務,可以使用官方的 @openai/api 軟體包。

首先安裝 SDK:

npm install openai

非同步串流模式 (ESM / TypeScript)

使用現代 JavaScript 的 for await...of 語法,可以極度簡潔地處理本機傳回的 SSE 串流:

import OpenAI from "openai";

const openai = new OpenAI({
  baseURL: "http://localhost:8080/v1",
  apiKey: "local-studio",
});

async function main() {
  const stream = await openai.chat.completions.create({
    model: "gemma-4-e4b",
    messages: [
      { role: "user", content: "Explain ownership in Rust programming" }
    ],
    max_tokens: 80,
    stream: true,
  });

  for await (const chunk of stream) {
    const content = chunk.choices[0]?.delta?.content || "";
    process.stdout.write(content);
  }
  process.stdout.write("\n");
}

main().catch(console.error);

3. Rust 串接範例

對於 Rust 開發者,社群中廣受歡迎的 async-openai crate 是串接 OpenAI 的主力。

在你的 Cargo.toml 中加入依賴:

[dependencies]
async-openai = "0.26"
tokio = { version = "1", features = ["full"] }
futures = "0.3"

串流模式範例

我們需要透過 ClientConfig 自訂底層呼叫的 API 根路徑:

use async_openai::{
    config::OpenAIConfig,
    types::{CreateChatCompletionRequestArgs, ChatCompletionRequestMessage},
    Client,
};
use futures::StreamExt;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // 1. 自訂配置,將 api_base 指向本機端點
    let config = OpenAIConfig::default()
        .with_api_base("http://localhost:8080/v1")
        .with_api_key("local-studio");

    let client = Client::with_config(config);

    // 2. 建立 Request 參數
    let request = CreateChatCompletionRequestArgs::default()
        .model("gemma-4-e4b")
        .max_tokens(80u32)
        .messages(vec![
            ChatCompletionRequestMessage::User(
                async_openai::types::ChatCompletionRequestUserMessageArgs::default()
                    .content("Explain ownership in Rust programming")
                    .build()?,
            )
        ])
        .stream(true)
        .build()?;

    // 3. 獲取非同步 Stream 並依序讀取 token
    let mut stream = client.chat().create_stream(request).await?;

    while let Some(result) = stream.next().await {
        match result {
            Ok(response) => {
                for choice in response.choices {
                    if let Some(content) = choice.delta.content {
                        print!("{content}");
                        std::io::Write::flush(&mut std::io::stdout())?;
                    }
                }
            }
            Err(err) => eprintln!("Error: {err}"),
        }
    }
    println!();

    Ok(())
}

4. 終端機 curl 快速測試

不需要寫任何程式碼,使用 curl 也是進行快速排錯與 API 驗證的好幫手:

非串流 (返回單一 JSON)

curl http://localhost:8080/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "gemma-4-e4b",
    "messages": [
      {"role": "user", "content": "Explain ownership in Rust programming"}
    ],
    "max_tokens": 80
  }'

串流 (返回 SSE 封包)

curl http://localhost:8080/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "gemma-4-e4b",
    "messages": [
      {"role": "user", "content": "Hello!"}
    ],
    "stream": true,
    "max_tokens": 30
  }'

實戰整合:VS Code 開發助手 Continue

除了自己編寫程式外,你還可以直接將 llm-local-studio-2 連接至現成的編輯器外掛中。

以熱門的開源 VS Code 程式編寫助理外掛 Continue 為例,你只需修改 Continue 的 config.json 設定檔,將其模型供應商設為 openai,並將 apiBase 指向你的本機服務:

{
  "models": [
    {
      "title": "Local Studio - Gemma",
      "provider": "openai",
      "model": "gemma-4-e4b",
      "apiBase": "http://localhost:8080/v1",
      "apiKey": "local-studio"
    }
  ],
  "tabAutocompleteModel": {
    "title": "Local Studio - Gemma Autocomplete",
    "provider": "openai",
    "model": "gemma-4-e4b",
    "apiBase": "http://localhost:8080/v1",
    "apiKey": "local-studio"
  }
}

存檔後,VS Code 側邊欄的 Continue 對話框與行內自動補完功能,就會直接利用本機運行的 llm-local-studio 進行推論!


總結

相容於成熟的公有雲 API 標準,為本地 AI 工具注入了無限的可能性。透過將 FFI 執行緒與 Axum Web 伺服器解耦,並對外曝露標準的 OpenAI 協定,llm-local-studio-2 現在可以輕鬆地成為你開發流程中任何一環的「智慧核心」。

不論你是使用 Python 進行指令碼開發、用 Node.js 建立網頁應用、還是用 Rust 編寫系統級程式,串接方式都非常直覺簡單。歡迎將這些程式碼片段複製到你的專案中試試看!