一. 前言:聊天很酷,批次推論才是真的工作流 #
第一次把本地模型跑起來時,大家通常會先打開聊天介面。 這很合理,因為你想確認模型能不能回答問題、中文會不會怪、速度是不是能接受。 可是拍拍君要說一句有點掃興但很實用的話:真正讓本地 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"}
今天的任務很簡單:請模型把每段文字分類成 python、ml、devops 或 life。
這不是為了追求最強分類器;傳統模型或規則可能更快。
我們要練的是批次推論的骨架:資料進來、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.0 或 0.2,top_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_ok 和 label_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 工作最後都會回到同一個問題: 我能不能重跑? 我能不能比較? 我能不能知道哪次改動讓結果變好或變壞? 如果答案是可以,模型才會慢慢變成工具。 如果答案是不知道,那它就只是一次很有趣的聊天。 拍拍君當然也喜歡聊天。 但寫工具時,還是讓輸出留下證據吧。