jev-gateway:自托管的 Jev / System One 兼容决策网关
| 项目 | 地址 |
|---|---|
| 代码仓库 | https://github.com/jermeyhu/jev-gateway |
| 官方文档 | https://jermeyhu.github.io/jev-gateway/ |
| 容器镜像 | ghcr.io/jermeyhu/jev-gateway:latest(amd64 / arm64) |
| Issue 反馈 | https://github.com/jermeyhu/jev-gateway/issues |
| 技术栈 | Python 3.11+ / FastAPI / httpx / pydantic / uvicorn |
| 许可 | Apache-2.0 |
本文对应 v0.2.0。配置方式在 v0.2.0 改过一次:配置文件
config.yaml已删除,全部改成JEV_前缀的环境变量,容器里不再挂任何配置文件。
前言
做 Agent 的时候有一类请求不是”生成答案”,而是”在几个候选里挑一个”:路由到哪个模型、这条工单算不算严重事故、这条回复要不要过人工审核。这类决策占了 Agent 里绝大部分的调用量,却经常被当成生成任务交给大模型,成本和延迟都浪费在解码上。
Jev(TypeSafe 的 System One)把这套决策抽象成了一个端点:POST /v1/systemone,一次调用同时完成多个分类,返回带概率的分布而不是一段文本。代价是它是闭源托管服务,而且官方契约只收文本——Pydantic AI 客户端遇到文件 part 直接抛 UserError: Files are not supported by this model,截图、监控面板这类证据根本没法参与打分。
jev-gateway 是我写的一个自托管替代品:对外暴露同样的 /v1/systemone,决策跑在自己的推理服务上,不依赖闭源 API,同一契约上扩展了本地图片输入。后端侧只说一种协议(OpenAI chat-completions),所以 llama.cpp、vLLM、SGLang、Ollama、LM Studio、TGI、托管 API 都能直接接。
五分钟跑起来:
docker run -d --name jev-gateway -p 8000:8000 ghcr.io/jermeyhu/jev-gateway:latest
curl http://127.0.0.1:8000/healthz
# {"status":"ok","version":"0.1.0"}
为什么需要一个决策网关
生成式调用要为”选 A 还是 B”解码几十个 token,决策网关只解码一个。
| 做法 | 每次决策的输出 token | 能否拿到概率分布 | 能否带图片 |
|---|---|---|---|
| 直接问大模型 | 20–200 | 要额外要求,格式不稳 | 取决于模型 |
| 工具调用 | 42–48(实测) | 无 | 取决于模型 |
| jev-gateway | 1 | 天然带 | 支持(images 字段) |
一次请求里可以并列多个问题,答案带完整概率分布而不只是标签——这正是做阈值门禁(比如 p < 0.7 转人工)所需要的东西。
工作原理
每个问题:
- 渲染证据 + 带字母的候选(
A、B、C…) - 向后端请求恰好一个 next token 的 logprobs(
max_tokens: 1) - 读出每个候选字母的 logprob
- 对候选做 softmax 得到概率分布
不生成任何文本,一次决策的成本就是 prompt 上的一次前向传播。这也是 0.6B–4B 小模型能当路由、门禁和分诊分类器的原因:全部开销就是这一次前向。
网关对后端只发 OpenAI chat-completions 请求(POST /v1/chat/completions 带 logprobs),不发送任何引擎专属参数。换后端就是换一个 JEV_BACKEND_BASE_URL:
| 服务 | 宿主机 JEV_BACKEND_BASE_URL |
compose 内部 | JEV_BACKEND_MODEL |
|---|---|---|---|
| llama.cpp | http://127.0.0.1:8080 |
http://llama-server:8080 |
留空(自动探测) |
| vLLM | http://127.0.0.1:8001 |
http://vllm:8000 |
对应 --served-model-name |
| SGLang | http://127.0.0.1:8002 |
http://sglang:30000 |
对应 --served-model-name |
JEV_BACKEND_MODEL 留空时,网关会问服务的 /v1/models 并取它报告的第一个模型;会报多个模型的服务(vLLM、开了多个 served model 的 SGLang)必须显式固定。容器里的 base_url 不能是 127.0.0.1——那指向网关容器自己,要访问宿主机上的服务得用 http://host.docker.internal:8080。Ollama、LM Studio、TGI 只要肯返回 logprobs.content[0].top_logprobs 就同样能接,仓库里只随附了 llama.cpp 那套 compose 模板。
工作方式的完整说明见文档 工作方式。
效果对比
同一组 10 条中文业务用例(noul × 4、choice × 4、score × 2),每条跑 3 轮,两条路径都关思考、严格单线程(后端并发有限,串行才测得到真实延迟),在两个后端上各跑一遍:每组 60 次调用,零失败。替代方案是让模型直接调工具再读参数,它必须生成这段调用,实测每个决策要解码 42–48 个输出 token;网关只解码 1 个。
27B:远端,跨境(openrouter)
| 指标 | 网关(logprobs) | 直连工具调用 |
|---|---|---|
| 准确率 | 30/30 100% | 27/30 90% |
| 延迟 mean | 1027 ms | 2005 ms |
| 延迟 median | 977 ms | 1820 ms |
| 输出 token / 答案 | 1.0 | 41.9 |
| primitive | 网关 | 直连 |
|---|---|---|
noul |
12/12 | 12/12 |
choice |
12/12 | 12/12 |
score |
6/6 | 3/6 |
快 1.95 倍、多对 3 题,差的全在 score。但 1027 ms 这个绝对值不能直接读,原因见下。
0.8B:本地 llama.cpp(有缓存)
| 指标 | 网关 | 直连 |
|---|---|---|
| 准确率 | 18/30 60% | 21/30 70% |
| 延迟 mean | 67 ms | 599 ms |
| 延迟 median | 47 ms | 528 ms |
| 输出 token | 1.0 | 47.5 |
| primitive | 网关 | 直连 |
|---|---|---|
noul |
12/12 | 12/12 |
choice |
6/12 | 6/12 |
score |
0/6 | 3/6 |
快 8.9 倍、输出 token 少 47.5 倍。
怎么读这两组数字
结论两边一致:网关更快,27B 上还更准。区别在数字本身可不可解读。
27B 那 ~1 秒是固定开销,两条路径都要付。 每次调用都经代理出境。实测把输入从 13 token 放大到 1283 token、输出从 1 token 放大到 10 token,延迟都没有上升趋势;同一请求连发 9 次是 1022 1653 1024 1025 1025 1232 2934 2079 1050 ms——一个约 1 秒的硬地板,上面叠着上游排队造成的随机尖峰(最长一次 5227 ms)。13 token 的空请求和跑完整题目的耗时基本一样,cached_tokens 恒为 0。
因为两条路径付的是同一笔开销,它在作差时抵消掉了:中间差的 ~978 ms 是真实省下的解码。它毁掉的是另外两件事——绝对值不可解读(1027 ms 里绝大部分不是决策本身,推不出吞吐和容量),比值被摊薄(真实收益是省下的 ~978 ms,除以含地板的总时延就只显出不到 2 倍)。
0.8B 那组是干净的测量。 67 ms 里没有任何网络底噪,8.9 倍就是协议效率的诚实读数;远端那个 1.95 倍是同一笔收益被 1 秒地板摊薄后的样子。
0.8B 的 score 全错是模型能力上限,不是协议缺陷。 排列实验证实:把三档风险标签轮换位置重跑,概率质量始终跟着内容走,但比较结果永远偏向最保守的那一档——0.8B 根本没法区分档位。同一题在 27B 上是 6/6。0.6B–4B 适合做二值门禁(noul),多档分级(score)要更大的模型。
最后一条:远端部署要优化延迟,方向不是压 prompt(实测压了没用),而是减少往返次数或换更近的接入点。
跑起来
从源码跑:
git clone https://github.com/jermeyhu/jev-gateway.git
cd jev-gateway
pip install -e ".[dev]"
JEV_BACKEND_BASE_URL=http://127.0.0.1:8080 python -m app # 监听 0.0.0.0:8000
curl -s http://127.0.0.1:8000/readyz
装好之后也有等价的控制台脚本 jev-gateway。
配置全是 JEV_ 前缀的环境变量,没有配置文件:不存在的变量直接用默认值,空环境下也能启动;值不合法则在启动时失败并点名是哪个变量。集中管理用仓库里的模板(.env 本身在 .gitignore 里):
cp .env.example .env
set -a; . ./.env; set +a
python -m app
常用变量:
| 变量 | 默认值 | 说明 |
|---|---|---|
JEV_BACKEND_BASE_URL |
http://127.0.0.1:8080 |
唯一通常需要改的:OpenAI 兼容服务地址,末尾斜杠会被去掉 |
JEV_BACKEND_MODEL |
空(自动探测) | 留空则从 /v1/models 取第一个模型;多模型服务要显式填 |
JEV_BACKEND_API_KEY |
空 | 仅托管 API 需要 |
JEV_BACKEND_TOP_LOGPROBS |
128 |
next-token 窗口,16–4096;服务端通常有更低上限,超限不报错(见踩坑) |
JEV_BACKEND_EXTRA_BODY |
{} |
JSON 对象,「关思考」开关写这里;不认识的 key 会被静默忽略 |
JEV_BACKEND_EXTRA_HEADERS |
{} |
JSON 对象,云 API key、租户标识 |
JEV_BACKEND_SUPPORTS_IMAGES |
auto |
false 可让带图请求在打到后端前就快速失败 |
JEV_BACKEND_MAX_CONCURRENCY |
32 |
单个网关实例的并发问题数上限 |
JEV_REQUEST_MAX_QUESTIONS |
64 |
单次请求 1–64 个问题 |
JEV_REQUEST_TOTAL_TIMEOUT_SECONDS |
60.0 |
整次请求的总预算 |
JEV_REQUEST_PROMPT_LAYOUT |
fused |
fused = 与参考网关字节兼容;split = 证据单独一条,便于命中 prompt 缓存 |
JEV_MULTIMODAL_* |
见下 | ENABLED=true / MAX_IMAGES=4 / MAX_IMAGE_BYTES=5MiB / ALLOW_REMOTE_URLS=false |
JEV_SERVER_HOST / JEV_SERVER_PORT |
0.0.0.0 / 8000 |
监听地址 |
空值等于未设置,所以 JEV_BACKEND_MODEL= 和不写这个变量效果一样。extra_body 永远不会覆盖 messages、max_tokens、logprobs、top_logprobs——契约字段总是胜出,写错也破坏不了决策路径。
Docker 两条命令:
docker compose up -d # 仅网关
docker compose -f docker-compose.llamacpp.yml up -d # 网关 + llama-server
两个模板都从源码 build、通过 environment: 传配置,不挂载任何文件。改后端地址就改 environment: 里的 JEV_BACKEND_BASE_URL;改模型、上下文长度或端口直接编辑 command 段。启动前把 .gguf 放进 ./models。
全部配置项见文档 配置参考。
接口
唯一决策端点是 POST /v1/systemone。questions 是”题名 → 问题”的字典,每个问题有一种题型:
| 题型 | criteria | 返回 |
|---|---|---|
noul |
可选,两键(默认 Yes / No) |
{"type": "noul", "noul": p} |
choice |
2–16 个候选的字典 | argmax 候选 + 全候选上的完整概率分布 |
score |
2–10 个有序档位的数组 | 档位下标的概率加权均值(可插值) |
请求:
curl -X POST http://127.0.0.1:8000/v1/systemone \
-H 'content-type: application/json' \
-d '{
"state": {"error_rate": 0.42, "p99_latency_ms": 3100, "recent_deploy": true},
"questions": {
"is_healthy": {"type": "noul", "instructions": "Is the service healthy?"},
"severity": {
"type": "choice",
"instructions": "Pick the incident severity.",
"criteria": {
"sev1": "error_rate is 0.20 or higher, OR a whole region is down",
"sev2": "error_rate is 0.05 or higher but below 0.20",
"sev3": "error_rate is below 0.05 AND users are not visibly impacted"
}
},
"urgency": {
"type": "score",
"instructions": "How urgent is the response?",
"criteria": ["can wait", "today", "right now"]
}
}
}'
响应:
{
"model": "Qwen3-4B-Instruct",
"answers": {
"is_healthy": {"type": "noul", "noul": 0.0314},
"severity": {
"type": "choice",
"choice": "sev2",
"probabilities": {"sev1": 0.024, "sev2": 0.921, "sev3": 0.055}
},
"urgency": {
"type": "score",
"score": 1.86,
"legend": {"0": "can wait", "1": "today", "2": "right now"},
"probabilities": {"0": 0.041, "1": 0.058, "2": 0.901}
}
},
"usage": {"input_tokens": 1421, "output_tokens": 3},
"diagnostics": {
"backend": "openai",
"model": "Qwen3-4B-Instruct",
"total_latency_ms": 214.7,
"questions": {"is_healthy": {"latency_ms": 61.2, "truncated": false, "backend": "openai"}}
}
}
usage.output_tokens 就是问题数:每题一个解码 token。state 支持字符串、对象或数组;请求里的 model 只是提示,响应回报的是后端自己报的模型名。images 是对官方契约的扩展,接受 data URL、远程 URL(默认关闭)或裸 base64,按官方契约写的客户端会忽略这个字段,所以不影响兼容。任何位置出现未知字段都会被拒绝。
其他端点:
| 端点 | 作用 |
|---|---|
GET /healthz |
存活探针,不访问后端,可直接用作容器健康检查 |
GET /readyz |
就绪探针,探测后端,不可用返回 503 |
GET /v1/models |
OpenAI 形状的模型列表 |
每个响应都带 x-request-id 头,日志用的是同一个 id。
错误码:
| code | 状态码 | 触发条件 |
|---|---|---|
INVALID_REQUEST |
400 | schema / 限额违规、非法图片 |
BACKEND_CAPABILITY_UNSUPPORTED |
400 | 模型或后端无法接收图片 |
BACKEND_PROTOCOL_ERROR |
502 | 后端返回了不可用的内容 |
BACKEND_UNAVAILABLE |
502 | 连接被拒 / 后端报错 |
BACKEND_NOT_READY |
503 | 后端未加载或不可达 |
BACKEND_TIMEOUT |
504 | 单个问题超过 JEV_BACKEND_TIMEOUT_SECONDS |
REQUEST_TIMEOUT |
504 | 整次请求超过 JEV_REQUEST_TOTAL_TIMEOUT_SECONDS |
所有问题并发执行(上限 JEV_BACKEND_MAX_CONCURRENCY),任一问题失败则整次请求失败,报出的是按问题顺序的第一个错误,不返回部分结果。请求的 schema 校验错误也落进同一个 error 结构,客户端只需要一个错误解析器。
完整字段与错误码见文档 接口参考。
适合哪些场景
| 场景 | 题型 | 说明 |
|---|---|---|
| 模型路由 | choice |
按问题复杂度在便宜模型和强模型之间分流 |
| 内容门禁 | noul |
概率低于阈值转人工,而不是二元放行 |
| 工单分诊 | choice + score |
一次请求同时定严重级别和紧急程度 |
| 标注质控 | choice |
把已标注样本喂回去看模型和标注者是否一致 |
| 视觉证据打分 | choice + images |
监控截图、界面渲染这类只有图才看得出的判断 |
模型规模按题型分:noul 二值门禁 0.6B–4B 够用,score 多档分级要更大的模型(数字见上面的效果对比)。
踩过的坑
推理模型必须关掉思考。 Qwen3 / Qwen3.5、DeepSeek-R1、OpenAI o 系列会把思考内容作为第一个 token 输出,于是没有任何候选字母落进 top-N 窗口,每个问题都以 BACKEND_PROTOCOL_ERROR 失败。各后端的关闭开关写进 JEV_BACKEND_EXTRA_BODY:
| 后端 | JEV_BACKEND_EXTRA_BODY |
|---|---|
| llama.cpp / vLLM / SGLang(Qwen3、Qwen3.5、Qwen3-VL、Gemma 3) | {"chat_template_kwargs": {"enable_thinking": false}} |
| OpenAI o 系列 / GPT-5 | {"reasoning_effort": "none"} |
| OpenRouter(任意推理模型) | {"reasoning": {"effort": "none"}} |
DeepSeek deepseek-reasoner |
无开关,改用 deepseek-chat |
OpenRouter 上有个坑:reasoning: {enabled: false} 会被接受、不报错,但完全没有作用——配置看起来完全正确,思考照旧。实测只有 effort: "none" 能真正关掉。不确定后端认哪种写法就把两种都发进去,不认识的 key 会被静默忽略。
top_logprobs 超过服务端上限会静默失败。 这是换后端时最难查的一种故障:HTTP 200,后端日志里什么错都没有,只有 logprobs 是 null,网关报 BACKEND_PROTOCOL_ERROR: backend response contained no logprobs object。OpenAI 规范把上限定在 20,而网关默认 128。在 openrouter/qwen/qwen3.8-27b 上实测:
top_logprobs |
5 | 20 | 24 | 32 | 64 | 128 |
|---|---|---|---|---|---|---|
logprobs |
✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
同一台服务器上的另一个模型上限只有 5。手写 curl 探活习惯用 top_logprobs: 20,它完全正常,而网关用默认的 128,于是每个请求都挂——症状一样,很容易得出「后端没问题」的错误结论。换新后端时一定要用网关的默认窗口去测,再用 20 / 24 / 32 / 64 / 128 试出上限,最后把能用的最大值填进 JEV_BACKEND_TOP_LOGPROBS。
判据要写成可判定的。 用模糊标签(”Total outage / Degraded / Minor”)时两条路径都接近随机,直连甚至把一个全绿的平凡案例判成 sev1。把每个候选改写成能从 state 直接验证的阈值(”error_rate ≥ 0.20,或整区宕机…”)后两条路径同时改善——瓶颈在措辞,不在网关。
阈值要自己校准。 概率只在所给候选间归一化,不是”答案正确率”。决策模型对校准敏感,激进量化可能保住 argmax 却毁掉置信度。门禁类用途优先 Q8_0 或 F16,Q4_K_M 当实验对待。换个服务、换个量化之后把同一个网关指向不同 JEV_BACKEND_BASE_URL 各跑一轮,对比三件事:argmax 一致性、mean|dp|(概率向量整体移动多远)、Brier 距离——盯的是 Brier,argmax 一致但 Brier 距离大,意味着换后端已经悄悄改坏了阈值。
truncated: true 不是估计值。 说明某个候选字母落在 top_logprobs 窗口之外、被截断到下界。上调 JEV_BACKEND_TOP_LOGPROBS 并重新测量;单题上限 16 个候选,字母排到 P,超了只能减候选。
概率是不是编的,跑对照就知道。 准备两份评分标准,一份精确、一份模糊,再把候选顺序打乱各跑一轮:如果只有胜出的字母变了、概率质量跟着证据走,分布是可信的;如果标签跟着位置翻转,这些数字就不该拿来设阈值。
远程图片默认关闭。 JEV_MULTIMODAL_ALLOW_REMOTE_URLS 默认 false:打开后网关会去取调用方给出的任意 URL,调用方可以让它访问 http://169.254.169.254/ 或内网里任何东西——这是服务端请求伪造面,只有两端都归你所有时才开。
更多问题见文档 限制与 FAQ。
项目状态
| 项 | 状态 |
|---|---|
| 测试 | 96 个测试函数,ruff 与 mypy 全绿;CI 跑 ubuntu/windows × Python 3.11/3.12/3.13,文档 mkdocs build --strict |
| 基准 | scripts/run_test_cases.py 跑用例(scripts/jev_test_cases.json),scripts/summarize_bench_cases.py 从原始 JSON 重算上面两张表 |
| 文档 | MkDocs Material,中英双语,含快速开始 / 工作方式 / 接口 / 配置 / 后端 / 图片 / 限制与 FAQ / 开发 |
| 镜像 | ghcr.io/jermeyhu/jev-gateway,amd64 与 arm64 同一 tag,发布前会把镜像拉回来跑一遍容器冒烟 |
| 版本 | v0.2.0,Apache-2.0 |
请求/响应契约与 logprob 分类的思路参考了 David-Lolly/Jev-Compatible。
参与
项目还在早期,Issues 和 PR 都欢迎:
- 仓库:https://github.com/jermeyhu/jev-gateway
- 文档:https://jermeyhu.github.io/jev-gateway/
- 遇到
BACKEND_PROTOCOL_ERROR这类问题,附上原始curl响应提 Issue,能省掉一轮来回
如果你也在把 Agent 里的分类判断从”生成任务”改造成”决策任务”,或者只是想看看 0.6B–4B 小模型在自己的业务上能判到什么程度,欢迎 Star 一下或者直接跑一遍再给反馈。
(完)