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

MLX-LM 本機 API Server:OpenAI 相容介面、Prompt Cache 與 Tool Calling

·8 分鐘· loading · loading · ·
Mlx MLX-LM LLM OpenAI API Prompt Cache Tool Calling Apple-Silicon
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
科技觀點 - 本文屬於一個選集。
§ 24: 本文

featured

一. 前言:模型跑得動,還不等於應用程式接得上
#

你已經在 Mac 上把 MLX-LM 跑起來了。 CLI 可以回答,Python 腳本也能生成文字。 接著通常會冒出另一個問題:

  • Streamlit、FastAPI 或自製工具都要各寫一套載入模型的程式
  • 每個 process 都重新吃一份記憶體
  • 想換成支援 OpenAI SDK 的現成 client,介面卻接不起來
  • 長 system prompt 每次都重算,第一個 token 慢得很有感
  • 加了 tools,模型卻只回一段看似 JSON 的文字 這時候,與其讓每個 app 直接 import MLX-LM, 不如先把推論包成一個本機 HTTP service。 MLX-LM 內建 mlx_lm.server,提供 OpenAI-compatible 的 completions 與 chat completions 介面。 前端只要換 base_url,就能用熟悉的 client 呼叫 Mac 上的模型。 今天拍拍君會把範圍鎖在「服務化」:
  1. 啟動本機 server
  2. 用 curl 與 OpenAI Python client 測試
  3. 看懂 server 自動重用的 prompt cache
  4. 正確處理 streaming 與 usage
  5. 驗證 tool calling,而不是看到 JSON 就宣布成功
  6. 整理 concurrency、記憶體與網路暴露的邊界 如果你還沒跑過 MLX-LM,先看 MLX-LM 本地模型推論。 要做離線資料集評測,則看 MLX-LM 批次推論

二. 安裝與選模型
#

先建立一個乾淨環境:

mkdir mlx-api-lab
cd mlx-api-lab
uv init
uv add mlx-lm openai httpx

確認 server 指令存在:

uv run mlx_lm.server --help

模型可以是 Hugging Face repo,也可以是本機已轉換的 MLX 權重目錄。 第一次測試請選記憶體負擔較小、而且 tokenizer 有 chat template 的 instruct model。 以下用環境變數代替特定模型名稱:

export MLX_MODEL="mlx-community/your-instruct-model-4bit"

這不是故意裝神祕。 模型版本、授權與 chat template 會變,部署時本來就應該明確固定自己驗證過的 repo revision。

三. 啟動第一個本機 API Server
#

最小啟動方式:

uv run mlx_lm.server \
  --model "$MLX_MODEL" \
  --host 127.0.0.1 \
  --port 8080

預設綁在 127.0.0.1 是好事。 它只接受同一台電腦上的連線,不會直接把模型服務暴露給區網。 另開一個 terminal,先看 health endpoint:

curl --fail http://127.0.0.1:8080/health

再查看 models endpoint:

curl --silent http://127.0.0.1:8080/v1/models | python -m json.tool

這兩個檢查很重要。 process 還活著,不代表模型載入成功;port 能連,也不代表你指定的模型真的可用。

四. 先用 curl 測 /v1/chat/completions
#

先不要急著接 UI。 用最薄的一層 HTTP request,最容易看出問題在 server、payload 還是 client。

curl http://127.0.0.1:8080/v1/chat/completions \
  -H 'Content-Type: application/json' \
  -d "{
    \"model\": \"$MLX_MODEL\",
    \"messages\": [
      {\"role\": \"system\", \"content\": \"你是簡潔的技術助手。\"},
      {\"role\": \"user\", \"content\": \"用三點解釋 KV cache。\"}
    ],
    \"temperature\": 0.2,
    \"max_tokens\": 256
  }" | python -m json.tool

正常回應會有 choices、assistant message 與 usage。 如果 server 說 tokenizer 沒有 chat template,別亂補一段網路上抄來的 Jinja。 先確認:

  • 下載的是 instruct/chat model,而不是 base model
  • model repo 有沒有正確 tokenizer 設定
  • --chat-template 是否真的符合該模型
  • 模型作者是否要求額外的 template arguments chat template 錯了,HTTP 200 也救不了回答品質。

