导语:用生成式 LLM 做分类,是"高射炮打蚊子"

邮件路由、工单分类、内容审核、紧急度打分——这类"决策型"任务几乎存在于每一条业务流水线里。现在很多团队的做法是:接一个 GPT 或者本地 LLM,写一大段 prompt 让它输出 JSON,再用正则和重试去兜底解析。这条路走得通,但每一步都在制造新问题:

  • 输出是自由文本:模型偶尔不听话,JSON 解析一崩,整条链路就崩;
  • 幻觉没有边界:分类任务不需要任何创造性,但 LLM 天生会"编";
  • 延迟不确定:token 一个个蹦出来,p99 没法承诺;
  • 成本按 token 计费:一封邮件问三个问题,账单翻三倍。

Convai Innovations 开源的 Laya 走的是另一条路:它是一个非自回归的 System 1 决策模型——你给它一个状态(一封邮件、一张工单、任何文本或 JSON 文档)和一组带类型的问题,它就在一次前向传播(约 33 ms)里返回带校准概率的类型化答案,支持 100+ 种语言。它不生成任何自由文本:没有输出要解析、没有幻觉可言、延迟是确定的。

本文会带你完整走一遍这条部署路径:用 optimum-cli 一条命令导出 OpenVINO™ IR(顺便用 --weight-format fp16 把权重量减半),用 optimum-intel 的 OVModelForLayaDecision 在 Intel CPU / GPU 上完成推理,最后跑一个交互式邮件分拣 Demo。文中所有推理输出均为本文在 Intel Core Ultra 9 288V(Lunar Lake)的 iGPU 上实测的结果,可以直接对照运行。

先看一眼效果

把一封"逾期发票停服警告"邮件连同三个问题喂给 Laya——该路由给哪个团队?是不是垃圾邮件?紧急程度如何?

模型一次前向传播全部作答:

category   -> billing (p=95.9%)     # 路由给账务团队,置信度 95.9%
is_spam    -> 2.9%                   # 几乎可以肯定不是垃圾邮件
urgency    -> 1.79 / 2               # 紧急度 1.79(0=不急, 1=需尽快, 2=阻塞级)

三个问题、一次前向、每个答案都自带概率——这就是 Laya 的全部工作方式。下面从头部署。

Laya:一个"只做决策"的模型

▪三种带类型的问题

Laya 的推理接口只有 system_one(state, questions),问题的"类型"只有三种:

  • choice —— 多选一。给一组带描述的选项,返回选中的选项和每个选项的校准概率。典型用途:路由、审核、分类。
  • score —— 有序打分。给一列从低到高的等级描述,返回期望分数(连续值)。典型用途:紧急度、严重度、优先级。
  • noul —— 带概率的"是/否"。返回命题成立的概率。典型用途:垃圾邮件、钓鱼检测、流失风险标记。

三种问题可以混在同一个 questions 字典里,一次前向传播全部出结果。

▪为什么它的概率"可信"

Laya 使用 RLCD(Reinforcement Learning for Calibrated Decisions)训练:奖励函数基于严格合规评分规则(strictly proper scoring rules)——这类规则的数学性质是,只有报告"诚实"的概率分布才能最大化奖励。说人话就是:模型虚报置信度会吃亏,所以它给出的概率可以直接拿去做业务阈值和护栏,而不用再人工调"温度"。

架构上它也很轻:一个 421M 参数的双向编码器(ModernBERT-large)加一个小的 transformer 决策头,没有任何自回归解码器——快、小、确定性强。

▪三个开源 checkpoint

三个 checkpoint 全部 Apache 2.0 开源:

注意 Laya 的 checkpoint 不是标准 transformers 模型:权重在单个 model.safetensors 里,决策头的超参数和校准温度在 rl_agent_config.json 里,分词器在 tokenizer/ 子目录里。本文默认使用英语 checkpoint,同样的代码对三个 checkpoint 都适用。

