开源openjev-sglang:用Qwen3.6+SGLang复刻Jev接口


这是 ekzhang 的开源项目 openjev-sglang,一个基于开源模型、与 TypeSafe/Jev HTTP API 兼容的推理服务端点。它的核心目标是用开源方案复现 TypeSafe AI 的 Jev(System One)决策模型的接口模式,不涉及 Jev 的闭源模型或训练方法

核心定位:做"决策",而非"生成"

传统聊天模型需要逐字生成答案句子,再由软件解析。而 openjev-sglang 采用 "prefill-only"(仅预填充) 的方式,直接读取模型对预定义选项的概率分布,不生成任何自然语言文本。

例如,对于"用户是否要求退款?"的问题,它直接返回 P(yes) 的概率,而不是生成"是的,用户要求退款"这样的句子。

技术架构

  • 推理引擎:基于 SGLang 0.5.19 的 Rust 前端,部署在 NVIDIA B200 上。
  • 基础模型:使用 Qwen3.6-35B-A3B(35B 总参数,3B 激活参数/Token),由 NVIDIA 提供 NVFP4 量化版本。
  • API 层:独立的 Python 进程,使用 FastAPI、uvloop 和 Rust 加速的 HF Tokenizer,通过本地 HTTP 连接池与 SGLang 通信。
  • 缓存机制:利用 Radix Attention 实现前缀缓存,对共享的对话状态只需预填充一次,即可并行处理多个问题。
  • CUDA 图:支持可中断的预填充 CUDA 图(breakable prefill CUDA graphs),提升推理效率

推理流程

  1. 校验与准备:验证请求 Schema、答案数量、Token 预算等。
  2. 模板渲染:使用模型原生对话模板渲染一次,关闭思考模式。
  3. 共享前缀预填充:将公共前缀发送到 SGLang 进行预填充(max_new_tokens=1),预热 Radix 缓存。
  4. 并行分支推理:对每个问题,并发发送"前缀 + 问题后缀 + 助手头部 + Answer:n",同样只生成一个 Token,并请求指定标签的 Log 概率。
  5. 结果归一化:用稳定的 Softmax 重新归一化标签概率。


B200单卡跑通6万亿参数分类接口,Eric Zhang用SGLang把大模型变成秒回判官!

一个人,一张卡,一晚上,端到端复刻商业分类API!

本文拆解GitHub项目openjev-sglang的核心机制,围绕Qwen3.6-35B-A3B、SGLang 0.5.19、Radix Cache、prefill-only推理、Modal Serverless部署等硬核技术,讲清楚为什么一次分类请求只需要一个token就能出结果,以及这套开源方案凭什么能对标闭源商业服务。


Eric Zhang掏出的B200猛料到底是啥

先把主角摆上桌:Eric Zhang,GitHub用户名ekzhang,头像编号7550632,Rustpad、sshx这些开源项目的作者,编程圈子里出了名的一人成军。这次他丢出来的仓库叫openjev-sglang,星标58,fork 8,只有他一个贡献者,全部Python,10次提交搞定。

这个仓库干的事情,用一句大白话讲,就是把TypeSafe公司卖钱的Jev分类接口,用开源模型加开源推理引擎重新做了一遍,做完之后免费扔到网上让人用。域名是ekzhang--openjev-sglang-openjev.us-west.modal.direct,直接curl就能打。

Jev是啥先说清楚。TypeSafe这家公司做的是一种叫做systemone的分类API,接口路径是POST /v1/systemone。你把一段对话或者文本扔进去,再列出几个问题,比如“用户是不是在要求退款”,接口返回的不是一大段自然语言,而是每个问题对应的概率分布。用途嘛:客服路由、内容审核、意图识别、评分打星,都能用。


Qwen3.6-35B-A3B这个奇怪的名字先掰扯明白

