iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0
AI Engineering

從 MCP 到專屬 Agentic 模型:30 天走完一條可評測、可微調、可自架的 AI Agent 模型與服務製作流程系列 第 22

[ Training & Deployment ] Day 22 — 模型匯出、量化與 vLLM 私有化部署:讓微調模型變成一個能被呼叫的服務

  • 分享至 

  • xImage
  •  

Day 22 今日地圖:今天在整條閉環的位置、承接與產出

I. 前言:訓練完成,離「能用」還有三步

昨天處理完踩坑與二次訓練,手上有了一個 loss 收斂、快測結果合理的 LoRA Adapter。

但那個 Adapter 現在還只是一個幾十 MB 的資料夾。明天的三方對決需要的不是一個資料夾,而是一個能被 ADEval 透過 HTTP 呼叫、而且會正確回傳 tool call 的服務端點。

從前者到後者,中間有三個步驟:

  1. 合併 —— 把 Adapter 疊回基座模型,變成一個獨立的完整模型
  2. 量化(可選)—— 用精度換顯存與吞吐
  3. 服務化 —— 用 vLLM 起一個 OpenAI 相容 API,而且要能正確解析工具呼叫

這三步各自都有一個容易踩空的地方,而且它們的失敗方式都不是「跑不起來」,而是「跑起來了但工具呼叫壞掉」

以下的內容,會逐步走完這三個步驟,並在最後把服務接回 Google ADK,架好明天對決要用的拓撲。

II. 第一步:把 LoRA 合併回基座

為什麼要合併

LoRA 的原理是在原本的權重旁邊加上一組低秩矩陣。推論時,PEFT 會在每一層額外做一次小矩陣運算 —— 這在訓練時無所謂,但在服務端有兩個實際問題:

  • 多一層 Python 層級的包裝,吞吐會受影響。
  • 多數量化工具與推論引擎預期的是完整權重,不吃 Adapter。

合併之後,模型就是一個普通的 AutoModelForCausalLM,什麼工具都能接。

import torch
from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer

BASE = "google/gemma-4-E4B"
ADAPTER = "out/leave-copilot-lora"
MERGED = "models/leave-copilot-merged"

base = AutoModelForCausalLM.from_pretrained(
    BASE,
    dtype=torch.bfloat16,      # 與訓練時一致
    device_map="cpu",          # 合併不需要 GPU,用 CPU 反而不會爆顯存
)

model = PeftModel.from_pretrained(base, ADAPTER)
model = model.merge_and_unload()          # 關鍵的一行

model.save_pretrained(MERGED, safe_serialization=True)

# tokenizer 一定要一起存
tok = AutoTokenizer.from_pretrained(BASE)
tok.save_pretrained(MERGED)

兩個容易踩空的地方

第一,dtype 必須與訓練時一致。

如果訓練時用 bfloat16,合併時卻用了 float16,數值會有微小的偏移。這種偏移不會讓模型壞掉 —— 它會讓模型變得有點不一樣,而您很難察覺。用同一個 dtype 是最省事的做法。

第二,tokenizer 一定要一起存。

merge_and_unload() 只處理模型權重,不碰 tokenizer。忘了存的話,vLLM 載入時會找不到 chat template,工具呼叫的格式會整個錯掉 —— 而且錯誤訊息通常指向別的地方,相當難查。

順帶一提:如果訓練時擴充過 tokenizer(例如加了特殊 token),那就必須存訓練時用的那一份,而不是從基座重新載入。這個系列沒有擴充,所以直接從 BASE 載入是安全的。

也可以選擇不合併

vLLM 支援直接掛載 LoRA Adapter:

vllm serve google/gemma-4-E4B \
  --enable-lora \
  --lora-modules leave-copilot=out/leave-copilot-lora \
  --port 8001

這在需要同時服務多個 Adapter 時很有用 —— 一個基座、多組 LoRA,共用同一份顯存。

但本系列選擇合併,理由是明天的對決需要一個與基座模型完全獨立的受測對象。掛 Adapter 的模式下,基座與微調模型共用同一個行程,萬一設定有誤,很難確定測到的到底是哪一個。

