Simply Patrick

理解多模態 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 編寫系統級程式,串接方式都非常直覺簡單。歡迎將這些程式碼片段複製到你的專案中試試看!


用 Rust 打造本地 LLM 工作站 (二):Axum 串流與 OpenAI 相容 API 的實現

featured.svg

本文由 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 的推理完全是另一回事:

  1. CPU/GPU 密集型運算:生成一個 token 需要執行龐大的矩陣乘法,會直接佔滿 CPU 核心或 GPU。
  2. 非 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)。
  • 即使客戶端突然中斷連線,非同步端丟棄了 rxblocking_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 串流需要包含三個部分:

  1. 初始區塊:宣布 role: assistant,但不含任何內容。
  2. 內文區塊:連續發送包含 delta 內容的 token 片段。
  3. 結束區塊:提供結束原因(例如 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,並實作了 ChatMLTemplateLlama3Template 以及 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 的輕量化替代品,串接到你常用的開發工具或瀏覽器外掛中!

下一個階段,我們將會探索:

  1. 多模型熱插拔 API:允許客戶端在運行時透過 API 請求動態載入/卸載不同模型。
  2. 進階採樣參數配置:在 API 中實作 Temperature、Top-P、Top-K 及 Repetition Penalty 等精細化推理配置。
  3. 更美觀的 Web 或 Desktop 管理介面

如果你對此專案有興趣,歡迎參考完整的原始碼並一起交流!


用 Rust 打造本地 LLM 工作站:llama.cpp 原生整合與 Hugging Face 模型管理

featured.svg

本文由 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

一個 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 工具鏈的幾個核心觀念:

  • 邊界分離InferenceEngine trait 讓 app 層不直接依賴 llama.cpp,替換引擎零改動。
  • 規劃先行plan_download() 把所有決策提前做完,下載前可以先確認。
  • 原子寫入.part 暫存檔 + rename() 確保不會出現半殘檔案。
  • 路徑安全.. 檢查防止路徑穿越攻擊。
  • 原生 FFI:透過 llama-cpp-2 直接在進程內跑推理,不走子進程 HTTP。
  • Streaming 體驗:逐 token 輸出,跟 chat 服務一樣的使用者體驗。
  • 模糊搜尋:模型名稱不用背完整路徑,給一部分就能找到。

整個專案四個原始檔、~400 行核心程式碼,就串起了「搜尋→下載→推理」的完整流程。雖然還沒有 chat template 支援、没有 OpenAI 相容 API、也没有漂亮的 GUI,但作為一個學習專案,它把最重要的骨架搭好了——後續的每一個 Phase 都是在這個穩固的基礎上往上蓋。

有興趣的朋友可以自己試著跑跑看,體驗一下「在自己的筆電上、用自己的程式碼、跑自己的語言模型」的樂趣!

參考資源