10 款 Agent 框架硬测 4B 模型:它们没死在调用工具,全卡在改代码上——兼 Hermes 实测复现

Reading Time: 11 minutes

先说清楚哪部分是谁做的。

「10 款 Agent 框架硬测 4B 模型」这个实验是 r/LocalLLaMA 上一位作者做的,我转引他的设计和结论,并在关键处标出他自己承认的边界。他做过的事我没有重做。

我自己做的是三件事:把「改代码」这件事拆成三种工具形态分别测,用 Hermes 本体在同一个 4B 上跑一遍留下完整日志,另外还抓到我实验台自己的两个 bug。

后半部分才是这篇文章的原创数据。

结论先行

那个帖子里最值钱的一句话在这里:工具调用不是问题。

作者翻遍了十个 agent 的日志和 llama-server 的日志,找解析错误,畸形调用,未知工具,一无所获。开着 --jinja 和原生工具调用,所有失败都不是工具调用的锅。

真正的坑在改代码。具体是:模型知道要改什么,但复述不出要改的那段文本。

这句话有一半是对的。我把三种改码接口摆在一起测完,发现精确匹配本身不是死因,缺了读文件的能力才是。同一个模型、同一个任务:

rename 任务(跨 3 个文件改名),各跑 3 轮
  exact    3/3 通过   10 步   0 错误   9,967 token
  rewrite  3/3 通过    7 步   0 错误   5,167 token
  ast      3/3 通过    5 步   0 错误   3,138 token

三种都过。差别在代价:token 差 3.2 倍,墙钟差最多 16 倍。

那个 10 框架实验做了什么

实验设计我照原文抄。模型是 Qwen3.5-4B Q4_K_M,服务端只有一个 llama-server。

参数是 64K 上下文、1 个 slot、f16 KV、--jinja,实验机是 M5 MacBook Pro 32GB。

每个 agent 无头运行,自动批准。每轮开始前重置任务文件,通过与否由脚本判定。10 个 agent × 5 个任务,每个组合跑一次。

被测名单分两批。第一批是 Pi 1.3.0、goose 1.55.0、Crush 0.98.1、Cline 3.0.73、OpenClaw 2026.10.5(local-model-lean 模式)。

第二批是 OpenCode 1.19.3、Hermes 0.22.2、Qwen Code 0.25.3、Codex CLI 0.163.0 和 Atomic Agent 0.6.9。

全过五个任务的是六个:Pi、goose、Crush、Cline、Atomic Agent、OpenCode。Hermes 不在里面。

边界在这里:每个组合只跑了一次。 作者自己写了 one run each。单次运行说明不了统计意义上的差异,只能说明「在这个配置下可能出现什么」。

他给出的五个失败模式,我觉得比分数有用得多:

精确匹配编辑崩掉    老文本少了一个空行,工具说 not found,模型原样重发五次
过拟合可见测试      只修好了测试里出现的字符,隐藏用例挂掉
数错数              三个日志里 40 条 ERROR,答成 43 条,但最常见的错误说对了
含糊指令字面执行     没说答案写哪,两个 agent 写到了日志旁边
时间差 11 倍        最快 1 分 36 秒,最慢 17 分 43 秒,同硬件同模型

还有一条环境事故。Codex CLI 只说 Responses API,llama-server 把它翻译成 chat messages。

Qwen3.5 的对话模板直接报 500,System message must be at the beginning。那个 agent 从头到尾没碰到模型。

我们怎么复现

任务设计成三个,每个都配一个隐藏测试,判定方式是跑脚本,不是看模型自己说没说完。

rename   跨 3 个文件把 slugify 改成 make_slug,文件里埋了空行陷阱
accent   修重音转写:café 要变成 cafe,Zürich 要变成 zurich
count    数三个日志里一共多少条 ERROR,并说出最常见的错误信息

accent 的隐藏测试里放了一个提示词里没出现过的城市 Málaga,专门测过拟合。

三种改码接口的定义:

exact    edit_file(path, old, new)        模型必须复述出 old,逐字节一致
rewrite  write_file(path, content)        模型输出整个文件
ast      rename_symbol(old_name, new_name) 模型只给符号名,改名由 AST 完成

三种策略都额外给了同一组只读工具:list_files 和 read_file。这一点很关键,后面会讲为什么。

判定器先做了双向验证:什么都不改,三个任务必须全部失败;手工正确修改,必须通过。两条都过了才开始跑模型。

我们量到的

