iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0

每次我要找今天可以寫什麼,會啟動一條「候選報告」流程。

它不是直接叫 AI 憑空想五個題目。前半段會先收集今日情報,後半段再讀昨天貼文的實績,對照發文前預測與近期基準,最後重新排列今天的候選題目。Ci 看到的是報告台上的候選清單,底下其實已經換過好幾次手。

今天先放大最前面的情報掃描。這段 daily-intel 工作流真的有三條分支,而且就是我啟動候選報告時會跑的那一段:

const scans = await parallel([
  () => agent("掃描腳本來源與 GitHub Trending"),
  () => agent("掃描 Threads 公開內容"),
  () => agent("掃描 X 與 AI 圈新聞"),
]);

const raw = scans.filter(Boolean).join("\n\n---\n\n");

這是把實際提示詞縮短後的控制流,三條分支名稱與合併方式沒有改。它們完成後,下一個判斷 Agent 會讀取 raw,產出熱點、候選預審、發文建議、海巡清單與資料源狀態。候選報告再補進昨天的成效校準,最多留下五個候選,轉成 HTML 上報告台。

三份都有內容時,filter(Boolean) 看起來很俐落。只要其中一份變成空值,麻煩就來了。

這個空值到底是「查完了,但沒有符合條件的資料」,還是「來源不存在」?是逾時、程式出錯,還是整個分支根本沒有把結果交回來?

filter(Boolean) 不知道。它只知道這個值是空的,所以把它丟掉。

後面的判斷 Agent 收到兩段完整的文字,照樣能排出一份候選清單。也正因為報告讀起來很順,我們反而更難發現第三份來源已經消失。

目前這條實際流程會要求判斷 Agent 列出資料源狀態,opencli 沒訊號時也規定改走 WebSearch fallback。但提示詞要求「記得說明缺口」,和程式保證「缺口一定會留下」仍是兩件事。只要前一層已經把空值濾掉,後一層就只能依手上的文字猜發生了什麼。

所以接下來的 branch-result.v1 與合併器,是我針對這條真實流程補做的參考實作,目前還沒有接回正式候選報告。本文要示範的是怎麼改,不會把改造後的設計說成早已在線上運作。

這篇要處理的不是怎麼把三個 Promise 寫在一起,而是多來源工作流真正困難的地方:每條分支要交回什麼,少一份時該怎麼判斷,以及合併器憑什麼決定繼續或停下來。

Day 19 到 Day 21,我依序拆了要不要用 Agent、它能碰哪些工具,以及下一步要往哪裡走。今天把單一路徑展開成多條,再把結果收回同一個出口。

下面講實作。

第一步:先確認這三件事真的能一起做

平行執行很容易讓人先想到速度,但程式的第一個問題應該是依賴關係。

如果 B 要讀 A 找到的關鍵字,B 就不能和 A 同時開始。如果三個分支會改寫同一份檔案,或同時觸發互相衝突的外部動作,也不能因為寫成陣列就當作彼此獨立。

我在這次的參考實作裡採用一個保守規則:只有 depends_on 是空陣列的分支,才能進平行模式。

function assertParallelizable(branches) {
  const dependent = branches.find(
    (branch) => branch.depends_on.length > 0,
  );

  if (dependent) {
    throw new Error(
      `dependency_not_parallelizable:${dependent.branch_id}`,
    );
  }
}

這不是說有依賴的工作不能執行,而是它應該走順序流程,或先把依賴圖拆成多個階段。平行化不會消除依賴,只會把原本的依賴問題藏得更深。

用內容產線來看,三個來源若都只需要同一份凍結後的題目與查詢條件,就有機會一起查。如果第二個來源必須等第一個來源找出人名才能開始,兩者就是前後步驟,不是平行分支。

這也是 Anthropic 在 Agent 實作文章裡談 parallelization 時的基本形狀:任務能拆成彼此獨立的區段,才適合分開執行後再由程式聚合。

第二步:不要讓分支只交回一段文字

確定可以一起跑之後,下一個問題才是分支的回傳格式。

