AI 互動教室 ‹ MLOps 自動化技術
下載 .py 開啟實戰 notebook ↗ 留言回報
TRACING · 補充 E · 10

MLflow Tracing:
LLM 應用的每一步都留下軌跡

客服機器人回了一句「可以分期,通常提供 3 期與 6 期免利息。」,客戶照做了,然後投訴。 你手上只有這句話——是檢索沒撈到文件?prompt 沒把文件放進去?還是模型自己編的? 點下面任何一個問題,把中間那段點亮:

問題
prompt

點任何一列展開它的 inputs/outputs 原文。

六條 trace 都是 notebook 的實測紀錄(MLflow 3.15.2,不打真的 LLM):trace id、標籤、每個 span 的 inputs/outputs 全是原文。毫秒是單次量測——這一課的耗時來自刻意加上的模擬延遲,你自己跑會不一樣。

01 · 從 RUN 到 TRACE

訓練記「一次訓練」,上線記「一次請求」

第 1 課的 run 是訓練的紀錄簿:一次訓練一筆,裡面有 params、metrics、artifacts, 好壞用一個 AUC 說了算。那套東西到了 LLM 應用就整個不夠用—— 因為你要記的不是「今天訓了一個模型」,而是今天有八萬個人問了問題

run(第 1 課)trace(這一課)
記錄的單位一次訓練一次請求
一天幾筆幾十筆幾十萬筆
裡面有什麼params / metrics / artifactsspan 樹:每一步的 inputs、outputs、耗時、屬性
好壞怎麼判斷一個 AUC 說了算沒有標準答案——要人標、要 scorer 打分
版本控制什麼模型(Registry)prompt(Prompt Registry)
出事時你要回答當時的參數是什麼當時檢索到什麼、prompt 長什麼樣、用的哪一版

span 就是「一步」:一次檢索、一次模型呼叫、一次工具呼叫。 一次請求裡的每一步各是一個 span,串起來就是一棵樹——這就是 trace。 你原本的 log 也記得到這些,前提是你想得到要記、而且記得下次也要記; tracing 的價值在於它是預設就記完整的,而且結構化到可以搜尋、可以比較、可以打分。

到 notebook 的 0️⃣ 節:假 LLM 客服與三條知識庫
02 · 第一個 TRACE

三個裝飾器,一棵樹

@mlflow.trace(span_type="RETRIEVER") def retrieve(question): ... # 去知識庫找文件 @mlflow.trace(span_type="LLM") def fake_llm(prompt): ... # 呼叫模型 @mlflow.trace(name="answer", span_type="CHAIN") def answer(question): # 把上面兩步串起來 ctx = retrieve(question) return fake_llm(f"資料:{ctx}\n問題:{question}")["content"]

要被記錄,只要加一行 @mlflow.trace你不用宣告誰是誰的父節點——answer() 呼叫了另外兩個函式, MLflow 就把它們變成巢狀 span,呼叫關係就是樹的形狀。 函式的參數自動變成 span 的 inputs、回傳值自動變成 outputs,你一個字都不用寫。

然後是這一課最容易踩的一個坑:trace 是非同步寫入的(不然每個請求都要等資料庫寫完才能回應)。 在 notebook 或測試腳本裡「跑完馬上查」,你查到幾筆是不一定的,而且沒有任何錯誤訊息

for q in ["退貨要多久內?", "運費怎麼算?", "可以分期嗎?"]: answer(q) mlflow.search_traces(experiment_ids=[EXP_ID]) # → 實測 0 筆或 2 筆,每次不一定 mlflow.flush_trace_async_logging() # ← 少了這行,上面那行就是擲骰子 mlflow.search_traces(experiment_ids=[EXP_ID]) # → 穩定 3 筆

背景執行緒可能剛好寫完一部分,所以結果是隨機的——「有時會過、有時不會」的測試比直接壞掉更難查。 正式服務不需要 flush(背景執行緒自己會寫完),它是給「跑完馬上要查」的場景用的。 另外注意參數名是 experiment_ids(複數、要 id)—— 寫成 experiment_names 會直接 TypeError: … Did you mean 'experiment_ids'?

search_traces() 回一張 12 欄的 DataFrame(一列一條 trace, 有 trace_idstateexecution_durationrequestresponsetags…), 要看樹則用 mlflow.get_trace(trace_id).info 是整條的資訊,.data.spans 是每一步。

