我开源了一个agent harness,起因是一组让我自己都愣住的数字:同一颗2B模型,权重文件原封不动,采样参数完全一样,跑同一套agent任务,在四个不同框架里的得分分别是0.017、0.15、0.41和0.821,最低和最高差了快50倍。一开始我以为是评测集写错了,查了两天代码,最后才确认问题出在框架本身。这篇文章把harness的完整设计、四个框架的差异拆解、以及从0.017到0.821背后的机制分析全部摊开讲,适合正在做agent评测、正在给小模型设计tool calling流程、或者想搞清楚“为什么同一模型换框架后能力忽高忽低”的人参考。
1. 先说结论:同一颗模型为什么能差出50倍
1.1 一个足以让人失眠的实验结果
事情是这样的。当时我手头有一颗开源的2B对话模型,量化成GGUF格式之后在本地跑得很稳,我就想拿它当“探针”,来验证不同agent框架对模型能力的影响。我选了一套包含日程管理、邮件分类、简单检索、数据库查询模拟等场景在内的20个agent任务,要求模型必须正确调用工具、传对参数、再基于工具返回内容作答。结果四个框架跑出来的数字完全不是一个量级:最低的只有0.017,基本等于模型“完全不会用工具”,每次尝试都死在格式错误或者调用循环里;最高的0.821,已经能稳定完成大部分任务。同一个模型,同一批任务,同一个后端推理,差距大到没法用“模型随机性”来解释。
这组数字逼我认真去拆每个框架到底做了什么。agent framework说白了就是一个包装层,它决定模型看到什么系统提示词、什么工具定义格式、生成结果怎么被解析、调用失败后怎么反馈。这些东西叠加起来,对一个大模型可能只是10%的波动,但对2B这种“预算紧张”的小模型来说,就是能用和不能用的区别。
1.2 不是模型不行,是框架在暗中决定上限
很多人拿到低分后习惯性归因于“模型太弱”。但我的实验恰恰说明,2B模型的能力边界确实有限,但远没低到0.017的程度。真正的问题是框架在几个关键节点上做了“反向下毒”。
第一,系统提示词和工具定义怎么写。有些框架为了“严谨”,往提示词里塞了大量安全约束、使用说明、输出规范化要求,还把所有工具用递归嵌套的XML格式写出来。2B模型的注意力资源很有限,真正对解题有用的信息被淹没在几千个token的“废话”里。
第二,输出解析器的容错程度。有的框架要求模型必须输出符合严格JSON Schema的完整块,差一个引号就整体解析失败;有的框架允许模型在文本里自然插入一个工具调用片段,解析器按行去抓,容忍大小写、前后缀、缺失字段的偏差。这两者的成功率可以差出好几倍。
第三,有没有反馈回路。工具调用失败之后,是把错误信息拼回去让模型重试,还是直接判负退出?有没有限制重试轮数?错误信息怎么格式化?这些细节直接决定模型能不能“自我修正”。
所以我后来在harness的README里写了一句话:agent框架不是模型的放大器,它是模型的滤镜。框架设计得差,能直接把模型的能力滤掉90%。
1.3 这个harness到底做了什么事
我开源的东西是一套“agent评测通用壳子”,它不绑定任何具体框架,而是把四个框架当作适配器插进来。核心设计目标是:让所有框架面对完全相同的模型、完全相同的任务、完全相同的工具注册表、完全相同的评测指标,唯一可以变的就是框架自身的“提示词模板 + 解析器 + 执行循环”。
它提供了一套统一的Task定义格式和ToolRegistry,内置了一个评测执行器,可以自动跑多轮工具调用,记录每一步的模型输出、解析结果、工具返回、重试次数,最后按统一口径计算得分。整套代码一千多行,不依赖重型框架,纯Python,主要靠一个异步runner把整个流程串起来。
最让我觉得值得分享的,不是“我写了一个评测工具”,而是“用这个工具测出来的框架差异,能反推出一大堆框架设计该做和不该做的事情”。
2. 实验设置与四个框架的得分画像
2.1 怎么保证“只有框架不同”
要比较框架,就必须把其他变量全部钉死。我在这块花了不少功夫,先说关键配置:模型统一用同一颗2B量化版Q4_K_M,推理后端统一用llama.cpp的server模式,上下文窗口设为4096,温度设为0.2,top_p设为0.9,max_tokens设为1024,每个任务最多允许8次工具调用轮次。工具集固定为五件套:创建日程、查询日历、发邮件、网页搜索、数据库查询模拟,每个工具的参数schema也完全一致。
评测集是我自己整理的事务型任务,比如“帮我安排明天下午3点和产品团队开会,时长一小时,顺便给参会者发邮件提醒”。这类任务需要模型先解析意图,拆出参数,调用第一个工具,再根据返回结果决定是否调用第二个工具。每个任务都有一个标准评分脚本,按“工具调用序列是否完全正确 + 最终回答是否准确”两个维度判分。
四个框架我分别用代码代号A、B、C、D来指代,避免品牌干扰。我需要强调一点:这四个框架不是来路不明的草台代码,而是在社区里都有一定用户的真实方案,只是它们对“模型应该如何表达工具调用”这件事的理解完全不同。它们的得分如下:
| 框架 | 工具调用格式 | 解析方式 | 平均分 |
|---|---|---|---|
| A | 严格JSON Block | 整体JSON解析 | 0.017 |
| B | 递归XML标签 | XML树解析 | 0.152 |
| C | Markdown代码块内JSON | 正则抽取 | 0.413 |
| D | 自然语言夹杂YAML块 | 行级容错解析 | 0.821 |
每个框架我都跑了三遍完整评测集,次数不算多,但三遍结果标准差都在0.03以内,说明这个差异是结构性、稳定性的,不是随机波动。
2.2 框架A:被提示词和硬解析一起压垮
框架A的得分低到0.017,但它不是随便设计的玩具。它在系统提示词里写了一大堆内容:安全红线、输出要求、工具使用规则、范例、反例、还有对模型回答风格的约束。工具定义用的是JSON Schema数组,每个字段都有description和enum。看起来非常“企业级”,但对2B模型来说,这个输入的复杂度已经接近灾难。
模型每次需要输出一个完整的JSON对象,必须包含“thought”“tool_name”“tool_args”三个字段。解析器拿到模型输出后,直接交给JSON解析库做整体parse,任何一个小错——多了个逗号、字符串里没转义的引号、输出完了还带一句“以上就是我的计划”——整个调用都算失败。2B模型在生成这种长JSON时,出错率本来就高,加上系统提示词里大量无关约束分散了注意力,它经常生成到一半就开始重复,输出被max_tokens切断,解析器永远拿不到一个合法JSON。
我单独抽了10次运行日志,发现模型有6次输出了不完整JSON,3次在JSON后面追加了无关文本,1次完全没有产生调用。这说明问题不在“模型不会调用工具”,而在于框架给模型布置了一个超出它当前token预算能力的“格式关卡”。
这里有一个很重要的经验:给2B模型用的工具调用协议,越像它预训练时所熟悉的文本格式越好。大部分2B模型在预训练时见过大量Markdown、JSON、普通列表文本,但你让它稳定输出一个“必须严格满足Schema的长JSON块”,它真的做不到。
2.3 框架B:递归XML与模型的语言直觉打架
框架B得分0.152,比A好一些,但仍然远低于可用门槛。它使用XML风格的工具调用,例如<tool_call><name>search_web</name><params><query>...</query></params></tool_call>,工具定义也是嵌套XML模板。看起来结构清晰、可读性强,但问题是2B模型对嵌套XML的闭合标签非常不敏感。
我在日志里看到大量“只写了开头标签,没闭合”“params和/params嵌套错位”“工具名出现在文本中间而不是标签里”这类错误。XML解析器在遇到格式错误时通常抛异常,框架捕获异常后只是简单返回“格式错误请重试”,没有任何定位能力。模型在重试时往往是重新生成一遍,而不是针对性修复,于是陷入“生成-失败-重新生成”的循环,直到把8轮配额耗尽。
这次对比让我意识到:格式设计不能只看“人觉得好不好看”,更要看“模型生成路径上是否容易出错”。递归嵌套结构对小型模型来说,每一步都要额外记住前面已经生成了哪些层级,生成跨度越长,出错率越高。对于2B模型,扁平化的、一行一个字段的格式往往比层层嵌套稳定得多。
2.4 框架C:用正则捞调用,容错上了一个台阶
框架C的得分是0.413,已经能算“可用但不够稳”。它的做法是把工具调用放在Markdown代码块里的JSON,比如```json {"tool_name":"create_event","args":{...}} ```,然后解析器不要求模型输出是整个JSON文件,而是用正则去文本里匹配“json代码块”,匹配不到再去捕捉双花括号包围的JSON片段。
这个容错策略对2B模型非常关键——模型不需要把整个回复变成机器可读的块,它可以先写“我来帮你安排会议”,然后再输出代码块承载工具调用。模型输出被截断的风险降低了不少,因为代码块通常出现在文本靠前的位置,即便后面还有内容没生成完,至少关键调用已经完整保留了。
C框架还有一个小改进:解析失败时,会把“解析器看到的原始输出片段”和“猜测的出错位置”一起拼回错误消息里。这让模型有机会理解自己哪里出了问题,重试成功率明显提升。但C框架的平均分卡在0.4附近上不去,原因是它的工具定义仍然偏长,每个工具都带大段description,5个工具的定义加起来超过1500个token,2B模型经常在生成时把参数名记错,比如把query写成keyword,这一调用就完全废了。
2.5 框架D:把模型当助手而不是代码生成器
最高分0.821来自框架D。它的思路和前面几个完全不同:框架不要求模型输出完整JSON块,而是让模型用自然语言把意图说出来,中间夹一行极简YAML风格的工具调用,例如:
action: create_event start: 2025-06-10 15:00 end: 2025-06-10 16:00 title: 产品团队周会 attendees: [alice@example.com]系统提示词被压到最短,只告诉模型三句话:你是一个助手,需要处理用户请求;需要做事时使用action块;action块放在文本之后。工具定义不是JSON Schema,而是一张极简的参数表,每个工具只有参数名和一行类型说明。
解析器采用逐行扫描,对每一行做key-value切分,允许多余空格、允许引号缺失、允许末尾标点,甚至允许参数顺序变化。它不要求模型一次性输出完整块,只要捕获到一个action块,就立即中断生成、执行工具、把结果作为新的上下文拼接回去。这种“边生成边执行”的循环避免了长输出被截断的问题。
D框架的三遍测试结果很稳定,0.82左右,个别任务会拿到满分。我复盘它成功的原因,核心是让2B模型做它擅长的事:用很短的文本表达意图。它不需要严格格式化,只需要在自由的表达中夹带几个可解析的关键字段。这就把“格式负担”从模型身上转移到了解析器身上,而解析器永远不会累。
3. 关键机制分析:是什么让分数差出了50倍
3.1 格式约定必须落在模型的语言先验上
四组对比实验指向一个核心规律:2B模型能否稳定生成某种工具调用格式,不取决于格式“逻辑上是否严谨”,而取决于它和模型预训练阶段见过的文本分布是否接近。框架A是典型的“逻辑严谨但语言陌生”,模型在open web里很少见到“必须输出严格JSON Block、不得附加任何文本”的长篇指令,当输入的指令风格偏离它的语言习惯时,生成质量会快速下降。
这里可以和生活做个类比:让一个习惯了白话文的人突然用八股文格式写申请报告,他肯定会写出大量“格式正确但内容僵化”的东西,但如果让他先用白话把事情说清楚,再由秘书改成八股文,效果反而好得多。框架D本质上就是这个“秘书”的角色——模型先说人话,解析器负责把有效信息转换成机器可执行的动作。
如果你正在自己的项目里给模型设计工具调用格式,优先考虑扁平化的“键值对”或“一行一个字段”风格,别上嵌套层级。必要的时候做一次小规模生成稳定性测试:让模型空跑20次,统计格式通过率。如果格式通过率低于60%,就说明这个格式对当前模型来说负担过大。
3.2 解析器的容错性比格式本身更重要
四个框架里,解析器设计造成的差异甚至大于提示词设计的差异。A框架的解析器是“整体解析、全错全错”,C框架是“正则去捕获、局部可用”,D框架是“逐行扫描、错一行不影响另一行”。同样一次模型输出,A框架可能判成0分,C框架能抓到50%的可用信息,D框架能恢复90%以上。
我特别建议做agent平台的人重视“部分可用输出”的恢复策略。比如模型输出了一个action块,里面有五个字段,四个正确一个错误,解析器不该直接丢弃整个块,而是保留可用字段,再通过错误消息明确告诉模型“attendees格式不对,其余字段已接受,请重新补充”。这种设计能在不消耗太多额外轮次的情况下修复大部分错误。
严格解析不是错,但前提是生成端做到了高精度。对大模型可能可以,对2B模型来说,解析器严厉一点,分数就会断崖式下跌。做评测时尤其要注意:你测的到底是模型+框架的联合能力,还是模型单点能力?如果是后者,解析器就要尽量宽容。
3.3 错误反馈回路:从0.4到0.8的分水岭
C框架和D框架的差距,除了格式选择之外,还有错误反馈的设计。C框架虽然能重试,但错误信息是“工具调用解析失败,请检查JSON格式”,没有告诉模型具体哪里不对;D框架的错误信息非常具体,比如“日期start格式应为YYYY-MM-DD,当前识别为‘下午3点’”,模型能直接修正参数。
有没有这条回路,直接决定了分数的天花板。2B模型本来就容易在第一次生成时犯错,如果框架不允许从错误中学习,那模型就只能靠一次命中,命中率20%就是20%;如果框架能把错误精确地反馈给模型,模型的第二轮重试成功率能升到80%。
在harness里我记录了两条数据:D框架有约30%的任务是在第一轮就成功完成的,剩下的几乎都是靠第二轮重试成功。也就是说,错误反馈回路对最终得分贡献了至少一半的增量。
3.4 上下文长度与注意力稀释
比较系统提示词的长度,四个框架差异很大:A框架的系统提示词约2800 token,B约2200 token,C约1600 token,D约400 token。2B模型的attention资源本来就不富裕,系统提示词越长,模型分配到最后几轮工具返回内容和当前用户请求上的注意力就越稀薄。
我在跑A框架时做过一个控制变量实验:把系统提示词里的安全红线和格式要求压缩一半,得分从0.017提升到0.11,说明“删除无用指令”本身就是重大优化。对于2B模型,最优策略是“能删则删”:删除所有模型不需要知道的内部逻辑,删除重复性的强调,删除对输出格式的面面俱到式规定。只保留用户请求、最精简的工具表、一两句话的角色定义,剩下的一切交给解析器去容错。
3.5 采样参数与确定性
这个harness还挂在配置里一个容易忽略的参数:温度。框架A跑的0.017是温度0.2的结果,我后来试着把温度调到0.1,得分涨到0.06,但依然不改“整体不可用”的结论。这里要说明,评测agent最好把温度固定在一个低值,否则高分和低分都会被“运气”污染。但也不要以为温度调低就万事大吉,因为低温度会增加重复生成的概率,特别是系统提示词冗长的时候,模型很容易卡在同一个循环里出不来。
我的建议是评测时温度设在0.1到0.3之间,同时打开重复惩罚(repeat_penalty)1.1左右,让模型不会无限重复同一句失败输出。如果你发现自己框架的得分在多次重复评测时飘忽不定,先查采样参数,再查解析器,最后才轮到模型和任务本身。
4. 实操:harness的架构与本地复现步骤
4.1 项目结构一览
harness的外形是一个纯Python项目,核心模块分成四层:Task定义层、Adapter适配层、Runner执行层、Report统计层。Task定义层描述“要做什么”和“怎么判分”;Adapter层是每个框架的桥接器,负责把统一的Task和ToolRegistry转成该框架需要的生活模板和解析逻辑;Runner层负责任务循环、超时控制、重试策略和错误注入;Report层汇总所有执行记录。
# 核心数据结构示意 @dataclass class Task: id: str user_query: str available_tools: list[str] expected_plan: list[tuple[str, dict]] expected_answer: str | None = None @dataclass class Tool: name: str params: dict[str, str] # 参数名 -> 类型 execute: Callable[[dict], str]整个调度流程是:加载评测集,实例化四个Adapter,拉起统一推理后端,依次把每个Task注入每个Adapter,得到四份执行日志,最后按统一评分函数计算。想跑完整套实验,先装依赖,再启动推理服务,再配置四个Adapter的API端点。
4.2 核心接口与工具注册
所有框架共享同一份ToolRegistry,但每个Adapter可以按自己的风格重新渲染工具描述。正是这一步让我发现了一个有趣的差异:即使执行函数完全一样,只要“工具描述文字”不一样,模型的调用成功率就不一样。
比如同一个“创建日程”工具,框架A将其描述为“create_event(todo: object)”,框架D将其描述为“创建日程:日期start,结束end,标题title”,后者对2B模型友好得多。工具描述的“自然语言化”是D框架0.821得分里非常重要的一环。你在自己的项目里给工具写描述时,不要只写字段类型,要写字段的人话含义和取值范围,而且要短。
下面是一个可参考的工具注册写法:
tools = [ Tool( name="create_event", params={ "start": "str,格式YYYY-MM-DD HH:MM", "end": "str,格式YYYY-MM-DD HH:MM", "title": "str,事件标题", "attendees": "list[str],参会人邮箱,可选", }, execute=create_event, ) ]4.3 自定义一个评测任务
新增一个评测任务只要两步:写Task描述,写评分函数。我建议把评分函数拆成两个维度:工具序列命中率(PlanScore)和最终答案正确率(AnswerScore),然后各取50%。这样做的好处是能区分“调用对了但不会总结”和“总结对了但调用一塌糊涂”两类失败。
def score_task(log: ExecutionLog, task: Task) -> float: plan_score = sequence_match(log.tool_calls, task.expected_plan) # 0~1 answer_score = answer_similarity(log.final_answer, task.expected_answer) # 0~1 return 0.5 * plan_score + 0.5 * answer_score如果你只想测框架的“调用能力”,可以把AnswerScore权重设为0;如果你想测完整的助手能力,就保留0.5对0.5。我自己在harness里两种配置都放了,命令行用--metric plan或--metric full切换。
4.4 跑通四个框架的完整流程
第一步,启动推理服务。注意事项是四个框架必须指向同一个服务,不能A用这个量化版本、B用另一个量化版本,否则测试结果就没有可比性。
第二步,初始化每个框架的Adapter,设定各自的API地址。每个Adapter内部固定了提示词模板、工具定义渲染方式、解析器实现和重试消息拼接方式。这套设计让我可以写出“同一份模型输出,四个框架各自解析”的离线测试脚本,方便单独调试解析器。
第三步,运行评测。命令行入口可以这样:
python run_eval.py \ --base-url http://127.0.0.1:8080 \ --model bhq4 \ --frames a b c d \ --tasks eval_set.json \ --max-rounds 8 \ --temperature 0.2输出目录结构以framework名分层,每个任务生成一份JSON日志,里面记录模型每一次原始输出、解析后结构化调用、工具返回、错误类型和耗时。
4.5 看懂评测报告里的关键指标
除了平均分,我强烈建议你看五个指标:格式通过率(模型输出能被解析器理解的比例)、工具调用准确率(解析成功的调用中序列正确的比例)、重试率(需要二次以上才能成功的比例)、平均调用轮次、空响应率(模型完全没生成任何可解析内容的比例)。
框架A的空响应率接近70%,格式通过率不足20%;框架D的空响应率只有4%,格式通过率92%。这两个指标比总分直观得多,问题定位时可以省很多时间。
5. 排坑实录:那些能直接抄走的经验
5.1 常见问题速查表
| 现象 | 优先排查项 | 解决方案 |
|---|---|---|
| 所有框架分数都极低 | 推理服务是否真的加载了指定模型 | 检查模型路径与量化参数,确认后统一重启 |
| 同一框架测试两次分数波动大 | 采样参数不固定 | 温度固定0.1-0.2,开启禁用动态采样 |
| 重试很多但成功率低 | 错误消息缺少具体信息 | 把失败原因和错误字段回传给模型 |
| 输出被截断导致解析失败 | max_tokens太小 | 增加到1024以上,或者让解析器支持流式捕获 |
| 模型从不调用工具 | 工具描述不够口语化 | 每个工具加一行“举个例子” |
| 格式通过率低 | 嵌套层级过多 | 改成扁平式键值对格式 |
5.2 分数不稳定的最大元凶:采样
我在评测中发现,同样的配置,同一框架,上一次跑0.78,下一次跑0.62,这种不稳定会让人怀疑代码有bug。后来我把每次生成的随机种子固定,温度压到0.1,重复惩罚打开,波动就控制在0.03以内了。如果你在评测agent,别小看采样参数,它不只是“风格调节器”,它是可复现性的前提。
5.3 量化级别对得分的影响
我一开始想偷懒,用更小的Q2_K量化做实验,发现分数普遍比Q4_K_M低10%到20%。后来换回Q4_K_M,数字才稳定下来。做框架对比时,模型量化级别必须锁定,否则你没法说清楚分数差异是来自框架还是来自模型精度。要复现这个实验,建议不要用低于Q4_K_M的量化。
5.4 明确边界:框架适配不等于模型能力提升
这个harness测出来的分数是“在这个框架提示和解析条件下的表现”,不是模型的通用能力。有人看了D框架的高分,会误以为“只要换了框架,什么模型都能起飞”。实际情况是,D框架只是把2B模型的格式负担降到最低,让模型本身的语义理解能力可以正常发挥。这已经很有价值,但不要把它包装成“提升模型能力”的魔法。
6. 一点体会与后续方向
这套harness现在还在持续更新,我个人的体会是:agent评测最大的坑就是“不控制变量”。框架、提示词、解析器、模型、采样参数,任何一个变了,分数都可能天翻地覆。如果你真想知道某个东西提升了多少,就必须把其他东西全部锁死。
建议你在自己的项目里也试试这个玩法:准备一套小任务集,把模型固定成一颗2B小模型,然后同时跑几个框架,用它来照出框架设计上的毛病。小模型对格式负担更敏感,会把问题放得很大,比直接用70B模型更容易暴露框架缺陷。
后面我想给harness加上多语言工具定义支持,再搞一个“解析器压力测试”模块,专门分析不同解析策略对模型输出的恢复率。如果你在跑这套工具时发现新的排查案例,欢迎把日志格式发给我,我会在后续版本里把这些案例整合成内置的检查规则。