iT邦幫忙

2026 iThome 鐵人賽

DAY 30
0
AI 自動化

情報進來,內容發出去:30 天拆一條每天真的在跑的 AI 內容產線系列 第 30

Day 30|換一種工作,還能照同一張圖做出來嗎?

  • 分享至 

  • xImage
  •  

過去十四天,我在同一條內容產線上累積了一整套規則:觸發要有時間窗、中斷要分 retry/resume/stop、部分成功要對帳不能整批回滾、環境要逐項對契約、規則改了要有回歸閘門、長期運作要有健康報告。這些規則聽起來很通用,但它們全部只在一件事上驗證過:把情報變成貼文,發出去。

一套規則只在一種工作上驗證過,我沒辦法分辨它是真的抓到了通用原理,還是剛好適合這個場景的巧合設計。要知道答案,得拿一件完全不同的工作去試。這個系列剛好有現成的:Day 11 到 15 做的會議紀錄垂直切片,跟內容產線除了都用 AI、都要人工複核之外,輸入輸出的形狀完全不一樣。

這不是臨時起意的順手實驗。2026-09-06 核准的系列規劃裡,就已經把「Day 30 把內容產線長出的責任圖回套到會議切片、驗證可移植」寫成正式的驗收項目,跟其他二十九天的產物一樣要交出證據,不是寫到最後一天才想到可以順便試試看。

今天是第十五天,任務不是再新增第十五種契約,是回頭檢查前面十四種裡有多少真的通用。十四種全部搬一遍,寫出來的會是一張勾選表,每一項都打勾,但沒有一項真的被驗證過。我挑兩組真的動手搬:對帳邏輯,跟中斷恢復。一組搬過去很順,另一組搬到一半發現行不通。後者其實比前者更值得寫,它告訴我原本的設計裡藏著一個我自己也沒發現的假設。

搬得順的那組:對帳邏輯

Day 26 的對帳模組裡,真正做決定的只有一個函式:

// examples/content-automation/day26-reconciliation.mjs
export const decideReconciliation = (externalTruth) => {
  if (!externalTruth || !EXTERNAL_TRUTH_STATUSES.includes(externalTruth.status)) fail("invalid_external_truth_status");
  const decision = {
    ERROR: "RETRY",
    IN_PROGRESS: "RETRY_QUERY",
    PUBLISHED: "CONTINUE",
    DUPLICATE_PUBLISHED: "HUMAN_REQUIRED",
  }[externalTruth.status];
  if (!DECISIONS.includes(decision)) fail("unmapped_reconciliation_decision");
  return { decision, reason_code: REASON_BY_DECISION[decision] };
};

開頭跟中間那兩行 fail(...) 只做輸入驗證:真相物件格式不對,或狀態值不在四選一之內,就丟例外中止。這兩行不影響它是純函式的宣稱,同一個不合法輸入永遠丟同一個例外,沒有引入任何跟輸入無關的行為。

輸入是外部查到的真相,四種狀態之一;輸出是四選一的決定。這個函式不讀寫任何外部狀態,給它同一個真相物件,永遠得到同一個決定,換句話說它是個純函式。純函式的正式定義要求兩件事:相同輸入永遠得到相同輸出,而且沒有副作用(不動非區域變數、不動可變參照引數、不碰 I/O)。它不依賴呼叫時的環境,這正是它能被原封不動搬到另一個網域的技術基礎。

我把它搬到 Day 14 的場景:會議行動候選送去建立待辦,用意圖識別碼防止重複建立。Day 14 原本的假供應端是同步、純記憶體的,呼叫一定會得到明確答案,沒有「回應遺失」這種故障。我補了一個會實際建立待辦、但故意讓呼叫端拿不到回應的版本,模擬「外部其實成功了,只是這次呼叫沒收到確認」。

接下來的問題是:怎麼知道外部到底發生了什麼?我寫了一個翻譯層,把 Day 14 的供應端狀態轉成 decideReconciliation 看得懂的格式:

// examples/meeting-automation/day30-reconciliation-port.mjs
export function queryProviderTruth(providerState, intentId) {
  const matches = providerState.idempotency_records.filter(
    (record) => record.intent_id === intentId,
  );
  if (matches.length === 0) return { status: "ERROR", remote_ref: null, remote_refs: null };
  if (matches.length === 1) return { status: "PUBLISHED", remote_ref: matches[0].first_result.task_ref, remote_refs: null };
  return { status: "DUPLICATE_PUBLISHED", remote_ref: null, remote_refs: matches.map((r) => r.first_result.task_ref) };
}

