0%

Jev 的开源替代 Laya:部署与实测

1. laya 是什么

laya 是一个跑在自己机器上的判断模型:给它一段材料(工单、邮件、命令、JSON)和几个预设问题,它返回选项和概率,不生成任何文字。

它是照着 Jev 的接口做的开源替代:比 Jev 快得多、免费、能自己部署,但开箱能力弱一档,选项一多就崩,要用得先拿自己的数据微调。下面说的快和弱,参照物都是 Jev。

1.1 和 Jev 的关系

2026 年 9 月 15 日 TypeSafe AI 发布 Jev,闭源托管、邀请制。三天后 laya 出现在 GitHub 上,由 Convai Innovations 开发。

laya 复现的是 Jev 的接口和那套概念,模型本身是另一套。它是三个 encoder 加一个决策头,英文基于 ModernBERT-large 421M 参数,多语言基于 mmBERT-base 322M,权重都是自己训练的。Jev 只提供托管 API,laya 的基准测试里也从没调用过 TypeSafe 的接口,取的都是官方公布的数字。

接口这一层对得上。laya 的 HTTP 服务源码开篇注释写着自己的定位:对外提供 TypeSafe Jev 那套 /v1/systemone 接口。照着 Jev 写的客户端,把 baseUrl 指到 laya 的地址就能继续用,返回结构一致,choice / score / noul 三种答案加一个 usage 块。仓库里那张官方对比图直接叫 laya_vs_jev_full.png。

规模上,仓库 9 月 18 日建仓,到 9 月 26 日已经 2.4 万 star、2100 多个 fork、154 个未关 issue。PyPI 上从 0.1.0 到 0.3.20 一共 28 个发行版,集中在头七天。

1.2 比 Jev 强在哪

延迟差一个数量级。官方在 T4 上测出单问题 32.8 毫秒,本机实测 70 毫秒。Jev 的 p50 延迟由 AbdelStark 和 nibzard 两个第三方项目测到 236 到 276 毫秒。两边样本和提示词不同,但差着七八倍。

权重开放,能自己部署。Apache-2.0,单卡甚至纯 CPU 都能起服务,数据不出本机,成本为零。Jev 要申请、按 token 付费。

同一个请求里问多个问题,材料只读一遍。判断依据能从文本直接读出来的任务,概率给得干脆,实测里按 ID 查库和识别明确动作请求稳定正确,可以直接接阈值。

1.3 比 Jev 差在哪

开箱准确率低一档。官方在 typed-decisions 基准上测,微调前只有 0.362,低于 0.461 的多数类基线;Jev 公布的是 0.727。laya 那个常被引用的 0.766 是拿这个基准的训练集微调之后的结果,同一张表里就列着微调前的 0.362。

选项一多就崩。所有选项共享一个固定的 token 池,超过 20 个选项准确率掉得很快,Banking77 上 laya 0.425、Jev 0.870。Jev 支持到 255 个选项。这是架构约束,微调也解决不了。

校准要自己动手。开箱状态的原始 ECE 是 0.213,比 Jev 的 0.144 还差;官方宣传的 0.081 是温度缩放拟合之后的结果。加载 checkpoint 时发的温度越界警告,说的就是这件事。

另外三条不是相对 Jev 的,是 laya 自身的问题:

  • 需要背景知识的判断做不了。「这是我们的文档还是通用知识」「这是问政策还是要执行」两类稳定出错,因为 laya 无从知道某个知识库里有什么。
  • 高置信度不等于判对。模型自信但判错的情况实测里出现过。
  • 多语言路由有个默认分支。拉丁字母、没有变音符的语种会被送进英文 checkpoint。
  • HTTP 服务的推理是串行的,约 14 QPS 封顶。

2. 装到本机

2.1 不走 Docker

官方平台页写明 Apple Silicon 上 Docker 只能跑 CPU。Docker Desktop for macOS 跑的是 Linux 虚拟机,Metal 透不进去,官方的 compose.cuda.yaml 和 compose.spark.yaml 也都跟 Mac 无关。本机是 M2 Max,有 Apple GPU,走 Docker 等于主动放弃它。一条 pip 命令就能解决的事,不值得为此再装一个 Docker Desktop。