官方给出的 laya-multilingual 长上下文测试:把 20 条覆盖 8 种语言的 support request 放在不相关长文本的末尾,考察 8192 token 窗口内准确率与延迟随上下文长度的变化:

Laya 多语言模型长上下文测试

端到端部署

▪环境准备

Laya 支持还没有合入 optimum-intel 正式版,本文使用携带该集成的 fork 分支(laya):

git+https://github.com/xiangfuxwang/optimum-intel-dev.git@laya

其余依赖照常从 PyPI 安装。注意 torch 和 torchvision 必须成对安装 CPU 版本——optimum-intel 依赖 torchvision,如果先装 CPU 版 torch、再从 PyPI 装 torchvision,会拉下 CUDA 版本,和 CPU 版 torch 配对时 import transformers 直接崩溃:

from pip_helper import pip_install
# 先卸载预装版本避免冲突
%pip uninstall -q -y optimum optimum-intel optimum-onnx
# torch 和 torchvision 必须从 PyTorch CPU 源成对安装
pip_install(
    "-q",
    "torch>=2.1",
    "torchvision",
    "--extra-index-url", "https://download.pytorch.org/whl/cpu",
)
pip_install(
    "-q",
    "openvino>=2025.4.0",
    "openvino-tokenizers",
    "nncf>=3.3.0",
    "optimum>=1.24.0",
    "transformers>=4.53.0",
    "safetensors",
    "huggingface_hub",
    "ipywidgets",
)
# Laya 支持:在合入上游之前,安装携带集成的 fork 分支
pip_install("-q", "git+https://github.com/xiangfuxwang/optimum-intel-dev.git@laya")

▪下载模型

从 Hugging Face Hub 拉取 checkpoint(国内网络可以取消注释走镜像):

import ipywidgets as widgets
from huggingface_hub import snapshot_download
# 访问 huggingface.co 较慢时可启用镜像:
# import os; os.environ["HF_ENDPOINT"] = "https://hf-mirror.com"
model_options = {
    "laya (English)": "convaiinnovations/laya",
    "laya-multilingual": "convaiinnovations/laya-multilingual",
    "laya-typed-decisions (fine-tuned)": "convaiinnovations/laya-typed-decisions",
}
model_selector = widgets.Dropdown(options=list(model_options.items()), description="Checkpoint:")
display(model_selector)
from pathlib import Path
model_dir = Path(model_selector.value.split("/")[-1])
if not (model_dir / "rl_agent_config.json").exists():
    snapshot_download(repo_id=model_selector.value, local_dir=model_dir)
print(f"Checkpoint downloaded to: {model_dir.resolve()}")
print(f"Files: {sorted(p.name for p in model_dir.iterdir())}")

下载完成后可以看到 checkpoint 的结构(不是标准 transformers 布局):

Files: ['assets', 'config.json', 'email_utils.py', 'encoder', 'eval',
        'model.safetensors', 'multilingual', 'README.md',
        'rl_agent_api.py', 'rl_agent_config.json', 'rl_common.py',
        'tokenizer', 'typed-decisions']

▪一条命令导出 OpenVINO IR

模型用 optimum-cli export openvino 导出为 OpenVINO IR,等效的命令行调用是:

optimum-cli export openvino --model convaiinnovations/laya --task feature-extraction --weight-format fp16 laya-ov-fp16

notebook 里通过下拉框选择权重压缩格式(见下一节的对比表),再用 cmd_helper 封装调用:

import ipywidgets as widgets
# 导出时应用的权重压缩格式
weight_format_options = {
    "FP16 (default)": "fp16",
    "INT8": "int8",
    "INT4": "int4",
}
weight_format_selector = widgets.Dropdown(
    options=list(weight_format_options.items()), value="fp16", description="Weights:"
)
display(weight_format_selector)
from cmd_helper import optimum_cli
weight_format = weight_format_selector.value
ov_model_dir = Path(f"{model_dir.name}-ov-{weight_format}")
if not (ov_model_dir / "openvino_model.xml").exists():
    print("Exporting to OpenVINO IR, this may take a few minutes ...")
    optimum_cli(
        model_dir,
        ov_model_dir,
        additional_args={"task": "feature-extraction", "weight-format": weight_format},
    )
