骨架)
前一陣我把一個開源大模型團隊放出的 Agent 框架源碼從前到后拆了一遍。拆之前我的預(yù)期挺簡單一個 Agent 框架核心就是“模型調(diào)用工具執(zhí)行”插件化無非是把工具注冊表做成可擴展的而已。拆完之后這個預(yù)期被徹底推翻了——在這套設(shè)計里插件化不是掛在骨架上的裝飾而是骨架本身。這篇文章不打算給你復(fù)述文檔。我想講的是從源碼層面看到的“插件化”是怎么一步步變厚的啟動階段做了哪些事、內(nèi)置插件走的是哪些插槽、你寫自定義插件的時候會遇到哪些隱藏約定還有我實際排查過的三個坑。如果你正在自研智能體應(yīng)用或者想把一個別人的 Agent 框架集成進業(yè)務(wù)系統(tǒng)這篇應(yīng)該能幫你在動手之前先建立一張地圖。1. 為什么插件化會決定 Agent 框架的上限1.1 “工具函數(shù)列表”和“插件體系”是兩碼事很多 Agent 工程起步時都會定義一個工具列表把搜索、計算器、查數(shù)據(jù)庫之類的函數(shù)掛上去模型需要時按名稱調(diào)用。這種實現(xiàn)跑 Demo 沒問題但把工具函數(shù)塞進一個大數(shù)組只算“擴展能力”不算“插件化”。插件化意味著整個運行過程可以被插拔。我把這套框架拆完之后印象最深的一件事是它的內(nèi)核幾乎沒有業(yè)務(wù)邏輯所有外部能力都是通過接口進來的。模型供應(yīng)商是一個插件記憶存儲是一個插件工具注冊是一個插件統(tǒng)計觀測也是一個插件。內(nèi)核只做一件事把對話循環(huán)跑起來然后在每個關(guān)鍵節(jié)點把控制權(quán)交給注冊好的插件。這個差別不是實現(xiàn)細節(jié)上的差別而是架構(gòu)思路上的差別。前者把擴展點寫死在代碼里后者把擴展點定義成一種“協(xié)議”。1.2 Agent 框架比普通后端框架更需要插件化為什么偏偏 Agent 框架要把插件化做得這么重我拆的時候一直在想這個問題。后來想明白了一個很現(xiàn)實的點Agent 系統(tǒng)的運行循環(huán)是反復(fù)迭代的——模型發(fā)一個請求工具返回一個結(jié)果再傳給模型再調(diào)用下一個工具。如果你把“模型調(diào)用方式”“記憶讀取策略”“工具沖突處理”全部硬編碼進主循環(huán)任何業(yè)務(wù)上的新需求都會變成對主循環(huán)的修改。比如今天要用流式輸出明天要切換一個向量數(shù)據(jù)庫后天要接入多個模型的自動路由。在主循環(huán)里堆 if else 是最快的翻車方式。插件化把變化點抽成一個接一個的插槽業(yè)務(wù)方只需要關(guān)心自己負責(zé)的那個插槽不需要理解整個循環(huán)。而且 Agent 場景天然多模型、多工具、多記憶策略這些東西的組合是乘法級別的。硬編碼只能支持線性增長插件化才能支撐組合式增長。1.3 既然插件化這么好為什么多數(shù)框架不做透拆這套框架的過程中我也順手翻過其他幾個開源 Agent 項目。很多項目的問題不是沒有插件概念而是插件化只做了一層皮有 Plugin 類、有 register 方法但插件之間無法互相調(diào)用插件生命周期沒人管理插件配置要靠全局變量傳遞。真正做透插件化的成本很高。第一你要定義一套足夠穩(wěn)定的生命周期第二你要處理插件之間的依賴關(guān)系第三你要給插件提供上下文對象而不是讓它們各自取全局狀態(tài)第四你還要處理加載順序和配置合并這些邊緣問題。這些設(shè)計每一條都燒時間而且短期內(nèi)看不出收益。所以當我在源碼里看到一整套“配置合并 → 模塊掃描 → 生命周期管理 → 上下文注入”的鏈路時我意識到這不是順手寫的而是認真設(shè)計過的。2. 啟動階段的三條暗線配置合并、模塊掃描、生命周期管理拆解啟動過程是我覺得收獲最大的部分。一個 Agent 服務(wù)啟動看起來只是“讀配置文件拉起服務(wù)”但插件化的地基全部埋在這里。2.1 第一條暗線配置合并的順序框架啟動時配置來源不是單一的一個文件而是有多層默認配置、本地配置文件、環(huán)境變量、命令行參數(shù)。這四層配置需要按優(yōu)先級合并。我在源碼里看到的合并邏輯是分層 Merge先加載默認配置再用配置文件覆蓋默認值接著用環(huán)境變量覆蓋配置文件最后命令行參數(shù)優(yōu)先級最高。每一層都是深合并不是整塊替換。這個順序直接決定了插件能不能拿到正確的配置。如果你自己實現(xiàn)插件化我建議把“配置合并”放進啟動器的前置階段并且給每個插件發(fā)一個只讀的配置快照而不是讓插件直接去讀全局配置。一旦插件散落在各處讀環(huán)境變量你就再也無法控制它們的可測試性了。2.2 第二條暗線模塊掃描啟動階段的第二件事是找到所有插件。這里沒有用一長串 import 硬編碼而是走了一套“聲明式掃描”。核心邏輯大概是先讀配置里聲明啟用了哪些插件再按照插件路徑去逐一導(dǎo)入模塊導(dǎo)入之后調(diào)用模塊暴露的注冊入口。# 示意代碼插件掃描與注冊 import importlib import logging logger logging.getLogger(__name__) def load_plugins(registry, config): plugin_paths config.get(plugins, []) if not plugin_paths: return [] for plugin_path in plugin_paths: try: module importlib.import_module(plugin_path) except ImportError: logger.warning(plugin %s not found, skip, plugin_path) continue if hasattr(module, register): # 注冊階段只聲明能力不做初始化 module.register(registry) else: logger.warning(plugin %s has no register function, plugin_path)這段看起來簡單但有兩個關(guān)鍵點。一是“注冊階段不初始化”這意味著 register 只負責(zé)把插件的能力聲明填到注冊表里不連接數(shù)據(jù)庫、不創(chuàng)建線程池。真正做重活的是后續(xù)的 setup 階段。二是“按配置聲明加載”而不是自動掃描所有包這樣既能控制加載范圍也能避免無意間把測試插件帶進生產(chǎn)環(huán)境。2.3 第三條暗線生命周期管理注冊只是第一步插件要從“已注冊”變成“可運行”中間還有一整套生命周期鉤子。我拆下來看到的鉤子主要有下面這幾個每個鉤子的觸發(fā)時機和作用完全不同鉤子名稱觸發(fā)時機典型用途validate插件注冊完成后校驗依賴是否齊全、配置是否合法setup框架啟動過程中建立外部連接、初始化客戶端start第一個對話開始前預(yù)熱緩存、加載預(yù)訓(xùn)練向量stop服務(wù)關(guān)閉前保存檢查點、落盤狀態(tài)teardown資源回收階段關(guān)閉連接、清理線程池這套生命周期最大的價值是把“插件的代碼”和“框架的運行狀態(tài)”解耦了。插件作者不需要猜測自己應(yīng)該在哪一步連接數(shù)據(jù)庫直接按鉤子語義來實現(xiàn)就行。比如你做一個記憶插件在 setup 里建立向量庫連接在 stop 里把內(nèi)存索引寫盤在 teardown 里釋放連接整個生命周期就能被框架穩(wěn)定地控制住。我在自己的工程里后來也復(fù)刻了這套生命周期哪怕只是極簡版也幫我避免了很多“插件對象被創(chuàng)建但還沒初始化”的尷尬問題。3. 內(nèi)置插件拆出來的四種“難做但值得做”的插槽框架內(nèi)置插件并不多但每個插槽的選擇都很有代表性。我挑四個印象最深的插槽展開說它們分別處理的是模型接入、工具注冊、記憶存儲、可觀測性。3.1 模型適配器把“換模型”變成換插件模型適配器是外部模型和內(nèi)部循環(huán)之間的接口。整套框架的對話循環(huán)不直接關(guān)心你用的是哪家模型、什么格式、什么參數(shù)體系。它只認一個統(tǒng)一的聊天完成接口包括普通生成和流式生成兩個變體。我拆下來看到的接口設(shè)計大概是這樣的思路定義一個 ChatModel 抽象里面有一個 chat 方法和一個 stream 方法不同模型各自實現(xiàn)這兩個方法。業(yè)務(wù)層要切換模型時只需要在配置里換掉當前激活的模型適配器。這個插槽的價值在只接一種模型的時候體現(xiàn)不出來。一旦你需要在不同供應(yīng)商之間做自動路由、需要根據(jù)任務(wù)難度選擇不同模型、需要灰度切換模型版本模型適配器就是唯一的改動入口。沒有這個插槽換模型意味著把主循環(huán)里的調(diào)用全部改一遍。3.2 工具注冊器把“工具聲明”和“工具執(zhí)行”拆開第二個讓我眼前一亮的內(nèi)置插槽是工具注冊器。很多實現(xiàn)里工具就是一個可調(diào)用函數(shù)最多帶上一個 JSON Schema 描述。但在這套框架里工具被拆成了兩個階段聲明和執(zhí)行。聲明階段提供工具的元信息包括名稱、描述、參數(shù) Schema、權(quán)限要求執(zhí)行階段才是真正跑業(yè)務(wù)邏輯??蚣茉诿看握{(diào)用工具前會先校驗參數(shù)再執(zhí)行映射執(zhí)行完畢還會把結(jié)果經(jīng)過統(tǒng)一的后續(xù)處理鉤子送回對話循環(huán)。這個設(shè)計說直白點就是把“工具調(diào)用”從一個函數(shù)調(diào)用變成了一條有攔截點的鏈路。審計日志可以在執(zhí)行前攔截權(quán)限校驗可以在執(zhí)行前攔截參數(shù)糾錯也可以在 Schema 校驗階段完成。如果只是把函數(shù)直接塞給模型這些能力強加不進去。3.3 記憶存儲記憶變成可替換的存儲后端記憶存儲是很多 Agent 項目最后才會考慮的事。前期對話短直接在上下文里塞就好等到對話歷史超過模型上下文窗口才不得不做壓縮和檢索。這套框架把記憶抽象成了幾個操作保存、讀取、列出、刪除。默認實現(xiàn)是內(nèi)存存儲但接口不變的情況下你可以換成向量庫、關(guān)系庫、文件存儲甚至接一層混合存儲熱數(shù)據(jù)放內(nèi)存冷數(shù)據(jù)放向量庫。這個插槽對我的啟發(fā)是記憶不應(yīng)該被硬編碼成“歷史消息數(shù)組”而應(yīng)該是一種可檢索的存儲接口。接口定得好后續(xù)加檢索器、加壓縮策略都很順手。接口定得差比如直接暴露一個列表讓你往里面 append那后面做任何優(yōu)化都要改業(yè)務(wù)代碼。3.4 觀測與評測讓數(shù)據(jù)收集也走插件通道這個插槽是我原本沒想到的??蚣馨讶罩?、指標、調(diào)用鏈追蹤、評測回調(diào)全都做成了插件。每完成一次模型調(diào)用框架會觸發(fā)一個“調(diào)用完成”鉤子每次工具執(zhí)行結(jié)束也會觸發(fā)對應(yīng)的鉤子。觀測插件只需要訂閱這些鉤子就能匯總 token 用量、耗時、調(diào)用次數(shù)、成功率。這比在業(yè)務(wù)代碼里到處埋點要干凈得多。業(yè)務(wù)方不需要關(guān)心數(shù)據(jù)怎么上報框架也不需要在主循環(huán)里寫死一個 metrics 系統(tǒng)。兩邊通過鉤子協(xié)議交互想接 Prometheus 就接 Prometheus想接日志平臺就接日志平臺。表格總結(jié)一下這四個插槽方便對照插槽類型核心接口思想默認實現(xiàn)常見擴展場景模型適配器統(tǒng)一 chat/stream 接口單一模型直連多模型路由、灰度切換工具注冊器聲明與執(zhí)行分離本地函數(shù)注冊權(quán)限校驗、審計、動態(tài)工具記憶存儲save/recall/list/delete內(nèi)存存儲向量庫、數(shù)據(jù)庫、多層存儲觀測與評測鉤子訂閱本地日志指標上報、調(diào)用鏈追蹤值得注意的是這四個插槽不是四個各自獨立的系統(tǒng)。它們?nèi)抗灿猛惶咨芷?、同一個配置注入機制、同一個上下文對象。這就是把插件化做透的意義當你學(xué)會寫其中一個插件其他插件的寫法也就都會了。4. 隱藏的約定注冊入口、配置注入與加載優(yōu)先級這部分內(nèi)容文檔里基本不會寫全我是靠翻源碼和跑調(diào)試才摸清楚的。我把它們稱為“隱藏的約定”因為不遵守這些約定插件代碼也能運行但會在一兩周后突然出問題而且問題非常難查。4.1 一個模塊必須暴露統(tǒng)一的注冊入口每個插件模塊都要暴露一個 register 函數(shù)框架加載時統(tǒng)一調(diào)用。這個約定的好處是可以做靜態(tài)檢查框架在啟動階段就能發(fā)現(xiàn)插件模塊缺少注冊入口從而快速失敗而不是等到運行中途才發(fā)現(xiàn)某個能力缺失。這和我們寫單元測試很像。你寫測試用例時不希望靠魔法自動發(fā)現(xiàn)測試函數(shù)你會約定測試類必須繼承某個基類或方法名必須以 test 開頭。插件系統(tǒng)也是同理統(tǒng)一入口比反射魔法更可控。4.2 插件聲明元數(shù)據(jù)而不是靠猜每個插件模塊還要聲明自己的名稱、版本號和依賴關(guān)系。依賴聲明在這個框架里特別重要因為插件之間可以互相調(diào)用。比如你的業(yè)務(wù)插件要用內(nèi)置記憶插件就必須在元數(shù)據(jù)里聲明這個依賴加載器會做依賴檢查依賴缺失時它會明確報錯而不是讓你的插件運行到一半再去訪問空實例。4.3 配置注入是“帶命名空間”的不是全局裸露的插件讀取配置時拿到的不是整個全局配置對象而是一個被隔離的配置片段??蚣軙o每個插件分配一個獨立的配置命名空間。插件只能感知自己命名空間下的配置不能隨隨便便讀到其他插件的內(nèi)部配置。這樣做的好處是隔離性。多個插件都由同一個團隊開發(fā)時全局配置隨手可讀一旦插件數(shù)量變多、甚至由不同團隊開發(fā)命名空間隔離能避免配置名互相覆蓋。我見過太多因為全局配置鍵沖突導(dǎo)致的線上事故所以這一點我極其贊同。4.4 加載優(yōu)先級內(nèi)置先于外部聲明順序決定覆蓋關(guān)系框架加載插件時默認先加載內(nèi)置插件再按用戶聲明順序加載外部插件。這個順序意味著外部插件可以覆蓋內(nèi)置插件的某些行為但內(nèi)置插件始終先注冊保證基礎(chǔ)能力可用。加載順序還影響同名鉤子的執(zhí)行順序。如果你寫了多個插件都訂閱了同一個“工具調(diào)用前”的鉤子那么先加載的插件會先執(zhí)行。這一點不搞清楚排查鉤子執(zhí)行順序的時候會非常困惑。4.5 約定太多也會變成負擔我必須說句公道話這套框架的約定足夠多多到新手剛開始一定會懵。約定帶來了規(guī)則的一致性也讓“框架感”更強。在實際落地時我給團隊的內(nèi)部規(guī)范是約定要選最有價值的寫進文檔其他用代碼示例引導(dǎo)。千萬別指望所有人自覺看源碼。5. 排查插件鏈路時踩過的三個關(guān)鍵坑拆這套框架期間我自己寫了好幾個自定義插件也順手修改過內(nèi)置插件。這個過程中踩了三個坑每個都花了不少時間排查?,F(xiàn)在回頭看它們恰恰能說明插件化設(shè)計里的幾個隱藏取舍。5.1 插件 B 調(diào)用插件 A拿到的是空實例第一個坑出現(xiàn)在插件互相調(diào)用時。我在自定義插件里要讀取內(nèi)置記憶插件的內(nèi)容直接在 setup 階段訪問結(jié)果拿到的是 None。我一開始以為是注冊失敗加了一堆日志才發(fā)現(xiàn)是加載順序問題我的插件先于內(nèi)置記憶插件完成 setup所以訪問時記憶插件的實例還沒有被初始化。排查鏈路是這樣走的第一步確認插件是否出現(xiàn)在注冊表里第二步加日志打印每個插件的 setup 時間和順序第三步發(fā)現(xiàn)同名插件的 setup 順序完全取決于加載順序第四步翻到一個依賴檢查機制需要在插件元數(shù)據(jù)里顯式聲明依賴。根因找到了框架沒有做自動依賴拓撲排序而是要求開發(fā)者顯式聲明依賴。表面上看這不夠“智能”但設(shè)計意圖是避免隱式依賴帶來的循環(huán)引用和無限遞歸。修復(fù)方式就是在元數(shù)據(jù)里補上依賴聲明并且在訪問別的插件時加一個空值守衛(wèi)。5.2 配置文件里寫了參數(shù)加載后還是默認值第二個坑更隱蔽。我在配置文件里寫了一個記憶插件的參數(shù)結(jié)果運行起來發(fā)現(xiàn)參數(shù)沒生效還是代碼里的默認值。第一反應(yīng)是框架沒讀我的配置文件后來打印了實際配置才發(fā)現(xiàn)配置文件被加載了但合并邏輯是淺合并導(dǎo)致我的自定義參數(shù)被默認配置整體覆蓋了。排查過程讓我理解了框架的選擇。它默認實現(xiàn)的是“按頂層鍵覆蓋”而不是“全路徑深合并”。這種策略的好處是避免嵌套結(jié)構(gòu)被意外合并出預(yù)期之外的組合壞處是如果你只改了子字段父級整塊可能被覆蓋。解決辦法是自定義插件要提供結(jié)構(gòu)清晰的默認配置塊并且在合并時采用逐字段深合并。這也提醒我在業(yè)務(wù)代碼里處理多層配置時明確合并策略比約定優(yōu)先級更重要。5.3 熱加載之后上下文不同步第三個坑是熱加載導(dǎo)致的。開發(fā)調(diào)試時我啟用了插件的熱加載能力修改插件代碼后不需要重啟服務(wù)。但運行中的 Agent 主循環(huán)還是拿著舊的對象引用導(dǎo)致改了代碼不生效。排查后發(fā)現(xiàn)熱加載只替了注冊表里的插件對象但已經(jīng)在運行中的上下問里持有的實例不會自動更新。框架選擇的是“會話級上下文快照”每個對話持有自己的插件引用新的對話才會拿到新版本插件。這個設(shè)計取舍我可以理解如果強行更新運行中的上下文很可能會導(dǎo)致正在執(zhí)行的調(diào)用鏈引用到不兼容的對象崩潰更頻繁。排查完之后我給自己定了個規(guī)矩插件熱加載只用于開發(fā)調(diào)試絕不在生產(chǎn)環(huán)境開著。6. 落地到自己的項目最小可用的 Agent 插件骨架拆完別人的框架真正有用的收獲是能提煉出一套可以落到自己項目里的最小骨架。這里我把它寫出來你可以直接參考。6.1 核心結(jié)構(gòu)Registry Context Lifecycle最小骨架只需要三個核心組件。Registry 負責(zé)注冊和查詢插件Context 負責(zé)在插件之間傳遞狀態(tài)Lifecycle 負責(zé)在正確時間調(diào)用插件的初始化與清理方法。6.2 極簡實現(xiàn)示例下面這段 Python 代碼展示的就是這套最小骨架的核心接口。它不追求完整但保留了最重要的生命周期和鉤子機制。from typing import Any, Callable, Dict, List class PluginContext: 插件上下文所有插件共享的狀態(tài)容器 def __init__(self, global_config: Dict[str, Any]): self.config global_config self.store: Dict[str, Any] {} class AgentPlugin: 插件基類子類只需要按需重寫鉤子方法 name: str base_plugin version: str 0.0.1 dependencies: List[str] [] def setup(self, ctx: PluginContext): pass def before_tool_call(self, ctx: PluginContext, tool_name: str, args: dict): pass def after_tool_call(self, ctx: PluginContext, tool_name: str, result: Any): pass def teardown(self): pass class Registry: 插件注冊表管理插件的注冊、查詢和鉤子分發(fā) def __init__(self): self.plugins: Dict[str, AgentPlugin] {} self.hooks: Dict[str, List[Callable]] {} def register(self, plugin: AgentPlugin): self.plugins[plugin.name] plugin for hook_name in [before_tool_call, after_tool_call]: fn getattr(plugin, hook_name, None) if fn is None: continue self.hooks.setdefault(hook_name, []).append(fn) def get(self, name: str): return self.plugins.get(name) def run_hook(self, hook_name: str, *args, **kwargs): fns self.hooks.get(hook_name, []) for fn in fns: fn(*args, **kwargs) def startup(self, ctx: PluginContext): for plugin in self.plugins.values(): plugin.setup(ctx) def shutdown(self): for plugin in reversed(list(self.plugins.values())): plugin.teardown()如果你之前沒接觸過這類設(shè)計可以把它想象成一個“有登記、有回調(diào)、有啟動順序”的插件總線。主循環(huán)只需要在對應(yīng)位置調(diào)用run_hook插件注冊時自動訂閱這些鉤子。6.3 為什么我堅持用 Context 而不是全局變量這個骨架里特別強調(diào)了 Context是因為 Agent 對話天然是并發(fā)場景。如果你用模塊級全局變量來傳遞狀態(tài)兩個并發(fā)對話會在同一份狀態(tài)上互相覆蓋這會引發(fā)非常難查的問題。Context 對象每一個對話循環(huán)創(chuàng)建一份插件在回調(diào)時拿到的永遠是當前對話的狀態(tài)。這樣做雖然多了一點傳參成本但換來的是并發(fā)安全。6.4 不需要照搬的部分不是所有細節(jié)都需要復(fù)制。比如我不建議一上來就做完整的依賴拓撲排序、復(fù)雜的熱加載、多層配置合并。先把“注冊入口、生命周期、Context 傳遞”這三件事做好插件系統(tǒng)已經(jīng)能支撐大部分業(yè)務(wù)需求。后續(xù)再慢慢補強遠比一開始就造一個重型框架更穩(wěn)妥。7. 拆完源碼后我的插件設(shè)計原則變了說實話拆之前我對插件化的理解還停留在“接口加注冊表”拆完之后沉淀下來的是幾條更細的設(shè)計原則。寫在這里也算給自己提個醒。第一注冊階段必須是純聲明不能做任何初始化動作。凡是 register 里連接數(shù)據(jù)庫、創(chuàng)建線程池的插件代碼都是定時炸彈。注冊只做能力登記啟動階段再做實際初始化。第二插件之間的依賴必須顯式聲明。哪怕你的插件現(xiàn)在只有一個也要把 dependency 字段留著。部署插件不是寫單機腳本你會很難記住誰依賴誰讓框架在啟動時幫你檢查依賴比運行時崩潰再查要高效得多。第三鉤子的粒度要圍繞 Agent 主循環(huán)來定。不要憑空定義很多看似漂亮的鉤子要對著真實的主循環(huán)找出變化點工具調(diào)用前、工具調(diào)用后、模型響應(yīng)后、會話結(jié)束。定義滿覆蓋率高插件作者才有地方插。第四上下文對象要小而明確。Context 里塞太多全局數(shù)據(jù)最后會變成一個沒有文檔的萬能包。寧可讓插件自己去注冊表取數(shù)據(jù)也別在 Context 里堆所有中間結(jié)果。第五插件出錯要能局部隔離??蚣茉谶@種場景下最害怕的就是一個插件的異常拖垮整個對話。我在最小骨架里特意沒有處理這個問題但實際落地時我會在 run_hook 外層包一層 try/except把插件異常轉(zhuǎn)成可觀測的日志項而不是直接拋出整個調(diào)用鏈。這套源碼拆完之后我自己最大的變化是不再把插件化當成一個“錦上添花”的功能而是開始把它當作 Agent 框架的基礎(chǔ)設(shè)施。工具會越來越多模型會越來越雜記憶和評測會越來越重沒有一套清晰的插件骨架這些東西遲早會把主循環(huán)變成一座搬不動的泥山。如果你也在做類似的項目我建議你先別急著追求復(fù)雜框架找個周末把自己項目里“未來一定會變”的那幾個點列出來然后照著注冊表、生命周期、上下文這三件套畫一版極簡骨架。跑一個老朋友對話的 Demo再回頭決定要不要把某個能力收編成插件。你會發(fā)現(xiàn)先搭骨架再填肉比直接在肉上打補丁要省心得多。