不用 TypeSafe:本地 DuckDB 跑 Jev
上一篇Jev 的「不会幻觉」,官方自己在脚注里打了折,我把 TypeSafe 的营销话术拆了一遍。评论区最常被问的一句是:不买单他们的托管 API,这套东西自己能不能跑起来?
这篇就是答案。能。而且跑通之后我盯着一个数字看了很久:
{"input_tokens":57,"output_tokens":2}一次问了两个问题,输出 token 数是 2。不是 200,不是 20,是 2——每个问题恰好消耗一个 token。这个模型全程一个字没写,但它把两个问题的答案连同概率都交回来了。
● ● ●
先交代一下"读"是怎么发生的
上一篇文章里说过原理,这里只留结论:不生成 token,改读 Answer: 之后那一个 token 的 top-k logprobs 分布。答案在结构上就不可能落在你声明的选项集外面,所以"格式解析失败"这一整类错误不存在。
TypeSafe 的 Jev 是这个思路的闭源实现,要 early access 申请 API key;MotherDuck 这周上了 prompt_jev(),要云账号。我两个都不想等——读 logprobs 是任何 OpenAI 兼容端点都有的能力,那就用本地模型搭一套一样的契约。
我的本地栈就三层,每层都能单独跑:
- 01推理后端
:Qwen3.5-4B(Q8 量化)跑 llama.cpp,开 logprobs,CPU,一台普通工作站; - 02HTTP 传输
:LuaJIT 的 FFI 直接 dlopen libcurl,一次调用零 fork; - 03SQL 层
:DuckDB 的 duckdb-luajit 扩展装一个 jev_askLua 模块 + 一套展开宏。
本地三层栈:SQL 层逐行调用、FFI 传输、后端只读 logprobs
第 1 层是"假 Jev"(它不是 TypeSafe 的模型),第 2、3 层是真干活。
● ● ●
用法:一条 SQL 扫完一整列
装完模块之后,SQL 长这样:
SET threads = 1; -- 为什么必须 1,下文第三个坑
SET VARIABLE q = (SELECT questions FROM jev_questions(
'Classify the topic of this news article.',
['World','Sports','Business','Sci/Tech']));
SELECT id,
jev_choice(r,'q') AS topic,
round(jev_conf(r,'q'),3) AS conf
FROM (SELECT id, jev(body, getvariable('q')) AS r FROM tickets)
ORDER BY id;
jev_questions 把选项列表拼成服务端要的题面,jev 对每一行发一次请求、拿回 JSON,jev_choice/jev_conf 是两句 json_extract 的宏。跑完就是两列:每行的分类、每行的置信度。0.8 以上走自动路由,以下转人工——门槛你想调多少调多少,一行 WHERE 的事。
● ● ●
实测:同一个基准,本地 87.3%
MotherDuck 的 prompt_jev() 博客给了一张表:AG News 10 万行,89% 准确率,40 秒,5 毛钱。他们帖子里附了完整的复现 SQL,采样方式写到 seed=43。
我抄了他们的口径,换成本地后端,先跑 1000 行:
| 本地 Qwen-4B + DuckDB(我测的) | 87.3% | 10 行/s | 约 2.8 小时 |
分类别看,Sports 最稳(F1 0.951),Business 最弱(precision 0.799——新闻里"经济"和"国际政策"本来就爱互相串)。
两个诚实的注脚。第一,87.3% 和 89% 接近是同档,不是"我复现了 Jev"——后端是 4B 的本地模型,不是 TypeSafe 那个,数字巧合级参考。第二,250 倍的吞吐差全在后端:托管是他们的 GPU 集群,我这是 CPU 跑 4B,每行 100 毫秒里传输层只占极小一截。集成层没拖后腿,是推理在排队。
● ● ●
三个厂商博客不会写的坑
坑一:200 行全绿,1000 行崩 104 次。
第一版跑 200 行,一次错误都没有。信心满满上 1000 行,日志里冒出 104 条 too many callbacks。查了半天:LuaJIT 的 FFI 给每一个"从 Lua 传给 C 的函数"生成一个新的 C trampoline,这个池子有上限——每行调用都传一次 Lua 回调函数,池子就随行数增长,到 1000 行耗尽。修法是模块级存一个函数,cast 成 C 指针只做一次,之后每行传这个 cdata:1500 次调用零 trampoline 增长,1000 行 0 错误。这类坑在 200 行规模下永远不显形,只有跑满千行才露头。
坑二:并行扫描,反而慢了 18%。
DuckDB 的本能反应是把扫描拆给 4 个线程,对普通 UDF 这是纯赚。但 FFI 状态不跨扫描线程共享,我实测了同一条 SQL:threads=1 是 10.02 行/s,threads=4 是 8.19 行/s——多给三倍资源,慢了 18%,0 失败,纯粹是线程在互相抢。per-row 外呼这类 UDF,SET threads=1 是纪律,不是调参。
坑三:门面要拆成两层,因为 DuckDB 不允许。
我第一版想把"选项列表 → 题面 JSON"和"发请求"写成一个标量宏,写不出来:DuckDB 的标量宏 body 里不能出现表子查询(那段 UNNEST + 聚合会被当表展开、绑定错位)。只能拆:列表转题面放表宏,发请求的宏 body 保持纯标量。约束挺硬,但绕过去之后形态反而更干净——题面是常量组装一次存变量,宏只管一行一行发。
● ● ●
边界,说清楚
本地这套的定位是范式验证 + 数据不出门的场景:涉密数据不能过 TypeSafe 的 API、不想按 token 计费、或者模型本来就私有,那 10 行/s 在"能不能用"和"跑不跑得完"之间,取决于你的行数和容忍度。100 行 10 秒,10 万行 2.8 小时,这个数字别骗自己。
吞吐要上去只有两条路:换 GPU 推理后端,或者回到托管 API——集成层(Lua 模块 + 宏)一行不用改,换的是第 1 层。
还有一条边界要明说:本地实现里概率守恒(四个选项概率加起来 = 1±1e-6)是测试用例硬断言的,18 条全过;而 TypeSafe 官方自己承认他们的 Jev 会出现同一张工单上 P(要退款)=0.72 与 P(不要退款)=0.47 并存、加起来 1.19 的情况——上一篇里提过。这是两套实现各自的账,我这边守住了的概率守恒,不代表范式本身保证了它。
再回头看那个让我盯着看了很久的 output_tokens: 2。生成式 LLM 干这个活,2 个 token 连一个 JSON 引号都写不完;读出头干这个活,2 个 token 就是两个完整答案的全部。差的不是这两个 token,是"写字"这个动作本身——拿掉它,格式错这一类错误就不存在了,剩下的只有判断错,而判断错这件事,87.3% 和 89% 说明,4B 的本地模型也够得着。
下一篇大概率把这套接上真实工单表跑一晚上,看看 0.8 门控的覆盖率在没标注数据上长什么样。
参考来源
- 01
本地实现源码与测试:github.com/alitrack/duckdb-luajit-libs(libs/udf/jev_ask.lua、jev_ask_macros.sql、curl_ffi.lua) - 02
基准数据:AG_NEWS_BENCH.md(AG News N=1000,reservoir seed=43,与 MotherDuck 帖内复现 SQL 同口径) - 03
MotherDuck 官方:Introducing prompt_jev(): bringing Jev to Motherduck SQL(motherduck.com/blog/motherduck-supports-jev) - 04
TypeSafe 官方文档:docs.typesafe.ai(System One 契约、choice/score/noul、定价)