如果每條分支只回傳 string | nullnull 會同時代表太多事情。合併器拿不到來源身分、不知道工作是否完成,也無法判斷要不要重試。

所以我替每條分支定義一份 branch-result.v1。下面是文章裡會用到的精簡版:

const branchResult = {
  schema_version: "branch-result.v1",
  branch_id: "source-a",
  source_id: "fixture:source-a",
  required: true,
  status: "ok",
  coverage: "claims_present",
  claims: [
    {
      claim_id: "claim-shared",
      value: "alpha",
      evidence_locator: "fixture:source-a#shared",
    },
  ],
  error_code: null,
};

重點不在欄位多,而是每個分支都要回答三件事:

  1. 我是誰,我查的是哪個來源?
  2. 我有沒有把工作做完?
  3. 我交回來的主張,要去哪裡找原始依據?

有了這層介面,「沒有文字」就可以拆成不同狀態:

結果 status / coverage 後續意義
查完但沒有符合項目 ok / complete_no_matches 正常完成,不必假裝失敗
指定來源不存在 not_found 依來源必要性決定停止或降級
時間內沒有完成 timeout 可以重試,也可能必須停下
執行過程失敗 error 保留錯誤碼,不產生主張
預期有分支,卻沒收到結果物件 missing 由協調層記下缺少的分支 ID

前四種是分支主動交回的狀態,第五種則要由協調層比較「預期收到什麼」和「實際收到什麼」才能發現。

這裡最容易混淆的是合法空結果和 not_found。前者是查詢真的完成,只是答案為空;後者是連指定的目標都不存在。兩者都不會產生正文素材,但重試與完整度完全不同。

第三步:執行器負責等待,合併器負責判斷

如果是 JavaScript,我會用 Promise.allSettled() 保留每一條工作的結束狀態:

async function runIndependentBranches(branches) {
  assertParallelizable(branches);

  const settled = await Promise.allSettled(
    branches.map((branch) => runBranch(branch)),
  );

  return settled.map((item, index) => {
    if (item.status === "fulfilled") return item.value;

    return {
      schema_version: "branch-result.v1",
      branch_id: branches[index].branch_id,
      source_id: branches[index].source_id,
      required: branches[index].required,
      status: "error",
      coverage: "incomplete",
      claims: [],
      error_code: "branch_rejected",
    };
  });
}

Promise.allSettled()只會告訴我們每一項是 fulfilledrejected。它不知道 source-a 是必要來源,也不知道 not_found 能不能接受,更不會檢查兩筆成功結果是否互相矛盾。

所以非同步 API 只負責「等工作結束」。內容產線自己的規則,必須交給另一個合併器。

為了把合併器的三個出口都測到,參考案例先用一條較嚴格的測試政策:A、B 是必要來源,C 是選用來源。這只是單元測試的配置,不是目前候選報告三條掃描的正式對應。真正接回流程時,必要性仍要在執行前定義,不能等看到哪一份比較容易取得才決定。

function decideCompleteness({
  requiredUnavailable,
  optionalUnavailable,
  conflicts,
}) {
  if (requiredUnavailable) {
    return {
      completeness: "blocked",
      stop_reason: "required_branch_unavailable",
      consumer_output_allowed: false,
    };
  }

  if (conflicts.length > 0) {
    return {
      completeness: "blocked",
      stop_reason: "unresolved_conflict",
      consumer_output_allowed: false,
    };
  }

  if (optionalUnavailable) {
    return {
      completeness: "partial",
      stop_reason: "optional_branch_unavailable",
      consumer_output_allowed: true,
    };
  }

  return {
    completeness: "complete",
    stop_reason: null,
    consumer_output_allowed: true,
  };
}

這裡有三個出口:

  • complete:需要的分支都可用,而且沒有衝突。
  • partial:必要分支完整,只有選用分支缺少。可以交給下一層處理,但不代表可以直接發布。
  • blocked:必要分支缺少,或來源之間仍有衝突。就算手上還有部分素材,整份結果也不能往下交。

最後再由合併器產生 merge-decision.v1,保存預期分支、實收分支、可用主張、衝突與停止原因。這樣下游拿到的不是一段看似完整的文字,而是一個能回答「為什麼可以繼續」的決定。

