项目 地址
代码仓库 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 转人工)所需要的东西。

工作原理

每个问题:

  1. 渲染证据 + 带字母的候选(A、B、C…)
  2. 向后端请求恰好一个 next token 的 logprobs(max_tokens: 1)
  3. 读出每个候选字母的 logprob
  4. 对候选做 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 一下或者直接跑一遍再给反馈。

(完)