夜雨聆风学习资料网

ARTICLE · 1069404

Laya 使用手册:下载、微调与推理部署

Laya 使用手册:下载、微调与推理部署

·  目录

·  一、Laya 是什么

·  二、环境准备与模型下载

·  三、推理使用

·  四、单卡 LoRA 微调

·  五、能力边界与选型建议

·  六、速查表与资源

01  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.21

·spherical_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% 才自动执行

02  环境准备与模型下载

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.0numpy>=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/cu128

2.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-en

ModelScope 也有对应仓库,可用于纯内网场景:

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.json

rl_agent_config.json 里被 Agent 读取的键及默认值:

默认值
作用
encoder
无默认,必需
骨干模型 HF id 或本地路径;tokenizer 目录不存在时的回退
max_len512
序列最大长度
head_max_len192
决策头(问题 + 选项)的 token 预算
temperature[1.0, 1.0, 1.0]
按 choice/score/noul 三个顺序的温度
temperature_by_options{}
按 (类型, 选项数) 分桶的温度覆盖,优先级高于 temperature
amp_dtype"fp16"
推理时自动混合精度类型,可设 "bf16"
head_layers2
决策头 Transformer 层数
act_costs{}
动作头成本矩阵,决定 n_act

输出的键名是 temperature 为 list、temperature_by_options 为 dict,两者都要写Agent 先按 temp_bucket(qtype, k) 去 temperature_by_options 查,查不到才回落到 temperature[qt]

03  推理使用

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": "user@acme.com",

"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 直接给业务侧做阈值判断,不要让调用方自己算

04  单卡 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\": \"user@acme.com\", \"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

数据质量建议(来自官方设计原则)

1.不要用 LLM 生成合成标签。用合成标签训练校准模型,只会让模型校准到 LLM 自身的错误和幻觉上。官方流水线 100% 使用人工标注的公开数据集。

2.给足训练量。官方用 1200 cases / 6000 decisions 达到 0.766。低于 2000 条 decision 时先做小样本验证,别期待天花板。

3.加干扰选项。故意放"长得像"的选项(电话 vs 紧急联系人电话、邮箱 vs 街道地址),能显著提升判别力。

4.动态扰动防走捷径:打乱选项顺序、改写问题表述、在原始文本与嵌套 JSON 之间交替、注入随机无关问题。选项顺序打乱尤其重要——否则模型会学到"永远选第一个"。

5.选项数控制在 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()

关键点

1.决策头不能冻也不能挂 LoRAheadscoreract_headtype_emb 都是随机初始化的新模块,冻结它们等于什么都没训。

2.编码器冻结后必须调 enable_input_require_grads(),否则配合 gradient checkpointing 时梯度传不到 LoRA 参数上。

3.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-55e-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.1

w_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-decisions0.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)

05  能力边界与选型建议

官方把限制写得很诚实,工程落地前必须知道:

5.1 选项数超过 20 就明显掉点

这是架构级约束。序列被切成两部分:选项提示预算 head_max_len 与状态预算 max_len - head_max_len

checkpoint
max_lenhead_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 语言基准里没有单列中文成绩。落地前建议:

1.  先用 laya-multilingual 在自有中文测试集上跑一遍基线,看是否满足需求

2.  不满意就按第四节做中文领域微调——中文场景基本一定要微调,因为标签体系是你自己的

3.  中文 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 整合现有系统。

06  速查表与资源

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_config

6.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 约等于抛硬币。

相关学习资料