Promise.all() 遇錯即停,其他工作不一定真的停了

這裡還有一個非同步程式很常見的誤會。

Promise.all()在其中一項拒絕後,外層 Promise 會立刻拒絕。但外層已經失敗,不代表其他底層工作也被取消。

我替這件事寫了一個很小的本機探針:第一條 Promise 先拒絕,第二條先停在等待狀態;外層收到錯誤後,再手動放行第二條,它仍然完成。整個過程沒有送出取消要求。

如果底層操作支援 AbortController,我們可以傳入 AbortSignal 要求中止。不過這是合作式取消,底層程式要願意觀察訊號才有用。

所以實作時最好把三件事分開記:是否提出取消、底層是否收到,以及工作最後是否真的結束。只看外層 Promise 的狀態,還不夠描述分支的生命週期。AWS Step Functions 的 Parallel 文件也特別提醒,狀態停止後,已經叫起來的 Lambda 仍可能繼續執行。

第四步:同一個主張有兩個答案,不要叫 AI 幫忙圓

三份來源都成功回來,仍然不代表可以直接合併。

假設 A 和 B 都交回 claim-shared,A 的值是 alpha,B 的值卻是 beta。最危險的作法,是把兩段文字直接丟給模型,要求它整理成一段順暢的結論。文字會變順,但衝突不會因此消失。

合併器應該按 claim_id 收集觀察值:

function mergeClaims(results) {
  const claimsById = new Map();

  for (const result of results) {
    if (result.status !== "ok") continue;

    for (const claim of result.claims) {
      const observations = claimsById.get(claim.claim_id) ?? [];
      observations.push({
        branch_id: result.branch_id,
        value: claim.value,
        evidence_locator: claim.evidence_locator,
      });
      claimsById.set(claim.claim_id, observations);
    }
  }

  return [...claimsById].map(([claimId, observations]) => {
    const values = new Set(observations.map(({ value }) => value));

    if (values.size > 1) {
      return {
        claim_id: claimId,
        resolution: "unresolved",
        observations,
      };
    }

    return {
      claim_id: claimId,
      value: observations[0].value,
      evidence_locators: observations.map(
        ({ evidence_locator }) => evidence_locator,
      ),
    };
  });
}

相同值可以整理成一筆主張,並保留所有證據位置。不同值則進入 conflicts,從可用主張中排除,再把整份合併結果設為 blocked

合併器在這裡沒有判斷 alphabeta 誰才是真的。它只做一件比較適合交給程式的事:確認衝突存在,保留兩邊的來源,不讓文字模型把衝突改寫成共識。

可追溯也不等於已判真。W3C PROV-DM提供的是描述來源關係的模型。能沿著 evidence_locator 找回原始位置,只代表這個主張有來處,最後是否採用仍要看來源品質與人的判斷。

第五步:誰先完成,不該決定誰比較重要

平行分支的完成順序可能每次都不同。如果合併器直接照回來的先後組資料,最快的來源就會偷偷變成第一順位。

這次的參考實作會按照政策事前宣告的分支順序輸出,再用 claim_id 和來源識別整理主張。測試把 A、B、C 的完成時間反轉後,完成順序跟著改變,最後的分支狀態、可用主張與衝突結果仍然相同。

Google Cloud Workflows 的平行步驟文件明確說分支執行順序不保證;Amazon States Language 的 Parallel State則會依分支定義順序組成輸出。各平台細節不同,但程式設計上的提醒一樣:執行先後不該偷偷變成內容權重。

我怎麼測這個合併器

老實說,多來源合併最難測的不是三份都成功,而是每種「少一份」都要有不同答案。

我先寫好 7 組固定合成案例,再把預期結果放進 parallel-oracle.json。這樣不管走順序或平行模式,都要面對同一份事前答案。

案例 預期結果
三條來源都成功,而且主張相容 complete
選用來源找不到 partial
必要來源逾時 blocked
選用來源執行錯誤 partial
必要來源對同一主張互相衝突 blocked
選用來源查完但沒有符合項目 complete
選用來源整張結果物件缺席 partial