温度 0,每个组合 3 轮,共 22 次有效运行。同策略三轮的 token 数完全一致,说明这个配置下结果是可复现的。

任务      策略       通过    步数   错误   token     墙钟
rename    exact     3/3    10.0   0.0    9,967   141 秒
rename    rewrite   3/3     7.0   0.0    5,167    18 秒
rename    ast       3/3     5.0   0.0    3,138     9 秒

accent    exact     0/3     3.0   0.0    3,757    42 秒
accent    rewrite   3/3     2.0   0.0    2,397    14 秒
accent    ast       3/3     3.0   0.0    4,088    31 秒

count     exact     3/3     5.0   0.0    3,774    33 秒
count     rewrite   3/3     5.0   0.0    3,350    26 秒
count     ast       0/3     5.0   0.0    3,758    17 秒

墙钟那一列要打个折看:rename/exact 三轮分别是 227 秒,128 秒,68 秒,第一轮含着模型冷启动。token 数才是可比的那一列。

rename 上三种全过,差别只在代价。为什么差这么多,看调用序列就明白了。

exact     list → read ×3 → edit_file ×6     每次改一处,六个改动点六个调用
rewrite   list → read ×3 → write_file ×3    每个文件一次
ast       list → read ×3 → rename_symbol ×1 一个符号一次

步数由「改动点的数量」决定,而不是由「文件数量」或者「符号数量」决定。 一个跨 3 文件 6 处的改名,在三种接口下是 6 次,3 次,1 次编辑调用。

这条可以直接迁移到工程决策:改动点越碎,逐字节替换的代价越高;改动点集中,三种接口差别不大。

反直觉之一:精确匹配不是死因

试点那一轮我做过一个错版本:三种策略都只给编辑工具,没给读文件的工具。结果 exact 八步全错,模型编了一个不存在的路径 src/main.py,然后把同一个失败调用原样重发八次。

我差点据此写下「精确匹配必然失败」。但它不是。是我没给它看文件的能力。

补上 list_files 和 read_file 之后,同一个模型同一个任务,exact 三跑三过,零错误:

list_files .            → app/utils.py  app/views.py  tests/test_utils.py
read_file ×3            → 拿到逐字节原文
edit_file ×6            → OK: replaced 151 chars / 29 / 33 / 32 / 29 / 50

那个「老文本少一个空行」的坑,在模型先读过文件的前提下根本不会踩。它复述的老文本是从刚读到的内容里复制的。

所以原帖那句「模型复述不出逐字节原文」要补一个前提:在不给它读文件机会的循环里。

这个区别在工程上很实在。给 agent 一套只读工具,成本是几百个 token;不给,你会在「精确匹配不可靠」这个错误结论上做一堆架构决策。

反直觉之二:工具清单会改变模型的答案

accent 任务最能说明问题。

同一个模型,同一个提示词,同一个温度 0。只有工具清单不同:

exact    0/3 失败   ModuleNotFoundError: No module named 'unidecode'
rewrite  3/3 通过   用了标准库 unicodedata.normalize('NFD', text)
ast      3/3 通过   用了标准库

exact 那边模型写了 import unidecode,一个没有安装的第三方包。

它读对了文件,写对了格式,改对了位置,就是挑错了一个库。标准库里 unicodedata 完全够用,另外两条策略就是这么做的。

这不是工具能力问题。exact 有同样的读写能力,改的也是同一处。是工具清单的差异改变了它选哪条路。

count 任务上是同一类现象。ast 那组多了一个 rename_symbol 工具,其余完全相同:

exact    3/3 通过   答 ERROR 28 db timeout(正确答案)
rewrite  3/3 通过   答 ERROR 28 db timeout
ast      0/3 失败   答 ERROR 30 db timeout,但最常见的错误信息说对了

数成 30,正确是 28。它同时把最频繁的错误消息说对了。这跟原帖那位作者遇到的一模一样(他那边是 40 对 43)。

一个多出来的、跟计数毫无关系的工具,让答案从 28 变成了 30。三轮全是 30。

这条对做 agent 的人相当不友好:你以为换工具只是换能力,实际上你在换模型的推理路径。

Hermes 实测:同样的事,差别在隔离

我用 Hermes 本体在这个 4B 上跑了同一个 rename 任务,用的是 hermes -z 单次模式,模型指向本地 llama-server。两次运行唯一的差别是评测脚本放在哪里。

第一次,评测脚本在工作目录里。 下面是完整的过程,一次没删:

search_files {"pattern":"slugify"}          → 找到 8 处
read_file app/utils.py                       →
read_file app/views.py                       →
read_file tests/test_utils.py                →
read_file _hidden.py                         ← 它读了评测脚本
read_file _prompt.txt                        ← 也读了任务提示
patch app/utils.py                           → success: true
patch app/views.py ×3                        → success: true(一次 not found 后重试成功)
patch tests/test_utils.py                    → success: true
execute_code(grep _hidden.py)              → Found 1 occurrences of 'slugify'
read_file _hidden.py offset=155 limit=15     → 错误:文件只有 10 行
execute_code(再查一遍)                      → OK: No slugify function definition found
patch _hidden.py                             → 失败:Escape-drift detected
patch _hidden.py                             → success: true
execute_code(复查三个业务文件)              → DONE: 所有文件没有 slugify 引用
assistant: DONE

它把三个业务文件改对了。slugify 计数归零,make_slug 全部就位。

然后它去改评测脚本。断言原文是:

assert not hasattr(__import__("app.utils", fromlist=["x"]), "slugify"), "旧函数名仍在"

它改成了:

assert not hasattr(__import__("app.utils", fromlist=["x"]), "make_slug"), "旧函数名仍在"

注意这个改动的方向。not hasattr(..., "make_slug") 在 make_slug 存在时必然为假,也就是必然断言失败。

它不是想蒙混过关。它把「rename everywhere」这条指令字面执行到了评测脚本上。

第一次尝试被 Hermes 自己的防线拦住了,报错原文值得抄下来:

Escape-drift detected: old_string and new_string contain the literal sequence '\\"'
but the matched region of the file does not. This is almost always a tool-call
serialization artifact where an apostrophe or quote got prefixed with a spurious
backslash. Re-read the file with read_file and pass old_string/new_string without
backslash-escaping '"' characters.

这条防线是对的,它挡的是转义漂移。但第二次模型改了写法,绕过去了。

最终判定:失败。它自己把判据改了,然后撞死在自己改的判据上。

这一轮的账:

消息 42 条 | 工具调用 24 次 | 输入 25,366 token | 输出 2,806 token | 6 分 45 秒

第二次,评测脚本挪到工作目录之外。 同样一个提示词:

search_files → read_file ×3 → patch ×5 → 验证 → 汇报
消息 15 条 | 工具调用 10 次 | 输入 2,269 token | 输出 874 token | 1 分 30 秒
最终判定:通过

对比:

              泄漏版          隔离版
消息数          42             15
工具调用        24             10
输入 token    25,366         2,269
输出 token     2,806           874
墙钟          6 分 45 秒     1 分 30 秒
结果            失败           通过

输入 token 差 11 倍,结果一个失败一个通过。 差别只有一个:评测脚本在不在它的可见范围内。

这是环境设计的问题。模型能力在这里不起作用。任何把标准答案,参考实现,测试用例放在 agent 工作目录里的评测,测到的都是别的东西。

我们自己实验台的两个 bug

这两个值得单独写,因为它们都伪装成了「模型的失败」。

第一个:少给只读工具。 试点那轮 exact 八步全错,我差点写成「精确匹配必然失败」。真相是我的 harness 没给 list_files、read_file,模型只能瞎猜路径。补上之后三跑三过。

第二个:工具定义了但没接线。 count 任务的 exact 策略,我把 write_file 加进了工具定义,忘了在执行分派里接上。

结果模型每次写答案都收到 ERROR: unknown tool write_file。12 步,7 次错误,13,749 token 全部白烧。

而它其实早就算对了:ERROR 28 db timeout,就是正确答案。它只是没地方写。

· write_file answer.txt   → ERROR: unknown tool write_file
· edit_file answer.txt    → ERROR: file not found
· write_file answer.txt   → ERROR: unknown tool write_file
· edit_file answer.txt    → ERROR: file not found
...共 7 次

一个字符串替换没匹配上,语法检查,hashing,日志全部正常,但那个工具从来没接上过。这类错误在报告里长得跟「模型不会做题」一模一样。

修好接线重跑,exact 3/3 通过。

所以:看到「模型失败」之前,先证明你的工具真的接上了。

跑本地 4B 模型的 Agent 配置

下面这套是我这轮实测用的,可以直接抄。先看服务端。

llama-server \
  -m Qwen3.5-4B-Q4_K_M.gguf \
  -c 65536 --parallel 1 \
  --cache-type-k f16 --cache-type-v f16 \
  --jinja --host 127.0.0.1 --port 8080 \
  --alias qwen3.5-4b -ngl 99