Qwen3.6-35B-A3B,读起来像车牌号。翻译成人话:Qwen是阿里通义千问的英文名;3.6是版本号;35B是模型总参数量350亿;A3B里的A是activated的意思,A3B表示每次推理实际激活的参数只有30亿。

这是个MoE架构,全称Mixture of Experts(专家混合)。想象一个巨大的公司有350亿员工,但每次接一个活儿,只叫醒其中30亿人干活,剩下的都在睡觉。这样显存要装下所有员工,但算力只需要养活其中一小撮,速度快、成本低。

openjev-sglang默认拉取的权重是nvidia/Qwen3.6-35B-A3B-NVFP4,前缀nvidia说明这是英伟达官方在HuggingFace上放的量化版本。NVFP4是英伟达自家推的4-bit浮点格式,专门给Blackwell架构的GPU吃的。B200就是Blackwell架构。这一整套组合拳的意思是:模型是阿里的、量化格式是英伟达的、硬件也是英伟达的B200,Eric Zhang负责把它们缝起来跑Jev协议。


SGLang 0.5.19的Rust前端到底比vLLM快在哪

推理引擎选的是SGLang 0.5.19,不是更出名的vLLM,也不是HuggingFace自家的TGI。这里必须掰扯清楚SGLang独有的一个东西:Radix Cache(基数树缓存)。

普通推理引擎的KV Cache是一次性的:你发一次请求,引擎给你算好键值缓存,请求结束就扔掉。下一个请求即使前缀完全一样,也得从头再算一遍。SGLang的Radix Cache把所有请求的KV缓存塞进一棵前缀树里,两个请求共享同一段前缀时,共享的那段缓存直接复用,只算不一样的部分。

这对openjev-sglang来说是命根子。设想一个场景:用户丢来一段2000个token的对话,配上5个问题。5个问题的前面2000个token都一样,只有后面几十个token不一样。用Radix Cache的话,2000个token只算一次,5个问题各自算自己那几十个token,速度直接快5倍。

Rust前端是SGLang 0.5.19的新玩意儿,把原本Python写的HTTP服务层用Rust重写了,减少GIL锁争抢,减少序列化开销。openjev-sglang默认走rust前端,环境变量OPENJEV_FRONTEND=rust,只有出问题时才回退到python。Rust前端有个小副作用:它不上报缓存命中的token数,所以响应头里的x-openjev-cached-tokens会是null,只能到调度器日志里去看真实命中数。


prefill-only是什么鬼,为啥能只吐一个token就出结果

这是openjev-sglang整个项目的灵魂机制,也是它跟任何“调用大模型做分类”的方案之间那道最不可替代的鸿沟。这里必须讲透。

大模型推理分两个阶段:prefill(预填充)和decode(解码)。prefill是把你输入的整段prompt一口气过一遍,算出每个位置的KV缓存和最后一个位置的logits(也就是每个候选token的原始得分)。decode是根据最后一个位置的logits采样出下一个token,然后再把这个新token喂回去,再算下一个,一个接一个吐字。

绝大多数人用大模型做分类的做法:给prompt,让模型decode出“yes”或者“no”这个词,然后拿字符串来判断。这种做法有两个坑:一是要decode至少一次,慢;二是模型可能吐出“Yes,”“ yes”“YES”这种五花八门的变体,还要写正则来清洗。

openjev-sglang彻底跳过了decode这一步。给SGLang发请求时,参数max_new_tokens=1,同时带上token_ids_logprob=[候选token的ID列表],logprob_start_len=-1。这三个参数组合起来的意思是:只做prefill,做完之后把最后一个位置上、我指定的那几个候选token的对数概率给我;至于你实际sample出哪个token,我不关心,扔掉。

举个具体的例子:问题是“用户是否要求退款”,候选答案是“yes”和“no”对应的token ID分别是9891和2201。SGLang做完prefill后,返回这两个ID的logprob,比如yes是-0.3,no是-1.5。做一个softmax归一化,得出P(yes)=0.77,P(no)=0.23。分类结果就出来了,一个token都没decode。

