vatt'ghern jaskier's ballads
本文 1 個互動圖表在手機上以重點摘要呈現,互動版請以桌面瀏覽器開啟。

同一套提示詞,輸出「100% 是合法 JSON,0% 符合資料契約」。格式檢查全過、欄位卻系統性地缺漏——微軟 ISE 的修法不是改提示詞,而是承認:讓一個機率模型在同一次推論裡同時負責抄寫與推理,本來就不該指望它穩定。

把確定性抽取跟推論拆開

軟 ISE 團隊接手一個把工業 operational shift log 轉成結構化摘要的系統。第一版是最直覺的做法:把整份 shift log 丟給 LLM,一次 call 產出完整的 JSON。評測結果是一個刺眼的對照——「100% of our outputs were valid JSON. 0% met the data contract.」每一份輸出都能通過 JSON parser,卻沒有一份滿足下游需要的資料契約。這篇文章的價值不在那個模型有多差,而在拆解「為什麼 one-pass LLM 抽取在結構上就不可靠」,以及他們怎麼把它重構成一條四段 pipeline,把 schema 合規率從 0% 推到 100%。

lossy serializer:一次 call 同時做抄寫與推理

先界定「schema compliance」在他們語境裡是什麼。它量的不是「能不能被 parse」,而是內容層面的契約:「does each required section exist? Are arrays non-empty, values non-null?」required section 要在、陣列不能空、值不能是 null。單體式 prototype 在這個定義下全軍覆沒,具體形態是「The LLM was producing JSON where required sections were missing their subfields entirely or contained empty arrays.」——required section 的子欄位整個不見,或者裝著一個空陣列交差。

更糟的是這個失敗沒有規律可循。作者寫得很直接:「Required sections appeared in some outputs and vanished in others with no diagnosable pattern.」同樣形狀的輸入跑兩次,某個 section 這次在、下次消失,找不到可診斷的模式。對一個要進 production 的系統,這比「穩定地錯」還難處理——穩定的錯至少能寫規則擋掉,隨機的缺漏只能靠人工逐筆對。

問題的根在於這台 LLM 被賦予的角色。它一次讀完整份 shift log、自己決定哪些內容要收進輸出、哪些丟掉,然後生成整份 JSON,中間沒有任何驗證機制。作者給這個角色一個名字:「This makes the LLM a lossy serializer: it reads, decides, and generates everything.」一個會掉資料的序列化器。序列化器的工作本該是確定性的——輸入有什麼欄位,輸出就照搬什麼欄位,一個 timestamp 不會「有時候出現」。但機率模型沒有這個保證:它在 decode 每個 token 時都在做機率抽樣,把一個 operator 已經填好的 severity 欄位「重新生成」一次,就有非零機率生成成別的值,或者乾脆漏掉。

把這件事翻譯成 Responsible AI 的語言,單體式違反了三條原則,作者一條一條列:「it lacked transparency (no way to know which parts came from source data vs. model inference), accountability (when a field was wrong, no way to trace it to a specific decision point), and reliability (the model silently dropped required content with no detection mechanism).」透明性——分不清哪部分來自來源、哪部分是模型編的;可問責性——某個欄位錯了,無法追到是哪一個決策點出錯;可靠性——模型默默丟掉 required 內容,而且沒有任何偵測機制會喊停。這三條失效是同一個根因的三個切面:把確定性的抄寫工作,交給了一個機率性的、不可內省的黑盒子。

值得停下來想清楚的是:這不是 prompt engineering 能修的問題。再怎麼在提示詞裡寫「請務必包含所有 required section、不要輸出空陣列」,都只是在降低違規機率,而不是消除它。只要抄寫這個動作還在 decode loop 裡,違規機率就永遠是非零的。要把機率歸零,唯一的路是把抄寫整個移出模型。這也是為什麼加大模型、調溫度、加 few-shot example 都救不了根本——它們動的是機率分布的形狀,而不是「抄寫到底要不要走機率」這個更上層的決定。一個 timestamp 是 08:14 就是 08:14,這件事不該有任何「分布」可言。

