
1. 一个让我坐不住的评测结果先交代背景。前段时间我在折腾小参数模型的工具调用能力手里有一颗 2B 级别的模型量化后大概 1.4G 显存占用跑在消费级显卡上毫无压力。我本来只是想验证一下它在函数调用场景下到底能不能用结果顺手做了个横向对比测出来的数字让我盯着屏幕愣了好一会儿同一颗模型、同一套测试用例、同一批工具定义只换执行框架得分从 0.017 一路拉到 0.821。0.017 是什么概念基本等于瞎猜。0.821 是什么概念在工具调用这个任务上已经能干活了。中间差了将近 50 倍。这件事让我意识到一个被很多人忽略的问题大家平时讨论模型能力讨论的是权重但真正决定一个 agent 能不能跑起来的往往是模型外面那一层壳。这层壳就是 harness——你可以叫它执行框架、编排层、脚手架叫什么都行它负责把模型的输出解析成结构化调用、把工具结果喂回去、管理多轮循环、处理错误重试、控制上下文长度。我后来把这套评测和 harness 实现整理开源了这篇文章就把整个思路、实现细节、踩过的坑完整讲一遍。如果你正在做小模型的 agent 落地或者单纯好奇为什么同样的模型换个框架差距这么大这篇应该能帮你省不少时间。2. 为什么同一颗模型会有天壤之别的得分2.1 先搞清楚 harness 到底在干什么很多人对 agent 的理解停留在模型输出 JSON然后执行。实际上一轮完整的工具调用循环harness 至少要处理这些事提示词组装把系统指令、工具 schema、历史对话、当前用户输入拼成一个模型能吃的 prompt。这里每个框架的模板都不一样有的用特殊 token 分隔有的用纯文本标记有的把工具定义塞在 system 里有的放在单独的 tool 字段。输出解析模型吐出来的东西可能是标准 JSON、可能是带 markdown 代码块的 JSON、可能是半截 JSON、可能夹杂自然语言解释、可能用单引号、可能少个括号。解析器要能从这堆东西里把调用意图抠出来。调用格式转换模型输出的字段名和真实工具 API 的字段名往往对不上需要一层映射。执行与回填调用工具、拿到结果、把结果按框架约定的格式塞回对话历史。循环控制判断该继续调用还是该结束设置最大轮数处理死循环。错误处理工具报错怎么办、解析失败怎么办、模型开始胡言乱语怎么办。这六件事里任何一件做得糙得分就会断崖式下跌。0.017 那个框架我后来定位下来问题出在输出解析和提示词模板两个环节——它假设模型会输出完美 JSON而 2B 模型在复杂 schema 下几乎不可能做到。2.2 小模型对 harness 的敏感度远超大模型这里有个反直觉的点值得展开说。同样换框架70B 级别的模型得分波动可能只有 10 到 15 个百分点但 2B 模型能差出几十倍。原因有三个第一小模型的指令遵循能力弱。大模型你告诉它只输出 JSON不要有任何其他内容它基本能守住。2B 模型经常忍不住加一句好的我来帮你调用这个函数然后 JSON 就废了。harness 如果不会从自然语言里提取 JSON这一轮就白给。第二小模型对 schema 的敏感度高。工具定义里字段一多、嵌套一深小模型就容易漏字段、编字段、把类型搞错。harness 如果能在提示词里把 schema 简化、给几个 few-shot 示例得分立刻不一样。第三小模型的容错空间小。大模型解析失败一次下一轮还能自己纠正回来。2B 模型一旦上下文里出现一个格式混乱的历史记录后面几轮基本就崩了。所以 harness 对失败轮次的清理和重试策略特别关键。我实测下来在 2B 这个量级上harness 的贡献度甚至超过模型本身。这不是说模型不重要而是说当你只有 2B 的预算时把 harness 打磨好性价比远高于去换一个 3B 或 4B 的模型。2.3 四个框架的选型逻辑我选的这四个框架覆盖了当前主流的几种 harness 设计哲学不是随便挑的框架类型设计哲学典型特征适合场景原生 JSON 模式信任模型输出直接 json.loads无兜底大模型 简单 schema模板强约束型用特殊 token 框住输出自定义分隔符正则提取中等模型 固定 schema多轮修复型解析失败就重试带纠错提示的二次调用小模型 复杂任务结构化生成型约束解码语法引导逐 token 限制任意模型 严格 schema得分 0.017 的是第一种0.821 的是第四种。中间两个大概在 0.4 到 0.6 之间。这个分布本身就说明了问题越是不信任模型、越是在 harness 层面做约束的方案在小模型上表现越好。3. 拆解四个框架的核心差异3.1 框架一裸 JSON 解析得分 0.017这个框架的实现简单到可笑核心就三行response model.generate(prompt) data json.loads(response) execute(data[name], data[arguments])它的假设是模型会输出干净的 JSON。在 2B 模型上这个假设的成立概率大概只有百分之几。我统计了一下失败原因分布输出带 markdown 代码块包裹约 45%JSON 前后有自然语言约 30%JSON 本身语法错误缺括号、多逗号约 15%字段名或类型错误约 8%其他约 2%也就是说光是一个去掉 markdown 代码块的处理就能把这个框架的得分翻好几倍。但它的设计者没做因为它原本是给大模型用的。这里有个经验如果你看到一个 agent 框架的解析逻辑只有一行 json.loads基本可以判断它没认真考虑过小模型场景。3.2 框架二模板强约束得分约 0.42这个框架的思路是用特殊标记把模型的输出框起来比如要求模型这样输出tool_call {name: get_weather, arguments: {city: 北京}} /tool_call然后用正则把tool_call和/tool_call之间的内容抠出来。这个方案比裸解析强很多因为即使模型在标记外面说了废话也不影响提取。但它的瓶颈在于2B 模型经常忘记加标记或者标记写错。我统计下来大约有 35% 的轮次模型没有正确使用标记。这时候框架只能退化成裸解析得分就掉下来了。改进方向是加 few-shot 示例在 system prompt 里给两三个完整的输入输出对。我试过加三个示例后标记正确率从 65% 提到了 88%得分也跟着涨到 0.55 左右。但示例会占用上下文对 2B 模型来说上下文一长后面的指令遵循能力又会下降这是个权衡。3.3 框架三多轮修复得分约 0.61这个框架的核心思想是解析失败不要紧把失败信息喂回给模型让它重试。流程是这样的模型输出尝试解析如果失败构造一个纠错提示你上次的输出无法解析错误是 XXX请重新输出合法的 JSON把纠错提示和上次输出一起塞回上下文再调一次最多重试 2 次这个方案在 2B 模型上效果明显因为小模型虽然第一次容易错但你明确告诉它错在哪它第二次往往能改对。我实测重试一次能把成功率从 42% 提到 61%重试两次到 68% 左右。但它的代价是延迟翻倍甚至三倍。每次重试都是一次完整的模型调用在 2B 模型上单次调用大概 0.8 秒重试两次就是 2.4 秒。如果你的场景对延迟敏感这个方案要慎重。还有个坑重试次数不能太多。我试过重试 5 次结果发现模型会陷入一种奇怪的循环反复输出同样的错误内容得分反而下降。2 次是个比较稳的甜点。3.4 框架四结构化生成得分 0.821这是得分最高的方案核心是约束解码——在模型生成每一个 token 的时候根据当前已经生成的内容和目标的语法规则动态限制下一个 token 的取值范围。举个例子如果目标是 JSON当模型已经输出了{name: get_那么下一个 token 必须是能构成合法字符串的字符不能是}或,。这个约束在解码阶段就生效模型根本没有机会输出非法内容。实现上主流做法是维护一个状态机或者用语法解析器比如基于 GBNF 或 JSON Schema 编译的语法在每个解码步计算允许的 token 集合然后把其他 token 的 logits 设成负无穷。这个方案的优势是从根上杜绝了格式错误模型输出的永远是合法结构。得分 0.821 里剩下的 0.179 损失主要来自语义错误——比如模型选错了工具、填错了参数值这些是格式约束管不了的。但结构化生成也有它的代价实现复杂度高需要把 JSON Schema 编译成语法还要和推理引擎的解码循环对接。对推理引擎有要求不是所有推理框架都支持自定义 logits 处理器。我用的这个支持但配置起来折腾了大半天。可能影响生成质量约束太严的时候模型被迫在有限空间里选有时候会选出一个格式合法但语义很差的输出。这个现象在 schema 复杂时更明显。我的经验是结构化生成适合 schema 固定、字段明确的场景。如果工具定义经常变维护语法编译的成本会很高。4. 从 0.017 到 0.821 的完整实操路径4.1 环境与模型准备我用的推理引擎支持自定义 logits 处理器和批量推理模型是 2B 级别的指令微调版本量化到 4bit。显存占用实测配置显存占用单次调用延迟FP16约 4.2G1.1s4bit 量化约 1.4G0.8s4bit KV cache 优化约 1.6G0.6s测试用例我构造了 200 条覆盖单工具调用、多工具选择、参数填充、多轮调用四种场景。评分标准是完全匹配——工具名对、参数名对、参数值对才算通过。这个标准比较严但能真实反映可用性。4.2 提示词模板的迭代过程提示词这块我改了大概七八版记录几个关键节点第一版直接把工具 schema 用 JSON 塞进 system prompt。得分 0.09。问题是 schema 太长2B 模型读到后面已经忘了前面。第二版把 schema 简化成自然语言描述比如get_weather 工具需要一个参数 city类型是字符串。得分 0.21。有提升但模型经常把参数名写错。第三版加两个 few-shot 示例。得分 0.38。示例的作用非常明显但占用了约 400 token 的上下文。第四版把示例精简成一个同时把工具描述改成名称 参数列表 一个调用示例的紧凑格式。得分 0.44上下文占用降到 200 token 左右。第五版在 system prompt 末尾加一句强指令只输出 JSON不要输出任何解释文字。得分 0.51。这句话对小模型特别有用。第六版配合结构化生成把提示词里的格式要求全部去掉因为约束解码已经保证了格式。得分直接到 0.82。这个迭代过程说明一个事提示词工程和 harness 机制是互补的不是替代关系。当你有了结构化生成提示词可以更专注于语义引导当你只有裸解析提示词再怎么优化也救不了格式问题。4.3 结构化生成的具体配置这部分是重点我尽量讲清楚。核心是把 JSON Schema 编译成语法规则然后在解码时约束。我用的是 GBNF 风格的语法定义一个简化的工具调用语法大概长这样root :: { ws \name\ ws : ws string ws , ws \arguments\ ws : ws object ws } string :: \ [^]* \ object :: { ws (pair (ws , ws pair)*)? ws } pair :: string ws : ws value value :: string | number | true | false | null | object | array然后在推理引擎里注册一个 logits 处理器每一步根据当前语法状态过滤 token。配置代码大概是这样from grammar_processor import GrammarLogitsProcessor grammar load_grammar(tool_call.gbnf) processor GrammarLogitsProcessor(grammar) output model.generate( prompt, logits_processor[processor], max_new_tokens256, temperature0.1 )几个关键参数的经验值temperature 设 0.1 到 0.3太低会重复太高会乱选。0.1 在工具调用上比较稳。max_new_tokens 设 256工具调用输出一般不超过 200 token留点余量。不要开 top_p 采样约束解码下采样反而可能选到语义差的合法输出用贪心更稳。踩过的坑语法文件里的空白处理很容易出错。我一开始没加ws规则结果模型输出带空格就解析失败。后来在所有可能的位置都插入了可选的空白匹配才稳定下来。4.4 多轮调用的上下文管理工具调用往往不是一轮就结束的。比如用户问北京和上海哪个更热模型需要先调 get_weather(北京)再调 get_weather(上海)然后比较。这里有个容易被忽略的点历史记录里工具返回的结果怎么存。如果直接把原始 JSON 塞回去上下文会迅速膨胀2B 模型很快就读不动了。我的做法是工具返回结果只保留关键字段去掉冗余的元数据结果用自然语言简述比如北京当前温度 28 度而不是完整的 JSON超过 3 轮的历史做摘要压缩实测下来这样处理能让多轮场景的得分从 0.35 提到 0.72。上下文管理在 2B 模型上不是优化项是必选项。5. 常见问题与排查实录5.1 得分突然掉到接近零怎么办如果你测出来得分在 0.05 以下基本可以确定是解析环节全挂了。排查顺序打印模型原始输出看看到底长什么样。十有八九是格式问题。检查提示词模板确认工具 schema 有没有正确注入。检查解析器用几条手工构造的正确输出测试解析逻辑本身。如果用了结构化生成检查语法文件是否和实际 schema 匹配。我遇到过一次得分 0.02 的情况最后发现是提示词模板里的一个占位符没被替换模型收到的是字面的{tools}字符串。这种低级错误在调试时特别容易被忽略。5.2 模型反复调用同一个工具这是 2B 模型的典型毛病。表现是模型调了 get_weather拿到结果后不结束又调一次 get_weather。原因通常是模型没理解工具结果已经返回了。解决办法是在工具结果后面加一句明确的引导工具已返回结果请基于结果回答用户不要重复调用。如果还不行就在 harness 层面加一个硬性限制同一个工具用相同参数调用超过 2 次直接强制结束并返回已有结果。5.3 参数值填错但格式正确这是结构化生成也解决不了的问题。模型输出{name: get_weather, arguments: {city: 上海}}格式完美但用户问的是北京。这类错误的排查要看提示词里的语义引导够不够。我的经验是在工具描述里明确参数的语义比如city 参数是用户想查询的城市名称从用户问题中提取加一个 few-shot 示例展示从问题到参数的映射如果参数是枚举类型把所有合法值列出来5.4 常见问题速查表现象可能原因排查方向解决手段得分低于 0.05解析全挂打印原始输出修解析器或换结构化生成得分 0.3 到 0.5格式时好时坏统计失败类型分布加 few-shot 或约束解码模型不调用工具提示词没引导检查 system prompt加明确的调用指令重复调用结果未识别检查回填格式加引导语 硬限制参数值错误语义理解不足检查工具描述补充参数语义说明多轮后崩溃上下文过长统计 token 数压缩历史记录延迟过高重试次数多统计重试率换结构化生成5.5 几个反直觉的经验经验一few-shot 示例不是越多越好。我试过放 5 个示例得分反而比放 2 个低。原因是上下文变长后2B 模型对后面的指令遵循能力下降。2 个示例是个比较稳的数量。经验二工具数量超过 5 个后得分会明显下降。2B 模型在多个工具之间做选择时容易混淆。如果工具确实多可以考虑先做一层工具分类缩小候选范围。经验三温度参数对结构化生成的影响比想象中小。因为约束解码已经把非法 token 排除了温度主要影响合法 token 之间的选择。但温度太高时模型会在合法但语义差的输出上浪费概率质量所以还是建议设低。经验四量化对工具调用得分的影响小于预期。我对比过 FP16 和 4bit得分差距只有 2 到 3 个百分点。但量化对延迟的改善很明显所以小模型场景下量化是划算的。6. 这套 harness 还能怎么扩展我现在这套实现主要针对单轮和简单多轮的工具调用。往深了做还有几个方向方向一并行工具调用。让模型一次输出多个工具调用harness 并行执行后合并结果。这对 2B 模型来说难度不小因为要保证多个调用的格式都正确。结构化生成在这里有天然优势可以把语法扩展成支持数组形式的调用列表。方向二工具结果的流式回填。现在我是等工具完全执行完再回填如果工具本身耗时整体延迟会很高。改成流式回填后模型可以在工具执行的同时开始生成后续内容。方向三动态工具裁剪。根据用户问题先做一轮粗筛只把相关的工具定义注入提示词。这样能显著缩短上下文对 2B 模型特别友好。我初步试过用关键词匹配做粗筛得分能再提 3 到 5 个百分点。方向四失败案例的自动收集与微调。把 harness 解析失败的案例自动存下来积累到一定量后做一轮 LoRA 微调。这个思路是把 harness 的负担部分转移回模型长期看可能比纯 harness 优化更划算。我个人在实际操作中的体会是小模型 agent 的落地七分靠 harness三分靠模型。很多人一上来就想换更大的模型但如果 harness 没做好换到 7B 可能也就从 0.017 涨到 0.1还是不能用。反过来把 harness 打磨到位2B 模型在特定任务上完全能跑出可用的效果。这个投入产出比值得每个做端侧 agent 的人认真算一算。