这就是prefill-only的核心:N个问题,只做N+1次prefill(多的那次是缓存预热),零次decode。跟传统方案比,速度差一个数量级。


26个字母怎么撑起64路分类,这里有个坑

问题类型choice和score都可能有多达64个选项。直觉上你会觉得,那就用数字标号呗,1、2、3……64。这个直觉在openjev-sglang里是错的。

Qwen的分词器把“10”“64”这种两位数拆成两个token。比如“64”可能被拆成“6”和“4”两个token。这意味着如果你用数字做标签,光看第一个token的概率,会把“6X”和“6Y”混成一坨,因为它们的第一个token都是“6”。整个分类就废了。

Eric Zhang的做法是用字母:A到Z一共26个,不够就上双字母AA、AB、AC……凑到64个。启动时代码会实际问一遍分词器:“AA是不是一个单独的token?”如果不是,就换下一个组合。所有64个候选标签都必须是单token,才能保证prefill-only那一招精准落地。

用户在请求里写的选项名叫billing、technical、urgent这种真实名字,openjev-sglang在内部悄悄把它们映射到A、B、C这种单token字母上,做完分类再把字母翻译回真实名字。用户完全感知不到这层映射,但推理速度因此保住了。


Modal serverless跑B200,冷启动的代价必须认

代码部署走的是Modal,一个专门做serverless GPU的平台。命令uv run modal deploy modal_app.py执行完,就得到一个公网域名,接受HTTPS请求,背后自动调度B200实例。

Modal的serverless GPU逻辑是这样的:没请求的时候,实例数是0,一分钱不花;来请求了,冷启动一个新实例;实例空闲5分钟没请求,自动销毁。省钱是省钱,代价是冷启动很痛:SGLang镜像巨大,模型权重从HuggingFace拉下来要好几十GB,加上CUDA graph捕获,第一次启动可能要好几分钟。这段时间HTTP请求返回503。

Eric Zhang做了两个优化:一是把模型权重、SGLang的tuning cache、Triton的编译缓存都塞进一个叫openjev-huggingface的Modal Volume(持久化磁盘),下次启动直接读盘,不用重新下载;二是Rust前端拿到本地tokenizer目录的绝对路径,避免因为版本号锁定导致远程查询失败。

即便这样,CUDA graph捕获还是每次启动都要做一遍,因为CUDA graph是GPU显存里的东西,实例销毁就没了。要想彻底避免冷启动,得在modal_app.py里加min_containers=1,让Modal至少保留一个热实例,代价是每小时都在烧B200的钱。


N+1这个数字到底是怎么算出来的

再回头把请求流程完整走一遍,把N+1这个魔法数字讲透。

一个请求进来,先做参数校验:问题数不超过64、每个问题选项数在2到64之间、请求体不超过2MiB、每条分支的token数不超过32768、总输入token数不超过262144。这些限制都是硬拒绝,超过就返回422,压根不进推理阶段。

校验通过后,openjev-sglang先用模型自带的chat template把对话渲染成一整段文本,thinking mode关闭(Qwen3.6支持思考链,但这里不需要)。然后把这段文本切成两部分:公共前缀(对话+系统指令)和每个问题独有的后缀(问题描述+选项列表+“Answer:\n”)。

第1步:把公共前缀发给SGLang,max_new_tokens=1,等它算完。这一步的唯一目的是把这段前缀的KV缓存写进Radix Cache。采样出来的那个token直接扔掉。这就是N+1里的那个1,叫做cache warmup(缓存预热)。

第2到N+1步:把公共前缀+第i个问题的后缀发给SGLang,同样max_new_tokens=1,带上token_ids_logprob=候选字母的token ID列表。因为公共前缀已经在Radix Cache里了,SGLang只会真正计算后缀那几十个token,前面2000个token的KV缓存直接复用。N个问题并发发出去,几乎同时返回。