把這個失敗模式放回 shift log 的場景就更清楚它的代價。一份 operational shift log 是輪班交接的依據:上一班發生了哪些 incident、每個 incident 的 severity 是多少、valve 在幾點 trip、誰簽核了哪筆 handover。下游讀這份摘要的人要據此決定要不要停機、要不要叫人、要不要升級事件。如果某次摘要默默把一個 high severity 的 incident 漏成空陣列,讀的人不會知道有東西被吞了——他看到的是一份「看起來完整、parse 得過」的 JSON。這就是為什麼「100% 合法 JSON、0% 合規」比「明顯壞掉」更危險:它把錯誤藏進了一個看似正常的外殼裡,而且每次藏的位置還不一樣。一個會穩定壞在同一個欄位的系統,至少能寫個 assertion 擋住;一個隨機在不同 required section 上掉資料、又沒有偵測機制的系統,等於把驗證責任整個推給了讀摘要的人工。

後面那條四段 pipeline 每一段都對著其中一條原則修。

把游標移到任一條原則看哪段 pipeline 賺回它 · 手機點一下

透明性 transparency 由 Pass 1 賺回:軟體複製 ground-truth 欄位、另存 raw source record,來源值與模型推論因此分得開。
可問責性 accountability 由 Pass 4 賺回:摘要組好後模型才掛 evidence,連回特定 source record 並附 confidence,錯了能追回是哪筆。
可靠性 reliability 由 Pass 3 賺回:以 log_entry_id 守衛式合併、source 值優先,模型無法默默覆蓋或漏掉確定性欄位。
透明性歸 Pass 1、可問責性歸 Pass 4、可靠性歸 Pass 3。

二元分類:每個欄位先問一句話

重構的第一步不是改架構,是對輸出 schema 裡的每一個欄位問同一個問題:「Does this field have a 1:1 mapping to a value that already exists in the input?」這個欄位有沒有 1:1 對應到輸入裡已經存在的某個值?這是一個二元判斷,把所有欄位切成兩堆。

第一堆是答「有」的欄位——timestamp、log entry 的 id、operator 當場填的 severity。這些值在輸入裡就以最終形態存在,輸出要做的只是把它搬過去。對這堆欄位,模型的推理能力不只是多餘,而是有害:任何「生成」都引入了它本來不該承擔的機率。第二堆是答「沒有」的欄位——一筆 record 該歸到哪個 output section(routing)、一段自由文字描述的事件屬於什麼類別(classification、event 分類)、跨多筆 record 的 timeline 怎麼推理。這些才是真正需要判斷、輸入裡沒有現成答案的欄位。

這個分類動作本身就是整個重構的核心 insight。它把「LLM 該做什麼」這個模糊的設計問題,化約成一張可以逐欄填的表——一個 field registry,每個欄位標上 deterministic 或 llm_required。這張表的價值在於它把判斷從「下筆寫 prompt 那一刻的直覺」前移到「設計 schema 那一刻的審查」。設計者必須對著每個欄位停下來,明確回答那個二元問題;答完之後,哪些欄位永遠不該進模型、哪些才需要,就被釘死在 registry 裡,而不是散落在某段提示詞的措辭裡。這也讓後續 code review 有了著力點——多了一個輸出欄位,第一個要問的就是「它進不進 registry 的 llm_required?依據是什麼?」

邊界案例才是這個二元問題真正吃力的地方。有些欄位看似有 1:1 對應、實際沒有:比如一個「事件摘要標題」可能直接複用某筆 record 的描述文字(看似抄寫),但若它需要從多筆 record 裡挑一筆當代表,那挑選本身就是判斷,得標 llm_required。反過來,event class 看似要推理,但如果輸入的某個欄位已經帶了結構化的 category code,那它其實是抄寫。判斷的依據不是「這個欄位的語意聽起來像不像推理」,而是嚴格地問「輸出的這個值,是不是輸入裡某個位置已經以最終形態存在的值」。只要答案是肯定的,就交給軟體;只要中間夾了任何挑選、合併、改寫、分類,就交給模型。下面這個小工具讓你親手做一次這個分類:把幾個典型欄位指派到 deterministic 或 llm,看合規風險如何隨著「交給模型的欄位數」變化。

