本文由 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 分成四層,每層有明確的職責:
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 工具來說,默默吞錯誤實在是最要不得的——明明沒權限卻跟你說「檔案不存在」,這種誤導比直接報錯還難 debug。
模型 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 就抓」這麼簡單,我刻意讓它走過規劃→確認→執行三個步驟:
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.ggufllama.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 介面,省了我們自己跟 FFI 搏鬥的功夫。
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 的話,每吐一個新字就要重算 1000 次。這不只是慢,根本是白工——因為前面那些 token 的 K 和 V 值壓根沒變啊!
KV cache 的想法其實很直覺:算過的就先存起來,下次直接拿來用。
對整個 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。
一個 metadata key 決定一切
上一篇 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 這個值,把你分派到對應的程式碼路徑。
Metadata key 的命名慣例
還記得上一篇 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 工具鏈的幾個核心觀念:
- 邊界分離:
InferenceEnginetrait 讓 app 層不直接依賴 llama.cpp,替換引擎零改動。 - 規劃先行:
plan_download()把所有決策提前做完,下載前可以先確認。 - 原子寫入:
.part暫存檔 +rename()確保不會出現半殘檔案。 - 路徑安全:
..檢查防止路徑穿越攻擊。 - 原生 FFI:透過
llama-cpp-2直接在進程內跑推理,不走子進程 HTTP。 - Streaming 體驗:逐 token 輸出,跟 chat 服務一樣的使用者體驗。
- 模糊搜尋:模型名稱不用背完整路徑,給一部分就能找到。
整個專案就四個原始檔、大概 400 行核心程式碼,竟然就把「搜尋→下載→推理」整條流程串起來了。當然,它還沒有 chat template 支援、沒有 OpenAI 相容 API、也沒有漂亮的 GUI,不過作為一個學習專案,最重要的骨架算是搭穩了——後續每個 Phase 都是在這個地基上往上疊。
有興趣的朋友不妨自己抓下來跑跑看,親手體驗一下「在自己的筆電上、用自己寫的程式碼、跑自己的語言模型」那種莫名的成就感吧 :-)
參考資源
- llm-local-studio 專案原始碼
- llama-cpp-2 crate — llama.cpp 的 Rust 封裝
- Hugging Face API 文件 — 模型搜尋與下載
- GGUF 解析器文章 — 前一篇:手寫 GGUF 二進位解析器
- clap crate — CLI 參數解析