計:Harness層與全插件化實(shí)踐)
做Agent框架的人大概都經(jīng)歷過這個階段Demo跑通時覺得世界盡在掌握等真正線上灰度的時候才發(fā)現(xiàn)所有問題都藏在主流程的縫隙里。我們這版Agent框架踩得最疼的一個坑是一直沒重視會話日志直到有個線上會話跑到第23步工具參數(shù)里憑空多出一個字段誰都沒法復(fù)現(xiàn)只能靠零散的聊天記錄猜。那次之后我才下決心把框架里負(fù)責(zé)承載執(zhí)行環(huán)境和記錄執(zhí)行證據(jù)的部分單獨(dú)抽出來做成獨(dú)立的Harness層并順手把日志從聊天記錄升級成可回放執(zhí)行軌跡。這篇就當(dāng)一次復(fù)盤記錄重點(diǎn)講全插件化設(shè)計和可回放會話日志這兩塊。1. 被事故逼出來的Harness層Agent框架里最不該省的一層1.1 第一版框架的差不多能用是怎么崩塌的第一版Agent框架的主流程其實(shí)非常簡單甚至可以用一句話概括把用戶消息丟給模型模型輸出工具調(diào)用執(zhí)行工具拿到結(jié)果再丟回模型循環(huán)直到模型給出最終回復(fù)。Demo階段這套循環(huán)跑得很順暢我們也理所當(dāng)然地把所有邏輯都揉在一個包里面工具函數(shù)直接寫在服務(wù)代碼中模型調(diào)用分散在各個業(yè)務(wù)流程會話歷史用普通數(shù)據(jù)庫表存一份JSON。當(dāng)時覺得這樣夠了因?yàn)锳gent本身就是一個循環(huán)循環(huán)外面不需要太多東西。等到真正接入多個業(yè)務(wù)方之后問題開始密集出現(xiàn)。第一個新業(yè)務(wù)要加一個內(nèi)部數(shù)據(jù)查詢工具本來只是加一個函數(shù)的事結(jié)果因?yàn)楣ぞ咝枰卟煌蔫b權(quán)體系和限流策略我們不得不在每個調(diào)用點(diǎn)處理這些差異。第二個業(yè)務(wù)要切換模型供應(yīng)商發(fā)現(xiàn)prompt組裝、消息格式轉(zhuǎn)換、流式解析散落得到處都是硬切了一周。最崩潰的是晚上查問題用戶說會話在某個步驟失敗了我們點(diǎn)開日志看到的是一堆無序JSON片段完全沒有上下文。那個第23步多字段的事故就是在這種狀態(tài)下發(fā)生的。1.2 Harness層的邊界承載Agent而不是指揮Agent業(yè)界常說的Harness最初我理解成測試框架里的test harness是一層用來掛載被測對象、控制輸入輸出、觀察內(nèi)部狀態(tài)的腳手架。放到Agent場景里也是類似的定位不管Agent用ReAct還是Plan-Execute不管模型來自哪個供應(yīng)商Harness只負(fù)責(zé)提供統(tǒng)一的運(yùn)行環(huán)境、掛載插件、記錄每一步的關(guān)鍵事件。我們?yōu)镠arness層劃了三條邊界Agent的決策邏輯完全留在業(yè)務(wù)側(cè)Harness不關(guān)心每一步為什么這么選只記錄發(fā)生了什么所有與外部世界的接觸包括模型調(diào)用、工具執(zhí)行、記憶讀寫都通過Harness暴露的插件接口完成不允許業(yè)務(wù)代碼繞過插件層直接發(fā)HTTP請求日志由Harness統(tǒng)一采集插件只負(fù)責(zé)上報事件細(xì)節(jié)不能自己另起一套日志通道。這個約束從源頭保證了回放日志的完整性和同源性。這三條邊界導(dǎo)致Agent框架被拆成了兩層上面是業(yè)務(wù)決策層負(fù)責(zé)想下面是Harness層負(fù)責(zé)做和記。想的部分可以隨時換做和記的部分保持穩(wěn)定。后來回頭看這個拆分最大的收益不是可維護(hù)性而是讓回放日志成為可能因?yàn)樗懈h(huán)境相關(guān)的交互點(diǎn)都被收口了日志才能被用來完整重建環(huán)境。2. 全插件化設(shè)計的骨架六個擴(kuò)展點(diǎn)與一條裝配鏈路2.1 六個核心擴(kuò)展點(diǎn)拆分Harness層之前我們先把所有可能變化的點(diǎn)列了一張清單。列完之后發(fā)現(xiàn)Agent框架的擴(kuò)展需求其實(shí)集中在六個地方模型訪問、工具執(zhí)行、記憶存儲、會話元數(shù)據(jù)、日志輸出、生命周期鉤子。每一個點(diǎn)都對應(yīng)一類插件接口。這六個接口沒有一個是Agent決策邏輯本身但缺了任何一個工程化都會卡住不抽象模型訪問接口切換模型供應(yīng)商就要改業(yè)務(wù)代碼不抽象記憶存儲長短期記憶就沒辦法在會話回放時被正確重建不抽象日志輸出回放引擎就失去數(shù)據(jù)來源。典型實(shí)現(xiàn)也各有側(cè)重比如模型適配器要處理流式與非流式協(xié)議差異工具提供者要處理權(quán)限與限流記憶適配器可能要同時對接向量庫和關(guān)系庫。擴(kuò)展點(diǎn)接口職責(zé)典型實(shí)現(xiàn)ModelClient統(tǒng)一消息格式、流式與非流式調(diào)用、重試與降級各模型供應(yīng)商適配器ToolProvider工具注冊、參數(shù)校驗(yàn)、調(diào)用執(zhí)行內(nèi)部搜索、數(shù)據(jù)查詢、權(quán)限代理工具M(jìn)emoryAdapter讀寫短期與長期記憶內(nèi)存實(shí)現(xiàn)、向量庫實(shí)現(xiàn)、數(shù)據(jù)庫實(shí)現(xiàn)SessionStore保存會話基礎(chǔ)信息關(guān)系庫、對象存儲Transport日志落盤和對外投遞本地文件、消息隊(duì)列、對象存儲HookPoint在關(guān)鍵節(jié)點(diǎn)注入觀察和修改審計、監(jiān)控、動態(tài)路由2.2 配置驅(qū)動的裝配與插件生命周期接口定義只是第一步真正讓全插件化成立的是裝配機(jī)制。我們沒有采用代碼里到處new插件實(shí)例的做法而是用配置驅(qū)動裝配。每個插件有一個全局唯一的id和type啟動時根據(jù)配置注冊進(jìn)Harness上下文。harness: plugins: - id: local-tool-search type: tool_provider enabled: true options: endpoint: http://internal-search timeout_ms: 3000 - id: sqlite-memory type: memory_adapter enabled: true options: path: ./data/agent_memory.db - id: jsonl-transport type: transport enabled: true options: path: ./logs/session/ replay_dir: ./logs/replay/配置驅(qū)動帶來的第一個好處是回放引擎可以在同一套配置里把ModelClient和ToolProvider替換成回放實(shí)現(xiàn)而其他插件保持不變。第二個好處是插件版本可以跟著配置走出現(xiàn)問題時能快速通過配置比對定位是哪個插件版本引入的。裝配時機(jī)上我們定義了Init、Start、Stop三個階段Init階段只做資源準(zhǔn)備Start階段才對外提供能力Stop階段負(fù)責(zé)釋放資源。插件之間有依賴時必須顯式聲明依賴的plugin id裝配器會做拓?fù)渑判蚝笤賳印?.3 組合式設(shè)計的選擇理由最初也討論過要不要用插件繼承體系比如讓每個工具插件繼承一個BaseTool然后覆寫execute方法。后來我們發(fā)現(xiàn)繼承會把插件之間的隱式狀態(tài)粘在一起比如子類改了父類的成員變量、多個插件共享同一個連接池時很難追蹤。于是改成組合式插件實(shí)現(xiàn)接口插件之間不直接通信數(shù)據(jù)交換統(tǒng)一走Harness上下文里的事件總線。用這種模式后單元測試變得非常簡單每個插件可以獨(dú)立啟動、注入mock數(shù)據(jù)、斷言輸出不需要跑整個Agent。寫插件的人也不需要理解Agent主循環(huán)只需要知道自己那個接口的協(xié)議。如果還是覺得抽象可以把Harness理解成電腦主板Agent是CPU插件是插在主板上的各類板卡板卡之間不直接連線一切都通過總線通信換顯卡不需要連帶著換聲卡。3. 從聊天記錄到執(zhí)行軌跡會話日志的事件模型設(shè)計3.1 一份典型會話日志長什么樣重構(gòu)前我們的會話記錄是一條條消息像普通聊天記錄一樣按時間排。重構(gòu)后會話日志變成了一串事件流每行一個事件用JSON Lines格式落盤。下面模擬一份被脫敏過的片段{type:session_started,event_id:ev_001,session_id:sess_93f2,seq:1,ts:2025-01-10T09:21:00Z,data:{agent_id:crm-demo}} {type:user_message,event_id:ev_002,session_id:sess_93f2,seq:2,ts:2025-01-10T09:21:03Z,data:{content:幫我查一下上個月華東區(qū)的活躍客戶名單}} {type:model_request,event_id:ev_003,session_id:sess_93f2,seq:3,ts:2025-01-10T09:21:05Z,data:{model:internal-llm-a,messages:[{role:user,content:...}],temperature:0.2}} {type:tool_invocation,event_id:ev_004,session_id:sess_93f2,seq:4,ts:2025-01-10T09:21:06Z,data:{tool:customer_query,input:{region:east,status:active,time_range:last_month},output:{count:128,samples:[...]}}} {type:model_response,event_id:ev_005,session_id:sess_93f2,seq:5,ts:2025-01-10T09:21:07Z,data:{role:assistant,content:已為你篩選出128位活躍客戶以下是名單前5條...}}可以看到每個事件都包含四個公共字段event_id、session_id、seq、ts。seq是Harness統(tǒng)一分配的自增序號ts只是給人查看用的時間戳排序一律以seq為準(zhǔn)。這個設(shè)計后面幫了大忙因?yàn)榉植际江h(huán)境下不同服務(wù)器的時間戳根本無法可靠排序。3.2 事件模型五類核心事件與因果鏈我們最終只保留了五類核心事件session_started、user_message、model_request、tool_invocation、model_response。很多人會問為什么沒有tool_observation因?yàn)楣ぞ邎?zhí)行結(jié)果已經(jīng)作為tool_invocation事件的output字段記錄再拆一個事件反而容易造成順序錯亂。會話結(jié)束時補(bǔ)一個session_ended但不算核心決策事件。事件間的關(guān)聯(lián)靠因果結(jié)構(gòu)而不是簡單的順序。比如model_request事件里會攜帶一個parent_event_id指向觸發(fā)這次模型調(diào)用的上一步事件tool_invocation里也有parent_event_id指向是哪一次模型輸出決定調(diào)用這個工具。有了這條因果鏈回放時就能區(qū)分是模型主動發(fā)起的工具調(diào)用還是用戶消息觸發(fā)的被動調(diào)用也能在調(diào)試時快速從錯誤工具調(diào)用回溯到導(dǎo)致它出現(xiàn)的模型輸出。實(shí)際排障中這個字段比時間戳好用得多。3.3 存儲層要解決的四個工程細(xì)節(jié)存儲層的事說起來瑣碎但直接影響日志能不能拿來回放。壓縮方面會話日志是寫多讀少默認(rèn)按天分片后用gzip落地單文件超過128MB自動滾切避免單個文件過大導(dǎo)致回放時加載過慢。脫敏方面data里出現(xiàn)key、password、credential等字段名時序列化階段直接替換成[REDACTED]但這里踩過坑后面專門講。采樣方面并不是每個中間推理token都要記錄日志級別分為trace和infoinfo只記錄用戶消息、模型請求摘要、工具調(diào)用全文trace才記錄流式chunk和模型推理細(xì)節(jié)。清理方面線上日志默認(rèn)保留15天事故相關(guān)的會話會打標(biāo)簽永久保留。到這里日志已經(jīng)不再是給人看的純文本而是一份機(jī)器可以讀的完整執(zhí)行證據(jù)。4. 回放引擎為什么能做到同一份日志跑出同一個Agent4.1 回放的本質(zhì)旁路模型與旁路工具回放一個會話很多人第一反應(yīng)是把日志里的歷史消息拼成一個prompt再丟給模型重新生成一次。這在簡單對話場景可行但在Agent場景完全不行因?yàn)锳gent下一步動作依賴的是當(dāng)時的工具結(jié)果、記憶狀態(tài)和上下文窗口把日志內(nèi)容重新拼湊后讓模型再跑結(jié)果大概率會發(fā)散。我們采用的方案是把回放當(dāng)成一次帶著劇本的真實(shí)演出。啟動回放時Harness正常啟動Agent決策邏輯原封不動記憶Adapter、SessionStore都是真實(shí)實(shí)現(xiàn)只有兩個插件被替換ModelClient從真實(shí)模型調(diào)用換成回放模型客戶端ToolProvider從真實(shí)工具調(diào)用換成回放工具提供者?;胤拍P涂蛻舳瞬徽{(diào)用任何模型只負(fù)責(zé)從日志事件流里讀回當(dāng)時的model_response回放工具提供者也不執(zhí)行任何真實(shí)業(yè)務(wù)邏輯只負(fù)責(zé)把當(dāng)時tool_invocation里的output原樣返回。Agent主循環(huán)完全意識不到自己是在回放環(huán)境里跑。每走一步Harness會對比當(dāng)前應(yīng)該觸發(fā)的事件和實(shí)際觸發(fā)的事件。如果一致繼續(xù)如果不一致拋出一個replay_mismatch并輸出期望事件和實(shí)際事件的差異。這一步是整個回放引擎的核心它不是讓Agent重跑一遍而是讓Agent在同一個環(huán)境里重演一次再通過每一步的比對來暴露當(dāng)時的隱含狀態(tài)。4.2 狀態(tài)快照支持從任意步驟續(xù)跑整段回放有兩個頭疼的問題一是日志太長時回放很慢二是如果只想調(diào)試第23步附近的問題每次都從最初用戶消息開始跑效率太低。于是我們加入了快照機(jī)制。Harness每執(zhí)行50步或者遇到關(guān)鍵checkpoint會持久化一份狀態(tài)快照內(nèi)容包括當(dāng)前seq、會話上下文摘要、記憶Adapter的狀態(tài)、插件運(yùn)行時狀態(tài)。快照不是全量內(nèi)存dump而是足以撐起回放下一步的最小狀態(tài)集?;胤艜r可以傳入從第100步開始的指令Harness會加載第100步之前最近的一份快照再順序注入日志事件直到追上目標(biāo)seq。整套實(shí)現(xiàn)下來大概只增加了15%的復(fù)雜度但調(diào)試效率提升了一個量級。關(guān)于快照有一個容易踩的坑快照保存的是引用還是值。我們一開始圖省事直接把內(nèi)存對象塞進(jìn)快照結(jié)果重啟回放后被插件修改過的list對象殘留在快照里導(dǎo)致后續(xù)事件全部錯位。后來統(tǒng)一改為序列化副本寧可多花點(diǎn)存儲也要保證快照在加載時是干凈狀態(tài)。4.3 確定性、冪等與副作用控制回放要能被用于測試就必須確定。我們把所有不確定來源都收口到兩個地方模型輸出從日志讀回工具輸出從日志讀回隨機(jī)數(shù)種子在回放啟動前固定時間由Harness統(tǒng)一注入。這樣同一份日志在任意機(jī)器上重放每一步比對結(jié)果都應(yīng)該完全一致。冪等性分兩層。第一層是針對回放引擎自身同一個事件不能因?yàn)榫W(wǎng)絡(luò)抖動或重復(fù)觸發(fā)而消費(fèi)兩次事件總線里用event_id做了消費(fèi)記錄。第二層是針對真實(shí)運(yùn)行時回放環(huán)境里的工具不會真正發(fā)起網(wǎng)絡(luò)請求但為了應(yīng)付回放工具的Output里包含一個URL業(yè)務(wù)代碼拿到后主動去請求的情況我們在回放模式下默認(rèn)攔截所有出站HTTP請求只允許訪問本地回環(huán)地址。這樣既能保障安全也能進(jìn)一步保證確定性。5. 回放不一致的五個真實(shí)現(xiàn)場踩坑與修復(fù)過程5.1 事件亂序插件各自打點(diǎn)埋下的雷第一次大規(guī)模回放測試時我們遇到了一個特別詭異的現(xiàn)象同一個會話在日志里看起來順序正常但回放引擎跑到某一步就報replay_mismatch。查了兩天才發(fā)現(xiàn)一些插件不是通過Harness上報事件而是自己在業(yè)務(wù)代碼里調(diào)transport.append()并且用了各自服務(wù)器的時間戳。A機(jī)器時鐘快了30秒B機(jī)器時鐘慢了10秒導(dǎo)致事件流的seq和實(shí)際因果順序不一致。問題一出解決方案反而簡單刪除所有插件直接寫入日志的入口事件來源只能有Harness調(diào)度器一個seq統(tǒng)一在事件總線入口分配ts只用于展示。從這以后任何插件想繞過Harness寫日志都會被直接打回。5.2 參數(shù)原地修改日志里記錄的不是真實(shí)入?yún)⒌诙€坑更隱蔽。某個工具插件在execute里拿到的入?yún)⑹且粋€dict插件內(nèi)部為了規(guī)范數(shù)據(jù)直接修改了入?yún)ο蟊热绨裻ime_range字段從last_month改成了last_30d。結(jié)果日志是在調(diào)用前序列化的記錄的是last_month但真實(shí)工具執(zhí)行用的是last_30d回放時輸出自然對不上。修復(fù)方法是在插件調(diào)用之前對入?yún)⒆鲆淮蝑eep copy并且立即序列化到事件data后續(xù)不管插件怎么改都不影響日志。這個教訓(xùn)后來被寫進(jìn)了插件開發(fā)規(guī)范插件收到入?yún)⒑髧?yán)禁原地修改要么只讀要么先拷貝再改。5.3 敏感信息泄漏一次API Key被寫進(jìn)日志的突發(fā)事件有一次聯(lián)調(diào)同事在工具入?yún)⒗锛恿艘粋€raw_token字段我們的脫敏規(guī)則只匹配token作為完整key的情況結(jié)果raw_token沒被識別API Key明文落盤了。這是可回放日志最尷尬的問題日志越完整越容易把不該記的東西記下來。修復(fù)不是一個正則表達(dá)式能解決的。我們做了一套字段級脫敏配置插件可以聲明哪些字段是敏感字段序列化時按json path精確匹配另外在Transport里加了一道兜底過濾器檢測常見密鑰格式并整條mask。還加了一個離線掃描任務(wù)定期掃歷史日志用高熵正則找出疑似泄漏的字符串。5.4 流式輸出與超長會話的工程應(yīng)對流式模型輸出是回放里最容易出問題的地方。真實(shí)運(yùn)行時model_response可能是一堆chunk事件帶增量內(nèi)容回放時如果簡單地把所有chunk一次性喂給上層業(yè)務(wù)代碼的流式解析邏輯會直接異常。我們?yōu)榛胤抛隽朔至骰胤拍J较碌哪P晚憫?yīng)不再按chunk逐個吐而是把完整內(nèi)容組裝好后再一次性回調(diào)同時保留chunk邊界信息避免影響消息解析。超長會話的應(yīng)對比較簡單日志按會話分片快照每50步存一個超過2000步的會話可以分段回放。同時把trace級日志默認(rèn)關(guān)掉線上只保留info級別只有顯式對某個會話開打點(diǎn)之后才記錄完整trace。6. 把可回放日志當(dāng)工程資產(chǎn)用測試、復(fù)現(xiàn)與性能分析6.1 最小回歸閉環(huán)一次事故一條用例引入回放日志后最有價值的變化是回歸測試方式變了。以前修完一個Agent bug只能靠測試同學(xué)手動構(gòu)造對話路徑驗(yàn)證費(fèi)時且覆蓋不全?,F(xiàn)在線上發(fā)生事故后只要把出問題的那個會話日志拉下來清洗掉敏感信息存成一個.replay文件提交到測試倉庫就能變成一條自動回歸用例。CI里跑回放斷言多了一條不許出現(xiàn)replay_mismatch。我們大概累積了兩百多條這樣的回放用例每次改動框架主流程或者某個核心插件都要全量跑一遍。這個效率是以前手工測試完全比不了的。6.2 線上問題離線復(fù)現(xiàn)的操作路徑具體做離線復(fù)現(xiàn)時流程大概是這樣的用戶反饋某次會話行為異常在會話管理后臺導(dǎo)出該session的日志文件在本地用同一版本Harness配置加載日志跑回放如果回放正常說明問題出在環(huán)境或非確定因素再用trace級日志重新定位如果回放失敗replay_mismatch信息會直接指出第幾步、期望事件是什么、實(shí)際事件是什么按圖索驥。這個流程看起來簡單但實(shí)際過程中最花時間的是配置版本對齊。日志里記錄了Harness的版本號和插件版本清單復(fù)現(xiàn)時必須用同樣的版本啟動不然事件格式都可能不同。我們后來把版本信息作為session_started事件data的一部分徹底規(guī)避了拿新代碼回放舊日志的錯位。6.3 日志驅(qū)動的問題定位與性能分析回放日志沉淀下來后除了排障還能做不少分析。最常用的是工具調(diào)用耗時分析從tool_invocation事件里能直接拿到每個工具的執(zhí)行耗時和返回體大小聚合后能看出哪個工具老是大請求返回。模型側(cè)也一樣model_request和model_response事件能統(tǒng)計出每次會話的模型調(diào)用次數(shù)、token消耗、哪個用戶的會話特別耗token。這些指標(biāo)以前都要靠監(jiān)控系統(tǒng)額外埋點(diǎn)現(xiàn)在直接從會話日志里就能算出來。由于事件帶有因果鏈我們還能做更細(xì)的分析從最終失敗節(jié)點(diǎn)反推前置工具調(diào)用鏈一句話就能看到這個Agent是經(jīng)過哪些步驟才陷入錯誤狀態(tài)的。這在prompt調(diào)優(yōu)時特別有用許多模型為什么不選擇工具B的問題翻幾份回放日志就能找到規(guī)律。最后再分享一點(diǎn)個人體會。做回放日志最花時間的地方不是設(shè)計事件格式而是約束團(tuán)隊(duì)不繞過Harness上報事件、不直接改插件入?yún)?、不在日志里夾帶敏感數(shù)據(jù)大部分坑都不是技術(shù)問題而是紀(jì)律問題。如果你也要在Agent框架里做類似設(shè)計我建議把可回放當(dāng)成一個硬性驗(yàn)收標(biāo)準(zhǔn)來約束架構(gòu)而不是當(dāng)成事后加上的調(diào)試工具。這個約束從第一天建立和從第200天開始補(bǔ)代價是完全不同的。