Jev 拆解 | 一个不生成 token 的模型,怎么做决策?
Jev 拆解 | 一个不生成 token 的模型,怎么做决策?
参考资料:TypeSafe 官方发布博客 · TypeSafe 官方文档 · NanoJev(开源复现)
1. 前言:为什么会有人做一个”不生成文本”的模型
你有没有想过,当你只是想让一个模型做个分类、打个分数、判断个是不是的时候,为什么非得走一整套”prompt 拼好 → 模型一个 token 一个 token 往外吐 → 再拿正则或 JSON parser 把答案抠出来”的流程?
这个链路里藏着好几个”行业心知肚明但没人愿意先解决”的问题:
- 慢:哪怕答案只是一个字母(A/B/C/D),自回归解码还是要走完整个 forward,且解码是严格串行的,没法并行。
- 贵:input token 可以并行算,但 output token 不能,KV cache 还得为这段用不上的”答案生成”继续占显存。
- 格式不稳:模型有概率不按你要的 JSON schema 吐,或者吐出来的选项字母压根不在你给的候选里——这是生成式方法的结构性风险,不是靠 prompt engineering 能根治的。
- 没有天然的置信度:next-token 的 softmax 概率不等于”这个分类判断有多大把握”,两者的语义完全不是一回事。
今年 9 月,TypeSafe AI 发布了一类新模型 System One Models,第一个成员叫 Jev。它明确不做生成,官方的定位是”给你一个概率决策接口“:你喂给它一段 state(现场/上下文)和若干个 question(问题),它直接给你每个问题的完整概率分布,不吐一个多余的字。
按官方发布博文的说法,这类模型的核心卖点是速度和成本的数量级差异——博文给出的是”自家工作流对比”下 20–200× 更快、40–400× 更便宜的区间(官方也承认这是较理想情况,短输入、美国西海岸测试对其有利,不是放之四海的保证)。这类数字要打个折看,但方向是对的:把”决策”这个子任务从生成任务里独立出来做,确实有专门优化的空间。
可能的应用场景,官方给了几个挺有意思的演示:
- 实时游戏 AI(Doom 里判断该往哪走、该不该开枪,纯文字状态输入,不需要先训一个 VLM)
- 超大候选集分类(Wikiracing:从一个 Wikipedia 页面几百个链接里选下一跳)
- 智能家居指令路由(一次判断请求类型、房间、设备、动作,四个维度并行出结果)
- Agent trace / 安全事件判定、发票信息抽取、检索重排(rerank)、客服工单分类
这些场景的共同点是:答案空间是有限、可枚举的,你要的是”选哪个”而不是”写一段话”。这恰好是自回归 LLM 最不擅长优化、但结构化决策任务最需要的东西。
下图是 NanoJev 官方仓库里的一个实测对比截图——同一个迷宫探索任务,Jev、NanoJev、以及未经微调的原始 Qwen3-0.6B 三者并排跑,实时展示每一步的决策概率分布:

