iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
AI 自動化

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

Day 16|排程啟動後,這一輪要處理哪些新資料?

  • 分享至 

  • xImage
  •  

2026-09-08 晚上 22:49,我坐在電腦前查自己的兩條排程,只想知道一件很簡單的事:你們今天到底有沒有做事?

一條是每日情報,抓當天的新訊息寫成一份 Markdown。腳本在,註解寫著每天 17:03 執行,但我查的時候,找不到對應的系統服務。情報檔累積了 53 份。

另一條是影片輪詢,定時去問來源清單有沒有新影片。服務有在系統清單裡,最後一次退出狀態是 0。候選數紀錄有 97 條,其中 88 條是 0。

看起來資訊很多,其實我什麼都沒問到。

53 份情報和 97 條紀錄都只是存量,不是「跑了幾次」。中間有些是我手動跑的,日期還缺了六天。而那個退出狀態 0 更不能讓我安心,它只說腳本正常收尾,沒說那一輪抓到東西。

所以我想知道的那題,兩份紀錄都答不出來:這一輪,到底有沒有新資料要處理?

那個讓我坐直的發現

我把影片輪詢腳本裡所有正常退出的位置全部翻出來,一共七處,整理起來是六類:

退出前發生什麼 實際意思
設定檔或來源網址缺失 這輪連開始的條件都不夠
必要指令缺失 執行環境不完整
單實例鎖被別人持有 另一輪可能在跑,這輪略過
來源清單抓取失敗 向來源要資料失敗了
待處理候選為零 來源有回,只是現在沒工作
試跑模式 只列候選,不做後續處理

六類,全部 exit 0。

「缺設定」跟「正常沒有新影片」,在我自己寫的腳本裡長得一模一樣。當初我一定覺得「這些都不是錯誤啊,回 0 很合理」。是很合理,但代價是三個月後的我,看紀錄看不出差別。

這就是我要修的東西。不是修那支腳本(它照樣跑),是補上它沒留下的那一層:排程有沒有被叫到、來源有沒有回應、這一輪有沒有新東西,是三件事,不該共用一個「成功」。

先講三個我後面一直會用的詞。一輪=排程被叫到一次、該處理的那一批工作。候選=來源給我的一筆一筆東西(一支影片、一則訊息),還沒決定要不要處理。選料=從候選裡挑出這一輪真的要送下去的那幾筆,挑掉的也要寫原因。

三層,各自回答一題

我把一輪要留的紀錄拆成三層:

層次 回答什麼 最少要留什麼
觸發 原本排定何時?程式何時真的進來? 排定時刻、觀察到的時刻、觸發種類
來源 有沒有去問?來源回了嗎?回幾筆? 狀態、候選數、失敗類別
選料 哪些候選屬於這一輪又通過規則?哪些被排除、為什麼? 接受清單、逐筆排除原因、規則版本、最後決定

三層不能互相代表。觸發層有紀錄,只說程式被叫到了;來源回空集合跟來源根本回不了,是兩種完全不同的狀況;要到選料層,才輪得到下游 AI 出場。

順帶說一個我原本以為可以省的欄位:排定時刻和真的進來的時刻,我一度想只存一個。後來看到 Apple 自己的 launchd 文件寫,Mac 睡著時錯過的工作可以在喚醒後才啟動,關機期間錯過的要等下一個排定時間;GitHub Actions 的文件也提醒,排程在高負載時可能延遲、甚至被直接捨棄。

那就是兩件事了。只存一個「07:30」,之後我分不出準時、遲到,還是根本沒跑。

為什麼要兩個編號

我原本也只想給一輪一個編號。逼我加第二個的是這件事:一次排定,不保證只進來一次。

Kubernetes 的 CronJob 文件講得很直白:排程是近似的,某些情況可能建立兩個 Job,也可能一個都沒建,所以工作本身要能處理重複。Google Cloud Scheduler 是至少一次投遞,同一個排定工作可能執行多次,官方建議用工作名稱加原定排程時間辨認重複。

所以分成兩個:

一輪識別碼代表「邏輯上的同一輪」。它由產線代號、排定時刻、時間窗起訖、選料規則版本組成一份固定 JSON,算 SHA-256,取十六進位輸出的前 32 個字元。同樣的輸入重算一定得到同一個值;我在測試裡改排定時刻、改時間窗起點、改規則版本,三次都跳出不同的值。

