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