到 notebook 的 1️⃣–2️⃣ 節:跑三題、把 span 樹畫出來
03 · SPAN_TYPE

那個字串不影響執行,但決定三件事

span_type="RETRIEVER" 不會改變任何計算結果,很容易讓人覺得可有可無。它決定的是:

它影響什麼標對了標錯(或沒標)
MLflow UI 怎麼畫這一步RETRIEVER 畫成「找到哪幾份文件」的清單,LLM 畫成對話框並顯示 token 數畫成一坨看不懂的 JSON
內建 scorer 找不找得到料「檢索到的文件跟問題有沒有關係」這類評分器靠 RETRIEVER 找文件評分器找不到文件,靜靜跳過
你自己的查詢與統計「所有 LLM span 的總 token」「TOOL span 的失敗率」分不了組

mlflow.entities.SpanType 提供 15 個常數(3.15.2 實測): CHAINLLMCHAT_MODELRETRIEVERTOOLAGENTEMBEDDINGRERANKERPARSERGUARDRAILEVALUATORMEMORYTASKWORKFLOWUNKNOWN。 傳字串或傳常數都可以,值一樣。

這個欄位沒有校驗——span_type="RETREIVER" 打錯字不會報錯, 程式照跑、trace 照存,只是 UI 不知道怎麼畫、scorer 當作沒看到。這是最沉默的那種錯, 所以能用常數就別打字串。

到 notebook 的 3️⃣ 節:15 種 span_type 與它們的用途
04 · 屬性、標籤、搜尋

一天幾十萬條,你需要能篩

mlflow.get_current_active_span().set_attributes({"n_docs": len(ctx), "question_len": len(q)}) mlflow.update_current_trace(tags={"topic": "退貨"}) mlflow.search_traces(experiment_ids=[EXP_ID], filter_string="tags.topic = '退貨'")

MLflow 給你兩個掛東西的地方,很多人混用,但用途完全不同:

span.set_attributes()update_current_trace(tags=)
掛在哪單一 span整條 trace
典型內容n_docstop_ktemperaturetopicuser_tierprompt_versession_id
能拿來搜尋嗎不能(要撈回來自己看)
什麼時候用事後想「那一步當時是什麼設定」事前想「之後我要用什麼條件撈出一群請求」

一句話判準:想用它「找出一群 trace」就放 tag;想用它「解釋某一步」就放 attribute。 這個選擇在寫程式的當下不痛不癢,但等到出事、你想撈「所有 VIP 客戶問退貨而且答錯的請求」時, 當初放錯地方的欄位就撈不出來了。

搜尋語法跟第 1 課的 search_runs 是同一套解析器(同樣的坑會再踩一次)。 有一條分界線值得先記起來:「點」前面那一段(entity type)MLflow 會驗,「點」後面那一段(key)不會—— 所以打錯前綴會被罵,打錯 tag 名字只會靜靜回 0 筆。全部是實測原文:

"tags.topic = 退貨" ← 值沒有引號 → Parameter value is either not quoted or unidentified quote types used for string value 退貨. "tags.topic == '退貨'" ← 用了 == → Invalid comparator '==' not one of '{'IS NOT NULL', 'IS NULL', '!=', 'ILIKE', '=', 'LIKE', 'RLIKE'}' "attributes.execution_duration > 100" ← DataFrame 的欄名不是 filter 的欄名 → Invalid attribute key 'execution_duration' specified. Valid keys are '{'execution_time_ms', 'status', 'name', 'timestamp_ms', 'run_id', 'request_id', …}' "foo.topic = '退貨'" ← entity type 不存在 → Invalid entity type 'foo'. Valid values are {'attribute', 'request_metadata', 'attributes', 'metadata', 'span', 'tags', 'expectation', 'tag', 'trace', 'issue', 'feedback'} "tags.Topic = '退貨'" ← 只是 tag 名字大小寫錯 → 不報錯,回 0 筆 ← 唯一不會叫你的那一種

前四個會叫你,第五個不會。查不到東西的時候,先懷疑自己的 filter,不要先懷疑資料。 兩個閱讀提示:那份合法清單裡 tagtags 都在, 兩種寫法都能用——別把少一個 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 毫秒):