官方把 Docker 定位在 Linux 服务器上:要在目标机上 build,不能用 buildx 交叉编译。

2.2 安装

1
2
3
4
python3 -m venv .venv
.venv/bin/pip install --upgrade pip
.venv/bin/pip install "laya[serve]" # 主体 + HTTP 服务
.venv/bin/pip install "httpx[socks]" # 挂 socks 代理时必需,见下

三条坑:

  • Homebrew 的 Python 是 externally-managed,不开 venv 直接 pip install 报 externally-managed-environment。
  • huggingface_hub 走 httpx,而 httpx 用 socks 代理需要额外的 socksio,否则首次下载 checkpoint 报 Using SOCKS proxy, but the 'socksio' package is not installed。pip 本身不受影响,是因为它走的是 http_proxy。装 httpx[socks] 解决,这条对任何用 httpx 的库都适用。
  • laya-serve 默认端口 8000。本机 8000 被另一个常驻服务占着,起服务前必须显式设 LAYA_PORT。

装机结果:Python 3.14.2 + torch 2.14.0 + transformers 5.17.0 + laya 0.3.20,venv 936 MB,checkpoint 缓存 1.4 GB。实测无兼容问题。

命令行跑一次完整预测(首次会从 HuggingFace 下 checkpoint,几分钟):

1
2
export LAYA_DEVICE=mps
laya "My payment failed twice and nobody replied for three days." --predict

2.3 起服务

HTTP 服务的配置全在环境变量里,不需要配置文件:

1
LAYA_PORT=8123 LAYA_DEVICE=mps LAYA_MODELS=english,multilingual laya-serve

laya-serve 不认 --help,任何参数都不接,直接启动。本机挂着代理,curl 要加 --noproxy '*',否则请求会被送去代理。

1
2
curl --noproxy '*' http://127.0.0.1:8123/health
# {"status":"ok","loaded":["english","multilingual"],"device":"mps"}

两个上限要知道。HTTP 路径不接受 max_len 参数,长文档只能吃到默认的 1024 token,要读长文得绕开 HTTP 走 Python API。

另一个是吞吐。推理走 ThreadPoolExecutor(max_workers=1),外面还套一层 asyncio.Lock,同一时刻只处理一个请求。短输入 70 毫秒一次,算下来约 14 QPS 封顶,并发一多就排队。源码注释解释了为什么这么设计:一次只跑一个前向传播,本来就是单张 CPU 或 GPU 的自然用法。要更高吞吐只能自己起多实例。

2.4 MPS 实测

进程内计时,已排除 import 和 checkpoint 加载,含 warmup:

输入CPUMPS加速
短(97 字符)376.9 ms70.7 ms5.3 倍
长(18980 字符)12610.3 ms1381.3 ms9.1 倍

长文本那 1381 毫秒,和官方宣称的「4000 token 约 1.7 秒 on Apple GPU」对得上,两边独立得出接近的数。CPU 和 MPS 的答案一致,只有末位浮点差异,切设备不用担心判断变了。

有一处测量陷阱:time laya "..." 量出来是 4 到 6 秒,看着像性能数据,量的却是整个进程。光 torch import 就占 2 到 3 秒,短输入的推理时间完全被淹没。短输入的性能必须进程内计时才有意义,上面那张表用的是进程内计时的脚本。

3. 接口:请求和返回

HTTP 端点是 POST /v1/systemone。Python 侧和它同一套结构,用 Router.predict()。

3.1 请求

请求体三个字段:

  • state:材料。一段文本,工单、邮件、命令、JSON 都行。
  • questions:问题集。一个对象,key 是问题名,value 是问题定义。
  • model:可选。强制指定 checkpoint,不传就按语种自动路由。

