ARTICLE · 1069404
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 处理"反射式决策"(工单路由、垃圾邮件识别、越狱检测、内容审核分级):
关键点:当模型不能生成任意文本时,幻觉和格式损坏在物理上不可能发生。护栏从"软约束"变成"硬保证"。
1.3 三种决策原语
所有问题必须归入以下三类之一,不接受自由文本作答:
choice | ||
score | ||
noul |
noul 是项目自造词,本质是一个命名的伯努利分布。0.9 就是"90% 概率为真"。
1.4 三个 checkpoint
convaiinnovations/laya | ||||
convaiinnovations/layamultilingual | ||||
convaiinnovations/layatyped-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 环境要求
safetensors>=0.4.0huggingface_hub>=0.20.0、numpy>=1.20.0 | |
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 | ||
max_len | 512 | |
head_max_len | 192 | |
temperature | [1.0, 1.0, 1.0] | |
temperature_by_options | {} | (类型, 选项数) 分桶的温度覆盖,优先级高于 temperature |
amp_dtype | "fp16" | "bf16" |
head_layers | 2 | |
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) | ||
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 基准上的选择性自动化表现:
丢弃一半低置信度请求,准确率从 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 | |
|---|---|---|
| 32.8 ms | ||
| 40.1 ms | ||
| 72.3 ms(7.2 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 与官方方案的差异(务必知情)
全参数微调load_state_dict(strict=True) + DDP,无任何 LoRA 代码) | ||
| 完全一致 |
诚实提示: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 | |
questions[qid].type | choice / score / noul |
choicecriteria | {c: None},无描述) |
scorecriteria | |
noulcriteria | {"false": "...", "true": "..."} |
gold[qid].probabilities |
数据质量建议(来自官方设计原则):
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.决策头不能冻也不能挂 LoRA。head、scorer、act_head、type_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
单卡适配建议:
MICRO_BATCH | ||||
GRAD_ACCUM | ||||
LR_ENCODER | 5e-5 | 5e-5 | ||
LR_HEAD | ||||
GROUP_SIZE | 8 | |||
EPOCHS | ||||
T_max = 总更新步数 | ||||
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:
laya | 0.081 | |
laya-multilingual | 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 参考值 | ||
|---|---|---|
基线对照(typed-decisions 2000 decisions):
laya-typed-decisions | 0.766 | ||||
laya | |||||
laya-multilingual | |||||
按原语拆分: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' | ||
'model.safetensors' not found | save_file 的路径与文件名 | |
FileNotFoundError: Local model path not found | ||
question 'xxx' options exceed head_max_len=... | head_max_len(详见 5.1),或减少选项 | |
encoder 参数 requires_grad=True | ||
model.enable_input_require_grads() | ||
Router(preload=True) |
05 能力边界与选型建议
官方把限制写得很诚实,工程落地前必须知道:
5.1 选项数超过 20 就明显掉点
这是架构级约束。序列被切成两部分:选项提示预算 head_max_len 与状态预算 max_len - head_max_len。
max_len | head_max_len | ||
|---|---|---|---|
laya | |||
laya-multilingual | |||
laya-typed-decisions |
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
正确姿势是组合,不是替代: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 资源链接
notebooks/laya_finetune_typed_decisions_2xT4_kaggle.ipynb | |
notebooks/laya_benchmark_colab.ipynb | |
BENCHMARKS.md | |
export HF_ENDPOINT=https://hf-mirror.com | |
6.4 一句话总结
Laya 不是"更小的 LLM",而是把决策从生成里剥离出来的另一类模型。 它不可替代 LLM,但它能让你的路由、分类、审核、护栏这些高频反射式决策,从 1500 ms 降到 33 ms,成本从按 token 计费降到 0,并且给出第一个在数学上站得住的置信度——足以支撑真正的自动执行与人工升级门控。
前提是:你必须微调它。 零样本的 Laya 约等于抛硬币。