oMLX 長對話驗收最容易犯的錯,是開機後問一題、看到 40 tok/s,就宣布這台 Mac 可以跑本機 Agent。真正到了第 30 輪,系統提示、工具定義、程式碼與舊回覆會一起塞回模型;如果 Prefix Cache(前綴快取)沒有命中,模型每輪都得重讀同一本愈來愈厚的筆記本。
所以這篇不做另一張漂亮的短跑排行榜,而是帶你建立一套可重跑的驗收流程:固定 oMLX、模型與硬體,累積到約 8K、32K、128K tokens,依序測 RAM hot cache、SSD restore、中段改寫、服務重啟、模型切換,以及 MTP/custom kernel 是否真的執行。
這是一份給第一次維運本機推論 endpoint(API 入口)的操作手冊。以下是驗收方法,不是 AlphaLab 在某台 Mac 上產生的性能榜;跑完後,你會拿到自己的 request、SSE(串流事件檔)、metrics 與答案雜湊,能回答「快取有沒有命中」和更重要的「有沒有命中錯內容」。
先說結論:oMLX 長對話驗收看三件事
🧭 記憶把手:Prefix Cache 驗收=該命中的前綴有命中+不該命中的後綴沒有命中+答案仍然正確。
- 速度:記錄 oMLX server-side TTFT/prompt-eval、decode,另由 client 計 request-to-finish wall time,不只看 decode tok/s。
- 命中:讀取
cached_tokens,再用cached_tokens ÷ prompt_tokens算 hit ratio;同時看 RAM/SSD tier 的 counters。 - 正確:固定取樣設定並埋入 marker;冷跑、熱命中與 SSD restore 的答案必須一致,中段改寫後則必須回答新 marker。
如果只留下 tok/s,三者只驗到半個「速度」。如果只看到 cached_tokens > 0,則連命中邊界和答案都還不知道。
Prefix Cache 為什麼比 tok/s 更接近 Agent 體感?
一個長對話請求可拆成兩段:prefill 是模型先讀完 prompt,decode 才是逐字寫答案。Agent 的舊對話通常大致不變,新增的只是尾端一輪;Prefix Cache 就像替筆記本前 200 頁做書籤,下一輪只讀新加的幾頁。
一般 Prefix Cache 的 key 以完整 token blocks 比對,還連著前一個 block 與模型名稱;SpecPrefill 的 static target prefix 另有 domain-separated exact-terminal path。因此中間改一個 token,改寫點之後的舊因果狀態就不能沿用;相同舊分支若尚未被淘汰,之後切回原文仍可能再次取回。這正是「只命中共同前綴」的安全邊界。想先補齊模型、引擎與 API server 的分工,可讀 LLM 推論引擎新手指南。

cached_tokens 數字可能來自不同 tier;把 TTFT、tier counters 與答案雜湊一起保存,才知道快在哪裡、對不對。oMLX 長對話驗收前:先凍結版本與環境
截至 2026 年 8 月 21 日,最新非預覽版是 v0.6.2,而目前 Homebrew tap 指向 v0.6.3rc2。兩者不能混成同一張圖;尤其 adapter 移除 system/inline-system 裡會變動的 <total_tokens>N tokens left</total_tokens> marker、同時保留 user 引用,是 v0.6.3rc1 才加入的 RC 功能。若目標 client 是 Claude Code,至少要把 RC 單獨驗一輪;若 production 只接受 stable,就把 v0.6.2 當另一條基線並清楚標示限制。
官方目前要求 macOS 15+、Apple Silicon M1~M5;原始碼安裝另需 Python 3.11~3.13。可用官方 DMG,或依 README 安裝 Homebrew 版,但安裝完成後一定先把實際版本寫進紀錄:
brew tap jundot/omlx https://github.com/jundot/omlx
brew install jundot/omlx/omlx
omlx --version
sw_vers
system_profiler SPHardwareDataType | grep -E 'Chip|Memory'
export MODEL_DIR="把 Admin → Models 顯示的實體模型路徑貼在這裡"
test -d "$MODEL_DIR"
test -f "$MODEL_DIR/config.json"
表頭至少保存:oMLX 版本或 commit、模型 repo/revision、模型檔與 tokenizer/chat-template 雜湊、量化方式、macOS、晶片、RAM、cache 目錄與容量、block size、concurrency,以及 MTP、DFlash、SpecPrefill、TurboQuant 等開關。後面的驗收腳本會把模型 ID 綁到實體路徑,並替 config、tokenizer、chat template 與 weights 建一次雜湊清單。若還沒決定模型大小,先用本機 LLM 記憶體指南估容量;若在 Q4、Q6、Q8 間猶豫,可再看量化格式與品質取捨。
第 1 關:明確開啟 RAM+SSD Prefix Cache
開一個 foreground server 專用 Terminal,先輸入 bash,讓 log 留在眼前;再執行 env | awk -F= '$1 ~ /^(OMLX|MLX)_/ {print $1"=<set>"}',只盤點變數名稱,不顯示 secret 值;記錄並逐一取消本次不需要的環境 override。以下範例把服務鎖在本機、固定單一併發,並明確指定 4GB hot RAM 與 50GB SSD cache;容量只是可讀範例,請依模型與 RAM 留足系統空間。2026 年 8 月 21 日的官方 parser 原始碼接受 B/KB/MB/GB/TB 或整數,README 裡的百分比寫法不適合拿來當這次可重跑設定。
#!/usr/bin/env bash
set -Eeuo pipefail
if [[ -z "${OMLX_API_KEY:-}" ]]; then
read -rsp '輸入這次 lab 專用 API key:' OMLX_API_KEY
printf '\n'
fi
: "${OMLX_API_KEY:?lab API key 不可空白}"
export OMLX_API_KEY
export MODEL_ROOT="把 Admin → Models 顯示的模型根目錄貼在這裡"
test -d "$MODEL_ROOT"
LAB_PARENT="$HOME/.omlx/acceptance-labs"
mkdir -p "$LAB_PARENT"
LAB_BASE="$(mktemp -d "$LAB_PARENT/omlx-prefix.XXXXXX")"
test -n "$LAB_BASE" && test -d "$LAB_BASE"
CACHE_DIR="$LAB_BASE/cache"
mkdir -p "$CACHE_DIR"
export LAB_BASE CACHE_DIR
omlx serve \
--host 127.0.0.1 \
--port 8000 \
--base-path "$LAB_BASE" \
--model-dir "$MODEL_ROOT" \
--paged-ssd-cache-dir "$CACHE_DIR" \
--paged-ssd-cache-max-size 50GB \
--hot-cache-max-size 4GB \
--max-concurrent-requests 1
--base-path 與 cache 都放進本次隨機目錄,避免繼承日常 base 裡的 settings.json/model settings,也不會把這次 flags 寫回日常設定。但 shell 環境變數的優先級仍高於檔案;先用 env | awk -F= '$1 ~ /^(OMLX|MLX)_/ {print $1"=<set>"}' 只盤點變數名稱,記錄並逐一取消本次不需要的 override,只保留 lab 所需的 OMLX_API_KEY,不要把它的值印出來。MODEL_ROOT 不要盲猜成 ~/.omlx/models:App、Homebrew 與自訂安裝可能不同,必須以 Admin 顯示的實體路徑為準。
本文的 curl 只供 127.0.0.1 使用。若真的要跨機器,先用 TLS reverse proxy 或 VPN/防火牆保護傳輸,再以環境變數 OMLX_API_KEY 帶 Bearer header;不要在明文 HTTP 傳 key。先用 http://127.0.0.1:8000/admin 確認 Resource Management 設定。更細的 block_size、prefix hits/misses、ssd_disk_loads、saves_persisted、hot evictions 與 errors 在已登入 Admin 工作階段的 /admin/api/stats;其完整回應含頂層 api_key,不可原樣存檔。後面的腳本會建立權限受限的 session cookie 暫存檔,只把指定模型的 runtime_cache 寫進 results/<case>.cache.json,結束時再刪 cookie。不要把 SSD manager 的原始 hit counter 直接當成 request-level Prefix Cache hit。