每个问题定义必填 type 和 instructions,type 决定还要给什么、返回什么:

  • choice:再给一个 criteria 对象,key 是选项名、value 是该选项的说明。返回选中的选项名和各选项概率。
  • score:再给一个 criteria 数组,从低到高排列的档位说明。返回加权分和各档概率。
  • noul:不用再给。返回一个 0 到 1 的数,是答案为「是」的概率。接近 1 是很可能「是」,接近 0 是很可能「否」,0.5 上下是拿不准。

noul 这个类型名是 TypeSafe 取的,官方文档给它的定义只有一句「a yes/no question」,没说这个词本身是什么意思。laya 要实现同一套协议,沿用了这个名字。功能上它就是二分类:命中则 1,不命中则 0,中间是概率。

三种混在一次请求里,材料只读一遍:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"state": "Hi, we were billed twice for March. Please refund the duplicate today.",
"questions": {
"department": {
"type": "choice",
"instructions": "Which department should handle this?",
"criteria": {
"billing": "invoices, payments, refunds",
"technical": "bugs, outages, system errors"
}
},
"urgency": {
"type": "score",
"instructions": "How urgent is this?",
"criteria": ["not urgent", "soon", "blocking"]
},
"churn_risk": {
"type": "noul",
"instructions": "Does the user threaten to cancel or leave?"
}
}
}

Python 侧同一个结构,predict() 还多两个参数:model 强制 checkpoint,max_len 放宽长度上限,长文传 8192。Router 构造时 preload=True 可让启动就加载,省掉首次请求的加载时间;max_loaded 控制同时驻留几个 checkpoint,默认 2。

官方带了五个现成问题集,省得自己拼 questions:

  • triage_questions():intent、is_urgent、frustration、refund_requested、churn_risk
  • email_questions():category、is_spam、is_phishing、urgency、needs_reply
  • guard_questions():jailbreak、prompt_injection、sensitive_data、harm_severity、topic
  • moderation_questions():toxic、harassment、threat、spam、severity
  • router_questions():difficulty、domain、needs_tools、is_sensitive

命令行对应 --preset triage 这种写法。

3.2 返回

一次真实往返。请求体存成 toolbody.json,问的是「该调哪个工具」,四个工具各出一个是/否概率:

1
2
3
curl -s --noproxy '*' -X POST http://127.0.0.1:8123/v1/systemone \
-H 'Content-Type: application/json' \
-d @toolbody.json
1
2
3
4
5
6
7
8
9
{
"state": "What is the weather in Tokyo tomorrow?",
"questions": {
"tool_web_search": {"type": "noul", "instructions": "Does answering `request` require live or external information that changes over time, such as weather, news, current prices?"},
"tool_order_lookup": {"type": "noul", "instructions": "Does answering `request` require looking up a specific order, shipment or invoice in our database?"},
"tool_refund_issue": {"type": "noul", "instructions": "Is the user asking us to perform a refund or cancellation now, as an action?"},
"tool_knowledge_base": {"type": "noul", "instructions": "Is the user asking how our product works, or about one of our policies or procedures?"}
}
}

服务器返回(格式化过,原响应是单行):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"model": "laya-rl-agent",
"answers": {
"tool_web_search": {"type": "noul", "noul": 0.6816, "confidence": 0.6816, "answer_confidence": 0.6816, "action": {"act_probability": 1.0}},
"tool_order_lookup": {"type": "noul", "noul": 0.0, "confidence": 1.0, "answer_confidence": 1.0, "action": {"act_probability": 1.0}},
"tool_refund_issue": {"type": "noul", "noul": 0.0, "confidence": 1.0, "answer_confidence": 1.0, "action": {"act_probability": 1.0}},
"tool_knowledge_base": {"type": "noul", "noul": 0.0, "confidence": 1.0, "answer_confidence": 1.0, "action": {"act_probability": 1.0}}
},
"usage": {"input_tokens": 214, "output_tokens": 0},
"routing": {
"model": "english",
"reason": "English Latin text",
"detection": {"script": "latin", "language": "en", "is_english": true,
"diacritic_rate": 0.0, "non_latin_fraction": 0.0}
}
}

判断是需要 web_search,概率 0.68,另外三个工具是确定的否。

