把一个 Qwen3.8的聊天模型改造成 JEV 决策模型
零、 先说清楚 JEV 是什么
普通大模型回答"这道题选 A 还是 B"的方式是生成文字:吐出 "答案是 B",你再拿正则去解析。慢、会格式错、而且它写出来的"我有 80% 把握"是编的,不是真实置信度。
JEV 换了一种做法。它不生成文字,而是让模型一次前向直接吐出一个校准过的概率分布:
json
{"发送优惠券": 0.141803, "推送提醒": 0.844072, "不打扰": 0.014125}
它只认三种题型(JEV 的原语,primitive):
| kind | 问什么 | 返回什么 |
|---|---|---|
noul |
是/否 | [P(false), P(true)] |
choice |
2--256 个选项选一个 | 每个选项一个概率 |
score |
0--5 打分 | 六档分布 + 期望分 |
AutoTrust 把它叫做 System 1 (快思考),而原来那个未被改动的 Qwen3.8-27B 聊天/推理能力叫 System 2(慢思考)。最关键的一点是:
这两个系统跑在同一份权重、同一个 vLLM 引擎里,靠请求里的 model 字段区分。
这就是本文要讲的事:怎么在一个冻结的聊天模型上,加出一套 System 1,然后把它部署起来。
训练篇:把 Qwen3.8 聊天模型改造成 JEV 决策模型
下面只讲训练这一段:怎么让一个只会"生成文字"的聊天模型,学会"一次前向直接吐出校准概率分布"的 decide 模式。按"为什么能这样训 → 训什么 → 怎么训 → 训完怎么验"的顺序展开。

