快轉到主要內容
  1. 教學文章/

MLX-LM 批次推論實戰:Prompt Template、抽樣參數與本機評測流程

·9 分鐘· loading · loading · ·
Mlx MLX-LM LLM Batch Inference Apple-Silicon Local AI
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
科技觀點 - 本文屬於一個選集。
§ 22: 本文

一. 前言:聊天很酷,批次推論才是真的工作流
#

第一次把本地模型跑起來時,大家通常會先打開聊天介面。 這很合理,因為你想確認模型能不能回答問題、中文會不會怪、速度是不是能接受。 可是拍拍君要說一句有點掃興但很實用的話:真正讓本地 LLM 變成工具的,常常不是聊天,而是批次推論。 例如把 200 篇短筆記整理成標籤、幫一批 issue 產生摘要、比較不同 prompt template 的輸出穩定度,或對同一組測試問題跑 temperature 參數實驗。 聊天是即興對話;批次推論是可以版本控制、可以重跑、可以比較的實驗流程。 如果你還沒看過 MLX-LM 的基本用法,可以先看前一篇 MLX-LM 實戰:在 Apple Silicon 上跑本地模型推論。 那篇處理的是「怎麼跑起來」。 今天這篇處理的是「跑起來之後,怎麼把它變成穩定的小型生產線」。 我們會做一個小但完整的批次 runner:

  • 讀取 tasks.jsonl
  • 套用 prompt template
  • 呼叫 MLX-LM Python API
  • 保存每筆輸入、參數與輸出
  • 用簡單規則做本機評測
  • 比較不同抽樣參數的結果 沒有神祕魔法,只有一堆小心翼翼的工程習慣。 但這些習慣會讓你的本地 LLM 工具從「好像能用」變成「我敢明天再跑一次」。

二. 安裝與專案結構
#

這篇假設你在 Apple Silicon Mac 上操作。 先建立一個乾淨專案:

mkdir mlx-batch-lab
cd mlx-batch-lab
uv init
uv add mlx-lm rich pydantic

如果你不用 uv,也可以用一般虛擬環境:

python3 -m venv .venv
source .venv/bin/activate
pip install mlx-lm rich pydantic

建議的檔案結構如下:

mlx-batch-lab/
  batch_infer.py
  tasks.jsonl
  prompts/
    classify_note.txt
  outputs/
    .gitkeep