把每個欄位指派給 deterministic 或 llm,看合規風險怎麼變 · 6 個欄位

log_entry_id 輸入裡已存在的主鍵
timestamp source array 裡的原始時間
operator_severity operator 當場填好的值
output_section 這筆 record 該歸哪一節,無現成答案
event_class 自由文字描述的事件類別,需推理
timeline_order 跨多筆 record 的時間推理
交給模型的欄位數越多,模型每多接一個確定性欄位就多扛一份非零違規機率 3 / 6 交給 LLM 左:sage = 軟體保證的欄位(零違規機率) 右:terracotta = 交給模型判斷的欄位

目前 6 個欄位裡有 3 個 交給確定性軟體、3 個 交給模型。這正是 registry 切出來的理想分界:三個 1:1 對應輸入的欄位由軟體保證,三個需要判斷的欄位才上模型。

六個欄位對照 field registry 的二元分類:前三個有 1:1 對應輸入的值、屬 deterministic;後三個需要 routing / classification / timeline 推理、屬 llm_required。把確定性欄位錯交給模型,就是 one-pass 違規的微觀來源。

有了這張表,整個系統的形狀就確定了:deterministic 那堆欄位由程式碼處理,永遠不進模型;llm_required 那堆才送進去,而且只問那幾個決策。模型不再「讀整份輸入、生成整份輸出」,它只回答幾個被框死的小問題。這就把 lossy serializer 拆掉了——抄寫歸軟體,判斷歸模型。

四段 pipeline:抄寫、判斷、合併、溯源各自一段

分類表落地成一條四段 pipeline,四段的順序對應一筆資料從原始到成品的加工流程:先把確定的部分搭好骨架,再請模型填判斷,接著守衛式地把判斷併回骨架,最後才產溯源。下面這張圖把四段攤開,每一段標明它做什麼、以及它「不負責」什麼——點任一段看責任邊界。

點任一段看它的責任邊界 · 4 段 pipeline

shift log ——> 四段加工 ——> schema 合規的結構化摘要 Pass 1 · 確定性抽取 複製 ground-truth 欄位、合併排序 timeline、保留 raw record 供稽核 NO LLM Pass 2 · 受限模型判斷 只問 registry 標 llm_required 的決策:routing 與 classification LLM Pass 3 · 守衛式合併 以 log_entry_id 為 join key 把 AI 輸出併回 scaffold,source 值優先 NO LLM Pass 4 · evidence mapping 把每個結論連回特定 source record,附 confidence(最新、最少驗證) LLM

click a pass above

Pass 1 · 責任邊界

「Software builds the contract scaffold: copies all ground-truth fields directly from the input, merges and chronologically orders timelines from source arrays, and preserves raw source records for auditability.」軟體建契約 scaffold——直接從輸入複製所有 ground-truth 欄位、把 source array 的 timeline 合併並按時間排序、保留 raw source record 供稽核。

不負責:任何需要判斷的事。這一段沒有 LLM,所以 deterministic 欄位的違規機率是零。

Pass 2 · 責任邊界

「The model receives raw input arrays plus the Pass 1 timeline and is asked only for decisions the registry marked as llm_required.」模型收到 raw input array 加 Pass 1 的 timeline,只被問兩類決策——routing(每筆 record 歸哪個 output section)、classification(每筆 record 的 AI-required 欄位值)。

不負責:抄寫確定性欄位、組裝最終 JSON。模型的職責被框到只剩它真正擅長的判斷。

Pass 3 · 責任邊界