一、核心思路:BoE(Blocks of Experts)
不要把整个 27B 模型拿去微调成一个"决策模型"。AutoTrust 的做法叫 Blocks of Experts :把强预训练模型当成一个冻结的专家块 ,另外训练一个小的、可拆卸的专家块,专门负责单一能力(这里是 JEV 决策)。
| 块 | 是什么 | 参数量 | 训练? |
|---|---|---|---|
| 主干 | Qwen3.8-27B 文本塔,bf16 | 25.6B | 否,逐位冻结 |
| System 2 块 | 原始 lm_head(248,320 × 5120) |
属于主干 | 否 |
| System 1 块 | LoRA r=16 + 24 槽 fp32 决策头 | 108.8M + 122,904 | 是 |
| 路由 | 请求里的 model 字段 |
--- | --- |
可训练参数 108,789,760,只占主干的 0.43%。这带来两个直接好处:
- System 2 零损失 :主干和
lm_head一个字节没改,HumanEval 78.0% 与基座逐字节一致。 - 便宜:1 × B200 约 9.2 GPU 小时、5000 步就够。
二、训练前提
1. 一个固定的答题模板(bare-v1)
所有训练数据、所有推理请求都套同一个模板,整段当一个普通字符串 tokenize:
[kind] choice
[state] 用户在电商平台浏览了3次同一款耳机,加入购物车但未付款,已过去2天。
[question] 现在应该采取什么动作?
[options]
A) 发送优惠券
B) 推送提醒
C) 不打扰
[decision]:
三个要点:
- 不套 chat 模板 ,
add_special_tokens=False。 - 选项原文换成字母
A/B/C...,字母到原文的对应由调用方记住。这就是 verbalizer:把"第 2 个选项"绑定到一个具体 token 上。 [kind]放在[state]前面------这不是为了好看,是为了让同题型的请求能命中 prefix cache。
2. 选定 24 个答案 token(verbalizer)
JEV 三种题型只需要 24 个单 token 就能表达所有答案,词表 ID 写死在 judge_config.json / decision_head.json 里:
| 题型 | 槽位 | verbalizer | token ID |
|---|---|---|---|
noul |
0--1 | false true |
3721, 1802 |
score |
2--7 | 0--5 |
15--20 |
choice |
8--23 | A--P |
32--47 |
注意它们都是单 token :A 是 32、false 是 3721,在 Qwen 词表里本来就是独立条目。超过 16 个选项继续用 Q--Z、双字母 AA/AB/...(同样单 token),choice 最多支持 256 个选项,不需要重新训练。
三、用 lm_head 的 24 行初始化决策头
决策头就是一个线性层 W[24, 5120] + b[24],12.3 万参数。它的 W 不是随机初始化 ,而是直接抄 lm_head 里那 24 个 verbalizer token 对应的 24 行;b 初始化为 0。
python
IDS = [3721,1802, 15,16,17,18,19,20, *range(32,48)] # 24 个 verbalizer
head = torch.nn.Linear(5120, 24, dtype=torch.float32).cuda()
with torch.no_grad():
head.weight.copy_(base.get_base_model().lm_head.weight[IDS].float())
head.bias.zero_()
为什么这一步这么关键? 抄完之后,在一条训练数据都没看的时候,这个决策头的输出就等于原聊天模型"下一个 token 只能从这 24 个里选"时的分布(官方验证 |Δp| < 1e-5,实测 4.5e-07)。也就是说,它天生就是个 zero-shot 分类器:
| 训练前(step 0) | 训练后(step 5000) | |
|---|---|---|
choice 与老师一致率 |
58.1% | 90.3% |
noul AUROC |
0.876 | 0.996 |
score MAE |
0.842 | 0.098 |
| KL | 0.430 | 0.0186 |
| ECE | 0.050 | 0.0009 |
预训练已经把大部分活干完了,蒸馏只是磨尖它。这正是 108.8M 参数、5000 步就够的根本原因。
四、冻结主干,挂 LoRA
python
lora = dict(r=16, lora_alpha=32, lora_dropout=0.05,
target_modules=["in_proj_qkv", "in_proj_z", "out_proj", # 线性注意力
"q_proj", "k_proj", "v_proj", "o_proj", # 全注意力
"gate_proj", "up_proj", "down_proj"]) # MLP
Qwen3.8 是混合架构:64 层里 48 层线性注意力(Gated DeltaNet)、16 层全注意力(每 4 层一次)。所以每层 LoRA 模块数不一样:
48 层 × (in_proj_qkv, in_proj_z, out_proj, gate, up, down) = 48 × 6 = 288
16 层 × (q, k, v, o, gate, up, down) = 16 × 7 = 112
合计 400 个模块
在 adapter_vllm/adapter_model.safetensors 上数:去掉 lm_head 那一个,正好 400 个 lora_A。
关键:训练时 lm_head 不在 target_modules 里。 主干 25.6B 全程冻结,只有 LoRA 和决策头可训练。
五、前向路径与损失函数
前向
模板文本 → tokenize → 冻结主干 + LoRA
→ 只取最后一个 token(":")的 5120 维隐状态 h
→ z = W_head · h + b_head # 24 个 logits
→ 按题型只保留对应槽位,其余 mask 掉
→ softmax → p # n 维,n = 选项数
损失
官方原文只有一行:KL(target ‖ model) over active slots + 0.5 · RPS for score。展开成完整形式:
L = w · [ KL(t ‖ p) + 0.5 · 𝟙(kind = score) · RPS(t, p) ]
(1)KL 项------前向 KL,软标签蒸馏
KL(t ‖ p) = Σ_i t_i · log( t_i / p_i ) 单位 nats
老师分布 t 在前(reference),模型 p 在后。这个方向是 mass-covering(覆盖式):凡是老师给了概率的选项,模型都必须给,不能塌缩到只押 argmax。
这正是"校准是目标函数的副产品"的原因------训完拟合出来的温度是 1.014 / 1.016 / 1.004,全都贴近 1,ECE 只有 0.0009,几乎不需要事后校正。
一个等价关系:因为 KL(t‖p) = CE(t,p) − H(t),而 H(t) 对模型是常数,所以最小化这个 KL 等价于拿老师的完整分布当软标签做交叉熵。对 one-hot 标签它就退化回普通 CE。
t 有三种来源,对应三条数据流:
| 数据流 | 条数 | t 是什么 |
样本权重 w |
|---|---|---|---|
yuri_v3 |
498,010 | 老师 TypeSafe Jev 1.13 的完整输出概率分布(保留两位小数) | 1 |
openjev_v2 |
94,801 | 程序生成的标准答案(one-hot) | 1 |
yuri_v1 |
148,154 | 占位标签 [0.5, 0.5],本身没有信息量 |
0.05 |
语料 jev-distill-corpus-v3 共 train 65.6 万条(49.8 万老师分布 + 9.5 万程序标签 + 14.8 万占位标签)。
(2)RPS 项------只给 score 题加的序数惩罚,权重 0.5
RPS(t, p) = 1/(K−1) · Σ_{k=1..K−1} ( T_k − P_k )² K = 6
T_k = Σ_{i≤k} t_i (目标的累积分布)
P_k = Σ_{i≤k} p_i (模型的累积分布)
0--5 是有序 档位。单靠 KL 无法区分"真值 0 却预测 5"和"真值 0 却预测 1"------这两个错误的 KL 一样大。RPS 比较累积分布,错得越远惩罚越重。结果:score MAE 0.098(约十分之一档),RPS 0.0077。
(3)这个损失顺带解释了三个现象
- 为什么全词表分布是乱的。 损失只覆盖 24 个槽位,
lm_head其余 248,296 行完全没有梯度、没有任何约束。实测:带 LoRA 时同一道题全词表 Top-10 概率合计只有 0.027 ,97% 概率散落别处;纯 base 是 0.895 。所以推理时allowed_token_ids屏蔽是必须的。 - 为什么 System 2 零损失。 全程没有语言建模损失,主干和
lm_head一个字节没改。 - 为什么 KL 既是训练目标又是头号评测指标。 训练轨迹表追踪的就是验证集 KL,发布 checkpoint 按它选。
六、训练代码骨架
官方训练代码没开源,下面按上面配方写一个最小可运行骨架(省略分布式和分桶):
python
import torch, torch.nn.functional as F
from peft import LoraConfig, get_peft_model
from transformers import AutoModelForCausalLM, AutoTokenizer
TOK = AutoTokenizer.from_pretrained("Qwen/Qwen3.8-27B")
base = AutoModelForCausalLM.from_pretrained("Qwen/Qwen3.8-27B",
dtype=torch.bfloat16, device_map="cuda")
base.requires_grad_(False) # 主干冻结
base = get_peft_model(base, LoraConfig(
r=16, lora_alpha=32, lora_dropout=0.05,
target_modules=["in_proj_qkv","in_proj_z","out_proj",
"q_proj","k_proj","v_proj","o_proj",
"gate_proj","up_proj","down_proj"])) # 注意:不含 lm_head
IDS = [3721,1802, 15,16,17,18,19,20, *range(32,48)] # 24 个 verbalizer
RANGES = {"noul": (0,2), "score": (2,8), "choice": (8,24)}
head = torch.nn.Linear(5120, 24, dtype=torch.float32).cuda()
with torch.no_grad(): # 抄 lm_head 的 24 行
head.weight.copy_(base.get_base_model().lm_head.weight[IDS].float())
head.bias.zero_()
lora_params = [p for n, p in base.named_parameters() if "lora" in n and p.requires_grad]
trainables = lora_params + list(head.parameters())
opt = torch.optim.AdamW([
{"params": lora_params, "lr": 1e-4},
{"params": head.parameters(), "lr": 2e-4},
], betas=(0.9, 0.98))
def rps(t, p): # 序数惩罚,K=6
return ((t.cumsum(-1) - p.cumsum(-1))[:, :-1] ** 2).mean(-1)
def step(kind, texts, targets, weights):
"""一个 batch 内题型相同(官方就是按 kind 分层 + 按长度分桶的),
所以每行的选项数 n 一致,张量是规整的。"""
s, _ = RANGES[kind]
n = targets.size(1)
ids = TOK(texts, return_tensors="pt", padding=True,
add_special_tokens=False).to("cuda")
B = ids["input_ids"].size(0)
pos = ids["attention_mask"].sum(1) - 1 # 最后一个非 padding 位置
hs = base.model(**ids).last_hidden_state # [B, L, 5120]
h = hs[torch.arange(B, device=hs.device), pos].float() # [B, 5120]
z = head(h)[:, s : s + n] # [B, n],其余槽位直接切掉
logp = torch.log_softmax(z, -1)
p = logp.exp()
t = targets.to(logp.device) # [B, n],老师分布或 one-hot
loss_kl = (t * (t.clamp_min(1e-9).log() - logp)).sum(-1) # KL(t ‖ p),nats
loss = weights * (loss_kl + (0.5 * rps(t, p) if kind == "score" else 0.0))
loss.mean().backward() # 只有 LoRA + head 有梯度
torch.nn.utils.clip_grad_norm_(trainables, 1.0)
opt.step(); opt.zero_grad()
return loss_kl.mean().item()
noul 的 n=2、score 的 n=6、choice 的 n 是选项数(2--16)。混合题型时把每行的槽位区间做成 mask 即可,但按 kind 分桶更快也更省显存。
七、训练产物
训练落盘三样东西:
JEV-27B/
├── adapter/ # peft 格式 LoRA(r=16)
├── head.safetensors # 122,904 参数:proj.weight[24,5120] + proj.bias[24]
└── calibration.json # 每题型温度(10,954 条校准集,L-BFGS 拟合)
温度校准 :为每种题型各拟合一个标量温度,存进 calibration.json:
json
{"per_kind": {"noul": 1.0143134751376188,
"choice": 1.0161280671450397,
"score": 1.0036213515883572}}
改善微乎其微(温度本来就贴近 1)------这恰恰证明 KL 蒸馏已经把校准做进了权重里。占位标签那批 yuri_v1 被排除在温度拟合之外。
验证指标(25,376 条 Jev 标签行):
| JEV-27B | 怎么读 | |
|---|---|---|
| 与老师分布的平均 KL | ≈ 0.017 | 0 = 完全一致 |
noul AUROC |
0.996 | 是非题排序几乎完美 |
choice top-1 一致率 |
90.3% | 老师明确(top-2 差 ≥0.1)的行上 95.8% |
score MAE(0--5) |
0.098 | 约十分之一档 |
| ECE | 0.0009 | 说 80% 就是 80% |
| 未见任务族 OOD KL / top-1 | 0.104 / 0.942 | 迁移能力 |
六个公开决策基准均值 84.07%,老师 TypeSafe Jev 1.13 是 83.85%(+0.22 点)。
八、部署总览:一个引擎,两个系统
训练产物要变成能服务的形态,中间还有一步打包,然后才能交给 vLLM。整体链路:
训练产物(adapter r=16 + head.safetensors)
│
▼ 打包:秩补齐 r16→32、决策头 → lm_head 上的 LoRA、偏置单独存
adapter_vllm/(451 MB)
│
▼ vLLM 注册两个 model 名字
model="autotrust/JEV-27B" → 只走 base lm_head → System 2 生成/推理
model="jev-decision" → base + LoRA 增量 → System 1 决策
│
▼ 网关 decide_server.py 实现 POST /v1/decide,其余路径反向代理
客户端:curl / requests / OpenAI SDK
8.1 为什么训练产物不能直接喂 vLLM
vLLM 只会算 lm_head · h 得到词表 logits,它不认识你那个额外的 24 类决策头。所以打包要做三件事:
① 秩补齐 r 16 → 32。 训练用 r=16,但 adapter_vllm/adapter_config.json 写的是 r: 32, lora_alpha: 64.0。缩放系数 α/r = 64/32 = 2,和训练时 32/16 = 2 一样,数值完全不变 ,只是把 lora_A 的后 16 行补零,满足 vLLM 的 --max-lora-rank 32。实测 400 个主干模块的 lora_A 每一个都恰好 16 行非零、16 行全零。
② 决策头 → lm_head 上的 LoRA(核心)。 训练时决策头对第 j 个槽位算 z_j = W_j·h + b_j;vLLM 对词表第 v 行算 logit_v = (lm_head_v + 2·B_v A)·h。要在这 24 个 verbalizer 行上对所有 h 相等,线性部分只需:
2 · B_{v_j} A = W_j − lm_head_{v_j} ≡ Δ_j
24 行拼成 Δ[24, 5120],秩最多 24 ,而打包后 LoRA 秩是 32 ≥ 24,存在精确解 。对 Δ 做 SVD,取 B = (1/2)·U·S^(1/2)、A = S^(1/2)·Vᵀ。B[248320, 32] 只有那 24 行非零。
本机实测:lm_head lora_B 非零行数 24,与 verbalizer_ids 完全一致;lora_A 奇异值前 23 个 0.72...0.22、第 24 个 1e-4、后 8 个全 0(有效秩恰好 24);max |lm_head[ids] + 2·B[ids]@A − W_head| = 6.09e-05,即 bf16 存储精度量级。这是数学上精确的编码,不是近似拟合。
③ 偏置单独存。 b_j 是常数项,LoRA 是 h 的线性映射(B·A·h 没常数项),装不进去。所以 b 存在 decision_head.json 的 bias 字段,推理时由网关加回。decision_head.json 的 note 写得很清楚:decision logits = lm_head-LoRA logprobs of verbalizer ids + bias, then per-kind temperature。
最终产物:
adapter_vllm/
├── adapter_config.json r=32, α=64, target_modules 含 lm_head
├── adapter_model.safetensors 225,689,600 参数 / 451 MB
│ = 主干 LoRA(补零后 217.6M) + lm_head LoRA(8.1M)
└── decision_head.json bias[24]、verbalizer_ids[24]、槽位表
一个容易搞错的点:
B在其他行是零,但其他 token 的 logits 依然和 base 不一样 ------因为主干 LoRA 改变了h本身。只有那 24 行被"修正"回决策头该有的值。
8.2 为什么不把 LoRA 合并进主干
合并了确实只要一份权重一个头,但代价是生成质量。JEV-9B 实测:
| HumanEval pass@1 | 散文困惑度 | |
|---|---|---|
| Qwen3.5-9B 基座 | 70.7% | 3.15 |
| LoRA 合并进主干后 | 61.6% | 3.30 |
掉了 9 个点,而困惑度几乎没动------困惑度看不出决策 LoRA 对代码能力的破坏。保持主干纯净、只在决策请求时挂 LoRA,就消掉了这个 trade-off。决策服务启动时在显存里合并(in-memory),延迟和真正合并的 bundle 一样。
九、启动
start_vllm.sh 全文(本机实际在跑的):
bash
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
export CUDA_VISIBLE_DEVICES="${CUDA_VISIBLE_DEVICES:-0,1,2}"
export NCCL_P2P_DISABLE=1
DP=$(awk -F, '{print NF}' <<<"$CUDA_VISIBLE_DEVICES")
nohup .venv-vllm/bin/vllm serve "$PWD/JEV-27B" \
--served-model-name autotrust/JEV-27B \
--host 127.0.0.1 --port 8000 \
--data-parallel-size "$DP" \
--dtype bfloat16 \
--max-model-len 262144 \
--gpu-memory-utilization 0.9 \
--max-num-seqs 64 \
--enable-lora --max-lora-rank 32 \
--lora-modules jev-decision="$PWD/JEV-27B/adapter_vllm" \
--logprobs-mode processed_logprobs \
--enable-prefix-caching --mamba-cache-mode align \
> vllm-jev.log 2>&1 &
echo $! > vllm-jev.pid
VLLM_URL=http://127.0.0.1:8000 nohup .venv-vllm/bin/uvicorn decide_server:app \
--host 0.0.0.0 --port 6008 --no-access-log \
> jev-gateway.log 2>&1 &
echo $! > jev-gateway.pid
逐参数解释:
| 参数 | 为什么必须这样 |
|---|---|
--host 127.0.0.1 |
vLLM 只听本机,对外只暴露网关。无鉴权服务不要直接开公网 |
--served-model-name autotrust/JEV-27B |
System 2 的 model 字段值 |
--data-parallel-size 3 |
三张卡各一份完整副本,提吞吐 |
--max-model-len 262144 |
开满 256K。显存不够降到 131072 |
--gpu-memory-utilization 0.9 |
权重占掉后剩下的全给 KV cache |
--max-num-seqs 64 |
每副本最大并发序列 |
--enable-lora --max-lora-rank 32 |
必须 32,打包后的 adapter 是 r=32(补零过) |
--lora-modules jev-decision=... |
注册第二个 model 名字,指向 adapter_vllm/ |
--logprobs-mode processed_logprobs |
必须,默认模式的 logprob 不是我们要的(见 11.1 坑①) |
--enable-prefix-caching |
同一 state 问多个问题时复用前缀 |
--mamba-cache-mode align |
Qwen3.8 混合架构(含 Gated DeltaNet),prefix caching 需要它 |
NCCL_P2P_DISABLE=1 |
保留。历史上 TP=4 曾在加载阶段报非法内存访问 |
停止:./stop_vllm.sh(按 PID 文件 kill 两个进程)。
验证:
bash
export URL=http://127.0.0.1:6008
curl -s -o /dev/null -w "%{http_code}\n" $URL/health # 200 即就绪
curl -s $URL/v1/models | python3 -m json.tool # 应看到两个 model id
/v1/models 应返回:
json
{"object":"list","data":[
{"id":"autotrust/JEV-27B","max_model_len":262144},
{"id":"jev-decision","parent":"autotrust/JEV-27B"}]}
十、两种调用方式
10.1 decide 模式(System 1)------ 走 /v1/decide
bash
curl -s $URL/v1/decide -H 'Content-Type: application/json' -d '{
"kind": "choice",
"state": "用户在电商平台浏览了3次同一款耳机,加入购物车但未付款,已过去2天。",
"question": "现在应该采取什么动作?",
"options": ["发送优惠券", "推送提醒", "不打扰"]}'
json
{"kind":"choice","options":["发送优惠券","推送提醒","不打扰"],
"probabilities":{"发送优惠券":0.141803,"推送提醒":0.844072,"不打扰":0.014125},
"choice_index":1,"choice":"推送提醒"}
noul 题额外返回 noul = P(true);score 题额外返回 score = Σ i·pᵢ。
批量(多个问题共用同一段 state,服务端并发发出,共享前缀命中 prefix cache):
python
import requests
r = requests.post(f"{URL}/v1/decide", json={
"state": state,
"questions": [
{"name": "refund", "kind": "noul", "question": "Is the customer asking for a refund?"},
{"name": "topic", "kind": "choice", "question": "What is this about?",
"options": ["bug", "feature", "question"]},
]}).json()
10.2 chat 模式(System 2)------ 标准 OpenAI 接口
bash
curl -s $URL/v1/chat/completions -H 'Content-Type: application/json' -d '{
"model": "autotrust/JEV-27B",
"messages": [{"role":"user","content":"用一句话介绍什么是安全库存"}],
"max_tokens": 200,
"chat_template_kwargs": {"enable_thinking": false}}'
注意 :enable_thinking 默认是开启的 ,忘了关会慢两个数量级。开思考链时 max_tokens 建议给到 4096 以上。
参考
- autotrust/JEV-27B 模型卡,Hugging Face(Apache-2.0)
- SargeDev/jev-distill-corpus-v3 蒸馏语料(Apache-2.0,
openjev_v2流额外 CC0) - TypeSafe Jev 1.13(老师模型,闭源托管 API)。
noul/choice/score三个原语与 System One 框架来自 TypeSafe AI;autotrust/JEV-27B 是 AutoTrust AI 用公开数据独立训练的学生模型,与 TypeSafe AI 无关联、不共享权重或代码。