文件導航

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。