這次進來的編號由一輪識別碼加上一個正整數序號推導。這裡要誠實:序號是呼叫端自己給的,我沒有替多個程序配置或鎖定序號,所以能證明的只有「同一輪加同一個序號,一定重算出同一個編號」,不是跨程序不會撞號。

實驗裡有一路就在演這件事:同一個排定事件送兩次,一輪識別碼相同,這次進來的編號不同,第二次在還沒去問來源之前就結束了。

07:30 那一筆,算哪一輪

時間窗用「前含後不含」:起點算進去,終點不算。

下面這兩個窗是固定測試資料,時段刻意取得跟我真實排程一樣方便對照,但出現的日期與候選都不是那條線的紀錄:

A:[09-07 22:00, 09-08 07:30)
B:[09-08 07:30, 09-08 22:00)

邊界那一刻不屬於 A、屬於 B。兩窗不重疊,同一瞬間不會被兩輪同時處理。

這個寫法其實有人早就解過。RFC 5545 那份行事曆規格,就把開始時間當包含、結束時間當不包含,相鄰的區間可以共用邊界,又不會讓同一瞬間屬於兩邊。第 10 天處理影音定位時我用過同一種判定,今天只是把對象從時間碼換成「一輪的資料範圍」。

然後我故意把 B 的起點往前挪一秒。兩個窗真的重疊了,工作流在還沒去問來源之前就以「時間窗重疊」被拒絕,來源與 AI 呼叫都是 0。這是我特別想驗的:擋,要擋在花錢之前。

時間對了,還要看規則

落在時間窗內,只代表這筆候選屬於這一輪的範圍。它還要通過一個帶版本號的選料規則。

歸窗看的是候選自己的發生時間,不是我抓到它的時間。

而且結果不能只存「候選幾筆」。每一筆都要留下識別、去留、原因。不然日後只看到「2 筆」,還是不知道發生了什麼。

有候選但沒有一筆合格那一路,固定回兩筆:一筆發生在時間窗起點之前,以「不在時間窗內」排除;另一筆在窗內,但自己不符合規則,以「規則排除」排除。接受清單空的,決定是「沒有合格輸入」,去問了來源一次,AI 呼叫 0 次。

成功那一路也是兩筆,一筆進接受清單、一筆被規則排除。

順帶一個當初沒想到的效果:規則版本換掉,同一個時間窗也會算出不同的一輪識別碼。因為規則版本本來就是一輪識別碼的組成部分。換了選料規則,本來就不該算成同一輪。這是撿到的,不是設計出來的。

六種結果

看最後一欄就好:只有最後一路呼叫了 AI。

情境 來源狀態 候選數 最後決定 問了來源幾次/呼叫 AI 幾次
來源回空集合 有回應 0 沒有新工作(來源為空) 1/0
時間窗重疊 沒去問 不適用 拒絕(時間窗重疊) 0/0
重複觸發 沒去問 不適用 忽略(重複觸發) 0/0
有候選但全被排除 有回應 2 沒有新工作(沒有合格輸入) 1/0
來源回不了 失敗 不適用 停止(來源無法回應) 1/0
選出合格新資料 有回應 2 可以開始 1/1

五個 0,一個 1。

下游那個 AI 的入口只有一個條件:決定是「可以開始」,而且接受清單不是空的。時間窗重疊和重複觸發連來源都不去問;空集合、全被排除、來源失敗這三路雖然碰了來源,也不會把空白或錯誤的資料送進模型。

這裡用的是確定性的假呼叫端,我只驗「AI 有沒有被呼叫、收到哪些候選」。沒有呼叫任何外部模型,也沒有評估摘要品質,那不是今天的題目。

我最在意的那條分界

同一張表裡,有兩種「沒有新工作」和一種「停止」,這是我覺得今天最值得記住的地方。

來源為空:來源正常回應,候選數就是 0。今天真的沒有新東西。

沒有合格輸入:來源回了候選,但每一筆都被選料規則排除。有東西進來,只是都不該處理。

兩者都以「沒有新工作」結束,也都不呼叫 AI。但原因得分開存——不然我永遠不知道是來源乾涸了,還是我的規則太嚴。

