級接入層:服務(wù)發(fā)現(xiàn)、健康檢查與智能路由實(shí)戰(zhàn))
“Agent”這個詞這兩年快被說爛了。從AutoGPT到各類MCP、工作流編排框架大家都在談智能體但真落到業(yè)務(wù)里你會發(fā)現(xiàn)一個特別容易翻車的環(huán)節(jié)你的智能體跑起來了但你怎么知道它現(xiàn)在活著、在哪兒、能不能被調(diào)用、負(fù)載怎么樣你用OpenAI的API不會操心這個問題但自建Agent集群時(shí)這套“服務(wù)發(fā)現(xiàn)健康檢查路由調(diào)度”的東西基本上得從頭造一遍。我最近在內(nèi)部把這套基礎(chǔ)設(shè)施做完了項(xiàng)目代號就叫Agent-Reach。名字很直白意思就是“讓每一個Agent都能被準(zhǔn)確觸達(dá)”。這篇文章把我這幾周的踩坑、設(shè)計(jì)取舍和落地實(shí)錄整理出來不聊虛的直接講怎么把一個Agent集群做成“有服務(wù)注冊、有健康探活、有智能路由”的生產(chǎn)級接入層。如果你正準(zhǔn)備把Agent能力接入業(yè)務(wù)系統(tǒng)或者想給自己的多Agent項(xiàng)目加一層可靠調(diào)度這篇內(nèi)容對你會很有用。不用懂太深的后端知識我會盡量把每一層的設(shè)計(jì)邏輯和實(shí)現(xiàn)細(xì)節(jié)都講清楚。1. 項(xiàng)目初衷與整體設(shè)計(jì)思路Agent-Reach不是一個業(yè)務(wù)Agent它不負(fù)責(zé)對話、寫代碼、做規(guī)劃。它是一層基礎(chǔ)設(shè)施我們內(nèi)部叫它“觸點(diǎn)管理服務(wù)”。簡單說它解決三個問題Agent注冊、Agent發(fā)現(xiàn)、Agent路由。1.1 為什么需要專門的Reach層如果你只有一兩個Agent用配置文件寫死地址就夠了。但一旦Agent數(shù)量上到幾十個或者Agent分散在多臺服務(wù)器、多個K8s節(jié)點(diǎn)上問題馬上變麻煩。部署了新版本的話舊地址失效某臺機(jī)器負(fù)載過高某類任務(wù)必須由特定Agent處理Agent崩潰后流量還是往它那邊打。這些問題單靠配置管理和負(fù)載均衡器是擋不住的因?yàn)锳gent有狀態(tài)、有類型、有容量差異不是普通的HTTP服務(wù)。我當(dāng)時(shí)走訪了一圈團(tuán)隊(duì)需求總結(jié)下來就是需要一個中間層讓上層應(yīng)用不必感知具體Agent實(shí)例的IP和狀態(tài)只按“能力名字”去調(diào)用剩下的尋址和調(diào)度全部交給這一層。這也是Agent-Reach最核心的設(shè)計(jì)目標(biāo)。1.2 三層架構(gòu)的取舍我最初參考了微服務(wù)里注冊中心的成熟套路但Agent和微服務(wù)有一個關(guān)鍵差異微服務(wù)實(shí)例相對穩(wěn)定Agent的啟停要頻繁得多而且Agent對上下文和數(shù)據(jù)隔離的要求也更高。所以我沒有直接照搬Eureka或者Nacos而是做了一個偏輕量的三層模型接入層負(fù)責(zé)接收Agent上報(bào)的狀態(tài)信息包括心跳、能力說明、當(dāng)前并發(fā)數(shù)、上下文長度調(diào)度層根據(jù)業(yè)務(wù)請求攜帶的能力標(biāo)簽、優(yōu)先級、數(shù)據(jù)親和性在可用Agent列表里選一個最合適的實(shí)例緩存層把Agent狀態(tài)數(shù)據(jù)同步到本地內(nèi)存和Redis避免調(diào)度層每次做遠(yuǎn)程調(diào)用使得請求路徑盡量短。這個設(shè)計(jì)的核心取舍是“以Agent自身狀態(tài)為中心”。調(diào)度器不主動刻板地輪詢一切選擇都以Agent最近一次上報(bào)的信息為準(zhǔn)。上報(bào)的間隔越短調(diào)度越精準(zhǔn)代價(jià)是Agent端壓力增大。我后來把默認(rèn)心跳設(shè)成了5秒既不會漏判導(dǎo)致大規(guī)模誤剔除也不會因?yàn)樯蠄?bào)太頻繁占滿Agent進(jìn)程的資源。1.3 核心模塊拆解整體代碼可以分為四個模塊。注冊模塊負(fù)責(zé)接收Agent第一次上線的注冊請求寫入唯一實(shí)例ID、能力名、標(biāo)簽和附加元數(shù)據(jù)探活模塊負(fù)責(zé)跟蹤每個Agent的續(xù)約超過租約時(shí)間就標(biāo)記為失聯(lián)路由模塊負(fù)責(zé)按策略篩選可用實(shí)例并返回目標(biāo)地址控制臺模塊提供一個極簡界面能實(shí)時(shí)看到各Agent的在線狀態(tài)和請求分布。注冊信息我用的是MySQL持久化但實(shí)時(shí)調(diào)度不查庫只在Agent狀態(tài)變更時(shí)才寫庫。這樣設(shè)計(jì)的好處是調(diào)度路徑上完全無SQL操作性能瓶頸基本就剩下網(wǎng)絡(luò)開銷了。實(shí)際壓測下來單機(jī)調(diào)度層QPS能到1.2萬左右對于內(nèi)部工具來說非常夠用。2. 核心機(jī)制注冊、保活與可達(dá)性判定Agent-Reach的關(guān)鍵在于“可達(dá)性判定”。這是所有調(diào)度邏輯的地基一旦誤判下游所有策略都會出問題。我把它細(xì)分成注冊協(xié)議、心跳租約、狀態(tài)摘除三個子環(huán)節(jié)。2.1 Agent注冊協(xié)議設(shè)計(jì)Agent首次啟動時(shí)調(diào)用Reach的注冊接口上報(bào)自己的實(shí)例名、Agent類型、可以處理的技能標(biāo)簽、限制信息最大并發(fā)數(shù)、單次任務(wù)最長執(zhí)行時(shí)間、支持的大模型類型等。注冊完成之后Reach會返回一個instanceId和一個臨時(shí)token后續(xù)的心跳和上下線都帶這個token防止偽造上報(bào)。注冊信息里有一項(xiàng)我覺得特別重要preferred_regions。這是Agent所在的物理或邏輯區(qū)域標(biāo)識。比如我的Agent分布在兩個內(nèi)網(wǎng)網(wǎng)段一部分在GPU區(qū)一部分在通用計(jì)算區(qū)。當(dāng)業(yè)務(wù)請求帶有數(shù)據(jù)本地性要求時(shí)調(diào)度器能根據(jù)這個字段避開跨網(wǎng)段傳輸省掉大量數(shù)據(jù)搬遷開銷。注冊接口本身冪等性很好做因?yàn)閕nstanceId由Agent自己生成一般是hostname進(jìn)程ID隨機(jī)數(shù)Reach側(cè)按這個ID做upsert。第一次上線是insert后續(xù)重復(fù)注冊就是update元數(shù)據(jù)。實(shí)測中重復(fù)注冊主要發(fā)生在Agent崩潰重啟或網(wǎng)絡(luò)閃斷后重連冪等設(shè)計(jì)省了前端很多事。2.2 心跳與租約機(jī)制心跳接口我設(shè)計(jì)得比較簡Agent每隔5秒打一次BeaconBody里只帶instanceId、當(dāng)前狀態(tài)BUSY或IDLE、當(dāng)前并發(fā)任務(wù)數(shù)、最近一次任務(wù)耗時(shí)。Reach收到心跳后刷新這條Agent的最后更新時(shí)間并同步更新內(nèi)存里的狀態(tài)快照。租約機(jī)制參考了分布式系統(tǒng)里常見的TTL思路。Reach指定一個leaseDuration默認(rèn)是15秒。如果15秒內(nèi)沒有收到某實(shí)例的心跳就判定該實(shí)例失聯(lián)將其從可用池子摘除。同時(shí)摘除動作只是把status置為OFFLINE記錄摘除原因和時(shí)間并不刪除注冊記錄這樣網(wǎng)絡(luò)抖動恢復(fù)后Agent重新上報(bào)心跳就能自動回池不需要再次全量注冊。這個設(shè)計(jì)有個細(xì)節(jié)值得注意心跳不能一過期就殺。瞬斷很常見Agent可能只是暫停了3秒的GC或一次長任務(wù)阻塞。所以我把狀態(tài)切換做成三級HEALTHY、SUSPECT、OFFLINE。超過15秒沒心跳進(jìn)SUSPECT轉(zhuǎn)發(fā)時(shí)標(biāo)記“降級權(quán)重”但還能接收輕量請求超過30秒還沒心跳才真正摘除。這個緩沖讓系統(tǒng)在波動時(shí)穩(wěn)定很多尤其能扛住Agent JVM導(dǎo)致的偶發(fā)停頓。2.3 狀態(tài)摘除與優(yōu)雅下線除了被動摘除Agent還可能主動下線比如發(fā)布新版本時(shí)需要排空流量。我預(yù)留了一個下線路由Agent調(diào)用/offline接口聲明“我要離線了”Reach把這個實(shí)例從可用列表里立刻摘掉但保留已有請求執(zhí)行完。這就避免新流量進(jìn)入正在重啟的進(jìn)程。這里踩過一個坑Agent進(jìn)程在卸載鉤子里調(diào)用offline但進(jìn)程退出太快HTTP調(diào)用還沒發(fā)出去就斷了。后來我把下線動作做了兩級保障——先標(biāo)記本進(jìn)程為“停止接收新任務(wù)”再發(fā)離線請求同時(shí)本地維持10秒的grace period確保離線請求在進(jìn)程真正退出前送達(dá)。對于極端場景比如機(jī)器斷電那就只能等心跳過期被動摘除了這類情況沒辦法完全避免。3. 路由策略與調(diào)度實(shí)現(xiàn)Agent-Reach真正花時(shí)間的是路由模塊。它不能隨便挑一個健康實(shí)例就算完事而要支持多維度約束。我把調(diào)度拆成“過濾打分選擇”三段每一段都有背后邏輯。3.1 過濾階段先排除不可用實(shí)例過濾規(guī)則按優(yōu)先級執(zhí)行第一層過濾是不可用狀態(tài)包括OFFLINE和SUSPECT第二層過濾是能力匹配業(yè)務(wù)請求里的capability字段必須與Agent支持的技能標(biāo)簽交集非空第三層過濾是數(shù)據(jù)親和性如果請求指定了data_region就只保留該區(qū)域的Agent最后一層是并發(fā)水位過濾Agent當(dāng)前并發(fā)數(shù)和最大并發(fā)比例超過0.85就臨時(shí)不參與調(diào)度。實(shí)際使用下來并發(fā)水位過濾是最影響結(jié)果質(zhì)量的。以前沒加這項(xiàng)時(shí)某個Agent可能已經(jīng)被壓滿調(diào)度器還是往里扔任務(wù)導(dǎo)致響應(yīng)時(shí)長一下子拉胯。加了之后系統(tǒng)會自動把熱點(diǎn)Agent留一口氣處理已有任務(wù)新任務(wù)分散到其他實(shí)例上。代價(jià)是小部分請求會跑到較遠(yuǎn)的區(qū)域但相比整體延遲劣化這個代價(jià)完全值得。3.2 打分階段量化每個候選Agent的適配度過濾只剩健康實(shí)例后進(jìn)入打分環(huán)節(jié)。打分函數(shù)我用的加權(quán)綜合公式Score 0.35 * ConcurrencyScore 0.30 * AffinityScore 0.20 * LatencyScore 0.15 * StabilityScoreConcurrencyScore是剩余并發(fā)容量的歸一化值A(chǔ)ffinityScore看的是候選Agent的歷史調(diào)用記錄如果有過去5分鐘內(nèi)成功處理過同一類請求就加分LatencyScore取最近100次任務(wù)的P50執(zhí)行耗時(shí)耗時(shí)越低分越高StabilityScore看的是近一小時(shí)GC暫停次數(shù)、內(nèi)存增長趨勢、注意這里是Agent運(yùn)行時(shí)統(tǒng)計(jì)上來的指標(biāo)不是Reach自己猜的。這個公式不是一開始就定死的。最初我把穩(wěn)定性的權(quán)重設(shè)為0結(jié)果某個Agent因?yàn)閮?nèi)存泄漏導(dǎo)致頻繁FullGC調(diào)度器毫不知情照樣把任務(wù)給它直到請求超時(shí)才發(fā)現(xiàn)。后來加入了穩(wěn)定性指標(biāo)并在控制臺打了一個“GC風(fēng)暴”告警這類問題才被提前攔下。3.3 選擇階段隨機(jī)加權(quán)與親和保持得分算完后自然是用加權(quán)隨機(jī)選擇而不是直接選最高分。原因很簡單如果始終選最高分實(shí)例所有流量會在瞬間集中到同一批Agent上很快就把它們打熱然后又被并發(fā)過濾防住導(dǎo)致“熱點(diǎn)漂移”。加權(quán)隨機(jī)讓高分實(shí)例有更高概率被選中但低分實(shí)例也有少量機(jī)會整體分布更平滑。這里還有一個“親和保持”的細(xì)節(jié)如果請求的traceId之前已經(jīng)固定到了一個Agent實(shí)例且該實(shí)例仍然健康我會把它直接返回不加額外計(jì)算。這保證同一用戶會話盡量在一個Agent上完成避免上下文反復(fù)搬運(yùn)。親和保持出過一次問題agent實(shí)例因網(wǎng)絡(luò)瞬斷進(jìn)入SUSPECT狀態(tài)但親和策略還把它返回給調(diào)用方導(dǎo)致一批請求全部失敗。修復(fù)方法是在親和命中后增加一個快速狀態(tài)校驗(yàn)SUSPECT直接跳過親和。這個邏輯是給調(diào)度器加了一行判斷但實(shí)際收益很明顯故障恢復(fù)速度從分鐘級降到了秒級。4. 實(shí)操落地從零搭建Agent-Reach理論講完了分享點(diǎn)直接能上手的操作。我用Go寫了Reach服務(wù)端Agent端SDK用了Python因?yàn)楝F(xiàn)在大部分Agent框架的膠水層還是Python多點(diǎn)。下面把關(guān)鍵環(huán)節(jié)列一下照著做基本能跑通。4.1 服務(wù)端核心代碼骨架Reach的服務(wù)端入口我用了Gin框架數(shù)據(jù)庫用MySQL緩存用了Redis和內(nèi)存混合。核心調(diào)度邏輯不依賴RedisRedis只用于跨節(jié)點(diǎn)同步和審計(jì)日志。代碼結(jié)構(gòu)比較清晰核心就兩個接口注冊和心跳。// 注冊Agent r.POST(/api/agent/register, func(c *gin.Context) { var req RegisterRequest if err : c.ShouldBindJSON(req); err ! nil { c.JSON(400, gin.H{error: err.Error()}) return } id : registry.Register(req) c.JSON(200, gin.H{instance_id: id, token: registry.GenToken(id)}) }) // 心跳上報(bào) r.POST(/api/agent/beacon, func(c *gin.Context) { var req BeaconRequest if err : c.ShouldBindJSON(req); err ! nil { c.JSON(400, gin.H{error: err.Error()}) return } registry.Heartbeat(req.InstanceID, req.Status) c.JSON(200, gin.H{ok: true}) })心跳接口沒有做復(fù)雜的鑒權(quán)只校驗(yàn)了token前綴的合法性因?yàn)锳gent端和Reach端都在內(nèi)網(wǎng)且中間有防火墻規(guī)則依賴內(nèi)網(wǎng)信任可以降低不少通信開銷。調(diào)度查詢接口是另一個重點(diǎn)。上層應(yīng)用傳入的調(diào)用ID其實(shí)是capabilityNameReach根據(jù)它選好Agent后返回完整的調(diào)用地址和參數(shù)模板。# 偽代碼簡化突出調(diào)度流程 def schedule_request(capability: str, context: dict) - AgentTarget: pool registry.get_candidates(capability) if not pool: raise NoAvailableAgentError(capability) filtered filter_by_health(pool) filtered filter_by_capacity(filtered) pool_with_score [(agent, compute_score(agent, context)) for agent in filtered] return weighted_choose(pool_with_score)4.2 Agent端SDK接入要點(diǎn)Agent端SDK封裝了注冊、心跳、狀態(tài)上報(bào)三個動作。我建議把SDK設(shè)計(jì)成啟動時(shí)自動異步注冊不阻塞Agent主流程。試用的時(shí)候發(fā)現(xiàn)如果注冊失敗直接拋異常很多Agent啟動邏輯會被卡住所以我改成“注冊失敗進(jìn)入重試隊(duì)列不影響本地執(zhí)行”的模式。心跳邏輯用后臺線程實(shí)現(xiàn)每次打心跳時(shí)順帶傳最近一次任務(wù)執(zhí)行耗時(shí)。這里要強(qiáng)調(diào)任務(wù)耗時(shí)的統(tǒng)計(jì)口徑必須準(zhǔn)我遇到過Agent端把排隊(duì)時(shí)間也算進(jìn)執(zhí)行耗時(shí)導(dǎo)致Reach端的LatencyScore完全失真。后來規(guī)范了字段task_duration_ms只算真正執(zhí)行時(shí)間排隊(duì)和網(wǎng)絡(luò)傳輸時(shí)間都單獨(dú)上報(bào)。class AgentReachSDK: def start(self): threading.Thread(targetself._beacon_loop, daemonTrue).start() self.register() def _beacon_loop(self): while True: self.report_state() time.sleep(5)這里還有一個經(jīng)驗(yàn)值心跳線程的網(wǎng)絡(luò)超時(shí)時(shí)間不要設(shè)置太長建議2秒以內(nèi)。如果Reach端整個宕機(jī)Agent不該無限等網(wǎng)絡(luò)響應(yīng)而是降級成本地模式保留Agent獨(dú)立工作的能力。畢竟Reach掛了業(yè)務(wù)Agent還能干活等到Reach恢復(fù)再做補(bǔ)償注冊就行。4.3 控制臺與可觀測性Reach控制臺有一個實(shí)時(shí)視圖能看到所有Agent實(shí)例的列表每行帶狀態(tài)、最近心跳時(shí)間、當(dāng)前并發(fā)、24小時(shí)成功率。這個視圖對運(yùn)維特別有用基本能替代一大部分調(diào)試工作。另外我加了一個事件流頁面所有Agent的注冊、失聯(lián)、恢復(fù)、路由變更都在頁面上滾動顯示。排查問題的時(shí)候按時(shí)間軸看事件流比看日志快很多。比如某個時(shí)刻調(diào)度變慢往上查一下往往能看到某臺機(jī)器因?yàn)镚C發(fā)生SUSPECT再結(jié)合日志確認(rèn)就是那次事件引起的連鎖反應(yīng)。5. 常見問題與排查經(jīng)驗(yàn)速查這幾周實(shí)際跑下來遇到的問題比預(yù)想的多。我挑了些有代表性的做成表格方便速查。現(xiàn)象可能原因排查手段Agent列表全部脫機(jī)Reach服務(wù)端與數(shù)據(jù)庫連接斷開檢查MySQL連接池心跳寫庫失敗導(dǎo)致判定全部失聯(lián)偶發(fā)請求打到已下線的Agent親和保持跳過了SUSPECT校驗(yàn)確認(rèn)親和命中后的狀態(tài)二次檢查邏輯是否生效調(diào)度結(jié)果總集中到同一實(shí)例打分函數(shù)內(nèi)緩存被人為刪掉檢查Redis緩存失效策略確認(rèn)實(shí)例分?jǐn)?shù)未過期心跳正常但請求超時(shí)Agent實(shí)例已阻塞但心跳線程仍能執(zhí)行查看實(shí)例執(zhí)行池線程消耗增加單獨(dú)的執(zhí)行狀態(tài)上報(bào)注冊接口偶發(fā)503注冊并發(fā)量過大數(shù)據(jù)庫寫沖突注冊寫庫改為異步批量同步注冊接口改為最終一致除了表格里這些還有一個非常值得說的坑Agent上線時(shí)如果注冊得太快下一條心跳還沒跟上Reach端可能會把它認(rèn)成“新實(shí)例但從未活躍”然后被租約機(jī)制誤判剔除。我的解決辦法是在注冊接口返回后代理端以此為起點(diǎn)立刻打一條帶initialtrue的心跳。這條心跳不參與正常租約計(jì)算只用于確認(rèn)“我確實(shí)活了”。還有一個和Agent調(diào)度強(qiáng)相關(guān)的問題——線程池阻塞。曾有一個Agent實(shí)例的心跳一直正常但它內(nèi)部處理請求的線程池全部阻塞新請求進(jìn)入后排隊(duì)耗時(shí)指數(shù)級增長。Reach的P50耗時(shí)指標(biāo)很快捕捉到了異常但由于閾值設(shè)置過寬沒有觸發(fā)自動摘除。后來我把LatencyScore的異常閾值收緊并加了一條“連續(xù)5次P99延遲超過3秒就強(qiáng)制摘除”的規(guī)則這類故障現(xiàn)在基本幾秒內(nèi)就能自動隔離。還有個細(xì)節(jié)Agent端時(shí)間不同步會導(dǎo)致心跳時(shí)間錯亂。Reach校準(zhǔn)時(shí)間是以自己的服務(wù)器時(shí)鐘為準(zhǔn)Agent上報(bào)的時(shí)間僅作參考不參與租約計(jì)算。如果環(huán)節(jié)里有人直接用了Agent端的系統(tǒng)時(shí)間做過期判斷很容易出現(xiàn)誤殺或漏殺。后來我統(tǒng)一了所有服務(wù)的NTP同步租約計(jì)算只用Reach本地時(shí)間戳。6. 后續(xù)可以怎么擴(kuò)展Agent-Reach現(xiàn)在對我這邊的多Agent平臺來說夠用了但有幾塊我還在籌劃升級。第一是加上容器化環(huán)境下的自動注冊K8s的Pod重建很快如果Reach沒有及時(shí)感知到新Pod注冊的實(shí)例流量會短暫丟失。我把這個做成一個單獨(dú)的控制器監(jiān)聽Pod的Ready狀態(tài)之后自動調(diào)用注冊接口。第二是路由策略加上語義標(biāo)簽?zāi)壳癱apability匹配是對標(biāo)簽做集合運(yùn)算下一步想接入向量相似度讓Agent能力描述更自然不再依賴人工打標(biāo)。還有一個方向是給Agent做調(diào)用配額管理。目前Reach只管調(diào)度不管“誰來調(diào)”。如果某個調(diào)用方寫了個死循環(huán)瘋狂請求會把整個Agent池打爆。我計(jì)劃增加一個配額層按調(diào)用方身份限流類似網(wǎng)關(guān)里的Rate Limit避免單個調(diào)用方把基礎(chǔ)設(shè)施拖垮。不過這些都是迭代計(jì)劃當(dāng)前的Agent-Reach已經(jīng)解決了最重要的問題Agent不再是一個個孤島而是能被統(tǒng)一發(fā)現(xiàn)、統(tǒng)一調(diào)度、統(tǒng)一治理的集群能力。如果你正好也在做類似的多Agent調(diào)度層建議先別急著堆功能把注冊協(xié)議、心跳租約、路由打分這三件事想透后面再加什么都順手。最后分享一個我自己的體會。做Agent基礎(chǔ)設(shè)施最忌諱的就是“把Agent當(dāng)普通服務(wù)”來管理。Agent有狀態(tài)、有偏好、有性能差異單純用負(fù)載均衡的思路去路由短期能跑規(guī)模一大必然出問題。我這次把“Agent自身狀態(tài)上報(bào)”放到了調(diào)度決策的中心收到的效果比預(yù)想好得多。如果你也卡在Agent集群管理這塊可以試試從“讓Agent主動說自己的情況”這個思路入手很多調(diào)度難題會一下變得簡單很多。