用「吃大鍋飯」的 config.toml 骨架)
1. 當(dāng)工具調(diào)用開(kāi)始「吃大鍋飯」問(wèn)題出在哪如果你正在用 GRPO 訓(xùn)練一個(gè)會(huì)調(diào)工具的 LLM Agent大概率遇到過(guò)這種場(chǎng)景模型在一輪對(duì)話里連續(xù)調(diào)了 5 次 API其中 3 次是對(duì)的、2 次是多余的但最終答案碰巧對(duì)了于是整條軌跡拿到一個(gè)正獎(jiǎng)勵(lì)所有步驟雨露均沾。訓(xùn)練幾十個(gè) step 之后你會(huì)發(fā)現(xiàn)模型學(xué)會(huì)了「多調(diào)幾次總沒(méi)錯(cuò)」——工具調(diào)用次數(shù)越來(lái)越多精確率卻上不去。這就是 MatchTIR 這篇工作想解決的核心問(wèn)題信用分配Credit Assignment的粗粒度化。傳統(tǒng)做法要么只看最終答案Outcome Reward要么給整條軌跡打一個(gè)總分再平均分給每一步Trajectory Reward本質(zhì)上都是「大鍋飯」——調(diào)對(duì)的工具和調(diào)錯(cuò)的工具拿一樣的獎(jiǎng)勵(lì)模型自然學(xué)不會(huì)「每一步都要精準(zhǔn)」。MatchTIR 的思路很直接既然工具調(diào)用天然是結(jié)構(gòu)化的工具名、參數(shù)名、參數(shù)值那就把「模型預(yù)測(cè)的調(diào)用序列」和「標(biāo)準(zhǔn)答案的調(diào)用序列」做一次二分匹配Bipartite Matching用匈牙利算法找到最優(yōu)的一對(duì)一對(duì)應(yīng)關(guān)系然后按匹配質(zhì)量給每一步精確打分。匹配上的按相似度給正獎(jiǎng)勵(lì)沒(méi)匹配上的給懲罰項(xiàng)。再配合雙重級(jí)優(yōu)勢(shì)估計(jì)軌跡級(jí) 輪級(jí)讓 GRPO 的 advantage 從「整條軌跡共享一個(gè)值」變成「每個(gè) token 位置有自己的值」。這篇不講論文推導(dǎo)講怎么落地。我會(huì)給你一份可以直接抄的config.toml骨架把工具調(diào)用配置和 GRPO 訓(xùn)練配置拆成鍵值對(duì)然后跑一次本地調(diào)用驗(yàn)證匹配結(jié)果是否收斂。適合已經(jīng)在用 GRPO 訓(xùn) Agent、想升級(jí)獎(jiǎng)勵(lì)粒度的同學(xué)。2. 前置準(zhǔn)備TaoToken 接入與依賴(lài)安裝MatchTIR 本身是訓(xùn)練側(cè)的方法但你要驗(yàn)證「匹配結(jié)果是否收斂」需要一個(gè)能穩(wěn)定調(diào)用工具、返回結(jié)構(gòu)化結(jié)果的推理端點(diǎn)。我用 TaoToken 來(lái)做這件事原因是它的 API 格式和主流 SDK 兼容改base_url就能接不用動(dòng)業(yè)務(wù)代碼。先去控制臺(tái)拿一個(gè) API Key地址是 https://taotoken.net/api-keys 登錄后創(chuàng)建一個(gè)新 Key復(fù)制出來(lái)存到環(huán)境變量里。注意 Key 只在創(chuàng)建時(shí)完整顯示一次丟了就重新建。export TAOTOKEN_API_KEYsk-你的key export TAOTOKEN_BASE_URLhttps://taotoken.net/apiPython 側(cè)依賴(lài)裝這幾個(gè)pip install openai scipy numpy tomliscipy是用來(lái)跑匈牙利算法的linear_sum_assignment直接可用不用自己實(shí)現(xiàn)增廣路徑。tomli是讀config.toml用的Python 3.11 以上可以用內(nèi)置的tomllib。如果你還沒(méi)決定用哪個(gè)模型做工具調(diào)用可以先到模型對(duì)話頁(yè)面 https://taotoken.net/models 試一下確認(rèn)工具調(diào)用的返回格式符合預(yù)期再往下走。對(duì)于長(zhǎng)期跑編碼類(lèi) Agent 的場(chǎng)景Coding Plan https://taotoken.net/coding-plan 的額度模型更適合持續(xù)訓(xùn)練不用每次手動(dòng)充值。3. 可復(fù)制配置config.toml 骨架下面這份config.toml把兩件事拆開(kāi)了[tool_call]段管工具調(diào)用的推理配置[grpo]段管訓(xùn)練側(cè)的獎(jiǎng)勵(lì)和優(yōu)勢(shì)估計(jì)參數(shù)。MatchTIR 的關(guān)鍵參數(shù)lambda_penalty和gamma都在[grpo]里。# config.toml # MatchTIR GRPO 工具調(diào)用訓(xùn)練配置骨架 [api] base_url https://taotoken.net/api api_key_env TAOTOKEN_API_KEY model claude-sonnet-4-20250514 timeout 60 max_retries 3 [tool_call] # 工具調(diào)用推理側(cè)配置 max_turns 8 # 單條軌跡最多交互輪數(shù) max_tools_per_turn 3 # 單輪最多并行調(diào)用工具數(shù) temperature 0.7 # rollout 采樣溫度 top_p 0.95 stop_on_final_answer true # 生成最終答案后停止 [tool_call.tools] # 工具定義name 必須和 ground truth 完全一致 weather { name get_weather, params [city, date] } stock { name get_stock_price, params [symbol, date] } search { name web_search, params [query] } [grpo] # 訓(xùn)練側(cè)配置 group_size 4 # GRPO 的 rollout 數(shù) G learning_rate 1.0e-6 batch_size 128 kl_coef 0.01 clip_ratio 0.2 [grpo.matchtir] # MatchTIR 核心參數(shù) enable true matching_mode hard # hard 用匈牙利算法, soft 用最優(yōu)傳輸 lambda_penalty 0.5 # 未匹配調(diào)用的懲罰系數(shù) gamma 0.9 # 輪級(jí)優(yōu)勢(shì)的折扣因子 similarity_weights { tool_name 1.0, param_name 1.0, param_value 1.0 } normalize_advantage true # 是否對(duì)優(yōu)勢(shì)做組內(nèi)歸一化 [grpo.matchtir.soft] # 僅 matching_mode soft 時(shí)生效 tau 0.1 # 指數(shù)變換溫度 sinkhorn_iters 50 # Sinkhorn 迭代次數(shù)幾個(gè)參數(shù)的選擇依據(jù)我按論文的消融實(shí)驗(yàn)給你標(biāo)一下參數(shù)推薦值依據(jù)matching_modehard硬分配在所有基準(zhǔn)上比軟分配高 1-2 個(gè)點(diǎn)lambda_penalty0.5太小精確率低太大召回率低0.5 是平衡點(diǎn)gamma0.90.1 只看眼前0.9 充分考慮長(zhǎng)期依賴(lài)group_size4論文用的 G4顯存不夠可以降到 2similarity_weights這三個(gè)權(quán)重對(duì)應(yīng)相似度公式里的三個(gè)維度工具名是乘法門(mén)控錯(cuò)了直接歸零參數(shù)名用 Jaccard 相似度參數(shù)值數(shù)正確個(gè)數(shù)。默認(rèn)都是 1.0除非你有特定維度想加強(qiáng)。4. 核心實(shí)現(xiàn)相似度矩陣與匈牙利匹配配置寫(xiě)好了接下來(lái)是 MatchTIR 的核心邏輯。整個(gè)流程分三步算相似度矩陣、跑匈牙利匹配、分配獎(jiǎng)勵(lì)。先看相似度矩陣的計(jì)算。給定模型預(yù)測(cè)的調(diào)用集合P和標(biāo)準(zhǔn)答案G構(gòu)造一個(gè)m x n的矩陣import numpy as np from scipy.optimize import linear_sum_assignment def tool_similarity(pred, gold): pred/gold: dict, 形如 {name: get_weather, params: {city: 北京, date: 今天}} 返回 [0, 1] 之間的相似度分?jǐn)?shù) # 工具名是乘法門(mén)控錯(cuò)了直接歸零 if pred[name] ! gold[name]: return 0.0 pred_params pred.get(params, {}) gold_params gold.get(params, {}) # 參數(shù)名 Jaccard 相似度 pred_keys set(pred_params.keys()) gold_keys set(gold_params.keys()) if not pred_keys and not gold_keys: s_pn 1.0 else: s_pn len(pred_keys gold_keys) / len(pred_keys | gold_keys) # 參數(shù)值正確個(gè)數(shù) s_pc sum( 1 for k in pred_keys gold_keys if pred_params[k] gold_params[k] ) # 歸一化到 [0, 1] denom 1 1 len(gold_keys) return (1.0 s_pn s_pc) / denom def build_similarity_matrix(preds, golds): m, n len(preds), len(golds) S np.zeros((m, n)) for i, p in enumerate(preds): for j, g in enumerate(golds): S[i, j] tool_similarity(p, g) return S然后是匈牙利匹配。scipy的linear_sum_assignment求的是最小代價(jià)所以要把相似度取負(fù)def match_and_reward(preds, golds, lambda_penalty0.5): 返回每個(gè)預(yù)測(cè)調(diào)用的獎(jiǎng)勵(lì)列表 if not preds: return [] if not golds: return [-lambda_penalty] * len(preds) S build_similarity_matrix(preds, golds) # 匈牙利算法求最小代價(jià)取負(fù)轉(zhuǎn)成最大化相似度 row_ind, col_ind linear_sum_assignment(-S) rewards [-lambda_penalty] * len(preds) for i, j in zip(row_ind, col_ind): rewards[i] S[i, j] return rewards跑一個(gè)具體例子驗(yàn)證一下。假設(shè)標(biāo)準(zhǔn)答案是get_weather(city北京, date今天)模型預(yù)測(cè)了兩個(gè)調(diào)用golds [{name: get_weather, params: {city: 北京, date: 今天}}] preds [ {name: get_weather, params: {city: 北京, date: 明天}}, {name: web_search, params: {query: 北京天氣}}, ] rewards match_and_reward(preds, golds, lambda_penalty0.5) print(rewards) # 輸出: [0.75, -0.5]第一個(gè)調(diào)用工具名對(duì)了、參數(shù)名都對(duì)了、只有 date 的值錯(cuò)了相似度(111)/(112) 0.75。第二個(gè)調(diào)用工具名不對(duì)相似度歸零沒(méi)匹配上拿懲罰-0.5。這就是 MatchTIR 和「大鍋飯」的區(qū)別——冗余調(diào)用被明確懲罰而不是跟著正確答案一起拿正獎(jiǎng)勵(lì)。5. 驗(yàn)證請(qǐng)求跑一次本地調(diào)用看匹配是否收斂光算獎(jiǎng)勵(lì)還不夠得驗(yàn)證整條鏈路能跑通。下面這段代碼用 TaoToken 的 API 做一次真實(shí)的工具調(diào)用然后把返回結(jié)果喂給匹配邏輯看獎(jiǎng)勵(lì)分布是否合理。import os import json from openai import OpenAI client OpenAI( base_urlos.environ[TAOTOKEN_BASE_URL], api_keyos.environ[TAOTOKEN_API_KEY], ) TOOLS [ { type: function, function: { name: get_weather, parameters: { type: object, properties: { city: {type: string}, date: {type: string}, }, required: [city, date], }, }, } ] def run_one_turn(user_query): resp client.chat.completions.create( modelclaude-sonnet-4-20250514, messages[{role: user, content: user_query}], toolsTOOLS, temperature0.7, ) msg resp.choices[0].message calls [] if msg.tool_calls: for tc in msg.tool_calls: calls.append({ name: tc.function.name, params: json.loads(tc.function.arguments), }) return calls # 跑一次 preds run_one_turn(北京今天天氣怎么樣) print(模型預(yù)測(cè):, preds) golds [{name: get_weather, params: {city: 北京, date: 今天}}] rewards match_and_reward(preds, golds, lambda_penalty0.5) print(匹配獎(jiǎng)勵(lì):, rewards)跑通之后你會(huì)看到類(lèi)似這樣的輸出模型預(yù)測(cè): [{name: get_weather, params: {city: 北京, date: 今天}}] 匹配獎(jiǎng)勵(lì): [1.0]如果模型調(diào)對(duì)了相似度是 1.0獎(jiǎng)勵(lì)滿分。如果模型多調(diào)了一個(gè)web_search你會(huì)看到獎(jiǎng)勵(lì)列表里多出一個(gè)-0.5。怎么判斷「匹配結(jié)果是否收斂」連續(xù)跑 20 次同樣的 query統(tǒng)計(jì)獎(jiǎng)勵(lì)的均值和方差。如果均值穩(wěn)定在 0.8 以上、方差小于 0.05說(shuō)明模型在這個(gè)任務(wù)上的工具調(diào)用已經(jīng)收斂。如果方差很大說(shuō)明模型還在探索階段需要繼續(xù)訓(xùn)練或者調(diào)低temperature。import statistics scores [] for _ in range(20): preds run_one_turn(北京今天天氣怎么樣) r match_and_reward(preds, golds, lambda_penalty0.5) scores.append(sum(r) / len(r) if r else 0.0) print(f均值: {statistics.mean(scores):.3f}) print(f方差: {statistics.variance(scores):.4f})6. 本篇常見(jiàn)錯(cuò)排查報(bào)錯(cuò)一linear_sum_assignment返回的匹配數(shù)少于預(yù)測(cè)數(shù)這是正常的。匈牙利算法求的是一對(duì)一匹配如果m n預(yù)測(cè)調(diào)用比標(biāo)準(zhǔn)答案多多出來(lái)的預(yù)測(cè)調(diào)用匹配不上會(huì)拿懲罰。如果你希望「多調(diào)的工具不懲罰」把lambda_penalty設(shè)成 0但這樣模型會(huì)傾向于亂調(diào)不建議。報(bào)錯(cuò)二相似度矩陣全是 0檢查工具名是否完全一致。get_weather和getWeather在字符串比較下是不同的相似度直接歸零。建議在config.toml的[tool_call.tools]里統(tǒng)一命名規(guī)范訓(xùn)練數(shù)據(jù)和推理側(cè)用同一套工具定義。報(bào)錯(cuò)三tomli讀取 config 報(bào)KeyErrorconfig.toml里的嵌套表必須用[section.subsection]格式不能寫(xiě)成[section]下面再縮進(jìn)。比如[grpo.matchtir]是合法的[grpo]下面寫(xiě)matchtir {...}在某些解析器里會(huì)出問(wèn)題。用tomli.load()讀之前先print(config.keys())確認(rèn)結(jié)構(gòu)。報(bào)錯(cuò)四API 返回 401檢查T(mén)AOTOKEN_API_KEY環(huán)境變量是否生效。在 Python 里print(os.environ.get(TAOTOKEN_API_KEY)[:8])看一下前 8 位確認(rèn)不是空值。如果 Key 是在別的終端 export 的當(dāng)前終端讀不到重新 export 一次。報(bào)錯(cuò)五輪級(jí)優(yōu)勢(shì)算出來(lái)全是 NaN檢查gamma是否設(shè)成了 1.0。gamma1.0時(shí)折扣累積獎(jiǎng)勵(lì)不收斂歸一化時(shí)分母可能為 0。按論文推薦用 0.9。另外確認(rèn)group_size至少是 2G1 時(shí)組內(nèi)歸一化的標(biāo)準(zhǔn)差是 0也會(huì)出 NaN。報(bào)錯(cuò)六匹配獎(jiǎng)勵(lì)和預(yù)期不符先單獨(dú)跑tool_similarity函數(shù)打印s_tn、s_pn、s_pc三個(gè)中間值。最常見(jiàn)的問(wèn)題是參數(shù)值比較用了但類(lèi)型不一致比如15和15不相等。建議在比較前統(tǒng)一轉(zhuǎn)成字符串。7. 下一步把匹配獎(jiǎng)勵(lì)接進(jìn) GRPO 訓(xùn)練循環(huán)配置和驗(yàn)證都跑通了最后一步是把它接進(jìn)你的 GRPO 訓(xùn)練代碼。改動(dòng)點(diǎn)只有兩個(gè)第一把原來(lái)的reward_fn換成 MatchTIR 的匹配獎(jiǎng)勵(lì)。原來(lái)你可能寫(xiě)的是reward 1.0 if answer_correct else 0.0現(xiàn)在改成對(duì)每個(gè) turn 的每個(gè)工具調(diào)用算獎(jiǎng)勵(lì)再按 turn 取平均得到輪級(jí)獎(jiǎng)勵(lì)r_t。第二把優(yōu)勢(shì)估計(jì)從「整條軌跡共享一個(gè)值」改成雙重級(jí)。軌跡級(jí)優(yōu)勢(shì)A_g用整條軌跡的總獎(jiǎng)勵(lì)做組內(nèi)歸一化輪級(jí)優(yōu)勢(shì)A_l用折扣累積獎(jiǎng)勵(lì)做組內(nèi)歸一化最后相加得到每個(gè) token 位置的優(yōu)勢(shì)值。如果你還在用單輪 SFT 的方式訓(xùn)工具調(diào)用建議先切到 GRPO 多輪端到端訓(xùn)練論文的消融實(shí)驗(yàn)顯示多輪比單輪高 4 個(gè)點(diǎn)以上。切過(guò)去之后再加 MatchTIR 的細(xì)粒度獎(jiǎng)勵(lì)提升會(huì)更明顯。需要查 API 詳細(xì)參數(shù)的話接入文檔在 https://taotoken.net/doc 里面有工具調(diào)用格式和流式返回的說(shuō)明。訓(xùn)練側(cè)如果顯存吃緊先把group_size從 4 降到 2max_turns從 8 降到 5跑通之后再往上加。