第 2 關:用同一批 8K/32K/128K 累積素材
準備三個純文字 fixture(固定測試檔):context-32k.txt 必須逐字以 context-8k.txt 開頭,context-128k.txt 又必須逐字以前者開頭。最好取同一個固定 commit 的程式碼、工具 schema 與操作紀錄,不要用一段字重複十萬次;在約 4K、20K、96K 深度分別放入 EARLY=CEDAR-04、MIDDLE=BLUE-17、LATE=VIOLET-96。於是 8K 應回答 CEDAR-04/NA/NA,32K 應回答 CEDAR-04/BLUE-17/NA,128K 才回答三個值。這三個預期值會直接寫進腳本,答錯一個就停止,不是只收集檔案。
檔名的 8K/32K/128K 只是目標,真正長度以 API 回傳的 prompt_tokens 為準,再增減尾端素材。腳本要求至少達各 tier 的 90%,並在名目上限前保留 256 tokens 給 generation:7,373~7,936、29,492~32,512、117,965~130,816 tokens,且三者嚴格遞增;超出就先修 fixture,不讓一份過短素材冒充長上下文。128K 只在模型 context window、量化與 RAM 都允許時跑;不能載入就是容量 gate 失敗,不要關掉 memory guard 硬撐。Mac 上的實際限制也可對照 Apple Silicon 大模型 benchmark 解讀。
開一個新的測試 Terminal,先輸入 bash,再列出 endpoint 提供的模型並選定唯一 ID;後面兩個程式區塊也要在這個 Bash 依序貼上,任何 assertion 失敗就會直接退出。以下解析命令需要 jq;尚未安裝可先執行 brew install jq:
export BASE_URL=http://127.0.0.1:8000/v1
if [[ -z "${OMLX_API_KEY:-}" ]]; then
read -rsp '輸入 server 使用的同一把 lab API key:' OMLX_API_KEY
printf '\n'
fi
: "${OMLX_API_KEY:?lab API key 不可空白}"
export OMLX_API_KEY
AUTH_HEADER=(-H "Authorization: Bearer $OMLX_API_KEY")
curl --fail-with-body -sS "${AUTH_HEADER[@]}" \
"$BASE_URL/models" | jq -r '.data[].id'
export MODEL_ID='把上一步的完整模型 ID 貼在這裡'
export MODEL_DIR='把該 ID 對應的實體模型目錄貼在這裡'
test -d "$MODEL_DIR"
export CACHE_PATH_KIND='ordinary-kv'
# 若 INFO log 明確顯示 minimax-m3 或 split-gdn,改填該值,
# 並另 export CACHE_ALLOWANCE_TOKENS=該路徑可接受的未命中上限。
選好後,先到 Admin 明確載入這個實體模型,等狀態變成 loaded;不要拿任何 fixture warm-up,也不要選 alias/profile。接著才在剛才設定 BASE_URL 與 MODEL_ID 的同一個 Bash 貼上下面函式。請在新的工作目錄執行;若已有 results,先移走備份,腳本會拒絕覆寫。它要求 streaming usage,固定關閉 thinking,保存原始 request 與 SSE,再抽出 oMLX 的 cached_tokens、TTFT、prefill/decode 時間與速率,最後把完整答案另存並計算 SHA-256:
#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
: "${BASE_URL:?請先設定 BASE_URL}"
: "${MODEL_ID:?請先設定 MODEL_ID}"
: "${MODEL_DIR:?請先設定 MODEL_DIR}"
: "${OMLX_API_KEY:?請先輸入 lab API key}"
: "${CACHE_PATH_KIND:?請明確設定 cache path kind}"
declare -a AUTH_HEADER=(-H "Authorization: Bearer $OMLX_API_KEY")
[[ ! -e results ]]
mkdir -m 700 results
ADMIN_BASE=http://127.0.0.1:8000/admin
ADMIN_COOKIE_JAR="$(mktemp results/admin-cookie.XXXXXX)"
MODEL_FILE_LIST="$(mktemp results/model-files.XXXXXX)"
trap 'rm -f "$ADMIN_COOKIE_JAR" "$MODEL_FILE_LIST"' EXIT
admin_login () {
jq -n '{api_key:env.OMLX_API_KEY,remember:false}' | \
curl --fail-with-body -sS -c "$ADMIN_COOKIE_JAR" \
-H 'Content-Type: application/json' --data-binary @- \
"$ADMIN_BASE/api/login" >/dev/null
}
admin_login
curl --fail-with-body -sS -b "$ADMIN_COOKIE_JAR" \
"$ADMIN_BASE/api/models" | \
jq -e 'if (.models | type) != "array" then error("models missing")
else [.models[] | {id,model_path,loaded}] end' \
> results/admin-models.redacted.json
ADMIN_MODEL_PATH="$(jq -er --arg id "$MODEL_ID" '
[.[] | select(.id == $id and .loaded == true and
(.model_path | type) == "string" and
((.model_path | startswith("builtin://")) | not))] |
if length == 1 then .[0].model_path
else error("MODEL_ID 必須是已載入的單一實體模型,不可用 alias/profile") end
' results/admin-models.redacted.json)"
MODEL_REAL="$(cd "$MODEL_DIR" && pwd -P)"
ADMIN_MODEL_REAL="$(cd "$ADMIN_MODEL_PATH" && pwd -P)"
[[ "$MODEL_REAL" == "$ADMIN_MODEL_REAL" ]]
CACHE_MODEL_ID="$MODEL_ID"
printf 'MODEL_ID=%s\nCACHE_MODEL_ID=%s\nMODEL_DIR=%s\n' \
"$MODEL_ID" "$CACHE_MODEL_ID" "$MODEL_REAL" \
> results/model-binding.txt
find -L "$MODEL_REAL" -type f \
\( -name 'config.json' -o -name 'generation_config.json' \
-o -name 'tokenizer*' -o -name 'vocab*' -o -name 'merges.txt' \
-o -name '*.model' -o -name 'added_tokens.json' \
-o -name 'special_tokens_map.json' -o -name '*chat_template*' \
-o -name '*.safetensors' -o -name '*.safetensors.index.json' \) \
-print0 > "$MODEL_FILE_LIST"
: > results/model-files.sha256
CONFIG_COUNT=0
TOKENIZER_COUNT=0
WEIGHT_COUNT=0
while IFS= read -r -d '' FILE_PATH; do
shasum -a 256 "$FILE_PATH" >> results/model-files.sha256
FILE_NAME="${FILE_PATH##*/}"
case "$FILE_NAME" in
config.json) CONFIG_COUNT=$((CONFIG_COUNT + 1)) ;;
tokenizer.json|tokenizer.model*|vocab*|merges.txt|*.model)
TOKENIZER_COUNT=$((TOKENIZER_COUNT + 1)) ;;
*.safetensors) WEIGHT_COUNT=$((WEIGHT_COUNT + 1)) ;;
esac
done < "$MODEL_FILE_LIST"
(( CONFIG_COUNT >= 1 && TOKENIZER_COUNT >= 1 && WEIGHT_COUNT >= 1 ))
LC_ALL=C sort -o results/model-files.sha256 results/model-files.sha256
snapshot_cache () {
local CASE="$1"
local PHYSICAL_MODEL="$2"
curl --fail-with-body -sS -G -b "$ADMIN_COOKIE_JAR" \
--data-urlencode "model=$PHYSICAL_MODEL" "$ADMIN_BASE/api/stats" | \
jq -e --arg model "$PHYSICAL_MODEL" '
if (.runtime_cache | type) != "object" then
error("runtime_cache missing")
else .runtime_cache as $r |
[$r.models[]? | select(.id == $model)] as $rows |
if (($rows | length) != 1 or
($rows[0].block_size | type) != "number" or
$rows[0].block_size <= 0)
then error("loaded physical model/cache block missing")
else {runtime_cache:($r | .models = $rows)} end
end
' > "results/$CASE.cache.json"
}
cache_counter () {
local FILE="$1"
local KEY="$2"
if [[ "$KEY" == ssd_disk_loads ]]; then
jq -er '.runtime_cache.models[0] |
((.loads // 0) - (.hot_cache_hits // 0)) | numbers' "$FILE"
else
jq -er --arg key "$KEY" '
(.runtime_cache.models[0][$key] // 0) | numbers' "$FILE"
fi
}
assert_counter_increase () {
local BEFORE="$1"
local AFTER="$2"
local KEY="$3"
local B A
B="$(cache_counter "$BEFORE" "$KEY")"
A="$(cache_counter "$AFTER" "$KEY")"
(( A > B ))
}
snapshot_cache before-cold "$CACHE_MODEL_ID"
CACHE_BLOCK_SIZE="$(jq -er '.runtime_cache.models[0].block_size | numbers' \
results/before-cold.cache.json)"
case "$CACHE_PATH_KIND" in
ordinary-kv) CACHE_ALLOWANCE_TOKENS="$CACHE_BLOCK_SIZE" ;;
minimax-m3|split-gdn)
: "${CACHE_ALLOWANCE_TOKENS:?依 INFO log 設定此模型路徑的未命中上限}" ;;
stateful-full-prefill)
echo '此路徑 exact repeat 仍 full prefill:cache reuse 判定 FAIL' >&2
exit 1 ;;
*) echo '未知 CACHE_PATH_KIND' >&2; exit 1 ;;
esac
[[ "$CACHE_ALLOWANCE_TOKENS" =~ ^[0-9]+$ ]]
(( CACHE_ALLOWANCE_TOKENS > 0 && CACHE_ALLOWANCE_TOKENS < 7373 ))
APPEND_ALLOWANCE_TOKENS=$((CACHE_ALLOWANCE_TOKENS + CACHE_BLOCK_SIZE))
(( APPEND_ALLOWANCE_TOKENS > CACHE_ALLOWANCE_TOKENS &&
APPEND_ALLOWANCE_TOKENS < 7373 ))
printf 'CACHE_PATH_KIND=%s\nCACHE_BLOCK_SIZE=%s\nCACHE_ALLOWANCE_TOKENS=%s\nAPPEND_ALLOWANCE_TOKENS=%s\n' \
"$CACHE_PATH_KIND" "$CACHE_BLOCK_SIZE" "$CACHE_ALLOWANCE_TOKENS" \
"$APPEND_ALLOWANCE_TOKENS" \
> results/cache-path-binding.txt
make_request () {
local CASE="$1"
local FILE="$2"
jq -n --arg model "$MODEL_ID" --rawfile ctx "$FILE" '{
model: $model,
messages: [
{role:"system", content:"只根據內容回答;依序只輸出 EARLY、MIDDLE、LATE 三個鍵的值,以 / 分隔;不存在寫 NA。"},
{role:"user", content:("CONTEXT\n" + $ctx + "\nEND CONTEXT")}
],
temperature: 0,
seed: 7,
max_tokens: 64,
chat_template_kwargs: {enable_thinking:false},
stream: true,
stream_options: {include_usage:true}
}' > "results/$CASE.request.json"
}
run_case () {
local CASE="$1"
local FILE="$2"
local EXPECTED="$3"
local NORMALIZED
make_request "$CASE" "$FILE"
curl --fail-with-body -sSN "${AUTH_HEADER[@]}" \
"$BASE_URL/chat/completions" \
-H 'Content-Type: application/json' \
--data-binary @"results/$CASE.request.json" \
--output "results/$CASE.sse" \
--write-out 'client_ttfb_s=%{time_starttransfer}\nclient_total_s=%{time_total}\n' \
> "results/$CASE.client-time.txt"
grep -qx 'data: \[DONE\]' "results/$CASE.sse"
sed -n 's/^data: //p' "results/$CASE.sse" | \
grep -v '^\[DONE\]$' | jq -ce '.' \
> "results/$CASE.events.jsonl"
jq -e -s '
all(.[]; (.error // null) == null) and
any(.[]; any(.choices[]?; .finish_reason == "stop"))
' "results/$CASE.events.jsonl" >/dev/null
jq -e -s --arg case "$CASE" '
([.[] | select(.usage != null) | .usage][-1]) as $u |
($u.prompt_tokens_details.cached_tokens // 0) as $cached |
if ($u == null
or (($u.prompt_tokens | type) != "number")
or (($cached | type) != "number")
or ($cached < 0)
or ($cached > $u.prompt_tokens)
or (($u.total_time | type) != "number")
or (($u.time_to_first_token | type) != "number")
or (($u.prompt_eval_duration | type) != "number")
or (($u.generation_duration | type) != "number"))
then error("missing final usage")
else {case:$case,
prompt_tokens:$u.prompt_tokens,
cached_tokens:$cached,
uncached_tokens:($u.prompt_tokens - $cached),
hit_ratio_pct:(if $u.prompt_tokens == 0 then 0
else (100 * $cached / $u.prompt_tokens) end),
model_load_s:($u.model_load_duration // 0),
server_ttft_s:$u.time_to_first_token,
server_stream_total_s:$u.total_time,
prefill_s:$u.prompt_eval_duration,
decode_s:$u.generation_duration,
prefill_tps:$u.prompt_tokens_per_second,
decode_tps:$u.generation_tokens_per_second}
end' "results/$CASE.events.jsonl" \
> "results/$CASE.metrics.json"
jq -jr '.choices[0].delta.content // empty' \
"results/$CASE.events.jsonl" > "results/$CASE.answer.txt"
test -s "results/$CASE.answer.txt" || return 1
NORMALIZED="$(tr -d '[:space:]' < "results/$CASE.answer.txt")"
[[ "$NORMALIZED" == "$EXPECTED" ]]
shasum -a 256 "results/$CASE.answer.txt" | awk '{print $1}' \
> "results/$CASE.answer.sha256"
}
assert_prompt_range () {
local CASE="$1"
local MIN="$2"
local MAX="$3"
jq -e --argjson min "$MIN" --argjson max "$MAX" '
.prompt_tokens >= $min and .prompt_tokens <= $max
' "results/$CASE.metrics.json" >/dev/null
}
assert_exact_repeat () {
local CASE="$1"
local REFERENCE="$2"
jq -e -n --argjson allowance "$CACHE_ALLOWANCE_TOKENS" \
--slurpfile current "results/$CASE.metrics.json" \
--slurpfile reference "results/$REFERENCE.metrics.json" '
$current[0].prompt_tokens == $reference[0].prompt_tokens and
$current[0].cached_tokens > 0 and
$current[0].uncached_tokens <= $allowance and
$allowance < $current[0].prompt_tokens
' >/dev/null
}
assert_append_prefix () {
local PREVIOUS="$1"
local CURRENT="$2"
jq -e -n --argjson allowance "$APPEND_ALLOWANCE_TOKENS" \
--slurpfile prev "results/$PREVIOUS.metrics.json" \
--slurpfile cur "results/$CURRENT.metrics.json" '
($prev[0].prompt_tokens - $allowance) as $minimum |
$minimum > 0 and
$cur[0].cached_tokens >= $minimum and
$cur[0].cached_tokens <= $prev[0].prompt_tokens and
$cur[0].cached_tokens < $cur[0].prompt_tokens
' >/dev/null
}
for FILE in context-8k.txt context-32k.txt context-128k.txt; do
test -s "$FILE"
done
BYTES_8="$(wc -c < context-8k.txt)"
BYTES_32="$(wc -c < context-32k.txt)"
BYTES_128="$(wc -c < context-128k.txt)"
(( BYTES_8 < BYTES_32 && BYTES_32 < BYTES_128 ))
cmp -s -n "$BYTES_8" context-8k.txt context-32k.txt
cmp -s -n "$BYTES_32" context-32k.txt context-128k.txt
run_case cold-8k context-8k.txt 'CEDAR-04/NA/NA'
jq -e '.cached_tokens == 0' results/cold-8k.metrics.json >/dev/null
assert_prompt_range cold-8k 7373 7936
snapshot_cache after-cold "$CACHE_MODEL_ID"
for N in 1 2 3; do
run_case "warm-8k-$N" context-8k.txt 'CEDAR-04/NA/NA'
assert_prompt_range "warm-8k-$N" 7373 7936
assert_exact_repeat "warm-8k-$N" cold-8k
cmp -s results/cold-8k.answer.txt "results/warm-8k-$N.answer.txt"
done
snapshot_cache after-warm-8k "$CACHE_MODEL_ID"
assert_counter_increase results/after-cold.cache.json \
results/after-warm-8k.cache.json hot_cache_hits
run_case append-32k context-32k.txt 'CEDAR-04/BLUE-17/NA'
assert_prompt_range append-32k 29492 32512
assert_append_prefix cold-8k append-32k
for N in 1 2 3; do
run_case "warm-32k-$N" context-32k.txt 'CEDAR-04/BLUE-17/NA'
assert_prompt_range "warm-32k-$N" 29492 32512
assert_exact_repeat "warm-32k-$N" append-32k
cmp -s results/append-32k.answer.txt "results/warm-32k-$N.answer.txt"
done
snapshot_cache after-warm-32k "$CACHE_MODEL_ID"
run_case append-128k context-128k.txt 'CEDAR-04/BLUE-17/VIOLET-96'
assert_prompt_range append-128k 117965 130816
assert_append_prefix append-32k append-128k
for N in 1 2 3; do
run_case "warm-128k-$N" context-128k.txt \
'CEDAR-04/BLUE-17/VIOLET-96'
assert_prompt_range "warm-128k-$N" 117965 130816
assert_exact_repeat "warm-128k-$N" append-128k
cmp -s results/append-128k.answer.txt "results/warm-128k-$N.answer.txt"
done
snapshot_cache after-warm-128k "$CACHE_MODEL_ID"
PROMPT_8="$(jq -er '.prompt_tokens' results/cold-8k.metrics.json)"
PROMPT_32="$(jq -er '.prompt_tokens' results/append-32k.metrics.json)"
PROMPT_128="$(jq -er '.prompt_tokens' results/append-128k.metrics.json)"
(( PROMPT_8 < PROMPT_32 && PROMPT_32 < PROMPT_128 ))
jq -s 'map(.server_ttft_s) | sort |
{samples:length,min_s:.[0],median_s:.[1],max_s:.[2]}' \
results/warm-8k-{1,2,3}.metrics.json \
> results/warm-8k-ttft-summary.json
jq -s 'map(.server_ttft_s) | sort |
{samples:length,min_s:.[0],median_s:.[1],max_s:.[2]}' \
results/warm-32k-{1,2,3}.metrics.json \
> results/warm-32k-ttft-summary.json
jq -s 'map(.server_ttft_s) | sort |
{samples:length,min_s:.[0],median_s:.[1],max_s:.[2]}' \
results/warm-128k-{1,2,3}.metrics.json \
> results/warm-128k-ttft-summary.json
mktemp 會替這次 run 建立全新 base 與 cache 目錄;建立失敗時,set -e 和目錄檢查會停止,不會帶著空變數回退到舊 cache。重啟測試完成前不要更換它,才能防止舊 SSD blocks 把 cold-8k 偷偷變成 warm。先在 Admin 明確載入選定模型,不要用任何 fixture 做 warm-up,再依序執行上面的命令。第一個推論若仍有 model_load_s,就另列,不要跟已載入模型的 server TTFT 直接比較。usage metrics 已算好 uncached_tokens、hit ratio、server-side TTFT 與 server stream total;目前一般 chat 的 prompt_eval_duration 會等於這個 server TTFT,不是另一支純 prefill 計時。真正從 client 送出到結束的 wall time,另存在 client-time.txt。單次 usage 的 prefill_tps 又是全部 prompt tokens 除以該 duration,不是「只算未命中 token」的口徑,因此不能單獨拿它判斷快取節省多少。
不要為了 control 打斷主 lane 的相依順序;先完成 cold→warm→append、中段改寫與 restart gate,再另用相同模型與 --max-concurrent-requests 1、但加上 --no-cache 的 control server 跑三次。每個 control sample 都新建自己的 --base-path 與程序,是為了排除所有殘留 process state,不是因為正常完成的 BatchGenerator request 必然留下 KV。每個新程序先用一段與 fixture 不重疊的短 prompt 做一次 compile/runtime warm-up,等它正常結束,再只量一個 8K control 後停止;否則首請求編譯成本會讓 TTFT 對照偏高。--no-cache 也會保存到該 base 的設定,所以 control 絕不能重用主 lane 的 LAB_BASE。三份 case 名用 control-8k-1 至 -3,再用同一條 jq -s 'map(.server_ttft_s) | sort | .[1]' 算中位數;它仍是跨程序的保守對照,要一併報中位數與離散程度。若 control 答案已不同,先以 marker 是否正確為硬 gate,並排查取樣、MTP 或數值路徑;不能把所有差異都歸咎於 Prefix Cache。
第 3 關:Exact repeat 與 append-only 要真的命中
Exact repeat 的第二次請求,要用 Admin 回傳的 runtime block_size 算 gate。對一般可裁切 KV 路徑,應覆蓋所有可保存的完整共同前綴 blocks,尾端未滿一個 block 可以重算;換成 metrics,就是 uncached_tokens 通常不超過一個 runtime block,等價門檻為 hit_ratio_pct ≥ 100 × (1-block_size/prompt_tokens)。腳本把它存成 CACHE_ALLOWANCE_TOKENS,並對每一個 warm sample 執行。Append-only 另放寬一個 block,涵蓋前一版尾端的 END CONTEXT/generation suffix 與 BPE 邊界重切,不能拿 exact-repeat 上限硬套。
MiniMax M3 的 prefill-step 對齊與 split GDN 可能刻意多算數個 blocks,必須把 CACHE_PATH_KIND 改成對應值,依 INFO log 與同模型 no-change baseline 明確填入 allowance;留空就停止。non-trimmable stateful 路徑若 exact repeat 仍 full prefill,則記為這個目標的「cache reuse 不支援/FAIL」,不能因為 log 有解釋就判 PASS。一般路徑在 prompt_tokens ≥ 10 × block_size 時,90% 只能當粗略紅旗線,不能取代精確公式,也不是 oMLX 官方 SLA。warm 與獨立 no-cache control 都各跑三次,再比較 TTFT 中位數;答案則用腳本逐一 cmp,不用單次時序下結論。
Append-only 則不是期待 8K 跳到 32K 時命中 32K,而是命中上一輪共同前綴的完整 blocks;立刻再送一次相同 32K,才期待接近整段命中。若每次都只剩幾個 blocks,先檢查 client 是否把時間、token budget、tool 順序或隨機 ID 放在 prompt 前端。這類 prompt 穩定性也是 AI Agent Harness 應該管理的責任。
第 4 關:改寫中段,錯的後綴必須失效
複製 32K fixture,把 marker value 的七個 ASCII 字元 BLUE-17 改成同長度的 GOLD-42,其餘 bytes 保持不變。不能只把次數印出來目測:下面會先要求原檔恰有一個舊 marker,再檢查改後檔大小相同、恰有一個新 marker且舊值為零。
邊界不要靠字元數猜,而且 probe 必須在送出改寫請求之前執行。它會用服務端同一份 chat template、thinking 設定與 tokenizer 找出當下可取回上限;probe 與 request 之間不得插入其他流量。若 counters 還在變動,就先等這條單併發 lane 穩定後,從 probe 重做。
ORIGINAL_BLUE="$(grep -o 'MIDDLE=BLUE-17' context-32k.txt | wc -l | tr -d ' ')"
[[ "$ORIGINAL_BLUE" == 1 ]]
cp context-32k.txt context-32k-edit.txt
LC_ALL=C perl -0pi -e 's/MIDDLE=BLUE-17/MIDDLE=GOLD-42/' \
context-32k-edit.txt
ORIGINAL_BYTES="$(wc -c < context-32k.txt)"
EDITED_BYTES="$(wc -c < context-32k-edit.txt)"
[[ "$ORIGINAL_BYTES" == "$EDITED_BYTES" ]]
EDITED_GOLD="$(grep -o 'MIDDLE=GOLD-42' context-32k-edit.txt | wc -l | tr -d ' ')"
[[ "$EDITED_GOLD" == 1 ]]
if grep -q 'MIDDLE=BLUE-17' context-32k-edit.txt; then exit 1; fi
make_request middle-edit-32k context-32k-edit.txt
jq '{model_id:.model,
messages:.messages,
chat_template_kwargs:.chat_template_kwargs}' \
results/middle-edit-32k.request.json \
> results/middle-edit-32k.probe-request.json
curl --fail-with-body -sS -b "$ADMIN_COOKIE_JAR" \
-H 'Content-Type: application/json' \
--data-binary @results/middle-edit-32k.probe-request.json \
"$ADMIN_BASE/api/cache/probe" \
> results/middle-edit-32k.probe.json
jq -e --arg model "$CACHE_MODEL_ID" '
.model_id == $model and .model_loaded == true and
(.ssd_hit_tokens | type) == "number" and .ssd_hit_tokens > 0 and
(.cold_tokens | type) == "number" and .cold_tokens > 0
' results/middle-edit-32k.probe.json >/dev/null
run_case middle-edit-32k context-32k-edit.txt 'CEDAR-04/GOLD-42/NA'
jq -e -n --argjson allowance "$CACHE_ALLOWANCE_TOKENS" \
--slurpfile p results/middle-edit-32k.probe.json \
--slurpfile m results/middle-edit-32k.metrics.json '
($p[0].ssd_hit_tokens - $allowance) as $minimum |
$minimum > 0 and
$m[0].prompt_tokens == $p[0].total_tokens and
$m[0].cached_tokens >= $minimum and
$m[0].cached_tokens <= $p[0].ssd_hit_tokens and
$m[0].cached_tokens < $m[0].prompt_tokens
' >/dev/null
grep -q 'GOLD-42' results/middle-edit-32k.answer.txt
if grep -q 'BLUE-17' results/middle-edit-32k.answer.txt; then exit 1; fi
snapshot_cache after-middle-edit "$CACHE_MODEL_ID"
三道數值條件代表:不是零命中、沒有越過 probe 邊界,也不能把整段誤報成 cache;答案另由新舊 marker assertion 保護。改回原檔後,如果舊分支未遭 LRU 淘汰,才可能重新命中原分支。
為什麼不能只看 hit counter?一份 2026 年 7 月、針對舊版 0.5.4rc1 SpecPrefill target-side restore path 的公開 bug report曾記錄 cached_tokens 非零,但 marker probe 仍暴露內容遺失;後續修正通過該 reporter 的案例。這不代表現在版本或一般 Prefix Cache 仍有同一個 bug,卻清楚說明「有 hit」和「答案正確」是兩道不同 gate。
第 5 關:Graceful restart 後要從 SSD 復用
4GB hot cache 採 write-back;髒 blocks 可能要到 graceful close 才完整 flush,因此不要在關機前硬等 saves=saves_persisted。先在測試 Bash 執行 snapshot_cache restart-before-stop "$CACHE_MODEL_ID",再到 server Terminal 按一次 Ctrl-C 正常停止。等程序完全退出並確認 log 沒有 flush/writer timeout 或 cache error,才用同一個 LAB_BASE、CACHE_DIR、版本、模型與 flags 重啟。不要重跑建立 LAB_BASE 的 mktemp 那幾行;在原 server Bash 只重跑同一條 omlx serve ... 命令。
重啟後先在 Admin 載入同一實體模型,但不要送 fixture;接著在原測試 Bash 重新登入 Admin,於同一個新 runtime 的 replay 前後取 model-scoped snapshots。不能拿舊程序的 counter 和新程序直接相減,因為 runtime counter 會重設。
admin_login
snapshot_cache restart-before-replay "$CACHE_MODEL_ID"
run_case restart-32k context-32k.txt 'CEDAR-04/BLUE-17/NA'
assert_exact_repeat restart-32k append-32k
cmp -s results/append-32k.answer.txt results/restart-32k.answer.txt
snapshot_cache restart-after-replay "$CACHE_MODEL_ID"
assert_counter_increase results/restart-before-replay.cache.json \
results/restart-after-replay.cache.json ssd_disk_loads
只有 after 的 ssd_disk_loads 大於同一新 runtime 的 before、答案等於基準,而且 cached-token gate 也通過,才算 SSD restore。它可能比 RAM hot hit 慢,因為還有 SSD 載入成本,但不能退化成完整 prefill。若要在停止前就驗證落盤,另開 --hot-cache-max-size 0 的 SSD-only lane。
把 graceful restart 與 crash/SIGKILL 分開。非正常中斷可能撞上尚未寫完的非同步保存,不能拿來否定正常 persistence,也不能拿正常關機結果宣稱 crash-safe。v0.6.2 將 exact GDN SSD snapshots 恢復為預設;若沒有明確選擇降精度,GDN sidecar state 使用 FP32,以處理 restore 後輸出一致性問題。因此本關的答案雜湊不是多餘潔癖,而是 release history 已經提醒過的正確性 gate。
mktemp 建立的測試 base 會留在 ~/.omlx/acceptance-labs,其中 cache 上限又設為 50GB。驗收完成後,先把 results 與需要的 log 備份、停止 oMLX,再用 printf '%s\n' "$LAB_BASE" 核對它確實是本次隨機的 acceptance-labs/omlx-prefix.XXXXXX 路徑,最後從 Finder 只把該 run 資料夾移到垃圾桶。不要刪預設 model/cache 根目錄,也不要清仍在服務中的 cache。
第 6 關:切模型、MTP、custom kernel 分開驗
用同一個 prompt 依序跑 Model A → Model B → Model A。先讓 A、B 的 Admin model_path 各自通過 canonical path 比對,確認是兩個不同實體目錄、不是同一 engine 的 alias/profile;路徑不同還不能證明 checkpoint 不同,兩邊都要跑前述 fail-closed weight hashing,再把 SHA-256 的 digest-only 清單排序比較,結果相同就不能拿來當 B。B 的第一個請求必須以 B 的 physical ID 取 model-scoped stats,並得到 cached_tokens=0,證明它沒吃到 A 的 KV。接著正常停止或卸載,讓 A 的 hot RAM 不再駐留;A 在新 runtime 載入後,先取 /admin/api/stats?model=<A_ID> 的 before snapshot,replay 後立刻再取 after。只有同一新 runtime 內 A 的 ssd_disk_loads 增加、答案等於 A 的基準,才算從自己的 SSD 分支復用;B 或其他流量的 aggregate counter 不算。若你直接用新權重覆寫同名模型資料夾,最安全的操作是清掉該模型 hot/SSD cache 再載入,不要把「同名覆寫後一定自動失效」當前提。
MTP(一次草擬多個後續 token,再由主模型驗證)先關掉跑基準,再到 Models → model settings 確認相容性、開啟並重新載入。合格證據不是檔名有 -mtp,也不是 toggle 亮起,而是載入 log 出現 Speculative backend selected ... (active),完成請求後還有 MTP[...] 的 cycles > 0、acceptance 與 timing;只有零 cycles 不算執行證據。固定 --max-concurrent-requests 1 是為了隔離;production concurrency 要另開一輪,因為多 row 或 pending merge 可能走標準 decode。
如果是 source build,再依官方方式檢查 native extensions:
python -c "from omlx.custom_kernels import native_kernel_status; print(native_kernel_status())"
某個 package 的 'available': True 只證明 native extension 已載入(新 build 另會通過 ABI probe),不等於這次 request 一定 dispatch 到該 kernel,也不證明 MTP active。plain pip install -e . 不會編譯這些 native kernels;自建需完整 Xcode/Metal 與 OMLX_WITH_CUSTOM_KERNEL=1。每次只開一個加速器,過完 cache correctness 再組合。想看模型是否真的能完成工具任務,而不只吐字,可接著套用 Qwen3.8-27B 本機 Agent 驗收框架。

最常踩的 6 個坑
- 把 decode tok/s 當互動速度:長 prompt 常由 prefill 與 restore 主導,請先看 TTFT。
- 把第一個 model load 混入 cold:先載入一次,再開始可比較的 cold/warm pair。
- 每輪偷偷改前綴:時間戳、token budget、工具順序放在前端,會讓後面全部失效。
- 重啟太快:正常停止、等程序完全退出,確認 flush 無錯且檔案穩定後再重啟。
- 一次開滿加速器:MTP、DFlash、SpecPrefill、TurboQuant 各自先做 A/B,否則 fallback 或衝突很難定位。
- 忽略溫度與電源:接電、固定風扇/環境並記錄 thermal state。cold→warm→append 的相依順序不能打亂;只有各用 fresh cache 的獨立完整 lanes 才能隨機換序。
oMLX 長對話驗收 FAQ
1. cached_tokens 很高,就代表快取正確嗎?
不代表。它只說 API 回報復用了多少 token;還要確認沒有越過第一個差異 block,並用 marker 與答案雜湊驗內容。
2. decode tok/s 很高,長對話就一定快嗎?
不一定。長 session 可能把時間花在 prefill、SSD load 或 MTP prime;互動體感要一起看 server-side TTFT 與 client-side request-to-finish wall time。
3. SSD Prefix Cache 一定能跨重啟嗎?
條件式可以。必須真的啟用 SSD,正常停止並等待程序退出,確認 flush/writer log 無錯後,再用相同相容設定重啟;hot RAM 本身不會跨程序保存。只有 --hot-cache-max-size 0 的 SSD-only lane 才在停止前檢查落盤完成。
4. 中段只改一字,後面相同文字可以繼續命中嗎?
不能沿用舊後綴的因果狀態。這次請求只能復用第一個差異前的完整 blocks;改回舊分支時,舊 cache 才可能再被取回。
5. 每台 Mac 都要跑 128K 嗎?
不用。產品只承諾 32K,就把 32K 做完整;128K 是容量與耐久 gate,不該為了打卡而讓系統 swap 或關掉保護。
6. 模型名稱有 -mtp 就代表 MTP 正在跑嗎?
不代表。轉換可能沒有保留權重,載入設定可能關閉,特定 batch 也可能 fallback;要看 active load log 與每次完成請求的 MTP 統計。
7. v0.6.2 與 v0.6.3rc2 該選哪個?
依上線政策分兩條跑。截至 2026 年 8 月 21 日,v0.6.2 是最新非預覽版;Claude Code 動態 budget marker 的前綴修正在 RC 線。不要把兩版數據平均,也不要把 RC 稱為 stable。
8. 這套驗收多久重跑一次?
任何會改變執行路徑的更新後都要重跑。包括 oMLX、MLX、模型 revision、量化、chat template、cache format、加速器與 macOS;平時 CI 至少留 exact repeat、中段改寫和 graceful restart 三個 smoke tests。
給新手的 5 個重點
- 先固定版本、模型與 prompt bytes,再談比較。
- 用
cached_tokens找命中量,用 TTFT 看體感,用答案 marker 驗正確。 - Exact repeat、append-only、中段改寫與重啟是核心;切模型、加速器 A/B 只在你的服務會用到時加入。
- RAM hot hit 和 SSD restore 是不同 tier(層),數據不要混在一起。
- 不同 Mac、量化與版本不可直接互比;你的可重跑 artifacts(驗收檔案)比別人的 tok/s 截圖更有價值。
想把 endpoint、Agent harness、eval 與部署驗收串成完整工程流程,可到 AlphaLab 軟體工程課程練習 System Design;更多本機 AI 與 Agent 教學則收在 AI 專區。
接著閱讀
左右滑動查看更多推薦
結語:今晚先跑一組「冷、熱、改、重啟」
回到開頭的公式:Prefix Cache 驗收=該命中的前綴有命中+不該命中的後綴沒有命中+答案仍然正確。今晚不必先追 128K;拿同一份約 8K fixture,依序保存 cold、warm、middle-edit、graceful-restart 四組 artifacts。四組都能解釋 cached tokens 的邊界、TTFT 的差異與答案雜湊,再往 32K、128K 和 production concurrency 擴大。這才是一台能陪本機 Agent 跑長 session 的 Mac,不是一張只在第一輪好看的 tok/s 截圖。