III. 合併之後先驗證,不要直接部署

合併完成後、起服務之前,先花兩分鐘用 transformers 直接跑一次。這一步能攔下大部分「服務起來了但輸出是亂的」的情況:

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

tok = AutoTokenizer.from_pretrained(MERGED)
model = AutoModelForCausalLM.from_pretrained(
    MERGED, dtype=torch.bfloat16, device_map="auto"
)

messages = [
    {"role": "system", "content": SYSTEM_PROMPT_C},   # Day 19 選定的配置 C
    {"role": "user", "content": "把我那張家庭旅遊的特休送出審核"},
]

text = tok.apply_chat_template(
    messages,
    tools=TOOLS_SCHEMA,          # Day 15 從 MCP 匯出的那份
    add_generation_prompt=True,
    tokenize=False,
)
print(text)                      # 先看渲染結果對不對

inputs = tok(text, return_tensors="pt").to(model.device)
out = model.generate(**inputs, max_new_tokens=256, do_sample=False)
print(tok.decode(out[0][inputs["input_ids"].shape[1]:]))

要看的是兩件事:

  1. apply_chat_template 的渲染結果,工具區塊有沒有正常出現。
  2. 模型的輸出裡,有沒有出現正確格式的 tool call,而且第一個叫的是 search_leaves

如果這裡就不對,那問題出在合併或訓練,跟部署無關 —— 先在這裡修好,不要帶著問題往下走。

IV. 第二步:要不要量化

量化是用精度換取顯存與吞吐。但在 Agentic 場景下,這個交換比一般對話場景更需要謹慎。

三條路線

表格:方案、產出、適合的推論引擎、特點

以 AWQ 為例:

from awq import AutoAWQForCausalLM
from transformers import AutoTokenizer

model = AutoAWQForCausalLM.from_pretrained(MERGED)
tok = AutoTokenizer.from_pretrained(MERGED)

model.quantize(tok, quant_config={
    "zero_point": True,
    "q_group_size": 128,
    "w_bit": 4,
    "version": "GEMM",
})

model.save_quantized("models/leave-copilot-awq")
tok.save_pretrained("models/leave-copilot-awq")

走 GGUF 的話則是 llama.cpp 的兩步流程:

python llama.cpp/convert_hf_to_gguf.py models/leave-copilot-merged \
  --outfile models/leave-copilot-f16.gguf --outtype f16

./llama.cpp/build/bin/llama-quantize \
  models/leave-copilot-f16.gguf models/leave-copilot-q4_k_m.gguf Q4_K_M

一個必須說清楚的警告

工具呼叫對量化的敏感度,比一般對話高。

原因不難理解。一般對話的評判標準是「語意通順、內容合理」,即使模型換了一個近義詞,讀起來也沒問題。但工具呼叫的評判標準是精確匹配

  • 參數名稱錯一個字元 → 失敗
  • 日期格式少一個 Z → 失敗
  • JSON 少一個引號 → 解析失敗
  • 該叫 search_leaves 卻叫了 get_leave → 失敗

這些正是我們花了 20 天訓練模型學會的細節,而量化恰好可能磨掉它們。

所以本系列的做法是:量化之後,必須重跑一次 Day 13 的評測。

adeval run <EXP_ID> --verbose
adeval stats <EXP_ID> --mcp http://127.0.0.1:8090/mcp --json > quantized.json

拿它跟未量化版本的數字並排比較。如果四個難點的通過率掉了,那就是量化吃掉了微調的成果 —— 這種情況下,寧可多租一點顯存,也不要量化。

E4B 這種規模的模型在 24GB 卡上用 bfloat16 相當寬裕,所以本系列的預設是不量化。量化這一節留給需要在更小的機器上部署的情境。

V. 第三步:用 vLLM 起服務

最小可用的指令

vllm serve models/leave-copilot-merged \
  --served-model-name leave-copilot \
  --port 8001 \
  --max-model-len 8192 \
  --enable-auto-tool-choice \
  --tool-call-parser gemma4

