模型上線:
從 pyfunc 到 REST API
Registry 裡有一個 models:/churn-clf@champion 了,然後呢?模型待在 Registry 裡不會替公司賺到一塊錢—— 它要被「用」,才叫上線。而「上線」有三種形態,成本差好幾個數量級。先玩最貴的那一種: 一台 mlflow models serve 起來的伺服器,你送什麼、它回什麼——
狀態碼、JSON 與錯誤訊息都是 notebook 的實測輸出(MLflow 3.15.2);毫秒是同一台機器上多次量測的範圍, 你自己跑會不一樣——看倍數,不要看絕對值。
上線不是一件事,是三個選擇
很多人一聽到「模型上線」就開始寫 REST API——那是最貴的一種,而且常常沒必要。先問一個問題:答案什麼時候需要?
| 批次評分 | 線上 API | 嵌入式 | |
|---|---|---|---|
| 答案什麼時候要 | 明天早上就好 | 這一秒 | 這一秒,而且不能連網 |
| 怎麼跑 | 排程跑一支腳本,結果寫回資料庫 | 一台一直開著的伺服器收 HTTP 請求 | 模型跟著 App 發佈,同一個行程裡呼叫 |
| 一列的成本 | 最低(實測每列約 0.02 ms) | 高(實測每筆 14–36 ms) | 最低(沒有網路) |
| 要維運什麼 | 一個排程 | 伺服器、擴縮、健康檢查、監控、版本切換 | App 的發版流程 |
| 換模型多快 | 下一次排程就生效 | 一行 alias + 重載 | 要等使用者更新 App |
| 典型場景 | 每日流失名單、隔夜信用評分 | 交易反詐、即時定價、對話系統 | 手機相機特效、離線裝置、資料庫 UDF |
三種不互斥——真實系統常常是「批次算好大部分,線上 API 只補算新客戶」。 判準只有一條:如果「昨天算好的答案」就夠用,就別為了即時性去付一台伺服器 24 小時的錢; 那台伺服器要監控、要擴縮、要值班,而排程壞掉只是明天的報表晚一點。
到 notebook 的 1️⃣ 節:三種形態的完整對照signature、input_example、pyfunc_predict_fn:為了上線而存在的三樣東西
signature 上一課擋掉了「少一欄」的輸入;這一課它更狠——mlflow models serve 會把它 直接變成 REST API 的輸入驗證:少一欄、型別錯的請求根本進不到模型,伺服器回 400 並指名哪裡錯。你一行驗證程式都不用寫。
input_example 會讓模型資料夾多一個 serving_input_example.json——那是一份 可以直接 POST 的 payload(信封就是 dataframe_split)。實測 champion 那一版的資料夾共 8 個檔案: MLmodel、model.skops、conda.yaml、python_env.yaml、 requirements.txt、input_example.json、serving_input_example.json、registered_model_meta。
pyfunc_predict_fn:pyfunc 是部署工具唯一認得的介面,但它只有一個 predict,而 sklearn 分類器的 predict 回類別(0/1)。流失預測要的是機率——這個參數就是在說「被當成 pyfunc 呼叫時,請去叫 predict_proba」。 之後不管是 pyfunc.predict() 還是 /invocations,每列都回兩個數字 [P(不流失), P(流失)]。
到 notebook 的 2️⃣ 節:三個參數與模型資料夾最便宜的上線方式,只有三行
沒有伺服器、沒有 API、沒有健康檢查——一支腳本掛到排程上就上線了;模型換版?下一次排程自動載到新的 @champion。這是維運成本最低的一種上線。
| 怎麼餵 | 總耗時 | 每列成本 |
|---|---|---|
| 一次 500 列 | 約 9–12 ms | 約 0.02 ms |
| 一次 1 列(跑 50 次取平均) | — | 約 7–11 ms |
| 同一個模型、同一台機器,每列成本差 300–500 倍(多次量測);load_model 本身約 100–200 ms。 | ||
為什麼?推論的固定開銷(建 DataFrame、schema 檢查、走訪 100 棵樹的 Python 呼叫)幾乎跟列數無關, 一次算越多列就攤得越薄。這就是批次便宜的全部原因——反過來說,一次只算十列的「批次」,跟線上 API 差不了多少。
到 notebook 的 3️⃣ 節:批次計時、寫回 CSV模型載一次,請求只做推論
自己包的好處是介面完全照你的規矩:欄位名稱、回傳格式、認證、記錄都自己決定。 伺服器用 uvicorn 跑在背景執行緒,port 現跟作業系統要一個(寫死 port 的下場是 OSError(98, 'Address already in use'))。
這裡有一個新手最常犯、而且上線之後才會痛的錯:把 load_model 寫進 handler 裡面。 看起來很合理(「這樣就永遠是最新的模型」),但每一筆請求都要重新讀檔、反序列化、重建模型物件。實測同一台機器、同一個模型:
| 端點 | 差別 | 單筆延遲(多次量測的範圍) |
|---|---|---|
| /predict | 模型載一次 | 約 14–36 ms(中位 14–33 ms) |
| /predict_slow | 每次請求都 load_model | 約 120–310 ms(中位 122–297 ms) |
同樣的答案,延遲差 8–10 倍——而且這台機器沒有別的負載;正式環境同時有幾十個請求進來時差距只會更大, 因為每個請求都在重複做同一件昂貴的事,還互相搶 CPU 與磁碟。「換版怎麼辦」是第 6 節的題目,不是把 load_model 搬進 handler 的理由。
到 notebook 的 4️⃣ 節:兩個端點並排實測+對數刻度成本圖不寫服務程式的另一條路
自己包很自由,但也代表每一個模型都要有人寫一支服務程式。MLflow 內建的伺服器一行指令就把 Registry 裡的模型變成 REST API, 實測從下指令到 /ping 回 200 約 7–15 秒(載模型、建 app、起 WSGI 伺服器)—— 所以正式環境不要靠重啟來換模型。
--env-manager local 是「直接用目前這個 Python 環境」。不加的話 MLflow 會照模型資料夾裡的 requirements.txt 建一個乾淨的虛擬環境再跑——那才是正式部署該做的(環境跟著模型走, 不會因為這台機器裝了別的版本而算錯),代價是啟動要多花好幾分鐘。
/invocations 不吃裸的 JSON 陣列,一定要有信封告訴它形狀。四個名字擇一: dataframe_split(欄名與資料分開,最省頻寬)、dataframe_records(每列一個物件,最好讀)、 inputs(欄名對值清單)、instances(純 2D 陣列——這個模型不吃,因為 signature 要欄名)。 實測 dataframe_split 帶不帶 index 都回 200,慣例是拿掉。
看懂這兩段,就懂了這條路的價值:signature 變成了 API 的輸入驗證,呼叫端少送一欄在進到模型之前就被擋下, 訊息還直接指名缺哪一欄;連信封放錯都講得清清楚楚(那四個名字的順序每次不同,因為它是 Python 的 set)。 自己包的 FastAPI 要達到同樣品質,schema、400、錯誤訊息都得自己寫。這就是取捨:內建伺服器給你標準與嚴謹,自包給你自由。
最後別忘了收:子行程不會跟著 notebook 結束,terminate() + communicate(timeout=15) 送出結束訊號並等它真的走掉, 不然它會一直占著那個 port 跑下去。
到 notebook 的 5️⃣ 節:六種請求一次打完、看伺服器怎麼回alias 移了,跑著的 API 什麼時候才知道?
上一課說「晉升=把 champion 移到新版本,服務程式一行不用改」。這句話有一個沒說出口的前提: 服務程式要重新載入模型,才會看到新的 alias。因為第 4 節那條規則——模型在啟動時載入一次—— 跑著的行程裡是一個已經載好的物件,它不會因為資料庫裡一列 alias 改了就自己變身。 實測:v3 註冊、alias 移過去之後,/health 照樣回舊版本、/predict 照樣回舊機率。
| 做法 | 怎麼觸發 | 停機 | 適合 |
|---|---|---|---|
| 重啟服務 | 部署流程重跑(mlflow models serve 只能這樣) | 有(起一次 7–15 秒) | 多台輪流更新時可接受 |
| 主動觸發 POST /reload | 晉升流程的最後一步去打它 | 無 | 自己包的 API、換版時機明確 |
| 定時輪詢 | 背景每 N 秒問 Registry,版本變了才重載 | 無 | 多台機器、不想讓晉升流程知道有誰在跑 |
那個 if 很重要:重載期間行程會多吃一份記憶體、還有幾百毫秒的延遲尖峰,版本沒變就不該白做。 另外兩個實務細節:換版要留紀錄(哪一秒從 v2 換到 v3,之後查指標異常時第一個要對的就是它); 回滾走同一條路(alias 指回去、再打一次 /reload),所以這條路平常就要是通的。 順帶一提,get_model_version_by_alias(...).version 回的是 int 不是字串——拿去跟 "3" 比對會永遠不相等,這種靜默的比較失敗最難查。
到 notebook 的 6️⃣ 節:晉升 → 不重載 → /reload 的完整過程檢查清單,與上線之後該記什麼
模型能載入 ≠ 模型能上線。四件事,每一件都是有人半夜被叫起來換來的: ① 用模型自己帶的 serving_input_example.json 跑 validate_serving_input——別讓 400 在正式環境才出現; ② 用同一份 payload 打一次真的服務(「模型能載入」跟「服務能回應」中間還隔著 HTTP 與 JSON 序列化); ③ 對答案:同一批輸入,批次算的機率要跟 API 回的一樣,不一樣就是線上/離線前處理不同步 (陷阱:對答案前要重新載入 champion,拿換版前那份舊模型去比,只會得到一個假的「不一致」警報); ④ 版本要看得見:/health 回目前模型版本,不然出事時你連「當時線上是哪一版」都答不出來。
| 上線後記什麼 | 為什麼 | 出事時的樣子 |
|---|---|---|
| 延遲 p50 / p95 / p99 | 平均值會騙人 | 平均 20 ms 很漂亮,p99 是 3 秒 |
| 錯誤率(依狀態碼分) | 400 跟 500 是完全不同的故障 | 400 暴增=上游資料格式變了;500 暴增=服務壞了 |
| 輸入分佈 | 資料會漂移,沒人會通知你 | 模型還在回答,只是越答越不準 |
| 預測分佈 | 最省事的早期警報 | 判為流失的比例從 5% 跳到 30% |
前兩類是軟體維運,任何 API 都要有;後兩類是機器學習特有的——模型不會拋例外,它只會安靜地越答越爛。 這正是下一課「模型監控」要處理的事。
到 notebook 的 7️⃣–8️⃣ 節:三項冒煙檢查+自己拉桿量成本換你動手
把 /health 加上「模型是什麼時候載進來的」與「已經服務幾筆請求」——線上排查時這兩個數字幾乎每次都會用到。
改用 dataframe_records 信封重打 /invocations,並故意少送一欄,把狀態碼與錯誤裡的 error_class 印出來。
不用真的 build,自己手寫 mlflow models build-docker 會產出的那份 Dockerfile:裝什麼、模型檔怎麼進去、CMD 怎麼寫、健康檢查指到哪。想想「環境跟著模型走」具體是哪一行。
卡住了?每一題在 notebook 末節都有折疊解答——先自己做,再打開對照。
情境測驗
離開前試試看:下面的情境都真的會遇到。每題選一個你認為的最佳做法,選了馬上看得到解釋。
Q1 情境題
客服部門要一份「今天最該打電話的 200 位流失高風險客戶」名單,每天早上九點看;資料半夜三點就備妥。團隊提議先寫一個 REST API 給客服系統即時查。最佳做法是?
需求裡的關鍵字是「每天早上九點看」——答案不需要即時,那就別去付一台伺服器 24 小時的錢。批次評分只是一支腳本加一個排程:沒有擴縮、沒有健康檢查、沒有值班,換模型下一次排程自動生效;而且每列成本實測差約 300 倍(一次 500 列每列約 0.02 ms,一次一列每列約 7 ms)。A 能動,但為了一個不需要即時的需求引進了伺服器、監控、版本切換一整套維運。C 最糟:既付了伺服器的代價,又用一列一筆的方式打,把批次的成本優勢全丟掉。D 讓每台電腦各自載模型,換版時要等所有客戶端更新,而且模型檔散在各處。
Q2 錯誤診斷
同事把原本打自家 FastAPI 的程式(requests.post(url, json=rows),rows 是一串 dict)直接指向新起的 mlflow models serve,每次都回 400。最直接的修法是?
訊息把答案寫在臉上:「必須是一個 JSON 物件,而且剛好含有這四個欄位之一」,「收到的是一個 list」。/invocations 不吃裸陣列,一定要有信封說明形狀——這串 dict 的形狀正好對應 dataframe_records(dataframe_split/inputs 是另外兩種寫法,instances 沒有欄名,這個模型的 signature 不吃)。順帶一提,那四個名字的順序每次執行都不同,因為它是 Python 的 set,別把順序當成規格。A 完全沒有根據,訊息連 schema 都還沒檢查到;C 若真的沒帶 Content-Type,錯誤會長得完全不一樣,而且 requests 的 json= 會自動帶;D 是把自家 API 的習慣套上來,/predict 是第 4 節自己包的那台才有的端點。
Q3 錯誤診斷
推論服務上線後延遲從 15 ms 掉到 300 ms 上下,錯誤率是 0、機器負載也不高。這次改動只動了這一段。最可能的原因與修法?
把 load_model 搬進 handler,等於每一筆請求都要重新讀檔、反序列化、重建模型物件;實測同一台機器、同一個模型,載一次是 15–35 ms,每次都載是 120–310 ms,差 8–10 倍——正好對得上症狀(延遲整體上移、錯誤率 0、負載不高,因為瓶頸是每次請求的固定成本而不是流量)。註解裡那個動機是真的需求,但解法不是這個:模型啟動時載一次,換版由 /reload(或定時輪詢版本)處理。A 換模型只會讓推論那幾毫秒變快,占大頭的載入成本一點都沒少;B 多開 worker 只是讓更多份重複的昂貴工作平行做,每台還各自吃一份記憶體;D 序列化在這裡是幾百微秒等級的事,不可能造成 300 ms。
Q4 情境題
晉升流程剛把 champion 從 v2 移到 v3,Registry 查起來確實是 v3。但線上服務的 /health 還是回 "model_version": 2,預測值也沒變。你該怎麼處理?
「alias 一行切換」講的是下一次載入會拿到誰;跑著的行程裡是一個早就載好的物件,資料庫改一列不會讓它變身。所以換版流程是兩步:移 alias、再讓服務重載(主動打 /reload、背景輪詢版本、或重啟)——而 /health 回版本號正是為了讓你能確認第二步做完了。B 症狀不符,Registry 已經查到是 v3;C 是把換版問題丟給每一筆請求付錢,延遲會變 8–10 倍;D 很危險:刪版本破壞了可回滾性,而且服務手上那個物件不會因此改變,只會讓你連回滾的路都沒了。
Q5 情境題
模型明天要上線。你只有半小時做部署前檢查,想用最少的步驟涵蓋最多的失敗模式。最有效的一組是?
這三步剛好覆蓋三層不同的失敗:validate_serving_input 驗「反序列化+schema+模型」這條路(不用起伺服器,最快);打真的服務多驗了 HTTP 與 JSON 序列化那一層;跟批次結果對答案則抓「線上/離線前處理不同步」——那是機率靜靜算錯、卻沒有任何錯誤訊息的一種故障。而且測資不用自己編,input_example 已經幫你生成現成的 payload。A 是讀檔案,讀對了也不代表跑得起來;B 把驗證成本轉嫁給真實客戶,而且流失機率算錯不會噴錯,小流量觀察半小時多半看不出來;C 驗的是模型品質(訓練時就驗過了),跟「能不能被服務起來」是兩件事。
實作在 molab 跑(免費)
molab 的登入狀態進不了內嵌框架(瀏覽器的跨站 cookie 保護), 所以 notebook 要在新分頁執行——把它跟本頁並排開,左邊教學照樣對照。
- 登入 molab(GitHub / Google)
- 開啟課程 notebook,Fork 成自己的副本即可編輯
- 從第一格往下全部執行(首次安裝套件約 1–2 分鐘)——免費 CPU 環境即可,不需要 GPU;伺服器都起在 notebook 自己的機器上,不對外
不想用 molab?下載 model-serving_ext.py 後在自己電腦
uvx marimo edit --sandbox model-serving_ext.py,依賴會自動安裝。
molab 的線上編輯器在手機上體驗有限——動手這一段建議用電腦進行。