在自动化工单路由、越狱检测、垃圾过滤和内容审核等“反射式”决策场景中,传统自回归 LLM 往往存在延迟高(500–2000 ms)、成本大以及易幻觉等痛点。为了解决这些问题,Convai Innovations开源了开源协议为 Apache 2.0 的 Laya 决策模型家族。
本文将从 Laya 的核心定位、模型架构、推理部署以及 LoRA 微调等维度,全面解析这一专门针对非文本生成式决策场景的 System 1 模型。
Laya 是什么
1.1 一句话定位
Laya 是 Convai Innovations 开源的 System 1 决策模型家族(Apache 2.0)。它基于双向编码器(ModernBERT / mmBERT),不做文本生成,在一次前向传播内对结构化问题输出经过校准的概率分布。单题延迟 32.8–39.5 ms(T4 GPU)。
它的对标对象是 TypeSafe AI 的闭源商业模型 Jev(2026 年 9 月发布,由 ChatGPT 共同发明人 Diogo Almeida 创立,其 API 命名为 "System One")。
1.2 它解决什么问题
传统做法用生成式 LLM 处理“反射式决策”(工单路由、垃圾邮件识别、越狱检测、内容审核分级):
| 环节 | 生成式 LLM | Laya |
|---|---|---|
| 计算方式 | 自回归,逐 token 串行生成 | 非自回归,一问一次并行前向 |
| 延迟 | 500–2000 ms | 32.8–39.5 ms(T4) |
| 输出 | 自由文本 | 类型化结构(标签 + 概率 + 置信度) |
| 后处理 | 需要正则 / JSON 解析器提取标签 | 无需解析,schema 天然保证 |
| 置信度 | "听起来很自信的 token 预测",无数学校准 | 严格适当评分规则训练,统计上可用 |
| 幻觉 | 会 | 输出空间仅限概率与数字,无法生成任意文本 |
| 成本 | 按 token 计费(Jev $0.042/1M input) | 自托管 $0 |
关键点:当模型不能生成任意文本时,幻觉和格式损坏在物理上不可能发生。护栏从"软约束"变成"硬保证"。
1.3 三种决策原语
所有问题必须归入以下三类之一,不接受自由文本作答:
| 原语 | 输出 | 典型用途 |
|---|---|---|
choice | 选项标签 + 每个选项的概率 + 置信度 | 部门路由、意图识别、主题分类 |
score | 序数量表上的期望分值 + 各等级分布 + 置信度 | 不满程度、工单紧急度、危害严重性 |
noul | 校准后的 P(true),取值 0.0–1.0 | 钓鱼检测、垃圾过滤、越狱检测、流失风险 |
noul 是项目自造词,本质是一个命名的伯努利分布。0.9 就是"90% 概率为真"。
1.4 三个 checkpoint
| HF 仓库 / 子目录 | 骨干编码器 | 参数量 | 上下文 | 用途 |
|---|---|---|---|---|
convaiinnovations/laya | ModernBERT-large | 421M | 512 | 英语 |
convaiinnovations/laya → multilingual | mmBERT-base | 322M | 1024 | 100+ 语言,速度快 2 倍 |
convaiinnovations/laya → typed-decisions | ModernBERT-large | 421M | 1024 | typed-decisions 工作流 |
内置 Router 会按请求自动在三个 checkpoint 间路由(详见 3.1)。
1.5 架构(来自 laya/common.py 源码)
输入:state(文本 / JSON)+ 类型化问题
↓
build_sequence() 构造序列:
[CLS] <问题类型> 指令 [SEP] [MASK] 选项0 [MASK] 选项1 ... [SEP] state [SEP]
↑ 每个选项前插一个 [MASK] token 作为锚点
↓
DecisionModel.forward()
├─ encoder:ModernBERT(双向自注意力,RoPE,交替局部/全局注意力)
├─ + type_emb(qtype) # 按 choice/score/noul 注入类型嵌入
├─ head:2 层 TransformerEncoder(post-encoder 决策头)
├─ torch.gather 抽取 [MASK] 位置的隐状态
├─ scorer:LayerNorm→Linear→GELU→Linear(1),每个选项打成 1 个标量 logit
└─ act_head:pooled [CLS] + 4 个分布特征 → 256 → [P(act), P(escalate)]
↓
softmax(logits / temperature) → 概率分布
confidence = 1 - H(p) / log(k) # 归一化香农熵4 个分布特征为:最高概率、top1−top2 差值、归一化熵、k/255(选项预算比)。拼接后是 d+4 维向量。
1.6 怎么训练出来的(RLCD)
RLCD = Reinforcement Learning for Calibrated Decisions。核心:奖励函数使用严格适当评分规则(strictly proper scoring rule)的复合形式:
r = log_score + w_sph · spherical_score − w_rps · RPS · 1[score 类型]log_score:对真值给低概率重罚,下限截断在-9.21spherical_score:有界 [0,1],配合w_sph控制软目标匹配RPS(Ranked Probability Score):累积分布平方距离,只对score类型生效,教会模型理解量表上的"距离"
为什么不用交叉熵:交叉熵只有在正确类 logit → ∞ 时才最小化,模型会变得过度自信;用 +1/0 二元奖励的策略梯度同理,会把最高概率推向 1.0,通过破坏校准来最大化准确率。严格适当评分规则的数学性质保证:只有当模型报告真实概率时才获得最高期望奖励。
策略梯度采用 GRPO 风格组基线:对每个问题采样 G 组带噪 logit,噪声做零和投影(eps - mean(eps),因为给所有 logit 加同一常数在 softmax 中会抵消),相对组均值算优势,探索标准差 σ 线性衰减。动作头用成本矩阵:自动执行且正确 +1.0、自动执行但错误 −3.0、升级人工 −0.5,因此策略会自发学会置信度高于约 62.5% 才自动执行。
环境准备与模型下载
2.1 环境要求
| 项 | 要求 |
|---|---|
| Python | ≥ 3.8(官方 classifier 覆盖 3.8–3.12) |
| PyTorch | ≥ 2.0.0 |
| transformers | ≥ 4.45.0(微调 notebook 实测用 ≥ 4.48.0) |
| 核心依赖 | safetensors>=0.4.0、huggingface_hub>=0.20.0、numpy>=1.20.0 |
| 推理显存 | 421M 参数 fp16 ≈ 0.9 GB 权重,1 GB 显存即可跑;建议 ≥ 4 GB |
| 推理设备 | CUDA / Apple MPS / CPU 均支持(CPU 约 193–464 ms 单题) |
| 微调显存 | 12–16 GB 用 LoRA;24 GB 可尝试全参数微调 |
2.2 安装
# 建议使用独立虚拟环境
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -U laya
# 验证安装:
python -c "import laya; print(laya.__file__)"
# 安装微调所需额外依赖(官方 notebook 的完整命令):
pip install -q -U "laya>=0.1.6" "transformers>=4.48.0" "datasets>=3.0.0" \
safetensors huggingface_hub pyarrow pandas scipy accelerate tabulate
# 本手册的单卡 LoRA 额外需要:
pip install -q -U peft bitsandbytes注意:如果你的显卡是 Blackwell / RTX 50 系列,装到的 PyTorch 可能不支持其 CUDA 架构,Laya 会自动回落到 CPU 并打印警告。此时按提示安装 nightly 版本:
pip install --pre torch --index-url https://download.pytorch.org/whl/nightly/cu1282.3 权重下载:三种方式
方式 A:运行时自动下载(最省事)
Agent / load 会在本地找不到路径时自动调用 snapshot_download。指定 subfolder 时,只下载该子目录(内部使用 allow_patterns=[f"{subfolder}/*"]),不会把整个模型家族拉下来:
import laya
agent = laya.load("convaiinnovations/laya") # 英语(仓库根目录)
agent_ml = laya.load("convaiinnovations/laya", subfolder="multilingual") # 100+ 语言
agent_td = laya.load("convaiinnovations/laya", subfolder="typed-decisions")方式 B:命令行预下载(推荐用于生产/内网)
# 只下英语 checkpoint(根目录文件)
huggingface-cli download convaiinnovations/laya \
--local-dir ./models/laya-en \
--exclude "multilingual/*" "typed-decisions/*"
# 只下多语言 checkpoint
huggingface-cli download convaiinnovations/laya \
--include "multilingual/*" \
--local-dir ./models/laya-ml
# 国内网络可用镜像
export HF_ENDPOINT=https://hf-mirror.com
huggingface-cli download convaiinnovations/laya --local-dir ./models/laya-enModelScope 也有对应仓库,可用于纯内网场景:
from modelscope import snapshot_download
d = snapshot_download("convaiinnovations/laya")方式 C:离线 / 内网部署
在能上网的机器上下载完整目录后打包传输,运行时传本地路径即可(Agent 检测到路径存在就不走网络):
agent = laya.load("/opt/models/laya-en", device="cuda")
# 加载多语言子目录时:
# agent = laya.load("/opt/models/laya", subfolder="multilingual")传本地路径时有一个校验:如果路径以 /、./、../ 开头或是绝对路径却不存在,会直接抛 FileNotFoundError,不会静默去联网下载。
2.4 权重目录结构(微调后必须保持一致)
Agent.__init__ 会按固定约定读文件,目录结构错了就加载不了:
模型目录/
├── rl_agent_config.json # 必需。缺失则报 "Incompatible model"
├── model.safetensors # 必需。缺失则报 "'model.safetensors' not found"
├── tokenizer/ # 可选。存在则从这里读;否则回退到 cfg["encoder"]
│ ├── tokenizer.json
│ ├── tokenizer_config.json
│ └── ...
└── encoder/ # 可选。存在则用本地 config 构建空骨干再灌权重
└── config.jsonrl_agent_config.json 里被 Agent 读取的键及默认值:
| 键 | 默认值 | 作用 |
|---|---|---|
encoder | 无默认,必需 | 骨干模型 HF id 或本地路径;tokenizer 目录不存在时的回退 |
max_len | 512 | 序列最大长度 |
head_max_len | 192 | 决策头(问题 + 选项)的 token 预算 |
temperature | [1.0, 1.0, 1.0] | 按 choice/score/noul 三个顺序的温度 |
temperature_by_options | {} | 按 (类型, 选项数) 分桶的温度覆盖,优先级高于 temperature |
amp_dtype | "fp16" | 推理时自动混合精度类型,可设 "bf16" |
head_layers | 2 | 决策头 Transformer 层数 |
act_costs | {} | 动作头成本矩阵,决定 n_act |
输出的键名是 temperature 为 list、temperature_by_options 为 dict,两者都要写:Agent 先按 temp_bucket(qtype, k) 去 temperature_by_options 查,查不到才回落到 temperature[qt]。
推理使用
3.1 方式一:Route 模式(官方推荐)
Router 在前向传播之前用纯 Python 检测 Unicode 字符集(22 种字母表)和停用词分布,决定该用哪个 checkpoint。这一步开销 0.09–0.73 ms,不到总延迟的 2%。
为什么必须先路由:英语 checkpoint 在非拉丁字符集上会崩溃——高棉语准确率 0.000 但置信度 0.952。模型自己不会告诉你它读不懂,所以基于置信度的门控救不了你,路由决策必须发生在前向之前。
import laya
from laya import Router
# preload=True:所有 checkpoint 常驻内存,语言切换只花检测开销(<1 ms)
router = Router(preload=True)
# router = Router(preload=True, device="cuda")
state = {
"from": "[email protected]",
"subject": "Duplicate charge on invoice #4411",
"body": "Hi, we were billed twice for March. Please refund the duplicate today or we will cancel our plan."
}
questions = {
"department": {
"type": "choice",
"instructions": "Which department should handle this request?",
"criteria": {
"billing": "invoices, payments, refunds",
"technical": "bugs, outages, system errors",
"sales": "pricing, new contracts",
"other": "everything else"
}
},
"urgency": {
"type": "score",
"instructions": "How urgent is this request?",
"criteria": ["not urgent", "soon", "critical deadline or blocking issue"]
},
"churn_risk": {
"type": "noul",
"instructions": "Does the user threaten to cancel or leave?"
},
"refund_requested": {
"type": "noul",
"instructions": "Does the user explicitly request a refund?"
}
}
res = router.predict(state, questions)
print("Department :", res["answers"]["department"]["choice"]) # -> billing
print("Urgency :", res["answers"]["urgency"]["score"]) # -> 1.84
print("Churn Risk :", res["answers"]["churn_risk"]["noul"]) # -> 0.892
print("Routing :", res["routing"]["model"]) # -> english路由元信息会说明判断依据:
res["routing"]
# {
# 'model': 'multilingual',
# 'repo': 'convaiinnovations/laya/multilingual',
# 'reason': 'non-Latin script (devanagari, 100% of letters); the English checkpoint cannot read it'
# }
# 只想知道会路由到哪,不实际推理:
router.route({"body": "Der Kunde wurde zweimal belastet"}, questions).reason
# "Latin script but language looks like 'de', not English"
# 手动指定 checkpoint
res_td = router.predict(state, questions, model="typed-decisions")内存管理:
router = Router(max_loaded=2) # 最多保留 2 个热 checkpoint(默认 1,LRU 淘汰)
router.preload(["english", "multilingual"]) # 只预加载需要的
router.attach("english", existing_agent) # 复用已构建的 agent,避免重复占显存
router.unload() # 释放为什么一定要 preload:冷构建一个 checkpoint 要数秒。默认 max_loaded=1 时,每次语言切换都会重建模型——实测 CPU 中位 7.4 s、T4 上 10.3 s。生产环境必须 preload。
| 部署方式 | 单请求延迟 | 模型重载 |
|---|---|---|
Router()(懒加载,max_loaded=1) | 每次语言切换 7–10 s | 每次切换 1 次 |
Router(preload=True) | 32.8 ms(GPU)/ 193–464 ms(CPU) | 无 |
3.2 方式二:单模型直连(固定管线用)
import laya
agent = laya.load("convaiinnovations/laya") # 也可以用 Agent(...) 或 RLAgent(...),同一个类
result = agent.predict(state, questions) # predict 是 system_one 的别名
answers = result["answers"]3.3 返回结构(源码级精确 schema)
{
"model": "laya-rl-agent",
"answers": {
"<question_id>": { ... } # 按问题类型三种形态之一
},
"usage": {"input_tokens": 512, "output_tokens": 0} # 生成 token 恒为 0
}三种问题的 answers[qid] 结构:
# choice
{
"type": "choice",
"choice": "billing", # 概率最大的选项标签
"probabilities": {"billing": 0.9412, "technical": 0.03, "sales": 0.02, "other": 0.0088},
"confidence": 0.9412, # = 1 - H(p)/log(k)
"action": {"act_probability": 0.87} # 动作头给出的“可自动执行”概率
}
# score
{
"type": "score",
"score": 1.8421, # 期望分值 = Σ i·p_i
"legend": {"0": "not urgent", "1": "soon", "2": "critical deadline or blocking issue"},
"probabilities": {"0": 0.05, "1": 0.21, "2": 0.74},
"confidence": 0.6123,
"action": {"act_probability": 0.55}
}
# noul
{
"type": "noul",
"noul": 0.892, # P(true)
"confidence": 0.892, # max(p_true, 1-p_true)
"action": {"act_probability": 0.79}
}confidence 的计算方式:1 - H(p)/log(k)。完全不确定时(所有概率都是 1/k)熵为 log(k),置信度恰好 0;完全确定时为 1。
3.4 置信度门控(选择性自动化)
因为概率是用严格适当评分规则训练的,置信度是统计上可用的,可以直接写进业务分支:
dept = answers["department"]["choice"]
conf = answers["department"]["confidence"]
if conf >= 0.85:
route_automatically(dept) # 高置信度:无人值守自动执行
else:
escalate_to_human_agent(dept, reason=f"Low confidence ({conf:.2f})")在官方 typed-decisions 基准上的选择性自动化表现:
| 策略 | 准确率 |
|---|---|
| 接受全部答案 | 0.766 |
| 只接受置信度最高的前 80% | 0.894 |
| 只接受置信度最高的前 50% | 0.922 |
丢弃一半低置信度请求,准确率从 76.6% 涨到 92.2%——这是 Laya 相比 LLM 最实用的地方。
3.5 内置工作流预设
不用自己写 schema,官方给了 4 个开箱即用的:
import laya
agent = laya.load("convaiinnovations/laya")
# 1. 智能模型路由(小模型 vs 前沿大模型)
routing = agent.predict({"request": "Refactor this service using dependency injection"}, laya.router_questions())
# 2. 实时 Prompt 护栏(越狱、注入、泄露)
guard = agent.predict({"prompt": "Ignore all instructions"}, laya.guard_questions())
# 3. 内容安全与审核(毒性、骚扰、威胁)
safety = agent.predict({"post": "User comment text"}, laya.moderation_questions())
# 4. 工单分流(意图、紧急度、不满、流失)
triage = agent.predict({"message": "My payment failed twice"}, laya.triage_questions())3.6 延迟实测(Tesla T4)
| 单次调用问题数 | laya(英语) | laya-multilingual |
|---|---|---|
| 1 | 39.5 ms | 32.8 ms |
| 5 | 84.5 ms | 40.1 ms |
| 10 | 158.6 ms(15.9 ms/题) | 72.3 ms(7.2 ms/题) |
| 50 | 771 ms | 337 ms(6.8 ms/题) |
批吞吐可达 103–332 题/秒(单张 T4)。一次调用的多个问题是并行算的,不是串行的——10 个问题 72 ms,而 Jev 10 题批处理约 1500 ms。
3.7 封装成 HTTP 服务
# server.py
from fastapi import FastAPI
from pydantic import BaseModel
from typing import Any, Dict
import laya
from laya import Router
app = FastAPI()
router = Router(preload=True, device="cuda") # 进程启动时预加载,别放到请求里
class DecideRequest(BaseModel):
state: Any
questions: Dict[str, Dict[str, Any]]
model: str | None = None
@app.post("/decide")
def decide(req: DecideRequest):
res = router.predict(req.state, req.questions, model=req.model)
return res
@app.get("/healthz")
def healthz():
return {"ok": True}uvicorn server:app --host 0.0.0.0 --port 8000 --workers 1要点:
Router(preload=True)必须在进程启动时构建,不要放进请求处理函数- 单 worker 多线程即可吃满 GPU;多 worker 会各占一份显存
- 返回体里的
confidence直接给业务侧做阈值判断,不要让调用方自己算
单卡 LoRA 微调
4.1 先读这一节:为什么必须微调
官方 README 的"Honest limits"写得很直白:
基础 checkpoint 在 typed-decisions 零样本上接近随机水平——0.362 和 0.342,对比 0.318 的随机基线和 0.461 的多数类基线。0.766 这个数字来自在该基准训练集上微调后的 checkpoint。
也就是说:
- Laya 是一个"快速可特化的底座",不是一个零样本决策引擎
- 微调能带来 +40 个准确率点(0.362 → 0.766),超过 Jev 公开的 0.727,也超过教师自一致上限 0.735
- 不微调直接用,效果约等于抛硬币;用
Router做英语通用任务还行,做你自己的业务标签体系一定不行
4.2 单卡 LoRA 与官方方案的差异(务必知情)
| 项 | 官方 notebook | 本手册单卡 LoRA 方案 |
|---|---|---|
| 硬件 | Kaggle 免费 2×T4(DDP) | 单张 12–24 GB 消费卡 |
| 微调方式 | 全参数微调(load_state_dict(strict=True) + DDP,无任何 LoRA 代码) | 编码器挂 LoRA,决策头全参训练 |
| 数据并行 | DDP,2 卡 | 单卡 + 梯度累积 |
| 训练时长 | 4 epoch / ~30k 问题 ≈ 4–5 小时 | 视数据量,通常 1–3 小时 |
| 算法 | RLCD(GRPO 风格策略梯度 + 适当评分规则) | 完全一致,不降级 |
诚实提示:LoRA 是本手册在官方全参方案上的工程适配,官方未提供 LoRA 版本。建议流程是——先在自己机器上跑通官方 notebook 建立基线,再换成 LoRA 看掉多少点。如果 24 GB 显存够,优先走全参数微调,那才是官方验证过的路径。
4.3 数据准备
官方参考数据集
from datasets import load_dataset
ds_train = load_dataset("LocalLLaMA/typed-decisions", "all", split="train")
ds_test = load_dataset("LocalLLaMA/typed-decisions", "all", split="test")
# train: 1200 cases / 6000 typed decisions
# test : 400 cases / 2000 typed decisions你自己的数据 schema
每行一条 case,五个字段(state / questions / gold 都是 JSON 字符串):
{
"id": "case-0001",
"workflow": "customer_service",
"state": "{\"from\": \"[email protected]\", \"subject\": \"Duplicate charge\", \"body\": \"We were billed twice...\"}",
"questions": "{\"department\": {\"type\": \"choice\", \"instructions\": \"Which department should handle this?\", \"criteria\": {\"billing\": \"invoices, payments, refunds\", \"technical\": \"bugs, outages\", \"sales\": \"pricing\", \"other\": \"everything else\"}}, \"churn_risk\": {\"type\": \"noul\", \"instructions\": \"Does the user threaten to cancel?\"}}",
"gold": "{\"department\": {\"label\": \"billing\", \"probabilities\": {\"billing\": 1.0, \"technical\": 0.0, \"sales\": 0.0, \"other\": 0.0}}, \"churn_risk\": {\"label\": true, \"probabilities\": {\"false\": 0.1, \"true\": 0.9}}}"
}字段要求:
| 字段 | 要求 |
|---|---|
state | 任意 JSON 或字符串。可以是邮件、工单、日志、对话轮次数组 |
questions[qid].type | 只能是 choice / score / noul |
choice 的 criteria | dict,键是标签名,值是描述。也接受 list(会被转成 {c: None},无描述) |
score 的 criteria | list,顺序即量表顺序(低 → 高) |
noul 的 criteria | 可省略;要写就写 {"false": "...", "true": "..."} |
gold[qid].probabilities | 支持软标签。有人工标注分布就直接给分布;只有硬标签就给 one-hot |
数据质量建议(来自官方设计原则):
- 不要用 LLM 生成合成标签。用合成标签训练校准模型,只会让模型校准到 LLM 自身的错误和幻觉上。官方流水线 100% 使用人工标注的公开数据集。
- 给足训练量。官方用 1200 cases / 6000 decisions 达到 0.766。低于 2000 条 decision 时先做小样本验证,别期待天花板。
- 加干扰选项。故意放"长得像"的选项(电话 vs 紧急联系人电话、邮箱 vs 街道地址),能显著提升判别力。
- 动态扰动防走捷径:打乱选项顺序、改写问题表述、在原始文本与嵌套 JSON 之间交替、注入随机无关问题。选项顺序打乱尤其重要——否则模型会学到"永远选第一个"。
- 选项数控制在 20 个以内(见 五、能力边界)。
数据预处理
复用 laya.common 里的函数,保证训练时的序列格式与推理时完全一致:
import json
import torch
from datasets import load_dataset
from transformers import AutoTokenizer
from huggingface_hub import snapshot_download
from laya.agent import _fix_tokenizer_config
from laya.common import build_sequence, render_options, QTYPES
MODEL_ID = "convaiinnovations/laya"
cfg = json.load(open(f"{snapshot_download(MODEL_ID)}/rl_agent_config.json"))
tok = AutoTokenizer.from_pretrained(cfg["encoder"])
_fix_tokenizer_config(snapshot_download(MODEL_ID))
MAX_LEN = cfg["max_len"] # 512(英语)/ 1024
HEAD_MAX_LEN = cfg["head_max_len"] # 192(英语)/ 256
def build_target(qtype, q, gold_q):
"""把 gold 转成与 render_options 顺序对齐的概率向量。"""
opts = render_options({"t": qtype, "ins": q["instructions"], "crit": q.get("criteria")})
k = len(opts)
probs = gold_q.get("probabilities")
if qtype == "choice":
keys = list(q["criteria"].keys()) if isinstance(q["criteria"], dict) else list(q["criteria"])
if probs:
return [float(probs.get(kk, 0.0)) for kk in keys]
return [1.0 if kk == gold_q["label"] else 0.0 for kk in keys]
if qtype == "score":
if probs:
return [float(probs.get(str(i), probs.get(i, 0.0))) for i in range(k)]
y = int(gold_q["label"] if "label" in gold_q else gold_q["score"])
return [1.0 if i == y else 0.0 for i in range(k)]
# noul: [P(false), P(true)]
p_true = float(probs.get("true", 0.0)) if probs else (1.0 if gold_q["label"] else 0.0)
return [1.0 - p_true, p_true]
def make_item(row):
state = json.loads(row["state"])
questions = json.loads(row["questions"])
gold = json.loads(row["gold"])
items = []
for qid, qdef in questions.items():
t = qdef["type"]
crit = qdef.get("criteria")
if t == "choice" and isinstance(crit, list):
crit = {c: None for c in crit}
ins = qdef["instructions"]
if not isinstance(ins, str):
ins = json.dumps(ins)
q_internal = {"t": t, "ins": ins, "crit": crit}
seq, markers = build_sequence(tok, state, q_internal, MAX_LEN, HEAD_MAX_LEN)
if len(markers) != len(render_options(q_internal)):
continue # 选项超出 head_max_len 预算,跳过
items.append({
"ids": seq,
"markers": markers,
"qtype": QTYPES[t],
"target": build_target(t, qdef, gold[qid]),
"label": int(gold[qid].get("label", -1)) if isinstance(gold[qid].get("label"), (int, bool)) else -1,
})
return items
all_items = []
for row in ds_train:
all_items.extend(make_item(row))
torch.save(all_items, "train_items.pt")
print(f"built {len(all_items)} training items")4.4 LoRA 配置
先确认真实模块名(ModernBERT 的命名不太常规):
from laya.common import build_model
m = build_model(cfg)
names = sorted({n.split(".")[-1] for n, _ in m.encoder.named_modules()})
print([n for n in names if n in ("Wqkv", "Wo", "Wi", "Wd")])
# ModernBERT: Wqkv / Wo(注意力), Wi / Wd(GeGLU 前馈)配置:
from peft import LoraConfig, get_peft_model
lora_cfg = LoraConfig(
r=16, # 12–16 GB 显存用 16;24 GB 可用 32
lora_alpha=32, # 惯例 = 2 × r
lora_dropout=0.05,
bias="none",
target_modules=["Wqkv", "Wo", "Wi", "Wd"],
task_type="FEATURE_EXTRACTION", # 我们要的是 last_hidden_state,不是分类头
)
model = build_model(cfg)
model = get_peft_model(model, lora_cfg)
# 决策头必须全参训练:它是随机初始化的,且只占几百 K 到 ~2M 参数
for name, p in model.named_parameters():
if "encoder" not in name:
p.requires_grad = True
model.print_trainable_parameters()关键点:
- 决策头不能冻也不能挂 LoRA。
head、scorer、act_head、type_emb都是随机初始化的新模块,冻结它们等于什么都没训。 - 编码器冻结后必须调
enable_input_require_grads(),否则配合 gradient checkpointing 时梯度传不到 LoRA 参数上。 reference_compile:官方推理时显式设成False(ModernBERT 的默认"auto"会 torch.compile,对 Laya 的小 batch 是净损失,且某些平台会挂)。训练时如果你要用torch.compile提速再打开。
model.encoder.config.reference_compile = False
model.enable_input_require_grads() # LoRA + gradient checkpointing 必需
model.gradient_checkpointing_enable()4.5 训练超参:官方值 vs 单卡适配
官方 train_ddp.py 里的原始值(逐字):
EPOCHS = 4
MICRO_BATCH = 8 # 8 sequences per forward pass per GPU
GRAD_ACCUM = 4 # Effective batch across 2 GPUs = 64 sequences (8 * 2 * 4)
GROUP_SIZE = 4 # GRPO baseline samples
LR_ENCODER = 2.5e-5 # Encoder adaptation rate
LR_HEAD = 1.0e-4 # Head adaptation rate
SIGMA_START = 0.4
SIGMA_END = 0.1
# cfg:
cfg["gradient_checkpointing"] = True
cfg["max_tokens_per_batch"] = 4096
cfg["max_len"] = 1024
cfg["head_max_len"] = 256单卡适配建议:
| 参数 | 官方(2×T4 全参) | 12 GB + LoRA | 24 GB + LoRA | 说明 |
|---|---|---|---|---|
MICRO_BATCH | 8 | 4 | 8 | 按显存调,OOM 就先降它 |
GRAD_ACCUM | 4 | 8 | 4 | 保持有效 batch 接近 32–64 |
LR_ENCODER | 2.5e-5 | 5e-5 | 5e-5 | LoRA 参数量少,学习率可放大 2 倍 |
LR_HEAD | 1.0e-4 | 1.0e-4 | 1.0e-4 | 决策头从零训,保持官方值 |
GROUP_SIZE | 4 | 8 | 8 | GRPO 组越大基线越稳,显存允许就加大 |
EPOCHS | 4 | 4 | 4 | 数据量小时可用早停 |
| 优化器 | AdamW, wd=0.01 | 同 | 同 | LoRA 参数建议 wd=0 |
| 调度器 | Cosine, eta_min=1e-6 | 同 | 同 | T_max = 总更新步数 |
| 精度 | fp16/bf16 AMP | bf16(Ampere+) | bf16 | T4 只支持 fp16 |
4.6 训练脚本
完整可运行脚本见配套文件 train_lora_rlcd.py。核心训练循环(与官方 notebook 逐行对齐,仅把 DDP 换成单卡):
# 1. 采样 G 组带噪 logit,噪声做零和投影
eps = torch.randn((GROUP_SIZE,) + logits.shape, device=device) * sigma * mask
eps = (eps - eps.sum(-1, keepdim=True) / k) * mask
z = logits.detach().unsqueeze(0) + eps
q = torch.softmax(z.masked_fill(~mask, -1e4), -1)
# 2. 适当评分规则奖励 + GRPO 组基线优势
with torch.no_grad():
r = proper_reward(q, target.unsqueeze(0), batch["qtype"].to(device), mask,
w_sph=0.75, w_rps=1.0)
adv = r - r.mean(0, keepdim=True)
adv = adv / (adv.std() + 1e-6)
# 3. 策略梯度损失 + 软交叉熵引导
logp = -(((z - logits.unsqueeze(0)) ** 2) * mask).sum(-1) / (2 * sigma ** 2)
loss_rl = -(adv * logp).mean()
loss_ce = -(target * torch.log_softmax(logits.masked_fill(~mask, -1e4), -1)).sum(-1).mean()
loss = (loss_rl + 1.0 * loss_ce) / GRAD_ACCUMσ 随 epoch 线性衰减:
progress = epoch / max(1, EPOCHS - 1)
sigma = SIGMA_START + (SIGMA_END - SIGMA_START) * progress # 0.4 → 0.1w_sph=0.75 是官方 notebook 的值(源码里 proper_reward 的默认值是 0.5)。0.75 表示更偏向软目标匹配。
关于 loss_ce 的系数 1.0:这是官方实现与论文描述的一个细微差别——论文说"监督式交叉熵损失为零",但官方代码实际用了 loss_rl + 1.0 * loss_ce。按代码来。
4.7 温度校准(微调后必做)
两个官方 checkpoint 出厂都是过度自信的。 校准前后的 ECE:
| 模型 | 拟合前 ECE | 拟合后 ECE |
|---|---|---|
laya | 0.466 | 0.081 |
laya-multilingual | 0.314 | 0.106 |
而且 laya-multilingual压根没带拟合好的温度,用之前必须自己拟合。
拟合方法:按 (问题类型, 选项数) 分桶,每桶用 LBFGS 优化单个温度标量,目标是最小化对数似然。官方实现:
def fit_one_temp(sel):
"""sel: [(logits_list, target_list), ...],返回拟合温度,clamp 到 [0.1, 10.0]"""
if len(sel) < 10:
return 1.0
kmax = max(len(z) for z, _ in sel)
Z = torch.full((len(sel), kmax), -1e4)
T = torch.zeros((len(sel), kmax))
for i, (z, t) in enumerate(sel):
Z[i, :len(z)] = torch.tensor(z)
T[i, :len(t)] = torch.tensor(t, dtype=torch.float32)
log_t = torch.zeros(1, requires_grad=True)
opt = torch.optim.LBFGS([log_t], lr=0.1, max_iter=100)
def closure():
opt.zero_grad()
loss = -(T * torch.log_softmax(Z / log_t.exp(), -1)).sum(-1).mean()
loss.backward()
return loss
opt.step(closure)
return float(torch.clamp(log_t.exp(), 0.1, 10.0).item())调用(在留出集上,官方取 all_items[::15][:400] 作为校准集):
fitted_temps = [1.2, 1.2, 1.2] # 拟合失败时的兜底值
try:
for qt in range(3): # 0=choice, 1=score, 2=noul
sel = [(z, t) for q_type, z, t in calib_preds if q_type == qt]
if sel:
fitted_temps[qt] = fit_one_temp(sel)
except Exception as e:
print("Temperature fitting fallback:", e)
cfg["temperature"] = fitted_temps分桶更细的话(对每个 k 单独拟合),额外写进 temperature_by_options:
from laya.common import temp_bucket # 例如 "choice:6-10", "noul:2"
cfg["temperature_by_options"] = {"choice:6-10": 1.43, "noul:2": 1.15}校准集必须与训练集分离。混合在训练样本上拟合温度,会拟合出一堆 1.0,校准等于没做。
4.8 保存与发布
import json, os
import torch
OUTPUT_DIR = "laya-ft-my-domain"
os.makedirs(OUTPUT_DIR, exist_ok=True)
# 1. 合并 LoRA 权重(如果想让产物是纯 safetensors,便于统一加载)
from peft import PeftModel
merged = model.merge_and_unload() # 合并后仍是 DecisionModel
# 2. 保存权重
from safetensors.torch import save_file
save_file({k: v.contiguous().cpu() for k, v in merged.state_dict().items()},
os.path.join(OUTPUT_DIR, "model.safetensors"))
# 3. 保存 tokenizer(Agent 会优先从 tokenizer/ 读)
tok.save_pretrained(os.path.join(OUTPUT_DIR, "tokenizer"))
# 4. 保存 encoder 配置(Agent 检测到 encoder/ 就用本地 config 构建)
from transformers import AutoConfig
AutoConfig.from_pretrained(cfg["encoder"]).save_pretrained(os.path.join(OUTPUT_DIR, "encoder"))
# 5. 保存配置(含拟合好的温度)
cfg["temperature"] = fitted_temps
json.dump(cfg, open(os.path.join(OUTPUT_DIR, "rl_agent_config.json"), "w"), indent=2)
# 6. 加载验证
import laya
agent = laya.Agent(OUTPUT_DIR, device="cuda")
print(agent.predict(state, questions)["answers"])目录校验清单(缺任何一个 Agent 都会抛异常):
- ☐
rl_agent_config.json存在,且含encoder键 - ☐
model.safetensors存在 - ☐
tokenizer/存在 - ☐
encoder/config.json存在(离线环境必需,否则会去联网拉cfg["encoder"]) - ☐
temperature是长度 3 的 list
发布到 Hub:
from huggingface_hub import HfApi
api = HfApi()
api.create_repo("your-org/laya-ft-my-domain", repo_type="model", exist_ok=True)
api.upload_folder(folder_path=OUTPUT_DIR, repo_id="your-org/laya-ft-my-domain")4.9 评估
必须同时看准确率和校准,只看准确率会漏掉最重要的退化。
import numpy as np
from laya.common import ece_score
all_confs, all_corrects = [], []
for row in ds_test:
state, questions, gold = (json.loads(row[k]) for k in ("state", "questions", "gold"))
res = agent.predict(state, questions)["answers"]
for qid, a in res.items():
all_confs.append(a["confidence"])
if a["type"] == "choice":
all_corrects.append(float(a["choice"] == gold[qid]["label"]))
elif a["type"] == "noul":
all_corrects.append(float((a["noul"] >= 0.5) == bool(gold[qid]["label"])))
else:
all_corrects.append(float(round(a["score"]) == int(gold[qid]["label"])))
print("Accuracy:", np.mean(all_corrects))
print("ECE :", ece_score(np.array(all_confs), np.array(all_corrects)))要跟官方数字对齐,需要报告这几项:
| 指标 | 含义 | 官方 laya-typed-decisions 参考值 |
|---|---|---|
| Accuracy | argmax 命中率 | 0.766 |
| Soft accuracy | 与教师完整分布的匹配度 | 0.471 |
| Brier | 概率平方误差 | 0.062 |
| ECE | 期望校准误差 | 0.213(温度拟合后 0.081) |
| score MAE | 序数量表期望值误差 | 0.242 |
| 延迟 p50 | 单题延迟 | 32.8–39.5 ms(T4) |
基线对照(typed-decisions 2000 decisions):
| 模型 | 准确率 | Soft acc | Brier | ECE | score MAE |
|---|---|---|---|---|---|
laya-typed-decisions | 0.766 | 0.471 | 0.062 | 0.213 | 0.242 |
laya | 0.362 | 0.332 | 0.316 | 0.175 | 0.694 |
laya-multilingual | 0.342 | 0.326 | 0.439 | 0.285 | 0.687 |
| Jev 1.13.0(公开) | 0.727 | 0.580 | 0.148 | 0.144 | 0.391 |
| 教师自一致上限 | 0.735 | — | — | — | — |
| 每题多数类 | 0.461 | — | — | — | — |
| 随机猜 | 0.318 | — | — | — | — |
按原语拆分:noul 0.857、choice 0.733、score 0.723。 按工作流拆分:发票处理 0.804、安全事件 0.766、客服 0.764、Agent 链路可观测性 0.730。
4.10 排查清单
| 现象 | 原因 | 处理 |
|---|---|---|
Incompatible model: ... does not contain 'rl_agent_config.json' | 目录结构不对,或加载到了非 Laya 模型 | 对照 2.4 的目录结构补齐 |
'model.safetensors' not found | 权重没保存成功,或文件名不对 | 检查 save_file 的路径与文件名 |
FileNotFoundError: Local model path not found | 传了绝对路径但目录不存在(不会静默联网) | 确认路径,或改传 HF repo id |
question 'xxx' options exceed head_max_len=... | 选项太多,token 预算装不下 | 调大 head_max_len(详见 5.1),或减少选项 |
| 训练 loss 不降 | 决策头被冻住了 | 确认所有非 encoder 参数 requires_grad=True |
| LoRA 梯度为 0 / 报错 | 编码器冻结 + gradient checkpointing | 调 model.enable_input_require_grads() |
| 准确率上去了但置信度全 0.99 | 只做了纯 CE/RL 没做校准 | 执行 4.7 的温度拟合 |
| 模型永远选第一个选项 | 选项顺序没打乱 | 训练时随机 shuffle 选项顺序 |
| 单题延迟数百 ms(非 30 ms) | 跑在 CPU 上 | 看是否有 "running on CPU" 警告;Blackwell 卡装 nightly torch |
| 语言切换时逐次卡 7–10 s | 没 preload | Router(preload=True) |
能力边界与选型建议
官方把限制写得很诚实,工程落地前必须知道:
5.1 选项数超过 20 就明显掉点
这是架构级约束。序列被切成两部分:选项提示预算 head_max_len 与状态预算 max_len - head_max_len。
| checkpoint | max_len | head_max_len | 留给 state 的 token |
|---|---|---|---|
laya(英语) | 512 | 192 | ~320 |
laya-multilingual | 1024 | 256 | ~768 |
laya-typed-decisions | 1024 | 256 | ~768 |
77 个选项时,每个标签只能分到 (256-16)//77 ≈ 3–4 个 token——文本失去区分度,准确率断崖。Banking77 上 Laya 0.425 vs Jev 0.870,就是被这个限制拖累的。
处理方式二选一:
# 方案 1:调大预算(注意显存和延迟会涨)
agent.cfg["head_max_len"] = 512
agent.cfg["max_len"] = 1024 # 可到 2048 / 4096 / 8192方案 2(更推荐):拆成粗到细的两级 choice。先判大类(≤10 个),再在命中的大类里判细类。这样每级的选项都少,准确率和延迟都更好控制。
顺带一提,laya-multilingual 的 mmBERT-base 编码器本身支持 RoPE 到 8192 上下文,所以上下文不是硬瓶颈,token 预算分配才是。
5.2 score 是最弱的原语
SST-5(5 级序数)上只有 0.372。序数判断比分类难,因为模型要学会"量表上的距离"概念。如果业务强依赖打分,要么加大训练样本密度,要么把 score 改造成多个 noul(例如"是否紧急?""是否一周内?")。
5.3 语言必须路由,不能靠置信度
laya出英语就会崩,且置信度不会下降(高棉语 0.000 准确率、0.952 置信度)laya-multilingual英语弱于laya(MASSIVE 英语 0.657 vs 0.783)- 51 语言宏平均:
laya只有 0.227,宏 ECE 高达 0.733;只有 23/51 语言能过 3 倍随机 laya-multilingual可用语言 45/51
所以:要么用 Router,要么按业务语言明确指定 checkpoint,不要指望模型自己知道它读不懂。
5.4 中文场景注意事项
官方 51 语言基准里没有单列中文成绩。落地前建议:
- 先用
laya-multilingual在自有中文测试集上跑一遍基线,看是否满足需求 - 不满意就按第四节做中文领域微调——中文场景基本一定要微调,因为标签体系是你自己的
- 中文 token 效率低于英文,
max_len/head_max_len的 token 预算要按实际 tokenizer 重新估算,不能照搬英语的 192/512
5.5 Laya 不擅长什么
- 需要推理和解释的任务:它不生成理由,也不做多步推理
- 开放式生成:完全不支持,这是设计选择不是缺陷
- 长文档深度理解:上下文 512/1024,超长文档要靠切分
- 高风险金融分类:官方自己说部分专业金融分类任务需要自回归模型的推理深度
- 软分布匹配:typed-decisions 上 argmax 准确率赢 Jev(0.766 vs 0.727),但软准确率输(0.471 vs 0.580)
5.6 什么时候该用 Laya
| 用 Laya | 用 LLM |
|---|---|
| 高频、可验证的反射式决策 | 需要推理、解释、开放式生成 |
| 路由、分类、打分、是否判断 | 复杂多步 Agent 规划 |
| 延迟预算 < 100 ms | 延迟不敏感(秒级可接受) |
| 要数据主权(内网 / HIPAA / GDPR) | 可以接受数据出网 |
| 需要数学校准的置信度做自动化门控 | 需要模型自己解释判断依据 |
| 想砍掉按 token 计费的推理成本 | 已有 LLM 账单可以接受 |
正确姿势是组合,不是替代:LLM 负责动脑(System 2),Laya 负责动手(System 1)——锁住用户目的、精简算力开支,再通过 tool use 整合现有系统。
速查表与资源
6.1 关键 API
# 加载
import laya
agent = laya.load("convaiinnovations/laya") # 英语
agent = laya.load("convaiinnovations/laya", subfolder="multilingual") # 多语言
agent = laya.Agent("./laya-ft-my-domain", device="cuda") # 本地微调产物
agent = laya.RLAgent("./laya-ft-my-domain") # 同 Agent 的别名
# 推理
res = agent.predict(state, questions) # predict 是 system_one 的别名
res["answers"][qid]["choice"] # choice 结果
res["answers"][qid]["score"] # score 期望分值
res["answers"][qid]["noul"] # P(true)
res["answers"][qid]["confidence"] # 1 - H(p)/log(k)
res["answers"][qid]["action"]["act_probability"]
# 路由
from laya import Router
router = Router(preload=True, device="cuda", max_loaded=2)
router.predict(state, questions, model="typed-decisions")
router.route(state, questions).reason
router.preload(["english", "multilingual"])
router.attach("english", agent); router.unload()
# 配置(改 cfg 后需重建 Agent 才生效)
agent.cfg["max_len"] = 1024
agent.cfg["head_max_len"] = 512
# 内置预设
laya.router_questions(); laya.guard_questions()
laya.moderation_questions(); laya.triage_questions()6.2 内部工具函数(微调会用到)
from laya.common import (
QTYPES, # {"choice": 0, "score": 1, "noul": 2}
QTYPE_NAMES, # 反向映射
serialize_state, # state → str (JSON, ensure_ascii=False)
render_criterion, # 单个 criterion → 文本
render_options, # 问题 → 选项文本列表(noul 恒为 [false, true])
build_sequence, # 构造 [CLS]...[MASK]opt...[SEP]state[SEP]
DecisionModel, # encoder + head + type_emb + scorer + act_head
build_model, # cfg → DecisionModel
proper_reward, # 适当评分规则奖励
td_lambda_targets, # 多轮轨迹的 TD(λ) 目标
ece_score, # 期望校准误差
confidence_from_probs, # 归一化熵置信度
temp_bucket, # (qtype, k) → "choice:6-10" 之类的桶名
collate_items, # batch → 张量字典
)
from laya.agent import _fix_tokenizer_config6.3 资源链接
| 资源 | 地址 |
|---|---|
| GitHub 仓库 | https://github.com/NandhaKishorM/laya |
| HF 模型 | https://huggingface.co/convaiinnovations/laya |
| HF 多语言 | https://huggingface.co/convaiinnovations/laya-multilingual |
| HF 在线 Demo | https://huggingface.co/spaces/convaiinnovations/laya-demo |
| PyPI | https://pypi.org/project/laya/ |
| ModelScope | https://modelscope.cn/models/convaiinnovations/laya |
| 官方微调 notebook | notebooks/laya_finetune_typed_decisions_2xT4_kaggle.ipynb |
| 官方基准 notebook | notebooks/laya_benchmark_colab.ipynb(先跑到 Colab 徽章) |
| 完整基准报告 | 仓库根目录 BENCHMARKS.md |
| 训练数据集 | https://huggingface.co/datasets/LocalLLaMA/typed-decisions |
| 中文镜像 | export HF_ENDPOINT=https://hf-mirror.com |
| 许可证 | Apache 2.0,由 Convai Innovations 开发 |
6.4 一句话总结
Laya 不是"更小的 LLM",而是把决策从生成里剥离出来的另一类模型。 它不可替代 LLM,但它能让你的路由、分类、审核、护栏这些高频反射式决策,从 1500 ms 降到 33 ms,成本从按 token 计费降到 0,并且给出第一个在数学上站得住的置信度——足以支撑真正的自动执行与人工升级门控。
前提是:你必须微调它。 零样本的 Laya 约等于抛硬币。
最后,Laya 成功打破了“万物皆需自回归生成”的思维定势。对于非生成式的判别与分类工作流,Laya 凭借其高吞吐、极低延迟、强校准概率输出以及零幻觉的特性,为企业搭建高效、低成本的轻量化 AI 护栏和分流系统提供了极具价值的解决方案。
文章评论