翻譯層寫完,decideReconciliation 直接 import 進來用,一行都沒改。這系列原本的慣例是每支 day*.mjs 自包含、互不 import,今天這一支刻意破例:這是同一個 repo、同一個 process 裡的跨目錄呼叫,不是真的跨系統搬遷,但決定邏輯本身確實原封不動。跑起來的結果:呼叫遺失後,查詢正確找回已建立的待辦,決定是接續,不重新建立。我也真的把同一個請求重送一次(不是模擬,是實際再呼叫一次),結果的 outcome 是 replayed,資源數量還是一個,冪等鍵確實擋住了重複。這條路徑寫了 5 個測試,全部通過。

這裡要老實承認一件事:狀態名稱 PUBLISHEDDUPLICATE_PUBLISHED,理由碼像 duplicate_publication_detected,其實還帶著內容發布網域的痕跡,不是完全中立的詞彙。把 PUBLISHED 套到 Day 14 的任務建立場景,對應的其實是「任務已建立」,這層翻譯是我自己判斷過才敢用的,函式並不知道。

比詞彙不中立更大的落差藏在 ERROR 這一格。翻譯層把「查無這筆紀錄」對應成 ERROR,但 Day 26 給 ERROR 配的理由碼是 retried_after_confirmed_failure,字面上是「已確認的失敗」。查無紀錄不等於已確認失敗,也可能是紀錄還沒寫進去、或查詢時機不對。這個落差我在寫翻譯層時就知道,選擇接受它,因為就算判斷錯了,冪等鍵會把後續的重送擋成 replay,不會真的造成重複建立,但這不是「沒有風險」,是「風險有另一層安全網接住」,兩者不一樣,測試裡有一條案例專門確認這個理由碼真的會被發出來。移植成功不代表詞彙自動通用,是這次剛好我翻得過去,而且翻得不夠精確的地方,靠的是另一層機制兜住。

跑完還有一個意外發現:decideReconciliation 的四個分支,這個網域只用得到兩個。DUPLICATE_PUBLISHED 這個分支,只要呼叫端乖乖走正常路徑就不會被觸發,因為 Day 14 供應端狀態的驗證邏輯強制同一個意圖識別碼不能出現兩筆紀錄。我手動構造一份違反這個唯一性的髒狀態、直接餵給正常的建立函式,確實會被擋下、拋出例外,這條也寫了測試。但這個保證只在「有經過驗證」的路徑上成立。我寫的翻譯層自己不會重新驗證它收到的狀態,如果狀態是從別的地方(例如跨程序讀檔)繞過驗證進來的,翻譯層一樣會老實回報 DUPLICATE_PUBLISHED。保證的來源在驗證那一層,不是翻譯層自己給的,這個界線我在測試裡特別寫了一條案例確認。RETRY_QUERY 對應的 IN_PROGRESS 也一樣沒有觸發路徑:Day 14 的供應端呼叫是同步的,沒有「還在處理中」這種中間態可以事後查到。函式沒有被閹割,四個分支都還在,只是這個網域比原本要解決的問題更簡單。

John Hughes 那篇談函式式程式設計的經典論文指出,高階函式與惰性求值能讓程式拆成獨立設計的小塊,事後重新組合。這篇論文講的機制(高階函式、惰性求值)decideReconciliation 其實一個都沒用到,借的只是同一個角度:程式可以拆成獨立設計的小塊,不用為了組合而互相修改。decideReconciliation 真正能被原封不動搬過來的技術基礎,是前面那條純函式的定義本身,就是相同輸入永遠得到相同輸出、沒有副作用。移植過來不用改一行,新網域用得上兩個分支,用不上的兩個分支保持原樣,不受影響,這是純函式獨立於呼叫環境的直接結果。

搬不動的那組:恢復契約

Day 17 的恢復契約,決定邏輯裡有一條分支專門處理「斷在等人審閱的時候」:

// examples/content-automation/recovery-contract.mjs
} else if (
  trustedCheckpoint.workflow_status === "waiting_for_human" &&
  interruptionPoint === "waiting_for_human" &&
  errorClass === "not_applicable" &&
  effectObservation.scope === "human_review_request" &&
  effectObservation.state === "not_applicable"
) {
  decision = {
    action: "resume",
    reason_code: "human_request_restored",
    resume_from: "waiting_for_human",
    next_workflow_status: "waiting_for_human",
  };
}

它問的問題是:恢復時,那個審閱請求還在不在?只要還在,就回到原本等待的狀態,繼續等。

Day 12 的會議身分複核,也有一段「等人審閱」的邏輯,我原本以為能直接對上。查了程式碼才發現不是同一件事:

// examples/meeting-automation/review-workflow.mjs
if (!sameSource(request.source_artifact, currentSourceArtifact)) {
  return reissueForChangedSource(state, currentSource, decision, payloadDigest);
}

Day 12 真正在乎的不是「審閱請求還在不在」,是「等待審閱的這段時間,來源逐字稿有沒有換了一個版本」。如果來源換了,它不會回到原本的等待狀態,是把舊請求標記過期,重新產生一份新版本的審閱請求。這條分支,Day 17 的模型裡完全沒有對應物,因為它假設中斷的原因永遠是流程本身出了問題(當機、逾時、暫時性錯誤),從來沒想過「等待的時候,題目本身變了」這種情況。

