可观测性
每次调用打给了谁、试了几次、慢在哪、离限额还有多远、这个实验花了多少——从响应头、响应体到控制台的日志、用量与导出。
每一次调用,SciRouter 都会把「实际发生了什么」交还给你:响应头与响应体里给当下这一次的事实, 控制台的请求日志里给完整的时间线与尝试明细。全部是 OpenAI 形状之外的附加项,官方 SDK 会忽略它不认识的字段,你什么都不用改就能拿到。
响应头
非流式响应带这一组:
| 头 | 含义 |
|---|---|
X-Request-Id | 这次调用的编号。报障时带上它,它是我们定位到那一次具体调用的唯一线索 |
X-SciRouter-Model | 实际服务的模型 id |
X-SciRouter-Provider | 实际服务这次调用的供应商 id。同一模型可能挂在多家供应商的线路上,失败时会自动转移 |
X-SciRouter-Attempts | 上游尝试总数(含跳过的)。2 及以上说明发生过自动转移;费用只按最终成功的那次结算 |
X-SciRouter-Upstream-Request-Id | 供应商返回的请求 id(有才给)。拿供应商工单时用它对账 |
X-SciRouter-Cost-Quota | 本次消耗的配额。流式响应给不出准确值,所以只有非流式有 |
X-SciRouter-Cache | HIT / MISS / BYPASS,见「缓存与幂等」 |
限额余量与 OpenAI 同名,你的 SDK 里已有的退避逻辑直接认:
| 头 | 含义 |
|---|---|
x-ratelimit-limit-requests / x-ratelimit-remaining-requests / x-ratelimit-reset-requests | 每分钟请求数的上限 / 余量 / 到当前窗口结束还有多久(如 42s) |
x-ratelimit-limit-tokens / x-ratelimit-remaining-tokens / x-ratelimit-reset-tokens | 每分钟 token 数,同上 |
余量按你的 Key 与账号两级里更小的那一级给。没有限额时这组头不出现(不会给你一个 0 让你误以为该退避了)。
流式响应的限额头在第一个分片之前就写好了;被限频的 429 响应仍带 Retry-After 与 error.details.retry_after_seconds。
浏览器里跨源调用时这些头都在 Access-Control-Expose-Headers 里,fetch 读得到。
响应体里的 scirouter 字段
非流式响应的顶层多一个 scirouter 对象:
json{
"id": "chatcmpl-…",
"choices": [ … ],
"usage": { … },
"scirouter": {
"request_id": "req_…",
"model": "deepseek/deepseek-r1",
"provider": "p_…",
"cost_quota": 18240,
"cache": "miss",
"attempts": 2,
"upstream_request_id": "…",
"timings": { "queue_wait_ms": 830, "ttfb_ms": 412, "total_ms": 5688 }
}
}timings.queue_wait_ms:在供应商渠道的容量队列里等了多久(没排队为 0)——这一段慢不是模型慢,是厂商给的容量满了;timings.ttfb_ms:成功那次尝试从发出到收到上游首字节;没走到上游时为 null;timings.total_ms:端到端总耗时。
流式响应在 data: [DONE] 之前多一个 choices 为空的分片,带同一个 scirouter 字段(形状和 OpenAI 自己的最后一个 usage 分片一样,SDK 都认):
textdata: {"id":"chatcmpl-…","object":"chat.completion.chunk","created":…,"model":"…","choices":[{"delta":{"content":"…"},…}]}
data: {"id":"chatcmpl-…","object":"chat.completion.chunk","created":…,"model":"…","choices":[],"scirouter":{"request_id":"req_…","attempts":1,…}}
data: [DONE]上游一个分片都没吐就结束的流不会有这个分片。
控制台里的请求日志
/console/logs 能按 request_id 直接查一条,也能按结束原因、来源、慢请求、缓存命中、错误码、自定义时间区间(最长 90 天)筛。
每条记录点开是调用详情:
- 时间线:准入 → 排队 → 路由 → 每次尝试 → 上游首字节 → 推流 → 结算,各段多久一目了然; 两个首字节(你看到的 / 上游给出的)各一根标记线,差值就是花在 SciRouter 侧的时间;
- 路由:实际供应商、每次尝试的结果与耗时;
- 诊断提示:只解释已有的事实——被
max_tokens截断、主要耗时在排队、发生过转移、用量为估算、客户端中途断开; - 复制报障信息:一键把
request_id、时间、模型、错误码、供应商请求 id 拼成一段,贴给我们即可。
归因:给调用贴标签
一个 Key 下跑十个实验,账单只能按 Key 分。在请求体里带 OpenAI 原生的 metadata 字段(字符串键值对),
每次调用就带上了你自己的归因标签:
pythonclient.chat.completions.create(
model="deepseek/deepseek-r1",
messages=[{"role": "user", "content": "…"}],
metadata={"exp": "abc", "run": "12"},
user="researcher-42",
)tsawait client.chat.completions.create({
model: 'deepseek/deepseek-r1',
messages: [{ role: 'user', content: '…' }],
metadata: { exp: 'abc', run: '12' },
user: 'researcher-42',
})- 上限:最多 8 个键,键只能是 1–32 位的小写字母、数字、
_、.、-,值最长 64 个字符、不能含换行;超限 400。 metadata与user都不会转发给模型供应商——它们是给你自己看的,落在控制台日志里。- 日志页可以按标签筛(
?tag=exp:abc,点一下记录上的标签即可),用量页可以按标签分组看花费、错误率、截断率(窗口 ≤ 30 天)。 - 站内对话(Chat)的每次调用自动带
chat_session标签,值是会话 id:日志页能从一条消息跳到那次调用,也能筛出一场对话的全部调用。
一段时间:用量页的维度
/console/usage 可以按模型 / Key / 项目 / 来源 / 状态 / 标签分组,看请求数、Tokens、配额、错误率、截断率,
按模型时还有 P50 / P95 延迟;开「与上期对比」会把上一个等长窗口画在同一张图上。窗口有近 24 小时 / 7 天 / 30 天 / 90 天,或自定义区间(最长 90 天)。
两条口径:请求数不含响应缓存命中(命中单独计数,它们没有调用模型、不计费);延迟分位只在按模型分组时给—— 分位数不能跨模型合并,合并出来的数字没有定义,所以其它分组那一列写的是「按模型查看」,不是 0。
此刻:离限额还有多远
控制台概览页的「实时」卡每 10 秒刷新一次:每分钟请求 / 每分钟 Token 当前窗口用了多少(与 x-ratelimit-* 头同一组读数)、
并发在途几个、正在跑的是哪几个请求、近 1 小时错了些什么。接口是 GET /console/limits/live,可选 ?keyId= 多看一级某个 Key 的读数。
导出
日志页的「导出当前筛选」按当前筛选把记录导成 CSV(UTF-8 BOM,Excel 直接开)或 JSON Lines,单次上限 50 000 行,
超过会先告诉你命中了多少条、让你缩小范围,不会导出一半。文件里没有提示词与返回片段,也没有请求参数;
时间列按你账号设置的显示时区,列名里写着时区(如 created_at (Asia/Shanghai))。
出事主动说:调用告警
账号页「通知」里有五个调用告警,默认开着、选了渠道就会发(每 5 分钟扫一次,冷却见括号):
| 通知项 | 触发 | 冷却 |
|---|---|---|
| 错误率飙升 | 某把 Key 近 15 分钟错误(不含限频)超过 20% 且样本 ≥ 20 | 每把 Key 1 小时 |
| 延迟劣化 | 某模型近 15 分钟 P95 比近 7 天基线高出一倍以上且样本 ≥ 20 | 每个模型 1 小时 |
| 频繁被限频 | 近 15 分钟 429 ≥ 30 次 | 1 小时 |
| 配额即将耗尽 | 按近 24 小时的消耗速度,剩余配额撑不过 3 天 | 24 小时 |
| 回答频繁被截断 | 某模型近 1 小时被 max_tokens 截断的回答超过 30% 且样本 ≥ 20 | 每个模型 24 小时 |
正文只有数字与名字,没有链接(提醒里的链接是钓鱼的样子);每条都写明该去控制台哪一页看。
接进你自己的系统:通用 webhook
通知渠道除了企业微信 / 飞书 / Bark,还可以是你自己的 https 地址(公网;本机与内网地址会被拒绝)。建好渠道时会显示一次签名密钥(whsec_…),之后读不回,换密钥要删掉重建。
每条提醒是一次 JSON POST:
json{
"id": "whd_01HZX…",
"event": "error_rate_spike",
"scope": "user",
"subject": "错误率飙升",
"text": "…纯文本…",
"markdown": "…Markdown…",
"data": { "key": "生产", "percent": "35", "requests": "40", "errors": "14", "topCode": "UPSTREAM_ERROR", "window": "15 分钟", "site": "SciRouter", "time": "2026-09-22 10:30" },
"site": "SciRouter",
"time": "2026-09-22T02:30:00Z"
}头:X-SciRouter-Event(通知项 id;试发是 test)、X-SciRouter-Delivery(投递 id,重试时不变,据此去重)、
X-SciRouter-Signature: t=<unix 秒>,v1=<hex>。验签三行:
pythont, v1 = parse("X-SciRouter-Signature") # "t=1726990000,v1=…"
expected = hmac_sha256(secret, f"{t}.{raw_body}").hexdigest()
ok = hmac.compare_digest(expected, v1) and abs(time.time() - int(t)) < 300你的接收端返回任意 2xx 即成功;5 秒没回或非 2xx 会再试 3 次(0.5 / 1 / 2 秒后)。连续失败 10 次渠道会自动停用,
原因写在渠道的「上次失败」里,修好地址后重新启用即可。data 里的键与通知模板的变量同名,后台改模板不影响它。
接进你的监控:Prometheus
自部署时网关的 /metrics 给出调用指标(只给内网抓,对外 404):
scirouter_request_duration_seconds{model,source,status}、scirouter_request_ttfb_seconds{model,source}、
scirouter_tokens_total{model,direction}、scirouter_attempts_total{provider,outcome}、
scirouter_ratelimited_total{scope}、scirouter_finish_reason_total{model,reason}。
标签只到模型 / 供应商 / 来源 / 状态,不含用户与 Key;仓库里带一份 Grafana 面板(scripts/grafana/scirouter.json)。
按用户、按 Key 看请用控制台的用量页与日志页。
几个容易读错的地方
finish_reason: "length"是被 max_tokens 截断,不是模型故障。需要完整回答就调大max_tokens。attempts数的是尝试,不是失败:2可能是一次失败 + 一次成功,也可能是一条渠道满了被跳过 + 一次成功。- 首字节有两个口径。SDK 里量到的首字节含网络与边缘那一段;
timings.ttfb_ms是网关到上游的那一段。判断「模型快不快」看后者。 - 响应缓存命中(
cache: "hit")的调用没有上游、不计费,attempts为 0,timings.ttfb_ms为 null。
隐私
请求日志里的提示词与返回片段按你在账号设置里的保留期保存,超期只剩统计列;日志不会存到浏览器里。 供应商只给名称,不给接口地址与线路信息。