問題總計retrievefake_llmLLM 佔比
退貨要多久內?(第一筆130–220 ms約 20 ms約 50 ms23–38%
運費怎麼算?71–73 ms約 20 ms約 50 ms約 70%
可以分期嗎?71–92 ms約 20–41 ms約 50 ms55–71%

第一筆的總計時間明顯偏高,那是第一次呼叫時 tracing 自己的暖機成本,不是你的程式慢。 看延遲永遠要看分佈(p50/p95),不要看單筆、更不要看第一筆。 真實系統裡這張表的比例會更極端:生成通常是幾百毫秒到幾秒,檢索是幾十毫秒。

到 notebook 的 4️⃣–5️⃣ 節:六種 filter 的命中數+延遲拆解
05 · 人工評估

沒有 AUC 的世界:把人的判斷寫回 trace

TEACHER = AssessmentSource(source_type=AssessmentSourceType.HUMAN, source_id="teacher") mlflow.log_feedback(trace_id=tid, name="correct", value=False, rationale="知識庫沒有分期付款這條,模型自己編了一個答案", source=TEACHER) mlflow.log_expectation(trace_id=tid, name="expected_answer", value="手冊裡沒有寫。", source=TEACHER)

訓練模型時有標準答案,AUC 一個數字說了算。LLM 應用沒有這種東西—— 「這個回答好不好」要人看了才知道。MLflow 的做法是把人的判斷掛回那條 trace,叫 assessment

log_feedbacklog_expectation
記什麼這次的回答好不好這一題正確答案應該是什麼
誰給的人工標記、線上的讚/倒讚、LLM judge人工(領域專家)
本課實測correct = False,理由寫在 rationaleexpected_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 並讀回來
06 · 自動評估

寫一個 scorer,讓機器幫你標——然後小心它騙你

@scorer def refuses_when_empty(inputs, outputs) -> bool: if inputs.get("context"): # 這一題本來就該有答案 → 放行 return True return "手冊裡沒有寫" in str(outputs) mlflow.genai.evaluate(data=[{"inputs": {...}, "outputs": "..."}, ...], scorers=[has_number, refuses_when_empty, short_enough])

人工標記很準,但一天標不了一百條。真實的做法是兩層: code-based scorer(純 Python 規則,快、免費、100% 可重現)處理 「有沒有引用來源」「有沒有超過長度上限」「該拒答時有沒有拒答」; LLM-as-judge scorer(拿另一個模型當評審)處理「語氣夠不夠禮貌」「有沒有答非所問」這類寫不出規則的事—— 準,但要錢、要金鑰,而且評審本身也會出錯。這一課只跑第一種,一毛錢都不用花。

同樣三題、同一個假模型,只換 prompt 版本(v2 加了拒答規則),三個 scorer 的實測分數:

scorerprompt v1prompt v2怎麼讀
refuses_when_empty0.6671.000拒答規則生效了,幻覺被修掉 ✅
has_number1.0000.667看起來變差了
short_enough1.0001.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 打分
07 · PROMPT REGISTRY

prompt 也是資產,也要版本與 alias

mlflow.genai.register_prompt(name="support-answer", template="…{{context}}…{{question}}…", commit_message="v2 加上拒答規則與問候語") mlflow.genai.set_prompt_alias("support-answer", alias="production", version=2) p = mlflow.genai.load_prompt("prompts:/support-answer@production") # 跟著 alias 走 p.variables # → {'context', 'question'} p.format(context="退貨期限為 7 天", question="可以退嗎?")

第 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)——不會偵測重複,別放在會重跑的迴圈裡
載入不存在的 aliasPrompt 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️⃣ 節:註冊、晉升、回滾、再晉升
08 · 串起來

哪個回答是哪一版 prompt 生的?一查就知道

@mlflow.trace(name="answer_v", span_type="CHAIN") def answer_versioned(question, uri="prompts:/support-answer@production"): pr = mlflow.genai.load_prompt(uri) # ← 在 trace 內載入 ctx = retrieve(question) out = llm_v(pr.format(context=ctx[0]["text"] if ctx else "", question=question)) mlflow.update_current_trace(tags={"prompt_ver": f"v{pr.version}"}) return out["content"]

關鍵在那一行的位置:只要 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」:

