
Jev
从零构建 Jev 式快速决策引擎:用 Qwen 开源模型改造分类头
Jev 是一类把大模型的理解能力压缩成单次前向判断的决策引擎,输出结构化结果而非整段文字。本文说明它解决什么问题,并给出用 Qwen2.5-Coder-1.5B-Instruct 作为骨干、替换分类头、准备数据并训练出本地快速分类器的完整流程。
传统程序依赖人写死的 if-then 规则,遇到边界模糊的输入就会失效;而聊天式大模型虽然能理解语境,却要逐词生成整段回答,速度慢、输出不稳定,程序还得从自然语言里再抠出结论。Jev 这类模型走的是另一条路:它保留大模型对语言的理解能力,但把「逐词生成」的输出头换成「分类」输出头,一次前向传播直接给出结构化判断,例如 [CATEGORY: LETTER, CONFIDENCE: 98%]。它适合需要在程序里做高速分流、打标、判定的开发者,例如先把客服消息分成技术问题、账单问题或垃圾信息,再决定交给哪个更慢更贵的下游流程。站内也有对应的工具条目可以参考:Jev。本文讲的是如何用开源权重模型自己搭一个同类引擎。
准备工作
在动手之前,需要先确认几件事。
- 硬件:本文选用的是 1.5B 量级的轻量模型,目标是能在普通电脑上跑起来,不需要数据中心级显卡。具体显存与内存占用取决于精度和批次大小,请以实际运行情况为准。
- Python 环境:需要一个可用的 Python 环境,并能安装 PyTorch、Transformers 等常规依赖。
- 模型下载:首次运行会从 Hugging Face 拉取权重文件,需要网络可达;之后会复用本地缓存,不再重复下载。
- 数据:需要自己准备一份带标签的文本样本集,标签就是你想让引擎输出的类别。
本文的示例项目由三个脚本组成:model.py、train.py 和 check_names.py。依赖通过 requirements 文件统一安装:
pip install -r requirements.txt
关于模型版本、许可证、可用地区以及各家平台的配额与价格,请以官网当前信息为准,本文不逐一列举。
操作步骤
第一步:理解为什么要换掉输出头
主流聊天模型本质上是高级自动补全。给它一段提示,它计算下一个最可能的词元并输出,然后把原提示加上新词元再算下一个,如此循环,直到生成停止词。这个过程叫自回归生成。它的代价是:哪怕你只想知道「这封邮件是不是垃圾邮件」,模型也要把整句话一个词一个词拼出来,程序再去解析这句话。
从结构上看,一个模型可以拆成两部分:骨干和头。骨干是底层的大网络,负责理解语言、语境和细微差别;头则负责把这份理解转成最终输出。聊天模型的头是为逐词生成设计的,但头是可替换的。把生成头摘掉、换上一个分类头,骨干的理解能力原封不动,输出方式却从「循环多次」变成「单次通过」——这正是速度差异的来源。
第二步:加载骨干,不要生成头
先选一个开源骨干作为地基。本文使用 Qwen2.5-Coder-1.5B-Instruct:体积小,且本身已经理解代码,便于后面的演示。分词器负责把你的文本切成模型认识的小块;AutoModel 只请求骨干本身,下载包里虽然带着生成头,但这次调用不会把它取出来。
from transformers import AutoModel, AutoTokenizer
model_id = "Qwen/Qwen2.5-Coder-1.5B-Instruct"
tokenizer = AutoTokenizer.from_pretrained(model_id)
backbone = AutoModel.from_pretrained(model_id)
第一次运行会从 Hugging Face 下载文件,之后复用本机已有的副本。
第三步:接上一个空的分类头
接下来做「换头」。骨干参数被冻结——requires_grad = False 表示这些数值不允许再变;self.score 是新的头,输出两个分数,因为本示例只有两个类别:match 和 mismatch。头的宽度与骨干的隐藏维度对齐,两块才能接上。
import torch
from torch import nn
class JevModel(nn.Module):
def __init__(self, backbone):
super().__init__()
self.backbone = backbone
for parameter in self.backbone.parameters():
parameter.requires_grad = False
self.score = nn.Linear(backbone.config.hidden_size, 2)
def forward(self, input_ids, attention_mask):
with torch.no_grad():
hidden = self.backbone(
input_ids=input_ids,
attention_mask=attention_mask,
).last_hidden_state
last = attention_mask.sum(dim=1) - 1
pooled = hidden[torch.arange(hidden.size(0)), last]
return self.score(pooled.float())
这里有几个关键点需要说清楚。
- 单次通过:骨干从左到右读完整段文本,读到最后一个真实词元时,它已经看过全部内容,所以头只需要看这个位置,直接吐出两个分数。
- 不记梯度:
torch.no_grad()告诉计算机不要为改动骨干而保留中间记录,只有那个小小的头允许学习。 - 不写任何词:整个前向过程不生成一个词,输出就是分数。
- 取最后有效位置:
attention_mask.sum(dim=1) - 1算出每条样本最后一个真实词元的下标,用它做池化,避免把填充位置当成内容。
第四步:准备训练数据
骨干虽然已经理解语言,但新接上的头完全没训练过,它还不知道怎么把这份理解映射到你的类别上。要补上这一步,就得给模型一批清晰的样例,也就是训练数据集。比如你想让引擎给邮件分流,就准备成千上万条「文本 + 正确类别」的配对,标出哪些是垃圾邮件、哪些是紧急问题。训练过程中,庞大的骨干基本保持冻结不变,只有那个小头在更新。
数据准备上有几点值得注意:
- 类别要固定:分类头的输出维度在初始化时就定死了。本示例是 2 类,如果你要分 5 类,就要把
nn.Linear的第二个参数改成 5,并保证标签编号与之一致。 - 标签要一致:同一类别的文本必须用同一个标签,否则头学到的映射会互相冲突。
- 样本要覆盖边界情况:规则程序最容易在奇怪输入上翻车,训练集里也应该包含这类难例,否则引擎在真实数据上同样会失手。
- 文本长度:超长文本会被截断,截断策略要与你的实际输入分布匹配。
第五步:训练并保存
训练脚本要做的事情是:加载骨干、套上分类头、把文本转成词元、前向计算分数、与标签比对算损失、反向传播更新头的参数。由于骨干被冻结,需要更新的参数量很小,训练开销主要花在前向计算上。
训练完成后,把头的权重单独保存下来即可。骨干可以从原始模型重新加载,头则从你保存的文件恢复,这样整个引擎的体积增长很小。
第六步:推理与验证
推理时不再需要标签,只需要把文本喂进去,取分数最高的类别作为输出。示例项目里的 check_names.py 就是用来做这类验证的脚本:给定若干条输入,打印模型给出的判定,人工核对是否符合预期。
验证时建议关注两类情况:一类是明显应该判对的样本,用来确认流程通了;另一类是刻意构造的模糊样本,用来观察模型在边界上的表现。如果边界样本错得离谱,通常说明训练数据里缺少同类样本,而不是架构本身有问题。
一个完整示例
下面把前面的步骤串成一个最小可运行的流程。假设我们要判断一段代码片段与某个函数名是否匹配,类别为 match 和 mismatch。
第一步,安装依赖。
pip install -r requirements.txt
第二步,写 model.py,内容就是前面给出的 JevModel 定义:加载骨干、冻结参数、接上两维分类头、在最后一个有效位置池化后输出分数。
第三步,准备数据。构造一个列表,每项包含一段文本和一个标签(0 表示 mismatch,1 表示 match)。文本可以是「函数名 + 代码片段」的拼接形式,标签表示两者是否对应。
第四步,写 train.py。流程如下:
- 用
AutoTokenizer.from_pretrained(model_id)加载分词器。 - 用
AutoModel.from_pretrained(model_id)加载骨干。 - 用骨干实例化
JevModel。 - 把文本批量编码为
input_ids与attention_mask。 - 前向得到两个分数,用交叉熵损失与标签比对。
- 反向传播,只更新
self.score的参数。 - 多轮迭代后保存头的权重。
第五步,写 check_names.py。加载骨干与保存好的头,对若干条输入做前向,打印每条输入对应的预测类别与分数。
第六步,运行验证。先跑训练脚本,再跑检查脚本。如果输出中明显匹配的样本被判为 match、明显不匹配的被判为 mismatch,说明整条链路已经打通。之后就可以把这个引擎接到你自己的程序里,作为高速分流的第一道判断。
注意事项
- 骨干冻结是设计的一部分:训练时只更新分类头,不要因为效果不理想就贸然解冻骨干。解冻会显著增加显存占用和训练时间,也容易在小数据集上过拟合。
- 类别数在初始化时确定:分类头的输出维度写死在
nn.Linear的第二个参数里。要改类别数,必须重新初始化并重新训练,不能只改标签。 - 池化位置依赖 attention_mask:如果掩码处理有误,取到的可能是填充位置,输出会变得没有意义。批量推理时尤其要检查这一点。
- 首次下载需要网络:模型权重从 Hugging Face 拉取,离线环境下需要提前准备好本地缓存。
- 输出是分数不是文字:引擎不会给你一句解释,只给每个类别的分数。需要可读解释的场景,要在下游另行处理。
- 训练数据决定上限:头是空白的,它的全部知识来自你给的样例。样例覆盖不到的情况,引擎同样判断不了。
- 版本与配额:模型版本、许可证、可用地区、平台配额与价格都可能变化,请以官网当前信息为准。