不让模型写作文,直接从它脑子里读答案:Jev 决策在 .NET 的两条路线

Jev 类型化决策的两条NET实现路线架构对照

这一周翻 GitHub 的时候,看到两个前后脚冒出来的项目更新,碰巧都指向同一件事:让 AI 直接给出程序能用的判断,而不是生成一段给人看的文字。

很多业务场景要的其实不是一段话。客服系统收到一条消息,要的是"转账单组还是技术组";审核系统拿到一张图,要的是"过还是不过";工单系统要的是一个严重度评分。答案就是一个选项、一个概率、一个分数,程序拿到就走自己的逻辑,没有人去读它。

以前这种需求的做法是:让大模型输出 JSON,程序解析,格式错了就重试。用过的人都知道这条路有多烦。最近流行的 Jev 风格做法换了个思路——不让模型"写"答案,直接从模型的输出层把概率"读"出来。

这篇文章聊聊我看到的两条 .NET 实现路线:一个是 TensorSharp 用 26B 扩散大模型做的 /v1/systemone,最近刚支持图片输入;另一个是 IoTSharp 开源的 Sezika,用小编码器加决策头,跑在 Native AOT 里。两条路几乎是反着走的,放在一起看挺有意思。最后我把我翻源码整理出来的请求/响应字段对照表也放在文章里,给想接入的人省点时间。

先说说上游发生了什么

9 月 22 日,vLLM 合并了 PR #57250,把 Google 的文本扩散模型 DiffusionGemma 改造成了一台结构化读取引擎。

扩散语言模型和自回归模型不太一样:它生成文本时是在一块叫 canvas 的区域里反复去噪,每次迭代刷新整段内容。vLLM 的做法是把这个机制反过来用——canvas 宽度从默认 256 缩到 16 或 32,刚好放下一小段 JSON;然后往 canvas 里预填一份答案模板,比如 "billing": "?",只在答案的位置留空;最后只跑一步去噪,直接读那个空位上各个候选词的 logprobs,归一化一下就是概率分布。

全程没有逐 token 生成,没有 JSON 解析,自然也没有格式错误和重试。一次前向,出来的就是三种类型化结果:布尔概率(noul)、有限候选选择(choice)、有序评分(score)。

Google Cloud 的 Karl Weinmeister 在 Medium 上写了完整的教程,还把它部署到了 Cloud Run 上。思路本身不复杂,但确实打开了一个口子:原来大模型不一定非要"说话",它也可以只"表态"。

路线一:TensorSharp,26B 大模型做决策,最近还会看图了

TensorSharp 是一个纯 .NET 写的本地 LLM 推理引擎,作者 Zhongkai Fu。vLLM 的 PR 合并第二天(9 月 23 日),它的 PR #225 就进了主线,实现了 Jev 兼容的 POST /v1/systemone 端点,底层跑的是 26B-A4B 的 DiffusionGemma。

它同样用 seed canvas 单步读取,但多做了一件事:稀疏输出投影。vLLM 原型是算完整词表的 logits 再挑需要的几个,TensorSharp 只计算答案位置和候选标签对应的那几行,不分配完整的 canvas×词表张量。按项目文档的说法,同硬件下比 LocalJev 那种"生成 JSON 再解析"的路子快 4 到 5 倍。

不过我觉得真正值得说的是图片输入,这是 vLLM 原型和 Jev 云端服务都没有的能力。

请求里加一个 images 字段(最多 8 张,base64 内联),图片就直接参与决策。这里有个工程上的小麻烦:市面上所有 DiffusionGemma 的 GGUF 文件都是纯文本的,转格式的时候视觉塔被丢掉了,也没有官方的 mmproj 投影文件。TensorSharp 的解决办法挺直接——上游官方 checkpoint 的第 11 个 safetensors 分片(2.84 GB)里装着视觉塔全部 356 个张量,那就直接读这个分片,不转格式。

另外有个细节我挺欣赏:如果服务器没加载视觉塔,收到带图的请求会返回 503 明确拒绝,而不是假装处理了;远程图片 URL 和本地文件路径也一律拒绝,免得推理服务器被人当成免费代理或文件读取器。

项目里自带的红绿灯例子(docs/examples/jev-traffic-light.json)设计得很巧。请求里嵌了一张合成的绿灯照片,但 state 文本只说"车辆正在接近路口",一个字没提灯:

{
  "model": "jev-latest",
  "state": "Dashcam frame captured as the vehicle approaches the intersection.",
  "images": ["data:image/png;base64,iVBORw0KGgo..."],
  "questions": {
    "signal": {
      "type": "choice",
      "instructions": "Which lamp of the traffic light is lit?",
      "criteria": { "red": "The top lamp is lit", "yellow": "The middle lamp is lit", "green": "The bottom lamp is lit" }
    },
    "stop": { "type": "noul", "instructions": "Must the vehicle stop before entering the intersection?" }
  },
  "samples": 1,
  "seed": 42
}

