一. 前言:模型跑得動,還不等於應用程式接得上 #
你已經在 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 上的模型。 今天拍拍君會把範圍鎖在「服務化」:
- 啟動本機 server
- 用 curl 與 OpenAI Python client 測試
- 看懂 server 自動重用的 prompt cache
- 正確處理 streaming 與 usage
- 驗證 tool calling,而不是看到 JSON 就宣布成功
- 整理 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 最前面
- 把使用者專屬資料放在共同規則之前 比較好的訊息順序是:
- 長且穩定的共同 system instructions
- 穩定的工具 schema
- session 內逐步增加的對話
- 容易變動的 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 需要三層同時成立:
- 模型學過並能穩定產生工具呼叫格式
- tokenizer/chat template知道怎麼把 tools 放進 prompt
- server parser能把模型輸出解析成
tool_callsMLX-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、並發與錯誤處理有明確的觀察位置。 拍拍君最推薦的起手式很樸素:
- 綁定
127.0.0.1 - 用 curl 驗證 health、models 與 chat
- 再換 OpenAI client
- 用兩次相同長前綴確認 cache
- 用固定案例測 tool calling
- 最後才接 UI 或開放遠端使用 模型會換,SDK 會更新,相容欄位也可能增加。 但只要 HTTP boundary、測試與安全邊界清楚,應用程式就不必跟著每次推論細節一起翻修。