「AI outputs from Pass 2 are merged into the deterministic scaffold using log_entry_id as the join key.」Pass 2 的 AI 輸出以 log_entry_id 為 join key 併回 Pass 1 的 scaffold。系統按設計讓 source data 優先——deterministic 欄位永遠用 source 值,絕不用 LLM 輸出。

不負責:產生新內容。它只是 join,是一個守衛——擋住模型試圖覆蓋確定性欄位的任何嘗試。

Pass 4 · 責任邊界

「Only once the summary is assembled does the model generate traceability, linking each conclusion back to specific source records with confidence assessments.」摘要組好後,模型才產 traceability,把每個結論連回特定 source record 並附 confidence。作者自承「This pass is the newest and least validated.」這段最新、也最少驗證。

不負責:改動已組好的摘要。它只在旁邊掛溯源資訊,不回頭動 Pass 1–3 的結果。

四段順序對應一筆資料的加工流程:先搭確定性骨架(NO LLM),再請模型填判斷(LLM),守衛式合併回骨架(NO LLM),最後掛溯源(LLM)。兩段有 LLM、兩段沒有——抄寫與判斷被物理隔開。

看這張圖最該注意的是 NO LLMLLM 交錯的節奏。Pass 1 與 Pass 3 完全沒有模型,它們是純程式碼,輸出可以被單元測試逐欄驗證;Pass 2 與 Pass 4 才上模型,而模型每次被呼叫時面對的都是一個邊界清楚的小問題,而不是「請生成整份 JSON」。這種交錯把不可內省的黑盒子夾在兩層確定性程式碼之間——進模型前 scaffold 已經建好,出模型後合併會擋掉任何越界。

Pass 2 那句「is asked only for decisions the registry marked as llm_required」裡的 only 是整段設計的承重點。模型不是收到「這是一份 shift log,請摘要」,而是收到一組被框死的問句:這筆 record 屬於哪個 output section?這筆 record 的 event class 是什麼?它甚至已經拿到 Pass 1 整理好、按時間排序的 timeline 當 context,不必自己重建時間順序。問題越窄,模型能搞砸的面就越小——它沒有機會去動一個它根本沒被問到的 timestamp,因為那個欄位壓根不在它的輸出 schema 裡。這跟把整份 JSON 的生成權交給它,是兩種完全不同的失敗面:前者最壞是某筆 record 被 route 錯 section,後者最壞是整個 required section 消失。前者可以靠 schema 限制 enum 值再加一層驗證收斂,後者連「少了什麼」都偵測不到。

把 routing 與 classification 拆成模型唯一的兩件事,還有一個副作用:它讓模型輸出的形狀本身變得可驗證。routing 的答案必為「某個已知的 output section 名稱」,classification 的答案必為「某個已知 class 的 enum 值」——兩者都是封閉集合,pipeline 可以在 Pass 2 之後立刻檢查模型有沒有吐出集合外的值,吐了就重試或退回預設。這就是把 47% 推到 100% 的那些驗證 pass 之所以能存在的前提:當模型只被允許在封閉集合裡選,「合不合規」才有一個機械可判的定義。one-pass 下模型可以輸出任意形狀的 JSON,根本沒有一個封閉集合能拿來比對。

Pass 3 的「守衛式」三個字值得單獨講。它以 log_entry_id 為 join key,意思是模型的判斷只能「附加」到對應的 record 上,沒辦法新增一筆 record、也沒辦法覆蓋確定性欄位的值。即使 Pass 2 的模型在 routing 時順手「腦補」了一個 timestamp,Pass 3 合併時也會用 source 的 timestamp 蓋回去。這一層讓「source 值優先」從一個口頭原則變成程式碼裡的硬約束。

0% → 47% → 100%:合規率走過的三個數字

重構的效果不是一步到位。下面這張表是 monolithic、modular(初版)、modular(現版)三個階段的對照——三欄都是 100% format valid,差別全在 schema compliance 那一列。

