本文由 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 引擎。敬請期待!
本文由 AI Agent(Antigravity)代筆撰寫,文中的「我」指的是 AI Agent。Patrick 只有在文章最後做過潤飾調整。
在之前的系列文章中,我們逐步建立起本機推論引擎的核心:
- 第一篇:基礎架構與 C FFI 整合:實現了模型註冊表、Hugging Face 下載管理,並直接透過 FFI 呼叫
llama.cpp 原生函式庫。
- 第二篇:非同步 Web API 伺服器:使用 Axum 搭建 HTTP 伺服器,設計了非同步與同步(FFI 執行緒)邊界的解耦架構,並實現相容於 OpenAI 規範的 SSE 令牌串流。
- 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。
這種架構在開發時非常方便,但在部署與發布給一般使用者時,會帶來不少痛點:
- 依賴環境複雜:使用者電腦必須安裝 Node.js 才能運行前端,對非網頁開發者門檻過高。
- CORS 跨網域阻擋:因為連接埠(Port)不同,瀏覽器會因為安全性考量阻擋請求,後端伺服器必須配置特別的 CORS 允許規則。
- 多檔案分發麻煩:你需要同時打包前端編譯產出的
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 run 或 cargo 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 build 或 cargo run 時,Rust 的編譯系統會自動檢查前端 ui 目錄並執行 npm install 與 npm 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 挑戰見!
本文由 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) 並將金鑰設為任意值,即可無縫對接。
這篇文章整理了如何使用 Python、Node.js、Rust 及 curl 串接本機伺服器的實用程式碼範例,並介紹如何將它對接到 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(通常內容為空),緊接著發送一個特殊的結束標記:
客戶端收到 [DONE] 後,便會主動關閉這個 HTTP 連線。
這套簡潔的文本串流規範就是 OpenAI API 的精髓。接下來我們來看看如何使用各語言的 SDK 包裝這套協議。
1. Python 串接範例
Python 是資料科學與 AI 開發的首選語言。我們可以使用官方的 openai 庫直接串接本機服務。
首先安裝 SDK:
非串流模式 (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:
非同步串流模式 (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 編寫系統級程式,串接方式都非常直覺簡單。歡迎將這些程式碼片段複製到你的專案中試試看!
本文由 AI Agent(Antigravity)代筆撰寫,文中的「我」指的是 AI Agent。Patrick 只有在文章最後做過潤飾調整。
在上一篇文章中,我們搭建了 llm-local-studio 的基石:實現了模型註冊表(Model Registry)、Hugging Face 模型下載,以及透過 Rust FFI 直接載入 llama.cpp 底層進行本機推理。
然而,光有 CLI 介面還不夠。要讓這個專案成為真正好用的「本地工作室」,它必須能夠跟現有的第三方生態系無縫對接。不論是網頁端的 Chat UI、VS Code 的 Copilot 外掛、還是本機的 Markdown 編輯器,它們大多都遵循同一個工業標準——OpenAI Chat Completions API。
因此,這次我們推出了 llm-local-studio-2。這篇文章將帶大家深入探討,我們是如何用 Rust 的 Axum 框架架設非同步 HTTP 伺服器、如何在非同步執行緒與同步的 llama.cpp FFI 引擎之間搭建高效安全的 thread 邊界,以及如何實現流暢的 Server-Sent Events (SSE) 令牌串流(Token Streaming)。
設計難題:非同步 HTTP 與同步 FFI 的衝突
在 Rust 裡寫 Web 伺服器,我們追求的是極高的併發處理能力。Axum 基於 Tokio 非同步執行時(Runtime),其核心設計是利用少數的 worker thread 透過 .await 快速切換任務,來達成高吞吐量。
但是,llama.cpp 的推理完全是另一回事:
- CPU/GPU 密集型運算:生成一個 token 需要執行龐大的矩陣乘法,會直接佔滿 CPU 核心或 GPU。
- 非 thread-safe 與同步阻塞:底層 C/C++ FFI 是同步阻塞的,而且對同一個模型上下文(Context)做推理是無法並行的,必須互斥存取。
如果在 Axum 的 handler 裡直接呼叫 llama.cpp 的推理函式,該 Tokio worker thread 就會被徹底阻塞。這會導致伺服器無法即時回應其他人的 HTTP 請求,甚至連簡單的 /health 健康檢查都會超時。
為了解決這個衝突,我們設計了下圖的架構:
sequenceDiagram
participant Client as Web Client
participant Axum as Axum / Tokio Task
participant Service as EngineService (Mutex)
participant Blocking as Tokio Blocking Thread
participant Llama as llama.cpp Engine (C FFI)
Client->>Axum: POST /v1/chat/completions (stream=true)
Axum->>Service: chat_streaming(messages)
Note over Service: 建立 tokio::sync::mpsc 管道 (tx, rx)
Service->>Blocking: tokio::task::spawn_blocking
Axum-->>Client: 立即回傳 HTTP 200 (SSE stream)
loop 推理循環 (在 Blocking Thread 中)
Blocking->>Service: 獲取 Mutex 鎖
Blocking->>Llama: 推理下一個 Token
Llama-->>Blocking: 回傳 token piece ("Rust")
Blocking->>Service: 釋放 Mutex 鎖
Blocking->>Axum: tx.blocking_send("Rust")
Axum-->>Client: SSE event (data: {"choices": [...]})
end
Note over Blocking: 推理結束 / 遇到 EOG
Blocking->>Axum: drop(tx) 關閉管道
Axum-->>Client: data: [DONE]
這套設計有三個關鍵點:
tokio::task::spawn_blocking:將所有 CPU 密集的 FFI 運算移交給 Tokio 專門的 blocking thread pool。這樣做可以解放主要的 non-blocking worker thread,確保 HTTP 伺服器隨時保持高響應性。
std::sync::Mutex:用來保護互斥的推理引擎。因為鎖是在 blocking thread 內部獲取與釋放,且絕不跨越 .await 邊界,所以我們可以直接使用標準庫的高效 Mutex,而不需要承擔 Tokio 異步 Mutex 的額外開銷。
tokio::sync::mpsc 管道:在非同步與同步 thread 之間搭建橋樑。當 blocking thread 生成 token 時,透過 tx.blocking_send 傳送;非同步端則將 rx 包裝成 Stream 連續輸出給 HTTP 客戶端。
非同步引擎服務:EngineService
我們封裝了一個 thread-safe 且 clone 成本極低的 EngineService 作為 HTTP handler 與底層引擎的媒介:
/// 執行緒安全、對非同步友善的推理服務
#[derive(Clone)]
pub struct EngineService {
inner: Arc<Mutex<LlamaCppEngine>>,
}
串流生成橋樑:chat_streaming
讓我們來看看如何把同步的 llama.cpp 回呼函式(callback)轉換成非同步的 mpsc::Receiver 串流:
pub fn chat_streaming(
&self,
messages: Vec<ChatMessage>,
max_tokens: u32,
seed: Option<u32>,
) -> (mpsc::Receiver<String>, tokio::task::JoinHandle<Result<GenerateOutput>>) {
// 1. 建立非同步多生產者單消費者管道,緩衝區設為 64
let (tx, rx) = mpsc::channel::<String>(64);
let engine = self.inner.clone();
// 2. 在 blocking thread pool 中啟動推理任務
let handle = tokio::task::spawn_blocking(move || {
let mut engine = engine.lock().expect("engine mutex poisoned");
let tx_clone = tx.clone();
let result = engine.chat(ChatRequest {
messages,
max_tokens,
seed,
// 3. 當底層生成一個 token piece 時,觸發此 FFI 回呼
stream_callback: Some(Box::new(move |piece: &str| {
// 如果用戶端已斷開連接(Receiver 已 drop),我們忽略錯誤並繼續
let _ = tx_clone.blocking_send(piece.to_owned());
})),
});
// 4. 任務結束時主動 drop(tx),使 Receiver 收到 None 訊號得知結束
drop(tx);
result
});
(rx, handle)
}
這個設計非常優雅:
blocking_send 能確保當非同步端的網路發送隊列滿載時,同步執行緒會進行適度的回壓(Backpressure)。
- 即使客戶端突然中斷連線,非同步端丟棄了
rx,blocking_send 也只會回傳錯誤,推理任務仍會安全地收尾,而不會導致伺服器崩潰。
構建 OpenAI 相容 API 伺服器
有了 EngineService 後,我們就能用 Axum 輕鬆搭建起對外的 HTTP API。
伺服器啟動與路由設定
在 src/api/mod.rs 中,我們設定了三個端點,並套用了 permissive CORS 中間件,以相容各類 Web 前端工具:
pub async fn start_server(config: ServerConfig, engine: EngineService) -> Result<()> {
let bind_addr = format!("{}:{}", config.host, config.port);
// 允許任何來源的 CORS 設定,方便第三方 Web UI 連接
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
let app = Router::new()
.route("/health", get(routes::health))
.route("/v1/models", get(routes::list_models))
.route("/v1/chat/completions", post(routes::chat_completions))
.layer(cors)
.with_state(engine);
let listener = tokio::net::TcpListener::bind(&bind_addr).await?;
axum::serve(listener, app).await.context("HTTP server error")
}
SSE 串流與非串流的分流處理
在 /v1/chat/completions 的實作中,我們會依據客戶端傳入的 JSON 中是否含有 "stream": true,來決定回傳單一的 JSON 響應,還是 SSE 事件串流:
pub async fn chat_completions(
State(engine): State<EngineService>,
Json(request): Json<ChatCompletionRequest>,
) -> Response {
let max_tokens = request.max_tokens.unwrap_or(128);
let seed = request.seed;
let streaming = request.stream.unwrap_or(false);
let model_id = engine
.model_info()
.map(|info| info.model_id)
.unwrap_or_else(|| request.model.clone());
if streaming {
handle_streaming(engine, request.messages, max_tokens, seed, model_id).await
} else {
handle_non_streaming(engine, request.messages, max_tokens, seed, model_id).await
}
}
實現 SSE Token 串流
串流生成需要遵循 Server-Sent Events (SSE) 規範。在 Axum 中,我們可以使用 Sse 回傳型態,將一個 Stream 轉化為 HTTP 串流響應。
為了完全模擬 OpenAI 的行為,我們的 SSE 串流需要包含三個部分:
- 初始區塊:宣布
role: assistant,但不含任何內容。
- 內文區塊:連續發送包含
delta 內容的 token 片段。
- 結束區塊:提供結束原因(例如
finish_reason: "stop"),並以 data: [DONE] 標記串流正式結束。
我們使用 futures::stream 的 combinator 將這三個階段鏈接成一個完整的 Stream:
async fn handle_streaming(
engine: EngineService,
messages: Vec<ChatMessage>,
max_tokens: u32,
seed: Option<u32>,
model_id: String,
) -> Response {
let completion_id = generate_completion_id();
let created = unix_timestamp();
// 1. 取得 mpsc 接收端與 blocking thread 的 JoinHandle
let (rx, result_handle) = engine.chat_streaming(messages, max_tokens, seed);
let token_stream = ReceiverStream::new(rx);
// 2. 第一個事件:發送 Role
let role_chunk = ChatCompletionChunk {
id: completion_id.clone(),
object: "chat.completion.chunk".to_string(),
created,
model: model_id.clone(),
choices: vec![ChunkChoice {
index: 0,
delta: ChunkDelta { role: Some(Role::Assistant), content: None },
finish_reason: None,
}],
};
let role_event = Event::default().data(serde_json::to_string(&role_chunk).unwrap_or_default());
let role_stream = stream::once(async move { Ok::<Event, std::convert::Infallible>(role_event) });
// 3. 中間的 token 事件流
let id_for_tokens = completion_id.clone();
let model_for_tokens = model_id.clone();
let token_events = token_stream.map(move |piece| {
let chunk = ChatCompletionChunk {
id: id_for_tokens.clone(),
object: "chat.completion.chunk".to_string(),
created,
model: model_for_tokens.clone(),
choices: vec![ChunkChoice {
index: 0,
delta: ChunkDelta { role: None, content: Some(piece) },
finish_reason: None,
}],
};
Ok::<Event, std::convert::Infallible>(
Event::default().data(serde_json::to_string(&chunk).unwrap_or_default())
)
});
// 4. 最後的結束事件:等 JoinHandle 結束取得 finish_reason,然後送出 [DONE]
let id_for_final = completion_id;
let model_for_final = model_id;
let final_events = stream::once(async move {
let finish_reason = match result_handle.await {
Ok(Ok(output)) if output.generated_tokens >= max_tokens => "length",
_ => "stop",
};
let done_chunk = ChatCompletionChunk {
id: id_for_final,
object: "chat.completion.chunk".to_string(),
created,
model: model_for_final,
choices: vec![ChunkChoice {
index: 0,
delta: ChunkDelta { role: None, content: None },
finish_reason: Some(finish_reason.to_string()),
}],
};
let done_json = serde_json::to_string(&done_chunk).unwrap_or_default();
let events = vec![
Ok::<Event, std::convert::Infallible>(Event::default().data(done_json)),
Ok::<Event, std::convert::Infallible>(Event::default().data("[DONE]")),
];
stream::iter(events)
}).flatten();
// 5. 鏈接所有串流並包裝成 SSE 響應
let full_stream = role_stream.chain(token_events).chain(final_events);
let sse = Sse::new(full_stream).keep_alive(axum::response::sse::KeepAlive::default());
// 6. 設定緩衝控制標頭,防止中介 Proxy 攔截阻礙實時輸出
(
[
(axum::http::header::CACHE_CONTROL, "no-cache"),
(axum::http::header::HeaderName::from_static("x-accel-buffering"), "no"),
],
sse,
).into_response()
}
Note
在回傳 SSE 時,我們加入了 x-accel-buffering: no 標頭。這是一個很容易被忽視但至關重要的設定。如果你的本機服務未來透過 Nginx 進行反向代理,Nginx 預設會快取(buffer)伺服器傳回的資料,直到集滿一定大小才一口氣發給客戶端,這會直接摧毀 SSE 的實時「打字機」效果。這個標頭能強制代理伺服器不要進行任何快取。
自動化對談範本偵測 (Chat Template System)
LLM 本質上只是接龍工具,把對談紀錄(例如 Messages Array)轉換成模型能讀懂的單一純文字 prompt,就需要「對談範本(Chat Template)」。
不同的模型使用的範本格式大相逕庭。例如:
- ChatML 格式(Qwen, Yi 等模型常用):
<|im_start|>system
You are a helpful assistant.<|im_end|>
<|im_start|>user
Hello!<|im_end|>
<|im_start|>assistant
- Llama 3 格式:
<|begin_of_text|><|start_header_id|>system<|end_header_id|>
You are a helpful assistant.<|eot_id|><|start_header_id|>user<|end_header_id|>
Hello!<|eot_id|><|start_header_id|>assistant<|end_header_id|>
在 src/chat_template.rs 中,我們定義了 ChatTemplate trait,並實作了 ChatMLTemplate、Llama3Template 以及 GenericTemplate(基於簡單的 ### User:)。
為了讓使用者不必每次都手動指定格式,我們寫了一個簡單實用的自動偵測機制:
pub fn auto_detect(model_name: &str) -> Box<dyn ChatTemplate> {
let lower = model_name.to_lowercase();
if lower.contains("llama-3") || lower.contains("llama3") {
Box::new(Llama3Template)
} else if lower.contains("chatml") || lower.contains("im_start") {
Box::new(ChatMLTemplate)
} else {
// 安全的安全預設值,因為大多數現代微調模型皆相容於 ChatML
Box::new(ChatMLTemplate)
}
}
有了自動偵測,當系統載入模型時,會自動判斷並解析出合適的 prompt 格式,大幅減少配置的繁瑣度。
命令列指令整合:serve
現在我們將這一切整合進 CLI。透過 clap 定義新的 serve 子命令:
/// 啟動與 OpenAI 相容的 HTTP API 伺服器
Serve {
/// 本地 GGUF 路徑、模型 ID 或已下載檔案名稱的局部片段
model: String,
/// 搜尋與解析下載模型時的模型根資料夾
#[arg(short, long, default_value = "models")]
dir: PathBuf,
/// 模型的上下文大小
#[arg(long, default_value_t = 2048)]
ctx_size: u32,
/// 伺服器繫結的 Host 位址
#[arg(long, default_value = "127.0.0.1")]
host: String,
/// 伺服器繫結的連接埠 (Port)
#[arg(long, default_value_t = 8080)]
port: u16,
}
在啟動時,它會先透過 resolve_model 模糊匹配找出實體檔案,將模型載入記憶體,然後開啟 HTTP 監聽:
async fn serve_model(model: String, dir: PathBuf, ctx_size: u32, host: String, port: u16) -> Result<()> {
let model_record = resolve_model(&model, dir)?;
println!("Loading model: {}", model_record.id);
println!(" path: {}", model_record.path.display());
println!();
let engine = EngineService::new();
engine.load_model(LoadModelRequest {
model_id: model_record.id.clone(),
path: model_record.path.clone(),
context_size: Some(ctx_size),
}).await?;
let config = ServerConfig { host, port };
start_server(config, engine).await
}
實測驗證
我們可以透過簡單的命令列直接運行它:
# 啟動 API 伺服器(系統會自動模糊搜尋 models/ 目錄下的 gemma-4-E4B-it 模型)
cargo run --release -- serve gemma-4-E4B-it --port 8080
當伺服器成功運行後,我們可以用 curl 來測試。
1. 測試一般 JSON 響應 (Non-Streaming)
curl http://localhost:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "gemma-4-E4B-it",
"messages": [
{"role": "user", "content": "Explain ownership in Rust programming"}
],
"max_tokens": 80
}'
輸出結果:
{
"id": "chatcmpl-a6bf02ed-3775-4dcd-a1b4-2492bec524d0",
"object": "chat.completion",
"created": 1779653484,
"model": "gemma-4-E4B-it-Q4_K_M",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Ownership is one of the most fundamental and unique concepts in the Rust programming language. It is the core mechanism that allows Rust to guarantee **memory safety** (preventing issues like dangling pointers and data races) *without* needing a garbage collector (like in Java or Python).\n\nIn simple terms, **Ownership is a set of rules that governs how Rust manages memory and how long data lives in memory"
},
"finish_reason": "length"
}
],
"usage": {
"prompt_tokens": 29,
"completion_tokens": 80,
"total_tokens": 109
}
}
Note
咦?這個回答是不是出乎意料的好?它非常準確地解釋了 Rust 的所有權(Ownership)機制:這是保證記憶體安全的核心機制,而且不需要垃圾回收機制(Garbage Collector)。
沒錯!這就是本地運行 gemma-4-E4B-it 的真實寫照。雖然它能在個人電腦上流暢運行,但依然保留了強大的程式碼與技術概念理解能力。
這也千真萬確證明了一件事:我們的本機推論引擎確實 100% 跑在自己的 CPU/GPU 上,成功載入了模型並進行實時推理。
2. 測試 SSE 串流響應 (Streaming)
curl http://localhost:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "gemma-4-E4B-it",
"messages": [
{"role": "user", "content": "Explain ownership in Rust programming"}
],
"stream": true,
"max_tokens": 30
}'
輸出串流結果:
data: {"id":"chatcmpl-20b6d4b6-1ef3-45ed-9400-7bb1ce361ff0","object":"chat.completion.chunk","created":1779653484,"model":"gemma-4-E4B-it-Q4_K_M","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}
data: {"id":"chatcmpl-20b6d4b6-1ef3-45ed-9400-7bb1ce361ff0","object":"chat.completion.chunk","created":1779653484,"model":"gemma-4-E4B-it-Q4_K_M","choices":[{"index":0,"delta":{"content":"Ownership"},"finish_reason":null}]}
data: {"id":"chatcmpl-20b6d4b6-1ef3-45ed-9400-7bb1ce361ff0","object":"chat.completion.chunk","created":1779653484,"model":"gemma-4-E4B-it-Q4_K_M","choices":[{"index":0,"delta":{"content":" is"},"finish_reason":null}]}
data: {"id":"chatcmpl-20b6d4b6-1ef3-45ed-9400-7bb1ce361ff0","object":"chat.completion.chunk","created":1779653484,"model":"gemma-4-E4B-it-Q4_K_M","choices":[{"index":0,"delta":{"content":" one"},"finish_reason":null}]}
...
data: {"id":"chatcmpl-20b6d4b6-1ef3-45ed-9400-7bb1ce361ff0","object":"chat.completion.chunk","created":1779653484,"model":"gemma-4-E4B-it-Q4_K_M","choices":[{"index":0,"delta":{},"finish_reason":"length"}]}
data: [DONE]
可以看到,所有的輸出都嚴格符合 OpenAI 協定,並且流暢地以 Token 片段傳回!我們看到了 “Ownership”, " is", " one" 等單字片段逐一生成,這無疑是 100% 真實的非同步 SSE 串流封包。
結語與展望
透過非同步執行緒與同步 FFI 的清晰解耦,我們讓 Rust 成功扮演起高效能 API 閘道器(Gateway)的角色,完美串接起底層 llama.cpp 原生庫與現代非同步 Web 生態系。
目前我們的本地 LLM 工作站已具備以下能力:
- 遞迴掃描管理本機 GGUF 模型。
- 從 Hugging Face 自動化規劃與安全原子下載。
- 自動化偵測與套用對談範本(Chat Templates)。
- 相容於 OpenAI API 標準的非同步 HTTP 伺服器,支援 SSE 串流。
這意味著你可以立刻把它當作 Ollama 的輕量化替代品,串接到你常用的開發工具或瀏覽器外掛中!
下一個階段,我們將會探索:
- 多模型熱插拔 API:允許客戶端在運行時透過 API 請求動態載入/卸載不同模型。
- 進階採樣參數配置:在 API 中實作 Temperature、Top-P、Top-K 及 Repetition Penalty 等精細化推理配置。
- 更美觀的 Web 或 Desktop 管理介面。
如果你對此專案有興趣,歡迎參考完整的原始碼並一起交流!
本文由 AI Agent(Claude)代筆撰寫,文中的「我」指的是 AI Agent。Patrick 只有在文章最後做過潤飾調整。
上一篇我們聊了怎麼用 Rust 手寫 GGUF 解析器,把模型的二進位結構拆解得清清楚楚。但光會「讀」模型檔還不夠——你還要能「搜」模型、「下載」模型、最後「跑」模型,這才是一個完整的工作流程。
這次我做了 llm-local-studio,一個學習導向的本地 LLM app shell。它的目標很明確:不用 Ollama 或 LM Studio 那種外部服務,直接在 Rust 裡呼叫 llama.cpp 做推理,同時把 Hugging Face 的模型搜尋和下載流程也串起來。
這篇文章來聊聊幾個關鍵的設計決定:怎麼架構 CLI、怎麼跟 Hugging Face API 溝通、怎麼安全地下載模型、以及最重要的——怎麼讓 llama.cpp 變成你 Rust 程式的一部分,而不是一個外掛的子進程。
整體架構:四層分離
這個專案的核心精神是邊界清楚。整個 app 分成四層,每層有明確的職責:
graph TD
AppShell[App Shell
CLI — 未來 Desktop/Web UI]
AppServices[Application Services
Model Registry · HF Catalog · Settings]
InferenceInterface[Inference Engine Interface
load · unload · chat · health]
LlamaCppAdapter[llama.cpp Native Adapter
Rust FFI → llama.cpp / ggml]
AppShell --> AppServices
AppServices --> InferenceInterface
InferenceInterface --> LlamaCppAdapter
為什麼要分這麼多層?因為依賴方向必須單向。App 層只仰賴「推理引擎介面」,不直接碰 llama.cpp 的 C API 符號。未來如果要換成別的推理引擎(例如 ONNX Runtime 或 candle),只要 implement 同一個 trait 就好,上面的程式碼一行都不用改。
CLI 設計:用 clap Derive 模式打造四個指令
CLI 是 Phase 1 的主要入口,用 clap 的 derive 模式定義:
#[derive(Debug, Subcommand)]
enum Command {
/// 掃描本地資料夾裡的 GGUF 檔案
Scan { dir: PathBuf },
/// 搜尋 Hugging Face 上的 GGUF 模型
HfSearch { query: String, limit: usize },
/// 從 Hugging Face 下載 GGUF 模型
HfDownload { name: String, filename: Option<String>, ... },
/// 用 llama.cpp 跑本地模型推理
Run { model: String, prompt: String, ... },
}
四個指令對應四個核心功能:掃描、搜尋、下載、推理。每個指令都是一個獨立的函式,從 Command::run() 分派出去:
impl Command {
fn run(self) -> Result<()> {
match self {
Self::Scan { dir } => scan_models(dir),
Self::HfSearch { query, limit } => search_hugging_face(query, limit),
Self::HfDownload { .. } => download_hugging_face_model(..),
Self::Run { .. } => run_model(..),
}
}
}
為什麼用 derive 而不是 builder?因為我們的指令結構很穩定,不需要動態組合。derive 模式讓每個欄位的 /// 註解自動變成 --help 說明文字,省了寫文件的重工。
而 Run 指令特別有趣的地方在於 model 參數——它不只接受檔案路徑,還能接受模型名稱。背後的 resolve_model_path() 會先檢查是不是直接路徑,如果不是就去 registry 裡模糊搜尋:
fn resolve_model_path(model: &str, dir: PathBuf) -> Result<PathBuf> {
let direct_path = PathBuf::from(model);
if direct_path.try_exists()? {
return Ok(direct_path);
}
let registry = ModelRegistry::scan_dir(&dir)?;
Ok(registry.find(model)?.path.clone())
}
所以你可以這樣用:
# 直接給路徑
llm-local-studio run models/Q4_K_M.gguf -p "Hello"
# 或者只給檔名的一部分
llm-local-studio run tinyllama -p "Hello"
Model Registry:遞迴掃描 GGUF 模型
ModelRegistry 的工作很單純:遞迴掃描一個資料夾,找出所有 .gguf 檔案,建構出 ModelRecord 清單:
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelRecord {
pub id: String,
pub path: PathBuf,
pub size_bytes: u64,
pub source: ModelSource,
pub status: ModelLoadStatus,
}
掃描的核心是 scan_dir_recursive():
fn scan_dir_recursive(dir: &Path, models: &mut Vec<ModelRecord>) -> Result<()> {
if !dir.try_exists()? {
return Ok(());
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let metadata = entry.metadata()?;
if metadata.is_dir() {
scan_dir_recursive(&path, models)?;
} else if is_gguf_file(&path) {
models.push(ModelRecord { ... });
}
}
Ok(())
}
這裡有個值得提的小細節:try_exists() 而不是 exists()。差別在哪?exists() 在遇到權限錯誤時會回傳 false(靜默吞掉錯誤),但 try_exists() 會把真正的錯誤(例如 permission denied)往外拋。對於一個 CLI 工具來說,不該靜默吞掉任何錯誤。
模型 ID 是從檔名推導出來的——model.gguf → "model",簡單直接。而 find() 方法支援精確比對和模糊比對的雙層搜尋:
pub fn find(&self, query: &str) -> Result<&ModelRecord> {
// 第一層:精確比對(不分大小寫)
let mut matches = self.models.iter().filter(|model| {
model.id.eq_ignore_ascii_case(query)
|| model.path.file_name()...eq_ignore_ascii_case(query)
});
if let Some(model) = matches.next() {
return Ok(model);
}
// 第二層:部分比對(contains)
let partial_matches = self.models.iter().filter(|model| {
model.id.to_lowercase().contains(&query_lower)
|| model.path...to_lowercase().contains(&query_lower)
}).collect::<Vec<_>>();
match partial_matches.as_slice() {
[model] => Ok(model), // 唯一命中
[] => bail!("no local model matched {query:?}"),
matches => bail!("model name {query:?} is ambiguous; matches: {ids}"),
}
}
模糊比對的歧義處理很貼心——如果多個模型都符合,它不是隨便挑一個,而是列出所有命中的模型 ID 讓你自己選。這避免了「我明明說 A 你卻跑了 B」的詭異情況。
Hugging Face 整合:從搜尋到下載的完整流程
三步下載流程
下載一個 Hugging Face 模型不是簡單的「給 URL 就抓」,而是經過規劃→確認→執行三個步驟:
graph LR
A[解析模型名稱
resolve_model] --> B[列出 GGUF 檔案
list_gguf_files]
B --> C[選擇檔案
choose_gguf_file]
C --> D[計算本地路徑
local_model_path]
D --> E[執行下載
download_gguf]
這個設計的關鍵在於 plan_download()——它把所有決策都做完了,但不會真的下載。你可以先用 --print-url 看看 URL 對不對:
# 先看下載 URL,不下載
llm-local-studio hf-download TheBloke/TinyLlama-1.1B-GGUF --print-url
# 確認無誤後再下載
llm-local-studio hf-download TheBloke/TinyLlama-1.1B-GGUF
智慧量化選擇
Hugging Face 上同一個模型通常有很多種量化版本(Q4_K_M、Q5_K_S、Q8_0……),到底該下哪個?choose_gguf_file() 用了一個偏好清單來自動判斷:
const PREFERRED_QUANTS: &[&str] = &["Q4_K_M", "Q4_K_S", "Q5_K_M", "Q8_0", "Q4_0"];
pub fn choose_gguf_file<'a>(
&self,
files: &'a [HuggingFaceFile],
requested: Option<&str>,
) -> Result<&'a HuggingFaceFile> {
if let Some(requested) = requested {
return files.iter()
.find(|file| file.rfilename == requested)
.with_context(|| ...);
}
for preferred in PREFERRED_QUANTS {
if let Some(file) = files.iter().find(|file| file.rfilename.contains(preferred)) {
return Ok(file);
}
}
files.first().context("repository does not contain GGUF files")
}
Q4_K_M 排第一位——這是社群公認 C/P 值最高的量化等級,兼顧模型品質和檔案大小。如果你有特別偏好,也可以用 --filename 手動指定。
安全下載:原子寫入
下載大檔案最怕什麼?寫到一半斷了,留下一個損壞的檔案。download_gguf() 用了一個簡單但有效的策略——先寫暫存檔,完成後才改名:
let temp_path = plan.destination.with_extension("gguf.part");
let mut output = fs::File::create(&temp_path)?;
let bytes_written = io::copy(&mut response, &mut output)?;
drop(output); // 確保 flush
fs::rename(&temp_path, &plan.destination)?;
.gguf.part → .gguf 的 rename 在同一個檔案系統上是原子操作——要嘛成功看到完整檔案,要嘛看到暫存檔(下次重跑會自動跳過)。不會出現「寫一半的爛檔案」。
防護不只這一個。路徑安全也很重要——local_model_path() 會檢查檔名裡有沒有 .. 路徑穿越攻擊:
fn local_model_path(models_dir: &Path, model: &HuggingFaceModelRef, filename: &str) -> Result<PathBuf> {
if filename.is_empty() || filename.contains("..") {
anyhow::bail!("invalid Hugging Face filename {filename:?}");
}
for component in filename.split('/') {
if component.is_empty() || component == "." || component == ".." {
anyhow::bail!("invalid Hugging Face filename {filename:?}");
}
path.push(component);
}
Ok(path)
}
你不會想讓一個惡意的 Hugging Face 上傳者用 ../../etc/passwd 這種檔名把東西寫到系統目錄裡吧?
下載後的目錄結構也很有系統——按照 models/huggingface/{owner}/{repo}/{filename}.gguf 組織,一目了然:
models/
huggingface/
TheBloke/
TinyLlama-1.1B-GGUF/
tinyllama-1.1b-chat-v1.0.Q4_K_M.gguf
llama.cpp 原生整合:不走子進程,直接 FFI
這是整個專案最核心也最有趣的部分。
大多數 Rust 專案要用 llama.cpp 時,都是用 Command::new("llama-server") 啟一個子進程,然後透過 HTTP API 溝通。但我們的做法完全不同——直接在 Rust 進程裡載入 llama.cpp 的 shared library,用純 Rust API 做推理。
這要歸功於 llama-cpp-2 這個 crate——它把 llama.cpp 的 C API 封裝成了安全的 Rust 介面。
InferenceEngine Trait:定義未來的邊界
我們先定義了應用層的推理介面:
pub trait InferenceEngine {
fn load_model(&mut self, request: LoadModelRequest) -> Result<ModelHandle>;
fn unload_model(&mut self, model_id: &str) -> Result<()>;
fn health(&self) -> EngineHealth;
}
以及對應的狀態型態:
pub enum EngineHealth {
NotConfigured,
Ready,
Loaded { model_id: String },
Error { message: String },
}
今天 LlamaCppEngine 實作了這個 trait,明天如果要接 ONNX Runtime 或 candle,只要新寫一個 impl——上面的 CLI 和 registry 完全不用改。
LlamaCppRunner:一行一行吐出回應
真正做推理的是 LlamaCppRunner。它的 run() 方法完整展示了用 llama.cpp 跑一次 text completion 的全流程:
impl LlamaCppRunner {
pub fn run(&self, request: &RunModelRequest) -> Result<RunModelOutput> {
suppress_llama_logs();
// 1. 初始化 llama.cpp 後端
let backend = LlamaBackend::init()
.context("failed to initialize llama.cpp backend")?;
// 2. 載入 GGUF 模型
let model = LlamaModel::load_from_file(
&backend, &request.model_path, &LlamaModelParams::default()
)?;
// 3. 建立 context(設定 context window 大小)
let context_size = NonZeroU32::new(request.context_size)?;
let context_params = LlamaContextParams::default()
.with_n_ctx(Some(context_size));
let mut context = model.new_context(&backend, context_params)?;
// 4. Tokenize prompt
let prompt_tokens = model.str_to_token(&request.prompt, AddBos::Always)?;
// 5. 檢查 context 容量
let requested_tokens = prompt_tokens.len() + request.max_tokens as usize;
if requested_tokens > context.n_ctx() as usize {
anyhow::bail!("prompt plus generated tokens require {requested_tokens} ...");
}
// 6. 處理 prompt(一次性送入 KV cache)
let mut batch = LlamaBatch::new(512, 1);
for (position, token) in (0_i32..).zip(prompt_tokens.into_iter()) {
batch.add(token, position, &[0], position == last_prompt_index)?;
}
context.decode(&mut batch)?;
// 7. 逐 token 生成(streaming)
let mut sampler = LlamaSampler::chain_simple([
LlamaSampler::dist(1234),
LlamaSampler::greedy(),
]);
while generated_tokens < request.max_tokens {
let token = sampler.sample(&context, batch.n_tokens() - 1);
sampler.accept(token);
if model.is_eog_token(token) { break; }
let piece = model.token_to_piece(token, &mut decoder, true, None)?;
print!("{piece}"); // 逐字印出
std::io::stdout().flush()?;
batch.clear();
batch.add(token, position, &[0], true)?;
context.decode(&mut batch)?;
position += 1;
generated_tokens += 1;
}
Ok(RunModelOutput { generated_tokens })
}
}
幾個值得注意的設計選擇:
Batch 處理 prompt:prompt 的所有 token 一次性放進 LlamaBatch,只有最後一個 token 標記為「需要 logits」(position == last_prompt_index)。這樣 decode() 一次就把整個 prompt 寫入 KV cache,比逐 token decode 快得多。
什麼是 KV Cache?
上面提到好幾次 KV cache,但它到底是什麼?為什麼這麼重要?
Transformer 的注意力機制有一個特性:每生成一個新 token,都需要看過之前所有的 token。更具體地說,每一層的 self-attention 在計算時,需要每個先前 token 經過 Key(K) 和 Value(V) 線性投影後的結果。
如果沒有 KV cache,每生成一個新 token,你就得把整個 prompt 再重新跑一次注意力計算——prompt 有 1000 個 token 的話,每跑一個新 token 就要重算 1000 次。這不是只是慢,而是浪費——因為前面那些 token 的 K 和 V 值根本沒變!
KV cache 的想法很直覺:算過的就存起來,下次直接用。
graph LR
subgraph 第一次 Decode
P1[Prompt: 1000 tokens] --> K1[Key 投影]
P1 --> V1[Value 投影]
K1 --> Cache1[寫入 KV Cache]
V1 --> Cache1
end
subgraph 之後每個新 Token
NT[新 Token: 1 個] --> KN[只算這 1 個的 K 和 V]
KN --> Cache2[追加到 KV Cache]
Cache1 -.-> Q[Query 用新 token
對整個 cache 做 attention]
Cache2 -.-> Q
Q --> Next[預測下一個 token]
end
實際效果有多驚人?以一個 32 層、4096 維度的 LLaMA 模型為例:
- 無 KV cache:每生成 1 個 token,要重新計算整個 prompt 的注意力 → O(n²) 隨 prompt 長度暴增
- 有 KV cache:每生成 1 個 token,只需計算新 token 自己的 K 和 V,然後對 cache 做一次 attention → O(n) 線性增長
這就是為什麼我們在程式碼裡,要把整個 prompt 一次性丟進 context.decode(&mut batch)——這一步就是在預填 KV cache(pre-fill)。之後的生成迴圈裡,每次只需要把一個新 token 的 K 和 V 追加到 cache 裡,再讓 Query 去對整個 cache 做 attention,就能預測下一個 token。
KV cache 的代價是記憶體。它佔用的空間跟「prompt 長度 × 層數 × 維度」成正比,這也是為什麼 context_size 參數這麼重要——它決定了 KV cache 最多能裝多少 token。我們程式碼裡的 context 容量檢查:
if requested_tokens > context.n_ctx() as usize {
anyhow::bail!("prompt plus generated tokens require {requested_tokens} ...");
}
本質上就是在檢查:你要裝進 KV cache 的 token 數,有沒有超過預先分配的容量。
Streaming 輸出:每生成一個 token 就 print!() + flush(),所以你在終端機上會看到模型一個字一個字慢慢冒出來,跟用 ChatGPT 的體驗一樣。這比「全部算完再一次印出」的使用者體驗好太多了。
End-of-generation 偵測:model.is_eog_token(token) 檢查是否遇到了結束符號(<eos>、<|end|> 等)。模型自己覺得說完了就停,不會硬湊到 max_tokens。
Sampler 鏈:LlamaSampler::chain_simple([dist, greedy]) 看起來有點奇怪——先加一個 temperature distribution(seed 1234),再用 greedy?這是 llama-cpp-2 的 API 設計:dist 負責根據機率分佈抽樣,greedy 在最後一步保證取最高機率的 token。對於 deterministic 輸出來說這是常見的簡易設定。
Context 容量檢查:在開始生成之前先檢查 prompt 長度 + max_tokens 有沒有超過 context window。與其跑到一半 OOM 或得到亂碼,不如一開始就明確報錯。
延伸話題:llama.cpp 怎麼「認出」模型架構?
你可能會好奇:同一個 llama-cli 程式,為什麼能跑 LLaMA、Mistral、Qwen2、Gemma、Phi-3、DeepSeek 等幾十種不同的模型?我們在命令列上從來不用特別指定「這是 Mistral 模型」——那 llama.cpp 是怎麼知道該用哪種計算圖的?
答案藏在 GGUF 檔案的 Metadata 裡:general.architecture。
上一篇 GGUF 解析器文章提過,每個 GGUF 檔案都有一組鍵值對形式的 Metadata。其中有一個關鍵的 key:
general.architecture → String("llama")
這就是模型的「身分證」。llama.cpp 在 llama_model_load() 時,第一件事就是讀取這個值,然後對應到一個內部的 enum:
// 概念上等價於 llama.cpp 內部的 e_model enum
enum ModelArch {
Llama,
Mistral,
Gpt2,
Phi2,
Qwen2,
Gemma,
Mamba,
// ... 60+ 種架構
}
如果 general.architecture 的值不在支援清單裡,llama.cpp 會直接拒絕載入,回傳 "model architecture not supported"——它不會猜,也不會 fallback。
架構決定了什麼?
選中的架構控制了推理的一切細節:
| 面向 |
LLaMA |
GPT-2 |
Mamba |
| 注意力機制 |
GQA + RoPE |
MHA + 絕對位置編碼 |
SSM(不是注意力) |
| 前饋網路 |
SwiGLU(gate + up + down) |
兩層 MLP |
SSM 門控 |
| 正規化 |
RMSNorm |
LayerNorm |
RMSNorm |
| 位置編碼 |
RoPE(旋轉位置編碼) |
學習式絕對位置 |
無需位置編碼 |
| Tensor 命名 |
blk.{i}.attn_q.weight |
transformer.h.{i}.attn.c_attn.weight |
blk.{i}.ssm_in.weight |
每一種架構都有自己的一套 tensor 命名慣例、計算圖結構、和特殊的 kernel 實作。llama.cpp 根據 general.architecture 的值,分派到對應的程式碼路徑。
還記得上一篇 GGUF 解析器讀出的 metadata key 嗎?它們有一個很規律的前綴系統:
# 如果 general.architecture = "llama"
llama.context_length → Uint32(2048)
llama.embedding_length → Uint32(4096)
llama.block_count → Uint32(32)
# 如果 general.architecture = "mistral"
mistral.context_length → Uint32(32768)
mistral.embedding_length → Uint32(4096)
架構名稱就是 metadata key 的前綴。 llama.cpp 讀到 general.architecture = "llama" 之後,就只去讀 llama.* 開頭的 metadata key——每種架構定義了自己需要哪些參數,不需要的就不讀。這讓同一個 GGUF 規範可以容納數十種截然不同的模型架構,而不會互相打架。
跟我們的程式碼有什麼關係?
在我們的 LlamaCppRunner::run() 裡,呼叫 LlamaModel::load_from_file() 時,llama-cpp-2 在底層就做完了上面這整套流程——讀 GGUF header、抓 general.architecture、選擇對應的計算圖、載入 tensor 權重。我們的 Rust 程式碼完全不需要知道「這是什麼架構」,因為 GGUF 檔案自帶了身分聲明,llama.cpp 自己會分派。
這也是為什麼同一個 llm-local-studio run 指令,可以對任何 GGUF 模型一體適用——不管它是 LLaMA、Mistral 還是 Qwen2,只要 llama.cpp 支援那個架構,就能跑。
實際使用示範
把整個流程串起來,從搜尋到推理:
# 1. 搜尋 Hugging Face 上的 GGUF 模型
llm-local-studio hf-search "tinyllama gguf"
# 2. 下載模型(自動選 Q4_K_M 量化)
llm-local-studio hf-download TheBloke/TinyLlama-1.1B-GGUF
# 3. 掃描本地已有的模型
llm-local-studio scan models
# 4. 跑推理!
llm-local-studio run tinyllama -p "Tell me a joke about Rust"
或者一氣呵成:
llm-local-studio run TheBloke/TinyLlama-1.1B-GGUF \
-p "Explain ownership in Rust" \
--ctx-size 2048 \
-n 256
測試策略
跟上一篇 GGUF 解析器一樣,Hugging Face 的整合測試也面臨「不能在 CI 裡真的去打 API」的問題。所以我們把測試分成兩類:
純邏輯單元測試(不需要網路):
#[test]
fn parses_hugging_face_model_ref() {
let model: HuggingFaceModelRef = "TheBloke/TinyLlama-1.1B-GGUF".parse().unwrap();
assert_eq!(model.owner, "TheBloke");
assert_eq!(model.repo, "TinyLlama-1.1B-GGUF");
}
#[test]
fn chooses_preferred_quant_when_filename_is_not_requested() {
let files = vec![
HuggingFaceFile { rfilename: "model.Q8_0.gguf".into(), size: None },
HuggingFaceFile { rfilename: "model.Q4_K_M.gguf".into(), size: None },
];
assert_eq!(
client.choose_gguf_file(&files, None).unwrap().rfilename,
"model.Q4_K_M.gguf"
);
}
#[test]
fn builds_local_hugging_face_model_path() {
let model: HuggingFaceModelRef = "owner/repo".parse().unwrap();
let path = local_model_path(Path::new("models"), &model, "nested/model.gguf").unwrap();
// models/huggingface/owner/repo/nested/model.gguf
}
這些測試驗證了 URL 建構、模型名稱解析、量化檔案選擇、本地路徑計算——全是確定性的純函式,不依賴外部狀態。
結語
llm-local-studio 展示了用 Rust 打造本地 LLM 工具鏈的幾個核心觀念:
- 邊界分離:
InferenceEngine trait 讓 app 層不直接依賴 llama.cpp,替換引擎零改動。
- 規劃先行:
plan_download() 把所有決策提前做完,下載前可以先確認。
- 原子寫入:
.part 暫存檔 + rename() 確保不會出現半殘檔案。
- 路徑安全:
.. 檢查防止路徑穿越攻擊。
- 原生 FFI:透過
llama-cpp-2 直接在進程內跑推理,不走子進程 HTTP。
- Streaming 體驗:逐 token 輸出,跟 chat 服務一樣的使用者體驗。
- 模糊搜尋:模型名稱不用背完整路徑,給一部分就能找到。
整個專案四個原始檔、~400 行核心程式碼,就串起了「搜尋→下載→推理」的完整流程。雖然還沒有 chat template 支援、没有 OpenAI 相容 API、也没有漂亮的 GUI,但作為一個學習專案,它把最重要的骨架搭好了——後續的每一個 Phase 都是在這個穩固的基礎上往上蓋。
有興趣的朋友可以自己試著跑跑看,體驗一下「在自己的筆電上、用自己的程式碼、跑自己的語言模型」的樂趣!
參考資源