可以看到原始 Qwen3-0.6B(右侧)在没有专门训练的情况下,走迷宫的表现明显更差——这也侧面说明了”decision head”不是免费的,背后是需要专门训练的。
官方目前没有公开 Jev 的网络结构、参数量、基座模型、RLCD 训练算法细节——创始人 Diogo Almeida 在 Hacker News 上明确说了架构现在保密(相关讨论,2026-09-15)。所以想搞清楚”它内部到底怎么算”,官方资料到这儿就断了。
好在有人已经动手复现了。NanoJev 是一个基于 Qwen3-0.6B 的开源”迷你复刻版”,作者非常克制——反复强调这是”面向同一接口的独立实现”,不是对 Jev 内部架构的还原或逆向。但正因为它诚实地区分了”官方证实的事实”和”自己的合理推断”,这份代码反而成了目前唯一能让我们”看到执行细节”的参考。这篇文章接下来就是:先讲清楚哪些是官方证实的,哪些是 NanoJev 自己的工程推断,再逐个 primitive 把概念和代码放在一起讲清楚。
2. 结构与代码:三种 primitive 到底怎么共享、怎么分叉
2.1 官方确认的接口语义(这部分是事实,不是推断)
Jev 的输入输出接口是公开且有文档的,这部分我们可以放心讲。一次请求由两部分组成:
-
state:字符串、JSON 对象,或数组,承载共享的上下文信息。多个问题可以共用同一个state。 -
questions:一个字典,每个问题有自己的type、instructions(自然语言指令)和必要的criteria(候选/等级描述)。问题之间互相独立、不能读取彼此的答案——这是接口层面的硬约束,不是模型能力不够。
三种问题类型(primitives),对应三套输出格式:
| 类型 | 输入 criteria | 输出 |
|---|---|---|
| Choice | 2–255 个候选,每个是 候选名: 描述 | 每个候选的完整概率分布 + confidence |
| Score | 2–10 个有序等级描述 | 每级的概率分布 + 期望分 Σ i·p_i + legend |
| Noul(对外 SDK 里叫 Boolean) | 可选的 true/false 说明 | 单个概率值 P(是),没有独立的 confidence |
下图是这套接口的示意:

几个容易忽略但很关键的细节,都是从官方文档核实过的:
- Choice 的候选名称和描述都会进入模型,但问题的 ID(比如你起的
route、urgency这种 key)不会进入模型输入——它只是用来在返回结果里对应答案,指令必须自己把完整问题说清楚,不能指望模型”看名字猜意思”。 - Score 的等级数字和相邻等级文本不会喂给模型,每一级是独立判断的,不告诉模型”你现在在第几级”。这个设计是为了避免模型对序号产生虚假的锚定。
- Noul 没有独立的 confidence 字段,这点和 Choice/Score 不一样。
上下文预算方面,官方 jaggedness 文档(2026-09-16 最后复核)给出的数字是:state + 全部 questions 合计 64k token,state + 最长单个 question 是 32k token;定价是输入 $0.042 / 百万 token,输出免费(因为它本来就不产生输出 token)。
confidence 的计算公式官方在 MIT 协议的 LLM adapter 里是公开的(这是 adapter 的实现,不是 Jev 服务端本身的证明,但可以合理参考):
choice_confidence = (max(p) - 1/K) / (1 - 1/K) # K 是候选数
m = argmax(p)
d = Σ p_i * |i - m|
d_uniform = (1/K) * Σ |i - (K-1)/2|
score_confidence = max(0, 1 - d / d_uniform)
说白了,confidence 是”分布有多集中”的一个统计量,不是另外训出来的”正确率预估”。分布越接近 one-hot,confidence 越接近 1;越接近均匀分布,越接近 0。
2.2 官方明确没有公开的部分
划清界限很重要,以下这些官方到目前为止没有披露:
- 网络的具体结构(多少层、attention 怎么做、有没有用现成的开源 base model)
- 参数量(官方只说”Jev 不是小模型,也不是传统 LLM”,没有量化数字)
- RLCD(Reinforcement Learning for Calibrated Decisions,这是官方给训练方法起的名字)的 reward 设计、loss、采样算法、训练数据规模
- 训练代码和数据本身
所以接下来这一整节讲的”网络怎么算”,全部来自 NanoJev 的独立工程实现,是”面向同一接口、能自洽跑起来的一种实现”,不是 Jev 真实架构的还原。这个界限我会在每一段反复提。
先说结论,再展开代码:三种 primitive 不是各自有一个独立的头。它们共享同一个 backbone、同一个 decision head,差异只发生在两处——Choice 在共享 logit 之上多叠加了一个修正头,Boolean 是数据构造层面的技巧而不是网络层面的分支。下面就按”先看共享部分,再看每种类型各自在哪里分叉”的顺序,把代码贴在对应概念旁边过一遍。代码在 scripts/train_toy_decisions.py。
2.3 共享的部分:一个 backbone + 一个 decision head,输入怎么拼、forward 怎么算
传统自回归 LLM 的最后一层是词表大小的 LM head,输出 [batch, seq_len, vocab_size],每一步都在整个词表上做 softmax 选下一个 token。NanoJev 的核心改动就是把这个 LM head 整个换掉:
- 骨干网络用 HuggingFace 的
AutoModel(不是AutoModelForCausalLM)加载 Qwen3-0.6B——这一个选择的区别很关键:Qwen3Model(AutoModel拿到的东西)只返回每一层算完的 hidden states,形状[B, N, d_model];Qwen3ForCausalLM才会在后面再接一层词表投影,得到[B, N, vocab_size]。NanoJev 完全不需要后面那层投影,所以干脆不加载它,省下一大块显存和计算。 - 骨干后面接一个极简的decision head:
LayerNorm(d_model) + Linear(d_model, 1)。每个候选走完 backbone 后,只取它最后一个有效 token 的 hidden state,过这个 head,拿到一个标量 logit。 - 同一个问题下的所有候选,标量 logit 拼起来做一次
softmax,就是这个问题的完整概率分布。
这个 backbone 和这个 head,Choice / Score / Boolean 三种类型完全共用同一份参数——接下来的代码会把这一点走一遍。整个流程画出来是这样:

这里最直白的收益是”删掉了解码循环”——不管候选有多少个,都只需要一次 forward(每个候选路径的最后一个 token 位置各自读一个 logit 出来),不需要一步步生成再判断有没有生成完。NanoJev README 里给的实测数字是”6 个状态 · 18 个问题 · 44 条候选路径 · 1 次 backbone 前向”。
先看输入怎么拼出来。三种类型在这一步就已经决定了各自会走几条候选路径:
# scripts/train_toy_decisions.py
# def load_examples(path, tokenizer, max_length) 第 28 行
for qid, q in row['questions'].items():
typ = q['type']
if typ == 'boolean':
ids, texts = ['false', 'true'], ['The proposition is true.']
gold = int(row['gold'][qid])
elif typ == 'choice':
ids = list(q['criteria'])
texts = [f"{key}: {q['criteria'][key]}" for key in ids]
gold = ids.index(row['gold'][qid])
else: # score
ids = [str(i) for i in range(len(q['criteria']))]
texts = q['criteria'] # 绝不注入等级数字或相邻等级
gold = int(row['gold'][qid])
segments = [f"State:\n{row['state']}\n",
f"Question type: {typ}\nQuestion:\n{q['instructions']}\n"]
prefix = sum([tokenizer.encode(t, add_special_tokens=False) for t in segments], [])
leaves = [prefix + tokenizer.encode(f"Candidate:\n{t}\nDecision:", add_special_tokens=False)
+ [tokenizer.eos_token_id] for t in texts]
这段代码的输入是原始 JSON 数据里的一行(一个 state + 若干 questions),输出是 leaves:一个 list,长度等于该问题的候选数,每一项是一条完整的 token id 序列。拿一个具体例子过一遍:假设 state="路口有一辆车正在左转",question 是一个 Boolean:”这辆车会不会撞到行人?”,那么 texts = ['The proposition is true.'](注意 Boolean 只有一条语义路径,这一点先记下来,下面 2.6 节会解释为什么),拼出来的完整字符串是:
State:
路口有一辆车正在左转
Question type: boolean
Question:
这辆车会不会撞到行人?
Candidate:
The proposition is true.
Decision:<eos>
分词后就是 leaves[0],一个 int 列表,比如长度 47。如果是 Choice,有 K 个候选,leaves 就有 K 条路径,每条都重复了完整的 State: + Question: 前缀,只在 Candidate: 部分不同——这正是下文 2.7 节说的”打平重复计算”,树共享方案就是想省掉这部分重复。
这里有一个容易踩的坑,代码里专门做了检查:max(map(len, leaves)) > max_length 时直接抛异常,不做静默截断。这是有意为之——如果因为超长被截断,候选的语义描述可能被砍掉一半,模型学到的就是错误信号,宁可训练直接失败也不要悄悄喂错数据。
再看这些拼好的路径怎么被喂进 backbone、怎么变成每候选一个 logit。这是 DecisionModel.forward 的前半段,从这里开始,Choice / Score / Boolean 走的是完全一样的代码,还没有任何分支:
# scripts/train_toy_decisions.py
# class DecisionModel(nn.Module)
# def forward(self, examples, pad_token) 第 104 行
def forward(self, examples, pad_token):
paths = [ids for ex in examples for ids in ex['leaf_tokens']]
device = self.scalar.weight.device
lengths = torch.tensor([len(ids) for ids in paths], device=device)
width = int(lengths.max())
tokens = torch.full((len(paths), width), pad_token, dtype=torch.long, device=device)
for i, ids in enumerate(paths):
tokens[i, :len(ids)] = torch.tensor(ids, device=device)
attention = torch.arange(width, device=device)[None, :] < lengths[:, None]
hidden = self.backbone(input_ids=tokens, attention_mask=attention,
use_cache=False).last_hidden_state
leaves = hidden[torch.arange(len(paths), device=device), lengths-1]
输入是 examples:一个 batch,里面每一项是一个”问题”(含它的若干候选路径 leaf_tokens)。第一行 paths = [...] 把所有问题的所有候选路径打平成一个大列表——假设这个 batch 里有 3 个问题,分别是 4 候选的 Choice、2 候选的 Boolean、5 候选的 Score,那 paths 长度就是 4+2+5=11,这 11 条路径会被塞进同一个 batch 一次过 backbone,这就是”并行”的字面含义:不是多线程意义上的并行,是把本该分别推理的 11 条路径合并成一次矩阵运算。
lengths 记录每条路径实际的 token 数(例子里假设分别是 [52,48,55,50, 31,29, 40,42,38,44,41]),width 取这批里最长的(假设是 55)。tokens 是 [11, 55] 的张量,短的路径用 pad_token 补齐;attention 是对应的布尔 mask,形状同样 [11, 55],True 表示这个位置是真实 token 不是 padding。
hidden = self.backbone(...) 这一步是整段代码里唯一一次真正的模型 forward(这也是 Choice/Score/Boolean 唯一共用的模型计算),返回值 last_hidden_state 形状是 [11, 55, 1024](1024 是 Qwen3-0.6B 的 hidden_size)。接下来 leaves = hidden[arange(11), lengths-1]——这一行是在每一行各自取自己那个真实长度对应的最后一个 token 的 hidden state(不是取第 55 列,因为大多数行第 55 列是 padding)。举例:第一条路径 lengths[0]=52,就取 hidden[0, 51](0-indexed,所以是第 52 个 token)。结果 leaves 的形状是 [11, 1024]——11 条路径,每条只留下一个 1024 维向量,代表”这个候选走到 Decision: 之后紧跟的 eos 位置,网络攒出来的完整语义”。
# 续 forward,第 116 行起
kmax = max(len(ex['candidate_ids']) for ex in examples)
h = leaves.new_zeros((len(examples), kmax, leaves.shape[-1]))
valid = torch.zeros((len(examples), kmax), dtype=torch.bool, device=device)
offset = 0
for i, ex in enumerate(examples):
n = len(ex['leaf_tokens'])
h[i, :n] = leaves[offset:offset+n]
valid[i, :len(ex['candidate_ids'])] = True
offset += n
h = self.norm(h)
z = self.scalar(h).squeeze(-1).float()
这一段是把刚才打平的 [11, 1024] 重新分组回”每个问题一组”。kmax 是这个 batch 里候选数最多的那个问题的候选数(例子里是 Score 的 5)。h 的形状是 [3, 5, 1024](3 个问题,每个最多 5 个候选,1024 维),第一个问题(4 候选的 Choice)填进 h[0, :4],剩下 h[0, 4] 是全零的占位;valid[0] = [True,True,True,True,False] 记录哪些位置是真实候选。h[i,:n] = leaves[offset:offset+n] 这一行的 offset 是靠一个从 0 开始累加的游标,跟着 for 循环走——第一个问题占了 leaves[0:4],第二个问题(Boolean)占 leaves[4:6],第三个(Score)占 leaves[6:11],这个顺序完全由 examples 列表本身的顺序决定,不需要额外记录映射表。
z = self.scalar(h) 过一层 LayerNorm + Linear(1024, 1),输出形状 [3, 5],也就是每个问题、每个候选位置各一个标量 logit——这就是每个候选最终的打分,padding 位置上的 logit 是无意义的(后面会被 mask 掉)。到 z 这一步为止,Choice/Score/Boolean 三个问题用的是同一套 norm + scalar 参数,代码里完全没有 if type == 判断——下面三节开始才是三种类型各自分叉的地方。
2.4 Choice:独立打分的天花板(IIA),以及 set-attention 怎么修正
如果每个候选都是独立过 backbone、独立打分(也就是上一节的 z),会有一个数学上绕不开的问题,专业名字叫 IIA(Independence of Irrelevant Alternatives,无关选项独立性):两个候选的相对胜率之比 p(a)/p(b) = exp(z_a - z_b) 只由 z_a, z_b 决定,跟你往候选集里加了什么第三个候选完全无关。
举个 NanoJev 自己举的反例:”从候选集合里选一个最接近均值的数字”。集合 {0, 4, 5} 里应该选 4;但换成 {0, 4, 5, 100} 之后,均值变了,应该选 5。如果 4 和 5 的 hidden state 都不知道对方存在、更不知道 100 的存在,它们的相对打分不可能因为多了个 100 而翻转——这不是训练数据不够或者温度调得不对,是”候选间互相看不见”这个设计本身的天花板。
NanoJev 对这个问题给出的方案是给 Choice 单独加一个 set-attention head,让同一个问题下所有候选的 hidden state 互相做一次 attention,再各自吐出一个修正量加到上一节算出的标量 logit z 上。注意这个头是”额外叠加”在共享 head 之上的,不是替换共享 head,而且只用在 Choice,Score 明确不用——因为官方 Score 语义就是”每级独立判断,不看相邻等级”,硬加集合上下文反而违反了接口本身的约定。对应代码紧接在上一节 z = self.scalar(h) 后面:
# 续 forward,第 127 行起
choice = torch.tensor([i for i, ex in enumerate(examples) if ex['type'] == 'choice'], device=device)
if self.set_head == 'attention' and len(choice):
log_k = valid[choice].sum(-1).float().log()[:, None, None].expand(-1, kmax, 1)
u = self.set_project(torch.cat([h[choice], log_k.to(h.dtype)], dim=-1))
mixed, _ = self.set_attention(u, u, u, key_padding_mask=~valid[choice], need_weights=False)
delta = self.set_output(torch.tanh(u + mixed)).squeeze(-1).float()
z = z.index_add(0, choice, delta)
输入是上一步算出的 h([3,5,1024])和 z([3,5]),先用 choice 这个下标张量只挑出类型是 Choice 的那些问题(例子里假设第 0 个问题是 Choice,choice = [0])。log_k 是这个问题实际候选数的对数(这里是 log(4)),拼进每个候选的表示里,让模型知道”当前候选集合有多大”——这是给 set-attention 的一个额外线索,候选数本身也是有信息量的。
self.set_attention 是一个 nn.MultiheadAttention(128, 4):让同一个问题下的所有候选(padding 除外,靠 key_padding_mask=~valid[choice] 排除)互相做一次 attention,输出 mixed 和输入 u 形状相同。delta = self.set_output(...) 把混合后的表示投影回一个标量修正量,z = z.index_add(0, choice, delta) 把这个修正量加回到原来独立算出的 logit 上——注意 set_output 的权重是零初始化的(nn.init.zeros_(self.set_output.weight)),这样训练刚开始时 delta 全是 0,网络的初始行为等价于完全没有 set-attention head,之后再靠训练慢慢学会要不要用它,这是一个很实用的稳定训练的小技巧。
Choice 最终的后处理很直接:p_j = softmax(z_j),z_j 就是修正过的这一份 logit。
2.5 Score:不加新头,只是把同一套 logits 的后处理换成期望值
Score 在 z = self.scalar(h) 这一步之后什么都不做——不进 set-attention(上一节说了,Score 不适用),也没有类似 Boolean 的覆写逻辑。它和 Choice 唯一的区别只发生在推理拿到 softmax 之后:Choice 直接把 argmax 当答案,Score 则把每一级的下标当权重,算一个期望分:
Score: p_jk = softmax(z_j)_k; score_j = Σ_k k · p_jk # k 是等级下标(0-indexed)
这一步不在 train_toy_decisions.py 的 forward 里,而是在推理脚本 predict_toy_decisions.py 里完成的,和 Choice/Boolean 的封装逻辑放在同一个函数里,可以对照着看三种类型最后是怎么分道的:
# scripts/predict_toy_decisions.py
# def answer_from_probabilities(example, probabilities) 第 131 行
def answer_from_probabilities(example, probabilities):
ids = example["candidate_ids"]
if len(probabilities) != len(ids) or not all(math.isfinite(p) and 0 <= p <= 1 for p in probabilities):
raise ValueError("模型产生了无效概率")
if abs(math.fsum(probabilities) - 1.0) > 1e-5:
raise ValueError("模型概率总和不为1")
best = max(range(len(ids)), key=probabilities.__getitem__)
result = {"type": example["type"], "probabilities": dict(zip(ids, probabilities))}
if example["type"] == "boolean":
result.update(p_true=probabilities[1], value=bool(best))
elif example["type"] == "choice":
result.update(choice=ids[best], value=ids[best])
else:
score = math.fsum(i * p for i, p in enumerate(probabilities))
result.update(score=score, level=best, value=score)
return result
这个函数的输入 probabilities 是 softmax 之后的一个概率列表(不管上游是 Choice、Score 还是 Boolean,到这里都已经是”每候选一个概率”的同一种形状),输出 result 是最终返回给调用者的 JSON。关键在于:它对三种类型走的是同一段代码骨架(校验概率合法、找 best),只在最后三行按 example["type"] 分支决定怎么打包——Score 分支多做的唯一一件事就是 math.fsum(i * p for i, p in enumerate(probabilities)) 这一行期望值计算。这也是整份代码里”三种 primitive 共享机制、只在收尾分叉”体现得最干净的一处。
2.6 Boolean(Noul):不是新头,是数据构造上的技巧
回到 2.3 节的例子:Boolean 问题在 load_examples 阶段就只生成了一条候选路径(ids, texts = ['false','true'], ['The proposition is true.']——语义上只有”这个命题为真”一句话,没有反过来写”这个命题为假”的路径)。这意味着 Boolean 走完 backbone、走完 z = self.scalar(h) 之后,z[i] 里只有一个真实数字,其余位置都是 padding。真正把这一个数字变成”是/否”两个候选的逻辑,在 forward 的最后几行:
# 续 forward,第 136 行起
out = []
for i, ex in enumerate(examples):
if ex['type'] == 'boolean':
out.append(F.pad(torch.stack([z[i, 0] * 0, z[i, 0]]), (0, kmax-2)))
else:
out.append(z[i])
return torch.stack(out).masked_fill(~valid, -1e9), valid
z[i, 0] 是 Boolean 那唯一一条路径打出来的 logit。torch.stack([z[i,0]*0, z[i,0]]) 构造出两个数:false 分支固定是 0,true 分支是模型算出来的 logit,等价于 p_true = sigmoid(z_true - z_false) = sigmoid(z - 0)——它没有单独训一个 sigmoid head,而是把”是/否”当成一个两候选的 Choice 特例,固定把 false 分支的 logit 钉在 0,让模型只需要学”true 分支比 false 分支高多少”。F.pad(..., (0, kmax-2)) 把这两个数后面补 0 补到统一的 kmax 长度,方便和 Choice/Score 的输出堆成同一个张量。
对照上一节的 answer_from_probabilities:Boolean 分支拿到的 probabilities 长度也是 2([p_false, p_true]),result.update(p_true=probabilities[1], value=bool(best))——同样是走通用的”概率列表 → 打包”骨架,没有任何 Boolean 专属的模型计算。
最后 masked_fill(~valid, -1e9)——所有 padding 位置的 logit 被强行设成一个很大的负数,这样后续无论对哪个问题做 softmax,padding 位置的概率都会被压到几乎 0,不会污染真实候选的概率质量。这个函数的返回值 (logits, valid) 会被 loss_for 用来算交叉熵(Choice/Score 用真实的 teacher 概率分布做 soft target,训练细节这里不展开),推理时则是直接 softmax(-1) 拿到每个候选的概率——这正是 2.1 节里官方接口返回的那个”完整概率分布”。
2.7 串起来看:一次 forward 里到底发生了几次矩阵运算
把 2.3~2.6 串起来看一遍完整的数据流向:
输入:3 个问题(4-Choice / 2-Boolean / 5-Score),共 11 条候选路径
↓ tokens[11,55], attention[11,55]
backbone(tokens, attention) # 唯一一次真正的 Transformer forward,三种类型共用
↓ hidden[11,55,1024]
取每行 lengths-1 位置
↓ leaves[11,1024]
按 examples 顺序重新分组
↓ h[3,5,1024], valid[3,5]
norm + scalar head(共享参数)
↓ z[3,5] # 每候选一个 logit,三种类型到此为止完全一样
仅 Choice 问题:set-attention 修正
↓ z[3,5](Choice 那一行被 delta 更新)
Boolean 问题:固定 false=0 覆写
↓ z[3,5](Boolean 那一行被覆写)
masked_fill + softmax(-1)
↓ 3 个问题各自的候选概率分布
Choice → argmax;Score → Σk·p_k 期望值;Boolean → p_true # answer_from_probabilities 收尾
全程只有一次 backbone forward,也没有任何自回归的解码步骤——这也是为什么 benchmark 函数(同一个文件里,第 236 行 @torch.no_grad() def benchmark)在打点计时报告里专门写了一行 'autoregressive_decode_steps': 0,把这个特征当成核心卖点直接打在性能报告里。
2.8 更进一步的推断:共享前缀树注意力(尚未落地,仅是设计规格)
上面讲的还是”每个候选独立走一遍完整 backbone”,如果 state 很长、候选很多,重复算同一个 state 显然浪费(2.3 节 load_examples 那段代码里已经能看到这个重复:每条候选路径都各自拼了一遍完整的 State: 前缀)。NanoJev 的 research 文档里有一份更激进的设计(明确标注是设计规格,不是已经跑通的代码):把一次决策请求的所有 token 组织成一棵树——
- 根节点是
State(所有 question 共享) - 每个
Question是它的子节点(同一 state 下的多个 question 共享 state,但互不相通) - 每个
Candidate + Decision:是对应 question 的子节点