五. 用 OpenAI Python Client:只換 base_url
#

新增 client.py

import os
from openai import OpenAI
client = OpenAI(
    base_url="http://127.0.0.1:8080/v1",
    api_key="local-mlx",
)
model = os.environ["MLX_MODEL"]
response = client.chat.completions.create(
    model=model,
    messages=[
        {"role": "system", "content": "你是拍拍君的程式碼審查助手。"},
        {"role": "user", "content": "列出三個 Python API 重試陷阱。"},
    ],
    temperature=0.2,
    max_tokens=300,
)
print(response.choices[0].message.content)
print(response.usage)

執行:

uv run python client.py

api_key 在這個本機 server 範例裡只是讓 SDK 通過參數驗證, 不是 MLX-LM 幫你建立了驗證機制。 這個差異千萬別忘記:

OpenAI-compatible 描述的是 request/response 形狀,不等於完整重現雲端服務的驗證、配額與所有 API。

六. 把連線設定集中起來
#

不要把 URL 散落在每個 app 裡。 做一個小工廠函式就夠:

import os
from openai import OpenAI
def make_llm_client() -> OpenAI:
    return OpenAI(
        base_url=os.getenv("LLM_BASE_URL", "http://127.0.0.1:8080/v1"),
        api_key=os.getenv("LLM_API_KEY", "local-mlx"),
        timeout=60.0,
        max_retries=1,
    )

應用程式只依賴 OpenAI-compatible boundary。 之後切換測試 server、反向代理或其他 provider,修改環境變數即可。 但不要把「可以切換」誤解成「所有 provider 行為完全一樣」。 至少要測:

  • 支援哪些 request fields
  • streaming chunk 格式
  • usage 是否在串流尾端提供
  • tool call 的 arguments 是字串還是已解析物件
  • 錯誤狀態碼與 timeout 行為

七. Streaming:讓 UI 不必等完整答案
#

OpenAI client 的串流呼叫也能沿用:

stream = client.chat.completions.create(
    model=model,
    messages=[
        {"role": "user", "content": "解釋本機 LLM server 的三層架構。"},
    ],
    stream=True,
    max_tokens=300,
)
for chunk in stream:
    delta = chunk.choices[0].delta.content
    if delta:
        print(delta, end="", flush=True)
print()

實務上要同時處理:

  • chunk 可能沒有文字內容
  • 使用者取消時,要中止上游 request
  • UI disconnect 不應留下無限生成
  • 最後一個 chunk 與 usage 的存在方式要以實測版本為準 先把純 client 測通,再接 Streamlit 或 WebSocket。 不然畫面沒更新時,你會同時懷疑五層元件,debug 很沒效率。

八. Prompt Cache:server 已經在做什麼?
#

長 system prompt、固定文件前綴或重複對話開頭,都會花掉 prefill 時間。 MLX-LM server 會維護 LRU prompt cache,尋找與新 request 最接近的 token prefix。 相同前綴越長,可重用的 KV cache 越多。 回應的 usage 可能包含:

{
  "prompt_tokens": 1200,
  "completion_tokens": 80,
  "total_tokens": 1280,
  "prompt_tokens_details": {
    "cached_tokens": 1040
  }
}

注意,cache key 的核心是 token prefix,不是你心中覺得「語意差不多」。 下面兩個小改動都可能讓後段無法命中:

  • 每次把 timestamp 塞在 system prompt 最前面
  • 把使用者專屬資料放在共同規則之前 比較好的訊息順序是:
  1. 長且穩定的共同 system instructions
  2. 穩定的工具 schema
  3. session 內逐步增加的對話
  4. 容易變動的 request-specific context 這不只對 cache 友善,prompt 結構也更容易觀察。

九. 控制 Prompt Cache 的數量與記憶體
#

server 提供兩個重要參數:

uv run mlx_lm.server \
  --model "$MLX_MODEL" \
  --prompt-cache-size 8 \
  --prompt-cache-bytes 2GB \
  --port 8080