模型回答绿灯、不用停车——文本里没有这个信息,答案只能来自像素。更妙的是文档里写了对照:同一个请求把图去掉,模型会很有把握地回答"红灯,必须停车"。等于自带了一个反事实实验,证明模型真的在看图,而不是靠文本瞎猜。

路线二:Sezika,不用大模型,编码器加决策头就够了

IoTSharp 团队的 Sezika 走的是完全相反的方向:既然只要一个判断,那为什么要用一个 26B 的生成模型?

它的底座是 mmBERT-base 级别的多语言编码器(固定的 Laya 检查点),上面接类型化决策头。输入进来,编码器过一遍,决策头直接给每个候选打分。没有 canvas,没有去噪,是真正的非自回归——连"一步"都不需要。

这个项目最吸引我的是它的纯粹程度。整个推理栈都是 C# 写的:embedding、attention、RoPE、归一化、MLP、决策头,CPU 上有标量 FP32、SIMD FP32、W8A32 量化三条路径,不碰 Python、PyTorch、ONNX Runtime 这些外部依赖。GPU 路径的做法我没在别的 .NET 项目里见过:构建期用 ILGPU 把 kernel 编译成 PTX,运行时 Native AOT 程序通过静态绑定直接调 CUDA Driver 执行——这么做是为了绕开 ILGPU 的运行时代码生成,因为它和 Native AOT 不兼容。cuBLAS、cuDNN 一概不用。

性能方面项目给了一组很克制的数据:Core Ultra 9 185H 加 RTX 4070 Laptop,CUDA FP32 后端,1 个问题(13 tokens)热路径约 40 毫秒,8 个约 311 毫秒,32 个约 1.23 秒。数字本身不算惊人,但作者把限定条件写得很全:只预热一次、采样五次,明说"五次采样不足以证明稳定的尾延迟";W8A32 量化路径目前比 SIMD 还慢,也照实写了;所有输出标记为 uncalibrated,没校准就是没校准。

这种"丑话说在前面"的文档风格,在这个人人吹 benchmark 的年代还挺少见的。

值得一提的是,Sezika 的 README 里写明了参考"TypeSafe Jev 的公开协议与边界",同时声明不复现 Jev 的闭源模型。也就是说,它是把 Jev 的接口契约搬到了一个能嵌进任何 .NET 程序的小引擎里——这也是为什么两条路线的 schema 会那么像。

像到什么程度?我把字段逐个对了一遍

为了搞清楚两边的兼容程度,我翻了 TensorSharp 的 JevRequest.Parse 和 JevInference.Run 源码,以及 Sezika 的 DecisionRequest/DecisionResponse 定义和输出文档,把请求和响应逐字段对了一遍。

请求顶层字段

字段 TensorSharp /v1/systemone Sezika predict / Evaluate
model 可选;jev-latest / jev-preview 是已加载模型的别名 必填;必须是固定的 convaiinnovations/laya-multilingual,加载时校验 revision 和逐张量 hash
state 必填;string / object / array,非字符串取 JSON 原文 必填;任意 JSON 值都行,取 JSON 文本当提示
顶层 instructions 可选,注入系统提示 没有这个字段
images 可选,≤8 张内联 base64;拒绝 URL 和文件路径 无(纯文本编码器,看不了图)
questions 必填 map,≤64 题;id 不许带冒号和控制字符 必填 map,默认 ≤32 题
samples 1–32 或 "auto"(默认):多读几次取平均 无,单次确定性前向
auto_max / auto_threshold 第一次读出来条件熵超过阈值(默认 0.1 nats)就自动多读,最多 auto_max 次(默认 4) 无
seed 默认 42;只保证同运行时同后端可复现 无(编码器没有随机性)
chunk_rows / chunk_prompt canvas 放不下时把 schema 切块 无(逐题编码,没有 canvas)
steps / think 必须是 steps=1、think=0,否则报错 无
显式拒绝的字段 depends_on、ask_if、sequential、audio/video 等,每个都带具体理由 重复属性直接报 decision_duplicate_property
体积限制 请求体 ≤8 MiB(可调) 请求 ≤1 MiB、JSON 深度 ≤32、state ≤256 KiB

questions 条目

字段 TensorSharp Sezika
type "noul" / "choice" / "score" "boolean" / "choice" / "score"
instructions 可选,默认空串 必填
criteria(choice) 候选名 → 描述,2–26 个 候选 ID → 描述,≤32 个
criteria(score) 有序等级数组,2–26 级 有序等级数组,≤10 级
criteria(boolean/noul) 可选 {"true": ..., "false": ...} 可选 {"true": ..., "false": ...},完全一样

类型名差一个:noul 对 boolean。这个名字差异背后是实现思路的不同——TensorSharp 要把候选编译成具体的 token 标签(布尔固定用 yes/no,choice 用 A/B/C…,score 等级少的时候用数字),然后在完整模板里重新分词,验证每个标签恰好占一个 token,验证不过直接拒绝请求。Sezika 没这层编译,每题编码成固定 marker 序列,决策头直接打分。

响应顶层字段