逐項說明為什麼是這些參數:

表格:參數、為什麼需要

這兩個參數才是重點

--enable-auto-tool-choice--tool-call-parser 是本節的核心。 少了它們,服務會正常啟動、也會正常回答問題 —— 但工具呼叫會以純文字的形式出現在 content 裡,而不是結構化的 tool_calls 欄位。

結果就是:ADEval 解析不到任何工具呼叫,明天的分數會全部是零。而且錯誤訊息不會告訴您原因。

parser 的名稱必須與模型家族對得上。vLLM 0.27.1 內建的 parser 相當多,常用的幾個是:

表格:Parser、適用

選錯 parser 的症狀很好認tool_calls 永遠是空的,而 content 裡塞著一段看起來像 tool call 的文字。遇到這個症狀,先檢查 parser。

驗證服務真的能呼叫工具

起服務之後,一定要用一個帶 tools 的請求驗證,而不只是 curl /v1/models

curl -s http://localhost:8001/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "leave-copilot",
    "temperature": 0,
    "messages": [
      {"role": "user", "content": "把我那張家庭旅遊的特休送出審核"}
    ],
    "tools": [{
      "type": "function",
      "function": {
        "name": "search_leaves",
        "description": "搜尋假單",
        "parameters": {
          "type": "object",
          "properties": {"keyword": {"type": "string"}}
        }
      }
    }]
  }' | python3 -m json.tool

要確認回傳的 JSON 裡有 tool_calls 欄位,而且 function.namesearch_leaves

看到這個,就代表這一整條路是通的。看不到,就回頭檢查上面那兩個參數。

VI. 接回 Google ADK

服務起來之後,還要讓 Google ADK 能用它。這靠 LiteLlm

from google.adk.agents import Agent
from google.adk.models import LiteLlm
from google.adk.tools.mcp_tool import McpToolset, StreamableHTTPConnectionParams

tuned_model = LiteLlm(
    model="openai/leave-copilot",              # openai/ 前綴 + served-model-name
    api_base="http://localhost:8001/v1",
    api_key="EMPTY",                          # vLLM 預設不驗證,但欄位不能空
)

root_agent = Agent(
    name="leave_copilot_tuned",
    model=tuned_model,
    instruction=SYSTEM_PROMPT_C,              # 必須與訓練時完全一致
    tools=[McpToolset(
        connection_params=StreamableHTTPConnectionParams(
            url="http://127.0.0.1:8090/mcp",
        ),
    )],
)

三個細節

第一,openai/ 前綴。 LiteLLM 靠前綴決定用哪一種協定。vLLM 提供的是 OpenAI 相容 API,所以前綴是 openai/,後面接 --served-model-name 設定的名稱。

第二,instruction 必須與訓練時一致。 Day 19 特地強調過這件事,這裡是它兌現的地方 —— 用 import 引用同一個常數,不要複製貼上。

第三,如果改用 Ollama,前綴必須是 ollama_chat/

LiteLlm(model="ollama_chat/leave-copilot")     # ✓
LiteLlm(model="ollama/leave-copilot")          # ✗

Google ADK 文件對此有明確警告:用 ollama/ 前綴會導致無限工具呼叫迴圈與忽略上下文。這個坑很值錢,因為症狀看起來像是模型壞了,而實際上只是前綴寫錯。

VII. 明天要用的拓撲

三方對比所需的部署拓撲:四個 agent 共用一個 MCP Server

明天的對決要在完全相同的條件下比較多個模型,所以拓撲必須先架好:

表格:服務、Port、內容

agents/ 底下的四個 app 就是四個受測對象:

agents/
├── leave_copilot_base/       # 基座模型 + 配置 C(微調前)
├── leave_copilot_fewshot/    # 基座模型 + 完整 few-shot(Prompt 的極限)
├── leave_copilot_tuned/      # 微調模型 + 配置 C
└── leave_copilot_gemini/     # 商業 API 對照組