返回体四块:

  • answers:key 是问题名,和请求里的 questions 一一对应。每项都带 type,而答案字段的名字与题型同名:choice 题读 choice(选中的选项名),score 题读 score(加权分),noul 题读 noul(答案为「是」的概率)。choice 和 score 另有 probabilities 给出完整分布,score 还多一个 legend,把分布里的下标映射回请求中写的档位说明。
  • usage:input_tokens 是材料和问题指令加起来 tokenize 后的长度;output_tokens 恒为 0,模型不生成 token,不是出错。
  • routing:model 是实际选中的 checkpoint,另有 reason 说明路由理由,detection 给出识别到的语种和依据。
  • model:顶层那个是架构名 laya-rl-agent,恒定不变,没有信息量。

每一项里还有 confidence、answer_confidence 和 action。前两个是两套不同的置信度,下一小节说。action.act_probability 目前没有可用信号,实测几乎恒为 1.0,官方文档里写明它的原始 logit 与判断对错反向相关,不要拿它做决策。

3.3 两个 confidence 不是一个意思

单题答案里有两个置信度字段,容易踩坑。

confidence 对 choice 和 score 是归一化熵,衡量整个分布有多集中;answer_confidence 是 max(p),也就是报出来的那个答案本身的概率。源码里写明,后者才是温度缩放拟合的对象,也是仓库里所有校准指标计算时用的量,只有它具备「报 c 就有约 c 的比例正确」这条性质。

noul 只有两个选项,max(p_true, 1 - p_true) 就是 max(p),所以这两种题上两个字段永远相等。顺带一提,Jev 的 noul 答案里没有 confidence 字段,官方原语表里它只返回一个数,是 laya 补上的。

两者量纲不同,不能拿同一个阈值去比。要门控只能看 answer_confidence。

反直觉的例子就在上面那份返回里。tool_order_lookup 的 noul 是 0.0,confidence 却是 1.0,看着像自相矛盾。实际是「100% 确定答案是『否』」:answer_confidence 取的是分布 [1.0, 0.0] 的最大值,也就是 1.0。答案是否为「是」,看的是 noul 字段,不是 confidence。

4. 工具路由:14 条对 7 条

场景就是上面那条请求:给一条 query,判断该调哪个工具。14 条用例覆盖按 ID 查订单、退款、查天气、问政策、闲聊。

试了两种问题架构,都只对 7 条。上面用的是「每个工具一个 noul」,四个问题一次前向传播全出结果,取概率最大值,全都低于阈值就判不需要工具:

1
2
3
4
5
6
probs = {k: v["noul"] for k, v in data["answers"].items()}
top = max(probs, key=probs.get)
if probs[top] >= 0.85:
call_tool(top)
else:
fallback_to_llm()

另一种是 N 选 1,一个 choice 问题,criteria 的 key 就是工具名:

1
2
3
4
5
6
7
{"route": {"type": "choice",
"instructions": "Which single tool should handle `request`?",
"criteria": {"web_search": "live info such as weather or news",
"order_lookup": "look up a specific order in our database",
"refund_issue": "perform a refund or cancellation now",
"knowledge_base": "how our product works, or our policies",
"none": "no tool needed"}}}

noul 那种更好用,尽管准确率一样。每个工具一个校准过的概率,阈值可以自己定;多个工具能同时命中,是真多标签;失败时还能看出是哪个工具判断失灵,choice 那种只看到「选错了」。

错的那 7 条有规律,按失败原因分成两类。

能用的,是触发条件能从文本直接读出来的:

  • 按订单号查询,order_lookup 给到 0.94 到 0.9999
  • 明确的动作请求(cancel order and refund me),refund_issue 给到 0.92 到 0.98
  • 闲聊致谢,所有工具都是 0.00

不能用的,是两类需要背景知识的判断。

一类是「这是我们的文档还是通用知识」。How do I reset my password? 这条,所有工具的概率都低于 0.15。问题不在措辞上:laya 无从知道某个知识库里有什么内容。这种场合正确的做法是直接检索或者总是调用,不要让它猜。

