第 1 天那篇的標題是「我想跟一個 AI Agent 認識」。
一個。
31 天後回頭看,這個系列最大的誤導可能就藏在那個量詞裡。因為真正讓我停下來反覆讀 code 的問題,幾乎沒有一個是出在那第一隻身上——它們全部出現在第二隻之後。
所以最後一天我想做一件跟前 30 天都不一樣的事。
前 30 天我一直在讀別人的系統。今天我想設計一個。
底座我會選 OpenAI 的 Agents SDK,因為它把單一 agent 那一層做得很完整,我不需要重新發明 Runner loop。
而 OpenClaw 是我的參考設計——因為它是我這 31 天唯一從頭讀過、而且真的在跑蜂群的系統。它踩過的坑,我不想再踩一次。
一句話定調今天的內容:
SDK 給我地基和掛載點,OpenClaw 告訴我該往上面蓋什麼。
先盤點地基。這幾樣我會直接用,不會自己寫。
Agent 的本體是一個迴圈,SDK 把它包成 Runner:
from agents import Agent, Runner
agent = Agent(name="Assistant", instructions="You are a helpful assistant")
result = Runner.run_sync(agent, "Write a haiku about recursion in programming.")
print(result.final_output)
底下是一個重複的序列:呼叫模型 → 看輸出 → 是最終答案就結束,是 handoff 就換 agent 重來,是 tool call 就跑工具然後重來。
關鍵是 max_turns。超過就丟 MaxTurnsExceeded。
這個設計看起來無聊,實際上是保命用的。第 10 天那篇「Agent 為什麼會忘記自己在做什麼」講過,迷路的 agent 不會安靜停下來,它會很努力地一直試。沒有上限的迴圈不是有彈性,是沒有煞車。
SDK 原生給了兩種,而且分得很乾淨。
Handoff(交接)——控制權轉移:
triage_agent = Agent(
name="Triage",
handoffs=[refund_agent, order_agent],
)
接手的 agent 繼承完整對話歷史,並且成為後續回合的當前 agent。原本那隻退場了。
Agent as tool(借用)——控制權不轉移。包成工具呼叫,跑完把結果交回來,主導權從頭到尾在你手上。
差別在於誰對最後的結果負責。handoff 之後,原本那隻的 instructions、目標、判斷標準全部退場;as tool 則完全不退。
而 handoff() 還給了幾個參數,其中兩個在蜂群裡非常重要,等一下會回來講:input_filter 和 input_type。
Guardrails,而且名字取得很清楚:
@input_guardrail —— 使用者輸入進來時@output_guardrail —— agent 要吐出去時@tool_input_guardrail —— 工具要被呼叫時@tool_output_guardrail —— 工具結果要回來時每個回傳 GuardrailFunctionOutput,裡面 tripwire_triggered 是一個布林值。踩到絆線,Runner 立刻丟 InputGuardrailTripwireTriggered,執行停住。
而且它留了一個很誠實的取捨:run_in_parallel 預設 True,跟 agent 同時開跑,延遲最低,但絆線觸發時 token 可能已經燒掉了;設成 False 則 agent 根本不會開始跑,一毛不花,但要多等檢查的時間。
Session 是持久記憶層,自訂的話要實作四個方法:
# 介面示意
async def get_items(limit=None): ... # 把歷史拿出來
async def add_items(items): ... # 存進去
async def pop_item(): ... # 把最後一筆收回來
async def clear_session(): ... # 清掉
pop_item() 我第一次看到是愣了一下的——它讓「使用者說剛剛講錯了」變成一個廉價操作,不用清掉整段重來。
後端也開得很寬:本地 SQLite、分散式 Redis、正式環境 SQLAlchemy,還有把歷史丟到 OpenAI Conversations API 的版本。壓縮則有 SessionSettings(limit=N) 這種輕的,和 OpenAIResponsesCompactionSession 這種包在別的 session 外面的殼。
output_type 可以指定結構化輸出這是今天整篇的關鍵。
RunHooks 讓你在執行過程中掛回呼:
on_llm_start / on_llm_end
on_agent_start / on_agent_end
on_handoff
on_tool_start / on_tool_end
為什麼重要?因為接下來我要蓋的七樣東西,幾乎全部都掛在這裡。
SDK 沒有直接給我蜂群需要的功能,但它給了一個夠好的觀察點。有了 on_tool_start 我可以做熔斷,有了 on_agent_start / on_agent_end / on_handoff 我可以記帳。
這就是我說的「地基和掛載點」。
地基盤完,接下來是這 31 天真正的收穫。
每一項我都會先講 OpenClaw 怎麼做(參考設計),再講我要怎麼蓋在 SDK 上。
SDK 缺什麼
SDK 的兩種語意都不符合蜂群最常見的需求:
但蜂群真正要的是第三種——派出去,我繼續做別的。
OpenClaw 怎麼做
它有 sessions_spawn,開一隻在別的 session 裡跑的子代理。而且它把「派出去之後主 agent 該怎麼辦」直接寫進了 prompt:
📄 原始碼:
src/agents/openclaw-tools.ts
Auto-announce is push-based. After spawning children, do NOT call sessions_list,
sessions_history, exec sleep, or any polling tool. Track expected child session
keys. Continue any independent work. If your final answer depends on child output,
wait for runtime completion events to arrive as user messages and only answer
after completion events for ALL required children arrive.
這段教了主 agent 四件事:不要輪詢(連 exec sleep 都禁止)、記住你在等誰、能做的先做、等全部到齊再回答。
而且結尾還處理了競態:如果完成事件在你回答之後才到,只回 NO_REPLY。
為什麼這條這麼重要
因為輪詢的成本是乘法。一隻 agent 輪詢五次是五次模型呼叫;五隻各自輪詢五次,加上主 agent 輪詢它們——token 就這樣沒了,而且沒有任何工作被完成。
更糟的是,輪詢會把主 agent 的上下文塞滿「還沒好、還沒好」,把真正重要的內容擠出去。
我的設計
spawn 做成一個 function tool,呼叫之後立刻回傳 handle,真正的 run 丟到背景跑:
@function_tool
async def spawn_agent(role: str, task: str) -> str:
"""派出一隻子 agent 並立刻回傳它的 id,不等它完成。"""
run_id = new_id()
asyncio.create_task(_run_child(run_id, role, task)) # 不 await
return f"spawned {run_id}" # 立刻回去
完成事件則用 SDK 的 session 回灌:子 agent 跑完之後,把結果以一則使用者訊息的形式 append 到父 session,下一輪父 agent 自然就看到了。
然後——這是最關鍵的一步——把不准輪詢寫進主 agent 的 instructions。
架構上支援事件推送還不夠。模型的直覺就是去查,你得明白地告訴它不准查。這一點 OpenClaw 用一整段 prompt 在防,我沒有理由覺得自己不需要。
SDK 缺什麼
SDK 沒有「目前有哪些 run 在跑」的註冊表。Runner.run() 回傳結果,但沒有一個地方能讓你事後問「現在有幾隻在跑」。
OpenClaw 怎麼做
它在子代理或 ACP 跑起來時,會自動開一份流程記錄。官方文件的說法是:
📄 文件:
docs/automation/taskflow.md
...so detached spawns get a stable flow handle for status and retry surfaces
without a controller.
a stable flow handle —— 一個穩定的把手。
而且它把三個動詞做到底:
openclaw tasks flow list [--status <status>] [--json]
openclaw tasks flow show <lookup> [--json]
openclaw tasks flow cancel <lookup>
我的設計
一張表,三個動詞,用 RunHooks 維護:
class SwarmRegistry(RunHooks):
async def on_agent_start(self, context, agent):
db.upsert(run_id=ctx_run_id(context), agent=agent.name, status="running")
async def on_agent_end(self, context, agent, output):
db.update(run_id=ctx_run_id(context), status="succeeded")
然後 list / show / cancel 三個介面全部讀這張表。
這一點比聽起來重要。蜂群最令人焦慮的不是它做錯事,是你不知道它在做什麼。一個 list 指令就能解決大半焦慮,而成本極低——狀態反正都要存。
SDK 缺什麼
SDK 的 Agent 各自帶自己的 tools,這很乾淨。但沒有一個集中的地方可以問「這隻 agent 被允許用什麼」,也沒有上下文預算的概念。
蜂群裡你不會希望那隻負責整理會議記錄的 agent 能碰部署工具。
OpenClaw 怎麼做
它把這件事做在 skill 這一層,而那個函式的註解寫得特別好:
📄 原始碼:
src/skills/discovery/agent-filter.ts:21-35
/**
* Explicit per-agent skills win when present; otherwise fall back to shared defaults.
* Unknown agent ids also fall back to defaults so legacy/unresolved callers do not widen access.
*/
export function resolveEffectiveAgentSkillFilter(
cfg: OpenClawConfig | undefined,
agentId: string | undefined,
): string[] | undefined {
const agentEntry = resolveAgentEntry(cfg, agentId);
if (agentEntry && Object.hasOwn(agentEntry, "skills")) {
return normalizeSkillFilter(agentEntry.skills);
}
return normalizeSkillFilter(cfg.agents?.defaults?.skills);
}
註解第二句是重點:
Unknown agent ids also fall back to defaults so legacy/unresolved callers do not widen access.
不認識的 agent id,不會擴權。
這是 fail-closed。系統遇到不認識的 agent,退回預設值,而不是「不知道就全部給你」。蜂群裡 agent 會越加越多、常常是動態的,fail-open 的話每一個 typo、每一個舊設定都是一個漏洞。
同一個檔案還有另一個函式,管的是 maxSkillsPromptChars——每隻 agent 可以個別限制能力描述佔多少字元的 prompt。這是 per-agent 的上下文預算。
第 6 天那篇講「一次把工具叫太多 Agent 會怎麼壞掉」,講的是單一 agent 的上下文被工具描述撐爆。蜂群裡這問題要乘以 N,所以預算也得能分開給。
我的設計
一個集中的解析函式,組裝 agent 的時候過一次:
def tools_for(role: str) -> list:
allowed = ROLE_TOOLS.get(role, DEFAULT_TOOLS) # 認不得就用預設,不是全開
return [t for t in ALL_TOOLS if t.name in allowed]
agent = Agent(name=role, instructions=..., tools=tools_for(role))
一行 ROLE_TOOLS.get(role, DEFAULT_TOOLS),但重點在那個 default 是最小集合,不是全部。
另外用 @tool_input_guardrail 當第二道:就算工具不小心被裝上去了,呼叫的當下再擋一次。
SDK 缺什麼
這是 SDK 最明顯的缺口,也是我認為最該補的一個。
SDK 的 Session 解的是對話持久化,不是進度持久化。你可以把五隻 agent 的對話都存起來,但沒有任何地方寫著「這整件事的目標是什麼、現在到第幾步、卡在誰身上」。
要做到,你得自己接 Temporal 那類耐久執行框架,或自己刻一層。
OpenClaw 怎麼做
TaskFlow,定位只有一句話:
Task Flow is the orchestration layer above background tasks. ... Flows survive
gateway restarts; individual tasks remain the unit of detached work.
task 是做一件事的單位,flow 是編排一連串事的單位。 而且 flow 要跨 gateway 重啟活著。
記錄裡有三個欄位我要抄:
goal 不是 optional。目標寫在 SQLite 裡,不在上下文裡。這解的正是第 10 天那個問題——派出去的子代理越多,主 agent 的上下文被回報塞得越滿,目標掉得越快。把目標搬出去,模型可以忘,系統不會忘。
blockedTaskId 和 blockedSummary:卡住的時候不只標記,還要記下卡在哪一隻、為什麼。
還有那個我最想抄的狀態:
| `blocked` | A step finished without a usable result; `blockedTaskId`/summary say which |
某一步跑完了,但沒有產出可用的結果。
這在蜂群裡每天都會發生。你派一隻去改設定,它跑完了、沒報錯,回報:「我查過了,這需要管理員權限,我沒有。」
那隻 agent 成功了——完整執行、正確回報、沒崩潰。
但流程卡住了——事情一步都沒往前。
如果任務結果只有成功和失敗兩個值,這兩種會被歸成同一類。然後你看到一片綠色,以為一切順利。
我的設計
帳本跟 session 分開,自己一張表:
@dataclass
class Flow:
flow_id: str
goal: str # 必填,不給 default
status: str # running / waiting / blocked / succeeded / failed / cancelled
current_step: str | None
blocked_run_id: str | None # 卡在哪一隻
blocked_reason: str | None
revision: int # 樂觀鎖
goal 不給 default,讓它在建立的時候就非填不可。
子 agent 的輸出用 output_type 強制帶上第三種結果:
class StepResult(BaseModel):
outcome: Literal["done", "blocked", "failed"] # 三個值,不是兩個
summary: str
blocker: str | None = None
revision 那個樂觀鎖也要,因為蜂群一定會有並發寫入——你在下取消、某隻正好回報完成、清理程序正在掃描,三個撞在一起。OpenClaw 那個欄位的存在,就是被並發咬過的證據。
蜂群需要兩種煞車,擋的是不同的東西。
SDK 缺什麼
SDK 只有 max_turns——數次數。這擋得住「一直跑」,擋不住「一次太多」,也擋不住「兩隻互相彈」。
OpenClaw 怎麼做(煞車一:併發上限)
📄 原始碼:
src/agents/embedded-agent-runner/run/lane-controller.ts:88-96
if (snapshot.queuedCount > 0 || snapshot.activeCount >= snapshot.maxConcurrent) {
params.onLaneWait({
waitMs: 0,
queuedAhead: snapshot.queuedCount + snapshot.activeCount,
waiting: true,
});
}
maxConcurrent 是上限,queuedAhead 是前面有幾個,而且這個數字會往上傳。
第二個才是重點。很多系統有併發上限,但塞住時什麼都不說,使用者只看到「沒反應」。
OpenClaw 怎麼做(煞車二:互彈熔斷)
兩隻 agent 互相呼叫,A 問 B,B 問 A。每一輪看起來都合理,整體沒有任何進展。這是蜂群特有的失控模式。
📄 原始碼:
src/agents/tool-loop-detection.ts:20-25
type LoopDetectorKind =
| "generic_repeat"
| "unknown_tool_repeat"
| "known_poll_no_progress"
| "global_circuit_breaker"
| "ping_pong";
觸發之後它不是直接砍掉 session,而是把這段話塞回去給模型看:
CRITICAL: You are alternating between repeated tool-call patterns (N consecutive
calls) with no progress. This appears to be a stuck ping-pong loop. Session
execution blocked to prevent resource waste.
把煞車做成對話。 而且門檻是階梯:warning 10 次、critical 20 次、全域熔斷 30 次。
順帶一提,OpenClaw 的 loop detection 預設是關的。因為誤判的代價可能比 loop 本身更糟——把正常輪詢當成鬼打牆擋掉,使用者會完全不知道發生什麼事。一個保命機制預設關著,比它開著更誠實。
我的設計
併發用 Semaphore,但一定要把排隊深度報出去:
sem = asyncio.Semaphore(MAX_CONCURRENT)
waiting = 0
async def run_child(...):
global waiting
waiting += 1
notify_ui(queued_ahead=waiting) # 關鍵:要讓人看得到
async with sem:
waiting -= 1
return await Runner.run(...)
熔斷掛在 on_tool_start,存最近 N 筆工具呼叫的雜湊,偵測交替模式:
class LoopBreaker(RunHooks):
async def on_tool_start(self, context, agent, tool):
self.history.append(hash_call(agent.name, tool.name))
if is_ping_pong(self.history[-30:]):
raise SwarmStuck("你正在來回呼叫且沒有進展,已停止以免浪費資源")
我會抄 OpenClaw 那個「告訴模型」的做法,而不是靜靜中斷——把訊息寫成給模型看的,而不是給 log 看的。
SDK 缺什麼
SDK 可以取消一個 run,但沒有「取消一整群、而且跨重啟仍然有效」的概念。
OpenClaw 怎麼做
`openclaw tasks flow cancel` sets a sticky cancel intent on the flow, cancels its
active child tasks, and refuses new managed child tasks. ... The intent is
persisted, so a cancelled flow stays cancelled even if the gateway restarts
before all child tasks have terminated.
三個關鍵詞:sticky(會留下來的意圖)、refuses new(不准再生新的)、persisted(寫進資料庫)。
最後那個尤其重要。沒有它,你會遇到最糟的情況:你取消了,系統重開,然後它若無其事地繼續跑。
還有一個細節。OpenClaw 判斷「取消完成了沒」時,連已經標成 cancelled 的子代理都還算 pending:
📄 原始碼:
src/tasks/task-cancellation-state.ts:5-13
export function isProvisionalSubagentKillTask(
task: Pick<TaskRecord, "runtime" | "status" | "error">,
): boolean {
return (
task.runtime === "subagent" &&
task.status === "cancelled" &&
task.error === SUBAGENT_KILL_TASK_ERROR
);
}
因為那個 cancelled 是我們單方面貼的標籤——發了 kill,但還沒確認對面真的停了。所以叫 provisional。
「我已經下令了」和「它已經停了」是兩件事。
我的設計
取消意圖寫進帳本,而不是只存在記憶體:
def cancel_flow(flow_id: str):
db.set_cancel_intent(flow_id, at=now()) # 先落地,重啟後還在
for run in db.active_runs(flow_id):
run.abort() # 盡力而為,不保證立刻停
# spawn 的入口加一道
def spawn_agent(flow_id, ...):
if db.has_cancel_intent(flow_id):
return "flow is cancelling, refused" # 拒絕新生
然後 flow 要等到所有子 run 都進終局才標成 cancelled,不是下令的當下就標。
SDK 缺什麼
SDK 的 run 結束就是結束,final_output 拿到就算完。沒有「這個結果送出去了沒」的狀態。
單一 agent 不需要,因為對方就是使用者。蜂群需要,因為對方可能是另一隻已經死掉的 agent。
OpenClaw 怎麼做
📄 原始碼:
src/tasks/task-registry.types.ts:24-31
export type TaskDeliveryStatus =
| "pending"
| "delivered"
| "session_queued"
| "failed"
| "parent_missing"
| "not_applicable";
parent_missing —— 子任務做完了,要回報,結果要回報的對象不見了。
session_queued —— 結果送到了,但對方的 session 正在忙,先排隊。
這兩個都不是失敗,也都不是成功。
這其實是第 13 天那篇結論的延伸:真正的完成不是做完,而是結果回來並被整合。當時那句話比較像感想,到這裡它變成了具體的狀態值。
我的設計
送達自己一個狀態欄位,而且父節點消失是預期內的路徑:
def deliver(parent_run_id: str, payload):
parent = db.get_run(parent_run_id)
if parent is None or parent.is_terminal():
db.mark_delivery(status="parent_missing", payload=payload) # 留著,不要丟掉
return
session.add_items([as_user_message(payload)])
db.mark_delivery(status="delivered")
parent_missing 的時候不要丟掉結果。那隻 agent 花了錢跑出來的東西,父節點死了不代表它沒價值——至少要能被人撈出來看。
不會一開始就做動態蜂群。 先寫死三隻、角色固定。動態生成 agent 很酷,但你會同時 debug「它為什麼這樣分工」和「它為什麼做錯」,兩個問題混在一起無解。
不會讓 agent 自己決定生幾隻。 上限由外面給。模型對「這件事需要幾個人」沒有直覺,而它猜錯的代價是你的帳單。
不會超過兩層。 主 agent 派子代理可以,子代理再派孫代理先不要。每多一層,追蹤成本和失控機率都是乘的。
不會一開始就抄 OpenClaw 的完整 admission 與 trust 矩陣。 那個複雜度是它要當平台逼出來的。我的蜂群如果不是平台,那些縫就只是縫。
七樣東西不要一起蓋,會蓋不完:
output_type、max_turns。蜂群是第二階段的問題,不要跳級as_tool 而不是 handoff。 先體驗控制權不轉移的版本,比較好 debuglist 指令。 掛 RunHooks,這時候你會第一次真正看到蜂群的樣子input_filter 或 input_type。因為到這一步,帳單開始不好看了goal 必填、結果三個值。 因為到這一步,你已經被重啟弄丟過進度了你會發現這個順序,大致就是這 31 天撞問題的順序。
這不是巧合。OpenClaw 會長成今天這樣,也是因為它一路上依序撞到了這些東西。
max_turns、handoff 與 as-tool、四個 guardrail 掛載點、Session、自動 schema、內建 tracingRunHooks 是最重要的那道縫,蜂群要補的東西幾乎全部掛在上面goal 必填、結果三個值、樂觀鎖max_turns 擋不住「一次太多」和「互相彈」,併發上限和 ping-pong 熔斷要自己補31 天前我想「跟一個 AI Agent 認識」。
現在我會說:認識一隻 agent 是入門,理解一群 agent 才是這個領域真正的門檻。
因為單一 agent 的問題,大多是模型問題——它懂不懂、會不會用工具、有沒有照指示做。這些會隨著模型變強而自然變好,你今天為它寫的很多 workaround,兩代之後就可以刪掉。
但蜂群的問題幾乎全部是系統問題:誰記得進度、誰在等誰、誰該停下來、誰負責收尾、結果送給誰。
這些不會因為模型變強就消失。模型再聰明兩代,你的五隻 agent 還是需要一本共同的帳,還是需要有人決定誰先跑,還是會有一個父節點在子任務回報前就先死掉。
這也是為什麼我讀 OpenClaw 讀了 31 天不覺得浪費。我不是在學一個工具怎麼用——那些 API 明年可能就變了。我是在看一個系統為了讓一群 agent 一起工作,最後被迫長出了哪些東西。
那份清單,換到任何框架上都還是成立的。
這 31 天沒寫到的還很多——gateway、node、media、auth profile,每一塊都還能再寫 31 天。但我想這個系列的任務已經完成了。
謝謝陪我走完這 31 天。
我們下個系列見。