字段 TensorSharp Sezika
model 有 有,还带 model_revision 和可选的 tokenizer_revision
backend 无(diagnostics 里有 engine 字段) 有,比如 cpu-modernbert-marker-head
answers map(问题 id → 答案) map(问题 id → 答案)
usage input_tokens / output_tokens(图片占的 soft token 也算进去) question_count / token_count / micro_batch_count / workspace_bytes
诊断信息 单独的 diagnostics 块:概率语义声明、seed、采样策略、耗时、分块情况、每题的条件熵和标准误 没有独立块,诊断信息放在每个答案的 calibration 和 status 里

单个问题的答案

公共部分:

字段 TensorSharp Sezika
type 回显 回显
status / abstention_reason 无拒答概念,要么给答案要么整体报错 有 answered / abstained;低于置信门槛时照样返回 argmax 和概率,但标记拒答,文档明说不许拿它当执行依据
calibration 无(语义说明在 diagnostics) 有,uncalibrated / calibrated / out_of_scope 三态
logits 不输出 可选输出原始 logits,方便和参考实现对齐

三种原语:

原语 TensorSharp Sezika
choice choice + probabilities + confidence(最大概率) choice + probabilities + concentration(分布集中度)
score score(Σ i·pᵢ,从 0 开始算期望)+ legend + probabilities + confidence score(公式相同)+ legend + probabilities + concentration
boolean/noul {"type":"noul", "noul": P(yes)} {"type":"boolean", "probability_true": P(true)}

字段之外,几个容易踩坑的差异

字段长得像,不代表行为一样。有几个语义差异我觉得比字段差异更值得注意。

confidence 和 concentration 不是一个东西。 TensorSharp 的 confidence 是条件分布里最大那个概率;Sezika 的 concentration 是归一化熵算出来的集中度。同一个请求在两边跑出来的这两个数没有可比性,别混着用。

对"不确定"的处理思路相反。 TensorSharp 遇到不确定的题会自己多读几次(这就是 samples: "auto" 干的事),最后给平均值,还在 diagnostics 里告诉你几次读取之间的一致性和标准误。Sezika 是确定性的,一次前向就一个答案,但它把"这个答案信不信得过"做成了协议的一部分:你可以设最低浓度门槛,不达标的答案标记为 abstained,由调用方决定怎么办。一个靠"多想几遍",一个靠"明说不敢答"。

可复现性的含义不同。 TensorSharp 有 seed,但文档坦白说不保证和 Python/NumPy 产生一样的噪声,只保证同环境可复现。Sezika 压根没有随机源,复现性靠固定模型版本加完整的 hash 校验链。

两边都很诚实,只是诚实的位置不一样。 TensorSharp 把限制写在 diagnostics 和各种拒绝码里;Sezika 直接写在每个答案的校准状态字段里。共同点是:都不允许"概率高"自动等于"可以执行",动不动手永远由调用方决定。

所以该用哪个

场景 建议
高频低延迟的本地判断:意图识别、路由、RAG 重排 Sezika,毫秒级,AOT 编出来嵌进进程就行
需要大模型的世界知识和语义理解 TensorSharp Jev
答案在图里:质检、票据、红绿灯这种视觉判断 TensorSharp Jev,目前只有它支持图片
业务流程里需要显式的拒答和校准状态 Sezika,这是协议自带的
不确定时希望模型自己多算几遍 TensorSharp Jev 的 samples: "auto"

其实两者也不冲突。接口契约几乎一样,完全可以分层用:Sezika 放前面做高频路由和初筛,拿不准的、需要看图或者需要常识的,再丢给 TensorSharp Jev。小引擎守门口,大引擎做终审。

最后

翻这两个项目的文档时,我印象最深的不是性能数字,而是两边都在反复强调同一件事:模型给出的概率只是概率,没校准过就不是正确率。Sezika 把每个答案都标上 uncalibrated,TensorSharp 在文档里专门写"条件置信度不是经验校准的正确率,上线前请用自己的数据评估"。

判断引擎这类东西,接入业务的门槛从来不在 API 好不好调,而在你敢不敢信它。这两个项目至少在这点上很清醒——先把丑话写进协议里,再谈怎么用。


参考资料

  1. TensorSharp 仓库(Jev 文档在 docs/models/jev.md):https://github.com/zhongkaifu/TensorSharp/
  2. vLLM PR #57250:https://github.com/vllm-project/vllm/pull/57250
  3. How to Build a Jev-Style Classifier with DiffusionGemma and vLLM(Karl Weinmeister):https://medium.com/google-cloud/how-to-build-a-jev-style-classifier-with-diffusiongemma-and-vllm-ef2e0bfa9ad7
  4. Sezika 仓库:https://github.com/IoTSharp/Sezika
  5. 《Sezika:用 C# 为本地应用构建一个"判断引擎"》( https://mp.weixin.qq.com/s/IMnsFV5t9khDKPXiALmi9g )
  6. LocalJev:https://github.com/githubnext/localjev

文中的性能数据都来自各项目的公开文档,测试条件以原文为准。

posted @ 2026-09-25 00:31  张善友  阅读(72)  评论(1)    收藏  举报