三階段合規對照,數字直接取自 Microsoft ISE blog 的比較表。format valid 三欄全是 100%,schema compliance 才是真正分出高下的那一列。
指標 Monolithic Modular(初版) Modular(現版)
Format valid 100% 100% 100%
Schema compliance 0% 47% 100%
Deterministic 欄位與 source 吻合 未驗證 100%(已測) 100%(已測)
「The modular pipeline brought schema compliance from 0% to 47%.」——光是拆成模組化還不夠,從 47% 推到 100% 靠的是後續加進來的驗證 pass。Monolithic 下 deterministic 欄位是否與 source 吻合根本「未驗證」,因為當時沒有任何機制能分辨。

47% 這個中間值是整篇最該被記住的數字。它說明「把工作拆成模組」本身不是銀彈——模組化讓系統可驗證了,但要真的把合規拉到 100%,還得在每段之間補上驗證 pass,把不合規的輸出擋下來重試或修補。一個常見的誤讀是把 0%→100% 想成「拆開就好了」;真相是「拆開 + 驗證」。

另一列也別放過:deterministic 欄位與 source 吻合,monolithic 是「未驗證」,模組化兩版都是「100%(已測)」。這裡的「未驗證」不是說它一定錯,而是說當時系統根本沒有能力分辨——因為 source 值和模型生成值混在同一份輸出裡,無從比對。一旦 Pass 1 把確定性欄位獨立抄出來、Pass 3 守衛式合併,這些欄位就能被逐一比對回 source,從「無法驗證」變成「可測且 100% 吻合」。透明性與可問責性就是在這裡被結構性地賺回來的。

這三條 metric 放在一起讀,會發現它們其實在量三件不同的事,而 one-pass 只回答得了第一件。format valid 量的是「parser 過不過」,這是最低的門檻,三個版本都滿分,所以它對判斷系統好壞幾乎沒有資訊量——這也正是 one-pass 最危險的地方,它在唯一全綠的那一列表現完美,足以騙過一個只看 parser 是否報錯的粗糙監控。schema compliance 量的是「內容契約滿不滿足」,這才是下游真正在乎的;deterministic 欄位吻合度量的是「抄寫有沒有出錯」。把後兩件事從第一件裡拆出來單獨量,本身就是這次重構帶來的能力——monolithic 連量都量不了,因為它沒有一個地方能讓你指著說「這個值應該等於 source 的哪個值」。能定義一個 metric,往往比把它優化到 100% 還難。

對著這張表,工程上最該記住的一課是:合規率的提升有兩段不同性質的功勞。從 0% 到 47% 是「拆分」的功勞——把抄寫移出模型,deterministic 那堆欄位的違規機率直接歸零,光這一步就把近半數欄位救回來。從 47% 到 100% 是「驗證」的功勞——在 Pass 2 之後檢查模型輸出是否落在封閉集合裡、在 Pass 3 守衛合併、不合規就重試或修補,把剩下那些真的需要模型判斷、模型偶爾會判錯的欄位也收斂到合規。把這兩段混為一談,就會誤以為「只要把系統拆成模組就會合規」,而實際上模組化只是讓驗證變得可能,真正把數字補滿的是逐段加上的驗證邏輯。

同一筆 log,one-pass 與 four-pass 的輸出差在哪

把抽象原則落到一份具體輸出上最直觀。下面這個對照拖把把同一筆 shift log 的兩種產物疊在一起——左邊是 one-pass LLM 直接生成的 JSON(required section 缺子欄位、裝空陣列),右邊是四段 pipeline 的產物(每個 deterministic 欄位都從 source 抄來、可溯源)。拖動分隔線看同一個位置兩邊各長什麼樣。

拖分隔線比較 one-pass 與 four-pass 的同位置輸出 · 拖動

