这篇笔记记录了我用一张 RTX 3050 显卡,把 JevForge 从环境搭建跑通到本地服务的全过程。虽然名字叫“从零到一”,但模型并不是从随机初始化开始练的,而是基于预训练权重起步的微调。
这次折腾下来,我发现最难的不是敲几条命令行命令。从配环境、转数据,到被显存卡脖子、选检查点,再到怎么读懂那些评估指标,每一步都在反复。跑通之后,我才算真正理清了整个脉络:模型是怎么给选项打分的,训练到底改变了什么,以及怎么把训好的结果接进自己的代码里。
1. JevForge
我在训练过程中用的是 JevForge,一个开源的 Jev 风格结构化决策工具。它主要处理三种问题:
| 类型 | 要回答的问题 | 结果形式 |
|---|---|---|
| Choice | 从给定候选中选哪一个? | 候选概率分布与选项 |
| Noul | 一个陈述是否成立? | 真/假判断及概率 |
| Score | 某个属性处于哪个等级? | 等级分布与评分结果 |
开源项目对应的任务是网页决策:根据当前的页面状态和任务,判断下一步该点哪个元素。这里有个前提:问题必须有明确的选项或判断标准。如果目标是“分析情况并给出完整方案”,那就得先把它拆成几个能独立评估的小问题,再由代码去拼结果。
概念澄清:Jev 的接口设计、JevForge 的实现,以及实际跑的模型,其实是三个概念。JevForge 在本地用 Qwen3.5-0.8B 作为预训练底座,提取隐藏表示后接了一个候选打分头,并不会去逐个生成 token 文本。看源码的话,它是“因果语言模型底座 + 标量候选头”的组合。所以,不要因为它的输出是分类或判断,就管它叫 BERT 式的 Encoder-only 模型,更不能以此去推断原版 Jev 的内部结构。
候选是怎么变成概率的?
对于一个有 K 个候选的问题,程序会构造 K 条输入。每条输入都包含相同的状态和问题,只是带上了不同的候选。底座模型算出隐藏表示后,取每条输入最后一个有效 token 的表示,扔给一个共享的两层 GELU 打分器:
同一状态 + 同一问题
│
┌───────────────┼───────────────┐
候选 A 候选 B 候选 C
│ │ │
底座隐藏表示 底座隐藏表示 底座隐藏表示
│ │ │
└────── 共享候选打分头 ──────────┘
│
logits [a, b, c]
│
在本题候选内做 softmax
│
概率 [p(A), p(B), p(C)]
重点在于:只能在同一个问题的候选之间做归一化,绝对不能用其他问题的候选来算。这个流程完全不需要生成解释文本,输出结果可以直接给代码用。
2. 环境与数据准备
我的运行环境是 Windows PowerShell + NVIDIA GeForce RTX 3050,Python 环境用的是 uv 管理。(因为没存完整的驱动、CUDA 和 PyTorch 版本记录,不做更多记录)。
先建虚拟环境,装依赖:
git clone https://github.com/zwliJay/jev-forge.git
cd jev-forge
pip install uv
uv venv
.\.venv\Scripts\Activate.ps1
uv pip install -r requirements.txt
因为仓库自带的流水线是 Bash 脚本,在 PowerShell 里跑不了,所以我把它拆成了下载、构建、训练、预测和评估几步分开跑,这样哪一步报错也容易查。下面的命令是基于我当时的本地版本整理的,如果你拉取的是最新代码,参数可能会有变化。
下载并锁定底座模型
$jevCacheDir = Join-Path $HOME ".cache\huggingface\hub"
uv run python scripts/fetch_hf_model.py Qwen/Qwen3.5-0.8B --cache-dir $jevCacheDir
Get-ChildItem (Join-Path $jevCacheDir "models--Qwen--Qwen3.5-0.8B\snapshots") -Directory
我这次用的快照是 2fc06364715b967f1860aea9cf38778875588b17。确认下载没问题后,把路径赋给变量:
$jevModelDir = Join-Path $jevCacheDir "models--Qwen--Qwen3.5-0.8B\snapshots\2fc06364715b967f1860aea9cf38778875588b17"
if (-not (Test-Path $jevModelDir)) { throw "请先下载并确认模型快照目录" }
(注:下载时我用了 HF_ENDPOINT 镜像。)
构建网页决策数据
我用的数据源是 LangAGI-Lab/Mind2Web-axtree-cleaned-lite,转换后存在 data/web 下。
uv run python -c "from huggingface_hub import snapshot_download; snapshot_download(repo_id='LangAGI-Lab/Mind2Web-axtree-cleaned-lite', repo_type='dataset', cache_dir='hf_home/hub')"
uv run python -m jevforge.build_web `
--dataset-glob "hf_home/hub/datasets--LangAGI-Lab--Mind2Web-axtree-cleaned-lite/snapshots/*/data/*.parquet" `
--output-dir data/web
$jevRecords = "data/web"
本地 manifest.json 里的数据划分是这样的(按网站分组来隔离,防止同一个网站的数据同时出现在训练集和测试集里):
| 划分 | 记录数 | 网站数 | 用途 |
|---|---|---|---|
| train | 4642 | 49 | 更新模型参数 |
| dev | 786 | 6 | 对比检查点 |
| calibration | 400 | 6 | 拟合温度等校准参数 |
| test | 800 | 8 | 测试集评估 |
| ood | 386 | 4 | OOD 划分评估 |
(报告里的 test 是 1600 个问题、OOD 是 772 个问题,是 Choice 和 Noul 两类问题加起来的总数,不是单类有这么多题。)
训练记录和接口请求的格式类型
我之前差点搞混。按 JevForge 的规范,训练用的 JSONL 除了 request,还得有 targets 等监督字段。下面是个 Choice 单题的示例:
{
"id": "example-choice-1",
"schema_version": "jevforge-record-v1",
"split": "train",
"source_group": "example-site",
"request": {
"state": "目标:打开设置。页面元素:e1 设置,e2 取消。",
"questions": {
"action": {
"type": "choice",
"instructions": "下一步应选择哪个元素?",
"criteria": {"e1": "设置", "e2": "取消"}
}
}
},
"targets": {"action": {"e1": 1.0, "e2": 0.0}},
"target_kinds": {"action": "uniform_over_positive_elements"},
"gold": {"action": "e1"},
"meta": {"positive_ids": ["e1"]}
}
目标分布必须覆盖所有候选,且总和为 1。如果有多个选项都算对,可以直接给正候选分配概率。另外,标签必须有确凿依据,如果用模型生成标签,一定要人工审核,不能把待评估模型的预测直接当真理。
3. 跑通训练:遇到的两个大坑
依赖安装失败
当时装 pyarrow 报错,疑似遇到了系统拦截,但没有保留足够的日志来确定原因。我试着固定版本并换用复制模式重装:
uv pip install "pyarrow==17.0.0" --reinstall --no-cache --link-mode=copy
当时我还试过关掉系统的智能应用控制,不建议大家完全照做,但确实有效果。遇到这类报错,优先核对报错文件、Python 和 wheel 的兼容性,看看安全中心记录。
显存炸了,训练极慢
一开始我把 microbatch-tokens 设成 12000,结果观察到专用显存直接占满,开始爆共享内存,速度断崖式下跌。这说明显存压力得优先处理。不过光看任务管理器看不出具体的回退机制,也不能随便给出一个固定降速倍数。
最后我保存的配置是 microbatch_tokens=4000。这个参数限制的是输入微批次的 token 预算,因为一道题展开成多个候选后,计算量会飙升。调显存的时候得综合考虑序列长度、候选数、问题批次和微批次预算,不能光看“模型不到 1B”就觉得肯定跑得动。
4. 从 200 步验证到 1200 步监督训练
我先跑了 200 步确认链路通了,再开始配置 1200 步的正式训练。下面的命令改用独立输出目录,方便再次运行时保留试跑结果:
$jevSftDir = "runs/my_first_jev_1200"
uv run python -m jevforge.train `
--records $jevRecords `
--output-dir $jevSftDir `
--base-model $jevModelDir `
--backbone-init pretrained `
--max-length 768 `
--steps 1200 `
--head-steps 12 `
--questions-per-batch 8 `
--microbatch-tokens 4000 `
--eval-every 50
历史产物我留在了 runs/my_first_jev。检查点配置里记了 seed=17、Brier 权重 0.5、底座学习率 2e-5、打分头学习率 2e-4,以及余弦学习率调度。
重点:配置写了 1200 步,不代表最终评估用的就是第 1200 步的权重。系统保存的 best_step 其实是 500,温度为 1.215。所以,后面我说的“1200 步运行”,指的是这次完整训练选出来的最优检查点,而不是选择最后一步的权重。
数据作用:dev 用来挑模型,calibration 用来做校准,test 和 OOD 用来出报告。
5. 后训练:我实际跑的 RLCD 流程
监督训练完,我接着跑了 RLCD 后训练模块。配置记的是 mode=exact,跑了 200 步,最优检查点就是第 200 步。这个模式会直接算候选分布下的效用目标,再结合 Brier 项和对监督模型的 KL 约束。
先用一个采样式例子直观说明 RLCD 的训练循环:
for step in 1..200:
for question in batch(6 个问题):
1. 采样 8 个决策
2. 算 utility 和 calibration → reward
3. 组内归一化 → advantage
4. 策略梯度更新决策头
5. 加 KL 约束
每 50 步:在 300 题 dev 集上评估 → 记录 ce/brier/utility/kl/top1
200 步后:拟合温度 → 在 test/ood 全量评估
上面是帮助理解采样、反馈和更新关系的简化示意。对照本地 JevForge 的 sampled 实现,采样 reward 来自 utility,advantage 通过减去组内平均 reward 得到;Brier 校准项和 KL 项另行加入总损失,更新范围包括底座和决策头。
如果使用 exact 模式,就直接对候选分布求期望,不需要“采样 8 个决策 → 计算 advantage”这一步。我这次实际运行的是这个模式,流程如下:
for step in 1..200:
for question in batch(6 个问题):
1. 计算当前模型和固定参考模型的候选分布
2. 直接计算候选分布下的期望 utility、Brier 和 KL
3. loss = -utility + 0.5 × Brier + 0.02 × KL
汇总批次损失,反向传播并更新底座和决策头
每 50 步:在最多 300 题 dev 集上评估 → 记录 ce/brier/utility/kl/top1
200 步后:加载最佳检查点并拟合温度 → 另行在 test/ood 全量评估
主要参数如下:
| 参数 | 值 |
|---|---|
| 训练步数 | 200 |
| 每批问题数 | 6 |
| microbatch token 预算 | 10000 |
| 底座/打分头学习率 | 5e-6 / 5e-5 |
| 效用/Brier/KL 权重 | 1.0 / 0.5 / 0.02 |
| 评估间隔 | 50 步 |
| dev 问题上限 | 300 |
| 随机种子 | 29 |
这是实际保存下来的配置,而且因为训练路径和监督阶段不一样,在其他的设备上不能光凭 token 预算去比显存需求。
后训练结束后,保存的温度是:Choice 1.5915、Noul 4.8265。所以,下面展示的结果是包含了温度校准的最终流水线结果。
6. RLCD结果
下面的数值保留了原记录精度。200 步是我笔记里的数据;1200 步运行和 RLCD 结果与本地两份 metrics.json 完全一致。
Choice:候选选择和概率分配都变好了
| 指标 | 划分 | 200 步试跑 | 1200 步运行 | RLCD 后 |
|---|---|---|---|---|
| CE ↓ | test | 1.8522 | 1.6711 | 1.5910 |
| CE ↓ | OOD | 1.8408 | 1.5518 | 1.3894 |
| Top-1 in positives ↑ | test | 0.4037 | 0.4387 | 0.4637 |
| Top-1 in positives ↑ | OOD | 0.4041 | 0.4793 | 0.5311 |
| Exact gold ↑ | test | 0.3925 | 0.4313 | 0.4550 |
| Exact gold ↑ | OOD | 0.3860 | 0.4585 | 0.5078 |
| Mass on positives ↑ | test | 0.1999 | 0.2834 | 0.3637 |
| Mass on positives ↑ | OOD | 0.1973 | 0.2842 | 0.3707 |
解释一下:top1_in_positives 是看最高概率的选项在不在有效集合里;exact_gold 是看它是不是刚好等于指定的 gold。如果有多个有效候选,这俩数值可能不一样。mass_on_positives 是所有正候选的概率之和,均匀分布的基线是“正候选数 / 总候选数”,不是固定的 0.20。
跟监督训练相比,RLCD 后的 top-1 在 test 上提了 2.50 个百分点,在 OOD 上提了 5.18 个百分点,而且分给有效候选的概率质量也高了。OOD 比 test 高不说明 OOD 简单,两个划分的网站和难度本来就不同,重点看同一个划分上的前后对比。
Noul:准确率没变,概率指标变好了
| 指标 | 划分 | 200 步试跑 | 1200 步运行 | RLCD 后 |
|---|---|---|---|---|
| CE ↓ | test | 0.5604 | 0.5296 | 0.5265 |
| CE ↓ | OOD | 0.5746 | 0.5332 | 0.5310 |
| Accuracy ↑ | test | 0.7762 | 0.7762 | 0.7762 |
| Accuracy ↑ | OOD | 0.7694 | 0.7694 | 0.7694 |
| Brier ↓ | test | 0.1816 | 0.1729 | 0.1717 |
| Brier ↓ | OOD | 0.1868 | 0.1748 | 0.1740 |
| ECE ↓ | test | 0.1122 | 0.0374 | 0.0263 |
| ECE ↓ | OOD | 0.1109 | 0.0392 | 0.0202 |
这组结果里,整体准确率没变,但概率指标改善了。准确率相同不代表每一道题的预测都没变。当然,准确率 77% 本身说明不了太多,还得结合类别比例、混淆矩阵和多数类基线一起看。
本地算的二分类 Brier 是预测概率与目标概率的平方误差均值,它衡量的是整体概率预测质量,不仅是校准。如果一直输出 0.5,对硬标签来说它是 0.25。ECE 是按置信度分箱算的(本地分了 10 箱),计算置信度和正确率的加权差异。它下降说明按这种算法得出的校准误差变小了。
这次能直接得出的结论是:在保存的 test 和 OOD 评估里,Choice 的选择指标提高了,Noul 的准确率持平但概率指标变好了。
7. 预测、评估与本地服务
对最后的检查点,先生成预测,再跑评估。RLCD 的产物在 runs/my_first_jev_rlcd_v1:
$jevRunDir = "runs/my_first_jev_rlcd_v1"
$jevPredDir = "artifacts/my_first_jev_rlcd_v1"
$jevReportDir = "artifacts/report_my_first_jev_rlcd_v1"
$jevRecords = "data/web"
uv run python -m jevforge.predict `
--checkpoint-dir $jevRunDir `
--records $jevRecords `
--splits "test,ood" `
--output-dir $jevPredDir
uv run python -m jevforge.evaluate `
--records $jevRecords `
--predictions "rlcd=$jevPredDir" `
--splits "test,ood" `
--output-dir $jevReportDir
启动本地服务:
uv run python -m jevforge.serve `
--checkpoint-dir $jevRunDir `
--port 8009 `
--host 127.0.0.1
让这个窗口一直开着,然后另开一个 PowerShell 窗口测一下健康状态,顺便发个请求。这里我把 Choice 和 Noul 放在一起测了,免得覆盖变量后读错字段:
Invoke-RestMethod -Uri "http://127.0.0.1:8009/health" -Method Get
$jevBody = @{
state = "目标:打开设置。页面元素:e1 设置,e2 取消。"
questions = @{
action = @{
type = "choice"
instructions = "下一步应选择哪个元素?"
criteria = @{ e1 = "设置"; e2 = "取消" }
}
is_target = @{
type = "noul"
instructions = "元素 e1 是正确的下一步目标。"
}
}
} | ConvertTo-Json -Depth 6
$jevResponse = Invoke-RestMethod `
-Uri "http://127.0.0.1:8009/v1/systemone" `
-Method Post `
-ContentType "application/json; charset=utf-8" `
-Body ([System.Text.Encoding]::UTF8.GetBytes($jevBody))
$jevResponse | ConvertTo-Json -Depth 10
$jevResponse.answers.action.choice
$jevResponse.answers.action.probabilities
$jevResponse.answers.is_target.noul
8. 踩坑总结
先用短训练把流程跑通,比一上来就直接运行复杂长训练靠谱得多。但短训练不能只看 loss 降没降,还得把预测、评估和服务加载都走一遍,确保保存的检查点后面能真的用上。
资源管理得盯紧。底座大小、输入长度和候选数量决定了计算量。显存吃紧的时候,看日志、看实际占用去调预算,别光盯着“模型参数量”瞎猜。
最后,所有结论都必须回到保存的配置和报告上。这次复盘我才理清第 500 步才是最佳检查点、RLCD 用的是 exact 模式、以及温度校准参与了最终输出。以后每次试跑还是得留独立目录,记好环境版本、命令、数据划分和评估报告,做到不互相覆盖。
实现依据
本文的实验数值都基于我这次的本地运行配置与报告,上游链接是为了方便读者追溯代码。
← 返回全部博客