這不是介面對不上這麼簡單的問題,是兩邊「等待人工」這個狀態名處理的是不同層次的問題:一個是執行層事故,一個是業務層變化。Eric Evans 在 DDD Reference(2015-03 版)裡給的定義剛好點出這個問題的形狀:脈絡(context)是決定一個詞或陳述意義的場景,對模型的陳述只能在某個脈絡裡被理解。Day 17 跟 Day 12 都用「等待人工」這個詞,但一個定義在「流程執行」的脈絡,一個定義在「輸入來源」的脈絡。這裡借的是「脈絡」這個定義本身,不是「限界脈絡」(bounded context)那個處理同一系統內多個模型互相碰撞的完整模式(它們是兩支獨立模組,沒有共用模型,也不存在互相碰撞的問題):同一個詞,換了脈絡,意義不會自動跟著搬過去。硬要塞進同一個狀態機,得先幫 Day 17 的錯誤分類加一種新的類別,還要重新界定這個新類別跟既有的暫時性錯誤、永久性錯誤、流程中斷之間的分界,這已經不是移植,是重新設計。

Joel Spolsky 那條「洩漏抽象法則」是另一個方向的同類現象:所有非平凡的抽象,多多少少都會洩漏底層細節,原文講的是程度,不是只有某些場合才漏。Day 17 對「等待人工」的抽象,在內容產線這個底層情境下運作得很好;換到會議產線這個不同的底層情境,抽象洩漏了,它藏著的假設跟新情境的真實情況對不上。這個假設在內容產線裡從來沒被戳破過,不是它比較完整,是內容產線那條線根本沒有「等待批准的時候草稿被換掉」這種情境需要處理,運氣好,沒踩到這顆石頭。換一種工作,石頭就自己冒出來了。

我沒有在今天把新設計做出來,決定誠實記下這個發現,不硬湊一個看起來能動、實際上把兩種語意混在一起的版本。

移植差異表

Day 16 到 29 累積了十四種契約,跟 Day 15 的會議切片交叉比對,分成三類:

類別 契約 結果
真的動手驗過 Day 26 對帳 → Day 14 冪等執行 成功,5/5 測試通過
真的動手驗過 Day 17 恢復 → Day 12 人工複核 中止,兩邊「等待人工」語意衝突
有雛形、待做 Day 19 架構判準 對應會議切片全程用固定程式做完的隱含選擇
有雛形、待做 Day 21 路由 對應四模組分流
有雛形、待做 Day 22 分支合併 對應三路版本化執行
有雛形、待做 Day 25 發布狀態 對應 awaiting_human_review 的等待態
有雛形、待做 Day 28 回歸閘門 對應既有的 20/20 專項測試
完全沒有對應 Day 16、18、20、23、24、27、29 觸發、成品流程分離、代理能力、修稿迴圈、多代理裁決、環境契約、存活健康:會議切片目前沒有排程、部署、代理呼叫、反覆修稿或長期執行歷史,移植前得先幫會議產線補上這些場景才有東西可搬

今天沒動手的十二種不代表判定為不可移植,只是份量超過一天能誠實驗完的範圍。

三十天的東西,今天證明了什麼

Day 1 定的目標,是跟著一條參考實作,把一次性任務做成可重跑、可檢查、可暫停、可恢復、不越權替人做決定的工作流;系列地圖把這個目標拆成十項具體能力。今天用得上、也真的有新證據的兩項:「用假客戶端或沙盒驗證發布、部分成功、對帳與恢復」,這項不只在原本場景驗證過,換一個完全不同的執行網域也站得住;「把同一套藍圖移植到另一項重複工作並提交驗證證據」,今天做到的是這一項的一小塊:一個決定函式搬過去成功,另一個契約搬過去中止,十四種裡另外十二種還沒動手。不能說整套藍圖都驗證過了,只能說這一項能力今天有了第一份證據,不是全部。

兩組移植的結果不一樣:對帳邏輯換個完全不同的網域,程式碼一行不改就能用;恢復契約帶著一個沒寫出來的假設,換個網域就現形,需要重新設計才能用。這是這次移植實驗實際拿到的結果,不是預先設定好要證明「一半一半」的結論。沒有真的做過就不能宣稱做得到,搬不動的地方要老實記下來,不能為了收尾好看就假裝順利。

系列到這裡停在這個結論。內容產線那條參考實作,加上今天證實過的一次成功移植與一次誠實的中止,就是這三十天能負責任交出去的東西。

參考資料


上一篇
Day 29|跑了但沒事,跟沒跑,看起來一樣
系列文
情報進來,內容發出去:30 天拆一條每天真的在跑的 AI 內容產線30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言