拿到N份logprob之后,做softmax归一化。noul类型返回P(yes);choice类型返回argmax和完整分布;score类型返回加权期望值,也就是Σ(i × Pi),i是选项的序号。三种类型的语义完全不同,但底层机制是同一套prefill-only。


SGLang有个已知bug,Eric Zhang绕了个弯

事情没那么简单。SGLang 0.5.19有一个已知的bug,编号是sgl-project/sglang#34719:同一个batch里如果混合了“要logprob的请求”和“不要logprob的请求”,会崩溃。

openjev-sglang的warmup请求本来是不需要logprob的,反正结果要扔掉。但如果warmup请求跟真正的分类请求混进同一个batch,就踩雷了。

Eric Zhang的处理:warmup请求也带上一个token_ids_logprob参数,随便指定一个没用的token ID。这样warmup请求跟分类请求在SGLang眼里是同一类,可以安全地进同一个batch,也就绕开了那个崩溃bug。

这个细节小到不能再小,但它体现了开源项目跟商业产品之间的差距:商业产品可以要求上游修bug,开源项目只能自己在业务层打补丁。README里那句“Keep warmup requests compatible with selected-logprob batches”的commit,就是这个补丁的记录。


信心值这个数怎么算的,公式必须看一眼

openjev-sglang给每个分类结果都会返回一个confidence字段,也就是信心值,介于0到1之间。公式很简单:


confidence = 1 - H(P) / log(K)

H(P)是概率分布P的香农熵(Shannon entropy),K是选项个数,log是自然对数。熵越高说明分布越均匀、模型越拿不准;熵越低说明某个选项概率越接近1、模型越自信。除以log(K)是为了归一化到0到1之间。

具体例子:3个选项的概率是[0.9, 0.05, 0.05],H(P)≈0.394,log(3)≈1.099,confidence≈1-0.394/1.099≈0.641。如果3个选项概率都是[1/3, 1/3, 1/3],H(P)=log(3),confidence=0。如果某个选项概率是1,另外两个是0,H(P)=0,confidence=1。

这个数不是校准过的正确性估计,只是分布的集中程度。模型在错的选项上极度自信的情况也存在。README里明确写了这一点,避免用户把confidence当成“正确率”来用。


三种问题类型的语义差别,用一个客服案例讲清楚

回到README里那个curl示例:客服场景,用户抱怨被扣了两次钱要求退款。三个问题类型全都用上了。

第一个问题refund是noul类型,问“用户是否要求退款”。noul是逻辑学里的yes/no二值判断,返回P(yes)。这个例子里P(yes)会非常接近1,因为用户确实明说了要退款。

第二个问题department是choice类型,选项是billing(付款与退款)和technical(软件bug),问“该由哪个部门处理”。choice返回argmax和完整分布,也就是最可能的那个选项加上所有选项的概率。这个例子里billing会拿到高概率,因为扣款问题就是billing部门管的。

第三个问题urgency是score类型,选项是Routine(例行)、Urgent(紧急)、Emergency(急件)三档。score返回的不是argmax,而是加权期望值Σ(i × Pi),i从0开始。假设概率分布是[0.2, 0.6, 0.2],返回值是0×0.2 + 1×0.6 + 2×0.2 = 1.0,对应Urgent档。这个数值可以直接拿来做告警阈值或者工单优先级排序。

三种类型看似不同,底层都是同一套prefill-only+首token logprob读取,区别只在于后处理如何把logprob翻译成业务语义。


一张不能画的表和几个必须记住的数字

限制清单,全部是硬性的:单请求最多64个问题;choice和score每题2到64个选项;请求体最大2 MiB;单条分支最多32768个token(包括其输出,虽然输出只有1个token);单请求总输入最多262144个token;同时处理的评估请求最多16个;后端并发调用最多64个;后端超时120秒。