else:
    print(f"Found existing conversion in {ov_model_dir}/, skipping export")
print(f"Files: {sorted(p.name for p in ov_model_dir.iterdir())}")

导出产出一个单一 OpenVINO IR,接口非常干净:

输入:input_ids、attention_mask([batch, sequence]);marker_pos、marker_mask([batch, num_options]);qtype([batch],0=choice、1=score、2=noul)
输出:logits([batch, num_options],每个选项标记位一个分数)和 act_logits([batch, 2],"作答 vs 上报"头)

这里有个关键机制:每个答案选项在输入序列里以一个标记 token 前缀,marker_pos 指向这些标记的位置,决策头只对这些位置打分——这就是"一次前向、多个问题"的实现方式。

导出还会把运行时需要的资产放在 IR 旁边:rl_agent_config.json(校准温度、序列预算)、tokenizer/ 目录,以及 OpenVINO™ 分词器 / 去分词器 IR:

Files: ['openvino_model.xml', 'openvino_model.bin',
        'openvino_tokenizer.xml', 'openvino_tokenizer.bin',
        'openvino_detokenizer.xml', 'openvino_detokenizer.bin',
        'rl_agent_config.json', 'tokenizer', ...]

▪压缩模型权重

默认导出保持 checkpoint 的 fp32 精度(约 1.6 GB)。--weight-format 选项通过 NNCF 权重压缩在导出时直接压缩权重:

因为 Laya 返回的是校准概率、且往往直接喂给业务阈值和护栏,使用 int8 / int4 压缩后请先确认决策仍然可接受,再投入生产。如果需要完整 fp32 保真,导出时不加这个标志即可。

▪加载模型与选择推理设备

导出完成后,加载过程和原生 transformers 几乎一模一样:

from notebook_utils import device_widget
from optimum.intel.openvino.modeling_laya import OVModelForLayaDecision
device = device_widget(default="CPU", exclude=["NPU"])
ov_model = OVModelForLayaDecision.from_pretrained(ov_model_dir, device=device.value)
print(f"Inputs : {list(ov_model.input_names)}")
print(f"Outputs: {list(ov_model.output_names)}")
Inputs : ['input_ids', 'attention_mask', 'marker_pos', 'marker_mask', 'qtype']
Outputs: ['logits', 'act_logits']

设备选择上:

CPU 开箱即用;

Intel GPU(集成显卡 / 独立显卡) 与上面导出的压缩 IR 是天作之合——权重导出时已经是低精度格式,加载时无需再做转换;

NPU 对该模型暂未验证,设备列表里已排除。

想对比自己机器上不同设备的延迟?下文"延迟实测"一节给出了测量代码。

▪推理:对齐官方 system_one API

运行时暴露的是官方 system_one(state, questions) API,所以转换后的模型是 PyTorch 原版的直接替代品。一个问题由三部分定义:

type —— "choice"、"score" 或 "noul";

instructions —— 问题文本;

criteria —— 选项:choice 用字典,score 用有序等级列表,noul 可选地给正 / 反命题描述。

下面是经典的 Laya 用例——邮件分拣:路由到正确的团队、标记垃圾邮件、给紧急度打分,一次前向全部完成:

import json
state = {
    "subject": "Invoice #4382 overdue - service suspension warning",
    "body": (
        "Hi, our accounting team flagged invoice #4382 (issued Sep 12, $1,240) as unpaid. "
        "Payment was sent on Sep 15 via wire transfer, please check on your side. "
        "If the payment is not located by Friday the service will be suspended."
    ),
}
questions = {
    "category": {
        "type": "choice",
        "instructions": "Which team should handle the email in `body`?",
        "criteria": {
            "billing": "invoices, payments, refunds",
            "technical": "bugs, outages, integrations",
            "sales": "pricing, demos, new purchases",
            "other": "none of the above",
        },
    },
    "is_spam": {"type": "noul", "instructions": "Is this email unsolicited spam or bulk marketing?"},
    "urgency": {
        "type": "score",
        "instructions": "How urgent is the issue described in `body`?",
        "criteria": ["no time pressure", "needs attention soon", "blocking issue or hard deadline"],
    },
}
result = ov_model.system_one(state, questions)
print(json.dumps(result, indent=2, ensure_ascii=False))

实测输出(Lunar Lake 的 iGPU):

{
  "model": "rl-agent",
  "answers": {
    "category": {
      "type": "choice",
      "choice": "billing",
      "probabilities": {
        "billing": 0.9587,
        "technical": 0.0183,
        "sales": 0.012,
        "other": 0.0109
      },
      "confidence": 0.8441,
      "rl_agent": { "act_probability": 1.0 }
    },
    "is_spam": {
      "type": "noul",
      "noul": 0.0287,
      "rl_agent": { "act_probability": 1.0 }
    },
    "urgency": {
      "type": "score",
      "score": 1.7927,
      "legend": {
        "0": "no time pressure",
        "1": "needs attention soon",
        "2": "blocking issue or hard deadline"
      },
      "probabilities": { "0": 0.0055, "1": 0.1963, "2": 0.7982 },
      "confidence": 0.5192,
      "rl_agent": { "act_probability": 1.0 }
    }
  },
  "usage": { "input_tokens": 358, "output_tokens": 0 }
}

怎么读这个输出:

choice 问题返回选中的选项加每个选项的校准概率——这里是 billing,约 96%;

score 问题返回期望分数(连续值)——这里 1.79(0..2 尺度),接近"阻塞级";

noul 问题返回命题成立的概率——noul: 0.03,几乎可以肯定不是垃圾邮件;

confidence 是答案分布的 1 − 归一化熵,衡量这个答案"多笃定";

rl_agent.act_probability 是模型自己对"该不该作答(还是上报人工)"的估计;

usage.input_tokens 是实际处理的 token 数——注意三个问题只消耗了一次前向传播。

换一封完全不同的邮件,用同样的代码消费结果:

state2 = {
    "subject": "App crashes when opening settings",
    "body": (
        "Hello, since the last update the Android app force-closes every time I open "
        "the settings screen. Reinstalling did not help. I use a Pixel 8 with Android 15."
    ),
}
result2 = ov_model.system_one(state2, questions)
for qid, ans in result2["answers"].items():
    if ans["type"] == "choice":
        top = ans["choice"]
        print(f"{qid:10s} -> {top} (p={ans['probabilities'][top]:.1%})")
    elif ans["type"] == "score":
        print(f"{qid:10s} -> {ans['score']:.2f} / {len(ans['legend']) - 1}")
    else:
        print(f"{qid:10s} -> {ans['noul']:.1%}")

实测输出:

category   -> technical (p=82.6%)
is_spam    -> 6.4%
urgency    -> 1.59 / 2

同样的三个问题,App 崩溃邮件被正确路由给技术团队——不需要改 prompt、不需要换模型、不需要第二次前向。

▪延迟实测

下面这段代码测量一次 system_one 调用的平均延迟。几次预热调用不计入测量——每个设备的首次推理包含一次性的模型编译(GPU 上还有 kernel JIT)开销。要对比不同设备,回到上面的设备选择单元格换个选项,重新执行后续单元格即可:

import time
for _ in range(3):  # 预热:排除一次性的编译 / JIT 开销
    ov_model.system_one(state, questions)
n_runs = 10
start = time.perf_counter()
for _ in range(n_runs):
    ov_model.system_one(state, questions)