順序與平行等待全部分支各跑一次,兩邊都是 7/7 符合事前答案,結果同樣是 2 組 complete、3 組 partial、2 組 blocked。兩種模式合計啟動 42 次腳本分支,收到 40 份結果;少掉的兩份,都被辨識為原本預期存在的選用分支,而不是被當成空字串略過。

虛擬排程裡,順序模式合計 289 邏輯刻度,平行模式是 141 邏輯刻度。這個數字只能說明固定測試資料下的排程差異,不能寫成實際加速成果。這次沒有呼叫真實模型、外部網路、工具、檔案寫入或發布,也沒有量牆鐘時間。

測試在這篇裡是配角。它要回答的是:換一種執行順序,合併器會不會做出不同決定;不是替正式內容產線宣稱效能或成功率。

放回真正的候選報告流程

現在可以把整條線接回來看:

  1. 先讀 opencli 收集的 X/Threads 公開頁;某一邊失敗就留下來源缺口。
  2. 啟動三路情報掃描:腳本源、Threads 公開內容、X 與 AI 新聞。
  3. 判斷層整理熱點、海巡清單與資料源狀態,寫成每日情報 Markdown。
  4. 候選報告刷新上一則 Threads 實績;只有資料完整而且是新鮮快照,才能參與候選重排。
  5. 最多留下五個候選,轉成 HTML 上私有報告台,最後由 Ci 選要寫哪一篇。

這條流程其實已經有兩種「少一份」的處理原則。

第一種發生在情報入口。opencli 沒訊號或失敗時,可以用 WebSearch 補缺口,但報告必須保留原本的來源狀態,不能把 fallback 寫成 opencli 成功。

第二種發生在昨日成效校準。如果 tracker 刷新失敗、資料不完整或已經過期,情報掃描仍可保留,但舊績效不能參與候選重排,也不能下「昨天變好或變差」的結論。

真正還沒補齊的,就是三路 parallel() 回來的那個接點。現在它先把空值濾掉,再把文字交給判斷 Agent。改造後,每條掃描都應交回來源識別、狀態、時間範圍與候選清單;合併器再決定這份報告是 complete、帶缺口的 partial,還是資料不足而必須停止排序。

整條程式主幹最後其實很短:

assertParallelizable(branches);

const results = await runIndependentBranches(branches);
const decision = mergeBranchResults(policy, results);

if (!decision.consumer_output_allowed) {
  return handoffToHuman(decision);
}

return handoffToReview({ results, decision });

接回現有流程後,handoffToReview() 對應的就是判斷 Agent 與後續候選校準;handoffToHuman() 最後則回到報告台,讓 Ci 看見缺了哪個來源,而不是收到一份假裝完整的排行。

真正重要的都藏在函式邊界裡:分支有沒有依賴、每條結果是否保留狀態、來源缺失能不能 fallback、昨日績效是否仍能參與排序,以及下游是否真的會服從 blocked

這也是我今天最想留下的四個實作原則:

  1. 平行以前先畫依賴,不要看到三個函式就一起跑。
  2. 分支回傳結構化結果,不要只回文字或空值。
  3. 執行器與合併政策分開,allSettled 不能取代內容判斷。
  4. 缺席與衝突都要留下來,別讓 AI 把不完整的材料寫成完整答案。

filter(Boolean).join(...) 並沒有寫錯,它只是負責整理字串。當我們需要知道少了哪一份、為什麼少,以及剩下的資料能不能用,回傳介面就不能再只是一段文字。

Day 22 走到這裡,多來源流程已經能產生一個可重算的合併決定。Day 23 接著處理另一個很實際的問題:第一版不合格時,AI 到底要改幾次?輸出不再變、開始來回震盪,或已經超過上限時,流程要怎麼停下來交人?

參考資料


上一篇
Day 21|下一步該寫成規則,還是交給 AI?
下一篇
Day 23|稿子沒過關,AI 要改幾次?
系列文
情報進來,內容發出去:30 天拆一條每天真的在跑的 AI 內容產線30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言