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 |
| 许可 | 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 转人工)所需要的东西。
工作原理
每个问题:
- 渲染证据 + 带字母的候选(
A、B、C…) - 向后端请求恰好一个 next token 的 logprobs(
max_tokens: 1) - 读出每个候选字母的 logprob
- 对候选做 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 一下或者直接跑一遍再给反馈。
(完)