HTTP API
laya-serve 通過 TypeSafe Jev 的 /v1/systemone 線上協議暴露 Laya。一個針對 Jev 寫的客戶端
—— hs-jev、typesafe-sdk,或你自己的 —— 把 base URL 指向這臺伺服器就能繼續用:Laya 的
predict() 輸出本就 schema 相容,伺服器只加了 HTTP 這一層:一條決策路由、一個健康探測、一個
可選的 bearer 校驗和請求限額。
有一個面向 PHP 的客戶端,目標不是 Jev API 而是 laya-serve:
marcreichel/laya-php 是一個 Composer SDK(PHP 8.4+),
它把一類 enum 和 attribute 對映到問題上並返回一個例項,讀取 GET /health 做部署檢查,還附帶
一個測試替身,讓呼叫方無需執行伺服器就能做單元測試。
pip install "laya[serve]"
laya-serve # http://0.0.0.0:8000
同一個入口也能嵌進任何 ASGI 伺服器:laya.serve.create_app() 構建這個 FastAPI 應用,可選地
注入一個 Router(create_app(router)),而不是從環境裡構建一個。
配置
全部是環境變數,所以一個映象既能服務筆記本上的開發執行,也能服務一個 systemd 單元。
| 環境變數 | 含義 | 預設值 |
|---|---|---|
LAYA_HOST |
繫結地址 | 0.0.0.0 |
LAYA_PORT |
繫結埠 | 8000 |
LAYA_ROOT_PATH |
在反向代理之後服務時的公開 URL 字首 | 空 |
LAYA_DEVICE |
每個 checkpoint 用的 torch 裝置 | auto |
LAYA_PRELOAD |
啟動時就構建 checkpoint,而不是懶載入 | 1 |
LAYA_MODELS |
要預載入的逗號列表(english,multilingual,typed-decisions);留空 = 全部 |
全部 |
LAYA_THREADS |
限制 CPU 上 torch 的程序內執行緒數;保持在物理核心數以內 —— 超額訂閱邏輯核心會帶來很大的效能回退 | torch 預設 |
LAYA_AUTO_TASK |
自動路由到 typed-decisions checkpoint | 0 |
LAYA_IDLE_UNLOAD_SECONDS |
空閒這麼多秒後解除安裝常駐的 checkpoint;下一次請求會重新載入它的 checkpoint。設為零則停用解除安裝 | 0 |
LAYA_DEFAULT_MODEL |
沒有語言證據的 state 回退到的 checkpoint;像 ml 這樣的別名按核心解析它們的方式解析,無法解析的名字會在啟動時讓伺服器停止 |
english |
LAYA_API_KEY |
設定後要求 Authorization: Bearer <key> |
無 |
LAYA_LOG_LEVEL |
uvicorn 日誌級別 | info |
LAYA_MAX_CONCURRENT |
通過鑑權後一次接納的請求數;超出的拿到 503 |
16 |
LAYA_MAX_BATCH_TOKENS |
一次 /v1/systemone/batch 的前向傳播可以拼接(collate)的 token 數(states x 問題數 x 行寬);更大的批次會被拆成多次傳播,而不是被拒絕 |
131072 |
LAYA_JEV_STRICT |
提供嚴格的 Jev 線上契約:不傳送根級 routing,每條答案不帶 action / answer_confidence,noul 答案不帶 confidence,且 usage 縮減為 input_tokens + output_tokens。供那些按 Jev 契約校驗響應、不允許任何額外欄位的客戶端使用 |
0 |
對於一個釋出在 /laya 這類字首下的部署,設定 LAYA_ROOT_PATH=/laya。FastAPI 在生成 OpenAPI
和 Swagger UI URL 時會用它。把反向代理配置成在把請求轉發給 Laya 之前剝掉 /laya;應用內部的
路由仍然是 /health 和 /v1/systemone。
容器(包括 CUDA 和 ARM64 映象)見 Docker 快速開始。
對於突發的本地使用,設定 LAYA_IDLE_UNLOAD_SECONDS=300。推理和解除安裝跑在同一個 worker 上,空閒
視窗在一次單條或批次前向傳播結束時重新開始計時,失敗的請求也算。下一次預測要付一次冷載入的
代價。解除安裝會釋放模型引用和裝置快取,包括 Metal;程序分配器可能保留 RAM 頁,所以程序 RSS 不一定
會下降一個 checkpoint 那麼多。
端點
GET /health
存活探針永遠開放(無需鑑權),並且在推理期間保持響應,因為 CPU 密集的前向傳播跑在它自己的
worker 上,而不是事件迴圈上。設定了 LAYA_API_KEY 的部署上,存活之後的那些欄位並不開放:沒有
bearer 時,/health 只回 {"status": "ok"},別的什麼都沒有,因為其餘部分點名了常駐的
checkpoint、它們確切的 revision SHA、裝置狀態,以及每個 checkpoint 最近一次的回退原因(那會引用
主機硬體)。探針只需要那個 200,所以健康檢查不受影響,帶錯 bearer 仍然是 200 而不是 401。沒有
設定 LAYA_API_KEY 時,每個呼叫方都拿到這裡展示的完整載荷。
{"status": "ok", "loaded": ["english", "multilingual"], "revisions": {"english": "...", "multilingual": "..."},
"device": "cuda", "device_is_preference": false,
"checkpoint_devices": {"english": "cuda", "multilingual": "cuda"},
"cpu_fallbacks": {"english": {"count": 0, "last_reason": null}, "multilingual": {"count": 0, "last_reason": null}}}
這是某一臺伺服器的答覆,所以各塊彼此一致:revisions、checkpoint_devices 和 cpu_fallbacks
的每個鍵都是 loaded 裡的一個名字。tests/test_serve.py 逐欄位地把這個樣例和產生它的處理器
對齊。
啟用了空閒解除安裝時,通過鑑權的健康響應還會帶上 idle_unload_seconds(配置的視窗)和
idle_seconds(自上次推理請求或完成以來的時間)。健康探針不會重置那個時鐘。空閒解除安裝之後
loaded 列表為空是正常的。
status只要程序還能作答就是ok。它對 checkpoint 隻字未提。loaded列出常駐記憶體的 checkpoint。在一次請求構建出一個之前它是空的,LAYA_PRELOAD=0就是 讓程序一直處於這種狀態。revisions是每個常駐 checkpoint 載入時所來自的 artifact 版本,以和loaded相同的名字為鍵, 這樣一個部署能確認它實際在服務什麼。device是常駐 checkpoint 真正在其上計算的裝置,它不總是LAYA_DEVICE要求的那個:一個想要 GPU 卻拿不到的 checkpoint 會靜默回退到 CPU,並且仍然正確作答。沒有任何常駐 checkpoint 時, 它是配置的偏好裝置。device_is_preference恰好在沒有任何常駐 checkpoint 時為true,而在處理器能測量時立刻變為false。這就是「伺服器在報告它的配置」和「伺服器在報告它的工作跑在哪裡」的區別:一個悄悄 丟了 GPU 的伺服器會說false且device為cpu,而不是繼續回答cuda。checkpoint_devices給出每個 checkpoint 的測量值,以loaded裡的名字為鍵;device就是這些 值裡的第一個。cpu_fallbacks按常駐 checkpoint 統計耗盡了 GPU 視訊記憶體並在 CPU 上重試過一次的請求:count是 自程序啟動以來的次數,last_reason攜帶最近一次的錯誤文本。這種降級只作用於失敗的那次請求, 所以一個因為 GPU 從來就不可用而在 CPU 上構建的 checkpoint 不算回退,這裡計數為0—— 那會 體現在device裡。
POST /v1/systemone
一次請求帶一個 state 和它上面任意數量的問題:
curl -s localhost:8000/v1/systemone -H 'content-type: application/json' -d '{
"state": "I was charged twice this month, I want my money back",
"questions": {
"queue": {"type": "choice", "instructions": "Which team?",
"criteria": {"billing": "billing and refunds", "tech": "login and app issues",
"other": "everything else"}},
"urgency": {"type": "score", "instructions": "How urgent?",
"criteria": ["calm", "firm", "angry", "furious"]}
}
}'
| 欄位 | 必填 | 含義 |
|---|---|---|
state |
是 | 要據以決策的文本、郵件、工單或 JSON 文件;缺失或為 null 的 state 會得到 400 |
questions |
是 | 按問題 id 索引的物件;每個問題是 choice / score / noul,帶 instructions 和 criteria |
model |
否 | 指名一個 checkpoint;路徑或未釋出的 Hub id 是 422,其他任何值都被忽略(見下) |
task |
否 | 用工作流名強制指定一個 checkpoint,而不是讓路由決定;未知的名字會得到一個點名它的 422 |
lang |
否 | 一個語言程式碼(de、en-US),當它指名一種語言時跳過檢測;空白或無法識別的程式碼會落到檢測這一步 |
lang_guess |
否 | 來自客戶端自帶識別器的語言程式碼,在 lang 之後、檢測之前參考;任何非英文程式碼都路由到 multilingual checkpoint |
max_len |
否 | 這次請求的總 token 視窗,由 LAYA_MAX_TOKEN_BUDGET 設上限 |
head_max_len |
否 | 選項提示詞共享的 token 視窗,同樣受上限約束;什麼時候一個問題需要它,見放寬 token 預算 |
min_confidence |
否 | [0.0, 1.0] 範圍內的棄答閾值;answer_confidence 低於它的答案會帶 low_confidence 標記返回,而答案本身仍然保留 |
model、task、lang、lang_guess、max_len、head_max_len 和 min_confidence 是
Router.predict 接受、而 JSON body 可以寫明的參數;每一個只在請求發了它時才轉發,所以缺了某個
時,由部署自己的 Router(...) 設定說了算。predict 還接受的五個鉤子參數 —— hooks、
on_predict_start、on_predict_end、hooks_raise、hooks_timeout —— 會被以 422 拒絕,
而不是被丟棄:鉤子是一個在伺服器程序內部執行的可呼叫物件,而最後兩個說明部署安裝的鉤子如何
執行,所以呼叫方發來的任何值在這裡都沒有意義。同樣這五個會被一個帶 base_url 的 LangChain
節點(laya.integrations.langchain)在客戶端拒絕,所以一條鏈和一個裸 HTTP 客戶端現在得到同樣
的答覆。
接受 model 是為了讓 Jev 客戶端能繼續發它。公開的 Hugging Face id
(convaiinnovations/laya-multilingual、convaiinnovations/laya-typed-decisions)、checkpoint
名(english、multilingual、typed-decisions)及其別名會選定一個 checkpoint。
convaiinnovations/laya,以及任何其他不是路徑或 Hub repo id 的值 —— 包括像 jev-1 這樣的
Jev id —— 都表示「讓路由器來選」,響應裡的 routing 塊記錄選了什麼以及為什麼。一個看起來像
檔案系統路徑或未釋出 Hub id 的值(/path/to/checkpoint、org/repo、~/ckpt、.\ckpt)在
/v1/systemone 和 /v1/systemone/batch 上都會得到 422:這臺伺服器載入不了它,而用另一個
checkpoint 來作答會掩蓋這一點。detail 和核心丟擲的 unknown model 文本相同,再加上一句提醒:
省略 model 讓路由器來選。
響應
{
"model": "laya-rl-agent",
"answers": {
"queue": {"type": "choice", "choice": "billing",
"probabilities": {"billing": 0.9519, "tech": 0.0327, "other": 0.0154},
"confidence": 0.797, "answer_confidence": 0.9519,
"action": {"act_probability": 1.0}},
"urgency": {"type": "score", "score": 1.6994,
"legend": {"0": "calm", "1": "firm", "2": "angry", "3": "furious"},
"probabilities": {"0": 0.0249, "1": 0.4136, "2": 0.3985, "3": 0.1629},
"confidence": 0.1925, "answer_confidence": 0.4136,
"action": {"act_probability": 1.0}}
},
"usage": {"input_tokens": 83, "output_tokens": 0, "state_tokens": 12,
"state_tokens_dropped": 0, "truncated": false, "truncated_questions": []},
"routing": {"model": "english", "repo": "convaiinnovations/laya", "reason": "English Latin text",
"detection": {"script": "latin", "script_profile": {"latin": 1.0}, "language": "en",
"is_english": true, "language_undecided": false, "diacritic_rate": 0.0,
"non_latin_fraction": 0.0, "mixed_segment": null},
"workflow": null}
}
這個樣例是這臺伺服器給出的一條答案,逐字照錄:就是上面的請求,用 CPU 上快取的 english
checkpoint。answers 和 usage 是 Jev 客戶端要解碼的鍵;model 是決策頭那個固定的名字,而
實際作答的 checkpoint 在 routing 裡。
| 答案型別 | 鍵 |
|---|---|
choice |
choice(argmax 選項),每個選項的 probabilities |
score |
score(期望檔位索引,可能落在檔位之間),以 "0".. "k-1" 為鍵的 probabilities,把索引對映到檔位文本的 legend |
noul |
noul,yes 選項的機率 |
| 全部 | confidence、answer_confidence 和 action.act_probability |
| gate | abstention、abstention_threshold 和 low_confidence,由棄答門寫出 —— 見下 |
gate 這一行是棄答報告(#361),也是呼叫方唯一能看出它花錢啟用的門確實執行過的地方。設定了
min_confidence 的請求會拿到它;沒設定的請求三個鍵一個都拿不到。abstention 是三種狀態之一,
寫在一次受門控請求的每一條答案上:passed(它的置信度越過了閾值)、abstained(它落到
閾值以下,並且 low_confidence 恰好在這些答案上為 true),或者 unevaluated(答案沒有攜帶
可用的置信度,門無法裁決 —— 把它報成通過,和把它報成標記是同一種謊)。abstention_threshold
回顯那些狀態所對照的閾值,正是這一點讓一次帶逐類閾值的批次執行可以在事後重新拆分。
min_confidence 未設定時,三個鍵都不會出現在任何答案上:缺席就是報告,而不是第四種狀態,呼叫方
正是靠它區分一次未受門控的執行和一次乾淨通過的門。恰好為 0.0 的 min_confidence 是設定了
的,所以狀態會被報告,而沒有任何東西能落在那之下,於是每條答案都讀作 passed —— 回顯的 0.0
正是用來把它和在一個真實閾值上的通過區分開的東西。答案本身在每種狀態下都保留;門只做標記,
不丟棄。
usage 報告前向傳播是由什麼構建起來的。模型讀一個 state 的多少,是一個 token 預算,而不是字元
數,而預算會隨 max_len、head_max_len 以及每個問題自己的選項提示詞(#174)而變動,所以這些鍵
是那個事實唯一可見的地方:
usage 鍵 |
含義 |
|---|---|
input_tokens |
state 各行非 padding 的 token —— 每個問題一行,所以它隨問題數增長,而不是一個上下文長度 |
output_tokens |
永遠是 0 —— 決策頭一次傳播就作答,它不生成任何東西 |
state_tokens |
整個序列化 state 需要的 token 數 |
state_tokens_dropped |
其中至少一個問題沒拿到的 token 數:對所有問題取最壞情況,因為每個問題留給 state 的空間不同 |
truncated |
當那個最壞情況丟掉了任何東西時為 true |
truncated_questions |
自己的視窗被削減的那些問題的 id,沒有時為 [] |
options |
僅當某個問題的選項不再各自擁有一個 token 跨度時出現:以問題 id 為鍵,帶 total(那個問題定義的選項數)、distinct(到達序列的跨度數)和 tokens_per_option |
一個被截斷的答案仍然是一條答案 —— 決策頭根據它拿到的證據來裁決 —— 但一個按字元數來給 state 定規模的呼叫方,在響應的其他任何地方都看不到這次削減。
routing 記錄是哪個 checkpoint 作答以及為什麼:
routing 鍵 |
含義 |
|---|---|
model |
作答的 checkpoint:english、multilingual 或 typed-decisions |
repo |
它的公開 Hugging Face id |
reason |
這個選擇的那句話,點名它據以行動的證據 |
detection |
對 state 跑 laya.lang.analyse() 的結果 —— script、script_profile、language、is_english、language_undecided、diacritic_rate、non_latin_fraction、mixed_segment —— 或者當路由在讀文本之前就已經決定時為 null |
workflow |
問題 id 匹配到的 typed-decisions 工作流,或者 null |
在每一條不讀 state 就決定的路徑上,detection 都是 null:被 model 或 task 強制的那條、
由 lang 或 lang_guess 作答的那條,或從問題 id 匹配到 typed-decisions 工作流的那條。
lang_guess 不留下自己的鍵 —— 它據以行動的提示在 reason 裡點名。model 和 task 這兩條分支
也把 workflow 報成 null,因為它們在被讀問題 id 之前就作答了。
置信度:兩個數字,不可互換
answer_confidence是落在所報告答案上的機率質量(max(p))。它正是溫度縮放所擬合的量,也是 本倉庫 ECE 數字所計算的量,所以它承載著基準與已知限制頁所依賴的門控性質 —— 但只對你的流量上驗證過溫度擬合的 checkpoint 成立。confidence每種型別含義不同:在choice和score上是歸一化熵1 - H(p)/log(k),在noul上是max(p_yes, p_no)(此時它等於answer_confidence)。
絕不要把這兩個數字拿來和同一個閾值比較。從 Jev 遷移時還要注意這個差別:TypeSafe 把置信度定義為
(n*p_max - 1)/(n - 1),所以從 Jev 部署帶過來的閾值在 Laya 的熵值上門的開關方式不一樣。
嚴格的 Jev 契約:LAYA_JEV_STRICT
上面的載荷是完整的 Laya 載荷。一個客戶端可能要求它遵守的 Jev 契約定義的東西更少:三個頂層欄位
(model、answers、usage)、每條答案上契約規定的鍵而別無其他,以及一個只含兩個 token 計數的
usage。一個按那個契約、不允許任何額外欄位來校驗響應的客戶端 —— OpenClaw 的 TypeSafe provider
外掛就是其一 —— 會拒絕這個完整載荷,所以 LAYA_JEV_STRICT=1 會在作答之前把響應投影到契約上,
在 /v1/systemone 和 /v1/systemone/batch 上都是如此:
- 根級只保留
model、answers和usage;routing不傳送; - 一條
choice答案保留choice、probabilities和confidence; - 一條
score答案保留score、probabilities、confidence和legend; - 一條
noul答案只保留noul; usage保留input_tokens和output_tokens;截斷事實和摺疊選項的天花板不傳送。
這個投影只保留契約規定的鍵,且不重算任何東西:每個值都是結果本來就攜帶的那個,所以嚴格客戶端
讀到的機率和分數,與完整載荷報告的一模一樣。預設仍是完整載荷,而一個開啟這個標誌的部署會失去
usage 提供的截斷可見性 —— 一個被削減的 state 那時只在日誌裡可見,不在響應裡。在嚴格契約下,
score 的 criteria 應該保持為純字串:嚴格客戶端會把返回的 legend 和它發來的 criteria 作
比較,而 Laya 用 Python 的 JSON 渲染一個結構化 criterion,一個把自己的 criteria 字串化的
JavaScript 呼叫方可能無法逐位元組匹配。
成功的響應還會帶上 Server-Timing: inference;dur=<ms> 和 X-Inference-Time-Ms。
限額
請求防護欄在 tokenization 之前檢查,所以一次超大的請求除了它讀過的位元組外不花伺服器任何成本。它們
每一個都是 413;detail 說明撞上了哪條限額。
| 限額 | 值 |
|---|---|
| 請求體 | 2 MiB,在流式傳輸時強制 —— 分塊或低報的 Content-Length 繞不過去 |
state |
模型所看到的文本 50,000 個字元 —— 字串 state 就是字串本身,物件或陣列則是 json.dumps(state, ensure_ascii=False) |
| 每請求問題數 | 64 |
每批請求的 states 數 |
64 |
每個 choice 問題的選項數 |
100 |
每個 score 問題的檔位數 |
32 |
| 所有問題的選項總數 | 512 |
| 同時接納的請求數 | LAYA_MAX_CONCURRENT(16) |
/v1/systemone/batch 的邊界方式不同,而且不是靠拒絕。它對每個 state 按每個問題 tokenize 一次,
並把每一行拼接成一個張量,所以各欄位的上限是相乘的:64 個 state、每個 64 個問題就是 4096 行,這是
本頁其他所有限額都允許的。一行花費的是它的寬度,而 max_len 本身就是一個請求欄位,所以一個批次
的成本是 states x questions x width。
與其拒絕一個大批次,這個端點會把它拆分:當那個乘積超過 LAYA_MAX_BATCH_TOKENS
(預設 131,072)時,它會選一個 batch_size,讓每次前向傳播都留在預算之內,然後
Router.predict_batch 多次傳播地跑完這批。每個 state 仍然會被作答,響應不變。行本來就裝得下的
請求根本不會被傳入 batch_size,所以它的行為和以前完全一樣 —— 這很重要,因為批次形狀會移動
浮點結果。呼叫方發來的 batch_size 永遠優先:是它要求了那個形狀。
在預設值下,一次傳播跑 256 行 —— 64 個 state、每個 4 個問題,或者 8 個 state、每個 32 個問題。
更大的批次會被拆分,而提高 max_len 會讓每次傳播更窄,而不是花費 16 倍的工作量。這沒有約束到的,
是一次請求佔用伺服器多長時間;那由 LAYA_MAX_CONCURRENT 和單推理 worker 決定,而且對一次跑
50,000 字元 state 的 /v1/systemone 請求本來就已經如此。
選項上限只是 HTTP 層的放大防護;模型本身要把選項 token 放進一個 head_max_len=192 的視窗,
所以一個在 HTTP 上限內的問題,當選項文本合起來超過那個預算時,仍然可能被拒絕為一個 422。
評估 harness 在沒有 HTTP 層的情況下程序內跑同樣的請求。
錯誤
| 狀態碼 | 何時 | body 的 detail |
|---|---|---|
400 |
body 不是合法 JSON、不是物件、沒有 questions、state 缺失或為 null、questions 不是物件,或者 body 裡任何位置的一個字串含有不成對的 \udXXX 代理項轉義 |
哪裡不對 |
401 |
設定了 LAYA_API_KEY 而 bearer token 缺失或錯誤 |
invalid or missing bearer token |
413 |
上面任何一條限額 | 哪條限額、超了多少 |
422 |
問題的 JSON 合法但對 Laya 無效(未知型別、選項超過 head 預算),或者某個請求控制項(lang、min_confidence、某個鉤子參數)不是這個端點接受的形式 |
點名那個問題或欄位,以及要修什麼 |
500 |
推理因其他任何原因失敗 | inference failed —— 永遠是這串字元,所以路徑、權重和記憶體狀態永遠不會洩漏;原因在伺服器日誌裡 |
503 |
LAYA_MAX_CONCURRENT 個請求已經在途 |
server busy, try again later |
那個不成對代理項的 400 是看起來不太尋常的一個。沒有配對的 \udXXX 是合法 JSON,但它所指的
那個字元無法編碼成 UTF-8,所以 tokenizer 會在它上面拋 TypeError —— 呼叫方自己的字串變成了
一次伺服器故障,每個請求一份 traceback。因此兩條決策路由都會遍歷解析後的 body 尋找落單的代理項,
並在它到達推理之前拒絕它。這次遍歷在大小檢查之後執行,所以超大的 body 仍然會先被拒絕,而字元和
問題限額約束了它能觸及的範圍。一個成對的代理項在解析器處理完時就是一個普通的增補平面字元,
所以 state 裡的一個 emoji 不受影響。
超載的負載是被拒絕,不是排隊:客戶端在流式傳輸慢 body 時佔著接納槽位,也餓不死 /health,而
一次重試可以拿走被拒客戶端留下的槽位。
併發模型
推理是一次同步的 torch 呼叫,在 CPU 上要花幾百毫秒到幾秒,所以它絕不跑在事件迴圈上:請求交給一個
單 worker 的執行器,也就是一次一個前向傳播 —— 這正是單裝置上單個 checkpoint 想要的形狀。接納
(LAYA_MAX_CONCURRENT 訊號量)在讀任何 body 位元組之前檢查,並一直持有到推理結束;推理閘門只在
body 完整之後才加入,所以一個慢客戶端佔著接納槽位,卻從不佔推理槽位。
(還)不在這裡的東西
這臺伺服器有意只講一種協議。沒有 OpenAI 相容端點;改成在一次請求裡跑多個問題,因為每個問題集
共享一次前向傳播。另一條路由是 POST /v1/systemone/batch,它在一個 states 陣列上回答一組
questions。本頁還沒有它的小節 —— 它的請求形狀在 README 的自託管一節裡 —— 而上面每一條檢查都
同樣適用於它,正如適用於 POST /v1/systemone:同樣的形狀 400,同樣的不成對代理項拒絕,同樣的
鑑權、接納、大小限額、body 控制校驗和 500 對映。laya CLI 和 MCP 伺服器覆蓋本地使用 —— 見
README。