
1. 從一場聯(lián)調(diào)事故說起Agent 之間為什么總失聯(lián)上個月我們內(nèi)部做一次多 Agent 協(xié)作的壓測場景很簡單一個規(guī)劃 Agent 接到用戶需求后拆出三個子任務(wù)分發(fā)給檢索 Agent、代碼生成 Agent 和質(zhì)檢 Agent。聽起來很常規(guī)對吧結(jié)果第一次跑通就翻了車。規(guī)劃 Agent 拿著子任務(wù)的執(zhí)行結(jié)果去做匯總發(fā)現(xiàn)三個子 Agent 的響應(yīng)里有兩個超時一個返回了別的主線的中間結(jié)果。日志里全是Connection refused和Task not found。當時我們幾個工程師對著監(jiān)控面板面面相覷每個 Agent 單測都是好的服務(wù)也都活著為什么一協(xié)作就變成一鍋粥問題出在一個被絕大多數(shù)人忽視的環(huán)節(jié)Agent 之間的尋址和投遞完全是裸奔的。單體 Agent 內(nèi)部怎么調(diào)函數(shù)都行一旦拆成多個 Agent 獨立部署、各自維護狀態(tài)你面對的就是一個分布式系統(tǒng)最經(jīng)典的三個難題——服務(wù)發(fā)現(xiàn)、可靠投遞、失敗恢復。當時市面上有不少編排框架但要么太重強制你上全套工作流引擎要么太輕只做 HTTP 轉(zhuǎn)發(fā)真正適合中小團隊自建 Agent 網(wǎng)絡(luò)的通信底座幾乎是空白。我花了三周時間寫了一個叫 Agent-Reach 的協(xié)調(diào)層組件用來解決上面這堆破事。這篇文章就把它的設(shè)計思路、核心實現(xiàn)、以及我在真實壓測中踩過的坑全部攤開講。適合正在搭多 Agent 系統(tǒng)、被互聯(lián)互通折磨得想罵人的同學參考。它不是銀彈但能讓你從天天救火變成睡個安穩(wěn)覺。2. 設(shè)計之前先把Agent 通信這件事拆明白2.1 三種通信模式你混用了嗎動手寫代碼之前我先把 Agent 之間的通信模式梳理了一遍。幾乎所有協(xié)作場景都逃不出這三種第一種是請求-響應(yīng)規(guī)劃 Agent 派活給執(zhí)行 Agent等它干完把結(jié)果拿回來。這是最常見的模式也是最容易出問題的因為一次請求的完整鏈路包含派活、執(zhí)行、回傳、確認任何一環(huán)斷了對調(diào)用方來說都是超時。第二種是事件廣播比如一個 Agent 監(jiān)測到配置變更需要通知所有相關(guān) Agent 刷新緩存。這種模式對送達的要求沒那么嚴格漏一兩個訂閱方可以接受但你不能把關(guān)鍵事件也做成廣播——那純粹是給自己埋雷。第三種是流式推送適合長時間運行的任務(wù)比如代碼生成 Agent 每產(chǎn)出一個文件就往倉庫 Agent 推一條消息。流式場景里真正難的不是推而是斷點續(xù)傳和消費位點管理。Agent-Reach 的核心思路很簡單所有模式統(tǒng)一抽象成消息只不過消息的投遞語義不同。請求-響應(yīng)是request類型廣播是event類型流式推送是stream類型再加上一個ack類型專門做確認回執(zhí)。類型一收攏后面的存儲、路由、重試邏輯就都能統(tǒng)一處理了。當時我自己的踩坑經(jīng)歷是第一版把所有通信都 HTTP 同步調(diào)以為最簡單。結(jié)果廣播一多Agent 之間互相等鏈路一長就雪崩。后來才意識到同步調(diào)用只能用于請求-響應(yīng)事件廣播和流式推送必須走異步隊列這是架構(gòu)層面的紅線不是優(yōu)化層面的喜好。2.2 注冊中心不Agent-Reach 用路由表 租約很多人的第一反應(yīng)是上 ZooKeeper 或者 etcd 做服務(wù)發(fā)現(xiàn)。但我做 Agent-Reach 時的約束條件很明確不想引入額外的基礎(chǔ)設(shè)施團隊里沒人愿意專門維護一個注冊中心集群。所以我把服務(wù)發(fā)現(xiàn)做成了基于租約lease的輕量路由表直接嵌在協(xié)調(diào)節(jié)點進程里。每個 Agent 啟動時向 Agent-Reach 的協(xié)調(diào)節(jié)點注冊自己注冊信息包括Agent 編號、服務(wù)名、能力標簽比如capability:codegen、回調(diào)地址、以及一個 TTL 租約。協(xié)調(diào)節(jié)點維護一張內(nèi)存路由表Agent 每隔 TTL/2 的時間續(xù)租一次。租約過期沒續(xù)上路由表就把這個 Agent 標記為不可用再等一個寬限期就徹底剔除。這套機制跟注冊中心比犧牲了跨節(jié)點容災(zāi)和歷史變更追溯換來了零依賴、毫秒級感知故障對小規(guī)模 Agent 網(wǎng)絡(luò)幾十個節(jié)點以內(nèi)完全夠用。你要問我什么時候該上 etcd當 Agent 數(shù)量超過一百、協(xié)調(diào)節(jié)點需要多活、或者你需要嚴格的版本兼容管理的時候再考慮不遲。路由表里還有一個容易被忽略的設(shè)計能力路由。調(diào)用方不指定具體 Agent 編號只聲明我要一個能做代碼生成的 Agent。協(xié)調(diào)節(jié)點會根據(jù)能力標簽、當前負載、租約新鮮度三個維度打分選一個最合適的投遞目標。這個設(shè)計讓 Agent 網(wǎng)絡(luò)具備了一定的彈性——某個 Agent 掛了能力路由會自動把任務(wù)分給同能力的另一臺。2.3 為什么必須引入收件箱我見過不少團隊做 Agent 通信直接在回調(diào)地址上做 POST然后把任務(wù) ID 掛在 URL 上。表面看能跑但有一個致命問題Agent 執(zhí)行任務(wù)期間可能重啟、可能斷網(wǎng)重啟之后回調(diào)地址可能都變了。這時候任務(wù)狀態(tài)就丟了你根本不知道它執(zhí)行到哪一步。Agent-Reach 給每個 Agent 維護一個持久化收件箱。所有發(fā)往該 Agent 的消息先進收件箱Agent 處理完一條彈出一條。收件箱的存儲我用的是本地嵌入式數(shù)據(jù)庫SQLite 就夠用單機場景沒必要上 PostgreSQL每條消息記錄message_id、type、payload、status、create_time、attempts六個字段。消息的流轉(zhuǎn)狀態(tài)機是pending → processing → done或者pending → processing → failed → pending待重試。收件箱的價值在于Agent 的消費進度不依賴進程內(nèi)存重啟后從數(shù)據(jù)庫里接著干。這本質(zhì)上就是 Outbox 模式在 Agent 場景的應(yīng)用。我當時想明白這一點之后很多任務(wù)神秘消失的問題都迎刃而解了。3. Agent-Reach 的核心實現(xiàn)注冊、路由、投遞、回執(zhí)3.1 注冊與租約續(xù)期的代碼骨架Agent 端的啟動注冊邏輯我用 Python 寫了個示例實際項目里不管什么語言套路是一樣的import asyncio import time import httpx class AgentClient: def __init__(self, agent_id, coordinator_url, callback_url, ttl30): self.agent_id agent_id self.coordinator_url coordinator_url self.callback_url callback_url self.ttl ttl self.lease_task None async def register(self, capabilities): payload { agent_id: self.agent_id, callback_url: self.callback_url, capabilities: capabilities, ttl: self.ttl } async with httpx.AsyncClient() as client: resp await client.post(f{self.coordinator_url}/register, jsonpayload) if resp.status_code 200: self.lease_task asyncio.create_task(self._keep_lease()) async def _keep_lease(self): interval self.ttl / 2 while True: await asyncio.sleep(interval) async with httpx.AsyncClient() as client: await client.post( f{self.coordinator_url}/renew, json{agent_id: self.agent_id} )這里有一個細節(jié)續(xù)租間隔我特意設(shè)為 TTL 的一半而不是 TTL。網(wǎng)絡(luò)抖動是常態(tài)如果間隔設(shè)成接近 TTL一次偶發(fā)超時就會導致 Agent 被誤判下線然后能力路由把任務(wù)發(fā)給別的 Agent造成重復執(zhí)行。TTL30 秒、每 15 秒續(xù)租一次既能容忍一定網(wǎng)絡(luò)波動又能讓故障感知控制在 30 秒內(nèi)算是一個經(jīng)過壓測驗證的平衡值。協(xié)調(diào)節(jié)點那端維護租約的數(shù)據(jù)結(jié)構(gòu)也很直接class LeaseRegistry: def __init__(self): # agent_id - {capabilities, callback_url, last_renew, status} self.agents {} def renew(self, agent_id): if agent_id in self.agents: self.agents[agent_id][last_renew] time.time() self.agents[agent_id][status] online def sweep(self, now, ttl, grace_period): # 定期清掃過期租約 for agent_id in list(self.agents.keys()): last self.agents[agent_id][last_renew] if now - last ttl: self.agents[agent_id][status] suspect if now - last ttl grace_period: del self.agents[agent_id]grace_period這個參數(shù)我吃過虧后面專門講?,F(xiàn)在先記住一個原則判定下線要慢標記可疑要快真正剔除要更慢。寧可讓一個 Agent 被短暫重復投遞也不能讓一個活著的 Agent 被永久剔除。3.2 消息投遞的三段式入隊、分揀、回執(zhí)消息從調(diào)用方發(fā)出到被 Agent 消費Agent-Reach 的處理管線是三段式的第一段入隊。調(diào)用方調(diào)用POST /dispatch協(xié)調(diào)節(jié)點把消息寫入目標 Agent 的收件箱狀態(tài)置為pending然后立刻返回一個message_id。注意這里不是同步轉(zhuǎn)發(fā)無論目標 Agent 是否在線消息都已經(jīng)落庫了。這個設(shè)計保證調(diào)用方不需要等目標 Agent 就緒消息一定不會丟。第二段分揀。Agent 端有一個長輪詢的消費者線程每隔幾百毫秒向協(xié)調(diào)節(jié)點拉取自己收件箱里statuspending的消息。為什么用拉而不是推因為推模式下 Agent 重啟期間推送的消息會堆積在協(xié)調(diào)節(jié)點的內(nèi)存緩沖里Agent 恢復后容易重復消費或漏消費。拉模式天然支持斷點續(xù)傳Agent 重啟后從數(shù)據(jù)庫里恢復自己的消費位點再繼續(xù)拉。這就是前面說收件箱的價值落地。第三段回執(zhí)。Agent 處理完消息后調(diào)用POST /ack傳message_id和處理結(jié)果。協(xié)調(diào)節(jié)點把消息狀態(tài)從processing改為done。如果 Agent 處理失敗調(diào)用POST /nack協(xié)調(diào)節(jié)點根據(jù)attempts字段判斷是重試還是進入死信。調(diào)用方怎么拿到最終結(jié)果每個request類型消息都有一個reply_to字段指向調(diào)用方自己的收件箱。目標 Agent 處理完結(jié)果作為一條reply消息進調(diào)用方的收件箱調(diào)用方拉取即可。你會發(fā)現(xiàn)每個 Agent 既是消息的生產(chǎn)者也是消費者整個網(wǎng)絡(luò)是對稱的。這個設(shè)計讓 Agent-Reach 天然支持多級流水線Agent A 派活給 BB 的回復進 A 的收件箱A 繼續(xù)往下派。3.3 重試策略指數(shù)退避與死信隊列消息重試是最容易拍腦袋設(shè)計的部分。我第一版寫的是固定間隔重試 3 次每次間隔 5 秒。實測下來問題很大如果目標服務(wù)因為過載而拒絕固定 5 秒重試三次等于在人家最忙的時候又捅了三刀。正確姿勢是指數(shù)退避加抖動。重試間隔 base_interval * 2^attempt再乘以一個隨機因子比如在 0.8 到 1.2 之間浮動。base_interval 建議 2 秒起最大間隔封頂 120 秒。同時設(shè)置max_attempts超過之后進死信隊列由人工或其他兜底 Agent 處理。這是我實測下來的重試參數(shù)表可以直接抄參數(shù)值說明base_interval2s首次重試的基礎(chǔ)等待時間max_interval120s重試間隔封頂避免無限拉長backoff_factor2指數(shù)增長的底數(shù)jitter0.8 ~ 1.2隨機抖動避免驚群max_attempts5超過進死信隊列dead_letter_ttl7d死信消息保留時間還有一個坑重試要盡量在協(xié)調(diào)節(jié)點做不要在 Agent 端做。如果你讓 Agent 自己重試Agent 重啟后重試狀態(tài)就丟了而且無法全局控制重試頻率。協(xié)調(diào)節(jié)點統(tǒng)一管重試才能和路由表聯(lián)動——目標 Agent 已經(jīng)下線了就直接把消息重新路由給另一個能力相同的 Agent而不是傻等。4. 壓測中踩過的三個坑你以為穩(wěn)了其實全是隱患4.1 坑一租約寬限期設(shè)置不當導致的腦裂式重復消費第一次壓測跑 50 個并發(fā)任務(wù)時我觀察到一個詭異現(xiàn)象同一份任務(wù)被執(zhí)行了兩遍而且兩個執(zhí)行 Agent 都不認為自己有問題。查下來的根因是租約寬限期設(shè)太短。我當時 TTL 設(shè) 15 秒寬限期設(shè) 5 秒。某次壓測時協(xié)調(diào)節(jié)點的 GC 停頓了 8 秒期間所有 Agent 的續(xù)租請求都沒處理。等 GC 結(jié)束協(xié)調(diào)節(jié)點認為所有 Agent 都過期了把任務(wù)重新路由給備用 Agent。原 Agent 其實只是沒續(xù)上租任務(wù)還在正常執(zhí)行于是兩邊同時處理產(chǎn)生重復結(jié)果。修復方案分兩層。第一層寬限期改成 TTL 的兩倍也就是至少 30 秒讓 GC 之類的偶發(fā)停頓不至于引起大規(guī)模誤判。第二層投遞時帶上execution_tokenAgent 每次真正開始執(zhí)行前要把 token 從pending搶到processing這個操作是原子的。誰搶到 token 誰執(zhí)行搶不到的自動放棄。這相當于給消息投遞加了一把分布式鎖重復路由的問題從根上被堵住了。4.2 坑二Agent 間循環(huán)調(diào)用引發(fā)的消息風暴Agent 協(xié)作系統(tǒng)有一個特別隱蔽的陷阱循環(huán)調(diào)用。A 給 B 派活B 處理不了轉(zhuǎn)給 CC 覺得應(yīng)該找 A于是一圈一圈轉(zhuǎn)下去。由于每條消息都在收件箱里有記錄這個循環(huán)不會造成傳統(tǒng)意義上的死循環(huán)——它是會結(jié)束的但每一步都在產(chǎn)生新消息最終把協(xié)調(diào)節(jié)點的消息表撐到幾十萬行。我在壓測里設(shè)計了一個查不到數(shù)據(jù)就轉(zhuǎn)給上一個 Agent 補充的流程結(jié)果三條消息在四個 Agent 之間轉(zhuǎn)了幾十輪消息表膨脹到十幾萬條SQLite 的寫入性能崩了。解法有兩個缺一不可。第一個是跳數(shù)上限每條消息加一個max_hops字段默認 10每經(jīng)過一次轉(zhuǎn)發(fā)減一歸零就把消息丟進死信。第二個是轉(zhuǎn)發(fā)軌跡追蹤協(xié)調(diào)節(jié)點在消息的trace字段里記錄所有經(jīng)過的 Agent 編號每次轉(zhuǎn)發(fā)前檢查 trace如果發(fā)現(xiàn)目標 Agent 已經(jīng)在自己這條軌跡里說明出現(xiàn)了環(huán)直接終止并告警。這兩個機制加起來循環(huán)調(diào)用最多造成一條死信消息不會拖垮整個系統(tǒng)。4.3 坑三回調(diào)地址漂移收件箱成了幽靈信筒第三個坑來自一個很日常的操作某個 Agent 因為要擴容用同樣的 Agent ID 在新機器上重新部署。新機器 IP 變了回調(diào)地址自然變了。老機器的進程沒完全退干凈還在續(xù)租于是路由表里出現(xiàn)了兩個相同 Agent ID 的條目一個指向新地址一個指向舊地址。舊地址的收件箱消息全部投遞失敗新地址的 Agent 卻完全不知道有人給它發(fā)過消息。排查了很久才發(fā)現(xiàn)問題出在注冊協(xié)議太寬松Agent ID 相同就直接覆蓋注冊信息不做版本校驗。修復方案是給注冊請求加一個instance_id機器級唯一 ID協(xié)調(diào)節(jié)點發(fā)現(xiàn)同一 Agent ID 重復注冊時強制要求新的注冊帶上epoch字段epoch 必須大于舊實例否則拒絕。老實例的租約續(xù)期請求也會被拒絕因為它攜帶的 instance_id 已經(jīng)不是當前活躍實例了。這個坑給我的教訓是Agent ID 只能標識邏輯身份不能標識物理實例。凡是涉及物理實例的注冊、續(xù)租、回調(diào)都必須以 instance_id 為準。有點類似 HTTP 的 ETag 機制——光靠 URL 尋址不夠還得校驗版本。5. 穩(wěn)定性之外的進階優(yōu)化負載感知、局部分片、可觀測性5.1 負載感知路由別把所有任務(wù)堆給同一個 Agent能力路由如果只看在線有對應(yīng)能力壓測時就會暴露一個問題所有任務(wù)被均勻地其實是輪詢地分發(fā)給所有 Agent但每個任務(wù)的復雜度差別巨大。處理簡單任務(wù)只要 200 毫秒的 Agent 和處理 5 分鐘重度任務(wù)的 Agent 被同樣對待忙的忙死閑的閑死。我在 Agent-Reach 里加了一個體重指標每個 Agent 在續(xù)租時上報自己的實時負載包括隊列深度收件箱 pending 消息數(shù)、最近一分鐘處理耗時 P95、當前并發(fā)數(shù)。協(xié)調(diào)節(jié)點在能力路由打分時這三個指標各占權(quán)重 30%、40%、30%選得分最優(yōu)的 Agent 投遞。這個優(yōu)化上線后壓測里的 P95 延遲直接降了 37%。原理不復雜從誰有空轉(zhuǎn)給誰變成誰最快把活干完轉(zhuǎn)給誰。負載感知本質(zhì)上做的是全局調(diào)度而不是簡單的負載均衡。5.2 收件箱局部分片單 Agent 收件箱的一百萬條消息問題壓力測試做到單個 Agent 收件箱超過 100 萬條消息時SQLite 的select * from inbox where statuspending limit N開始出現(xiàn)明顯的性能退化。原因是 pending 消息和已處理消息混在同一張表里沒有索引覆蓋全表掃描越來越慢。修復方法很粗暴但很有效按status做局部分區(qū)。pending消息存一張只有幾百條記錄的熱表done消息定期歸檔到冷表failed消息單獨存放。熱表因為體積小查詢永遠是毫秒級冷表只做歷史追溯不在乎慢。這個優(yōu)化帶出一個通用經(jīng)驗任何消息系統(tǒng)熱數(shù)據(jù)和冷數(shù)據(jù)必須物理分離。不要因為數(shù)據(jù)量還沒那么大就偷懶等到 100 萬條再處理代價是幾小時的遷移和一堆線上事故。5.3 消息軌跡的三種監(jiān)控視圖最后一個大家容易忽略的模塊是可觀測性。Agent 通信鏈路比單體接口長得多出問題的時候如果沒有軌跡追蹤排查一天都不一定有結(jié)果。Agent-Reach 里我實現(xiàn)了三種監(jiān)控視圖都是在消息的trace字段基礎(chǔ)上做的鏈路視圖輸入一個message_id畫出它從發(fā)出、經(jīng)過哪些 Agent、最終完成的完整時間線每一跳的耗時都標出來。排查任務(wù)到底卡在哪一步時這張圖是救命稻草。隊列視圖每個 Agent 的收件箱 pending 數(shù)量、處理速率、平均等待時間實時展示。隊列深度暴漲往往比接口延遲更能提前暴露系統(tǒng)瓶頸。失敗歸因視圖所有 failed 消息按失敗類型聚類看看是超時、連接拒絕、還是處理異常。如果連續(xù)五分鐘超時占比超過 20%自動告警。這三種視圖加起來讓我從事后看日志變成了事前看趨勢。有一次壓測中發(fā)現(xiàn)某個 Agent 的隊列深度緩慢上漲但接口延遲正常順著隊列視圖往下查發(fā)現(xiàn)是這個 Agent 在處理某個特定類型的消息時出現(xiàn)線性重試每次都要等滿 120 秒。如果沒有隊列視圖這個問題要等用戶投訴才能發(fā)現(xiàn)。6. 收尾Agent-Reach 適用的邊界以及我最后的建議寫了這么多得說點實在話收個尾。Agent-Reach 不是什么高大上的框架它的定位就是自建多 Agent 系統(tǒng)時的一塊通信拼圖。我最初做它是因為實在受不了 Agent 之間裸連 HTTP 導致的各種玄學問題其實一點都不玄全是分布式系統(tǒng)的基本問題只是分散在 Agent 場景里顯得特別難查。適用邊界說一下Agent 數(shù)量在幾十個量級、部署在同一個內(nèi)網(wǎng)、不需要跨數(shù)據(jù)中心強一致性的團隊用它最合適。如果你的場景是幾百上千個 Agent、要求多區(qū)域容災(zāi)、需要嚴格的消息事務(wù)語義那么建議直接考慮成熟的 MQ 或工作流引擎Agent-Reach 的能力模型會變成你的限制。最后分享幾個我實際用下來的體會第一先把失敗場景想清楚再寫正邏輯。Agent 通信 90% 的代碼都是在處理消息丟了怎么辦Agent 掛了怎么辦重復消費怎么辦。我第一版把正邏輯寫得很漂亮后來 80% 的時間都在補失敗路徑。如果重來我會先寫故障注入測試。第二給每條消息留下足夠的元數(shù)據(jù)。message_id、trace、max_hops、execution_token這些字段看似廢話但每一個都在實際事故里救過我。別嫌消息頭重寧多重十個字段不要在排查時因為沒有 trace 而看半天。第三把重試、超時、路由這些邏輯統(tǒng)一收斂在協(xié)調(diào)節(jié)點。Agent 端保持無狀態(tài)是最好的這樣你隨便怎么縮擴容都不用擔心 Agent 端的本地狀態(tài)丟失。Agent-Reach 的核心價值就是把穩(wěn)做到了協(xié)調(diào)層而不是指望每個 Agent 自己自覺。如果你也在搭 Agent 網(wǎng)絡(luò)希望這篇分享能幫你少踩幾個坑。通信底座穩(wěn)了Agent 的智能才能真正發(fā)揮出來——不然再聰明的 Agent消息都送不到對方手里那不叫協(xié)作叫互相折磨。