一句话:一个 OpenAI 兼容的多上游聚合网关,把「传话层工程降损」(P2 调用前预检 / P3 失败归因 / P8 损耗量化基线) 从第一天就建在核心链路里,而不是事后插进去。
它卖的不是 token,是"错误对 Agent 可执行"。 成功响应逐字节透传,一点不碰。
| 项 | 值 |
|---|---|
| 运行时 | Node.js ≥ 22.5(用到 node:sqlite) |
| 语言 | TypeScript 5.6(strict),编译到 ESM |
| 运行时依赖 | 0 个(仅 node:http / node:crypto / node:sqlite 等内置模块) |
| 开发依赖 | typescript、@types/node、tsx |
| 源码规模 | 42 个文件 / 19,966 行(不含测试) |
| 测试规模 | node --test 实测 269 项通过 / 0 失败 |
| 存储 | SQLite(WAL,控制面) / 内存(可切换) |
| 核心模块 | loss/ 降损层 2,073 行 · gateway/ 网关 3,383 行 · upstream/ 上游池 · store/ 度量库 |
cd api-relay
bash demo.sh # 或者: npm run demo它会在这一个进程里同时起好 mock 上游 + 网关 + 看板,自动避开被占用的端口, 启动后立刻跑 5 项自检并打印结果,然后告诉你下一步点哪里。
不需要任何密钥、不需要配置文件、不需要 npm install。 看到这样的输出就成功了:
api-relay 体验模式已启动(mock 上游 + 网关 + 看板,全部在这个进程里)
① 打开看板 http://127.0.0.1:8787/admin
...
自检结果
✓ 健康检查 HTTP 200
✓ 正常请求(Pro 层) HTTP 200
✓ P3 失败归因(注入 429) 归因卡片 + 可执行动作 ✓
✓ P2 预检拦截(畸形 tool_calls) 拦截于上游之前 ✓
✓ 免费层纯管道(无归因) 错误体零附加 ✓
正在运行,按 Ctrl+C 退出…
然后照它打印的提示做就行:打开看板看疗效,把 model 换成 mock-429 / mock-balance /
mock-filter 看"错误被翻译成可执行动作"长什么样,换 key 看三层的行为差异。
体验模式监听
127.0.0.1且管理接口免令牌,仅供本机体验,不要用于生产。 生产部署见docs/DEPLOYMENT.md。
cd api-relay
node dist/cli.js vendors # 看有哪些内置厂商模板
node dist/cli.js starter --vendor deepseek # 生成 starter.yaml(单厂商单模型)
export DEEPSEEK_API_KEY=sk-你的key
node dist/cli.js check --config starter.yaml # 不导出密钥也能跑,会告诉你还缺什么
node dist/cli.js serve --config starter.yaml # 启动 → 打开 http://127.0.0.1:8787/admin生成的 starter.yaml 只有一个上游、一行 models、两个 key 槽——刻意的,别一上来就面对选项森林。
换厂商只是换个 flag,换模型只是改 models 那一行:
node dist/cli.js starter --vendor dashscope --out starter.yaml # 阿里云百炼
node dist/cli.js starter --vendor siliconflow --out starter.yaml # 硅基流动
node dist/cli.js starter --vendor mock --out starter.yaml # 本地 mock,零密钥离线跑第一次接真实上游会看到什么(这才是重点,不是配置能不能生成):
- 正常调用:
/v1/chat/completions直接通 - 上游限流:拿到
rate_limited → retry_with_backoff + retryAfterMs,而不是一句429 - 上游余额不足:拿到 HTTP 402 +
top_up_balance(明确不让你重试) - 上游挂了:
/admin的上游页会显示熔断,请求自动切到下一个可用上游(只有一个上游时是重试同一个)
内置模板:deepseek dashscope siliconflow moonshot zhipu ark openrouter agnes mock。
有些厂商的 HTTP 约定和 OpenAI 不一样,这类差异必须靠配置表达,不能让代码去猜(猜错会静默鉴权失败)。 目前已知一例:
| 厂商 | 差异 | 配置写法 |
|---|---|---|
| Agnes(apihub.agnes-ai.com) | Authorization 不带 Bearer 前缀,裸 token 才行;带上反而 401 |
auth: { format: bearerless } |
starter --vendor agnes 生成的配置里已经写好了这个字段。可选的 format:
bearer(默认)/ bearerless / api-key-header(可配 headerName)/ x-api-key。
Agnes 的
/models是公开接口(带任意 Authorization 头即返回 200,不校验), 所以不能用它来判断密钥是否有效——必须真的发一次/chat/completions。 同一账号下还有agnes-video-*/agnes-image-*,那些端点语义与计费不同(视频按秒), 不在本网关覆盖范围内,因此起步配置只列 chat 模型。
模型名与价格会变。模板只保证格式正确,不保证当下最优——接好之后用
GET /v1/models核实你账号实际可用的模型,再改models一行。
竖向沿能力阶梯换模型,横向换同模型的别的供应商。客户只写一个名字,剩下的交给你。
modelLadders:
旗舰: [agnes-3.0-flash, agnes-2.5-flash, agnes-2.0-flash] # 旗舰挂了 → 往下推
fast: [agnes-2.0-flash, agnes-2.5-flash, agnes-3.0-flash] # 极速挂了 → 往上推
平衡: [agnes-2.5-flash, agnes-2.0-flash, agnes-3.0-flash] # 同层先切,再向外阶梯名会进对外模型清单,所以客户直接写 "model": "旗舰" 就能用(2026-09-14 起档位名改中文,旧英文名 flagship/balanced/fast 不再保留)。
阶梯只用一家厂商自家的型号:旗舰 / 平衡 / 极速 是 Agnes 自家三档,
mimo-旗舰 / mimo-极速 是 MiMo 自家两档;跨厂商兜底的另起名字
(旗舰-any / 极速-any,Agnes 整条不可用时落到 MiMo)——混在一起会让
"往下推一档"变成"换一家厂商",那是另一个决策,不该藏在同一串名字里。
上游超时必须显式设(对外配置的教训):内置默认是 TTFB 120 秒 / 整体 600 秒,
对阶梯来说太长——上游一卡,请求就一直吊着,阶梯根本没机会往下走
(实测:免费档的 Agnes 卡了 31.6 秒,客户端只能自己断开)。对外配置里写死
upstreamTtfbTimeoutMs: 90000(首字节 90 秒内没来就判这阶失败,2026-09-14 从 60s 上调——客户发 14.5 万+ token 超长轨迹,上游首字节本来就要 15~53 秒)+ upstreamTimeoutMs: 300000。
实测的一次真实阶梯(第一阶的模型该 key 没权限,返回 403):
+4ms ok route 档位阶梯共 3 阶:agnes-2.5-pro → agnes-3.0-flash → agnes-2.5-flash
+4ms ok route → agnes(smart_routing)model=agnes-2.5-pro
+852ms FAIL classify permission_denied
+852ms ok retry 阶梯推进:agnes-2.5-pro → agnes-3.0-flash(因 permission_denied)
+1870ms ok upstream_response HTTP 200
+1870ms ok respond HTTP 200
换了模型一定会告诉你(否则就是"静默降级"):
x-relay-model-requested: 旗舰(响应头只允许 ASCII,中文名按 UTF-8 百分号编码,如 %E6%97%97%E8%88%B0) ← 你请求的
x-relay-model-served: agnes-3.0-flash ← 实际答的
x-relay-ladder: 2/3 ← 走在第几阶
x-relay-switched: 旗舰->agnes-3.0-flash(同上百分号编码)
换模型不是等价操作——带 tool_calls 的请求切到不支持 function calling 的模型,
整个任务直接废掉,比返回错误还糟。所以切之前要能判断"换过去还成立吗":
modelCapabilities:
agnes-3.0-flash: { tools: true }
agnes-2.5-flash: { tools: true }
agnes-2.0-flash: { tools: true, contextWindow: 32000 }tools: false的模型,带工具的请求会跳过它contextWindow填了之后,超长请求会自动跳过放不下的模型- 未声明的字段视为"未知,允许"——宁可试,也不要因为漏配而拒绝服务
判据是换一个模型有可能成功吗:
| 换 | 不换(换谁都会同样失败) |
|---|---|
| 限流、过载、超时、网络、上游 5xx、流中断 | 内容过滤(rewrite_input) |
| 模型无权限(403)、模型不存在 | 请求参数非法、tool_calls 结构错 |
| 密钥/余额问题(换个模型可能落在别的供应商上) | 治理层自身错误 |
| 上下文超限(换个上下文更大的就成立) |
请求级错误直接返回让客户改请求,而不是拿他的请求去试遍所有模型。
node dist/cli.js pull-models --config agnes-ladder.yaml # 拉取并写回配置
node dist/cli.js pull-models --config agnes-ladder.yaml --dry-run # 只看差异它会自动排除视频/图片/向量等非对话模型——那些端点语义与计费都不同,
拉进配置只会让客户拿它们打 chat/completions 然后失败:
【agnes】拉取 https://apihub.agnes-ai.com/v1/models
上游共 12 个模型
可作对话模型 6 个: agnes-2.0-flash, agnes-2.5-flash, ...
已排除非对话 6 个: agnes-image-2.0-flash, agnes-video-2.5, ...
新增 3: agnes-2.0-flash, agnes-2.5-pro-alpha, agnes-2.5-pro-beta
注意:
/models列出 ≠ 你 key 有权限。实测 Agnes 的 pro 系列在清单里但调用返回 403。 拉完配置后建议逐个真调一次确认。
等第一个厂商跑顺了、也看清了自己的真实失败率和成本结构,再考虑加第二个。
配置里所有密钥都用 {ENV:NAME} 插值,不允许明文写在文件里。
node dist/cli.js init # 生成 config/relay.yaml(三上游完整示例)
$EDITOR config/relay.yaml # 填上游 baseUrl / models / cost,写客户端 key
node dist/cli.js check # 校验配置,并告诉你还缺哪些环境变量(这一步不用导出密钥)
export DEEPSEEK_API_KEY=sk-...
export RELAY_ADMIN_TOKEN=$(openssl rand -hex 24) # 48 位十六进制,与 docs/DEPLOYMENT.md 一致
node dist/cli.js serve # 启动
node dist/cli.js report # 打印度量报告(P8)config/relay.example.yaml 是可运行的三上游示例(DeepSeek + 硅基流动 + mock),
含 cost 配置与三层客户端 key,可以直接照抄改造。
前置要求只有一条:Node.js ≥ 22.5(用到内置 node:sqlite)。运行时零依赖,
所以生产环境不需要 npm install --production。
npm test # 单测数量以实际输出为准(当前 216 项)
bash scripts/smoke.sh # 30 项真实进程冒烟:流式、故障注入、限流、优雅退出、重启后度量冒烟脚本会自己起 mock 上游与网关,跑完自动清理,不需要预先准备任何环境。
- OpenAI 兼容网关:
/v1/chat/completions(含 SSE 流式)、/v1/embeddings、/v1/completions、/v1/models。 客户端把base_url指过来就能用,不改一行业务代码。 - 多上游聚合:一个 key 聚合多上游;按优先级 / 成本 / 权重 / 延迟路由;可恢复错误自动故障转移;熔断与半开。
- 多 key 池:同一上游可挂多个 key,轮换分摊限流;被拒的 key 自动报废或短冷却,用户无感。
- 分层商业模式:
free/standard/pro三层,降损能力按层开关(见 §4)。 - 降损层(核心差异):
- P2 调用前预检:畸形
tool_calls、非法参数、越界采样、模型名拼错……在上游之前拦下,省一次往返。 - P3 失败归因:raw error →
{failure_reason, suggested_next.action, recoverable},让 Agent 直接知道下一步做什么。 - P8 损耗量化基线:归因投递率、自动恢复数、无效重试数、预检拦截数、降损层自身开销占比。
- P5 请求级轨迹:一次请求内
auth→precheck→route→upstream→classify→retry→stream→meter的完整时间线,排障不再靠猜。 - P4-lite 无状态指纹:只从单个请求的结构特征识别 Agent 客户,不存会话正文。
- P2 调用前预检:畸形
- 可验证的透明性:
X-Relay-Verify-Nonce→ 响应头返回内容 HMAC,客户端可自行证明字节未被篡改。 - 运营看板:内嵌单页 HTML,零 CDN 依赖,内网可用。
| 不做 | 原因 |
|---|---|
| P1 服务端改写成功响应 | 动摇"我只是管道"的信任假设;且有损 bug 会变成客户事故。降损只做错误路径。 |
| P6 响应缓存 | 命中即零 token 零收入,且 LLM 非确定性下返回陈旧结果是信任灾难。只缓存纯函数产物(规则表、正则)。 |
| P7 同轮去重 | 没有"轮"的概念,也不该替用户返回旧结果。降级为重复观测(duplicateInWindow),只统计不干预。 |
| P4 跨请求意图栈 | 要求 Redis 有状态架构与会话内容留存。降级为 P4-lite 无状态指纹。 |
| Agent 平台能力 | 中转站只看得到单请求。一旦开始"帮用户编排",就不是中转站了。见 docs/research/03 的边界论证。 |
| fork OneAPI / NewAPI | NewAPI 已迁至 QuantumNous/new-api,许可为 AGPL-3.0 + Section 7,闭源商用路径被否决;OneAPI 是 MIT 但其 Channel.Key 是单个字符串、错误处理器只保留一个字符串(语义全丢),且没有我们需要的降损插槽。见 docs/research/01。 |
每条都对应一个通过的测试,不是宣传语。
| 能检(会在上游之前拦下) | 不能检(也不该假装能) |
|---|---|
model 缺失 / 不在清单(带编辑距离推荐最接近的模型) |
用户环境里的 DOM 元素是否存在 |
messages 非数组 / 为空 / 超上限 |
图片 URL 是否可达(要外网探测,引入 SSRF 面) |
role 非法、content 类型错误 |
语义级"这个工具调用有没有意义" |
assistant.tool_calls[].function.arguments 不是 JSON 字符串(最高频的 Agent 框架 bug) |
上游是否"心情不好" |
tool_call_id 回引不存在的声明 / 重复回填 |
— |
tool_choice 指向未声明的工具、工具名重复 |
— |
temperature/top_p/penalty 越界、max_tokens 非法 |
— |
json_object 模式但消息里没有 JSON 指令(warn 级,不拦) |
— |
| shell 类工具参数命中危险命令黑名单(可配置) | — |
三条工程铁律(都有测试):
- fail-open,但安全项例外:预检内部异常或超出预算(默认 3 ms)一律放行,并如实记
precheckDegraded。 "治理层不能成为主流程的单点故障"不是口号,是_degraded计数器。 唯一的例外是危险命令扫描:它排在预算守卫之前、自己不看预算,成本用"最多扫 1024 条工具调用" 这种结构性上限保证有界。原因见DECISIONS.mdADR-014 —— "CPU 一忙危险命令拦截就自动关闭"是安全级的静默失效,不能用性能取舍来解释; 分界线是失效后果:漏拦危险命令会删用户机器,漏拦tool_choice只是少一次提前提示。 - 只拦"上游必然拒绝"的:
temperature上限各上游不同,用一套边界拦所有上游就是自我 DDoS。因此只拦协议级硬边界(OpenAI 规范明确写死的范围)。 - 误拦逃生舱:客户端加
X-Relay-Precheck: off即绕过拦截(请求照常放行并留痕)。用户不该被治理层的误判锁死。
15 类 failure_reason × 11 种 quick_action,覆盖:
- 限流 vs 余额不足的区分(关键):
429 + insufficient balance必须判为quota_exhausted(不可重试), 否则 Agent 框架会对RateLimitError疯狂重试。同理余额不足返回 402 而不是 429。 - 六种上游错误体形态都能解析(有测试):标准 OpenAI / 智谱 GLM(缺 type/param)/ Kimi(缺 code)/
SiliconFlow(无
error包裹层) / OpenRouter(code 是数字 HTTP 状态码) / HTML 错误页。 - 输出永远补齐 OpenAI error schema 的必填字段(
type/param/code),否则严格解码器会直接崩。 - 绝不改写上游原文:
message与code以上游为准,归因只作为附加字段/响应头。 - HTTP 200 里的过滤信号也覆盖(
finish_reason=content_filter)——这类不走APIStatusError,是最容易漏的一类失败。 - "没有可用上游"分四种说法,禁止混淆(
DECISIONS.mdADR-013): 模型真没人支持 →404 model_not_found;上游全熔断 / 被人工摘除 / 被本请求排除 →503 upstream_error并附relay_details.root_cause(如rate_limited)与标准Retry-After。 不变量:只要有上游声明支持该模型,就绝不说成"模型不存在" —— 否则人会去改模型名,那是在唯一正确的动作之外做了唯一错误的动作。 - "模型名写错了"与"这个 key 没开通"必须分开:前者是改名字的事,后者是去开通的事。
模型名不在网关清单里 →
404 model_not_found,并附拼写建议与这把 key 的可用清单; 模型存在但白名单没有 →403 model_not_allowed。混着说会把人引向完全错误的方向 (实测有人把agnes写成agens,网关却告诉他"你的 key 未开通模型 agens")。 - "客户端 key 不对"与"上游拒绝密钥"分开算,且都要留下证据(
DECISIONS.mdADR-016): 前者是网关自己回的 401(failure_reason=auth_invalid且没有上游参与),后者才归因到上游。 混在一个计数里,面板横幅必然指错方向 —— 一个要用户换 key,一个要运维换上游密钥。 401 文案带 key 指纹(sha256前 8 位)并指向面板自检入口;被拒的 key 记入台账 (只记指纹/提交形态/长度,永不记明文)。 - 失败归因与维度聚合一律读持久层,重启后不许失真;未鉴权流量单独成桶
(未鉴权), 不得冒充free层或空模型名。这条是被真实事故逼出来的:评测时客户端用了旧 key, 网关连回 202 次 401,而当时byModel走 SQL 报 202 条、byFailureReason走内存只报 1 条, 同一个看板自相矛盾,"上游拒绝密钥"的横幅因此永不触发 —— 用户看到的是"API 掉线了"。
投递通道按分层降级:
| 层 | precheck |
attribution |
效果 |
|---|---|---|---|
| free | off | off | 纯管道:错误体一个字节都不加(守住"同价"承诺与信任假设) |
| standard | observe | header | 错误体保持原样,归因只进 X-Relay-Attribution 响应头 |
| pro | enforce | full | 错误体 error.relay_attribution 内附完整卡片 |
看板与 relay report 给出的口径(每个都对着一个可行动的结论):
| 指标 | 为什么它决定下一步 |
|---|---|
| 归因投递率 | < 80% 说明有失败模式没被规则覆盖 → 该补规则了 |
| 自动恢复数 | 重试后成功 = 用户少一次人工介入 = 留存价值 |
| 无效重试数(60s 窗口同 payload) | 高 → 该引导用户加同轮去重,或在客户端加幂等键 |
| 预检拦截数 | 每条都是一次省下来的上游往返与 token |
| 预检降级放行数 | 应恒为 0;不为 0 说明治理层在 fail-open,是 bug 信号 |
| 降损层开销占比 | 预检+归因耗时 / 总耗时。治理层不该超过千分之一 |
| prompt cache 命中 token | 唯一"不改内容就降本"的路径(DeepSeek prompt_cache_hit_tokens / OpenAI cached_tokens) |
trace tr_xxx request req_xxx
outcome=upstream_error duration=1043ms failed_at=upstream_response
+ 0ms ok auth key=pro tier=pro
+ 1ms ok precheck 通过(0 条提示,耗时 62µs)
+ 2ms ok route → mock(priority)model=mock-small
+ 3ms ok upstream_request POST /chat/completions
+ 180ms FAIL upstream_response HTTP 429
+ 181ms FAIL classify rate_limited → retry_with_backoff
+ 182ms ok retry 第 2 次尝试将在 20ms 后发起
+ 205ms ok route → mock(failover)model=mock-small
+ 412ms FAIL upstream_response HTTP 429
+ 413ms FAIL classify rate_limited → retry_with_backoff
+ 414ms ok respond HTTP 429
打开 http://127.0.0.1:8787/admin。八个页签(总览/指标/客户 Key/对账/账本/排障/运维/信任) + 登录门 + 右侧详情抽屉:
| 视图 | 回答什么问题 | 可写操作 |
|---|---|---|
| 总览 | 现在健康吗?降损层在起作用吗?下一步优化什么? | — |
| 指标 | 这个数为什么是这个值(计数器 + n/avg/min/max 分布 + 派生比率) | — |
| 客户 Key | 每个客户用多少、额度还剩多少、哪些快到期;"我手上这个 key 到底能不能用" | 新建、编辑层/模型白名单/额度、轮换、禁用/启用、重置额度、粘贴自检 |
| 上游 | 渠道健康、熔断、key 池可用数、模型可达性 | 摘除/恢复、清熔断(不重启即时生效) |
| 请求账本 | 某次请求卡在哪一步(按结果筛选,点击看步骤时间线) | — |
| 信任 | 凭什么信你(四条设计红线 + 它对应哪组测试 + 客户自助核对方法) | — |
界面约定:
- 页签进 URL:
/admin#users可直接打开某一页(刷新/收藏/分享都停在原处)。 - 客户 Key 页默认只看「生效中」,并可按 id / 备注 / 指纹搜索 —— 历史停用的 key 是噪音,默认收起来。
- 不可逆的动作集中在「更多…」抽屉里(轮换 / 停用),并把后果写在按钮前面; 行内只留「编辑」和「重置」。轮换会让旧 key 立即失效,挂在每行上容易误点(真实发生过一次)。
- 数字列右对齐 + 等宽数字(
tabular-nums):金额/token/耗时能成列对齐,扫读快很多。 - 载入时有明确反馈(整页淡化 + 屏蔽点击),避免"点了没反应"被当成卡死。
- 登录门:未鉴权时拦住整个界面,令牌只存本浏览器
localStorage,401 自动登出。看板外壳(
/admin的 HTML)与panel-info本身不需要令牌,其余/admin/api/*一律需要。 原因很实在:浏览器顶层导航带不了X-Admin-Token,而登录门就在那张 HTML 里—— 外壳一旦也走鉴权,配了adminToken的网关在浏览器里就永远是一页 401 JSON,面板等于不存在 (DECISIONS.mdADR-017)。外壳是纯静态的、不含任何数据; 要连外壳都藏起来请用前置反代,而不是把登录门锁死。 - 详情用右侧抽屉:列表是主视图,详情按需展开,不占常驻空间。Esc 或点遮罩关闭。
- 只读页 15 秒自动刷新;客户 Key 与表单页刻意不自动刷新,免得打字时被重置。
- 每次写操作都进事件流(
relay_event表),可审计、可回溯。 - 明文 key 只显示一次(服务端只有 sha256,接口永不回显明文,有测试断言),忘了只能轮换。
- 每个 key 都显示指纹(sha256 前 8 位):这是"用户手上的 key"与"网关签发的 key"之间 唯一安全的对照物 —— 既不泄露明文,又能一眼判断是不是同一个 key。
- 额度有两个口径,各自独立判断:
额度(token)适合"给多少量";预算(元)(quotaCny)适合"给多少预算"。钱必须按钱算 —— 输入 ¥3/M、输出 ¥6/M 时, 同样 ¥10 折成 token 数完全取决于输入输出比例,用 token 额度冒充只能猜,客户对不上账。 超预算返回 402(不是 429),避免客户端把"没钱了"当成"被限流"而无效重试。 预算也落库,进程重启后仍在(老库靠ALTER TABLE轻量迁移补列)。
"API 全 401" 的最短排查路径(DECISIONS.md ADR-016):
- 总览横幅会直接说 "客户端提交的 key 不对(N 次 401)",并说明这不是上游故障; 它与"上游拒绝了你配置的密钥"是两条不同的横幅——修复动作完全不同,所以不能合并。
- 到「客户 Key」页粘贴你手上的 key,服务端换算指纹后回答四种结论之一: 有效 / 已禁用 / 已过期 / 不是本网关当前签发的。用不着懂哈希,也不用把 key 发到别处。
- 同一页的「最近被拒的 key」按指纹聚合,显示次数、最后时间、提交形态
(
Bearer/ 裸 key / 没带 key),并标注该指纹是否命中已签发的 key —— "key 存在但被禁用"和"根本不是这里签发的"必须分开,否则人会去查错方向。 - 服务端日志同时落一条
auth.rejected(含指纹与端点的warn),每种新的错 key 记一次, 同一种刷屏时按 100 次折一条,不会把日志写爆。
轮换 key 会让旧 key 立即失效。这是有意的(旧 key 泄露时必须能马上止血), 但也正是"用户以为 API 挂了"的常见来源——所以面板现在必须能自己说出这件事。
三条产品原则:
- 把承诺摆在明面上。 「信任」页不是文案,是四条不变量 + 每条后面写着它在哪组测试里被钉住,
外加客户自己就能跑的对拍命令(
x-relay-verify-nonce→x-relay-content-hmac)。 - 区分"业务结论"与"原始读数"。 总览回答业务,指标页回答"这个数怎么来的"—— 排查异常时你要的是后者(计数器的原始值、延迟的 min/max、治理层开销占比)。
- 可逆。 关掉某个降损模式即回滚;误摘的上游一键恢复;熔断计数可清。 热更新的值重启后回到配置文件——面板是应急出口,不是配置漂移的来源。
管理 API(全部需要 X-Admin-Token,未配置时仅接受 127.0.0.1):
GET /admin/api/overview?window=24h | metrics | series | providers | keys | traces | traces/:id
GET /admin/api/config | events | report | auth-policy | auth-failures | loss-reduction | attribution/catalog | panel-info
POST /admin/api/keys/create | keys/update | keys/revoke | keys/rotate | keys/reset-quota | keys/whoami
POST /admin/api/providers/toggle | providers/reset | loss-reduction/updatePOST /admin/api/keys/whoami 就是上面那个"粘贴自检"接口:明文只在这一次调用里参与
哈希计算,不写日志、不落盘、不进台账。
免鉴权端点只有两个:GET /admin(静态外壳)与 GET /admin/api/panel-info(只回答"要不要令牌")。
面板创建的 key 会持久化,进程重启后仍然可用(哈希落库,明文不可恢复)。
⚠ 它是落在
storage.path那个库里的,所以「换一份配置」= 「换一个钥匙串」: 另一份配置如果指向别的库,同一台机器上也会认不出这个 key(表现为 401invalid_key), 而面板创建的东西不会有任何报错。一个部署用一个storage.path(DECISIONS.mdADR-018)。 真要并存多套配置,就让它们共用同一个库。 反过来,如果 key 是在别的库里创建的,哈希可以直接迁过来——用户客户端里已配好的 key 原样继续可用。
| 层 | 计费 | 降损 | rpm | 并发 | 周期额度 |
|---|---|---|---|---|---|
| free | 无 | 全关(纯转发) | 20 | 4 | 200K token |
| standard | 按 token | 预检 observe + 归因 header | 300 | 32 | 无限 |
| pro | 订阅 | 预检 enforce + 归因 full + smart routing | 1200 | 128 | 5M token |
额度用尽返回 402(而非 429);X-Relay-Mode 响应头始终回显本次请求被哪一层、哪套开关处理——这就是"透明"的实现。
更完整的商业化推演(含财务模型、冷启动、以及研究后修正的 Pro 定价建议)见 docs/research/01、docs/research/03 §对评估文档的修订建议。
一个可卖的套餐就是三件事的组合,每件各管一种故障:
| 轴 | 解决的问题 | 怎么配 |
|---|---|---|
| 竖向(档位阶梯) | 这个模型本身挂了 → 换同厂更低/更高的档 | modelLadders:mimo-flagship: [mimo-v2.5-pro, mimo-v2.5]、mimo-fast: [mimo-v2.5, mimo-v2.5-pro] |
| 横向(同模型多供应商) | 这家供应商挂了 → 同一个模型名换另一家 | 两个 provider 声明同名模型(mimo-v2.5-pro 同时由原厂与聚合站提供);priority 大者优先 |
| 闸(预算) | 客户花超了 → 这个 key 就结束 | key 上设 预算(元)(quotaCny),花完返 402,面板状态显示「已用完」 |
三者的关系要分清:竖向换的是"模型",横向换的是"供应商",混在同一串阶梯里会让
"往下推一档"偷偷变成"换一家厂"——那是两个不同的决策,所以跨厂商的那条另起名字(*-any)。
套餐边界用 key 的 allowedModels 圈定:圈内全通、圈外一律 403 model_not_allowed,
所以"客户拿哪把 key"就等于"他买了哪个套餐"。
在售的一套(MiMo 套餐):mimo-v2.5-pro(原厂主用,宕机时自动落到聚合站兜底)
mimo-v2.5(仅原厂)+ 两条竖向阶梯,圈在一把带 ¥10 预算的 key 里。
恢复是自动的,而且是三段式的(每一段都有测试钉住):
- 同一次请求内:主用失败 → 重试时把失败的那家降权 → 当次就换兜底 → 客户仍拿到 200。
- 连续失败到阈值(
breakerThreshold: 5)→ 熔断该上游breakerCooldownMs; 这期间请求直接走兜底,不拿客户请求去反复撞一个已经坏掉的上游。 - 冷却到期 → 半开试探一次:成功就清零计数、立刻切回主用; 失败就立刻再熔断一轮。所以"原厂修好了"最长一个冷却周期就被发现。
关键前提:两家声明的是同一个模型名,所以切换期间 x-relay-model-served 不变
(实测三态都是 mimo-v2.5-pro,客户端 200),Agent 问到的模型与回答语义完全一致 ——
这正是"同模型才敢兜底"的原因。
| 文档 | 内容 |
|---|---|
docs/ARCHITECTURE.md |
架构、模块边界、请求生命周期、扩展点、性能特征 |
docs/DESIGN.md |
设计契约与红线:零改写、fail-open、纯函数审查、归因通道、合规 |
docs/DEPLOYMENT.md |
部署、配置项全表、运维手册、容量与安全、故障排查 |
DECISIONS.md |
决策记录(含 ADR-011 分两个系统的边界、ADR-012 起步只做单厂商) |
docs/research/01-竞品与生态矩阵.md |
OneAPI / NewAPI / LiteLLM / Portkey / Helicone / OpenRouter 逐项对比(含源码行号证据) |
docs/research/02-上游协议与错误契约.md |
实现者速用区:字段表、错误码映射、归因规则素材(21 个 URL 实证) |
docs/research/03-降损层在网关层的落点裁决.md |
把 P1~P8 逐条落到 HTTP 语义上,含 fail-open 分阶段、合规方案、反方压力测试 |
DECISIONS.md |
决策记录(为什么自研、为什么零依赖、为什么只做错误路径) |
已完成(本仓库可运行、可测试):调研 → 架构 → 自研实现 → 108 单测 + 30 项冒烟 → 管理看板 → 运维文档。
已知边界(诚实清单):
- Anthropic / Gemini 原生协议只留了实现位,当前按 OpenAI 兼容处理;非 OpenAI 兼容上游会返回可归因错误而不是静默出错。
- 流式响应不做内容校验证明(HMAC 证明仅对可缓冲响应生效);流式内容的 mid-stream 错误目前只记录在 transcript 里,尚未注入 SSE
error帧回传客户端。 - 未做分布式限流(多实例部署时限流/额度是每实例独立的)。需要时把
Store.incrRate/quotaSnapshot换成 Redis 实现即可,接口已隔离。 - 模型路由的成本优化依赖
providers[].cost配置;未配置时自动退化为优先级路由。 - 火山方舟的错误码口径未实证(研究 02 已列为已知空洞),归因走兜底规则。
下一步优先级建议:
- 用真实上游跑 2~4 周,先把 P8 基线跑出来,再定 Pro 定价(研究 03 指出"降损省 40%"是高估)。
- 补 Anthropic / Gemini 原生适配(若有客户确实需要)。
- 客户端 SDK 薄封装:把
retry_with_backoff+retryAfterMs变成一行autoRetry()。 - 误拦率实测:用 Dify / LangChain / openai-python 做"注入 400"对照实验(研究 03 的最不确定项)。