项目 地址
代码仓库 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
许可 Apache-2.0

前言

做 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 要额外要求,格式不稳 取决于模型
工具调用 ~18(固定 JSON 开销) 无 取决于模型
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),不发送任何引擎专属参数。换后端就是换一个 base_url:

服务 宿主机 base_url compose 内部 backend.model
llama.cpp http://127.0.0.1:8080 http://llama-server:8080 null(自动探测)
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
Ollama http://127.0.0.1:11434 http://ollama:11434 模型名

工作方式的完整说明见文档 工作方式。

效果对比

Qwen3.5-0.8B + llama.cpp,单线程,6 个 SRE 分诊案例 × 3 轮,两条路径都关思考:

指标(每案例 = 3 个决策) 网关(logprobs) 直连工具调用
延迟,prompt 缓存命中后 421 ms 4547 ms
延迟,冷缓存(首轮) 4742 ms 4258 ms
输出 token 3(固定) ~55
正确率(精确评分标准) 3/4 3/4

缓存命中后快约 11 倍,输出 token 少约 18 倍,正确率相同。生成一次工具调用 JSON 每个决策要解码约 18 个 token,而网关只解码 1 个。冷缓存那一行是诚实的补充:首次请求的 prefill 两边都要付,优势出现在之后的每一次请求上。

跑起来

从源码跑:

git clone https://github.com/jermeyhu/jev-gateway.git
cd jev-gateway
pip install -e ".[dev]"

# 编辑 config.yaml,把 backend.base_url 指向自己的推理服务
python -m app                              # 监听 0.0.0.0:8000
python scripts/smoke_test.py --url http://127.0.0.1:8000

装好之后也有控制台脚本 jev-gateway,等价于 python -m app。

Docker 两条命令:

docker compose up -d                                   # 仅网关
docker compose -f docker-compose.llamacpp.yml up -d    # 网关 + llama-server

JEV_GATEWAY_CONFIG 是网关唯一读取的环境变量,默认 ./config.yaml。配置全在一个文件里,未知键启动即报错,不用猜哪些字段生效:

server:
  host: 0.0.0.0
  port: 8000

backend:
  type: openai
  base_url: http://127.0.0.1:8080
  model: null
  top_logprobs: 128        # 读回多少个 next-token logprob(16-4096)
  max_concurrency: 32
  extra_body:
    chat_template_kwargs:
      enable_thinking: false

request:
  max_questions: 64
  total_timeout_seconds: 60.0
  prompt_layout: fused    # fused = 与参考网关字节兼容;split = 证据单独一条便于命中 prompt 缓存

multimodal:
  enabled: true
  max_images: 4
  max_image_bytes: 5242880
  allow_remote_urls: false

全部配置项见文档 配置参考。

接口

唯一决策端点是 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",
    "total_latency_ms": 214.7,
    "questions": {"is_healthy": {"latency_ms": 61.2, "truncated": false}}
  }
}

usage.output_tokens 就是问题数:每题一个解码 token。state 支持字符串、对象或数组;images 是对官方契约的扩展,接受 data URL、远程 URL 或裸 base64,按官方契约写的客户端会忽略这个字段,所以不影响兼容。

其他端点:

端点 作用
GET /healthz 存活探针,不访问后端
GET /readyz 就绪探针,探测后端,不可用返回 503
GET /v1/models OpenAI 形状的模型列表

错误码:

code 状态码 触发条件
INVALID_REQUEST 400 schema / 限额违规、非法图片、不支持的能力
BACKEND_PROTOCOL_ERROR 502 后端返回了不可用的内容
BACKEND_UNAVAILABLE 502 连接被拒 / 后端报错
BACKEND_NOT_READY 503 后端未加载或不可达
BACKEND_TIMEOUT 504 单个问题超过 backend.timeout_seconds
REQUEST_TIMEOUT 504 整次请求超过 request.total_timeout_seconds

所有问题并发执行(上限 backend.max_concurrency),任一问题失败则整次请求失败,不返回部分结果。请求的 schema 校验错误也落进同一个 error 结构,客户端只需要一个错误解析器。

完整字段与错误码见文档 接口参考。

适合哪些场景

场景 题型 说明
模型路由 choice 按问题复杂度在便宜模型和强模型之间分流
内容门禁 noul 概率低于阈值转人工,而不是二元放行
工单分诊 choice + score 一次请求同时定严重级别和紧急程度
标注质控 choice 把已标注样本喂回去看模型和标注者是否一致
视觉证据打分 choice + images 监控截图、界面渲染这类只有图才看得出的判断

踩过的坑

推理模型必须关掉思考。 Qwen3 / Qwen3.5、DeepSeek-R1、OpenAI o 系列会把思考内容作为第一个 token 输出,于是没有任何候选字母落进 top-N 窗口,每个问题都以 BACKEND_PROTOCOL_ERROR 失败。各后端的关闭开关:

后端 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: {enabled: false}
DeepSeek deepseek-reasoner 无开关,改用 deepseek-chat

判据要写成可判定的。 用模糊标签(”Total outage / Degraded / Minor”)时两条路径都接近随机。把每个候选改写成能从 state 直接验证的阈值(”error_rate ≥ 0.20,或整区宕机…”)后两条路径同时改善——瓶颈在措辞,不在网关。

阈值要自己校准。 概率只在所给候选间归一化,不是”答案正确率”。决策模型对校准敏感,激进量化可能保住 argmax 却毁掉置信度。门禁类用途优先 Q8_0 或 F16,Q4_K_M 当实验对待,并用 scripts/compare_backends.py 盯 Brier 距离而不是 argmax 一致率。

truncated: true 不是估计值。 说明某个候选字母落在 top_logprobs 窗口之外、被截断到下界。上调 backend.top_logprobs 并重新测量;服务端拒绝大窗口就减少候选数(单题上限 16 个,字母排到 P)。

概率是不是编的,跑脚本就知道。 scripts/probe_position_bias.py 打乱候选顺序:如果只改变胜出的字母、概率质量跟着证据走,分布是可信的;如果标签跟着位置翻转,这些数字就不该拿来设阈值。

更多问题见文档 限制与 FAQ。

项目状态

项 状态
测试 103 个 pytest,ruff 与 mypy 全绿
文档 MkDocs Material,中英双语,含快速开始 / 工作方式 / 接口 / 配置 / 后端 / 图片 / 脚本 / FAQ
镜像 ghcr.io/jermeyhu/jev-gateway,amd64 与 arm64 同一 tag,发布前会跑通容器冒烟
版本 0.1.0,Apache-2.0

参与

项目还在早期,Issues 和 PR 都欢迎:

  • 仓库:https://github.com/jermeyhu/jev-gateway
  • 文档:https://jermeyhu.github.io/jev-gateway/
  • 遇到 BACKEND_PROTOCOL_ERROR 这类问题,附上原始 curl 响应提 Issue,能省掉一轮来回

如果你也在把 Agent 里的分类判断从”生成任务”改造成”决策任务”,或者只是想看看 0.6B–4B 小模型在自己的业务上能判到什么程度,欢迎 Star 一下或者直接跑一遍再给反馈。

(完)