至於來源無法回應,狀態是失敗,決定是停止。如果把它也記成零候選,我就分不出「今天真的沒有新東西」和「系統根本沒拿到資料」。這兩個看起來都是一片空白,但一個該睡覺,一個該去修。

HTTP 早就把這個責任分開了。RFC 9110 裡,204 是請求成功但沒有內容,503 是服務目前無法處理請求。都沒拿到內容,但語意完全不同。我用的是自己的狀態名,不是把 shell 的退出碼重新命名成 HTTP 狀態碼。

一輪的紀錄長什麼樣

先給一組對照,看程式欄位就不用猜:noop 是沒有新工作、ready 是可以開始、rejected 是拒絕、ignored 是忽略、stopped 是停止;lane_id 是哪一條產線、accepted_refs 是接受清單。

這是「來源回空集合」那一路保存下來的完整內容:

{
  "schema_version": "trigger-contract.v1",
  "lane_id": "content-intake",
  "trigger": {
    "kind": "scheduled",
    "scheduled_for": "2026-09-08T07:30:00+08:00",
    "observed_at": "2026-09-08T07:30:05+08:00"
  },
  "window": {
    "start_inclusive": "2026-09-07T22:00:00+08:00",
    "end_exclusive": "2026-09-08T07:30:00+08:00"
  },
  "selection_policy_version": "policy-v1",
  "run_id": "run_a860db97e33115dfb9030fc9986dd88c",
  "attempt_id": "attempt_9b92c6df2d1d4da4125b6de15d118f7f",
  "source_observation": { "status": "responded", "candidate_count": 0 },
  "selection": { "accepted_refs": [], "rejected": [] },
  "decision": { "status": "noop", "reason": "source_empty" }
}

時間全部帶時區,這是 RFC 3339 的格式。只存「07:30」這種沒有日期也沒有時區的字串,跨程序就重算不出來。

裡面沒有 AI 呼叫計數。那是外層稽核記的東西,屬於測試觀察,不是這份契約的欄位。這兩件事我刻意不放在一起。

六路的測試檔這次跑 16 條全過,含六種結果、識別碼重算、時間窗邊界與重疊拒絕,以及保存的稽核能被程式完整重算。

別人也在解同一個問題

寫到這裡我才發現,我以為自己在解一個很土的個人問題,其實它有一整排前例。

Apache Airflow 把一輪的資料區間、邏輯日期、實際開始時間分開存。對按時間處理資料的工作來說,邏輯日期代表資料區間的起點,排程器通常等區間結束後才建立那一輪,所以「處理哪一段資料」跟「程式什麼時候真的跑」本來就不是同一個時間。

AWS 的 EventBridge Scheduler 有「彈性時間窗」,那是允許在哪段時間內送達;跟我這裡的選料時間窗名字很像,責任完全不同。一個管投遞,一個管選料。

CloudEvents 規格要求事件的 id 在它的 source 範圍內唯一,消費者用 source 加 id 的組合辨認重複事件。這個拆法剛好可以拿來檢查我有沒有把「來源範圍」「事件識別」「時間」混成一個欄位。

這些都是相鄰機制,用來說明問題的形狀。我沒有用 Airflow、沒有用 EventBridge、也沒有發 CloudEvent;雲端排程器的重複與延遲行為,也不能拿來當我本機 launchd 的實測。我那兩條真實排程的健康度,22:49 那一次查核也只能說明查核當下,推不出其他日期。

這份紀錄是為明天留的

今天沒有做出什麼很炫的東西。做出來的是一張單子:這一輪原本排定何時、實際何時進來、時間窗到哪、來源回了什麼、哪幾筆被挑走、哪幾筆為什麼被丟掉、最後決定是什麼。

它的價值在明天。

第 17 天要問的是:這一輪跑到一半,程序掛了,重新啟動的時候該重試、該接續,還是該停?

那時候要判斷的依據,就是今天這份單子。所以我今天不寫恢復策略——沒有可靠的一輪紀錄,恢復只能靠猜。而猜錯的代價,可能是重複處理,也可能是永遠漏掉。

參考資料


上一篇
Day 15|從逐字稿包到可審會議報告,整條流程真的接起來了嗎?
下一篇
Day 17|AI 工作流跑到一半停了,怎麼接著跑?
系列文
情報進來,內容發出去:30 天拆一條每天真的在跑的 AI 內容產線18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言