同一颗 2B 模型,换四个不同的 agent 框架去跑,得分能从 0.017 一路拉到 0.821。这个数字第一次出现在我面前的时候,我盯着屏幕看了很久,怀疑是不是评测脚本写错了。0.017 基本等于瞎猜,0.821 已经接近一个能用的水平了,中间差了将近 50 倍。模型权重一个字节都没动,变的只是外面那层"壳"——也就是 agent harness。
这件事让我意识到一个被很多人忽略的事实:在 agent 这个领域,模型能力只是下限,harness 才是决定上限的东西。你手里那颗 2B 的小模型,放在一个糟糕的框架里就是个废物,放在一个设计合理的框架里,它能干出让你惊讶的活。我开源的这个 agent harness,就是围绕这个观察做出来的,目的不是再卷一个框架出来,而是想把"harness 到底影响什么、怎么影响"这件事讲清楚、跑明白。
如果你正在做 agent 开发,手里有本地部署的小模型,或者你只是好奇为什么同样的模型在不同框架里表现天差地别,那这篇内容应该能给你一些实在的参考。我会从评测结果讲起,拆开 harness 的每一层,告诉你哪些设计真正吃掉了分数,哪些只是看起来很美。
1. 四个框架跑同一颗 2B 模型,分数为什么能差 50 倍
先把实验摆出来。我用的是同一颗 2B 参数量的指令微调模型,量化到 4bit,跑在本地一张消费级显卡上,推理后端统一用 Ollama,温度、top_p、max_tokens 这些采样参数全部锁死。唯一变量就是 agent harness。四个框架我分别记为 A、B、C、D,都是社区里能拿到的开源实现,覆盖了从极简到重工程化的不同风格。
评测任务是一组多步工具调用题,模型需要读懂任务、决定调用哪个工具、填对参数、拿到结果后继续推理,直到给出最终答案。评分用的是最终答案正确率加上工具调用格式合规率的加权。结果如下:
| 框架 | 最终得分 | 工具调用合规率 | 平均步数 | 单题平均耗时 |
|---|---|---|---|---|
| A(极简 prompt 拼接) | 0.017 | 12% | 1.3 | 1.8s |
| B(ReAct 循环) | 0.204 | 41% | 3.7 | 6.2s |
| C(结构化输出 + 重试) | 0.536 | 78% | 4.1 | 9.5s |
| D(本 harness) | 0.821 | 94% | 4.4 | 11.3s |
看到这张表,很多人的第一反应是"D 是不是偷偷换了模型"。没有。D 慢一点、步数多一点,恰恰说明它在做更多的事——它在帮模型把话说清楚、把格式修对、把错误兜住。A 之所以只有 0.017,是因为它几乎不做任何约束,模型输出一段自然语言,框架直接当答案交上去,工具根本没被正确调用过。
1.1 0.017 这个数字背后的真相
0.017 不是"模型很笨",而是"框架根本没给模型机会"。A 框架的做法是把工具描述塞进 system prompt,然后让模型自由发挥,输出什么就解析什么。2B 模型在这种自由度下,大概率会输出一段"我觉得应该先查询天气,然后……"这样的叙述性文字,而不是结构化的工具调用。框架解析失败,直接判定为错误。
这里有个关键点:小模型的指令遵循能力本来就弱,你越给它自由,它越容易跑偏。A 框架的错误在于把"灵活性"当成了优点,实际上对 2B 这个量级的模型来说,约束才是朋友。我在调试初期也犯过同样的错,觉得 prompt 写得越开放模型越聪明,结果就是一堆无法解析的输出。
1.2 从 0.204 到 0.536,中间补的是什么
B 到 C 的跃升,补的是"结构化"和"容错"。B 用了经典的 ReAct 循环,让模型交替输出思考和动作,比 A 强不少,但它的解析还是基于文本正则,模型一旦换个措辞,正则就匹配不上。C 换成了强制 JSON schema 输出,并且加了失败重试——模型第一次输出格式不对,框架把错误信息回填给它,让它重来。
这一步的收益非常明显,合规率从 41% 跳到 78%。但 C 还有天花板:它的重试是"无脑重试",不区分错误类型,模型可能连续三次犯同一个错,白白浪费 token 和时间。而且 C 的上下文管理比较粗糙,多步之后历史消息堆得太长,小模型开始"忘记"最初的任务。
1.3 D 框架多出来的那 0.285 分从哪来
D 相比 C,主要多了三件事:错误分类重试、上下文压缩、以及工具调用的预校验。错误分类重试是说,框架能判断这次失败是格式问题、参数问题还是逻辑问题,针对性地给提示,而不是笼统地说"你错了重来"。上下文压缩是把早期的工具返回结果做摘要,只保留关键信息,避免小模型被长上下文淹没。预校验是在真正执行工具前,先检查参数类型和取值范围,把明显错误的调用拦下来。
这三件事加起来,把合规率从 78% 推到了 94%,最终得分到了 0.821。注意,这些都不是模型层面的改动,全是 harness 的工程活。这也是我想强调的核心:在 agent 系统里,harness 的工程量直接换算成分数。
2. 拆开 harness:一颗小模型到底需要框架帮它做什么
很多人对 harness 的理解停留在"就是调用模型的代码"。这个理解太浅了。Harness 是一整套围绕模型的运行时环境,它要解决的是"模型想说但说不清楚、想做但做不对"的问题。对 2B 这种小模型来说,框架要补的坑比大模型多得多。我把 harness 拆成五个层次,逐层讲清楚每层在干什么、为什么小模型特别依赖它。
2.1 提示词组装层:不是拼接,是翻译
提示词组装听起来简单,把 system prompt、工具描述、历史消息拼起来不就完了?对小模型来说完全不是。2B 模型的注意力容量有限,你塞进去的东西越多,它越抓不住重点。所以这一层的核心任务是"翻译"——把工具的能力翻译成模型能理解的最短描述,把历史对话压缩成模型能消化的形式。
我在这层的做法是:工具描述不用完整的 JSON schema 原文,而是转成一句自然语言加一个精简的参数列表。比如一个查询天气的工具,不写一大段 schema,而是写成"get_weather(city: 城市名) - 查询指定城市的当前天气"。模型看到这种描述,调用准确率明显比看完整 schema 高。这跟大模型相反,大模型能吃完整 schema 并且利用其中的约束信息,小模型会被冗余信息干扰。
2.2 输出解析层:容错比严格更重要
解析层的任务是判断模型输出的是不是合法的工具调用。这里有个反直觉的经验:对小模型,解析器要"宽容地严格"。宽容是指能识别多种表达形式,严格是指最终必须归一化成统一结构。
我见过太多框架用一套死正则去匹配,模型输出Action: get_weather能匹配,输出我要调用 get_weather就匹配不上。实际上小模型经常会用自然语言夹杂工具名的方式表达意图。我的解析层会先做一轮意图识别,把"想调用什么工具"和"参数是什么"分开抽取,再归一化。这样即使模型措辞不规范,也能救回来一部分。
2.3 执行与反馈层:工具返回也要"翻译"
工具执行完返回结果,这个结果直接丢给模型吗?不行。很多工具返回的是原始 JSON 或者一大段文本,小模型读不懂或者读偏。反馈层要做的是把工具结果转成模型友好的形式,比如把嵌套 JSON 拍平成"温度 25 度,天气晴",把长文本截断成关键句。
这一步的收益经常被低估。我做过对比,同样的工具返回,直接丢原始 JSON 和经过翻译的简洁文本,模型下一步的正确率差了将近 20 个百分点。原因很简单,小模型处理结构化数据的能力弱,你给它越干净的自然语言,它越不容易出错。
2.4 上下文管理层:小模型的记忆是稀缺资源
2B 模型的上下文窗口通常不大,就算窗口够大,它的有效注意力也有限。上下文管理层要做的就是在有限预算里,保留最相关的信息。我的策略是分层:最近两轮对话完整保留,更早的工具返回做摘要,最初的 system prompt 和任务描述永远置顶。
这里有个坑:很多框架为了省事,直接把所有历史消息按时间顺序堆着,超了就从头截断。结果就是任务描述被截掉了,模型忘了自己要干什么。我踩过这个坑,后来改成"任务描述固定保留 + 中间历史滚动摘要",多步任务的完成率提升很明显。
2.5 错误恢复层:区分"不会"和"手滑"
错误恢复是 D 框架拉开差距的关键。模型失败有很多种:格式错了、参数类型错了、调用了不存在的工具、逻辑上走偏了。这几种错误的修复方式完全不同。格式错,回填格式要求让它重写;参数类型错,指出具体哪个参数不对;调用不存在的工具,把可用工具列表再强调一遍;逻辑走偏,可能需要重新规划。
如果框架只会说"你错了,重来",模型大概率会重复同样的错误。我统计过,无差别重试的二次成功率只有 30% 左右,而分类重试能到 65% 以上。这个差距在长任务里会被放大,因为每一步的失败都会累积。
3. 把 2B 模型塞进本地环境:Ollama 部署与推理参数的那些细节
聊完 harness 的设计,得说说底层怎么跑起来。我整套实验都基于本地部署,推理后端用 Ollama,模型是 4bit 量化的 2B 版本。选 Ollama 的理由很实际:它把模型加载、显存管理、HTTP 接口这些脏活都包了,我只需要关心 harness 逻辑。但用下来也踩了不少坑,这里一并说清楚。
3.1 模型量化的选择:4bit 是甜点,但不是无脑选
2B 模型在消费级显卡上跑,量化几乎是必须的。我对比过 fp16、8bit、4bit 三种精度。fp16 显存占用最大,速度最慢,但输出质量最好;8bit 是折中;4bit 显存最省,速度最快,但输出质量有可见下降。
对 agent 任务来说,量化带来的质量下降会直接影响工具调用的准确率。我的实测是,同一个 harness 下,fp16 得分 0.86,8bit 是 0.84,4bit 是 0.821。差距不算大,但如果你追求极致,8bit 是更稳的选择。4bit 的优势在于能在更小的显存上跑,适合显存紧张的场景。我最终选 4bit 是因为要留显存给并发测试,如果你只跑单任务,8bit 更划算。
3.2 Ollama 的推理参数:temperature 不是越低越好
很多人做 agent 会把 temperature 调到 0,追求确定性输出。这个做法在简单任务上没问题,但在多步 agent 任务里会出问题。temperature 为 0 时,模型容易陷入重复循环,一旦某一步走错,后面会一直重复同样的错误,因为它每次都选概率最高的那个 token。
我的经验是把 temperature 设在 0.1 到 0.3 之间,给模型一点点随机性,让它有机会跳出错误循环。同时 top_p 设在 0.9 左右,避免采样到太离谱的 token。这个组合在我的测试里比 temperature=0 的完成率高出一截。当然,如果你的任务对确定性要求极高,可以设 0,但要配合 harness 层的错误恢复来兜底。
3.3 并发与显存:别让 OOM 毁掉你的评测
跑评测的时候,如果串行执行,几百道题要跑很久。我一开始想开并发加速,结果直接 OOM。原因是 Ollama 默认会为每个请求分配独立的 KV cache,并发数一上去显存就爆了。
解决办法有两个:一是限制并发数,根据显存大小算一下能开几个;二是调小单请求的上下文长度上限,减少 KV cache 占用。我最后用的是并发数 2 加上下文上限 4096 的组合,在 8G 显存的卡上跑得很稳。这里提醒一句,评测脚本一定要加超时和重试,Ollama 偶尔会因为显存碎片化卡住,没有超时机制整个评测会挂死。
4. 评测方法论:怎么保证那 0.821 不是自欺欺人
分数好看不代表结论可信。我特别担心的一件事是:这套评测会不会因为题目设计、评分标准的问题,人为地把 D 框架抬高了。所以我在评测方法论上花了不少功夫,这里把关键点讲清楚,你也可以用同样的思路验证自己的框架。
4.1 任务集的设计:覆盖失败模式,而不是覆盖场景
很多评测集是按场景划分的,天气、订票、查资料各来几道。这种划分的问题是,它测的是"模型会不会这个场景",而不是"框架能不能兜住错误"。我设计任务集时,是按失败模式来的:格式易错的任务、参数易错的任务、需要多步推理的任务、工具返回复杂的任务,每类都有足够的样本。
这样设计的好处是,能精确定位框架在哪类任务上强、哪类任务上弱。比如 D 框架在格式易错任务上优势最大,在纯逻辑推理任务上优势就小很多,因为那部分主要靠模型本身。这种细粒度的结论比一个总分有用得多。
4.2 评分标准:合规率和正确率要分开算
如果只看最终答案正确率,会掩盖很多问题。一个框架可能答案对了,但过程全是错的,靠运气蒙对。所以我把评分拆成两部分:工具调用合规率和最终答案正确率,加权得到总分。合规率反映框架的工程能力,正确率反映端到端效果。
更进一步,我还记录了"首次调用合规率"和"重试后合规率"。这两个指标能看出框架的容错能力。D 框架首次合规率是 81%,重试后到 94%,说明它的重试机制确实在起作用。而 C 框架首次合规率 62%,重试后 78%,重试的边际收益明显低一些。
4.3 消除随机性:多次运行取统计量
agent 任务有随机性,单次运行的结果不可靠。我的做法是每个配置跑 3 次,取平均值,同时记录方差。如果方差很大,说明结果不稳定,需要排查原因。D 框架三次运行的得分分别是 0.815、0.824、0.823,方差很小,说明它的表现是稳定的,不是某次运气好。
这里有个细节:跑多次的时候要确保每次的随机种子不同,否则等于跑了个寂寞。Ollama 支持设置 seed,我在评测脚本里显式指定不同的 seed,保证每次运行是独立的。
5. 复现这套 harness 时最容易踩的五个坑
前面讲的都是设计层面的东西,真正动手复现的时候,坑都在细节里。我把调试过程中印象最深的五个坑列出来,每一个都让我多花了不少时间,希望你能绕过去。
5.1 工具描述太长,小模型直接"看不见"工具
我一开始把工具的完整 JSON schema 塞进 prompt,觉得信息越全越好。结果模型经常忽略后面的工具,只调用第一个。排查后发现是 prompt 太长,小模型的注意力被前面的内容占满了,后面的工具描述等于没写。
解决办法是把工具描述精简到极致,每个工具一行,参数只保留必填项。如果工具特别多,还要考虑分组或者动态加载——根据当前任务只注入相关的工具。这个思路在大模型上不一定需要,但对 2B 模型是刚需。
5.2 重试次数不设上限,token 烧光任务还没完
错误恢复层如果无脑重试,遇到模型死活做不对的任务,会一直重试到 token 耗尽。我早期版本就犯了这个错,一个任务重试了十几次,最后超时失败,还浪费了大量 token。
正确的做法是设置重试上限,并且区分错误类型。格式错误可以多给几次机会,逻辑错误给一两次就够了,因为逻辑走偏往往不是重试能解决的,需要重新规划。我现在的策略是格式错误最多重试 3 次,逻辑错误最多 1 次,超过就标记为失败进入下一题。
5.3 上下文压缩把关键信息压没了
上下文压缩是个双刃剑。压得太狠,关键信息丢了,模型后面全错;压得太松,等于没压,上下文还是爆。我踩的坑是早期用简单的截断,把工具返回结果从中间截掉,结果关键数字正好在被截掉的部分。
后来改成摘要式压缩:用一个轻量规则或者小模型把工具返回结果提炼成一句话,保留关键实体和数值。这个摘要的质量直接决定后续步骤的正确率。如果摘要本身出错,后面全盘皆输。所以摘要逻辑要保守,宁可多留一点信息,也不要丢关键内容。
5.4 工具执行没有超时,一个卡住的工具拖垮整个任务
工具执行层必须加超时。我有个查询类工具,偶尔会因为外部原因卡住,没有超时的话整个 agent 循环就停在那里。加了超时之后,超时的工具返回一个明确的错误信息,模型可以据此决定是重试还是换方案。
超时时间要根据工具类型设置,本地计算类工具可以短一点,网络请求类工具要长一点。我一般设 5 到 10 秒,超过就判定失败。这个错误信息也要"翻译"成模型能理解的形式,比如"查询超时,请稍后重试或换一种方式"。
5.5 评测脚本和 harness 耦合太紧,改一处崩一片
最后一个坑是工程结构上的。我早期把评测逻辑和 harness 逻辑写在一起,结果想换个评测集或者调个参数,就要动 harness 的代码,很容易引入 bug。后来我把两者彻底解耦:harness 只负责"给定输入产出输出",评测脚本负责"喂输入、收输出、打分"。
解耦之后,我可以独立地迭代 harness 和评测集,互不影响。这个结构上的调整看起来不起眼,但大大加快了后续的调试速度。如果你打算长期维护这套东西,一开始就把边界划清楚,能省很多事。
6. 从 0.017 到 0.821,我真正想说的几件事
做完这轮实验,我对 agent 系统的理解变了不少。以前总觉得模型能力是决定性的,现在越来越觉得,在特定量级的模型上,框架的工程能力才是那个变量最大的部分。同一颗 2B 模型,你给它一个好的 harness,它能干活;给它一个烂的 harness,它连工具都调不明白。
这个结论对小模型尤其重要。大模型容错空间大,框架差一点也能靠模型自己找补回来。小模型没有这个余量,框架的每一处设计都会直接反映在分数上。所以如果你在做端侧、本地部署或者成本敏感的 agent 应用,harness 值得你花大力气去打磨。
我开源这个 harness,不是想说它有多好,而是想把评测方法和设计思路摊开,让更多人能复现、能对比、能改进。分数本身会过时,但"harness 决定上限"这个认知,我觉得会一直有用。后续我打算把工具集再扩一扩,加入更多类型的失败模式,看看这套设计在更复杂的任务上还站不站得住。如果你也在做类似的事,欢迎拿这套评测方法去跑你自己的框架,结果可能会让你重新审视手里的模型。