跳到主要內容

【2026 最新】oMLX 長對話驗收怎麼做?Mac Agent 的 Prefix Cache 6 關教學

最後更新: ·
oMLX 長對話驗收與 Prefix Cache 6 關教學首圖

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 推論引擎新手指南

oMLX 長對話請求先比對前綴,再走 RAM hot cache、SSD restore 或 cache miss prefill 的流程圖
同一個 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_loadssaves_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。

oMLX Admin Resource Management 顯示 hot RAM cache 與 cold SSD cache 容量控制的官方介面
oMLX Admin 將 hot RAM 與 cold SSD 分開顯示;畫面是官方示例,不是本文建議容量。圖/oMLX

第 2 關:用同一批 8K/32K/128K 累積素材

準備三個純文字 fixture(固定測試檔):context-32k.txt 必須逐字以 context-8k.txt 開頭,context-128k.txt 又必須逐字以前者開頭。最好取同一個固定 commit 的程式碼、工具 schema 與操作紀錄,不要用一段字重複十萬次;在約 4K、20K、96K 深度分別放入 EARLY=CEDAR-04MIDDLE=BLUE-17LATE=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_URLMODEL_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_BASECACHE_DIR、版本、模型與 flags 重啟。不要重跑建立 LAB_BASEmktemp 那幾行;在原 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 驗收框架

oMLX 長對話六關驗收矩陣,列出 exact repeat、append-only、中段改寫、重啟、模型切換與 MTP 的通過和失敗標準
Exact repeat、append-only、中段改寫、graceful restart 是單模型核心;多模型服務才加切模型,啟用加速器才加 MTP/kernel。任何適用關卡失敗,都要保留 request、SSE、log 與 cache counters 再定位。

最常踩的 6 個坑

  1. 把 decode tok/s 當互動速度:長 prompt 常由 prefill 與 restore 主導,請先看 TTFT。
  2. 把第一個 model load 混入 cold:先載入一次,再開始可比較的 cold/warm pair。
  3. 每輪偷偷改前綴:時間戳、token budget、工具順序放在前端,會讓後面全部失效。
  4. 重啟太快:正常停止、等程序完全退出,確認 flush 無錯且檔案穩定後再重啟。
  5. 一次開滿加速器:MTP、DFlash、SpecPrefill、TurboQuant 各自先做 A/B,否則 fallback 或衝突很難定位。
  6. 忽略溫度與電源:接電、固定風扇/環境並記錄 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 個重點

  1. 先固定版本、模型與 prompt bytes,再談比較。
  2. cached_tokens 找命中量,用 TTFT 看體感,用答案 marker 驗正確。
  3. Exact repeat、append-only、中段改寫與重啟是核心;切模型、加速器 A/B 只在你的服務會用到時加入。
  4. RAM hot hit 和 SSD restore 是不同 tier(層),數據不要混在一起。
  5. 不同 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 截圖。

ALPHALAB 社群

有問題?來 Telegram 聊

和 Terry、編輯、其他網友一起討論這篇文章。提問、分享觀點,回覆更即時。

加入 Telegram 討論

📩 訂閱 AlphaLab 電子報

每週最多兩封,收到週報精選與關鍵 Alpha Signal。

我們不會 spam,隨時可退訂。