☰
把一个 Qwen3.8的聊天模型改造成 JEV 决策模型
2026/10/29 2:04:32 网站建设 项目流程

把一个 Qwen3.8的聊天模型改造成 JEV 决策模型


零、 先说清楚 JEV 是什么

普通大模型回答"这道题选 A 还是 B"的方式是生成文字:吐出 “答案是 B”,你再拿正则去解析。慢、会格式错、而且它写出来的"我有 80% 把握"是编的,不是真实置信度。

JEV 换了一种做法。它不生成文字,而是让模型一次前向直接吐出一个校准过的概率分布:

{"发送优惠券":0.141803,"推送提醒":0.844072,"不打扰":0.014125}

它只认三种题型(JEV 的原语,primitive):

kind问什么返回什么
noul是/否[P(false), P(true)]
choice2–256 个选项选一个每个选项一个概率
score0–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 文本塔,bf1625.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里:

题型槽位verbalizertoken ID
noul0–1falsetrue3721, 1802
score2–70–515–20
choice8–23A–P32–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。

IDS=[3721,1802,15,16,17,18,19,20,*range(32,48)]# 24 个 verbalizerhead=torch.nn.Linear(5120,24,dtype=torch.float32).cuda()withtorch.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%
noulAUROC0.8760.996
scoreMAE0.8420.098
KL0.4300.0186
ECE0.0500.0009

预训练已经把大部分活干完了,蒸馏只是磨尖它。这正是 108.8M 参数、5000 步就够的根本原因。


四、冻结主干,挂 LoRA

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_v3498,010老师 TypeSafe Jev 1.13 的完整输出概率分布(保留两位小数)1
openjev_v294,801程序生成的标准答案(one-hot)1
yuri_v1148,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 比较累积分布,错得越远惩罚越重。结果:scoreMAE 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 按它选。

六、训练代码骨架

官方训练代码没开源,下面按上面配方写一个最小可运行骨架(省略分布式和分桶):

importtorch,torch.nn.functionalasFfrompeftimportLoraConfig,get_peft_modelfromtransformersimportAutoModelForCausalLM,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_headIDS=[3721,1802,15,16,17,18,19,20,*range(32,48)]# 24 个 verbalizerRANGES={"noul":(0,2),"score":(2,8),"choice":(8,24)}head=torch.nn.Linear(5120,24,dtype=torch.float32).cuda()withtorch.no_grad():# 抄 lm_head 的 24 行head.weight.copy_(base.get_base_model().lm_head.weight[IDS].float())head.bias.zero_()lora_params=[pforn,pinbase.named_parameters()if"lora"innandp.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))defrps(t,p):# 序数惩罚,K=6return((t.cumsum(-1)-p.cumsum(-1))[:,:-1]**2).mean(-1)defstep(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-hotloss_kl=(t*(t.clamp_min(1e-9).log()-logp)).sum(-1)# KL(t ‖ p),natsloss=weights*(loss_kl+(0.5*rps(t,p)ifkind=="score"else0.0))loss.mean().backward()# 只有 LoRA + head 有梯度torch.nn.utils.clip_grad_norm_(trainables,1.0)opt.step();opt.zero_grad()returnloss_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:

{"per_kind":{"noul":1.0143134751376188,"choice":1.0161280671450397,"score":1.0036213515883572}}

改善微乎其微(温度本来就贴近 1)——这恰恰证明 KL 蒸馏已经把校准做进了权重里。占位标签那批yuri_v1被排除在温度拟合之外。

验证指标(25,376 条 Jev 标签行):

JEV-27B怎么读
与老师分布的平均 KL≈ 0.0170 = 完全一致
noulAUROC0.996是非题排序几乎完美
choicetop-1 一致率90.3%老师明确(top-2 差 ≥0.1)的行上95.8%
scoreMAE(0–5)0.098约十分之一档
ECE0.0009说 80% 就是 80%
未见任务族 OOD KL / top-10.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全文(本机实际在跑的):

#!/usr/bin/env bashset-euopipefailcd"$(dirname"$0")"exportCUDA_VISIBLE_DEVICES="${CUDA_VISIBLE_DEVICES:-0,1,2}"exportNCCL_P2P_DISABLE=1DP=$(awk-F,'{print NF}'<<<"$CUDA_VISIBLE_DEVICES")nohup.venv-vllm/bin/vllm serve"$PWD/JEV-27B"\--served-model-name autotrust/JEV-27B\--host127.0.0.1--port8000\--data-parallel-size"$DP"\--dtypebfloat16\--max-model-len262144\--gpu-memory-utilization0.9\--max-num-seqs64\--enable-lora --max-lora-rank32\--lora-modules jev-decision="$PWD/JEV-27B/adapter_vllm"\--logprobs-mode processed_logprobs\--enable-prefix-caching --mamba-cache-mode align\>vllm-jev.log2>&1&echo$!>vllm-jev.pidVLLM_URL=http://127.0.0.1:8000nohup.venv-vllm/bin/uvicorn decide_server:app\--host0.0.0.0--port6008--no-access-log\>jev-gateway.log2>&1&echo$!>jev-gateway.pid

逐参数解释:

参数为什么必须这样
--host 127.0.0.1vLLM只听本机,对外只暴露网关。无鉴权服务不要直接开公网
--served-model-name autotrust/JEV-27BSystem 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 alignQwen3.8 混合架构(含 Gated DeltaNet),prefix caching 需要它
NCCL_P2P_DISABLE=1保留。历史上 TP=4 曾在加载阶段报非法内存访问

停止:./stop_vllm.sh(按 PID 文件 kill 两个进程)。

验证:

exportURL=http://127.0.0.1:6008curl-s-o/dev/null-w"%{http_code}\n"$URL/health# 200 即就绪curl-s$URL/v1/models|python3-mjson.tool# 应看到两个 model id

/v1/models应返回:

{"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

curl-s$URL/v1/decide-H'Content-Type: application/json'-d'{ "kind": "choice", "state": "用户在电商平台浏览了3次同一款耳机,加入购物车但未付款,已过去2天。", "question": "现在应该采取什么动作?", "options": ["发送优惠券", "推送提醒", "不打扰"]}'
{"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):

importrequests 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 接口

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 无关联、不共享权重或代码。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询