这棵树只需要一次 backbone forward,靠一个自定义 attention mask保证”树”的语义不被破坏:
- token
i能看到 tokent,仅当t是i的严格祖先节点,或者t和i在同一节点内且位置更靠前。 - 换句话说:state 只能看到 state 自己;question 能看到完整 state + 自己的 question 前缀;candidate 能看到完整 state + 自己的 question + 自己的候选前缀。兄弟分支之间完全没有边——普通的下三角因果 mask 是做不到这一点的,那样会让后排的候选”偷看”到排在它前面的兄弟候选。
判断”是不是祖先”不需要存一张 N×N 的布尔表,用 DFS 遍历给每个节点标记一个 (tin, tout) 区间,严格祖先关系就退化成一次区间比较 tin[t] < tin[i] < tout[t],是效率和实现复杂度的一个很直接的权衡。
position id 也不是简单的从 0 递增:每个 token 的 position 是”从根到它的路径深度”,不是它在打平后的张量里的列号。这意味着不同分支上可以出现相同的 position id——这没问题,因为 attention mask 已经把兄弟分支互相隔开了,RoPE 不会在它们之间产生”距离”上的误解。
这个方案在文档里明确列了一堆还没验证的风险点:HuggingFace 在 attention_mask=None 时会用 position id 的跳变自动推断 packed sequence 边界,这个机制会误伤树结构的祖先连接,必须显式传 mask;T_tree(树的 token 数)比 T_flat(打平后的 token 数)小,并不代表树版本一定跑得更快,因为 dense attention 的实际计算量看的是”有效边数”,大量短前缀 + 长候选拼成的巨树,兄弟间被 mask 掉的部分本身也占显存和算力;真正的收益需要换成 FlexAttention 的 BlockMask 才可能兑现,SDPA/eager 上的 dense mask 只是正确性实现,不是加速实现。这部分我把它定位为”值得关注的优化方向”,而不是”已经证实有效的结论”。
3. 小结:这类模型该怎么定位
写到这里,可以把这篇文章想讲的核心事情收一下:
Jev/System One 这类模型解决的不是”模型能不能做分类”的问题(这个问题从 BERT 时代就解决了),而是”怎么让一个能理解自然语言指令、支持任意候选集合、支持 zero-shot 的分类器,也具备接近专用分类器的速度和成本”——关键是”任意候选集合 + zero-shot”这个组合,这意味着它不是一个训死的固定标签分类器,指令和候选描述都是每次请求现场给的。这也是为什么它比传统的”训一个专用分类头”更通用,比”用 LLM 生成 JSON 再解析”更快更省。
架构层面,官方目前守口如瓶,NanoJev 给出的是一份诚实的、可审计的独立实现:三种 primitive 共享同一个 backbone、同一个 decision head,把 Boolean 处理成两候选的特殊 Choice、给 Choice 单独加 set-attention 处理 IIA 限制,这些都是为了实现同一套接口而做的工程选择,不代表 Jev 内部真的是这样写的。共享前缀树注意力那部分更进一步——目前还只是设计规格,没有对应的已验证代码,如果你想深入研究”怎么把同一个 state 下多个问题的重复计算省下来”,那份审计文档(research/algorithm_architecture_audit_zh.md)值得细读,但别把它当成结论。
校准(calibration)这个话题被我刻意放在了背景里没有展开——RLCD 到底怎么训练、输出的概率是不是真的可信,这本身是个足够写一整篇文章的话题,NanoJev 自己也只做了一个小规模的”成对适当奖励”实验(docs/RLCD_EXPERIMENT.md),离验证真实 RLCD 效果还有距离,感兴趣的话可以自己去看。
顺带扯一句题外话:这类”用 decision head 替代生成”的思路,本质上跟我们平时聊 LLM 压缩、推理加速的角度不太一样——不是在优化生成过程,而是判断这个任务压根不需要生成。这也是这两年一个挺明显的趋势:越来越多团队开始琢磨”这个子任务到底要不要走完整的自回归”,而不是无脑上生成式方案。我们把 LLM 架构自动化、模型压缩这些相关的积累整理成了一本书,里面第 9 章正好讲 LLM 驱动的 AutoML 和 LLM Agent 结合的方向,跟这篇聊的”任务专用化改造”是不同角度但目标相近——都是想让 LLM 别在不需要它全力发挥的地方浪费算力。
欢迎评论区交流,如果发现文章里哪些细节和你了解的官方情况不一致,也欢迎指出来。