--prompt-cache-size 限制保存多少個 distinct KV caches。 --prompt-cache-bytes 則限制 cache 的總位元組數。 數字不是越大越好。 cache 會和模型權重、active generation、其他 app 一起競爭 unified memory。 調校時至少記錄:

  • 冷啟動第一個 token latency
  • 相同 prefix 第二次請求的 latency
  • cached_tokens
  • server process 的記憶體
  • 同時請求時是否開始 swap 如果只看第二次很快,卻沒發現整台 Mac 在換頁,那不叫最佳化,只叫把帳單藏起來。

十. Tool Calling:三層都要支援
#

OpenAI-compatible request 可以帶 tools

tools = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "查詢指定城市的天氣",
            "parameters": {
                "type": "object",
                "properties": {
                    "city": {"type": "string"},
                },
                "required": ["city"],
                "additionalProperties": False,
            },
        },
    }
]
response = client.chat.completions.create(
    model=model,
    messages=[{"role": "user", "content": "台北今天需要帶傘嗎?"}],
    tools=tools,
    max_tokens=200,
)
message = response.choices[0].message
print(message.tool_calls)

但成功的 tool calling 需要三層同時成立:

  1. 模型學過並能穩定產生工具呼叫格式
  2. tokenizer/chat template知道怎麼把 tools 放進 prompt
  3. server parser能把模型輸出解析成 tool_calls MLX-LM server 發現 tokenizer 不支援 tool calling 時會提出警告。 這時不要只靠 prompt 要模型「輸出 JSON」。 普通 JSON 文字與結構化 tool_calls 不一樣:
  • client 不一定能識別
  • arguments 可能截斷或格式錯誤
  • tool result 無法用正確 role 接回對話
  • 不同模型的特殊 token 規則可能完全不同

十一. 實際執行工具:模型不能直接碰你的系統
#

收到 tool call 後,應用程式才負責驗證與執行:

import json
ALLOWED_CITIES = {"台北", "台中", "高雄"}
def get_weather(city: str) -> dict[str, str]:
    if city not in ALLOWED_CITIES:
        raise ValueError("unsupported city")
    return {"city": city, "forecast": "短暫陣雨"}
for call in message.tool_calls or []:
    if call.function.name != "get_weather":
        raise ValueError("tool not allowed")
    arguments = json.loads(call.function.arguments)
    result = get_weather(city=arguments["city"])
    print(result)

真實服務還要補上 schema validation、timeout、權限、audit log 與輸出大小限制。 永遠不要把模型產生的 command 直接丟進 shell。 tool calling 是「模型提出結構化請求」,不是「模型取得任意執行權」。

十二. Tool Result 要放回對話
#

完整流程不是拿到函式名稱就結束:

messages = [{"role": "user", "content": "台北今天需要帶傘嗎?"}]
messages.append(message.model_dump(exclude_none=True))
messages.append(
    {
        "role": "tool",
        "tool_call_id": message.tool_calls[0].id,
        "content": json.dumps(result, ensure_ascii=False),
    }
)
final = client.chat.completions.create(
    model=model,
    messages=messages,
    tools=tools,
    max_tokens=200,
)
print(final.choices[0].message.content)

這段能不能順利運作,仍取決於模型與 template 是否理解 tool role。 所以部署前要用固定測試集驗證:

  • 應該呼叫工具的問題
  • 不應該呼叫工具的問題
  • 缺少必填參數
  • 多個工具名稱很相似
  • 工具回傳錯誤
  • arguments 被截斷

十三. Concurrency:先測 workload,不要只調大數字
#

server 有 prompt 與 decode concurrency 相關參數。 較高 concurrency 能增加吞吐量,卻不保證單一 request 更快。 本機互動工具通常在意 latency; 批次 API 可能更在意每分鐘完成多少 request。 簡單壓測可用 httpx.AsyncClient 搭配 asyncio.gather(),同時送出固定 prompt;記得為 client 設定 timeout,並對每個回應呼叫 raise_for_status()。 請同時看 p50/p95 latency、吞吐量、記憶體與錯誤率。 只拿一個 request 的秒數,無法決定 concurrency 設定。

十四. 錯誤處理與 Readiness
#