線上請求留下 trace → 有問題的 trace 被人標記(feedback / expectation) → 標記過的 trace 就是評估資料集 → 改 prompt、register 新版本 → 用同一批資料重跑 scorer,分數過了才 set_prompt_alias 晉升 → 新的 trace 帶著新版本號回到第一步
到 notebook 的 9️⃣ 節:v1/v2 各跑三題、比較表與 linkedPrompts
09 · 接上真模型

換成 OpenAI 要改幾行?一行

import mlflow, openai mlflow.openai.autolog() # ← 就這一行 client = openai.OpenAI() resp = client.chat.completions.create(model="gpt-4o-mini", messages=[...])

這一課從頭到尾用的是一個規則式的假 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 樹
10 · 實戰

換你動手

LEVEL 1

客服還要能查訂單。加一個 @mlflow.trace(span_type="TOOL")order_status(order_id)(假資料就好),讓流程在問題含訂單編號時呼叫它,然後在 span 樹裡看到那個 TOOL 節點。

LEVEL 2

寫一個 scorer grounded:回答必須引用檢索到的文件。難的不是規則,是例外——正確的拒答要放行,憑空生出來的答案要被抓到。用它去評 v1 與 v2。

LEVEL 3

把這一課接到真模型上:裝 openai、加一行 mlflow.openai.autolog()、把假 LLM 換成真的 API 呼叫,其他一行都不改。怎麼確認自己做對了?span 樹裡會多出一個你沒有標過的 CHAT_MODEL span。

卡住了?每一題在 notebook 末節都有折疊解答(含實測輸出)——先自己做,再打開對照。

11 · 驗收

情境測驗

離開前試試看:下面的情境都真的會遇到。每題選一個你認為的最佳做法,選了馬上看得到解釋。

Q1 情境題

客服機器人對「可以分期嗎?」回了一段講得很篤定的分期方案,但公司根本沒有分期。客訴進來了,你手上有這條請求的 trace。第一步該做什麼?

trace 存在的意義就是「不用重現也能查」。span 樹會直接把三種可能分開:RETRIEVER 的 outputs 是 [] = 知識庫裡根本沒這條(本課實測就是這個);有文件但 LLM span 的 prompt 裡沒有它 = 組 prompt 的程式錯了;兩者都對卻還亂答 = 才輪到模型或 prompt 規則的問題。診斷完才知道要改哪裡——本課這個案例的解法是在 prompt 加拒答規則,換模型或加 log 都沒打到點。A 對非決定性系統有用,但這裡的資訊已經在手上,重現只是浪費時間;B 是最貴又最沒根據的一步,檢索沒撈到文件時再大的模型也變不出資料;D 說明你還沒發現 trace 已經記完了——而且它把診斷延後到「下次再發生」。

Q2 錯誤診斷

同事寫了一支回歸測試腳本:跑完幾個問題就把結果標記起來。它在 log_feedback 這行掛掉,但同一支程式在服務裡跑得好好的。最可能的原因是?

for q in QUESTIONS: answer(q) df = mlflow.search_traces(experiment_ids=[EXP_ID]) # 明明跑了 3 題,這裡有時 0 筆有時 2 筆 mlflow.log_feedback(trace_id=known_id, name="correct", value=True, source=SRC) MlflowException: Trace with ID 'tr-51d4bd5e57a…' not found. It may have been deleted.

兩個症狀合起來只指向一件事: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 是一個空字典。最直接的修法是?

@scorer def cites_source(answer_text) -> bool: return "依據" in str(answer_text) res = mlflow.genai.evaluate(data=data, scorers=[cites_source]) print(res.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 原文,也不能一行回滾。

HANDS-ON · MOLAB

實作在 molab 跑(免費)

molab 的登入狀態進不了內嵌框架(瀏覽器的跨站 cookie 保護), 所以 notebook 要在新分頁執行——把它跟本頁並排開,左邊教學照樣對照。

  1. 登入 molab(GitHub / Google)
  2. 開啟課程 notebook,Fork 成自己的副本即可編輯
  3. 從第一格往下全部執行(首次安裝套件約 1–2 分鐘)——免費 CPU 環境即可,不需要 GPU,也不需要任何 API key:這一課用規則式的假 LLM,機制與真模型完全一樣

不想用 molab?下載 mlflow-tracing_ext.py 後在自己電腦 uvx marimo edit --sandbox mlflow-tracing_ext.py,依賴會自動安裝。

molab 的線上編輯器在手機上體驗有限——動手這一段建議用電腦進行。