docjev 换本地引擎:4B 从 24/37 到 40/40,没换模型
同一个 4B、同一台机器、同一批文档,只把「怎么读出它的决定」换了一种,分类从 24/37 变成 40/40。
上一版我把 docjev 的决策层换成本地模型,跑出分类 64.9%、切分 0/8,写下的解释是「本地引擎能力差」。这个解释是错的。这一版把它改对,顺手拿到了比上一版有用得多的东西。
docjev 是 9 月 19 日出现在 GitHub 上的一个小仓库,作者是 LlamaIndex 的联创 Jerry Liu。它做两件事:把一批混合文档整篇分类;按页把一包里的小文档切开。设计主张是解析层和决策层分开——解析用本地的 liteparse(不调云端、零解析费用),决策交给 TypeSafe 的 Jev 云端,也可以自己换。地址是 github.com/jerryjliu/docjev
docjev 到本地模型的调用链
● ● ●
换引擎确实不用改代码
这一点上一版就验证过,如今还是这次实验里最稳的结论。决策层走 typesafe-sdk,SDK 支持用环境变量覆盖服务地址,docjev 自己没往里传 base_url,于是两行环境变量就够:
export TYPESAFE_API_KEY=local
export TYPESAFE_BASE_URL=http://127.0.0.1:18090
请求就这样走到我本地的 jev-clone(Rust 重写的 Jev 服务端),后端是 Mac Studio 上 llama.cpp 跑的 Qwen3.5-4B。字段逐项对得上:答案数组、token 用量、模型名字段都在;请求里写的 model: jev-1.13.0 被本地后端忽略、回显它自己的模型名,没有副作用。
一行源码没动,这是全套实验的前提。
● ● ●
上一版那两组难看的数字
语料是仓库自带的 40 份真实美国政府公开出版物(IRS 表格、财政部拍卖公告、美联储新闻稿、CDC 与 FEMA 公开件),外加 8 个混合包共 116 页。全部跑完:
分类的失败形态很整齐:13 个错误全部落进 other,一个都没串到别的类去——financial_report 错了 5 个、press_release 错了 8 个,全都是「真值 → other」。它读不进 rules 里那段排除条款。
我当时的解释是:小模型读不懂规则文本,所以往 other 里躲。听起来很合理。它是错的。
● ● ●
同一个模型,只换了「怎么读它的答案」
docjev 的决策层有两种接法。一种是作者默认的:让 Jev 直接给出类别概率,读它的答案。另一种是仓库自带的 engines/openai.py:把同一个 prompt 发给通用模型,让它写出结论(结构化输出)。
上一版我走的是第一种,而且是拿一个通用 instruct 模型去走第一种。这一版我把通道变量单独拎出来,重新跑:
| 40/40 | 8/8 | |||
| 40/40 | ||||
| 40/40 |
模型、机器、语料、rules、打分脚本全都没动,只换了读出方式。 分类三遍全部满分(每类 8/8),一次都没漏。
答案本身很整齐:切分三遍是 8/8、6/8、7/8,均值约 7/8——和真 Jev、GLM 5.3 是同一档。切分那两处失手也是模型侧的老问题:一遍里模型对某个包什么都没吐(空响应),另一遍把两个相邻类别的边界多并了一页。读数通道那条 0/8 不是模型做不到,它只是没人问它。
为什么读数通道会把答案压扁
这个机制值得记下来,因为它是可复现的、有物理原因的。
读数通道的做法是:把「A/B/C/D/E」五个字母摆在模型面前,读它下一个 token 的概率分布。本地 4B 走这条路时,即使判对了,答案里 other 的平均概率也有 0.414;判错的那 13 份,错误方向全部是「真值 vs other」这一对——financial_report 和 press_release 之间几乎从不互混。而错的那些有个共同特征:低置信,平均最大概率 0.587,判对的 24 份是 0.799。
再看选项的排布顺序(按码点):financial_report / legal_notice / other / press_release / tax_form。other 正好在正中间那一格。
三件事拼起来是一句话:通用 instruct 模型没有被训练成「在答案位置把决定读成字母概率」,于是那五个字母的 softmax 退化成了字母先验,中间槽位成了吸铁石。 Jev 小模型之所以能靠这套打出高分,是因为它就是被训练来做这件事的。
jev-clone 自己的代码里写着这句话:它返回的是一个「条件选项分数,不是校准过的决策置信度」。只是这句话没变成对外可见的信号——未校准的模型接进来,它就静默地降级算错了。这跟我自己的项目里那条规矩冲突(校验失败要拒绝服务,不能降级静默算错),所以我把这条也写进报告:同一条规矩得延伸到模型维度。
● ● ●
Mac 上那个 35B:三遍都拿满分
顺着这个结论,我把 Mac Studio 上另一个模型也接上了:qwen3.6:35B,Qwen3.6 的 35B 混合专家版本(每次激活约 3B 参数),Q4_K_M 量化,走 Ollama 服务。
| 40/40 | |
| 8/8 | |
| 116/116 = 1.0000 | |
| 1.0000 / 1.0000 / 1.0000 | |
这是目前所有列里切分最好的成绩——超过了真 Jev(7/8、F1 0.9846)和 GLM 5.3(7/8)。真 Jev 每次都切错的那个包(p001,press_release [7–10] 被从中间多切一刀),它切对了。
三遍跑下来,输入 221,744 token、输出 34,946 token,三遍的数字一模一样,逐包分段也逐包相同——生成参数钉成了 temperature 0,三次跑出来的是同一份答案,这一点得说清楚:它证明的是可复现,不是「三次独立采样都赢了」。
顺手捡到一个更干净的对照
这一轮我还拿到一个模型:TypeSafe 的 LLM Gate 上有一个 openjev-qwen3.6-35b-a3b-nvfp4——同一个 Qwen3.6-35B-A3B 基座,NVFP4 量化,走读数通道。
| 0/8 | ||||||
| 8/8 |
分类两边都满分,切分一边 0/8、一边 8/8。 基座是同一个,量化不同、服务不同、通道不同。这是第二条独立证据——第一条是同一台机器、同一个模型文件只换通道(0/8 → 6~8/8),这一条的基座连模型都没换。
同一模型的两条通道
同一个 Gate 上还有一个 4B 的读数模型 semif-qwen3.5-4b-fp8,分类也是 40/40、切分 0/8。所以「4B 太小」这个解释也被排除了:差别在模型是否为槽位读数训练过。
顺带一提:读数通道在切分上整体偏弱
把手上所有列摆在一起,切分这一格的分布很说明问题:
训过的 Jev 拿 7/8,没训过的 0/8。生成通道这边,从 4B 到 35B 都在 6/8 以上。「每页问一次、逐个读边界」这套方式,比「整包一次让它写出分段」更容易崩。 这跟模型大小无关。
● ● ●
上一版还有一个发现站得住:那个没接上的回退触发器
这部分与通道无关,是 docjev 自己的工程事实,仍然成立。
上一版第一遍跑切分时,8 个包全军覆没,全部以 HTTP 502 返回。原因不在 docjev:它把整个包一次性发给决策层(十二页的包,请求体 78 KB,单包状态约 9,334 token),而我的本地后端当时是 8k 上下文。它在自己的字节预算检查里是过的——那套预算是照着 Jev 的 64k 上下文留的——于是照发,被后端顶了回来。
更值得看的是失败处理:docjev 里有一段上下文回退逻辑,但只在 400 / 413 / 422 这三种状态码上触发。同一个错误被包成 502 之后,那段回退一次都没启动过。逻辑写对了,触发器没接上。
我把这个做成了可复用的数字:固定同一份状态,只增加问题的个数。
两个点拟出同一条式子:状态 token + 问题数 × 约 1.1k ≤ 上下文。而问题本身的 JSON 只有 1.4 KB(约 350 token)——多出来的部分没被省掉。这件事在 jev-clone 自己的 README 里也有记录:它设计时预期前缀复用达到 3 倍以上,在共享端点上实测只有 0.96 倍。
把后端从 8k 提到 64k 重启后,同样 8 个包有 6 个通过。用 llama.cpp 的 /tokenize 量了每个包的精确状态 token:
失败的正好是超过界线的两个。这台 llama.cpp 是 4 个并发槽位共享一个 64k 的 KV 缓存,所以要装下的是「槽位数 ×(状态 + 一个问题)」。后端日志里那句 decode: Context size has been exceeded. off = 686, n_batch = 1 也印证了:报错发生在解码过程中,入口并没有拒绝。
可搬走的一条经验:算这类文档 AI 的容量,要按「状态 token × 并发槽位」算,页数只是表象。 这份语料上单包 16.3k 能过、17.3k 过不去;换算下来实测 4.14 字节一个 token,密集的美国政府表格大约 800–900 token 一页。
需要说清楚的是:8k 与 64k 这两轮、以及把后端从 8k 提到 64k,都是我改的旋钮。默认配置下切分跑不通,是我这条链路的环境事实,不是 docjev 的缺陷。
● ● ●
另一条独立的坑:别信 200
这一轮最值钱的教训跟分数无关,上一版写过,仍然建议每个接第三方模型的人都先踩一遍。
docjev 的对照引擎调的是 OpenAI 的 Responses API,结构化输出写在 text.format 里(严格 json_schema)。我把地址指到本机自建代理,接口回 200、状态 completed,一切正常——但模型把那条格式要求直接忽略了,回了段散文。程序拿到的不是 JSON,一行 json.loads 就挂了。
四条路我都试了:
/v1/responsestext.format 严格 schema | |
/v1/chat/completionsresponse_format 严格 schema | |
/v1/chat/completionstools + 强制 tool_choice | |
/v1/chat/completionsresponse_format: json_object |
两条:别信 200,200 只代表请求被收下了;也别拿短请求去验,短 prompt 下模型会自己「配合」出 JSON,换成真实长度的请求立刻原形毕露。
● ● ●
作者那组数字,我独立复跑了一遍
拿到 TypeSafe 的 key 之后,我把同一批冻结语料直接打到真 Jev 云端(api.typesafe.ai,模型 jev-1.13.0),用仓库自带的打分脚本对同一份冻结答案。
| 40/40 | ||
| 7/8 | ||
| 116/116 | ||
| 0.9846 | ||
| $0.005028 | ||
| $0.006635 |
成本吻合到小数点后六位,48 次请求合计 $0.011663。唯一失败的包也是同一个:p001。
唯一对不上的是延迟,而问题出在口径上。 作者从美国实测分类 p50 138.6 ms、切分 209.6 ms;我从国内(WSL,出口走代理)实测分类 1114.7 ms、切分 2239.4 ms,8 倍和 10.7 倍。这段差值里跨太平洋的往返和服务端排队各占多少,我拆不开。引用「Jev 138 毫秒」这类数字时,得说清楚测的人在哪。
● ● ●
一分钱不花的那一列,和最贵的那一列
把七列放在一起,按我这台机器上的实测:
延迟这一列不能并排读:Gate 那两列是服务端排队后的读数延迟,真 Jev 从国内出去要跨太平洋,本地那几列是攒够一整轮的结果。能并排读的是同一台机器上跑的那几行。
代码之外还多出一层意思:这套基准的「强模型档位」是饱和的。GLM 5.3 跟作者那栏的 GPT-5.6 Luna、跟真 Jev,三列的数一个不差,连唯一失败的包都一样。40/40 与 7/8 是这个任务上强模型的本底,别读成某一家模型的独特本事。它给你的是一个能把自己那把引擎塞进去的尺子,不是一个排名。
● ● ●
回到那两行环境变量
换引擎是一行环境变量的事,docjev 这块做得很薄,这是它做对的地方。但上一版我漏了一个前提:能接上,不等于接上就能用。
我在同一个模型上花了整整一轮才发现:读数通道要的那个「概率」,只有在模型被训练过读概率的时候才是概率;通用模型接进去,它会退化成一个字母先验,还会静默地往中间那格倒。而生成通道更朴素——你让它写出结论,它就写。
所以现在你问我该给 docjev 配哪把引擎,我的答案是:先别挑模型大小,先挑通道。 手上有个 4B,走生成通道就能拿到 40/40 和 6~8/8;手上有 35B,走生成通道能拿满分。反过来,一个 35B 走错通道,切分是 0/8——这一格的差距比「4B 换 35B」大得多。
至于那两行环境变量,它还是照旧管用:今天你手上任何一把 OpenAI 兼容的引擎,改地址就能接进来,接进来之后,尺子是现成的。你手上那把引擎,走读数通道是什么样?接进来量一遍就知道。
● ● ●
参考来源
- 01
docjev 仓库(作者 Jerry Liu):github.com/jerryjliu/docjev - 02
冻结语料与作者对照记录:仓库内 datasets/real-small/v1/ 与 benchmarks/results/real-small-v1-run01/report.md - 03
评分指标实现:仓库内 benchmarks/metrics.py - 04
Jev 官方模型文档:docs.typesafe.ai - 05
本地决策层:jev-clone(Rust 重写的 Jev 服务端)、Mac Studio 上的 llama.cpp 与 Ollama 服务 - 06
生成通道跑法:仓库自带 engines/openai.py + 强制 tool call(本机自建适配层,零改仓库源码);读数通道为 docjev 默认引擎接本地 jev-clone - 07
各列原始结果(含每次请求的 token、延迟明细):未公开,需要可私信交流