另一类是「问政策」和「要执行」的区分。What is your refund policy 应该走知识库,不该走退款。英文低置信度地判错,中文是高置信度地判错:你们的退款政策是什么? 被判成 refund_issue,概率 0.96。后面这条是 multilingual checkpoint 的真实缺陷。

调参记录也值得记一笔。第一版问题集 7 条通过,收紧措辞、加了触发词的第二版反而降到 6 条。说明瓶颈不在措辞,在模型对该类别的理解。而且第二版是盯着失败用例改的,本身就是过拟合,真实项目里必须留一份没看过的留出集。

5. 多语言路由的盲区

laya 内置一个 Router,按语种自动挑 checkpoint,号称支持 100 多种语言。同一段诉求换 18 种语言写,看它路由到哪个 checkpoint。

非拉丁字母和带变音符的语种全部路由正确,中文、日文、俄语、阿拉伯语、西班牙语、德语、法语这些,置信度在 0.79 到 0.998。

拉丁字母、没有变音符的语种全军覆没。荷兰语、印尼语、马来语、他加禄语、斯瓦希里语,五种都被送进了英文 checkpoint,斯瓦希里语那条还判错了答案。

原因在 laya/lang.py 的检测器。它只在能证明「不是英文」时才转走,证明靠两条:变音符号比例达到 0.02,或者非拉丁字母占比达到 0.2。两条都不满足才去查停用词表,而且要求命中至少两个词、且比英文的命中数还多两个。荷兰语那句自然句里,一个 nl 词表的词都没撞上,变音符号又是零,检测器就给不出语言。源码把「判不出语言 + 没有非英文字母」一律当英文,这是有意为之,为的是不把短英文误送进 multilingual。

解法是显式指定 checkpoint。CLI 加 --model multilingual,Python 传 model="multilingual",HTTP 在请求体里加 "model": "multilingual"。

但别无脑全局强制。英文文本下两个 checkpoint 各有胜负,五条用例里各错一条。只在流量确定是这几类拉丁语种时才强制。

有个可用的信号:英文 checkpoint 处理外语文本时,answer_confidence 明显偏低,在 0.53 到 0.80 之间;正确路由后是 0.96 到 0.99。所以置信度低于 0.85 时值得怀疑是不是路由错了。这只是信号不是保证,英文原文也可能给出低置信度,实测里有一条 0.49。

6. 放在链路的哪个位置

laya 适合当前置闸门,不适合当唯一的分类器。一次典型的接法:请求进来先过 laya,概率过阈值的直接路由到对应工具或队列,低置信度的转给 LLM 或走检索兜底。

这个闸门挡得住什么、挡不住什么要分清。低概率说明模型自己拿不准,转走是对的。但高概率不等于判对了。上面那条中文用例「你们的退款政策是什么?」,被判成 refund_issue 的概率是 0.96,照样是错的。阈值只能滤掉「拿不准」这一类,「很自信但判错」那一类滤不掉,只能靠选任务避开。

阈值按自己的数据定。官方 README 的说法是:A threshold is a policy you choose from measured accuracy at that coverage on your data, not a property of the model。

三档 checkpoint 按语种和领域分,第三个留给自己微调。微调是这个项目预设的用法,官方给了在 Kaggle 免费 2x T4 上跑完整流程的 notebook。

7. 参考

  • laya 仓库 · 官方 README,含 Jev 对比表、Benchmarks 与 Honest limits 两节;serve.py 声明实现 Jev 的接口
  • laya 文档站 · 部署、hooks、Docker 平台说明,Apple Silicon 只能跑 CPU 的出处
  • PyPI 上的 laya · 0.1.0 发布于 2026-09-18,到 0.3.20 共 28 个发行版
  • 模型权重 · Apache-2.0,三个 checkpoint 的架构与长文准确率表
  • TypeSafe 官方文档 · Jev 的 API 契约与 RLCD 的定义,用于对齐两边概念
  • AbdelStark、nibzard · Jev p50 延迟 236~276 毫秒的两份独立测量,均为第三方,非 TypeSafe 发布