上一篇看完 MacBook Pro M3 Max 的硬體規格之後,今天終於要實際把模型丟進去跑了。
這次我想回答的問題很單純:
之後在這台 MacBook Pro 上做 Local AI Agent,我到底該用 llama.cpp,還是 MLX?
Mac 上常見的 Local LLM 方案主要有:
Ollama 本身就是建立在 llama.cpp 生態之上的封裝,所以這次我不另外拿它來做底層效能比較,而是直接測兩條不同的技術路線:
這篇也先不碰 Agent、RAG、Tool Calling,只測最基本的:
Prompt → LLM → Response
先把純推論效能搞清楚,再往後面的 Agent 實驗疊上去。
我主要看三個數字:
其中我最在意的是前兩個。
因為之後做 Agent,一次任務可能會呼叫模型很多次,TTFT 會一直累積;Generation Speed 則直接決定實際互動起來順不順。
這次使用:
推論方案:
我另外包了一個簡單的 FastAPI,讓兩套引擎都走同一個介面,再統一量 TTFT、生成速度與記憶體。
llama.cpp 這邊使用 llama-cpp-python:
CMAKE_ARGS="-DGGML_METAL=on" pip install llama-cpp-python
MLX:
pip install mlx mlx-lm fastapi "uvicorn[standard]" requests
這次測四顆模型:
hf download unsloth/Qwen3.8-27B-GGUF \
Qwen3.8-27B-UD-Q4_K_M.gguf --local-dir ./models
hf download unsloth/gemma-4-12b-it-GGUF \
gemma-4-12b-it-Q4_K_M.gguf --local-dir ./models
hf download ggml-org/gpt-oss-20b-GGUF \
gpt-oss-20b-MXFP4.gguf --local-dir ./models
hf download bartowski/google_gemma-4-26B-A4B-it-GGUF \
google_gemma-4-26B-A4B-it-Q4_K_M.gguf --local-dir ./models
# engines.py —— 兩套推論引擎的統一介面
import gc
import time
import resource
from dataclasses import dataclass
@dataclass
class InferMetrics:
prompt_tokens: int = 0
completion_tokens: int = 0
ttft: float = 0.0
gen_tok_s: float = 0.0
total_time: float = 0.0
peak_mem_gb: float = 0.0
@dataclass
class InferResult:
text: str
metrics: InferMetrics
def _peak_mem_gb() -> float:
# macOS 上 ru_maxrss 單位是 bytes,而且是「整個 process 從啟動到現在」的歷史最高值,
# 不會隨著模型被換掉而下降——這點在下面的踩坑段落會再解釋。
return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / (1024 ** 3)
class LlamaCppEngine:
"""透過 llama-cpp-python 載入 GGUF 模型,n_gpu_layers=-1 代表全部 layer 都丟給 Metal。
快取只留「目前這一顆」模型:換模型之前先把舊的丟掉、觸發垃圾回收,
釋放 Metal 那邊配置的 GPU 記憶體,不然連續測好幾顆模型會把統一記憶體榨乾。
"""
def __init__(self):
self._cache = {}
def _evict(self):
self._cache.clear()
gc.collect()
def _get_model(self, model_path: str, n_ctx: int = 4096):
from llama_cpp import Llama
if model_path in self._cache:
return self._cache[model_path]
self._evict()
llm = Llama(model_path=model_path, n_gpu_layers=-1, n_ctx=n_ctx, verbose=False)
self._cache = {model_path: llm}
return llm
def infer(self, model_path: str, prompt: str, max_tokens: int = 2048) -> InferResult:
llm = self._get_model(model_path)
t0 = time.perf_counter()
ttft, n_tokens, chunks = None, 0, []
stream = llm.create_chat_completion(
messages=[{"role": "user", "content": prompt}],
max_tokens=max_tokens,
stream=True,
)
for chunk in stream:
delta = chunk["choices"][0]["delta"].get("content")
if delta:
if ttft is None:
ttft = time.perf_counter() - t0
n_tokens += 1
chunks.append(delta)
t_total = time.perf_counter() - t0
gen_tok_s = (n_tokens - 1) / (t_total - ttft) if n_tokens > 1 and ttft else 0.0
prompt_tokens = len(llm.tokenize(prompt.encode("utf-8")))
return InferResult(
text="".join(chunks),
metrics=InferMetrics(prompt_tokens, n_tokens, ttft or 0.0, gen_tok_s, t_total, _peak_mem_gb()),
)
class MLXEngine:
"""透過 mlx-lm 的 stream_generate 做推論。
快取邏輯跟 LlamaCppEngine 一樣只留目前這一顆;記憶體用量改用 MLX 自己的
峰值計數器(可以在每次推論前重置),比 process 層級的 ru_maxrss 更準。
"""
def __init__(self):
self._cache = {}
def _evict(self):
self._cache.clear()
gc.collect()
try:
import mlx.core as mx
mx.clear_cache() # 把 MLX 內部保留、還沒真的還給系統的記憶體池清掉
except Exception:
pass
def _get_model(self, model_id: str):
from mlx_lm import load
if model_id in self._cache:
return self._cache[model_id]
self._evict()
entry = load(model_id)
self._cache = {model_id: entry}
return entry
def _mlx_peak_mem_gb(self) -> float:
import mlx.core as mx
# 不同版本的 mlx-lm,這組 API 有時候在 mx 底下、有時候在 mx.metal 底下,兩個都試一次。
for get_peak, reset_peak in (
(getattr(mx, "get_peak_memory", None), getattr(mx, "reset_peak_memory", None)),
(getattr(getattr(mx, "metal", None), "get_peak_memory", None),
getattr(getattr(mx, "metal", None), "reset_peak_memory", None)),
):
if get_peak is not None:
peak_bytes = get_peak()
if reset_peak is not None:
reset_peak()
return peak_bytes / (1024 ** 3)
return _peak_mem_gb() # 都拿不到就退回 process 層級的數字
def infer(self, model_id: str, prompt: str, max_tokens: int = 2048) -> InferResult:
from mlx_lm.generate import stream_generate
model, tokenizer = self._get_model(model_id)
formatted = tokenizer.apply_chat_template(
[{"role": "user", "content": prompt}], add_generation_prompt=True
)
prompt_tokens = len(tokenizer.encode(prompt))
t0 = time.perf_counter()
ttft, n_tokens, chunks = None, 0, []
for resp in stream_generate(model, tokenizer, formatted, max_tokens=max_tokens):
if ttft is None:
ttft = time.perf_counter() - t0
n_tokens += 1
chunks.append(resp.text)
t_total = time.perf_counter() - t0
gen_tok_s = (n_tokens - 1) / (t_total - ttft) if n_tokens > 1 and ttft else 0.0
return InferResult(
text="".join(chunks),
metrics=InferMetrics(prompt_tokens, n_tokens, ttft or 0.0, gen_tok_s, t_total, self._mlx_peak_mem_gb()),
)
ENGINES = {"llama.cpp": LlamaCppEngine(), "mlx": MLXEngine()}
FastAPI:
# app.py —— 服務本體:POST /v1/infer,回傳生成內容 + 這次推論的指標
from fastapi import FastAPI
from pydantic import BaseModel
from engines import ENGINES
app = FastAPI(title="Local Inference Bench Service")
class InferRequest(BaseModel):
engine: str # "llama.cpp" 或 "mlx"
model: str # llama.cpp: 本機 GGUF 路徑;mlx: HuggingFace repo id
prompt: str
max_tokens: int = 2048
@app.post("/v1/infer")
def infer(req: InferRequest):
engine = ENGINES.get(req.engine)
if engine is None:
return {"error": f"未知的 engine:{req.engine},只支援 llama.cpp / mlx"}
result = engine.infer(req.model, req.prompt, req.max_tokens)
m = result.metrics
return {
"engine": req.engine,
"model": req.model,
"text": result.text,
"metrics": {
"prompt_tokens": m.prompt_tokens,
"completion_tokens": m.completion_tokens,
"ttft_s": round(m.ttft, 3),
"gen_tok_s": round(m.gen_tok_s, 2),
"total_time_s": round(m.total_time, 3),
"peak_mem_gb": round(m.peak_mem_gb, 2),
},
}
啟動:
uvicorn app:app --host 127.0.0.1 --port 8000
每組不是只跑一次,而是:
測試 Prompt 固定:
請用三句話說明什麼是統一記憶體架構
完整測試腳本:
# run_bench.py —— 依序呼叫服務,每組跑熱身 + 多次正式量測,取平均與標準差
import csv
import statistics
import requests
SERVICE_URL = "http://127.0.0.1:8000/v1/infer"
PROMPT = "請用三句話說明什麼是統一記憶體架構"
MAX_TOKENS = 2048
WARMUP_RUNS = 1 # 熱身次數,結果不計入
REPEAT_RUNS = 10 # 正式量測次數,取平均與標準差
# (顯示名稱, engine, model 路徑或 repo id)
JOBS = [
("Qwen3.8-27B", "llama.cpp", "./models/Qwen3.8-27B-UD-Q4_K_M.gguf"),
("Qwen3.8-27B", "mlx", "mlx-community/Qwen3.8-27B-4bit"),
("Gemma-4-12B-it", "llama.cpp", "./models/gemma-4-12b-it-Q4_K_M.gguf"),
("Gemma-4-12B-it", "mlx", "mlx-community/gemma-4-12B-it-4bit"),
("Gemma-4-26B-A4B-it", "llama.cpp", "./models/google_gemma-4-26B-A4B-it-Q4_K_M.gguf"),
("Gemma-4-26B-A4B-it", "mlx", "mlx-community/gemma-4-26b-a4b-it-4bit"),
("GPT-OSS-20B", "llama.cpp", "./models/gpt-oss-20b-MXFP4.gguf"),
("GPT-OSS-20B", "mlx", "mlx-community/gpt-oss-20b-MXFP4-Q8"),
]
def call_once(engine, model):
resp = requests.post(
SERVICE_URL,
json={"engine": engine, "model": model, "prompt": PROMPT, "max_tokens": MAX_TOKENS},
timeout=600,
)
return resp.json()["metrics"]
def run_job(name, engine, model):
print(f"跑 {name} / {engine} ...")
for i in range(WARMUP_RUNS):
print(f" 熱身 {i + 1}/{WARMUP_RUNS}(結果不計入)")
call_once(engine, model)
ttfts, gen_speeds, peak_mems, prompt_tokens = [], [], [], None
for i in range(REPEAT_RUNS):
print(f" 正式量測 {i + 1}/{REPEAT_RUNS}")
m = call_once(engine, model)
ttfts.append(m["ttft_s"])
gen_speeds.append(m["gen_tok_s"])
peak_mems.append(m["peak_mem_gb"])
prompt_tokens = m["prompt_tokens"]
return {
"model": name,
"engine": engine,
"prompt_tokens": prompt_tokens,
"ttft_s_mean": round(statistics.mean(ttfts), 3),
"ttft_s_stdev": round(statistics.stdev(ttfts), 3) if len(ttfts) > 1 else 0.0,
"gen_tok_s_mean": round(statistics.mean(gen_speeds), 2),
"gen_tok_s_stdev": round(statistics.stdev(gen_speeds), 2) if len(gen_speeds) > 1 else 0.0,
"peak_mem_gb": round(max(peak_mems), 2), # 峰值記憶體取這組裡的最大值,不是平均
"note": "",
}
def main():
fieldnames = ["model", "engine", "prompt_tokens", "ttft_s_mean", "ttft_s_stdev",
"gen_tok_s_mean", "gen_tok_s_stdev", "peak_mem_gb", "note"]
rows = []
for name, engine, model in JOBS:
try:
rows.append(run_job(name, engine, model))
except Exception as e:
rows.append({"model": name, "engine": engine, "note": f"失敗:{e}"})
with open("results.csv", "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
print(f"\n(每組跑 {WARMUP_RUNS} 次熱身 + {REPEAT_RUNS} 次正式量測,下表是平均值 ± 標準差)")
print("| 模型 | 引擎 | Prompt Tokens | TTFT (s) | 生成速度 (tok/s) | 峰值記憶體 (GB) | 備註 |")
print("| :--- | :--- | :---: | :---: | :---: | :---: | :--- |")
for r in rows:
if r.get("note"):
print(f"| {r['model']} | {r['engine']} | - | - | - | - | {r['note']} |")
continue
print(f"| {r['model']} | {r['engine']} | {r['prompt_tokens']} | "
f"{r['ttft_s_mean']} ± {r['ttft_s_stdev']} | {r['gen_tok_s_mean']} ± {r['gen_tok_s_stdev']} | "
f"{r['peak_mem_gb']} | {r['note']} |")
if __name__ == "__main__":
main()
每組跑 1 次 Warm-up + 10 次正式量測。
| 模型 | 引擎 | Prompt Tokens | TTFT (s) | 生成速度 (tok/s) | 峰值記憶體 (GB) | 備註 |
|---|---|---|---|---|---|---|
| Qwen3.8-27B | llama.cpp | 9 | 0.411 ± 0.036 | 16.1 ± 0.76 | 15.65 | |
| Qwen3.8-27B | mlx | 9 | 0.648 ± 0.089 | 14.4 ± 4.14 | 14.44 | |
| Gemma-4-12B-it | llama.cpp | 13 | 0.039 ± 0.002 | 23.8 ± 1.04 | 30.27 | |
| Gemma-4-12B-it | mlx | - | - | - | - | 失敗:Expecting value: line 1 column 1 (char 0) |
| Gemma-4-26B-A4B-it | llama.cpp | 13 | 0.019 ± 0.001 | 51.57 ± 0.52 | 30.27 | |
| Gemma-4-26B-A4B-it | mlx | 12 | 0.227 ± 0.002 | 81.68 ± 1.94 | 13.43 | |
| GPT-OSS-20B | llama.cpp | 18 | 0.015 ± 0.001 | 67.14 ± 1.68 | 31.19 | |
| GPT-OSS-20B | mlx | 18 | 0.257 ± 0.001 | 83.67 ± 0.38 | 11.38 |
Qwen3.8-27B 這組反而是 llama.cpp 比較快:
16.1 ± 0.76 tok/s
14.4 ± 4.14 tok/s
TTFT 也是 llama.cpp 比較低。
所以不能因為 MLX 是 Apple 自家的框架,就直接認定它所有模型都會比較快。
兩顆 MoE 的生成速度都很漂亮。
Gemma-4-26B-A4B-it:
51.57 ± 0.52 tok/s
81.68 ± 1.94 tok/s
GPT-OSS-20B:
67.14 ± 1.68 tok/s
83.67 ± 0.38 tok/s
這也讓我更確定,之後挑 Local LLM 不能只看「幾 B」。
Dense 或 MoE、Active Parameters、量化方式,都會直接影響實際推論體感。
在 Gemma-4-26B-A4B-it 與 GPT-OSS-20B 上,llama.cpp 的 TTFT 分別只有:
0.019 ± 0.001 s
0.015 ± 0.001 s
這點對之後 Agent 很重要。
因為 Agent 一個任務可能會呼叫模型很多次,如果每次都要重新付一次 TTFT,差距累積起來就會很明顯。
Gemma-4-12B-it 的 MLX 版本這次直接失敗。
我沒有把它刪掉,因為對實際開發來說:
模型支援度本來就是推論框架的一部分。
llama.cpp 最大的優勢之一,還是在 GGUF 生態非常成熟,遇到特定模型時通常比較容易找到可以直接用的版本。
這次表格裡雖然有 Peak Memory,但我暫時不拿它直接判斷 MLX 與 llama.cpp 誰比較省。
原因是兩邊量測方式不同。
llama.cpp 目前用的是:
resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
這個值代表的是整個 Process 從啟動以來的歷史最高記憶體使用量,不是單一模型、單次 inference 的乾淨峰值。
所以這次 Memory 數字先保留,未來如果真的要比較,我會改成每個模型獨立啟動 Process 再測一次。
如果後面的鐵人賽只能先選一個主要推論環境,我目前會選:
MLX 為主,llama.cpp 為輔。
原因不是 MLX 全面比較快,而是這次兩顆我比較有興趣的 MoE 模型,在 MLX 上都跑到 80 tok/s 左右,對之後做 Local Agent 很有吸引力。
但 llama.cpp 我一定會留下來。
因為:
所以後面的架構大概會是:
MacBook Pro
├── MLX
│ └── 主要推論環境
└── llama.cpp
└── GGUF / 相容性 / fallback
這次測的還只是最單純的對話生成。
接下來才會開始加入 System Prompt、Tool Calling、RAG、Agent Loop,看現在的推論差異,到了真正的 Local Agent 任務裡還會不會成立。