注意 MCP Server 只有一個。 這是刻意的 —— 四個 agent 面對的是完全相同的工具定義與資料狀態,唯一的變數只有模型本身。

但共用一個 Server 也帶來一個必須處理的問題:update_leave_status 會真的改變資料。 第一個模型跑完之後,假單狀態已經不是初始值了。所以每個模型開跑前都必須呼叫 Day 3 做的 reset()。明天會再強調一次。

啟動順序:

python -m leave_mcp.server &                                    # 8090
vllm serve models/leave-copilot-merged --served-model-name leave-copilot --port 8001 \
  --enable-auto-tool-choice --tool-call-parser gemma4 &       # 8001
adk api_server agents/ --port 8000 &                          # 8000

curl -s http://localhost:8000/list-apps          # 應該列出四個 app
curl -s http://localhost:8001/v1/models          # 應該看到 leave-copilot

VIII. 結語

部署這一段的技術難度不高,但它有一個特徵值得警覺:這裡的失敗多數是靜默的。 服務起來了、也會回答問題,但 tool_calls 是空的 —— 而那正是整個系列要驗收的東西。

總結來說,今天有三個重點值得帶走:

  • 合併時 dtype 要一致,tokenizer 要一起存: merge_and_unload() 只處理權重,不碰 tokenizer。少了它,vLLM 找不到 chat template,工具呼叫的格式會整個錯掉,而錯誤訊息通常指向別的地方。
  • --enable-auto-tool-choice--tool-call-parser 少一個都不行: 少了它們,服務照樣啟動、照樣回答,但工具呼叫會變成純文字塞在 content 裡,ADEval 一個都解析不到。parser 名稱要與模型家族對得上,本系列的 Gemma 4 對應 gemma4
  • 量化對工具呼叫的傷害,比對一般對話大: 參數名、日期格式、JSON 結構都是精確匹配,而那正是我們花了二十天教會模型的東西。若要量化,務必用 Day 13 的評測重跑一次,拿量化前後的數字並排比較。

明天是整個系列的高潮:雙評測三方對決。用 Day 13 凍結的那把尺量專用能力、用 Day 14 的通用能力基準線守住底線,在完全相同的條件下驗收這二十多天的成果 —— 包含那些退步的項目。

Day 22 Cheat Sheet:指令、參數與容易踩的地方


參考來源

  • PEFT・LoRA merge——merge_and_unload() 用法與注意事項(peft 0.20.0)
  • vLLM・OpenAI-Compatible Server——vllm serve 參數
  • vLLM・Tool Calling——--enable-auto-tool-choice--tool-call-parser
  • vllm/tool_parsers/__init__.py(v0.27.1)——內建 parser 名稱清單,含 gemma4qwen3_xmlhermes
  • AutoAWQ——AWQ 量化流程
  • llama.cpp——convert_hf_to_gguf.pyllama-quantize
  • Google ADK・Ollama——ollama_chat/ 前綴的必要性與 ollama/ 的已知問題
  • google/adk/models/lite_llm.py(google-adk 2.7.1)——LiteLlm(model, **kwargs) 將額外參數傳給 litellm

查證日期:2026-08-24


I am Simon

大家好,我是 Simon 劉育維,是一位 AI 領域解決方案專家,目前也擔任 Google Cloud AI 領域開發者專家 (GDE),期待能夠幫助企業導入人工智慧相關技術解決問題。如果這篇文章對您有幫助,歡迎在我的 Linkedin 上留言提供意見,並與我一起討論有關人工智慧的主題,期待能夠對大家有所幫助!

我的個人部落格資訊:https://medium.com/@simon3458


上一篇
[ Training & Deployment ] Day 21 — 微調踩坑實錄與二次訓練決策樹:失敗才是最好的老師
下一篇
[ Benchmark & Evaluation ] Day 23 — 閉環驗收:ADEval + Twinkle Eval 雙評測三方對決
系列文
從 MCP 到專屬 Agentic 模型:30 天走完一條可評測、可微調、可自架的 AI Agent 模型與服務製作流程30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言