MLflow Tracing:
LLM 應用的每一步都留下軌跡
客服機器人回了一句「可以分期,通常提供 3 期與 6 期免利息。」,客戶照做了,然後投訴。 你手上只有這句話——是檢索沒撈到文件?prompt 沒把文件放進去?還是模型自己編的? 點下面任何一個問題,把中間那段點亮:
點任何一列展開它的 inputs/outputs 原文。
六條 trace 都是 notebook 的實測紀錄(MLflow 3.15.2,不打真的 LLM):trace id、標籤、每個 span 的 inputs/outputs 全是原文。毫秒是單次量測——這一課的耗時來自刻意加上的模擬延遲,你自己跑會不一樣。
訓練記「一次訓練」,上線記「一次請求」
第 1 課的 run 是訓練的紀錄簿:一次訓練一筆,裡面有 params、metrics、artifacts, 好壞用一個 AUC 說了算。那套東西到了 LLM 應用就整個不夠用—— 因為你要記的不是「今天訓了一個模型」,而是今天有八萬個人問了問題。
| run(第 1 課) | trace(這一課) | |
|---|---|---|
| 記錄的單位 | 一次訓練 | 一次請求 |
| 一天幾筆 | 幾十筆 | 幾十萬筆 |
| 裡面有什麼 | params / metrics / artifacts | span 樹:每一步的 inputs、outputs、耗時、屬性 |
| 好壞怎麼判斷 | 一個 AUC 說了算 | 沒有標準答案——要人標、要 scorer 打分 |
| 版本控制什麼 | 模型(Registry) | prompt(Prompt Registry) |
| 出事時你要回答 | 當時的參數是什麼 | 當時檢索到什麼、prompt 長什麼樣、用的哪一版 |
span 就是「一步」:一次檢索、一次模型呼叫、一次工具呼叫。 一次請求裡的每一步各是一個 span,串起來就是一棵樹——這就是 trace。 你原本的 log 也記得到這些,前提是你想得到要記、而且記得下次也要記; tracing 的價值在於它是預設就記完整的,而且結構化到可以搜尋、可以比較、可以打分。
到 notebook 的 0️⃣ 節:假 LLM 客服與三條知識庫三個裝飾器,一棵樹
要被記錄,只要加一行 @mlflow.trace。 你不用宣告誰是誰的父節點——answer() 呼叫了另外兩個函式, MLflow 就把它們變成巢狀 span,呼叫關係就是樹的形狀。 函式的參數自動變成 span 的 inputs、回傳值自動變成 outputs,你一個字都不用寫。
然後是這一課最容易踩的一個坑:trace 是非同步寫入的(不然每個請求都要等資料庫寫完才能回應)。 在 notebook 或測試腳本裡「跑完馬上查」,你查到幾筆是不一定的,而且沒有任何錯誤訊息:
背景執行緒可能剛好寫完一部分,所以結果是隨機的——「有時會過、有時不會」的測試比直接壞掉更難查。 正式服務不需要 flush(背景執行緒自己會寫完),它是給「跑完馬上要查」的場景用的。 另外注意參數名是 experiment_ids(複數、要 id)—— 寫成 experiment_names 會直接 TypeError: … Did you mean 'experiment_ids'?。
search_traces() 回一張 12 欄的 DataFrame(一列一條 trace, 有 trace_id、state、execution_duration、 request、response、tags…), 要看樹則用 mlflow.get_trace(trace_id): .info 是整條的資訊,.data.spans 是每一步。
到 notebook 的 1️⃣–2️⃣ 節:跑三題、把 span 樹畫出來那個字串不影響執行,但決定三件事
span_type="RETRIEVER" 不會改變任何計算結果,很容易讓人覺得可有可無。它決定的是:
| 它影響什麼 | 標對了 | 標錯(或沒標) |
|---|---|---|
| MLflow UI 怎麼畫這一步 | RETRIEVER 畫成「找到哪幾份文件」的清單,LLM 畫成對話框並顯示 token 數 | 畫成一坨看不懂的 JSON |
| 內建 scorer 找不找得到料 | 「檢索到的文件跟問題有沒有關係」這類評分器靠 RETRIEVER 找文件 | 評分器找不到文件,靜靜跳過 |
| 你自己的查詢與統計 | 「所有 LLM span 的總 token」「TOOL span 的失敗率」 | 分不了組 |
mlflow.entities.SpanType 提供 15 個常數(3.15.2 實測): CHAIN、LLM、CHAT_MODEL、 RETRIEVER、TOOL、AGENT、 EMBEDDING、RERANKER、PARSER、 GUARDRAIL、EVALUATOR、MEMORY、 TASK、WORKFLOW、UNKNOWN。 傳字串或傳常數都可以,值一樣。
這個欄位沒有校驗——span_type="RETREIVER" 打錯字不會報錯, 程式照跑、trace 照存,只是 UI 不知道怎麼畫、scorer 當作沒看到。這是最沉默的那種錯, 所以能用常數就別打字串。
到 notebook 的 3️⃣ 節:15 種 span_type 與它們的用途一天幾十萬條,你需要能篩
MLflow 給你兩個掛東西的地方,很多人混用,但用途完全不同:
| span.set_attributes() | update_current_trace(tags=) | |
|---|---|---|
| 掛在哪 | 單一 span | 整條 trace |
| 典型內容 | n_docs、top_k、temperature | topic、user_tier、prompt_ver、session_id |
| 能拿來搜尋嗎 | 不能(要撈回來自己看) | 能 |
| 什麼時候用 | 事後想「那一步當時是什麼設定」 | 事前想「之後我要用什麼條件撈出一群請求」 |
一句話判準:想用它「找出一群 trace」就放 tag;想用它「解釋某一步」就放 attribute。 這個選擇在寫程式的當下不痛不癢,但等到出事、你想撈「所有 VIP 客戶問退貨而且答錯的請求」時, 當初放錯地方的欄位就撈不出來了。
搜尋語法跟第 1 課的 search_runs 是同一套解析器(同樣的坑會再踩一次)。 有一條分界線值得先記起來:「點」前面那一段(entity type)MLflow 會驗,「點」後面那一段(key)不會—— 所以打錯前綴會被罵,打錯 tag 名字只會靜靜回 0 筆。全部是實測原文:
前四個會叫你,第五個不會。查不到東西的時候,先懷疑自己的 filter,不要先懷疑資料。 兩個閱讀提示:那份合法清單裡 tag 與 tags 都在, 兩種寫法都能用——別把少一個 s 當成 bug;還有大括號裡的順序每次執行都不一樣(那是 Python 的 set), 上面是某一次的輸出、也做了截斷,別把順序或長度當成規格。 同一組陷阱還有兩個孿生兄弟:get_current_active_span() 在 trace 外面呼叫回 None(接著 .set_attributes() 就是 AttributeError);update_current_trace() 在 trace 外面呼叫 什麼都不會發生也不會報錯——標籤靜靜地掉了。
不是每個 span 都來自裝飾器:一段 for 迴圈、一個批次流程沒有函式可以掛,就用 with mlflow.start_span(name=…, span_type=…) as s 手動開一個, 自己呼叫 s.set_inputs() / s.set_outputs() ——裝飾器做的其實就是幫你自動抓參數與回傳值而已。
順手就能拿到的第二個好處是延遲分析。「這個請求要 3 秒」是抱怨, 「這 3 秒有 2.7 秒在等模型」才是可以動手的情報,而 trace 天生就有這份資料。 本課實測(耗時是刻意加上的模擬延遲:檢索 20 毫秒、生成 50 毫秒):
| 問題 | 總計 | retrieve | fake_llm | LLM 佔比 |
|---|---|---|---|---|
| 退貨要多久內?(第一筆) | 130–220 ms | 約 20 ms | 約 50 ms | 23–38% |
| 運費怎麼算? | 71–73 ms | 約 20 ms | 約 50 ms | 約 70% |
| 可以分期嗎? | 71–92 ms | 約 20–41 ms | 約 50 ms | 55–71% |
第一筆的總計時間明顯偏高,那是第一次呼叫時 tracing 自己的暖機成本,不是你的程式慢。 看延遲永遠要看分佈(p50/p95),不要看單筆、更不要看第一筆。 真實系統裡這張表的比例會更極端:生成通常是幾百毫秒到幾秒,檢索是幾十毫秒。
到 notebook 的 4️⃣–5️⃣ 節:六種 filter 的命中數+延遲拆解沒有 AUC 的世界:把人的判斷寫回 trace
訓練模型時有標準答案,AUC 一個數字說了算。LLM 應用沒有這種東西—— 「這個回答好不好」要人看了才知道。MLflow 的做法是把人的判斷掛回那條 trace,叫 assessment:
| log_feedback | log_expectation | |
|---|---|---|
| 記什麼 | 這次的回答好不好 | 這一題正確答案應該是什麼 |
| 誰給的 | 人工標記、線上的讚/倒讚、LLM judge | 人工(領域專家) |
| 本課實測 | correct = False,理由寫在 rationale | expected_answer = "手冊裡沒有寫。" |
AssessmentSource 要寫清楚是誰標的(HUMAN / LLM_JUDGE / CODE)——之後要分「人標的」跟「機器標的」全靠它。 讀回來用 get_trace(tid).info.assessments,但要重新讀一次: assessment 是掛上去之後才存在的,手上那個舊的 trace 物件不會自己更新。
這件事的意義比它看起來大得多:一批被標記過的 trace,就是你的評估資料集。 你不用另外維護一份 CSV、不用請人編題目——線上真實流量本身就是題庫, 出問題的那幾條標一標,下次改 prompt 就有回歸測試可以跑。 這也是為什麼上一節那些 tag 那麼重要:你要先撈得出「該標的那一群」,才標得動。
實測會踩到的一個錯誤:log_feedback 給一個不存在的 trace_id 會噴 MlflowException: Trace with ID 'tr-…' not found. It may have been deleted. ——這句話八成不是真的被刪了,而是你忘了 flush,trace 還在緩衝區裡沒進資料庫。
到 notebook 的 6️⃣ 節:標記那條幻覺 trace 並讀回來寫一個 scorer,讓機器幫你標——然後小心它騙你
人工標記很準,但一天標不了一百條。真實的做法是兩層: code-based scorer(純 Python 規則,快、免費、100% 可重現)處理 「有沒有引用來源」「有沒有超過長度上限」「該拒答時有沒有拒答」; LLM-as-judge scorer(拿另一個模型當評審)處理「語氣夠不夠禮貌」「有沒有答非所問」這類寫不出規則的事—— 準,但要錢、要金鑰,而且評審本身也會出錯。這一課只跑第一種,一毛錢都不用花。
同樣三題、同一個假模型,只換 prompt 版本(v2 加了拒答規則),三個 scorer 的實測分數:
| scorer | prompt v1 | prompt v2 | 怎麼讀 |
|---|---|---|---|
| refuses_when_empty | 0.667 | 1.000 | 拒答規則生效了,幻覺被修掉 ✅ |
| has_number | 1.000 | 0.667 | 看起來變差了 |
| short_enough | 1.000 | 1.000 | 兩版都沒超過 40 字 |
但 v2 沒有變差。它那一題答的是「手冊裡沒有寫。」——正確的拒答裡本來就不會有數字, 是這個 scorer 誤殺了它。更諷刺的是:v1 那句幻覺「3 期與 6 期免利息」裡有數字, 所以 has_number 給了它滿分。
兩個教訓。第一,一個指標會騙人,一組指標才看得出真相—— 如果團隊只盯 has_number,這次修正會被判定為「退步」而回滾。 第二,scorer 要把合法的例外寫進規則裡:像 refuses_when_empty 那樣先問「這一題本來就該有答案嗎」, 而不是無條件套同一條規則。
最後一個實測到、會讓你找很久的坑:scorer 的參數名只能從 inputs / outputs / expectations / trace 裡挑。 寫成 def my_scorer(answer_text) 不會報錯—— evaluate 照跑完,metrics 回一個空字典 {},你會以為是資料有問題而不是自己打錯字。
到 notebook 的 7️⃣ 節:三個 scorer 對 v1/v2 打分prompt 也是資產,也要版本與 alias
第 2 課你把模型放進 Registry:一個名字、很多版本、一個 @champion 指向線上那版。 LLM 應用有一個東西跟模型一樣重要、卻更常被改動——prompt。
想想它的日常:產品經理說「語氣太硬」,有人在群組貼了一段新的 prompt,工程師複製貼上進程式碼,deploy。 三天後客訴變多了。上週那版 prompt 長什麼樣?如果它寫在程式碼裡,也許翻得到 git log; 如果它在資料庫、在設定檔、在某個人的筆記本裡,就沒救了。Prompt Registry 給它一套跟模型一樣的規矩:
| URI 寫法 | 意思 | 什麼時候用 |
|---|---|---|
| prompts:/support-answer@production | 跟著 alias 走,晉升之後下一次載入自動變新版 | 服務程式碼裡 |
| prompts:/support-answer/1 | 釘死第 1 版,之後怎麼晉升都不變 | 重現舊結果、A/B 對照 |
晉升就是一行 set_prompt_alias(..., version=2),回滾就是把它指回去—— 跟第 2 課移 champion 是同一個動作、同一套心智模型。 四個實測到的行為,寫程式前知道比較不會受傷:
| 你做的事 | 實際發生什麼 |
|---|---|
| 同名、同內容再註冊一次 | 產生一個新版本(v1 的內容再註冊一次得到 v3)——不會偵測重複,別放在會重跑的迴圈裡 |
| 載入不存在的 alias | Prompt alias nope not found. |
| 載入不存在的版本 | Prompt (name=support-answer, version=99) not found |
| URI 忘了 prompts:/ 前綴 | Prompt with name=support-answer@production not found(它把整串當成名字了) |
| format() 少給一個變數 | Missing variables: {'context'}. To partially format the prompt, set `allow_partial=True`. |
第二列與第三列的錯誤訊息長得不一樣,這其實很有用:看到前者是 alias 沒建(或打錯 alias 名), 看到後者是版本號打錯——訊息本身就告訴你該去查哪裡。
到 notebook 的 8️⃣ 節:註冊、晉升、回滾、再晉升哪個回答是哪一版 prompt 生的?一查就知道
關鍵在那一行的位置:只要 load_prompt() 是在 @mlflow.trace 的函式裡面呼叫的,MLflow 會自動幫這條 trace 加一個標籤 mlflow.linkedPrompts,內容是 [{"name": "support-answer", "version": "2"}]。 你不用自己記「這次用了哪版 prompt」——它自己就在 trace 上。
再加一個自訂 tag prompt_ver,兩版的請求就分得開了。 同樣三題、同一個模型,只換 prompt 版本(實測原文):
| 問題 | v1(沒有拒答規則) | v2(加了拒答規則與問候語) |
|---|---|---|
| 退貨要多久內? | 退貨期限為 7 天,商品需保留原包裝與吊牌。 | 您好,退貨期限為 7 天,商品需保留原包裝與吊牌。 |
| 運費怎麼算? | 單筆滿 1000 元免運,未滿運費 80 元。 | 您好,單筆滿 1000 元免運,未滿運費 80 元。 |
| 可以分期嗎? | 可以分期,通常提供 3 期與 6 期免利息。(知識庫沒有這條) | 您好,手冊裡沒有寫。 |
這就是 LLMOps 的閉環,而且它跟第 5 課的訓練管線是同一個形狀—— 只是把「模型」換成「prompt」、把「AUC」換成「一組 scorer」:
換成 OpenAI 要改幾行?一行
這一課從頭到尾用的是一個規則式的假 LLM:prompt 裡有拒答規則它就拒答,沒有它就開始編。 這樣設計不是為了省事,是為了讓你在沒有金鑰、沒有帳單、每次結果都一樣的條件下把機制學完。
加上 autolog() 之後,每一次 API 呼叫都會自動變成一個 CHAT_MODEL span,而且比手寫的還完整:模型名、temperature、每則訊息、token 用量都自動記進去 (支援的供應商還會估算費用)。你自己寫的 @mlflow.trace(span_type="CHAIN") 照舊——兩者會自動接成同一棵樹, 因為它們共用同一個「目前的 trace」。
LangChain、LlamaIndex、Anthropic、Gemini、DSPy 等等都有各自的 autolog(),用法一模一樣。這一課學的 span 樹、tag、assessment、 scorer、Prompt Registry,在真模型上一個字都不用改——會變的只有兩件事: 回答不再每次相同(所以嚴格的 scorer 分數會浮動,寫報告要寫範圍不寫點估計), 以及 LLM span 的耗時會從 50 毫秒變成幾百毫秒到幾秒,延遲那張表的比例會整個變樣。
到 notebook 的 🔟 節:自己問一題,看它的 span 樹換你動手
客服還要能查訂單。加一個 @mlflow.trace(span_type="TOOL") 的 order_status(order_id)(假資料就好),讓流程在問題含訂單編號時呼叫它,然後在 span 樹裡看到那個 TOOL 節點。
寫一個 scorer grounded:回答必須引用檢索到的文件。難的不是規則,是例外——正確的拒答要放行,憑空生出來的答案要被抓到。用它去評 v1 與 v2。
把這一課接到真模型上:裝 openai、加一行 mlflow.openai.autolog()、把假 LLM 換成真的 API 呼叫,其他一行都不改。怎麼確認自己做對了?span 樹裡會多出一個你沒有標過的 CHAT_MODEL span。
卡住了?每一題在 notebook 末節都有折疊解答(含實測輸出)——先自己做,再打開對照。
情境測驗
離開前試試看:下面的情境都真的會遇到。每題選一個你認為的最佳做法,選了馬上看得到解釋。
Q1 情境題
客服機器人對「可以分期嗎?」回了一段講得很篤定的分期方案,但公司根本沒有分期。客訴進來了,你手上有這條請求的 trace。第一步該做什麼?
trace 存在的意義就是「不用重現也能查」。span 樹會直接把三種可能分開:RETRIEVER 的 outputs 是 [] = 知識庫裡根本沒這條(本課實測就是這個);有文件但 LLM span 的 prompt 裡沒有它 = 組 prompt 的程式錯了;兩者都對卻還亂答 = 才輪到模型或 prompt 規則的問題。診斷完才知道要改哪裡——本課這個案例的解法是在 prompt 加拒答規則,換模型或加 log 都沒打到點。A 對非決定性系統有用,但這裡的資訊已經在手上,重現只是浪費時間;B 是最貴又最沒根據的一步,檢索沒撈到文件時再大的模型也變不出資料;D 說明你還沒發現 trace 已經記完了——而且它把診斷延後到「下次再發生」。
Q2 錯誤診斷
同事寫了一支回歸測試腳本:跑完幾個問題就把結果標記起來。它在 log_feedback 這行掛掉,但同一支程式在服務裡跑得好好的。最可能的原因是?
兩個症狀合起來只指向一件事:search_traces 回的筆數比實際少、而且每次不一樣,接著 log_feedback 說「找不到」——資料還在記憶體的緩衝區裡,根本沒進資料庫。加一行 mlflow.flush_trace_async_logging() 就好(本課實測:不 flush 時 0 筆或 2 筆,flush 之後穩定 3 筆)。「有時會過、有時不會」正是非同步寫入的招牌症狀。服務裡不會出事,是因為背景執行緒早晚會寫完,而服務不會「跑完馬上查」。A 更慘:experiment_names 這個參數不存在,會直接 TypeError: search_traces() got an unexpected keyword argument 'experiment_names'. Did you mean 'experiment_ids'?;C 被錯誤訊息裡的 “It may have been deleted.” 帶著走了,那只是一句籠統的提示,而且 get_trace() 找不到時是回 None、不會拋錯,你會更困惑;D 把 trace 跟 run 搞混了,assessment 掛在 trace 上,跟 run 沒有關係。
Q3 錯誤診斷
你寫了一個 scorer 想檢查回答有沒有引用來源。evaluate 跑完沒有任何錯誤,但 metrics 是一個空字典。最直接的修法是?
MLflow 是照參數名把資料餵給 scorer 的,名字不在那四個裡面就沒東西可餵,於是這個 scorer 整個被跳過——而且不報錯,evaluate 照樣印出「完成」,只是 metrics 空空如也(本課實測)。這種靜默失敗最花時間,所以看到空字典的第一個動作就是回頭看參數名。B 是誤解:bool 完全可以,本課三個 scorer 都回 bool,平均就是通過率;C 也是誤解,code-based scorer 不需要任何模型,這一課全程沒有金鑰也跑得出 has_number/mean 這些數字;A 只有在 scorer 真的宣告了 expectations 參數時才需要,這裡的問題出在更前面。
Q4 情境題
團隊只盯一個指標「回答要含數字」。你把 prompt 改成 v2 加上拒答規則,修掉了亂編分期方案的問題,但這個指標從 1.000 掉到 0.667。主管說「數字變差就回滾」。你該怎麼做?
指標掉了要先問「掉的是哪一筆、為什麼」,而不是直接相信數字。本課實測就是這個情形:v2 唯一掉分的那題答的是「手冊裡沒有寫。」——那是正確行為,只是不含數字;更諷刺的是 v1 那句幻覺「3 期與 6 期免利息」有數字,反而拿滿分。修法有兩層:把合法例外寫進規則(先判斷「這題本來就該有答案嗎」),以及不要只有一個指標——refuses_when_empty 從 0.667 升到 1.000 才是這次改動真正的成果。B 為了配合壞掉的量尺去扭曲產品行為,本末倒置;C 讓指標永遠好看,等於放棄評估;D 太貴也太快:judge 要錢、要金鑰,而且評審自己也會出錯——判斷得出規則的事就該用規則,judge 留給規則寫不出來的(語氣、切題)。
Q5 情境題
你們的 prompt 直接寫在服務程式碼裡,每次調整就 deploy 一次。上週改過語氣之後客訴變多,但沒人說得出「上週那版長什麼樣」。要讓這件事下次不再發生,最有效的一組做法是?
問題有兩半:找得回舊版,以及知道當時線上是哪一版。Registry 解前半(版本+commit_message+alias,晉升與回滾都是一行 set_prompt_alias);在 @mlflow.trace 的函式內 load_prompt 解後半——MLflow 會自動幫每條 trace 掛上 mlflow.linkedPrompts(實測內容是 [{"name": "support-answer", "version": "2"}]),哪個回答由哪一版生成不用另外記。這跟第 2 課用 @champion 管模型是同一套心智模型。A 靠人的紀律,而且截圖無法程式化比對、也無法回滾;B 讓程式碼越長越髒,還是回答不了「線上跑的是哪一版」;D 事後比得出雜湊,卻換不回 prompt 原文,也不能一行回滾。
實作在 molab 跑(免費)
molab 的登入狀態進不了內嵌框架(瀏覽器的跨站 cookie 保護), 所以 notebook 要在新分頁執行——把它跟本頁並排開,左邊教學照樣對照。
- 登入 molab(GitHub / Google)
- 開啟課程 notebook,Fork 成自己的副本即可編輯
- 從第一格往下全部執行(首次安裝套件約 1–2 分鐘)——免費 CPU 環境即可,不需要 GPU,也不需要任何 API key:這一課用規則式的假 LLM,機制與真模型完全一樣
不想用 molab?下載 mlflow-tracing_ext.py 後在自己電腦
uvx marimo edit --sandbox mlflow-tracing_ext.py,依賴會自動安裝。
molab 的線上編輯器在手機上體驗有限——動手這一段建議用電腦進行。