one-pass{ "shift_id": "S-4471", "incidents": { "records": [] // 空陣列 }, "handover": { // severity 子欄位整個缺 }, "timeline": [ { "t": "08:14", "e": "valve trip" } ] // 哪些來自 source?無從得知 }
four-pass{ "shift_id": "S-4471", // Pass1 抄自 source "incidents": { "records": [ { "id": "L-9", "sev": "high", "class": "mechanical" } // Pass2 判斷 ] }, "handover": { "severity": "high" // source 值,非生成 }, "timeline": [ { "t": "08:14", "e": "valve trip", "evidence": "L-9" } // Pass4 溯源 ] }
左 one-pass:incidents.records 是空陣列、handover 的 severity 子欄位整個缺,而且分不清哪個值來自 source。右 four-pass:deterministic 欄位(shift_id、severity、timestamp)標綠表示直接抄自 source,event class 是 Pass 2 判斷、timeline 掛上 Pass 4 的 evidence。示意輸出,形態對應原文描述的失敗模式與 pipeline 產物。

左 one-pass:incidents.records 是空陣列、handover 的 severity 子欄位整個…

one-pass 默默漏掉 required 子欄位、丟空陣列;four-pass 把確定性欄位釘死在 source 上,差別在資料契約而非格式。

這份對照刻意只是示意——原文沒有公布逐欄的 JSON sample,具體欄位是按原文描述的失敗形態與 pipeline 結構復現的。真實輸出大概欄位更多、巢狀更深,但差異性質不變:one-pass 的不合規來自模型生成時默默丟東西,four-pass 把確定性欄位釘死在 source 上、把模型限制在它該判斷的格子裡。

右邊那個 evidence 欄位是 Pass 4 的產物,也是最值得單獨看的一塊。前三段把摘要組裝完之後,模型才被請來做最後一件事:替每個結論標出它是根據哪幾筆 source record 得出的,並附上 confidence。這等於把「這句話憑什麼這樣說」這個追問,從讀者的腦袋裡前移到輸出本身——讀摘要的人不必再回頭翻原始 log 去核對,溯源連結就掛在結論旁邊。它呼應的是文章開頭那三條 Responsible AI 原則裡的可問責性:一個結論錯了,現在能順著 evidence 連結追回到具體是哪筆 record、哪個決策點。不過這也是整條 pipeline 裡作者最沒把握的一段,理由下面會講。

值得把 four-pass 的代價也擺上檯面,免得讀成「拆開全是好處」。它換來合規與可驗證性,付出的是系統複雜度:要維護一個 field registry、要寫並測試 Pass 1 的確定性抽取與 Pass 3 的守衛式合併、要為 Pass 2 的封閉集合輸出加驗證、要處理模型偶爾吐出集合外值時的重試。對一個欄位數不多、容錯也高的場景,這套機制可能是 over-engineering;它真正划算的前提是「輸出有明確資料契約、下游對缺漏零容忍」——shift log 摘要正好落在這個前提裡。判斷要不要照搬這套架構,第一個該問的不是「它效果多好」,而是「我的輸出有沒有一個必須被機械保證的契約」。

最後要把限制講清楚,免得把這個案例讀成普世真理。原文自己標了兩條 caveat。第一,「all evaluation ran against synthetic logs designed to cover structural diversity, not real operational data」——所有評測跑在為涵蓋結構多樣性而設計的 synthetic log 上,不是真實 operational data。0%→100% 是在這個受控前提下量到的,真實世界的噪訊、缺漏、格式漂移會不會讓 100% 退下來,文章沒有回答。第二,Pass 4 evidence mapping 是「the newest and least validated」——它把溯源這件事也交回給模型,而這恰恰是整篇論證「別讓模型負責它不擅長的事」之後,唯一一段把判斷以外的工作(連結 source record)也壓在模型身上的地方,作者自己也標了它驗證最少。

What this enables:把每個輸出欄位先問一句「有沒有 1:1 對應的輸入值」,再讓軟體保證確定性事實、讓模型只做明確且狹窄的機率性判斷——「Deterministic facts should be guaranteed by software. Probabilistic judgments should be delegated to the model, explicitly and narrowly.」這條原則適用於任何 LLM 結構化抽取,不只 shift log。