每次我要找今天可以寫什麼,會啟動一條「候選報告」流程。
它不是直接叫 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 | null,null 會同時代表太多事情。合併器拿不到來源身分、不知道工作是否完成,也無法判斷要不要重試。
所以我替每條分支定義一份 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,
};
重點不在欄位多,而是每個分支都要回答三件事:
有了這層介面,「沒有文字」就可以拆成不同狀態:
| 結果 | 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()只會告訴我們每一項是 fulfilled 或 rejected。它不知道 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 仍可能繼續執行。
三份來源都成功回來,仍然不代表可以直接合併。
假設 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。
合併器在這裡沒有判斷 alpha 或 beta 誰才是真的。它只做一件比較適合交給程式的事:確認衝突存在,保留兩邊的來源,不讓文字模型把衝突改寫成共識。
可追溯也不等於已判真。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 邏輯刻度。這個數字只能說明固定測試資料下的排程差異,不能寫成實際加速成果。這次沒有呼叫真實模型、外部網路、工具、檔案寫入或發布,也沒有量牆鐘時間。
測試在這篇裡是配角。它要回答的是:換一種執行順序,合併器會不會做出不同決定;不是替正式內容產線宣稱效能或成功率。
現在可以把整條線接回來看:
這條流程其實已經有兩種「少一份」的處理原則。
第一種發生在情報入口。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。
這也是我今天最想留下的四個實作原則:
allSettled 不能取代內容判斷。filter(Boolean).join(...) 並沒有寫錯,它只是負責整理字串。當我們需要知道少了哪一份、為什麼少,以及剩下的資料能不能用,回傳介面就不能再只是一段文字。
Day 22 走到這裡,多來源流程已經能產生一個可重算的合併決定。Day 23 接著處理另一個很實際的問題:第一版不合格時,AI 到底要改幾次?輸出不再變、開始來回震盪,或已經超過上限時,流程要怎麼停下來交人?