拍拍君建議一開始就把三件事分開:

  • tasks.jsonl 是資料
  • prompts/*.txt 是提示詞模板
  • outputs/*.jsonl 是每次執行留下的結果 這樣做有點囉嗦。 可是等你開始比較 prompt、模型和抽樣參數時,你會感謝現在的自己。

三. 準備一個 JSONL 測試集
#

批次推論最重要的第一步,不是模型,是資料格式。 我們先用 JSONL。 一行一筆資料,很容易 append,也很容易用 shell 工具檢查。 建立 tasks.jsonl

{"id":"note-001","text":"今天把 FastAPI 的 health check 補好了,部署前還要確認 timeout 設定。","expected_label":"devops"}
{"id":"note-002","text":"讀了一篇介紹 LoRA fine-tuning 的文章,感覺資料清理比訓練本身更麻煩。","expected_label":"ml"}
{"id":"note-003","text":"Streamlit 的 session_state 如果亂放 mutable object,rerun 之後很難除錯。","expected_label":"python"}
{"id":"note-004","text":"整理旅行清單時發現護照快過期了,週末要去拍證件照。","expected_label":"life"}

今天的任務很簡單:請模型把每段文字分類成 pythonmldevopslife。 這不是為了追求最強分類器;傳統模型或規則可能更快。 我們要練的是批次推論的骨架:資料進來、prompt 產生、模型輸出、結果被保存、最後能評測。 資料裡放 expected_label 是為了本地測試。 正式處理未標註資料時,當然可以拿掉。

四. 把 Prompt Template 放進檔案
#

建立 prompts/classify_note.txt

你是一個謹慎的文字分類器。

請閱讀下面的筆記,並只輸出一個 JSON 物件。

可用 label:
- python
- ml
- devops
- life

輸出格式:
{"label":"<label>","reason":"<不超過 20 字的原因>"}

筆記:
{{ text }}

請注意這裡故意要求「只輸出 JSON 物件」。 不是因為模型一定會乖,而是因為你要先把期望講清楚,後面才知道怎麼檢查它有沒有亂跑。 很多批次推論失敗,不是模型不行,是 prompt 太像聊天,沒有明確輸出契約。 拍拍君的習慣是:

  • 模板放檔案,不寫死在 Python 字串裡
  • 輸出格式寫清楚
  • label 或 enum 寫成短清單
  • 要求短原因,方便人工抽查
  • 不要在同一個 prompt 裡塞太多任務 先做窄,才做穩。

五. 讀取資料與套用模板
#

先寫最小版本的資料讀取與模板替換。 建立 batch_infer.py

from __future__ import annotations

import json
from pathlib import Path
from typing import Any


def read_jsonl(path: Path) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    with path.open("r", encoding="utf-8") as f:
        for line_no, line in enumerate(f, start=1):
            line = line.strip()
            if not line:
                continue
            try:
                rows.append(json.loads(line))
            except json.JSONDecodeError as exc:
                raise ValueError(f"{path}:{line_no} is not valid JSON") from exc
    return rows


def render_template(template: str, row: dict[str, Any]) -> str:
    prompt = template
    for key, value in row.items():
        prompt = prompt.replace("{{ " + key + " }}", str(value))
    return prompt


def main() -> None:
    tasks = read_jsonl(Path("tasks.jsonl"))
    template = Path("prompts/classify_note.txt").read_text(encoding="utf-8")
    print(render_template(template, tasks[0]))


if __name__ == "__main__":
    main()

先跑:

uv run python batch_infer.py

在呼叫模型之前,先確認 prompt 長什麼樣子。 這一步很無聊,但少做一次,你就會把時間浪費在「模型怎麼亂答」上。

六. 用 MLX-LM 做單筆推論
#

現在接上 MLX-LM。 先把 batch_infer.py 補上 import:

from mlx_lm import generate, load

再加入一個生成函式:

def run_one(
    model: Any,
    tokenizer: Any,
    prompt: str,
    *,
    max_tokens: int,
    temperature: float,
    top_p: float,
) -> str:
    messages = [{"role": "user", "content": prompt}]
    chat_prompt = tokenizer.apply_chat_template(
        messages,
        tokenize=False,
        add_generation_prompt=True,
    )
    return generate(
        model,
        tokenizer,
        prompt=chat_prompt,
        max_tokens=max_tokens,
        temp=temperature,
        top_p=top_p,
        verbose=False,
    )

不同模型的 chat template 可能不一樣。 不要自己手刻特殊 token;用 tokenizer 提供的 apply_chat_template,通常比較穩。 接著修改 main() 先跑單筆:

def main() -> None:
    tasks = read_jsonl(Path("tasks.jsonl"))
    template = Path("prompts/classify_note.txt").read_text(encoding="utf-8")
    model_id = "mlx-community/Qwen2.5-1.5B-Instruct-4bit"
    model, tokenizer = load(model_id)
    prompt = render_template(template, tasks[0])
    text = run_one(model, tokenizer, prompt, max_tokens=128, temperature=0.2, top_p=0.9)
    print(text)

模型名稱可以換。 重點是先用小模型把流程跑通。 流程穩了,再換更大的模型。

七. 批次跑完整資料集
#

現在把單筆改成批次。 我們希望每一筆輸出都包含原始 id、模型名稱、抽樣參數、原始輸出、解析後 JSON,以及是否命中預期 label。 先寫一個寬容一點的 JSON 解析器:

def parse_json_object(text: str) -> dict[str, Any] | None:
    start = text.find("{")
    end = text.rfind("}")
    if start == -1 or end == -1 or end <= start:
        return None
    try:
        value = json.loads(text[start : end + 1])
    except json.JSONDecodeError:
        return None
    return value if isinstance(value, dict) else None


def append_jsonl(path: Path, row: dict[str, Any]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("a", encoding="utf-8") as f:
        f.write(json.dumps(row, ensure_ascii=False) + "\n")

模型有時會多講一句話。 我們先把第一個 JSON object 抽出來,後面再記錄是否解析成功。 主流程可以長這樣:

def main() -> None:
    tasks = read_jsonl(Path("tasks.jsonl"))
    template_path = Path("prompts/classify_note.txt")
    template = template_path.read_text(encoding="utf-8")
    model_id = "mlx-community/Qwen2.5-1.5B-Instruct-4bit"
    params = {"max_tokens": 128, "temperature": 0.2, "top_p": 0.9}
    output_path = Path("outputs/classify_note-qwen25-1b5-temp02.jsonl")
    model, tokenizer = load(model_id)

    for row in tasks:
        prompt = render_template(template, row)
        raw = run_one(model, tokenizer, prompt, **params)
        parsed = parse_json_object(raw)
        predicted = parsed.get("label") if parsed else None
        expected = row.get("expected_label")
        append_jsonl(output_path, {
            "id": row["id"],
            "model": model_id,
            "template": str(template_path),
            "params": params,
            "expected_label": expected,
            "predicted_label": predicted,
            "ok": predicted == expected,
            "raw_output": raw,
            "parsed_output": parsed,
        })

跑完之後,你會得到一個 JSONL 結果檔。 這個檔案才是批次推論最重要的產物。 不是終端機上閃過的回答,而是可以留著比較的輸出紀錄。

八. 用 Rich 顯示進度與統計
#

批次任務如果沒有進度,很容易讓人焦慮。 我們加一點 Rich:

from rich.progress import track

correct = 0
for row in track(tasks, description="Running MLX-LM batch inference"):
    raw = run_one(model, tokenizer, render_template(template, row), **params)
    parsed = parse_json_object(raw)
    ok = parsed and parsed.get("label") == row.get("expected_label")
    correct += int(bool(ok))
    append_jsonl(output_path, {"id": row["id"], "ok": bool(ok), "raw_output": raw})

print(f"accuracy = {correct / len(tasks):.1%}")

這不是正式 benchmark。 但對日常工具來說很夠用:你可以快速看到模型有沒有照格式輸出、換 prompt 後有沒有變好,以及 temperature 調高是否開始亂飄。

九. 比較不同抽樣參數
#

LLM 的輸出不是只有 prompt 影響。 抽樣參數也很重要。

  • temperature:越高越發散,越低越保守
  • top_p:限制候選 token 的累積機率範圍
  • max_tokens:限制最多生成多少 token 分類、抽取、格式化這類任務,通常不要太高溫。 可以從這組開始:
PARAM_GRID = [
    {"max_tokens": 128, "temperature": 0.0, "top_p": 0.9},
    {"max_tokens": 128, "temperature": 0.2, "top_p": 0.9},
    {"max_tokens": 128, "temperature": 0.7, "top_p": 0.95},
]

for params in PARAM_GRID:
    suffix = f"temp{params['temperature']}-top{params['top_p']}"
    output_path = Path(f"outputs/classify_note-{suffix}.jsonl")
    # 內層沿用前面的批次 loop,只改 params 和 output_path。

拍拍君建議不要一次測太多組。 你不是在煉金;你是在找一組夠穩、夠快、容易解釋的設定。 對分類和資料整理,先試 temperature=0.00.2top_p=0.9 通常夠了,max_tokens 不要開太大。

十. 把評測獨立成一個小函式
#

如果評測邏輯散在主流程裡,很快會變成一坨。 把它抽出來:

VALID_LABELS = {"python", "ml", "devops", "life"}


def evaluate(row: dict[str, Any], parsed: dict[str, Any] | None) -> dict[str, Any]:
    if not parsed:
        return {"predicted_label": None, "parse_ok": False, "label_ok": False, "ok": False}

    predicted = parsed.get("label")
    expected = row.get("expected_label")
    label_ok = predicted in VALID_LABELS
    match_ok = predicted == expected
    return {
        "predicted_label": predicted,
        "parse_ok": True,
        "label_ok": label_ok,
        "ok": label_ok and match_ok,
    }

為什麼要拆 parse_oklabel_ok? 因為這兩種錯誤的修法不同。 如果 parse_ok=False,你要改善輸出格式或 JSON 修復流程。 如果 label_ok=False,你要把 label 清單寫得更明確。 如果格式都對但分類錯,才是真的模型或 prompt 判斷問題。 不要把所有錯誤都塞進同一個 failed。 那樣很難除錯。

十一. 批次推論常見坑
#

第一個坑是每次結果覆蓋掉。 輸出檔名最好包含模型和重要條件,例如:

outputs/classify_note-qwen25-1b5-temp02-top09-20260610.jsonl

如果你一直寫到 output.jsonl,過幾天一定忘記那是哪個模型跑的。 拍拍君不相信人類的記憶。 也不太相信自己的。 第二個坑是 prompt 版本沒有保存。 結果檔裡只存 template 路徑還不夠。 更穩的做法是把 prompt 內容也存一份,或至少存 template hash:

import hashlib


def sha256_text(text: str) -> str:
    return hashlib.sha256(text.encode("utf-8")).hexdigest()

第三個坑是模型輸出不是合法 JSON。 你可以用剛才的 parse_json_object() 救回來,也可以在 prompt 裡更明確地寫:

不要輸出 Markdown。
不要輸出解釋。
不要使用 ```json code fence。
只輸出一個 JSON object。

不過要記得,prompt 不是型別系統。 你還是要 parse、validate、記錄失敗。 第四個坑是太早相信 accuracy。 四筆測試資料跑出 100% 沒什麼意思,它只能代表流程沒炸。 如果你真的要比較 prompt,至少準備幾十筆不同類型的樣本,而且要放一些邊界案例。

十二. 什麼時候適合用 MLX-LM 批次跑?
#

MLX-LM 批次推論很適合這些情境:

  • 資料不想送到外部 API
  • 你想離線跑
  • 任務可以慢慢處理,不需要即時回覆
  • 你想快速比較 prompt 或模型
  • 輸入資料量中小型,Mac 可以承受 不太適合這些情境:
  • 需要穩定高併發 API
  • 延遲要求很低
  • 需要多人共享服務
  • 模型太大,單機跑到痛苦
  • 任務其實用規則或傳統模型更簡單 拍拍君最喜歡的用法是「本機資料整理實驗室」。 先在自己的 Mac 上用 MLX-LM 跑小批資料,確認 prompt、格式、評測方式都合理。 如果之後真的需要變成服務,再考慮 Ollama、vLLM、雲端 API 或專門的推論服務。 先把問題想清楚,不要一開始就架一座城堡。 收工前記得檢查:資料是合法 JSONL、每筆都有穩定 id、prompt template 有版本控制、輸出檔名包含模型和參數、parse 失敗會被記錄,而且不要把敏感輸出誤 commit。

結語:把一次性實驗變成可重跑流程
#

今天這篇沒有做炫砲聊天 UI,也沒有做超大型 agent 系統。 我們只是把 MLX-LM 放進一個很樸素的批次流程裡:JSONL 輸入、prompt template、MLX-LM 生成、JSONL 輸出、本機評測、參數比較。 但這套骨架非常實用。 因為很多 LLM 工作最後都會回到同一個問題: 我能不能重跑? 我能不能比較? 我能不能知道哪次改動讓結果變好或變壞? 如果答案是可以,模型才會慢慢變成工具。 如果答案是不知道,那它就只是一次很有趣的聊天。 拍拍君當然也喜歡聊天。 但寫工具時,還是讓輸出留下證據吧。

延伸閱讀
#

科技觀點 - 本文屬於一個選集。
§ 22: 本文

相關文章

MLX-LM 實戰:在 Apple Silicon 上跑本地模型推論
·9 分鐘· loading · loading
Mlx MLX-LM LLM Apple-Silicon Python Local AI
在 Mac/iPhone 生態跑本地 AI:Ollama、MLX 與行動端工作流
·9 分鐘· loading · loading
LLM Ollama Mlx Apple-Silicon IPhone Local AI
本地 AI App 架構:Streamlit、Ollama、MLX 怎麼分工
·10 分鐘· loading · loading
LLM Local AI Streamlit Ollama Mlx Python Architecture
MLX + Embeddings:在 Apple Silicon 上打造本地語意搜尋
·7 分鐘· loading · loading
Mlx Embeddings Semantic Search Apple-Silicon Python
Streamlit + Ollama:打造本地 LLM Chatbot App
·11 分鐘· loading · loading
LLM Ollama Streamlit Python Local AI Chatbot
Docker Compose + Ollama:一鍵啟動本地 AI 開發環境
·8 分鐘· loading · loading
Docker Docker-Compose Ollama LLM Local AI Devops