應用程式啟動時,不要假設 server 一定已經 ready。 readiness check 應以短 timeout 依序請求 /health/v1/models,兩者都成功且 model list 非空才放行流量。 至少區分:

  • connection refused:process 沒啟動或 port 錯
  • timeout:模型太慢、記憶體壓力或 request 卡住
  • HTTP 4xx:payload、model id、template 或 unsupported field
  • HTTP 5xx:server 內部錯誤
  • HTTP 200 但內容怪:模型能力、template 或 sampling 問題 不同類型應有不同告警,別全部包成一句「LLM 壞掉了」。

十五. 不要直接暴露到 0.0.0.0
#

把 host 改成 0.0.0.0 很容易,安全責任也會一起出現。 內建 server 適合本機開發與受控環境。 若要讓其他裝置呼叫,至少在前面加一層有明確政策的 gateway:

  • TLS
  • API authentication
  • request size limit
  • rate limit
  • timeout 與 concurrency limit
  • audit log
  • 可呼叫模型與 tools 的 allowlist 也不要把 CORS 當成 authentication。 CORS 主要限制瀏覽器行為,不能阻止任意 HTTP client 連線。 最保守的架構通常是:
App -> authenticated gateway -> 127.0.0.1:8080 MLX-LM server

如果只在同一台 Mac 使用,就維持 loopback,事情會簡單很多。

十六. 一份可維護的啟動設定
#

開發環境可以用明確參數固定行為:

uv run mlx_lm.server \
  --model "$MLX_MODEL" \
  --host 127.0.0.1 \
  --port 8080 \
  --max-tokens 512 \
  --temp 0.0 \
  --prompt-cache-size 8 \
  --prompt-cache-bytes 2GB \
  --log-level INFO

然後把這些資訊寫進 README:

  • 驗證過的 MLX-LM 版本
  • 模型 repo 與 revision
  • 記憶體需求
  • 支援的 endpoints 與 fields
  • tool calling 測試結果
  • health/readiness 檢查方式
  • gateway 與網路邊界 「OpenAI-compatible」不該成為省略相容性文件的理由。 它只是讓接線更容易,仍需要你定義服務契約。

十七. 上線前檢查清單
#

  • /health 成功
  • /v1/models 回傳預期模型
  • 非串流 chat completion 通過
  • streaming 可取消,不留下背景生成
  • 冷、熱 prompt latency 都有紀錄
  • cached_tokens 與記憶體行為符合預期
  • tool calling 模型、template、parser 三層都測過
  • tool arguments 有 schema validation
  • server 維持 loopback,或前方已有驗證 gateway
  • timeout、request size 與 concurrency 有上限
  • client 與 server 版本已固定

結語:把本機模型變成清楚的服務邊界
#

MLX-LM server 最實用的地方,不只是多了一個 HTTP endpoint。 它讓模型生命週期集中在一個 process, 讓 app 透過熟悉的 OpenAI-compatible client 接線, 也讓 prompt cache、tool calling、並發與錯誤處理有明確的觀察位置。 拍拍君最推薦的起手式很樸素:

  1. 綁定 127.0.0.1
  2. 用 curl 驗證 health、models 與 chat
  3. 再換 OpenAI client
  4. 用兩次相同長前綴確認 cache
  5. 用固定案例測 tool calling
  6. 最後才接 UI 或開放遠端使用 模型會換,SDK 會更新,相容欄位也可能增加。 但只要 HTTP boundary、測試與安全邊界清楚,應用程式就不必跟著每次推論細節一起翻修。

延伸閱讀
#

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

相關文章

MLX-LM 批次推論實戰:Prompt Template、抽樣參數與本機評測流程
·9 分鐘· loading · loading
Mlx MLX-LM LLM Batch Inference Apple-Silicon Local AI
MLX-LM 實戰:在 Apple Silicon 上跑本地模型推論
·9 分鐘· loading · loading
Mlx MLX-LM LLM Apple-Silicon Python Local AI
MLX-LM LoRA 微調入門:Adapter、資料格式與 Apple Silicon 本機評測
·13 分鐘· loading · loading
Mlx MLX-LM LoRA Fine-Tuning Apple-Silicon 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