avg_ms = (time.perf_counter() - start) / n_runs * 1e3
print(f"Device: {device.value}")
print(f"Average latency over {n_runs} runs: {avg_ms:.1f} ms per system_one call")

本文实测(Intel Core Ultra 9 288V,同一示例):CPU 稳态延迟约 571 ms;换到 Lunar Lake 的 iGPU 后稳态约 30 ms,加速接近 20 倍,与官方标称的约 33 ms 吻合。

交互式 Demo

教程最后提供了一个可以直接上手的 ipywidgets 界面:粘贴任意邮件正文,点一下按钮,同一组问题实时分拣。

注意输入长度限制。 序列化后的 state 必须装进模型的上下文窗口——英语 laya checkpoint 是 512 token(laya-typed-decisions 为 1024,laya-multilingual 为 1024–8192)。装不下时,运行时会静默截断序列化 state 的末尾,长邮件结尾处的细节(截止时间、联系方式、签名)可能被悄悄丢掉。保持输入精炼、把重要信息放在前面——Demo 每次运行后会打印实际处理的输入 token 数:

from IPython.display import clear_output
body_input = widgets.Textarea(
    value=state["body"], description="", layout=widgets.Layout(width="100%", height="140px")
)
run_button = widgets.Button(description="Triage email", button_style="primary")
truncation_note = widgets.HTML(
    "<b>Note:</b> inputs that exceed the model's context window are truncated at the end — "
    "keep the email concise and put important details first."
)
demo_output = widgets.Output()
def triage(_):
    with demo_output:
        clear_output()
        res = ov_model.system_one({"subject": "(user input)", "body": body_input.value}, questions)
        for qid, ans in res["answers"].items():
            if ans["type"] == "choice":
                top = ans["choice"]
                print(f"{qid:10s}: {top}  (p={ans['probabilities'][top]:.1%})")
            elif ans["type"] == "score":
                print(f"{qid:10s}: {ans['score']:.2f} / {len(ans['legend']) - 1}")
            else:
                print(f"{qid:10s}: {ans['noul']:.1%}")
        print(f"({res['usage']['input_tokens']} input tokens processed)")
run_button.on_click(triage)
display(
    widgets.VBox(
        [
            widgets.Label("Email body:"),
            body_input,
            truncation_note,
            run_button,
            demo_output,
        ]
    )
)

总结与资源链接

Laya 把"LLM 流水线里最难缠的那段——解析生成文本"整个消灭掉了:带类型的问题进、带校准概率的决策出,一次前向、确定延迟、Apache 2.0 开源。而 OpenVINO™ + Optimum Intel 让它在 Intel CPU / GPU 上的部署简化成"一条 optimum-cli + 几十行 Python"——fp16 权重减半、Lunar Lake 的 iGPU 再加速近 20 倍。如果你的业务里有一堆"其实只需要一个分类器 / 一个打分器"的环节,Laya 值得一试。

相关资源:

  • Laya × OpenVINO™ 教程 notebook(已合并至 openvino_notebooks 主仓库):https://github.com/openvinotoolkit/openvino_notebooks/tree/latest/notebooks/laya-decision-models
  • 携带 Laya 集成的 optimum-intel fork 分支(等待上游合入):https://github.com/xiangfuxwang/optimum-intel-dev/tree/laya
  • Laya 模型卡:https://huggingface.co/convaiinnovations/laya
  • Laya 多语言模型卡:https://huggingface.co/convaiinnovations/laya-multilingual
  • Laya 类型化决策模型卡:https://huggingface.co/convaiinnovations/laya-typed-decisions
  • Laya 官方代码仓库:https://github.com/NandhaKishorM/laya
  • Laya 官方文档:https://nandhakishorm.github.io/laya/
  • Optimum Intel:https://github.com/huggingface/optimum-intel
  • OpenVINO™:https://github.com/openvinotoolkit/openvino
Logo

为开发者提供丰富的英特尔开发套件资源、创新技术、解决方案与行业活动。欢迎关注!

更多推荐