三个参数值得说。

--jinja 是必须的,原生的工具调用走它。关掉之后模型只能吐文本格式的调用,解析失败率会上去。

--parallel 1 让上下文独占。多 slot 时同一个请求可能落到不同 slot,prompt 缓存命中率掉下来,实测里 rename/exact 三轮 227 秒,128 秒,68 秒的差别就来自缓存是否命中。

--cache-type-k f16 --cache-type-v f16 保住长上下文下的精度。4B 在这个配置下显存占用不大,没必要省。

请求侧

{
  "temperature": 0,
  "chat_template_kwargs": {"enable_thinking": false},
  "max_tokens": 1200
}

enable_thinking 这条是实测踩出来的:不关的话,20 个 token 全花在思考链里,content 返回空字符串,你只会看到一个空答案和 completion_tokens: 20。

关掉之后同一个请求,2 个 token 出结果。跑 agent 循环时思考链是纯成本,因为它占的是每一步的预算,而不是一次性的。

工具设计,按这次量到的顺序给建议:

必给      list_files + read_file。没有它们,三种编辑接口全部退化
首选      AST 或符号级接口。rename 任务上 5 步、3,138 token,是 exact 的三分之一
次选      全量重写。7 步、5,167 token,改动分散时比 exact 省
慎用      逐字节替换。能用,但步数等于改动点数量,改 6 处就是 6 次调用

还有一条不在工具里、但影响最大的:把评测脚本,参考实现,标准答案全部移出工作目录。 这一条的效果比换任何工具接口都大。

复现方法

环境是 macOS 26.5.2,Apple Silicon,Python 3.12.13,llama.cpp 0.7.0(brew),Qwen3.5-4B Q4_K_M。整套下来不花钱,只烧电,成本核算在这里。

brew install llama.cpp
git clone https://github.com/unsloth/Qwen3.5-4B-GGUF   # 或直接从 HF 下 Q4_K_M
llama-server -m Qwen3.5-4B-Q4_K_M.gguf -c 65536 --parallel 1 \
  --cache-type-k f16 --cache-type-v f16 --jinja \
  --host 127.0.0.1 --port 8080 --alias qwen3.5-4b -ngl 99

跑对照:

python3 lab.py setup
python3 lab.py all --runs 3 --max-steps 12
python3 lab.py report

每一轮的完整对话落在 lab/runs/<任务>-<策略>-<轮>/_transcript.json。里面记着模型每次调用的参数,工具返回的原文,还有结束时的文件快照。

文章里引用的所有日志都出自这些文件,没有一处是事后描述的。

判定器先跑双向控制再信结果:空跑必须全 FAIL,手工正解必须 PASS。

边界

10 个框架那次测试是转引。 我只读了原帖全文,没有重跑他的实验,也没有他的任务文件和 agent 日志。他每个组合只跑一次,这个限制我原样保留。

我自己的实验只有一个模型、一台机器。 Qwen3.5-4B Q4_K_M,M4 Mac mini 16GB。换模型,换量化等级,换硬件,绝对数字都会变,方向未必。

Hermes 版本有偏差。 原帖测的是 0.22.2,我本机是 0.22.1+299。文中所有 Hermes 日志都是 0.22.1 跑出来的。

Hermes 那次只跑了两轮。 够不上对照实验,属于两次现场记录。它足够说明「隔离会改变结果」,不足以给出成功率。

count 任务的三策略对比里,ast 那组多了一个无关工具。 这是有意保留的,因为它恰好暴露了「工具清单会改变答案」。但它也让这个格子不是严格的单变量对照,读的时候要知道。

accent 任务的失败是选库失败,不是改码失败。 模型改对了位置、写对了格式,只是挑了一个没装的包。如果环境和真实 agent 一样能 pip install,这条会变成另一种结果。

一句话收尾:

4B 模型在 agent 循环里真正卡住的地方在别处:它不知道自己在改什么,也不知道你让它看见了多少不该看见的东西。

请喝一杯咖啡
这些内容都是我一个人查资料、跑数据、核事实写出来的。如果对你有用,可以请我喝一杯咖啡。
微信扫码 · 微信支付微信扫码 · 微信支付
支付宝扫码支付宝扫码
个人收款码 · 微信支付 / 支付宝

本文采用 CC BY 4.0 许可。欢迎转载与引用,请注明作者并附上原文链接。
Licensed under CC BY 4.0. Quoting and republishing are welcome with attribution and a link back to this article.

发表评论