静态化、接口抽象、测试用例、评估管线,这套东西一步步推进下来,前后大约八周时间跑通了全流程。下面我会把这套方案的完整设计、训练数据构造、偏好优化细节、评估管线搭建过程全部展开,包括那些文档里不会写的坑和我自己的取舍逻辑,尽量做到看完就能在你的项目里去试。
1. 核心命题:让模型“造工具”难在哪里
1.1 调用工具和生成工具是完全不同的两条路
先说清楚一个很基础的区分。平时我们做Agent,最常见的方式是给模型挂几个预置工具——搜索、计算、查数据库,模型在推理时选一个并填好参数。这条路线解决的是“用工具”,核心是意图识别和参数抽取。但这里有一个天然的天花板:工具集合是人预先写好的,模型永远只能在你给它的选项里挑,遇到没有覆盖到的新需求就抓瞎。
而“Training LLMs to write tools generalized beyond self use”这个命题,要解决的是“造工具”——模型针对新场景直接用Python写一个可复用的函数、类甚至一个带命令行入口的模块,然后下一次遇到类似需求,不管是它自己还是另一个Agent,都能直接把这段工具拿过去用。这不是让模型学会生成一个一次性脚本把当前任务跑完就算结束,而是要生成一个脱离当前上下文依然能独立运转的工具。
从我的实测经验来看,代码生成能力本身早已不是瓶颈。开源社区几个代码模型在LeetCode级别题目上已经刷得很高,但真让它们给一个具体场景写“可复用工具”时,问题一个接一个:要么函数里硬编码了上下文里的中间变量,要么接口设计得极其别扭,要么完全不写错误处理,要么docstring等于摆设。这说明“生成工具”和“生成代码”之间,差的不是一个编程水平问题,而是一个“工具观”问题。
1.2 “超越自身使用”到底在考什么
这个标题里最有分量的词是“beyond self use”。我把它拆成三个可量化的要求,后面整个训练和评估都是围绕这三条展开的。
第一条是独立性。模型写出来的工具,不能依赖生成它当时的对话上下文,比如不能在函数体里引用一个只有那轮对话里才有的变量,不能依赖某个临时创建的全局状态。第二条是可接入性。另一个Agent或者另一位开发者,拿到这个工具的函数签名和docstring,不需要看原始对话,就能正确调用。第三条是可组合性。工具不是为了某一个特定任务定制的,而是可以被嵌套、被其他工具调用,或者通过参数配置适配相似场景的不同变体。
这三条看起来是软件工程的基本要求,但对LLM来说并不是默认具备的。语言模型天然倾向于“顺着上下文走”,它习惯了在一个完整上下文里续写代码,而工具生成要求它在不依赖上下文的前提下,从零构建一个封闭、自洽的模块。这个习惯的扭转,单靠提示词是压不过来的,需要在训练数据的分布和奖励信号的设置上做文章。
1.3 这个方向适合谁关注
如果你在做多智能体平台、自动化工作流、企业级Agent应用,或者你负责维护一个会被多个业务方调用的内部工具库,这个方向非常值得关注。过去我们需要人肉为Agent编写新工具,而现在的思路是把这个环节变成模型自主完成,并且保证产出物能进入共享资产池。我也在一个内部模拟项目X里验证过:让模型自动生成数据清洗工具、日志解析器、报表聚合函数,经过管线校验后直接进入团队共享的“某内部工具库”,实际替换掉了大约七成的手写工具工作量。适合的人群包括:Agent基础设施开发者、LLM微调工程师、以及所有被“工具维护成本”卡住的应用团队。
2. 训练数据构造:泛化能力的源头在样本设计
2.1 数据样本的结构化设计
训练数据是整个方案的灵魂。我在构造SFT阶段的数据时,没有简单地收集“问题→代码”的样本,而是给每条样本设计了一套固定的结构,让它同时包含:自然语言任务描述、接口契约、工具实现、docstring、跨场景调用示例、测试用例。每条样本在喂给模型之前,都被统一格式化成下面这样的样子:
【任务描述】 你所在的项目需要解析一类嵌套的配置文本,结构不固定但层级明确。 请编写一个工具函数parse_nested_config,要求: - 输入:文本字符串,包含花括号嵌套的键值对 - 输出:嵌套的字典 - 该函数不得引用任何外部变量,不得依赖全局状态 - 需要处理非法的格式并抛出明确异常 【工具实现】 def parse_nested_config(text: str) -> dict: import json # 实现细节略 return result 【调用示例】 config_dict = parse_nested_config("a={b=1, c={d=2}}")注意一个关键细节:任务描述里我刻意写上了“不得引用任何外部变量”这样的限制,目的是把“独立性”直接变成任务条件,而不是让模型自己领悟。工具实现之后,还会附带一段“跨场景调用示例”,展示这个函数被另一个完全不同的调用方引用的场景,让模型在训练时看到“工具写完后会被别人拿去用”这个存在。
10万条短问答式的代码数据堆不出这种效果。我自己的经验是,高质量的工具生成样本,两三千条认真构造的数据就能带来肉眼可见的行为变化,重点在于每条数据都要踩中“独立性、可接入性、可组合性”三个维度里的至少一个。
2.2 泛化信号怎么注入训练样本
数据构造时,我在三个维度上做了刻意的信号注入。
第一个维度是“新任务遇到旧工具”。我在一部分样本里直接给模型展示:你之前已经写过一个工具A,现在遇到了一个与A高度相似但细节不同的新需求,你应该直接复用A并说明它满足需求,而不是重新写一个A2。这个信号非常重要,因为它让模型学会“识别已有资产的边界”,而不是机械地每次都生成新函数。
第二个维度是“跨场景测试用例”。每条工具样本,我都配上来自另一个业务领域的测试用例。比如模型为一个配置解析工具写的代码,测试用例里会模拟一个完全不相关的业务域:解析另一个项目的配置文件格式。这样模型会天然学会不要为当前任务做过度特化,要把输入输出设计得通用。
第三个维度是“残缺描述”。有一类样本,任务描述故意不给完整信息,只给模糊意图。让模型在写工具前先自己补齐接口契约,主动提问或声明它对输入输出的假设。这个设计后来发现非常有用,因为它迫使模型养成“先定契约再写实现”的习惯,生成的工具接口更整洁。
2.3 数据配比与规模经验
我前后跑了三轮数据实验,有组配比数据值得参考。
| 数据类别 | 占比 | 作用 |
|---|---|---|
| 通用代码生成(开源代码语料筛选) | 20% | 维持基础编码能力 |
| 工具生成(完整契约结构样本) | 50% | 主力训练信号 |
| 接口优化改写(给定一次实现,优化其可复用性) | 15% | 让模型学会迭代重构 |
| 工具选择与复用(新任务匹配旧工具) | 10% | 避免重复造轮子 |
| 工具文档编写(函数+docstring逆推说明) | 5% | 提升可接入性 |
总数据量方面,我第一轮只用了大约3000条工具生成样本,模型已经表现出明显的“工具化倾向”,但生成的函数在复杂错误处理上还会拉跨。第二轮扩到9000条之后,单测通过率和多Agent接入成功率都跨过了实用线。再把量往上翻到3万条,提升就不太明显了,所以我个人认为工具生成SFT阶段,1万条级的高质量样本是性价比最合适的区间。
3. 训练策略与偏好优化:逼着模型做更好的设计决策
3.1 基座选型:代码能力优先还是指令遵循优先
基座模型的选择会直接决定后面需要花多少精力在纠偏上。我对比过两类基座:一类是通用指令模型,优点是指令理解强,能很好理解长任务描述,但写出的代码在工程完整性上平平;另一类是代码专项模型,函数实现质量高,但对“自然语言里包含复杂限制条件”的指令遵循能力偏弱。
最终的取舍是代码专项模型占优。原因是工具生成这件事的核心矛盾是“代码质量”,而指令理解可以通过样本设计来补偿——我只需要在SFT数据里反复强化边界条件,模型就能学会。反过来,如果基础代码能力弱,靠训练数据去补漏洞成本极高。举个例子,我在早期用通用模型做过一次实验,生成一个递归解析工具时,模型在最大递归深度、循环引用检测这类边界处理上频频失误,后期无论怎么叠样本都很难彻底修好。换成某个已在海量代码上预训练过的代码基座后,同一批数据量,工具的一次成型率提升了将近两成。
3.2 SFT阶段的任务格式统一
数据格式统一对SFT效果影响大。工具生成任务需要一个稳定且强约束的模板,我最终固定下来的格式可以当参考:
SYSTEM: 你是一个工具生成器。给定任务描述后,你应当: 1. 写出满足需求的独立函数,不得依赖外部变量 2. 必须包含完整签名、类型标注和docstring 3. 提供至少一个调用示例 4. 不要编写完整可执行脚本,只输出可导入的工具代码 USER: 任务描述:{任务文本} ASSISTANT: 工具名称: xxx 函数签名: ... docstring: ... 代码实现: ... 调用示例: ...这个模板的用意是让模型在SFT阶段就看到一个稳定的“工具产出格式”。你不需要模型自由发挥文本结构,结构越固定,模型越容易把注意力集中在实现质量上。我对比过几轮:使用模板格式时,后续评估中工具的“即拿即用率”比自由格式高出约15个百分点,这个差距相当可观。
3.3 偏好优化:给模型建立“什么是好工具”的偏好
SFT只能让模型学会模仿好的输出格式,但要让它在面临设计分歧时做出好选择,还需要偏好优化。我采用的是DPO路线。关键工作是构造偏好对。
一个偏好对包含两个候选工具实现:一个得分高,一个得分低。我让两者都跑过单测,也就是说两者在功能上都是正确的,但它们在“可泛化性”上有明显差异。举一个真实的偏好对例子:
候选A(得分高):
def normalize_phone(raw: str, country_code: str = "86") -> str: digits = re.sub(r"\D", "", raw) return digits if digits.startswith(country_code) else country_code + digits候选B(得分低,依然能跑通测试):
def normalize_phone(raw: str) -> str: digits = re.sub(r"\D", "", raw) if not digits.startswith("86"): digits = "86" + digits return digits两个函数在当前测试用例下输出完全一样,但B把“86”硬编码进了函数体,无法处理国际号码。当另一个Agent接到一个解析美国电话号码的任务并尝试复用它时,B就废了。偏好优化的目标,就是让模型学会区分这类表面“正确”但实质“不可复用”的实现。
训练时,我要求每次生成的答案先跑一套自动化规则打分,分值由三条组成:接口独立性(是否引用了外部状态)、参数充分性(是否把可变常量设计为参数)、边界处理充分性(是否有异常抛出)。然后按得分差异组织DPO训练对。这里有一点值得提:对pair的构造不要全用模型生成,要在人工标注的极端案例上做数据增强——否则DPO只会放大模型已有的行为偏好,不会突破它的设计惯性。
实际跑完D偏好优化后,我拿固定测试集做了一次前后对比:SFT后的模型“T1单测通过率”大约在78%,做偏好优化后提升到84%;更关键的“跨场景复用成功率”从41%提升到63%,这个数字差恰恰说明偏好优化真正影响的是“泛化”维度,而不是基础编码能力。
4. 评估体系搭建:量化“泛化能力”这个模糊指标
4.1 三层评估结构
没有评估就无法迭代。我把评估拆成了三层,每一层对应的能力各有侧重。
| 评估层 | 名称 | 核心问题 | 手段 |
|---|---|---|---|
| T1 | 工具单测通过率 | 功能是否正确 | 固定测试用例集 |
| T2 | 跨场景复用成功率 | 换一个任务场景是否还能接入 | 新任务描述 + 已有工具池调用 |
| T3 | 多Agent插拔成功率 | 别的Agent在不知情情况下能否直接接管 | 只给函数签名和docstring做黑盒调用 |
T1解决的是“这个工具能不能跑”;T2解决的是“这个工具换个场景还能不能用”;T3解决的是“这个工具是否真的超越自我使用”。三个阶段层层递进,我见过太多只跑到T1就宣布成功的案例,那基本是在自欺欺人。
4.2 自动化评估管线的实现思路
评估必须全自动化,否则没法批量迭代。我写过一个简单的评估脚本框架,核心逻辑是:让被测模型产出工具代码,落盘到文件,然后在独立的子进程里导入并运行测试用例。这里我强烈建议用子进程方式运行,而不是在同一进程里exec,否则被评估工具一旦有恶意或危险的全局操作,会直接污染整个评估环境。
import subprocess import tempfile import json def evaluate_single_tool(model_output: str, test_cases: list): with tempfile.TemporaryDirectory() as tmpdir: tool_path = os.path.join(tmpdir, "tool.py") with open(tool_path, "w", encoding="utf-8") as f: f.write(model_output) # 在独立进程里运行测试,隔离环境 test_script = ''' import sys, importlib.util sys.path.insert(0, {tmpdir!r}) spec = importlib.util.spec_from_file_location("tool", {tool_path!r}) tool = importlib.util.module_from_spec(spec) spec.loader.exec_module(tool) results = [] for case in {test_cases!r}: try: out = tool.tool(**case["args"]) results.append(out == case["expected"]) except Exception as e: results.append(False) print(json.dumps(results)) ''' result = subprocess.run( [sys.executable, "-c", test_script], capture_output=True, timeout=10 ) return json.loads(result.stdout)有了这条管线,我每次改训练数据、调超参、替换基座之后,几个小时就能拿到三层评估的完整回归报告,让迭代速度变得很快。
4.3 一组有参考价值的实测数据
我把整个流程跑完后得到一组数字,不同基座和数据配置的差异很明显。同一组训练数据,代码基座跑出来的T3多Agent插拔成功率为57%,通用基座只有39%;加上DPO精调之后代码基座T3提升到71%。把数据配比里的“跨场景测试用例”从10%提高到30%,T3又往上跳到76%。
这个趋势说明:想让工具的泛化能力变强,训练数据内部需要足够比例的“跨场景测试”样本,否则模型只会针对当前输入过拟合。另外T2和T3的提升曲线比T1平滑得多,说明泛化能力的学习是渐进的,不要期望一轮SFT就一步登天,需要靠偏好优化和数据调整慢慢推高。
5. 实操复盘:踩过的坑和沉淀下来的经验
5.1 高频问题排查清单
代码生成类项目里,最终决定成败的往往不是模型能力,而是那些细碎的实践问题。我把实操中反复遇到的典型问题整理成了一张速查表。
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
| 生成的工具在本地能跑,换环境就报错 | 隐式依赖了外部库版本或全局变量 | 在训练任务描述里显式要求“仅标准库可导入”,或强制生成requirements |
| 函数表面正确但换输入就崩 | 测试用例覆盖太弱,模型学了捷径 | 评估时注入随机生成的新注入用例,不只用给定测试用例 |
| 工具之间的接口风格五花八门 | 训练样本里的函数签名不统一 | 在SFT模板里加入统一的命名规范和参数风格要求,比如统一用objects字典传入参数 |
| 生成工具太长,几百行一个函数 | 模型没有模块拆分意识 | 增加“将过大的工具拆分为多个子函数”的重构样本 |
| 模型总是重新发明轮子 | 训练数据里缺乏“复用已有工具”正例 | 显著提高“新任务匹配旧工具”类样本的比例 |
这条表里我特别想说的是第三行“换输入就崩”。我做中间有一轮模型生成的日志解析工具在原有测试集上表现完美,但换了一个时间格式后直接抛异常。后来检查发现,模型靠正则匹配了训练集里的几种模式,根本不是在做通用解析。从那以后我开始在评估管线里加入随机化测试:每次评估时会额外生成一批与原测试格式结构相近但具体内容不同的用例,这个习惯帮我挡住了大量“假阳性”通过。
5.2 三个值得复制的小技巧
第一个技巧是“先契约后实现”。SFT阶段强制模型先输出函数签名和docstring完整契约,再开始写实现。这样生成的代码在接口层面明显比没有强制顺序的更通用,而且docstring质量也提高了不少。第二个技巧是“工程化注入依赖”,即在训练样本里刻意给工具增加边界输入训练,比如空字符串、超大数字、非法编码等,让模型默认就有防御式编程意识。第三个技巧是在偏好优化阶段不要用传统代码风格打分,而要对“可复用性”打标——一句话总结就是别评它写得美不美,评它换个人能不能直接用。
5.3 关于数据飞轮的维护
模型落地到生产环境后,不能停在一个版本上不管。我发现最有价值的增量数据不是随便爬来的代码,而是自己系统里“同一个工具被不同Agent反复调用且不出错”的真实行为记录。把这些记录清洗后沉淀为新的偏好对:一个工具被成功复用,就是一条正样本;一个工具在复用现场出错,则是比任何人工构造都真实的负样本。这个数据飞轮建立起来之后,模型的工具泛化能力会随着使用量的增长自然走强,而不是停滞在原地。
在推进这类项目时,我还体会到一点:与其在一开始就追求完美的大规模训练框架,不如先把“输入数据格式”和“评估管线”这两个地基打牢。数据格式决定了模型能不能学会你想要的模式,评估管线决定了你能不能看清模型到底学会了什么。地基不牢,后面加任何训练技巧都是在沙子上盖楼。这套原理并不高端,但很多人恰恰会因为地基太枯燥而跳过去,等返工时才意识到时间都浪费在了哪里。