計實戰(zhàn):從Function Calling到可維護(hù)的工具調(diào)用框架)
最近在調(diào)一版帶工具調(diào)用的agent把一堆API函數(shù)注冊進(jìn)去之后模型開始各種“自由發(fā)揮”參數(shù)傳錯、調(diào)錯函數(shù)、甚至卡在一個技能里反復(fù)打轉(zhuǎn)。折騰幾天后我意識到問題不在于模型不夠聰明而是我壓根缺了一層叫agent-skills的東西。所謂agent-skills簡單說就是把a(bǔ)gent“能執(zhí)行的動作”從一行行裸奔的函數(shù)代碼升級成一套帶描述、帶參數(shù)協(xié)議、帶返回規(guī)范、帶安全護(hù)欄的完備技能層。練好這一層模型才能真正“拿得穩(wěn)、調(diào)得準(zhǔn)、改得動”。這篇文章就是一次完整的復(fù)盤從為什么需要技能層到怎么設(shè)計技能再到一個可以直接抄走的最小Python框架以及我自己踩坑排雷的實錄。適合正在做function calling、Tool Use、自定義Agent流程卻被各種奇怪調(diào)用行為折磨的開發(fā)者參考。1. 為什么需要“技能層”把“能做什么”從模型參數(shù)里拿出來1.1 模型不是工具是調(diào)度器很多第一次做agent的朋友會默認(rèn)一件事只要模型夠強(qiáng)它就能自己完成“查天氣→算溫差→發(fā)短信提醒”這種完整鏈路。實測下來會發(fā)現(xiàn)模型確實能寫出一段像模像樣的計劃但一執(zhí)行就露餡——它沒有數(shù)據(jù)庫連接不會發(fā)HTTP請求連本地文件都摸不到。模型本質(zhì)上是個“調(diào)度器”它擅長的是判斷“現(xiàn)在該做什么”而不是親自“把事做成”。所以你得給它一雙手。這雙手就是技能。每給我一個能力我會把它注冊成一個技能給這個技能起一個唯一的名字寫清楚“什么時候用、怎么用、參數(shù)長什么樣”然后接一個真正干活的函數(shù)。模型會根據(jù)用戶的請求和技能描述自己決定要不要調(diào)用某個技能、傳入什么參數(shù)。第一步先要把“能力”獨立出模型本身變成可維護(hù)、可生長的一套組件。1.2 一個完整技能的最小組成一個合格的技能不是“一個函數(shù)”那么簡單我通常要求自己寫的每個技能至少包含四部分唯一名稱全局唯一建議用“動詞_名詞”格式比如query_stock_price、send_reminder。清晰描述告訴模型這個技能什么時候觸發(fā)、什么時候別碰稍后我會細(xì)講這個的關(guān)鍵程度。參數(shù)模板用JSON Schema聲明每個字段的類型、必填項、取值范圍。執(zhí)行函數(shù)真正干活的Python函數(shù)入?yún)膮?shù)模板里來出參走統(tǒng)一的返回格式。我在下面起了個最小范例能看到一個技能長什么樣{ name: get_weather, description: 查詢指定城市的當(dāng)前天氣。當(dāng)用戶明確提到某地天氣時使用若未指定城市必須先向用戶詢問。, parameters: { type: object, properties: { city: {type: string, description: 城市名如北京、上海} }, required: [city] }, execute: call_weather_api(city) }千萬注意這段JSON不是給人看的是給模型“讀”的。模型通過描述里的文字來匹配“用戶意圖”和“技能”。這也是為什么很多新手把技能做成純函數(shù)后效果很差因為函數(shù)定義和模型能理解的自然語言描述之間缺了翻譯層。2. 設(shè)計技能的實用前提命名、描述與返回規(guī)范2.1 先寫對description再寫代碼如果你只能花10分鐘在一個技能上我會勸你全花在description上。代碼邏輯錯了還能靠報錯排查描述寫得模糊模型會在調(diào)用時做出完全不可預(yù)期的行為。我舉個例子。某次我把“根據(jù)當(dāng)前城市的PM2.5指數(shù)提醒用戶要不要戴口罩”的能力封裝成一個技能描述最初寫得極簡get_pm25_and_remind(city)。結(jié)果模型在用戶問“今天出門要注意什么”的時候調(diào)用了它用戶說“幫我看看明天的安排”它也調(diào)用了它。因為描述沒有限定觸發(fā)場景模型靠猜就會擴(kuò)大適用范圍。后來我改成這樣當(dāng)用戶詢問空氣質(zhì)量、PM2.5、口罩建議以及涉及戶外活動健康提醒時使用。 必須提供city參數(shù)如果用戶沒有說明城市先向用戶詢問城市名禁止默認(rèn)使用北京。 返回內(nèi)容包含污染物數(shù)值與對應(yīng)的活動建議。一段好的描述要回答三個問題什么時候用、參數(shù)從哪來、返回什么。再加一條負(fù)面約束“禁止默認(rèn)使用北京”能把誤調(diào)用率直線拉低。條件允許的話再加一個never_use_when的字段來顯式寫禁區(qū)追求更極致的效果可以加上實際經(jīng)驗里多寫幾句就能見效。2.2 參數(shù)必須顯式聲明別讓模型瞎猜很多人的技能函數(shù)是這么寫的def send_email(to_addr, content, ccNone, attachmentsNone): ...然后注冊給agent時就把函數(shù)的__doc__和inspect.signature直接傳給了模型。這確實省事但副作用是參數(shù)邊界完全失控。模型可能會嘗試把cc傳成字符串把a(bǔ)ttachments傳成文件路徑而不傳文件內(nèi)容甚至?xí)跊]有附件時憑空捏造一個附件路徑。技能的參數(shù)協(xié)議必須精確到“這個字段允許什么、不允許什么”。我在項目里統(tǒng)一用Pydantic做參數(shù)模型from pydantic import BaseModel, Field class SendEmailParams(BaseModel): to_addr: str Field(description收件人郵箱要符合郵箱格式) content: str Field(description郵件正文內(nèi)容) cc: list[str] | None Field(defaultNone, description抄送人郵箱列表例如[ax.com]) attachments: list[str] | None Field(defaultNone, description附件路徑列表路徑必須以/data/reports/開頭)這樣模型就能看到精確說明再配合校驗異常時的報錯回傳誤調(diào)參數(shù)的問題會好很多。記住一句話你給的參數(shù)描述越精確模型傳參越穩(wěn)定。別讓任何參數(shù)靠模型猜。2.3 統(tǒng)一返回結(jié)構(gòu)模型才不會精神分裂技能調(diào)用完結(jié)果要回到模型手里進(jìn)行下一步推理。如果每個技能返回的格式都不一樣模型要花很多額外精力去“理解這一次到底返回了什么”不僅變慢還容易出錯。我直接推一個賭咒發(fā)誓好用的規(guī)范不管內(nèi)部執(zhí)行成什么樣對外一律返回{ok: bool, data: ...}或{ok: false, error: 錯誤原因}。def run_skill(skill_name, params): try: result SKILL_REGISTRY.execute(skill_name, params) return {ok: True, data: result} except Exception as e: return {ok: False, error: f[{skill_name}] 執(zhí)行失敗: {str(e)}}統(tǒng)一返回值之后Agent主循環(huán)只需要處理這兩種情況模型也只需要根據(jù)ok字段決定是要繼續(xù)還是要把錯誤信息說給用戶聽。返錯時把錯誤信息原樣給到模型模型能自己讀完錯誤決定下一步。數(shù)據(jù)越規(guī)整模型越能專心做“調(diào)度”而不是做“翻譯”。3. 從一個空目錄開始搭一套技能執(zhí)行框架3.1 注冊機(jī)制用一個裝飾器把技能收攏起來設(shè)計完單個技能下一步是“收攏”。我不建議用一堆if-else去分發(fā)技能那樣每加一個新技能就要改主循環(huán)很快就瘋掉。我習(xí)慣用一個注冊中心讓每個技能自報家門主循環(huán)根本不關(guān)心技能細(xì)節(jié)。下面這個幾十行的注冊器是我個人一直在用的基礎(chǔ)版from typing import Callable, Any from pydantic import BaseModel SKILL_REGISTRY: dict[str, dict[str, Any]] {} def skill(name: str, description: str, params_model: type[BaseModel]): def decorator(func: Callable): SKILL_REGISTRY[name] { name: name, description: description, parameters: params_model.model_json_schema(), handler: func, params_model: params_model, } return func return decorator def get_skills_manifest() - list[dict]: 返回給模型的技能清單只保留聲明信息不暴露handler。 out [] for s in SKILL_REGISTRY.values(): out.append({ name: s[name], description: s[description], parameters: s[parameters], }) return out async def execute_skill(name: str, params: dict) - dict: skill_def SKILL_REGISTRY.get(name) if not skill_def: return {ok: False, error: fskill not found: {name}} try: validated skill_def[params_model](**params) result skill_def[handler](**validated.model_dump()) return {ok: True, data: result} except Exception as e: return {ok: False, error: f[{name}] error: {e}}關(guān)鍵點有兩個。一是清單和handler分離模型只能看到“聲明”看不到底層實現(xiàn)免得模型跑去調(diào)用你的Python內(nèi)部函數(shù)。二是參數(shù)自動解析模型傳進(jìn)來的是普通dictpydantic直接完成字段校驗和類型轉(zhuǎn)換執(zhí)行函數(shù)拿到的就一定是干凈數(shù)據(jù)。3.2 Agent主循環(huán)讓模型“發(fā)言—調(diào)用—拿結(jié)果”閉環(huán)有了技能注冊中心主循環(huán)就變成一件很機(jī)械的事情。我用最樸素的方式寫了一個循環(huán)把人類消息、技能清單、歷史記錄一起丟給模型如果模型返回的是調(diào)用技能的指令就執(zhí)行技能再把結(jié)果回傳反復(fù)直到模型給出最終回復(fù)。def agent_loop(user_query: str, max_steps: int 5): messages [] # 先把技能清單注入系統(tǒng)提示 system_prompt f你是任務(wù)調(diào)度助手可調(diào)用以下技能\n{json.dumps(get_skills_manifest(), ensure_asciiFalse)}\n messages.append({role: system, content: system_prompt}) messages.append({role: user, content: user_query}) for step in range(max_steps): resp call_llm(messages) # 模型可返回文本或技能調(diào)用指令 if resp.get(type) final: return resp[content] if resp.get(type) skill_call: skill_result execute_skill(resp[skill_name], resp.get(params, {})) messages.append({role: function, name: resp[skill_name], content: json.dumps(skill_result, ensure_asciiFalse)}) else: return 抱歉我無法完成這個請求。 return 達(dá)到最大調(diào)用次數(shù)提前結(jié)束。這里有個幾乎沒被新手重視的點一定要把上一步的結(jié)果以明文JSON回傳給模型模型靠它理解“剛才調(diào)成功了嗎、數(shù)據(jù)是什么”從而決定下一步是繼續(xù)調(diào)下一個技能還是向用戶匯報。主循環(huán)自己不需要做業(yè)務(wù)判斷真正的判斷全交給模型。很多開源框架在工具循環(huán)里會加各種復(fù)雜路由我覺得前期完全沒必要。先跑通這個“裸循環(huán)”把突出的問題一個個修完再引入路由編排不遲。3.3 動手加一個真實技能實時匯率查詢到目前為止全是框架得用個真實技能驗證一下。我這里拿“匯率查詢”做示例因為它的邏輯足夠清晰模型必須從用戶話里抽取出“原幣種”和“目標(biāo)幣種”然后調(diào)用一個外部API完成換算。若幣種缺失技能要引導(dǎo)模型追問用戶。技能本體skill( namecurrency_convert, description當(dāng)用戶要求匯率換算例如“100美元等于多少日元”“港幣兌人民幣”使用該技能。 必須同時提供from_currency和to_currency如果缺少任一幣種禁止猜測應(yīng)提示用戶補(bǔ)充。, params_modelCurrencyConvertParams, ) def currency_convert(from_currency: str, to_currency: str, amount: float 1.0): url fhttps://api.frankfurter.dev/v1/latest?base{from_currency.upper()}symbols{to_currency.upper()} resp requests.get(url, timeout10) data resp.json() rate data[rates][to_currency.upper()] return {from: from_currency.upper(), to: to_currency.upper(), rate: rate, converted_amount: round(amount * rate, 4)}然后去真實環(huán)境里跑這幾條用戶輸入“100美元是多少日元” → 技能收到from_currencyUSD, to_currencyJPY, amount100“幫我算算港幣兌人民幣” → 模型沒有amount按默認(rèn)1.0處理返回匯率本身“100塊能換多少歐元” → 模型會猜測“100塊”是人民幣如果技能的description里沒有寫默認(rèn)幣種這里就全靠模型常識兜底注意最后一個例子描述里如果明確說了“當(dāng)用戶只說‘塊’而沒有明示幣種時默認(rèn)視為CNY”模型行為會穩(wěn)定非常多。別嫌這啰嗦技能描述本來就是用來消滅歧義的。我在這個基礎(chǔ)上又加了一個“匯率反向換算”的小技巧當(dāng)用戶說“50歐元的菜貴不貴”我需要先把50歐元換算成人民幣再對比本地人均消費。做法是把currency_convert拆成get_exchange_rate和convert_money兩個原子技能讓模型自己組合。實現(xiàn)后你會發(fā)現(xiàn)模型在大多數(shù)情況下能準(zhǔn)確串聯(lián)這兩個技能。這就是“原子技能”的價值把詞根拆得足夠小組合才靈活。4. 技能多了之后沖突路由、權(quán)限與護(hù)欄設(shè)計4.1 技能的原子化與組合技能少的時候怎么設(shè)計都行一旦超過15~20個模型的選擇困難就會開始暴露。它可能在“查天氣”和“查空氣質(zhì)量”之間反復(fù)橫跳也可能在“發(fā)郵件”和“寫郵件草稿”之間選錯。我的解法是給技能分兩層原子技能和復(fù)合技能。原子技能是最小可執(zhí)行單元例如get_stock_price、get_user_location、send_email。復(fù)合技能是把多個原子技能按固定劇本編排成的新技能比如“收盤播報” 查持倉 → 查行情 → 生成文字 → 推送流程完全固定不需要模型臨時決策。在技能注冊表里我加了一個depends_on字段復(fù)合技能執(zhí)行時自動依次調(diào)起依賴的原子技能SKILL_REGISTRY { daily_portfolio_report: { handler: daily_report_handler, depends_on: [get_positions, query_stock_price, make_markdown_table], } }這樣模型面對復(fù)合技能時不用一次性想出全部細(xì)節(jié)只需要一個“按鈕”就能觸發(fā)一條固定流程。既省token又降低決策出錯率。我踩過的最深的坑是把“生成報告”和“發(fā)送報告”寫成了一個技能。結(jié)果模型在一次用戶說“把報告發(fā)我”的請求里直接重復(fù)調(diào)用了“生成報告”三四次就是不調(diào)用“發(fā)送報告”。拆開之后模型的行為才恢復(fù)正常。復(fù)合技能適合固定編排原子技能適合靈活決策混淆這兩者會讓模型行為充滿隨機(jī)性。4.2 不讓模型亂來護(hù)欄與確認(rèn)機(jī)制能力越多風(fēng)險越大。如果技能里有delete_file、transfer_money這類高危動作一定不能在模型“想調(diào)就調(diào)”的范圍內(nèi)。我一直建議在高危技能外部包一層確認(rèn)機(jī)制模型調(diào)用該技能時不直接執(zhí)行而是返回一個“需要用戶確認(rèn)”的信號等用戶在對話里輸入“確認(rèn)”后再真正跑。SENSITIVE_SKILLS {delete_file, batch_send_emails, apply_for_leave} def execute_skill_safe(name: str, params: dict, user_confirmed: bool False): if name in SENSITIVE_SKILLS and not user_confirmed: return {ok: False, as shall_ask: True, data: 該操作會影響數(shù)據(jù)需要用戶確認(rèn)請向用戶展示確認(rèn)信息并征得同意不要自行執(zhí)行。} return execute_skill(name, params)這種“軟護(hù)欄”讓模型在對話層面完成確認(rèn)而不是在代碼層強(qiáng)制中斷用戶的體感會自然很多。實測下來高危操作只有不到兩成的誤觸率通過這層機(jī)制被攔截下來剩下八成正是在描述里沒寫清負(fù)面約束導(dǎo)致模型不該調(diào)卻調(diào)了。正規(guī)項目里可能還會加權(quán)限令牌、調(diào)用頻率限制、可溯源日志等。早期我建議至少做兩層一層是會話級確認(rèn)一層是操作級審計日志。任何技能調(diào)用都要留下痕跡誰調(diào)的、什么參數(shù)、什么時間、返回什么。不然出了事故你連復(fù)盤的機(jī)會都沒有。4.3 技能版本化與回歸測試最后聊聊技能多了之后的日常維護(hù)。每改一個技能描述、每加一個參數(shù)都可能改變模型的行為。我第一次改“匯率換算”的參數(shù)說明把a(bǔ)mount的默認(rèn)值從1改成了“必填”結(jié)果模型在用戶只問“今天匯率多少”時直接拒絕回答還一本正經(jīng)地說“您沒有提供金額我無法查詢匯率。”這就是描述約束和實際語義不匹配的后果。為了避免這類事我現(xiàn)在維護(hù)一個輕量回歸集把過去一段時間內(nèi)真實用戶的高頻問題整理成30到50條每次改完技能就全量跑一遍看一眼行為有沒有劣化。不用做自動化斷言只要肉眼檢查輸出就能發(fā)現(xiàn)九成的問題。因為核心不穩(wěn)定因素本來就不是邏輯而是模型對描述語義的“理解漂移”?;氐桨姹净蟻?。每次上線的技能改動我都打一個tag并在技能描述里順手帶一個version字段。這樣一旦發(fā)現(xiàn)線上行為不對能快速判斷是哪個版本引入的回歸也可以讓Agent在運行日志里記錄版本號回滾不用改代碼改配置文件就行。5. 調(diào)Agent時必踩的坑與排查技巧5.1 常見現(xiàn)象與修復(fù)辦法做技能化Agent過程中我收集了一張“故障速查表”基本都是自己踩過的坑。遇到問題時先對照一遍比無頭緒調(diào)試管用得多?,F(xiàn)象根因處理方式模型不調(diào)用任何技能只會聊天技能清單沒注入系統(tǒng)提示詞或技能描述與用戶請求語義關(guān)聯(lián)太弱檢查主循環(huán)是否傳了技能清單在描述里增加典型的用戶問法例句模型調(diào)用了不相關(guān)的技能兩個技能描述有重疊語義邊界模糊拆技能、刪冗余描述給其中一個顯式寫never_use_when模型反復(fù)調(diào)用同一個技能不退出上一步調(diào)用結(jié)果沒回傳給模型或返回結(jié)果的error信息不明朗模型陷入重試死循環(huán)設(shè)置最大步數(shù)把返回結(jié)果以function role回傳錯誤信息要具體參數(shù)傳錯類型或格式JSON Schema里缺少類型和格式約束用Pydantic強(qiáng)校驗在字段描述里給出具體的示例值不要只寫抽象說明技能返回結(jié)果太長模型上下文爆了技能返回了完整大文本比如整份PDF內(nèi)容在技能內(nèi)做摘要、截斷或只返回“條數(shù)前幾條摘要文件路徑”換了新模型版本后行為變怪模型對描述語義的敏感度變化同一套prompt不一定適配回歸集重跑重新措辭描述必要時升級技能版本號其中“參數(shù)傳錯類型”是出現(xiàn)頻率最高的而且往往是描述里偷懶造成的。拿“城市名”舉例你只寫city: string模型會老實傳“北京”但你寫成“城市名如‘北京’、‘上?!灰獛А小趾缶Y”它的傳參準(zhǔn)確率和穩(wěn)定度會明顯提升。5.2 排查工具與調(diào)試習(xí)慣最后說說長期能省大力的三個調(diào)試習(xí)慣。第一把模型和技能之間的交互全程打出來。主循環(huán)里每產(chǎn)生一次技能調(diào)用都把完整入?yún)?、返回、模型下一步的原始輸出落日志。不要只記摘要摘要往往丟掉關(guān)鍵細(xì)節(jié)。我見過太多人排查半天最后發(fā)現(xiàn)問題是“模型傳參時多了一個空格”。第二給每個技能單獨做一個最小測試腳本。只調(diào)模型不調(diào)技能或只調(diào)技能不調(diào)模型把故障點隔離開。如果技能本身能用示例參數(shù)正確返回模型又調(diào)得不對那就是描述問題反過來就是技能自己的bug。第三建立一套“召喚詞”測試集。挑幾個用戶最典型的問法不去糾結(jié)模型要不要調(diào)技能只看最終結(jié)果對不對。比如“幫我把今天新到的郵件歸檔到項目文件夾”“匯率換成美元看看”這些句子覆蓋常見意圖每次上線前跑一遍跑完再發(fā)布。這輪做下來后我對“agent不聽話”這件事的心態(tài)徹底變了。過去我總想靠更復(fù)雜的prompt把模型“壓住”現(xiàn)在更愿意花精力把技能層打磨得像一份產(chǎn)品需求文檔每個能力都有明確的觸發(fā)場景、參數(shù)邊界、返回規(guī)范讓模型去當(dāng)那個讀需求的人。你喂給它的說明書越像人話它做事就越像樣。再送你一個小技巧給每個技能描述末尾加一段“典型調(diào)用示例”比如正確用法get_weather(city北京) - {ok: true, data: {...}}。模型看到示例后格式跟隨的穩(wěn)定度會高出一大截這也是我多次實測下來投入產(chǎn)出比最高的一項微調(diào)。