超过请求数限制返回529 Too Many Requests附带Retry-After头;超过体积限制返回413 Payload Too Large;参数不合法返回422 Unprocessable Entity;后端超时返回504 Gateway Timeout。所有拒绝都发生在推理之前,不会浪费B200的算力。

这些数字之所以要记住,是因为如果拿openjev-sglang去做压力测试或者集成到自己的业务里,超过这些限制的请求会被硬拒。想调大限制,得改环境变量并且重新部署,比如OPENJEV_MAX_CONCURRENT_REQUESTS=32。


想在自己机器上跑,两条路各有代价

不用Modal也能跑,README里给了两条本地路径。

第一条:连接到已有的SGLang实例。命令是uv run openjev serve --connect http://127.0.0.1:30000。前提是本机已经跑起来一个SGLang,模型和分词器版本必须跟openjev-sglang默认的Qwen3.6-35B-A3B-NVFP4完全一致,而且SGLang启动时必须开启token_ids_logprob和Radix Cache。这条路适合已经有GPU集群、只想蹭Jev协议的用户。

第二条:在装了SGLang 0.5.19的B200机器上,用uv run openjev serve --sglang-python /path/to/sglang/bin/python命令启动。openjev-sglang会自己拉起SGLang子进程,并且监控它的存活。SGLang挂了,openjev-sglang也会跟着退出,让容器编排系统重新拉起来,而不是留一个死后端配活HTTP的僵尸服务。

两条路都绕不开B200或者其他Blackwell架构显卡。NVFP4量化格式在老架构上跑不了。想在A100或者H100上跑,得换成FP8或者BF16的权重,模型ID也得跟着换,不能用nvidia/Qwen3.6-35B-A3B-NVFP4这个默认值。


那些看似冗余的x-openjev响应头,其实是排错工具

openjev-sglang在HTTP响应头里塞了几个自定义字段,前缀都是x-openjev-。x-openjev-prefix-tokens报告本次请求识别出来的公共前缀token数;x-openjev-cached-tokens报告Radix Cache里实际命中的token数(Rust前端下这个字段是null,因为Rust前端不上报,只能查调度器日志)。

还有一个标准字段Server-Timing,把请求的耗时拆成三段:prompt preparation(分词、切分、渲染chat template)、shared prefill(公共前缀的prefill)、branch inference(N个分支的并发prefill)。这个头是浏览器DevTools原生支持的,Chrome的Network面板可以直接可视化。

usage字段跟OpenAI协议看齐但语义不同:input_tokens是SGLang报告的所有分支prompt token数之和(包括缓存命中的部分),output_tokens固定是N+1(每个分支各1个,加上warmup那1个)。这个数字不是计费用的,是后端使用量统计。真正省下来的算力体现在cached tokens里,缓存命中的部分不会重新计算。


收尾抛一个悬念:58颗星背后那8个fork在干嘛

Eric Zhang一个人搞出来的这个项目,star 58,fork 8,contributor只有他自己。这8个fork是什么人在fork、他们打算改什么,仓库里一个PR都没有。

评估目录evals里有一个commit叫“Evaluate OpenJev on the matched MMLU-Pro subset”,日期2026年9月18日。MMLU-Pro是一个学术界公认的语言模型评测集,把它跑通意味着openjev-sglang的prefill-only路径不仅能跑Jev协议,还能作为一个通用的多选题评测框架。但是这个评测的实际得分,README里没提,evals目录里的具体数字也没有在项目主页展示。

如果Qwen3.6-35B-A3B用prefill-only跑MMLU-Pro能打到跟传统decode路径接近的分数,那意味着一大批分类和评测任务都可以用同一套架构提速一个数量级。如果打不到,那说明prefill-only这条路在某些复杂推理题上会掉分,只适合浅层意图识别,深度推理还得老老实实decode。

答案就藏在那个commit的代码里,但README没给结论。这事儿没完。