用管理實戰(zhàn))
去年年底在重構(gòu)一個內(nèi)部 AI 助手時我反復(fù)遇到同一個尷尬模型本身很聰明但它總是用錯工具、漏傳參數(shù)甚至在不需要查詢時硬去搜索。后來我把所有工具調(diào)用收攏成一套獨立管理機制也就是現(xiàn)在這個名為 agent-skills 的技能注冊與調(diào)度體系才真正把會對話的模型變成會辦事的模型。這篇文章不聊空理論只講我實際怎么設(shè)計、怎么落地以及中間踩過的那些坑給準備做 Agent 技能編排、工具庫管理、Function Calling 架構(gòu)的同學(xué)一個可參考的樣本。1. 為什么我會專門抽一層技能庫來管 Agent 的能力1.1 之前把所有工具寫死在代碼里問題有多嚴重最初做 Agent 原型時我的寫法非常直接在系統(tǒng)提示詞里把所有工具描述寫進去然后代碼里放一堆 if-else 或者 match-case把模型返回的函數(shù)名映射到實際函數(shù)。這個方案在只有兩三個工具時很好用但一旦超過十個麻煩就接踵而至。首先是提示詞越來越長每次調(diào)用都要攜帶全部工具描述token 消耗大而且模型對后邊的工具描述記憶明顯減弱。其次是新增一個工具要改多處代碼——注冊、描述、參數(shù)校驗、異常處理漏一處就出 Bug。第三是工具之間會有交叉依賴比如搜索和抓取網(wǎng)頁經(jīng)常要組合使用但在硬編碼結(jié)構(gòu)里這種組合邏輯完全沒法復(fù)用。最后逼我動手重構(gòu)的導(dǎo)火索是一次演示翻車模型明明應(yīng)該調(diào)用查數(shù)據(jù)庫技能卻因為描述里有個模糊詞誤選了搜索文件。那時候我意識到不能把工具當散兵游勇得把它們變成一個結(jié)構(gòu)化的技能資產(chǎn)來治理。1.2 agent-skills 的設(shè)計目標我需要的不是一個框架而是一套輕量級的規(guī)范。當時列了幾個硬性要求每個技能有獨立、自描述的結(jié)構(gòu)包含名稱、用途、參數(shù)校驗規(guī)則、執(zhí)行體。技能注冊是聲明式的新增技能不需要改動調(diào)度主邏輯。模型看到的技能目錄是動態(tài)生成的可以根據(jù)對話上下文裁剪而不是一股腦全塞進去。技能之間可以有顯式的依賴關(guān)系允許一個技能內(nèi)部調(diào)用另一個技能。這套規(guī)范我起名叫 agent-skills后面就是按這個思路一步步實裝的。它的本質(zhì)是把模型可以調(diào)用什么這件事從代碼中解耦出來變成可注冊、可發(fā)現(xiàn)、可度量、可淘汰的資源。1.3 和 Function Calling、插件體系的關(guān)系提一句容易混淆的概念。OpenAI 的 Function Calling 是模型輸出結(jié)構(gòu)化調(diào)用指令的能力LangChain 的 Tool 則是將函數(shù)包裝成模型可用形式的抽象。agent-skills 更接近一個技能管理層它位于模型和實際工具函數(shù)之間負責技能的登記、索引、描述生成、參數(shù)校驗和調(diào)用編排。你可以理解成 Function Calling 是通信協(xié)議Tool 是單個接口而 agent-skills 是管理這些接口的注冊中心和調(diào)度器。這樣分層之后換一個底層模型、換一種 Function Calling 實現(xiàn)技能定義不用大改上層業(yè)務(wù)代碼也不受影響。2. 技能的標準結(jié)構(gòu)一個可被模型讀懂和執(zhí)行的單元2.1 核心組成身份、Schema、執(zhí)行體在 agent-skills 里我定義了一個技能的最小單元它必須有四個部分name機器可讀的技能標識用 snake_case比如 web_search。description給模型看的人類可讀說明必須包含什么時候用、什么時候不要用。parameters參數(shù)結(jié)構(gòu)定義使用 Pydantic 模型自動生成 JSON Schema。execute異步執(zhí)行函數(shù)負責完成具體動作并返回結(jié)構(gòu)化結(jié)果。這個結(jié)構(gòu)參考了 OpenAPI 規(guī)范和 Anthropic 的 tool use 格式但為敏捷開發(fā)做了一些簡化。下面是實際代碼里 Skill 類的核心片段。from typing import Any, Callable, Optional, Type from pydantic import BaseModel, create_model import json class Skill: def __init__( self, name: str, description: str, params_model: Type[BaseModel], execute: Callable[..., Any], category: str general, version: str 1.0.0, ): self.name name self.description description self.params_model params_model self.execute execute self.category category self.version version property def param_schema(self) - dict: # 由 Pydantic 模型直接生成 JSON Schema供模型側(cè)使用 return self.params_model.model_json_schema() async def run(self, **kwargs) - Any: # 入口處統(tǒng)一做參數(shù)校驗失敗時給出可讀錯誤信息 validated self.params_model(**kwargs) return await self.execute(**validated.model_dump())這里最容易被忽略的一點是參數(shù)校驗不能放到執(zhí)行函數(shù)內(nèi)部做必須在技能入口統(tǒng)一做。因為模型返回的參數(shù)經(jīng)常有缺漏、類型錯誤如果每個技能里各寫各的校驗很快就會出現(xiàn)同一個錯誤在不同技能上報錯格式不一致的情況。統(tǒng)一在 run 里做校驗后續(xù)做日志審計、指標收集都會方便很多。2.2 為什么要用 Pydantic 自動生成 Schema而不是手寫 JSON我見過不少項目直接在代碼里手寫 JSON Schema比如{ type: object, properties: { query: {type: string, description: 搜索關(guān)鍵詞} }, required: [query] }第一次寫沒問題但技能有二十個以后字段一改手寫的 JSON 經(jīng)常忘記同步。模型拿到的 Schema 和實際執(zhí)行函數(shù)對不上后果就是調(diào)用時報參數(shù)錯誤甚至是更隱蔽的漏參。用 Pydantic 之后參數(shù)模型就是唯一事實來源。比如我定義搜索技能from pydantic import BaseModel, Field class WebSearchParams(BaseModel): query: str Field(description搜索關(guān)鍵詞盡量精確) max_results: int Field(3, ge1, le10, description返回結(jié)果數(shù)量) region: str Field(zh-CN, description搜索區(qū)域) async def web_search(query: str, max_results: int, region: str) - list[dict]: # 實際調(diào)用搜索 API ...param_schema會直接生成{ properties: { query: {description: 搜索關(guān)鍵詞盡量精確, title: Query, type: string}, max_results: {default: 3, description: 返回結(jié)果數(shù)量, maximum: 10, minimum: 1, type: integer}, region: {default: zh-CN, description: 搜索區(qū)域, type: string} }, required: [query], title: WebSearchParams, type: object }這樣寫的好處不僅是少改一份文件更重要的是Pydantic 的 Field 約束ge、le、枚舉等會直接變成模型可讀的約束信息模型生成參數(shù)時會更少越界。實測下來參數(shù)非法導(dǎo)致的重試次數(shù)下降了約 40%。2.3 技能描述怎么寫模型才聽得懂這是整個技能庫里最軟但也最關(guān)鍵的部分。我發(fā)現(xiàn)很多團隊把描述寫成一句話簡介比如執(zhí)行搜索模型根本選不準。我的經(jīng)驗是描述里必須寫清使用場景和不要使用的場景而且要給出正反例。下面是我常用的一段描述使用場景當用戶需要查詢實時信息、獲取最新新聞、查找某個機構(gòu)/人物/產(chǎn)品的當前情況時。 不要使用如果用戶只是問概念解釋、歷史知識且不要求最新信息請使用 knowledge_base 技能。這段描述直接把搜索技能和知識庫技能區(qū)分開了。模型對什么時候不要用特別敏感因為大量誤選都發(fā)生在兩個技能邊界模糊時。后面我還會專門講怎么靠邊界描述來提升技能選擇的準確率。3. 注冊中心與動態(tài)發(fā)現(xiàn)讓新建技能像插線板一樣簡單3.1 基于裝飾器的注冊機制有了技能定義下一步是把它們收集到一個注冊中心里。我用的方式是在模塊加載時通過裝飾器自動注冊。先定義全局注冊表from typing import Dict from dataclasses import dataclass, field class SkillRegistry: def __init__(self): self._skills: Dict[str, Skill] {} def register(self, skill: Skill) - Skill: if skill.name in self._skills: raise ValueError(fSkill {skill.name} already registered) self._skills[skill.name] skill return skill def get(self, name: str) - Skill: return self._skills[name] def all(self) - list[Skill]: return list(self._skills.values()) registry SkillRegistry() def skill_skill( description: str, category: str general, version: str 1.0.0, ): def wrapper(params_model: Type[BaseModel], execute: Callable): skill Skill( nameexecute.__name__, descriptiondescription, params_modelparams_model, executeexecute, categorycategory, versionversion, ) registry.register(skill) return skill return wrapper具體使用如下skill_skill( description獲取指定城市的當前天氣適合用戶詢問天氣、溫度、降雨概率時。, categoryutility, ) class GetWeatherParams(BaseModel): city: str Field(description城市中文名) async def get_weather(city: str) - dict: ...裝飾器把函數(shù)名作為技能名參數(shù)類作為 Schema 定義整個流程非常輕。新加一個技能時只有模塊被 import 進來技能就會自動進入注冊表主調(diào)度邏輯完全不用改。這基本就是插件架構(gòu)的低配版但已經(jīng)能很好地滿足需求。3.2 為什么不用硬編碼列表很多人會問項目不大直接在列表里寫清楚不就行了我早期也這么做過但吃了幾次虧之后決定改用注冊中心。一次是多人協(xié)作時同事加了一個技能但導(dǎo)入了模塊卻忘記在列表里追加結(jié)果模型看不到這個技能。還有一次是寫測試時需要隔離技能集合硬編碼列表讓測試非常別扭。改用注冊中心后測試時可以輕松構(gòu)造一個臨時 Registry 并注入 mock 技能再也不用擔心污染公共列表。另外自動掃描還有利于做按需加載。大項目里技能可能涉及重依賴比如 PDF 解析技能要導(dǎo)入一堆庫。我可以在技能模塊里做懶加載注冊階段只存描述和 Schema執(zhí)行時才真正 import 重依賴庫。這樣應(yīng)用啟動速度和內(nèi)存占用都更可控。3.3 技能間依賴從重復(fù)實現(xiàn)到組合調(diào)用技能庫管理工具多了以后第二個高頻需求就是技能復(fù)用。比如天氣查詢和穿衣建議兩個技能后者應(yīng)該組合前者而不是重新實現(xiàn)一遍天氣邏輯。我采用的方式是在 Skill 執(zhí)行體里可以直接拿到注冊表實例async def dressing_advice(city: str, temp: float) - str: weather_skill registry.get(get_weather) weather await weather_skill.run(citycity) ...這樣調(diào)度核心不關(guān)心技能內(nèi)部怎么組織下游技能只需要知道自己依賴哪個技能名。但我會在技能描述里顯式聲明依賴比如本技能需要依賴 get_weather 獲取實時溫度這樣模型選擇組合型技能時會更清楚它背后的成本。不過這里也要提醒一句技能間調(diào)用會增加一次模型調(diào)度延遲如果可以盡量在同一個執(zhí)行函數(shù)內(nèi)并行調(diào)用多個基礎(chǔ)技能而不是串行依賴。我后面會專門講性能優(yōu)化。4. 讓模型知道用什么技能技能選擇提示詞的組裝策略4.1 全量技能都塞進 Prompt 是最蠢的做法一開始我天真地把所有技能的名字、描述、參數(shù)規(guī)則全部塞進 system prompt。技能數(shù)量只有五個的時候還行到十五個以后模型的選擇準確率明顯下降token 消耗也讓人肉疼。后來我統(tǒng)計了一次完整對話的平均 token 消耗系統(tǒng)提示詞里技能描述占了 60% 以上而實際單輪對話中模型通常只需要兩三個技能。也就是說絕大部分信息是冗余的反而干擾了模型的注意力。4.2 兩級索引先選技能再補詳情我的解法是兩級索引策略。第一級維護一個精簡的技能目錄每個技能只保留 name、一句話簡介、使用場景、參數(shù)約束摘要。這個目錄盡量控制在模型能一屏看完的規(guī)模目標是讓模型快速定位候選技能。第二級當模型在回復(fù)中表示需要調(diào)用技能 X時調(diào)度器再把技能 X 的完整參數(shù) Schema、詳細描述、示例注入到下一輪上下文里讓模型嚴格按 Schema 生成參數(shù)。具體實現(xiàn)上我在 system prompt 里放這樣的模板可用技能目錄 {skills_catalog} 如果你需要完成某個操作請先輸出要使用的技能名稱以及對應(yīng)的參數(shù) JSON。def build_skills_catalog(skills: list[Skill]) - str: lines [] for s in skills: lines.append( f- {s.name}: {s.description.split(使用場景)[0].strip()} ) return \n.join(lines) def build_skill_detail(skill: Skill) - str: return ( f技能名稱: {skill.name}\n f完整描述: {skill.description}\n f參數(shù)Schema: {json.dumps(skill.param_schema, ensure_asciiFalse, indent2)}\n f請嚴格按照Schema生成參數(shù)。 )調(diào)用流程簡化為模型閱讀技能目錄判斷需要哪個技能。模型輸出技能名和參數(shù)摘要也可以直接輸出空參數(shù)。調(diào)度器找到技能詳情拼接到下一輪 prompt。模型輸出最終結(jié)構(gòu)化參數(shù)。調(diào)度器校驗并執(zhí)行技能。這個流程讓模型每次只需要關(guān)注一小段信息準確率提高非常明顯。代價是多了一輪交互但很多場景下值得。后續(xù)也可以對高頻技能做緩存根據(jù)對話主題直接預(yù)加載幾個可疑技能減少試探輪次。4.3 上下文裁剪根據(jù)對話狀態(tài)動態(tài)過濾技能目錄除了兩級索引動態(tài)裁剪也很關(guān)鍵。我的實現(xiàn)里維護了一個context_tags也就是從當前對話中抽取的場景標簽比如天氣新聞SQL。然后在構(gòu)建目錄時根據(jù)標簽過濾掉明顯不相關(guān)的技能。比如用戶問今天天氣就沒必要把數(shù)據(jù)庫備份這種運維技能展示給模型。這個能力依賴于對用戶意圖的初步判斷不一定要很精確只要能把候選集從二十個降到五六個模型選擇的準確度就能上一個臺階。我建議用一次快速的輕量分類來打標簽而不是讓主 Agent 又做意圖識別又做技能選擇否則每輪推理成本會高得離譜。5. 實測我把這套技術(shù)寫作助手跑起來之后5.1 場景設(shè)定與技能清單為了驗證 agent-skills 不是玩具我做了一個相對完整的示例項目一個技術(shù)寫作助手。它需要完成資料搜索、網(wǎng)頁內(nèi)容摘要、代碼示例獲取、稿件素材整理、保存到 Notion 數(shù)據(jù)庫這些任務(wù)。當時注冊的技能包括技能名用途依賴web_search搜索最新技術(shù)資料無fetch_webpage抓取網(wǎng)頁正文并轉(zhuǎn)成純文本無extract_code從網(wǎng)頁正文中提取代碼塊fetch_webpagegenerate_summary調(diào)用大模型對文本生成摘要無save_to_notion將整理好的內(nèi)容保存到數(shù)據(jù)庫無5.2 效果對比技能選擇準確率從 68% 提到 94%我準備了一百條真實用戶問句作為測試集覆蓋搜索、摘要、保存、組合任務(wù)等類型。在沒做技能庫管理、所有工具硬編碼、描述也很簡陋的情況下模型正確選擇技能的比例只有 68%也就是三成的情況下選出了錯誤的工具。經(jīng)過技能結(jié)構(gòu)標準化、兩級索引、動態(tài)裁剪和描述優(yōu)化之后同樣的一百條樣本技能選擇準確率提升到了 94%。誤選主要發(fā)生在fetch_webpage和extract_code之間的調(diào)用順序上后來靠強化示例才壓下去。token 消耗方面也有明顯改善。沒優(yōu)化前每輪對話平均在系統(tǒng)提示詞上花費約 1800 token優(yōu)化后因為只注入目錄和必要的技能詳情平均降到 700 token 左右整體對話成本下降了約 60%。當然這個數(shù)據(jù)跟具體技能數(shù)量和模型上下文能力有關(guān)系但方向是通用的。5.3 一個讓我意外的發(fā)現(xiàn)技能執(zhí)行結(jié)果也需要結(jié)構(gòu)化回填做到一半我發(fā)現(xiàn)技能執(zhí)行完返回的數(shù)據(jù)不能原樣丟給模型。直接返回一長串網(wǎng)頁全文不僅浪費 token而且模型難以提取重點。后來我給技能加了一層format_result讓每個技能返回結(jié)構(gòu)化且精煉的結(jié)果摘要。比如搜索技能返回的不是完整結(jié)果列表而是每個結(jié)果的標題、URL、時間、一句話摘要。這個改動讓后續(xù)對話的上下文變得更干凈模型在引用資料時也更準確。我建議給每個技能準備一個result_summary方法或者至少對返回內(nèi)容做一個 token 上限截斷。這比在主提示詞里寫請忽略無關(guān)內(nèi)容有效得多。6. 最容易翻車的三個細節(jié)與我的完整排查鏈路6.1 現(xiàn)象模型總把參數(shù)類型搞錯第一次上線時模型調(diào)用web_search時把max_results傳成了字符串 5。Pydantic 其實會自動做類型轉(zhuǎn)換但如果是字符串 abc 就會直接報錯。本來我以為校驗失敗會讓模型自己重試但發(fā)現(xiàn)模型報錯后經(jīng)常不知道該改成什么。排查鏈路我先在日志里打印每次技能執(zhí)行的validated參數(shù)確認錯誤來源是類型強制轉(zhuǎn)換失敗。然后檢查 Pydantic 的model_config發(fā)現(xiàn)沒有禁止字符串強轉(zhuǎn)成數(shù)字。調(diào)整參數(shù)模型增加strictTrue讓多余的類型強迫轉(zhuǎn)換直接失敗反而讓模型更容易理解錯誤信息。再給校驗錯誤設(shè)計了一條清晰的錯誤提示包括出錯字段、期望類型、傳入值要求模型重新生成參數(shù)。這個鏈路最值得夸的一點是Pydantic 的嚴格模式一開始就要開。如果不嚴格很多隱性類型問題會在技能執(zhí)行階段才炸出來而且定位成本更高。6.2 現(xiàn)象兩個技能描述太像模型反復(fù)選錯有一次模型在查天氣和查空氣質(zhì)量之間反復(fù)橫跳幾乎沒什么規(guī)律。我把兩個技能的完整描述拿出來逐字對比發(fā)現(xiàn)都寫著用于查詢城市的當前環(huán)境信息。這顯然不行。排查鏈路我寫了一個小腳本對所有技能描述做兩兩相似度計算用簡單的關(guān)鍵詞重疊度發(fā)現(xiàn)get_weather和get_air_quality的相似度最高。為每個技能重寫了描述補充明確的邊界場景。比如空氣質(zhì)量技能必須提到AQI、PM2.5、污染天氣技能必須提到溫度、濕度、降雨。在測試集上加了兩條容易混淆的用例比如今天出門要不要戴口罩應(yīng)該選空氣質(zhì)量今天會不會下雨應(yīng)該選天氣。之后我把技能描述相似度檢查加入了 CI。每次提交代碼時自動跑一遍如果發(fā)現(xiàn)兩個技能描述相似度過高就報警提示人工review。這個工具對團隊協(xié)作特別有用。6.3 現(xiàn)象上下文里技能太多模型瞎選有段時間我的技能數(shù)量增加到二十多個即便做了目錄精簡模型還是時不時選出一個跟當前話題八竿子打不著的技能。排查鏈路我記錄了模型每次選擇的 log發(fā)現(xiàn)誤選大多發(fā)生在對話較長的中后段。進一步檢查發(fā)現(xiàn)前置對話把模型注意力帶偏了尤其是之前提到過某個技能模型容易慣性選擇。于是在構(gòu)建新一輪技能目錄時我顯式把當前用戶問題放在目錄之前讓模型先明確問題再看技能。另外把技能目錄按類別折疊默認只展示用戶當前場景可能相關(guān)的類別收起無關(guān)類別。改完以后長對話中的誤選率明顯下降。這說明技能選擇不是純靠模型理解能力輸入的結(jié)構(gòu)化程度對結(jié)果影響很大。7. 技能治理版本、評估與淘汰機制技能庫不是一勞永逸的。加了新技能可能擠壓舊技能的選擇空間改了一個技能的描述可能影響其他技能的邊界。我后來慢慢把這套東西當成一個需要治理的代碼庫來對待。7.1 每個技能都要有版本和負責人我在 Skill 結(jié)構(gòu)里增加了author和version字段。雖然聽起來不重要但在多人協(xié)作時版本標簽?zāi)茏屓罩纠锏恼{(diào)用記錄對應(yīng)到一個明確的代碼版本。線上出問題后git blame加version能快速定位是誰改過、什么時候改的。7.2 建立技能選擇回歸集我強烈建議項目里至少準備 50 到 100 條標注好的意圖 - 技能 - 參數(shù)測試用例。每次修改技能定義后跑一遍回歸集統(tǒng)計技能選擇準確率和參數(shù)生成準確率。再進一步可以給每個技能單獨維護一條技能熱度和技能錯誤率。如果一個技能連續(xù)半個月沒被調(diào)用或者調(diào)用后頻頻報錯就該考慮下線或重寫描述。我用一張簡單的表來跟蹤技能名調(diào)用次數(shù)成功率平均耗時最后調(diào)用時間web_search32092%1.2s2025-01-10extract_code2578%3.1s2025-01-08定期看這張表你能發(fā)現(xiàn)很多之前沒注意的問題。比如extract_code調(diào)用次數(shù)少但成功率低說明描述可能太窄或者依賴的fetch_webpage返回內(nèi)容不理想。這種數(shù)據(jù)驅(qū)動的迭代比憑感覺改 prompt 高效得多。7.3 技能描述也要做 A/B 測試最后分享一個偏門但有效的經(jīng)驗對高爭議的技能描述做 A/B 測試。同一時間讓一半流量看到描述 A一半看到描述 B統(tǒng)計技能選擇的準確率、任務(wù)完成率。我自己測試過web_search描述里加不加不要使用約束結(jié)果加了之后誤選率下降了 12%。看似一句話的差別在幾十個技能并存時影響會被放大。如果你沒有完整 A/B 平臺至少可以在本地跑一個小樣本對比把兩個描述各跑二十條測試用例看看哪個更穩(wěn)。說實話做到這一步agent-skills 已經(jīng)不再是一個簡單的工具管理腳本而是一套關(guān)于如何讓模型可靠地使用工具的方法論。它的價值不在于某個具體的技能實現(xiàn)而在于你能把每個能力變化都變成可測試、可回滾、可觀測的過程。對我個人而言這套機制最直接的好處是我再也不怕業(yè)務(wù)方突然提再加一個技能的需求了——無非是寫一個函數(shù)、配一個描述、跑一遍回歸集的事。