上一篇講的是怎麼讓一個 agent 可靠地跑起來:運行循環、工具路由、上下文壓縮、可自愈的會話。那一層回答的是「單個 agent 怎麼不出岔子地走完一個任務」。這一篇講它上面的一層——當一個 agent 不夠用、一件事得拆給一個團隊來做時,會發生什麼。
這就是人們常說的多 Agent 編排(multi-agent orchestration):一個掌管對話的主 agent 把請求拆成若干塊,每塊交給一個專門的子 agent,等該等的那幾個跑完再開下一個,把結果往後傳,並在某一步失敗時把整件事穩在軌道上。Orkas 把這一整套完全跑在使用者自己的機器上。下面就拆一下這層編排是怎麼搭起來的——代碼做了脫敏和泛化,但結構是真實的。
主 Agent 與子 Agent
心智模型是一支指揮鏈清晰的小團隊。一個主 agent(我們叫它 commander)掌管對話和整體上下文。它不親自乾所有活,它的職責是決定什麼該做、按什麼順序、由誰來做。子 agent 則是專才——各自配著自己的系統提示詞、自己被允許用的工具、自己的一套技能。一個子 agent 只擅長其中一小塊工作,輪到那一塊時才被調起來。
有兩點讓它不只是個口號。第一,子 agent 和技能是一等單元,不是提示詞技巧:子 agent 是一個真實、單獨配置的 agent,派遣給它是一次帶著獨立上下文的真正交接。第二,協調不靠模型的良好意願——它由一份顯式的產物來驅動,而這份產物的真偽由系統、而非模型來保證。這份產物就是計劃(plan)。
計劃是一張圖,不是一段腳本
當主 agent 判斷一個請求需要不止一步時,它會寫一份計劃。這份計劃既不是自由的散文,也不是一條線性清單——它是一張小小的依賴圖(DAG)。每個節點是一步,每一步帶著編排器派遣它所需要的一切:
interface PlanStep {
index: number; // 1-based, stable, never renumbered
title: string; // human-readable, shown in the UI
assignee: string; // who runs it: "user" | "commander" | a sub-agent
input?: string; // the dispatch payload — a template (see below)
wait_for?: number[]; // upstream step indexes; defaults to [index - 1]
on_failure?: "abort_plan" | "continue" | "ask_commander";
// --- runtime state, owned by the orchestrator, NOT written by the model ---
status: "pending" | "in_progress" | "done" | "failed" | "skipped" | "blocked";
output_summary?: string; // short summary of what the step produced
output_files?: string[]; // files the step produced
failure_reason?: string;
}把列表變成圖的,正是 wait_for 這個字段。預設一步只等它前面那一步(一條簡單的鏈),但一步可以聲明它依賴前面好幾步——「寫總結」可能同時要等「調研市場」和「摸排競品」。這是一個菱形,不是一條直線,編排器把它當圖來處理。
誰說了算:編排器,不是模型
再看一眼那個結構體里的切分。模型在第一次寫計劃時填的是意圖——標題、執行者、輸入、依賴關係。但所有關於執行狀態的東西——status、output_summary、failure_reason——只歸編排器所有。模型只提一次計劃,它永遠沒機會把自己的步驟標成「done」。
這個分離是刻意的,也是整個設計里最重要的一個決定。語言模型完全可能在第 3 步明明報錯時,樂呵呵地宣佈「第 3 步完成」,也可能在一段長對話里跑到一半就忘了還有哪些步驟沒做。如果狀態活在模型腦子里,計劃就會和現實越漂越遠。把狀態做成一份只有執行器才寫、而且只在某一步真正結束時才寫的結構化產物,計劃就始終是「真實發生了什麼」的準確鏡像。模型決定工作的形狀,運行時決定關於進度的事實。
派遣那些已經就緒的步驟
計劃一旦存在,一個小引擎——執行器(executor)——就推著它往前走。核心動作是「找出就緒的步驟並派遣它們」。一步在它仍是 pending、且它所等待的每一步都到達了某個終態、且足夠成功時,才算就緒:
function findReadySteps(plan): PlanStep[] {
return plan.steps.filter((s) => {
if (s.status !== "pending") return false;
const deps = s.wait_for ?? (s.index > 1 ? [s.index - 1] : []);
return deps.every((d) => isTerminal(plan.step(d).status)); // done or skipped
});
}它不是靠定時器跑的,而是由事件驅動。每當一步結束——一個子 agent 返回了、commander 收尾了一輪綜合——執行器就做一次「對賬(reconcile)」:先記下剛結束那一步的結果,再重新掃一遍看哪些因此變就緒了,然後派遣出去。編排就是這個對賬循環,一輪接一輪地翻動,直到沒有步驟剩下。
派遣走的是同一個對話,不是旁路通道
有個選擇讓系統始終誠實:派遣一步用的不是一條隱藏的 RPC 通道。它是往使用者正盯著看的那個群組對話里發一條消息,以主 agent 的名義、@ 那個子 agent。在子 agent 看來,「被派遣」和「在聊天里被點名」沒有區別——它就是照常跑一輪。不存在第二條要和第一條保持同步的執行路徑。
執行者(assignee)可以是三種之一,每種派遣方式略有不同:
- 子 agent——最常見的情況。執行器把 agent 名字解析成 id,以 commander 的名義發出
@<agent> <rendered input>。子 agent 接住,跑一輪完整的 agent turn。 - 使用者——當某一步確實需要人來提供資訊時,這一步會變成一張表單並暫停計劃(下文細說)。在使用者回答之前,下游什麼都不會動。
- commander 自己——用於綜合或決策步驟(「把上面的都讀一遍,寫出總結」)。這是一次私密喚醒,不會再發一條多餘的、使用者可見的消息;主 agent 直接帶著收集到的上下文跑一輪。
把上下文從一步傳到下一步
一個團隊只有當工作能在成員之間流動時才有用。這個機制就是 input 模板。主 agent 寫一步時,輸入並不是一個凍死的字符串——它可以引用前面的結果,執行器在派遣那一刻把這些引用渲染出來:
// step 3.input, as written by the lead agent
"Using the findings below, draft the launch note.\n\n{{step_1.output_summary}}"
// what the sub-agent actually receives at dispatch time
"Using the findings below, draft the launch note.\n\n- Market is growing ~20% YoY; two incumbents…"注意往後傳的是什麼:output_summary,每一步完成後的一份簡短摘要——不是它的完整對話記錄。這是一個預算上的決定,背後是和 harness 上下文壓縮同一個直覺。如果每個下游步驟都繼承前面所有內容逐字逐句的完整歷史,上下文會迅速膨脹,幾跳之後成本就爆了。摘要讓每一次交接都很便宜,也讓每個子 agent 專注在它真正需要從上游拿到的東西上,而不是去趟上游「是怎麼走到這一步」的渾水。最初的使用者消息和任何附件也會一路帶著走,所以鏈條下游第三跳的步驟,依然知道最初的訴求是什麼。
預設串行——以及為什麼
你可能以為,當好幾步同時變就緒時——比如菱形的兩條分支——編排器會把它們一起並行發出去。它本可以;但今天它的做法是一次只派遣一個就緒步驟,按 index 取最早的那個,其餘的等下一次對賬。讓一整支團隊嚴格地一次只跑一個,是一個刻意的、保守的選擇,值得老實講清楚為什麼。
原因是併發下的正確性。設想兩個子 agent 幾乎在同一瞬間結束。兩個結束都會觸發對賬;兩次對賬都會讀計劃;兩次都看到同一個下游步驟還停在 pending——於是兩次都把它派了出去。現在同一步跑了兩遍。為了讓這件事根本不可能發生,對某個對話的每一次「讀取—修改—派遣」循環,都被串行化在一把以對話為粒度的鎖後面:
// all state-changing paths for one conversation run under one mutex
planLock(uid, cid).runExclusive(async () => {
const plan = await readPlan(uid, cid);
applyOutcomeOfFinishedStep(plan); // mark done / failed / skipped
await dispatchReady(plan); // dispatch the next ready step
});這把鎖保證「記錄什麼結束了」和「決定接下來做什麼」作為一個不可分割的整體發生,於是下游步驟永遠不會被派兩遍。有了這把鎖,一次派一個就是那種「一眼就看得出是對的」的最簡做法。真正的並行扇出(fan-out)是在這個地基之上一個完全可行的擴展——但地基是一個串行化、無競態的執行器,而這個優先級排序(先對,再快)正是關鍵所在。
當某一步出錯時
跑在真實機器上、對接外部模型 API,失敗是家常便飯,編排器把它分成幾類來處理,而不是把所有錯誤一視同仁。
它先問這次失敗是不是只是瞬時的——連接斷了、被限流、抖了一下。如果是,且這一步還沒燒光那一點點重試預算,就悄悄把它回滾到 pending,讓下一次對賬重新派遣。(這一層在 harness 自己的單輪重試之上;只有當 agent 自己的嘗試都用盡後,計劃才會重新派遣,而且有硬上限,免得一個真壞掉的步驟無限打轉。)
如果是真失敗,這一步聲明的 on_failure 策略決定團隊接下來怎麼走:
abort_plan——這一步重要到沒了它下游全都沒意義。把它標記為失敗,然後級聯:每一個還 pending 的步驟都標成skipped。計劃乾淨地停下,而不是在一個缺失的地基上繼續蓋。continue——這一步是可選的。把它標成skipped,讓下游步驟當它什麼也沒產出,照常往下走。ask_commander(預設)——既不盲目中止,也不盲目繼續。把它標記為失敗,喚醒主 agent 來看看發生了什麼、再決定——換個方式重試、繞過它、還是停下來問使用者。
還有第六種狀態值得單獨點出來:blocked。一個子 agent 跑到一半,可能意識到它需要只有使用者才能給的東西,於是拋出一張表單或一個問題。這一步不算失敗——它進入 blocked,整份計劃暫停。使用者一回答,執行器就對賬,團隊從剛才停下的地方原樣接著乾。一份 blocked 的計劃是暫停的計劃,不是壞掉的計劃。
每一步都是一次完整的 agent 運行
值得把環路接回上一篇。當編排器把一步派給一個子 agent 時,這個子 agent 跑的不是某種精簡版例程——它跑的是完整的 harness 循環:自己的流式運行循環、自己的工具呼叫、自己帶壓縮的上下文窗口、自己可自愈的會話。編排乾淨地坐在單 agent 運行時之上,從不伸手進它內部。主 agent 決定工作的形狀和交接的順序;每個子 agent 一旦接過自己那一塊,本身就是一個完整的 agent。
正是這種分層,讓兩篇文章能拼起來。harness 讓一個 agent 在一個任務上值得信任。編排器把若干個值得信任的 agent 拼成一支團隊,去啃一個對任何單個 agent 都太大、或太雜的任務。
幾個關鍵的決定
執行狀態歸編排器,意圖歸模型。 模型提計劃;只有運行時才把步驟標成 done、failed 或 skipped,而且只在真有事情發生時才標。就這一條邊界,讓計劃是現實的誠實鏡像,而不是模型樂觀的猜測。
派遣走同一個對話,不走旁路。 被派遣的一步只是主 agent 發給子 agent 的一條消息。一條執行路徑,沒有藏起來會跑偏的東西,使用者還能在自己正讀的那個會話里看著團隊乾活。
步驟之間傳摘要,不傳全文。 每次交接帶的是上游產出的一份簡短摘要。它讓長鏈條上的上下文預算保持理智,也讓每個子 agent 專注於它需要的東西,而不是前一個是怎麼得到的。
先對,再並行。 一把以對話為粒度的鎖把每一次「讀取—修改—派遣」循環串行化,步驟一次派一個。嚴格串行是那個「一眼看得出沒有重復派遣競態」的版本;並行扇出是疊在一個已經正確的地基上的優化。
小結
Orkas 的編排層核心沒有什麼奇異算法。它的價值在於守住了幾條邊界:計劃是依賴圖而不是腳本;執行狀態歸運行時而不歸模型;派遣流經使用者正看著的同一個對話;上下文以摘要的形式在步驟間流動;執行器把正確性擺在併發之前。每一條單看都簡單。合起來,它們把一個可靠的單 agent,變成一支會分工、會把工作往後傳、在某一塊出錯時還能恢復的團隊。
想看這一層下面那一層,去讀一個 agent 是怎麼被工程化得可靠運行的。想看讓每個 agent 越用越好用的那一層,去讀 Orkas agent 是怎麼從自己的工作里學習的。如果你更想直接指揮這一層,而不是自己造一套,Orkas 把它做成了跑在你自己機器上的開源 AI agent 編排。