alitrack

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 到本地模型的调用链

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 页。全部跑完:

指标
本地 4B(读数通道)
分类正确
24/37(64.9%;按 40 份全算是 60.0%)
切分整包精确
0/8
页准确率
27/116 = 23.3%
边界 F1
0.0976

分类的失败形态很整齐:13 个错误全部落进 other,一个都没串到别的类去——financial_report 错了 5 个、press_release 错了 8 个,全都是「真值 → other」。它读不进 rules 里那段排除条款。

我当时的解释是:小模型读不懂规则文本,所以往 other 里躲。听起来很合理。它是错的。

● ● ●

同一个模型,只换了「怎么读它的答案」

docjev 的决策层有两种接法。一种是作者默认的:让 Jev 直接给出类别概率,读它的答案。另一种是仓库自带的 engines/openai.py:把同一个 prompt 发给通用模型,让它写出结论(结构化输出)。

上一版我走的是第一种,而且是拿一个通用 instruct 模型去走第一种。这一版我把通道变量单独拎出来,重新跑:

通道
分类
切分整包
页准确率
边界 F1
读数(上一版那列)
24/37
0/8
27/116
0.0976
生成 · 第 1 遍
40/408/8
116/116
1.0000
生成 · 第 2 遍
40/40
6/8
100/116
0.9000
生成 · 第 3 遍
40/40
7/8
115/116
0.9688

模型、机器、语料、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)
切分整包精确
8/8
页准确率
116/116 = 1.0000
边界 P/R/F1
1.0000 / 1.0000 / 1.0000
分类延迟 p50
4810 ms
切分延迟 p50
35444 ms

这是目前所有列里切分最好的成绩——超过了真 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 量化,走读数通道。

服务
量化
通道
分类
切分
页
边界 F1
Gate(云端读数)
NVFP4
读数
40/40
0/8
111/116
0.7500
Mac(本地生成)
Q4_K_M
生成
40/40
8/8
116/116
1.0000

分类两边都满分,切分一边 0/8、一边 8/8。 基座是同一个,量化不同、服务不同、通道不同。这是第二条独立证据——第一条是同一台机器、同一个模型文件只换通道(0/8 → 6~8/8),这一条的基座连模型都没换。

同一模型的两条通道

同一模型的两条通道

同一个 Gate 上还有一个 4B 的读数模型 semif-qwen3.5-4b-fp8,分类也是 40/40、切分 0/8。所以「4B 太小」这个解释也被排除了:差别在模型是否为槽位读数训练过。

顺带一提:读数通道在切分上整体偏弱

把手上所有列摆在一起,切分这一格的分布很说明问题:

列
通道
切分整包
真 Jev 1.13(云)
读数
7/8
GLM 5.3
生成
7/8
本地 4B(三遍)
生成
8/8、6/8、7/8
Mac 35B-A3B(三遍)
生成
8/8、8/8、8/8
openjev-35b(Gate)
读数
0/8
semif-4b(Gate)
读数
0/8
本地 4B(上一版)
读数
0/8

训过的 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 之后,那段回退一次都没启动过。逻辑写对了,触发器没接上。

我把这个做成了可复用的数字:固定同一份状态,只增加问题的个数。

状态大小
每个请求最多答几个问题
8.5 KB(1,717 token)
5 个(第 6 个开始 502)
25.8 KB(5,242 token)
2 个(第 3 个开始 502)

两个点拟出同一条式子:状态 token + 问题数 × 约 1.1k ≤ 上下文。而问题本身的 JSON 只有 1.4 KB(约 350 token)——多出来的部分没被省掉。这件事在 jev-clone 自己的 README 里也有记录:它设计时预期前缀复用达到 3 倍以上,在共享端点上实测只有 0.96 倍。

把后端从 8k 提到 64k 重启后,同样 8 个包有 6 个通过。用 llama.cpp 的 /tokenize 量了每个包的精确状态 token:

包
页数
状态 token
结果
p001
12
9,334
通过
p003
15
16,283
通过
p007
16
8,304
通过
p005
20
17,727
502
p008
18
17,256
超时

失败的正好是超过界线的两个。这台 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/responses
 + text.format 严格 schema
200,格式被静默忽略,回散文
/v1/chat/completions
 + response_format 严格 schema
短 prompt 下碰巧合规,换成真实长 prompt 同样不兑现
/v1/chat/completions
 + tools + 强制 tool_choice
真的按 schema 返回
/v1/chat/completions
 + response_format: json_object
也能用(保底)

两条:别信 200,200 只代表请求被收下了;也别拿短请求去验,短 prompt 下模型会自己「配合」出 JSON,换成真实长度的请求立刻原形毕露。

● ● ●

作者那组数字,我独立复跑了一遍

拿到 TypeSafe 的 key 之后,我把同一批冻结语料直接打到真 Jev 云端(api.typesafe.ai,模型 jev-1.13.0),用仓库自带的打分脚本对同一份冻结答案。

指标
作者记录
我这次独立复跑
分类正确
40/40
40/40
切分整包精确
7/8
7/8
页准确率
116/116
116/116
边界 F1
0.9846
0.9846
分类决策成本
$0.005028
$0.005028
切分决策成本
$0.006635
$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 毫秒」这类数字时,得说清楚测的人在哪。

● ● ●

一分钱不花的那一列,和最贵的那一列

把七列放在一起,按我这台机器上的实测:

列
通道
分类
切分
页
边界 F1
分类 p50
切分 p50
真 Jev 1.13(云)
读数
40/40
7/8
116/116
0.9846
1114.7 ms
2239.4 ms
GLM 5.3
生成
40/40
7/8
116/116
0.9846
4584.3 ms
7916.9 ms
本地 4B
生成
40/40
8/8·6/8·7/8
116/116·100/116·115/116
1.0·0.9·0.9688
4496~4824 ms
12.5~14.3 s
Mac 35B-A3B
生成
40/40
8/8×3
116/116
1.0000
4810 ms
30.1~35.4 s
openjev-35b(Gate)
读数
40/40
0/8
111/116
0.7500
520 ms
2512 ms
semif-4b(Gate)
读数
40/40
0/8
87/116
0.4808
529 ms
7971 ms
本地 4B(上一版)
读数
24/37
0/8
27/116
0.0976
7661 ms
25861 ms

延迟这一列不能并排读: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 兼容的引擎,改地址就能接进来,接进来之后,尺子是现成的。你手上那把引擎,走读数通道是什么样?接进来量一遍就知道。

● ● ●

参考来源

  1. 01
    docjev 仓库(作者 Jerry Liu):github.com/jerryjliu/docjev
  2. 02
    冻结语料与作者对照记录:仓库内 datasets/real-small/v1/ 与 benchmarks/results/real-small-v1-run01/report.md
  3. 03
    评分指标实现:仓库内 benchmarks/metrics.py
  4. 04
    Jev 官方模型文档:docs.typesafe.ai
  5. 05
    本地决策层:jev-clone(Rust 重写的 Jev 服务端)、Mac Studio 上的 llama.cpp 与 Ollama 服务
  6. 06
    生成通道跑法:仓库自带 engines/openai.py + 强制 tool call(本机自建适配层,零改仓库源码);读数通道为 docjev 默认引擎接本地 jev-clone
  7. 07
    各列原始结果(含每次请求的 token、延迟明细):未公开,需要可私信交流