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

把一个 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 无关联、不共享权重或代码。
相关推荐
kaixin_啊啊2 小时前
【零基础学AI】第 19 章课后练习与答案
人工智能
A小码哥2 小时前
RAG 召回差?先改切片和重排
人工智能
瓶瓶持续学习中2 小时前
空间智能与物理AI:国内厂商如何与英伟达协同发展
大数据·人工智能·信息可视化·架构
阿乔外贸日记2 小时前
外贸风控|美国市场7大类欺诈模式解析与风控框架
大数据·人工智能·物联网·microsoft·智能手机
金融小师妹2 小时前
时间序列分析:历次联储利率调整与政策周期变化研究
大数据·人工智能·逻辑回归
水如烟2 小时前
孤能子视角:算账要算到存续------脱锚环境下的能效判据层上移:安全与发展、认知与伦理,是存续层能效的两个对位轴
人工智能
tellmewhoisi2 小时前
深度学习24:PyTorch:Sequential构建模型
人工智能·pytorch·深度学习
Elastic 中国社区官方博客2 小时前
使用 OpenTelemetry 在 Elastic 中实现 Claude 平台的人工智能 agent 可观测性
大数据·运维·服务器·人工智能·elasticsearch·信息可视化·全文检索
天远Date Lab2 小时前
零信任架构实战:基于天远企业股权变更构建自动化招投标供应商股东穿透审查网关
java·人工智能·架构·自动化