# 缓存与幂等

> 提示词缓存与响应缓存的命中条件、请求头与计费，以及用 Idempotency-Key 避免重试重复扣费。

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

SciRouter 上有两层缓存，作用不同：

| 项目 | 上游提示词缓存 | SciRouter 响应缓存 |
|---|---|---|
| 缓存位置 | 模型厂商 | SciRouter 网关 |
| 命中条件 | 请求开头一段与之前的请求相同 | 整个请求与之前的请求完全相同 |
| 计费 | 命中部分按较低的缓存命中价计费，模型仍会推理 | 不调用模型，不计费 |
| 调用方操作 | 多数情况无需操作；单轮批量调用见[单轮批量调用](#单轮批量调用) | 对话将 `temperature` 设为 0 即自动缓存；其他情况带请求头 |

## 上游提示词缓存

同一段长前缀（系统提示、参考文档、工具定义）重复发送时，上游会缓存该前缀。命中部分按模型详情页的缓存命中价计费，通常远低于输入价。

部分上游首次写入缓存时按缓存写入价计费，略高于输入价，之后几轮的命中可抵消这部分差价。Anthropic 协议的 1 小时缓存写入单独计价（`cache_write_1h_input_tokens`，通常更贵）：模型未单独定价时按缓存写入价计，缓存写入价也未设置时按输入价计。模型未单独设置缓存价时，对应部分按输入价计费。

网关在以下两种上游上自动处理缓存：

| 上游类型 | 网关行为 |
|---|---|
| 需要显式标记缓存位置 | 输入较长（约 1000 token 起）且为多轮对话（system 之外至少两条消息）时，在工具定义、系统提示、上一轮末尾与最后一块内容上加标记 |
| 支持缓存分组键 | 计算分组键后发送。默认按 API Key 隔离；请求带 `prompt_cache_key` 时使用该键，仍按账号隔离 |

### 用量与计费

| 位置 | 内容 |
|---|---|
| `usage.prompt_tokens_details.cached_tokens` | 命中缓存的输入 token 数 |
| `usage.prompt_tokens_details.cache_write_tokens` | 写入缓存的输入 token 数，仅在有写入时返回 |
| 响应体 `scirouter.prompt_cache` | 单次调用的缓存明细，见[可观测性](https://www.scirouter.cn/docs/observability) |
| 控制台[用量页](https://www.scirouter.cn/console/usage)的「上游缓存命中」一栏 | 一段时间内的命中率、命中与写入的 token 数、命中节省的配额 |

走 OpenAI 兼容入口时，`usage` 由上游原样返回；个别兼容服务只在顶层返回 `prompt_cache_hit_tokens`，两种写法均按其计费。

命中部分按缓存命中价计费，写入部分按写入价计费，均不免费。用量页的命中率 = 命中 ÷ 全部输入 token（普通输入、命中与两种写入之和）。

### 单轮批量调用

一批请求共用同一段很长的系统提示、但每条只有一个问题时，网关不自动加标记。在需要缓存的内容段上加 `cache_control`：

```json
{
  "model": "YOUR_MODEL_ID",
  "messages": [
    {
      "role": "system",
      "content": [
        { "type": "text", "text": "（很长的评测规则与参考资料…）", "cache_control": { "type": "ephemeral" } }
      ]
    },
    { "role": "user", "content": "第 17 题：…" }
  ]
}
```

- 同一请求最多保留 4 个标记，超出时保留最后 4 个。
- 需要标记的线路按标记缓存；其他线路按线路画像保留或去掉该字段（自动缓存的上游一律去掉，不影响缓存效果）。
- Anthropic 格式的请求经转换时，断点同样按画像带或不带，见 [Messages](https://www.scirouter.cn/docs/messages)「请求的转发方式」。
- 上游明确不支持该字段时，平台去掉后重发（响应头 `X-SciRouter-Param-Actions` 中为 `healed=cache_control`），调用方不会收到 400，见[参数如何被处理](https://www.scirouter.cn/docs/params)。

### 提高命中率

将不变的内容放在前面，变化的内容放在后面。系统提示开头写入当前时间、随机 id 等内容时，每次请求的前缀都不同，无法命中。

## SciRouter 响应缓存

对话的 `temperature` 显式为 0 时自动缓存：1 小时内完全相同的请求，第二次起直接返回上一次的结果，无需带请求头。对话接口的缓存规则：

| 条件 | 缓存行为 |
|---|---|
| `temperature` 显式为 0，未带 `X-SciRouter-Cache` | 缓存 |
| 带 `X-SciRouter-Cache: enabled` | 缓存，不论 `temperature` 取值 |
| 带 `X-SciRouter-Cache: disabled` | 本次不缓存 |
| 未写 `temperature`（或不为 0），且未带请求头 | 不涉及缓存 |

向量接口（`/embeddings`）默认缓存，且按条缓存，见[向量按条缓存](#向量按条缓存)。不需要时带 `X-SciRouter-Cache: disabled`。

以下示例带请求头，不依赖 `temperature`：

```bash
curl https://api.scirouter.cn/v1/chat/completions \
  -H "Authorization: Bearer $SCIROUTER_API_KEY" \
  -H "Content-Type: application/json" \
  -H "X-SciRouter-Cache: enabled" \
  -d '{
    "model": "YOUR_MODEL_ID",
    "temperature": 0,
    "messages": [{"role": "user", "content": "列出这段序列里所有的跨膜区段"}]
  }'
```

```python
import os

from openai import OpenAI

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

response = client.chat.completions.create(
    model="YOUR_MODEL_ID",
    temperature=0,
    messages=[{"role": "user", "content": "列出这段序列里所有的跨膜区段"}],
    extra_headers={"X-SciRouter-Cache": "enabled"},
)
```

适用场景：批量评测反复运行同一套题、向量化含重复文本的批次、开发时反复调试同一请求。要求每次重新生成的场景不适用：命中时返回的回答与首次完全相同。

带 `X-SciRouter-Cache: enabled` 时不论 `temperature` 取值都缓存；温度不为 0 时，之后每次得到的都是首次那一份随机结果。

### 请求头

| 请求头 | 取值 | 说明 |
|---|---|---|
| `X-SciRouter-Cache` | `enabled` / `disabled` | 开启或关闭本次请求的缓存。对话 `temperature` 显式为 0 时不带该请求头也开启 |
| `X-SciRouter-Cache-TTL` | 正整数（秒） | 本次结果的保存时长。对话默认 1 小时、向量默认 24 小时；超过上限（默认 24 小时）时按上限计 |
| `Cache-Control` | `no-cache` | 不读取旧结果，调用模型后用新结果覆盖缓存 |
| `Cache-Control` | `no-store` | 本次既不读取也不写入 |

`X-SciRouter-Cache` 不是上述两个取值、`X-SciRouter-Cache-TTL` 不是正整数时，请求直接返回 400。`Cache-Control` 的其他取值（如 `max-age`）被忽略。

### 命中状态

响应头 `X-SciRouter-Cache` 的取值：

| 取值 | 含义 |
|---|---|
| `HIT` | 来自缓存，未调用模型，不扣配额 |
| `MISS` | 未命中，正常调用并计费；成功的结果会被缓存 |
| `PARTIAL` | 仅向量接口：一批输入中部分命中，只有未命中的条目发给模型并计费，见[向量按条缓存](#向量按条缓存) |
| `BYPASS` | 本应走缓存，但调用方要求不读取（`Cache-Control: no-cache` / `no-store`，或对向量、`temperature` 为 0 的对话带 `X-SciRouter-Cache: disabled`），或请求形状不可缓存，见[不缓存的情况](#不缓存的情况) |
| 无该响应头 | 本次不涉及缓存。例如对话未带 `X-SciRouter-Cache: enabled`，且 `temperature` 不是显式的 0（对话只带 `Cache-Control` 或 `disabled` 不会开启缓存） |

其他相关的响应头与响应体字段：

| 响应头 / 字段 | 说明 |
|---|---|
| `X-SciRouter-Cache-Age` | 命中时返回：该结果产生至今的秒数 |
| `X-SciRouter-Cache-TTL` | 结果将被保存时返回：实际保存的秒数。请求的时长超过上限时为截断后的值 |
| `scirouter.cache`、`scirouter.cache_age_s`、`scirouter.cache_ttl_s` | 与上述响应头的结论相同，取值小写 |
| `scirouter.saved_quota` | 命中时返回：按当前倍率计算的节省配额 |
| `scirouter.cache_items` | 仅向量接口：`{"hit": 命中条数, "total": 总条数}` |

流式响应在 `data: [DONE]` 之前、`choices` 为空的扩展分片中也带这些字段。排队较久、心跳已先发出响应头时，响应头中不带这些信息，以扩展分片或请求日志为准。

命中时响应中的 `usage` 是首次调用的真实用量（与无缓存时相同），但本次不扣费；本次消耗见 `scirouter.cost_quota`（命中时为 0）。控制台[请求日志](https://www.scirouter.cn/console/logs)中命中的记录带「缓存」标记，token 与费用记为 0；用量页的请求数、Tokens、配额均不含命中，命中次数单独列在「缓存命中」。

### 相同请求的判定

按规范化之后的请求体比较：

- 键的顺序、空白、`1` 与 `1.0` 等写法差异不影响比较；
- `stream`、`stream_options`、`user`、`safety_identifier`、`metadata`、`prompt_cache_key`、`store` 不参与比较。流式与非流式共用一份缓存，非流式保存的结果也可以流式回放；
- `max_completion_tokens` 与 `max_tokens` 视为同一参数，`n: 1` 与不写相同；
- 其余任何字段不同（更换模型、修改一个标点、`temperature` 不同）均视为另一个请求。

### 向量按条缓存

向量接口按每一条输入分别缓存，不按整批缓存：同一段文字无论出现在哪一批、排在第几条，都能命中。一批中只有部分命中时（`PARTIAL`），网关只将未命中的条目发给模型，再按原顺序拼回。计费只计发给模型的条目，命中部分记为节省的配额（`scirouter.saved_quota`）；响应中的 `usage` 仍为整批输入的 token 数。

### 不缓存的情况

- 要求多个候选（`n` 大于 1）或逐 token 概率（`logprobs` / `top_logprobs`）的对话：回放无法提供这些内容，响应头为 `BYPASS`；
- 带平台托管工具的请求（结果随网页内容变化）；
- 失败的调用，以及未正常结束的回答：只保存 `finish_reason` 为 `stop` / `tool_calls` 且有内容的结果，被截断（`length`）、被过滤（`content_filter`）或内容为空的均不保存；
- 过大的结果：对话单条超过 256 KB，或向量中单条输入的结果超过 2 MB（按压缩前的大小计）；
- 缓存服务自身故障时，一律按未命中处理，调用照常进行。

### 隔离与计费

- 缓存按 API Key 隔离：同一账号下的不同 Key 互相看不到对方的缓存。
- 命中计入每分钟请求数（RPM），不计入每分钟 token 数与并发；命中不扣配额，因此不受 Key 月度上限、项目预算与套餐每日上限的约束。
- 平台更换该模型背后的上游，或管理员清空该模型的缓存后，旧结果不再命中。
- 响应缓存只作用于 `/v1` 的调用；Playground 与 Chat 不使用响应缓存。

## 幂等

网络抖动时客户端可能重试。为避免一次重试产生两次计费，在请求中带调用方自行生成的 `Idempotency-Key`：

```python
import os
import uuid

from openai import OpenAI

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

key = str(uuid.uuid4())  # 同一个逻辑请求的所有重试用同一个值
response = client.chat.completions.create(
    model="YOUR_MODEL_ID",
    messages=[{"role": "user", "content": "帮我判断这段序列的二级结构倾向"}],
    extra_headers={"Idempotency-Key": key},
)
```

`/chat/completions`、`/messages`、`/responses` 与 `/embeddings` 支持该请求头。键最长 128 字节，按 API Key 隔离，各接口各自独立，保留 24 小时。下表与下方说明适用于这四个接口；图片接口同样接受该请求头，但按规整后的请求判定，`b64_json` 与流式的结果不保存，规则见[图片](https://www.scirouter.cn/docs/images)「幂等」。

| 情况 | 结果 |
|---|---|
| 同一个键、同一个请求体 | 直接返回首次的响应体，带响应头 `Idempotent-Replayed: true`，不再调用模型、不再计费 |
| 首次请求尚未处理完，同一个键再次请求 | 409 `idempotency_conflict` |
| 同一个键、不同的请求体 | 409 `idempotency_conflict`，通常是代码将键复用到了其他请求 |
| 首次请求失败（非 2xx） | 不保存，可用同一个键修正后重试 |
| 首次请求被客户端先断开 | 服务端取消该次调用、不保存结果；**之后同一个键的重试会重新调用模型并照常计费** |
| 流式请求 | 流未结束时，同一个键的重试返回 409；流结束后，同一个键重新执行一次并照常计费。流式结果无法重放 |

- 请求体按原始字节比较。重试时发送完全相同的字节（SDK 的自动重试即如此）；重新拼接 JSON 导致键的顺序变化时返回 409。
- 重放的响应体中 `scirouter.request_id` 仍为首次的编号，按它查询日志。`X-SciRouter-*` 与 `x-ratelimit-*` 响应头不重放，重放也不产生新的日志记录。
- 两种 409 的 `code` 相同，只有 `message` 不同。确定是重试同一个请求时，退避等待后再试，并设次数上限。
- 客户端超时应长于模型的正常耗时；回答可能很长时使用流式。
- 幂等存储自身故障时平台放行请求，不拒绝；极少数情况下可能重复执行一次。

幂等与响应缓存相互独立：幂等只识别调用方提供的键，保证同一个请求只计费一次；响应缓存按请求内容识别，使不同时间发出的相同请求共用结果。两者可同时使用。
