# 可观测性

> 响应头与扩展字段、控制台日志与用量、调用告警与 webhook，以及 OpenTelemetry 链路导出。

来源：https://www.scirouter.cn/docs/observability · 更新于 2026-10-25

SciRouter 在响应头与响应体中返回单次调用的实际情况，在控制台请求日志中提供完整的时间线与尝试明细。这些都是 OpenAI 格式之外的附加项，官方 SDK 忽略不认识的字段，调用方无需修改代码。

## 响应头

非流式响应（对话补全、Messages 与向量）带下列响应头。流式响应只带 `X-Request-Id`、`X-SciRouter-Cache` 与限额响应头（表中注明流式也发出的除外）；其余各项在调用结束后才能确定，放在流末尾的扩展分片中，见[响应体的 scirouter 字段](#响应体的-scirouter-字段)。

| 响应头 | 说明 |
|---|---|
| `X-Request-Id` | 本次调用的编号，始终由服务端生成，不沿用请求中携带的值。报障时提供该值，平台据此定位单次调用 |
| `request-id` | 与 `X-Request-Id` 取值相同（Anthropic SDK 读取该名称），`/v1` 的每个响应都带 |
| `X-SciRouter-Model` | 实际提供服务的模型 id |
| `X-SciRouter-Provider` | 实际提供服务的厂商 id。同一模型可能接入多个厂商的线路，失败时自动转移 |
| `X-SciRouter-Attempts` | 上游尝试总数（含跳过的线路）。值为 `2` 及以上表示发生过自动转移；费用只按最终成功的一次结算 |
| `X-SciRouter-Upstream-Request-Id` | 厂商返回的请求 id，厂商未返回时不带。向厂商提交工单时用于对账 |
| `X-SciRouter-Cost-Quota` | 本次消耗的配额，命中缓存时为 0。流式响应头在正文之前发出，此时消耗未知；流式调用的消耗见末尾扩展分片中的 `scirouter.cost_quota` |
| `X-SciRouter-Cache` | `HIT`、`MISS`、`PARTIAL`（仅向量）或 `BYPASS`；另有 `X-SciRouter-Cache-Age`、`X-SciRouter-Cache-TTL`，见[缓存与幂等](https://www.scirouter.cn/docs/caching) |
| `X-SciRouter-Variant-Switch` | 请求带了会改变计价档位的参数、被自动改用模型变体时返回；值为触发的参数，以逗号分隔。目前只出现在 `/messages`，流式响应同样在正文之前发出，见 [Messages（Anthropic 格式）](https://www.scirouter.cn/docs/messages) |
| `X-SciRouter-Max-Tokens-Clamped` | Messages（Anthropic 格式）的 `max_tokens` 超过模型或套餐的单次上限、平台按上限发出时返回，格式为 `requested=…; effective=…; limit=model`（套餐的上限更小时为 `limit=plan`）。流式响应同样在正文之前发出 |
| `X-SciRouter-Param-Actions` | 有参数被平台去掉、翻译、模拟、整形或自愈时返回，如 `dropped=seed,top_k; translated=reasoning(xhigh→high); emulated=response_format`。五组固定顺序，超过 512 字节截断。三种格式均返回，流式响应同样在正文之前发出。各参数的处理见[参数如何被处理](https://www.scirouter.cn/docs/params) |

### 限额响应头

限额响应头与 OpenAI 同名，SDK 已有的退避逻辑可直接识别：

| 响应头 | 说明 |
|---|---|
| `x-ratelimit-limit-requests` | 每分钟请求数上限 |
| `x-ratelimit-remaining-requests` | 每分钟请求数余量 |
| `x-ratelimit-reset-requests` | 距当前窗口结束的时长，如 `42s` |
| `x-ratelimit-limit-tokens` | 每分钟 token 数上限 |
| `x-ratelimit-remaining-tokens` | 每分钟 token 数余量 |
| `x-ratelimit-reset-tokens` | 距当前窗口结束的时长 |

- 余量按 Key 与账号两级中较小的一级返回。没有限额时不返回这组响应头。
- 流式响应的限额响应头在第一个分片之前写入。例外：排队较久、心跳已先发出响应头时，这组响应头与 `X-SciRouter-Cache` 均不带。
- 被限频的 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": "YOUR_MODEL_ID",
    "provider": "…",
    "cost_quota": 18240,
    "cache": "miss",
    "attempts": 2,
    "upstream_request_id": "…",
    "timings": { "queue_wait_ms": 830, "ttfb_ms": 412, "total_ms": 5688 }
  }
}
```

数值为示例，`choices` 与 `usage` 省略了内容。

| 字段 | 说明 |
|---|---|
| `timings.queue_wait_ms` | 在线路容量队列中等待的时长，未排队时为 0。该段耗时表示厂商分配的容量已满，与模型推理速度无关 |
| `timings.ttfb_ms` | 成功的那次尝试从发出到收到上游首字节的时长；未到达上游时为 `null` |
| `timings.total_ms` | 端到端总耗时 |

以下字段按需返回：

| 字段 | 说明 |
|---|---|
| `saved_quota`、`cache_age_s`、`cache_ttl_s`、`cache_items` | 响应缓存相关，见[缓存与幂等](https://www.scirouter.cn/docs/caching) |
| `tool_rounds`、`tool_calls` | 使用平台托管工具时返回，见 [MCP 与托管工具](https://www.scirouter.cn/docs/mcp) |
| `variant_switch` | `{from, to, params}`，自动改用模型变体时返回 |
| `max_tokens_clamped` | `{requested, effective, limit}`，Messages 的输出上限被按上限发出时返回 |
| `param_actions` | `{dropped, translated, emulated, reshaped, healed}`，各项为参数名列表，与响应头 `X-SciRouter-Param-Actions` 内容相同 |
| `schema_valid` | 结构化输出经平台模拟、且带 `X-SciRouter-Validate-Schema: 1` 时返回，表示正文是否符合 schema |
| `finish_detail` | 值为 `repetition` 时表示模型陷入复读、平台中断了上游，此时 `finish_reason` 为 `length` |
| `prompt_cache` | `{read, write, write_1h, saved_quota}`：命中上游提示词缓存、写入 5 分钟缓存、写入 1 小时缓存的输入 token 数，`saved_quota` 为命中节省的配额。未到达上游或四项均为 0 时不返回 |
| `endpoint_provider` | `provider` 的旧名，取值相同。新代码读取 `provider` |

`param_actions`、`schema_valid`、`finish_detail` 的规则见[参数如何被处理](https://www.scirouter.cn/docs/params)。

提示词缓存的命中部分按缓存命中价计费、写入部分按写入价计费，节省的配额不并入 `cost_quota`。一段时间内的命中率见控制台[用量页](https://www.scirouter.cn/console/usage)的「上游缓存命中」一栏，统计口径见[缓存与幂等](https://www.scirouter.cn/docs/caching)。

### 流式响应的扩展分片

流式响应在 `data: [DONE]` 之前多一个 `choices` 为空的分片，带同一个 `scirouter` 字段。该分片的形状与 OpenAI 最后一个 usage 分片相同，SDK 均可识别：

```text
data: {"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]
```

其中 `cost_quota` 为本次的准确消耗。上游未输出任何分片即结束的流不带该分片；流开始后出错时，最后一个分片为 `{"error": {…}}`，没有扩展分片，也没有 `[DONE]`。

## 请求日志

控制台 `/console/logs` 可按 `request_id` 查询单条记录，也可按结束原因、来源、慢请求、缓存命中、调用格式（OpenAI / Anthropic）、错误码与自定义时间区间（最长 90 天）筛选。同一个 Key 同时供 OpenAI SDK 与 Claude Code 使用时，可按调用格式分开查看。

每条记录可展开为调用详情：

| 部分 | 内容 |
|---|---|
| 时间线 | 准入 → 路由 → 排队 → 每次尝试 → 上游首字节 → 推流 → 结算，未发生的阶段不显示。两个首字节（平台发给调用方的 / 上游返回的）各有一条标记线，差值为 SciRouter 侧的耗时 |
| 路由 | 实际厂商、每次尝试的结果与耗时 |
| 诊断提示 | 只解释已有的事实：被 `max_tokens` 截断、主要耗时在排队、发生过转移、用量为估算、客户端中途断开 |
| 复制报障信息 | 将 `request_id`、时间、模型、错误码、厂商请求 id 汇总为一段文本，用于报障 |

## 归因标签

一个 Key 下运行多个实验时，账单只能按 Key 区分。在请求体中带 OpenAI 原生的 `metadata` 字段（字符串键值对），每次调用即带上自定义的归因标签：

```python
import os

from openai import OpenAI

client = OpenAI(api_key=os.environ["SCIROUTER_API_KEY"], base_url="https://api.scirouter.cn/v1")

client.chat.completions.create(
    model="YOUR_MODEL_ID",
    messages=[{"role": "user", "content": "…"}],
    metadata={"exp": "abc", "run": "12"},
    user="researcher-42",
)
```

```typescript
import OpenAI from 'openai'

const client = new OpenAI({ apiKey: process.env.SCIROUTER_API_KEY, baseURL: 'https://api.scirouter.cn/v1' })

await client.chat.completions.create({
  model: 'YOUR_MODEL_ID',
  messages: [{ role: 'user', content: '…' }],
  metadata: { exp: 'abc', run: '12' },
  user: 'researcher-42',
})
```

| 项目 | 规则 |
|---|---|
| 键的数量 | 最多 8 个 |
| 键 | 1–32 个字符，只含小写字母、数字、`_`、`.`、`-` |
| 值 | 字符串，最长 64 个字符，不能含控制字符（换行、制表符等） |
| 超出限制 | 返回 400 |
| `user`（`/v1/messages` 为 `metadata.user_id`） | 超过 512 个字符时截断保存，不报错 |

- `metadata` 与 `user` 不转发给模型厂商，只记录在控制台日志中。
- 日志页可按标签筛选（`?tag=exp:abc`，点击记录上的标签即可）；用量页可按标签分组查看花费、错误率与截断率（窗口不超过 30 天）。
- 站内对话（Chat）的每次调用自动带 `chat_session` 标签，值为会话 id：日志页可从一条消息跳转到对应调用，也可筛选出一场对话的全部调用。
- `session_id` 是保留键：取值相同的调用归入同一个会话，控制台按会话列出各条执行链路，导出时也带该值，见[会话 id](#会话-id)。

## 用量页

控制台 `/console/usage` 可按模型、Key、项目、来源、状态、标签分组，查看请求数、Tokens、配额、错误率与截断率；按模型分组时另有 P50 / P95 延迟。开启「与上期对比」后，上一个等长窗口画在同一张图上。窗口可选近 24 小时、7 天、30 天、90 天，或自定义区间（最长 90 天）。

团队工作区还可按成员分组。统计口径：

- 请求数不含响应缓存命中。命中单独计数，这些调用未调用模型、不计费。
- 延迟分位只在按模型分组时提供。分位数不能跨模型合并，按其他维度分组时表中没有 P50 / P95 两列，图上也不能选择 P95，表格上方有说明。

## 实时限额

控制台概览页的「实时」卡每 10 秒刷新一次，显示：

- 每分钟请求数与每分钟 token 数在当前窗口的用量（与 `x-ratelimit-*` 响应头为同一组读数）；
- 同时进行的模型调用数；
- 同时运行的 agent 数；
- 正在进行的请求；
- 近 1 小时的错误。

agent 指 Chat 的一轮、课题的一步或研读的一次生成，一个 agent 可能同时调用多次模型。agent 数已满时新的 agent 排队，不报错。

在团队工作区中，卡片显示团队的限额：在团队工作区发起的调用（包括团队项目下的 Key）按团队限额计、成员共用，不占个人限额。进行中的请求与错误只列出当前用户自己的。

程序中读取同一组读数，使用每次响应的 `x-ratelimit-*` 响应头。

## 导出

日志页的「导出当前筛选」将当前筛选的记录导出为 CSV（UTF-8 BOM，Excel 可直接打开）或 JSON Lines。单次上限 50 000 行；超过时先提示命中条数并要求缩小范围，不导出部分结果。

- 文件中不含提示词、返回片段与请求参数。
- 包含已打码的来源 IP、`user`、标签、`saved_quota`、调用格式 `api_format` 等列；列顺序固定，新列只追加在末尾。
- 时间列按账号设置的显示时区输出，列名中注明时区（如 `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>` | 签名，验证方法见下 |

`secret` 为渠道创建时显示的完整字符串（含 `whsec_` 前缀），**直接作为 HMAC 的 key，不做 base64 解码**。`v1` 为以 `secret` 为 key、对 `{t}.{原始请求体}` 计算的 HMAC-SHA256 十六进制值。验签示例：

```python
import hashlib
import hmac
import time


def verify(secret: str, raw_body: bytes, signature: str) -> bool:
    parts = dict(item.split("=", 1) for item in signature.split(","))
    t, v1 = parts["t"], parts["v1"]
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1) and abs(time.time() - int(t)) < 300
```

- 接收端返回任意 2xx 即视为成功；5 秒未响应或返回非 2xx 时再重试 3 次（分别在 0.5、1、2 秒后）。
- 连续失败 10 次后渠道自动停用，原因记录在渠道的「上次失败」中；修复地址后重新启用即可。此处的「一次」指一条提醒的完整投递（含 3 次重试）；投递成功即清零，24 小时内没有新的失败也重新计数。
- `data` 中的键与通知模板的变量同名，后台修改模板不影响 `data`。

## Prometheus 指标

自部署时，网关的 `/metrics` 提供调用指标。对外经 nginx 访问时返回 404，只供内网抓取；生产环境须配置 Basic Auth，用户名默认为 `collector`。

| 指标 | 标签 |
|---|---|
| `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 面板（`server/scirouter/scripts/grafana/scirouter.json`）。按用户或按 Key 查看时，使用控制台的用量页与日志页。

## 链路导出

日志中的一条记录对应一次调用。Chat 的一轮、课题与研读的一次运行、`/v1` 的托管工具循环通常包含多次模型调用以及其间的工具、检索与等待，平台将其记录为一条执行链路：以运行为根的树，每个节点有起止时刻、结局与用量。

添加导出目的地后，这些链路以 OTLP/HTTP JSON 发送到调用方自有的 Langfuse 或任意 OpenTelemetry 后端，与应用自身的链路一起查看。

### 添加目的地

| 配置位置 | 配置者 | 导出的链路 | 可带正文 |
|---|---|---|---|
| 控制台侧栏「[链路导出](https://www.scirouter.cn/console/trace-destinations)」 | 用户本人 | 个人工作区内的：个人 Key 的 `/v1` 调用，个人工作区内的 Chat、课题、研读 | 可以（默认关闭） |
| 团队详情中的「链路导出」 | 团队 owner / admin（成员不可见） | 该团队工作区内的，含团队项目下的 Key 发起的 `/v1` 调用 | 不可以 |

平台运维另在管理后台配置平台级目的地，导出全部链路的元数据，不带正文。每个目的地的配置项：

| 配置项 | 说明 |
|---|---|
| 地址 | OTLP/HTTP 的基地址，导出时请求 `POST {地址}/v1/traces`。只接受 https、端口 443、公网可达的地址；地址中不能含用户名与密码，也不能含 `?` 或 `#` |
| 类型 | `langfuse` 或 `otel`。`langfuse` 另加请求头 `x-langfuse-ingestion-version: 4` 与 Langfuse 识别的若干属性，见[导出内容](#导出内容) |
| 鉴权 | 不鉴权、Basic（用户名与凭据）、Bearer、自定义请求头（请求头名与凭据）。凭据加密保存，保存后无法读回；地址改到另一台主机时须重新填写凭据，已保存的凭据不会发往新地址 |
| 采样率 | 0–1，默认 1（全部导出），见[采样、重试、停用与测试](#采样-重试-停用与测试) |
| 带上内容 | 默认关闭，只有个人目的地可以开启，见[导出内容](#导出内容) |

个人默认最多配置 3 个目的地，每个团队最多 5 个。

### Langfuse

1. 在 Langfuse 项目的 Settings → API Keys 中新建一对 key：public key（`pk-lf-…`）与 secret key（`sk-lf-…`）。
2. 在「链路导出」中添加目的地，按下表填写：

| 配置项 | 取值 |
|---|---|
| 类型 | `langfuse` |
| 地址 | Langfuse Cloud 欧洲区 `https://cloud.langfuse.com/api/public/otel`，美国区 `https://us.cloud.langfuse.com/api/public/otel`；自托管填 `https://<域名>/api/public/otel` |
| 鉴权 | Basic（`langfuse` 类型只支持 Basic）：用户名填 public key，凭据填 secret key |

3. 点击「测试」。成功时 Langfuse 中出现一条名为 `scirouter connectivity test` 的链路。

自托管的 Langfuse 同样须使用 https、端口 443 且公网可达；只在内网可达的实例无法连接。

### 其他 OpenTelemetry 后端

类型选择 `otel`，地址填写后端 OTLP/HTTP 接收端的基地址（不含 `/v1/traces`，平台自动追加）：

| 后端 | 地址 | 鉴权 |
|---|---|---|
| 自建 OpenTelemetry Collector（再转发给 Jaeger、Tempo 等） | 接收端对外的 https 地址，如 `https://otel.example.org` | 按 Collector 的配置：`bearertokenauth` 扩展对应 Bearer，`basicauth` 对应 Basic |
| Jaeger | OTLP/HTTP 接收端对外的 https 地址 | 按前置反向代理的要求填写 |
| Honeycomb | `https://api.honeycomb.io`（欧洲区 `https://api.eu1.honeycomb.io`） | 自定义请求头：请求头名 `x-honeycomb-team`，凭据填 Honeycomb 的 API Key |

- 平台只发送 OTLP/HTTP 的 JSON 编码，不使用 gRPC；接收端须接受 `application/json`（Collector 与 Jaeger 的 OTLP 接收端均支持）。
- Collector 与 Jaeger 的 OTLP/HTTP 接收端默认使用 4318 端口与明文 http，平台只连接 https 的 443 端口：须在前面加一层 https 反向代理，或为接收端配置证书并监听 443。
- 单次发送的请求体默认不超过 4 MiB，接收端的请求体上限须大于该值。

### 导出内容

一条链路是 trace id 相同的一组 span，每个节点对应一个 span：

| 节点 | span 名 | `gen_ai.operation.name` | Langfuse 类型 |
|---|---|---|---|
| 运行：Chat 的一轮、课题 / 研读的一次运行、`/v1` 托管工具循环的根（`v1_tool_loop`） | `invoke_agent {名字}` | `invoke_agent` | agent |
| 步骤 | `invoke_agent {步骤种类}` | `invoke_agent` | agent（主 agent）/ chain（其余） |
| 模型调用 | `chat {模型 id}` | `chat` | generation；验收评审的调用为 evaluator |
| 检索 | `execute_tool {名字}` | `execute_tool` | retriever |
| 工具、沙箱、MCP、读技能、读回原文、留言 | `execute_tool {名字}` | `execute_tool` | tool |
| 等待：排队等名额、撞沙箱闸放回、等待用户回答 | `wait {名字}` | 无 | span |
| 截断标记：该链路记录的节点数已达上限，之后的节点不再记录 | `marker truncated` | 无 | event |

- 重试、换模型、续写、压缩、退回队列不单独成为 span，记为所在节点上的 span event（事件名为 `retry`、`fallback`、`continue`、`compaction`、`requeue` 或 `other`；能对应到某次调用时带该调用的 `scirouter.request_id`）。
- 模型调用的 span kind 为 CLIENT，其余为 INTERNAL；出错节点的 status 为 ERROR，消息为错误码。
- resource 只有 `service.name = scirouter`；instrumentation scope 为 `scirouter.traceexport`，版本为 `gen-ai-dev/1.42.0-dev`。属性名遵循 OpenTelemetry GenAI 语义约定的该版本；该约定处于 Development 阶段，上游改名时平台同步修改，scope 的版本号标明所用版本。

每个节点都有的属性：

| 属性 | 说明 |
|---|---|
| `gen_ai.operation.name` | 见上表；等待与截断标记没有该属性 |
| `gen_ai.conversation.id` | 会话 id：Chat 会话、课题、论文，或 `/v1` 的 `metadata.session_id`；不属于任何会话时没有该属性 |
| `scirouter.trace.kind` | `chat` / `campaign` / `paper` / `api` |
| `scirouter.node.type` | `run` / `step` / `generation` / `tool` / `retrieval` / `sandbox` / `mcp` / `skill` / `recall` / `post` / `wait` / `marker` |
| `scirouter.status` | `ok` / `error` / `cancelled` / `skipped`；运行收尾时仍未结束的节点为 `running` |
| `scirouter.error_code` | 出错时的错误码 |

按节点类型另有的属性：

| 节点 | 属性 |
|---|---|
| 模型调用 | `gen_ai.provider.name`（厂商 id，没有时为 `scirouter`）、`gen_ai.request.model`、`gen_ai.usage.input_tokens`、`gen_ai.usage.output_tokens`、`gen_ai.usage.cache_read.input_tokens`（命中提示词缓存的部分，大于 0 时才有）、`gen_ai.response.finish_reasons`；`scirouter.request_id`（控制台日志可按它查到该次调用）、`scirouter.cost_quota`、`scirouter.attempts`、`scirouter.provider`、`scirouter.purpose`（`turn` / `review` 验收评审 / `compaction` 压缩摘要 / `other`）、`scirouter.first_token_ms` |
| 工具类 | `gen_ai.tool.name`；`scirouter.cost_quota`（工具自身按次的花费，有时才返回）；`scirouter.tool.<键>`：只含白名单中的计数、布尔值与短枚举，即 `argsBytes`、`resultChars`、`results`、`sources`、`papers`、`reused`、`prefetched`、`gated`、`empty`、`sandboxStatus`、`wallMs`、`timeout`、`exitCode`、`round`、`rounds`、`toolCalls`、`failure` |
| 运行、步骤 | `gen_ai.agent.name` |

类型为 `langfuse` 时，每个 span 另加：

| 属性 | 说明 |
|---|---|
| `langfuse.observation.type` | 上文节点表中的 Langfuse 类型 |
| `langfuse.trace.name` | 链路标题，如「第 3 轮对话」 |
| `session.id` | 与 `gen_ai.conversation.id` 相同，Langfuse 的 Sessions 据此将多条链路归为一个会话 |
| `langfuse.observation.completion_start_time` | 模型调用的首字时刻 |
| `user.id` | 仅团队目的地：节点所属成员的用户 id。个人目的地不带 |

trace id 与 span id 由平台的链路 id、节点 id 经 SHA-256 截取为 16 / 8 字节，同一节点每次导出的 id 相同；带 `traceparent` 的链路直接使用调用方的 trace id，见 [traceparent](#traceparent)。

正文默认不导出。节点中只有上述计数、长度与枚举，不含提示词、回答、检索词与网址。个人目的地开启「带上内容」后，模型调用另带 `gen_ai.input.messages` 与 `gen_ai.output.messages` 两个属性（按约定的消息结构序列化的字符串：输入整段记为一条 `user` 消息，输出记为一条 `assistant` 消息）：

- 内容取自请求日志保存的正文片段，因此只有该次调用保存了正文时才有（账号设置「数据保留」中的「保存请求与响应正文」，见[隐私](#隐私)）；
- 平台内部的验收评审与压缩摘要调用一律不带正文；
- 单个节点带上正文后超过请求体上限时，该节点不带正文发送；
- 团队与平台级目的地不能开启该选项。

导出时机：

- 导出每分钟运行一轮。Chat、课题、研读的链路在整次运行结束后一次发送；`/v1` 的节点在各自结束后发送。
- 节点结束 2 分钟后才发送（等待全部落库），因此后端中看到的数据比调用晚两三分钟。
- 每个节点对每个目的地只发送一次。例外：对方已接收、平台尚未记录进度时实例恰好重启，该批会重发一次。
- 只发送归属该目的地的节点，同一条链路中其他用户的调用不会一并发出。
- 向量调用（`/embeddings`）不在链路中。

### traceparent

`/v1/chat/completions`、`/v1/messages`、`/v1/responses` 识别 W3C Trace Context 的 `traceparent` 请求头。带该请求头时，本次调用记录在调用方的 trace 之下：

- 链路 id 为调用方的 trace id；本次调用（使用托管工具时为循环的根节点）的父节点为调用方的 span id。导出时两者原样使用，平台的节点挂在发起调用的 span 之下，在后端中属于同一棵树。
- 同一个 trace id 的多次请求，在控制台中也属于同一条链路。
- 只识别 version `00`：`00-<32 位小写十六进制 trace id>-<16 位小写十六进制 span id>-<2 位十六进制 flags>`。格式错误、id 全为零或带多个 `traceparent` 时视为未带，不报错；不读取 `tracestate`；flags 中的采样位不影响平台是否记录与导出。
- 未带时本次请求自成一条链路，链路 id 为本次调用的 `request_id`（托管工具循环的各轮沿用第一轮的值）。

`traceparent` 只负责关联两侧的链路。要在后端中看到完整的树，还须添加指向同一后端的导出目的地，见[添加目的地](#添加目的地)。

```bash
curl https://api.scirouter.cn/v1/chat/completions \
  -H "Authorization: Bearer $SCIROUTER_API_KEY" \
  -H "Content-Type: application/json" \
  -H "traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" \
  -d '{
    "model": "YOUR_MODEL_ID",
    "messages": [{"role": "user", "content": "…"}],
    "metadata": {"session_id": "notebook-42"}
  }'
```

Python（OpenAI SDK 与 OpenTelemetry SDK）在每次调用时用 `extra_headers` 带上当前 span：

```python
import os

from openai import OpenAI
from opentelemetry import trace
from opentelemetry.propagate import inject

client = OpenAI(api_key=os.environ["SCIROUTER_API_KEY"], base_url="https://api.scirouter.cn/v1")
tracer = trace.get_tracer("my-agent")

with tracer.start_as_current_span("summarize-paper"):
    headers = {}
    inject(headers)  # 把当前 span 写成 traceparent 头
    response = client.chat.completions.create(
        model="YOUR_MODEL_ID",
        messages=[{"role": "user", "content": "…"}],
        metadata={"session_id": "notebook-42"},
        extra_headers=headers,
    )
```

- 整个脚本在同一个 span 下运行时，也可以在创建 client 时用 `default_headers={"traceparent": …}` 一次带上。
- 安装 OpenTelemetry 的 httpx 自动埋点（`opentelemetry-instrumentation-httpx`）后，OpenAI SDK 发出的请求自动带 `traceparent`，无需手动设置。

### 会话 id

请求体 `metadata` 中的 `session_id` 是保留键，取值为调用方自定义的会话 id。`traceparent` 决定一条链路中节点的父子关系，`session_id` 决定多条链路是否属于同一个会话，两者可同时使用。

- 控制台：会话链路逐行列出该会话中的链路（调用次数、Token、配额、耗时，表头为整个会话的合计）。`session_id` 同时是普通的归因标签，日志页按 `session_id:<值>` 可筛选出该会话的全部调用。
- 导出：写入 `gen_ai.conversation.id`，`langfuse` 类型另写 `session.id`。
- 规则与其他标签相同，见[归因标签](#归因标签)：值为字符串，最长 64 个字符，不能含控制字符，占 8 个键中的一个；格式错误时返回 400。
- `/v1/messages` 同样写在 `metadata` 中（`user_id` 之外的字符串键均视为标签）；`/v1/responses` 写在其自身的 `metadata` 中。

### 采样、重试、停用与测试

| 项目 | 规则 |
|---|---|
| 采样 | 按链路 id 的哈希判定，同一条链路整条导出或整条不导出 |
| 失败与重试 | 对方返回 2xx 视为已接收（含 OTLP 的部分成功）；连接失败、10 秒未响应、返回非 2xx 均视为失败。失败的批次不丢弃，1 分钟后重试，之后每次等待时间翻倍，最长 1 小时；成功一次后失败计数清零 |
| 停用 | 连续失败 10 次后自动停用，目的地上注明原因（对方最近一次的响应，截取 300 字，凭据会被抹去）。修复后修改配置保存或重新启用：失败计数清零，从停止处继续发送；停用较久的只补发最近 24 小时的链路 |
| 测试 | 用已保存的配置发送一条只含一个 span 的合成链路（span 名 `scirouter connectivity test`，属性 `scirouter.test = true`），当场返回对方的状态码、错误原因与耗时。不计入导出条数，也不改变失败计数与状态 |
| 平台暂停导出 | 不能新建、不能测试；已有目的地可以查看、修改、删除，但不发送。恢复后从停止处继续发送（同样最多补发 24 小时） |

## 易误读的字段

- `finish_reason: "length"` 表示回答被 `max_tokens` 截断，与模型故障无关。需要完整回答时调大 `max_tokens`。
- `attempts` 统计的是尝试次数，不等于失败次数：`2` 可能是一次失败加一次成功，也可能是一条线路已满被跳过加一次成功。
- 首字节有两种口径。SDK 测得的首字节包含网络与边缘节点的耗时；`timings.ttfb_ms` 只计网关到上游的一段。判断模型速度以后者为准。
- 命中响应缓存（`cache: "hit"`）的调用不经过上游、不计费，`attempts` 为 0，`timings.ttfb_ms` 为 `null`。

## 隐私

- 请求日志默认不保存提示词与返回正文。在账号设置「数据保留」中开启「保存请求与响应正文」后，之后的调用才会保存（脱敏后每段最多 16 000 字，更长的只保留开头，并在日志详情中标注）。
- **正文由平台保留 7 天后自动清空**（天数由平台策略决定，页面上显示当前值）。关闭开关只影响之后的请求，已保存的正文按期过期。
- 调用记录本身（模型、耗时、token、配额、状态、标签等）保留 90 天，到期整行删除；按小时、按天汇总的用量不受影响。
- 日志不存储在浏览器中。响应中的厂商只返回 id（控制台中显示名称），不返回接口地址与线路信息。
- 执行链路只发送到已添加的导出目的地：个人工作区的链路发往用户本人添加的目的地，团队工作区的链路发往团队 owner / admin 添加的目的地，平台级目的地只接收元数据。只有个人目的地开启「带上内容」时才带正文，见[链路导出](#链路导出)。发出后的存储方式与保存时长由接收方决定。
