缓存与幂等
省钱的两层缓存怎么用,以及重试时怎样不被重复扣费。
SciRouter 上有两层缓存,省的东西不一样:
| 上游提示词缓存 | SciRouter 响应缓存 | |
|---|---|---|
| 缓存在哪 | 模型厂商那边 | 我们的网关 |
| 命中条件 | 请求开头一段和之前相同 | 整个请求和之前完全相同 |
| 省什么 | 命中部分按更低的「缓存命中价」计,仍然会推理 | 不调用模型,不计费 |
| 要你做什么 | 多数情况什么都不用做(单轮批量调用见下) | 对话请求带一个请求头 |
上游提示词缓存
同一段长前缀(系统提示、参考文档、工具定义)反复发送时,上游会把它缓存起来, 命中的部分按模型详情页里的缓存命中价计,通常远低于输入价。 有的上游第一次写入缓存时按缓存写入价计,略高于输入价,之后几轮命中就省回来了。
网关替你做了能做的:
- 需要显式标记缓存位置的上游:输入够长(约 1000 token 起)且是多轮对话(system 之外至少两条消息)时, 网关自动在工具定义、系统提示、上一轮末尾和最后一块上打标记;
- 支持缓存分组键的上游:网关替你算好分组键再发过去(默认按 API Key 隔离,你在请求里给了
prompt_cache_key就按你的键,仍按账号隔离)。
用量里 usage.prompt_tokens_details.cached_tokens 是命中的数量。
单轮批量调用要自己标。 一批请求共用同一段很长的系统提示、但每条只有一个问题时,网关不会自动打标记
(它判断不出这段前缀还会不会再用)。在想缓存的那一段内容上加 cache_control,网关会原样转给上游:
json{
"model": "bio/protein-72b",
"messages": [
{
"role": "system",
"content": [
{ "type": "text", "text": "(很长的评测规则与参考资料……)", "cache_control": { "type": "ephemeral" } }
]
},
{ "role": "user", "content": "第 17 题:……" }
]
}同一个请求里最多保留 4 个标记(多了留最后 4 个);不需要标记的上游会忽略这个字段。
想让它多命中,你只需要做一件事:把不变的内容放在前面,变化的内容放在后面。 把当前时间、随机 id 之类塞进系统提示的开头,会让每次请求的前缀都不一样,一次也命中不了。
模型没有单独标缓存价时,这部分按输入价计——不会因为没标价就不收,也不会多收。
SciRouter 响应缓存
对话请求带上 X-SciRouter-Cache: enabled,完全相同的请求第二次起直接返回上一次的结果:
bashcurl 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": "bio/protein-72b",
"temperature": 0,
"messages": [{"role": "user", "content": "列出这段序列里所有的跨膜区段"}]
}'Python(OpenAI SDK):
pythonresponse = client.chat.completions.create(
model="bio/protein-72b",
temperature=0,
messages=[{"role": "user", "content": "列出这段序列里所有的跨膜区段"}],
extra_headers={"X-SciRouter-Cache": "enabled"},
)向量接口(/embeddings)默认就走缓存——同样的输入必然是同样的向量。
不想要时带 X-SciRouter-Cache: disabled。
适合用它的场景:批量评测反复跑同一套题、向量化一批会重复出现的文本、开发时反复调试同一个请求。
不适合要求每次重新生成的场景——命中时你拿到的是同一份回答,一字不差。
带了这个头就会缓存,不看 temperature:温度不为 0 时,之后每次拿到的都是第一次那一份随机结果。
请求头
| 请求头 | 取值 | 作用 |
|---|---|---|
X-SciRouter-Cache | enabled / disabled | 开启或关闭这次请求的缓存 |
X-SciRouter-Cache-TTL | 正整数(秒) | 这次结果保存多久。对话默认 1 小时、向量默认 24 小时;超过 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—— 没命中,正常调用并计费,成功的结果会存下来;BYPASS—— 本该走缓存,但这次请求要求不读(disabled、no-cache、no-store),或者请求形状不能缓存(见下);- 没有这个头 —— 这次请求不涉及缓存。例外:流式请求排队很久、心跳已经先把响应头发出去时, 这个头来不及带上;以请求日志里的「缓存」标记为准。
非流式响应的 scirouter.cache 字段是同样的结论(小写)。
控制台的请求日志里,命中的记录带「缓存」标记,token 与费用都是 0,也不计入用量统计。
什么算「完全相同」
按规范化之后的请求体比较:
- 键的顺序、空白、
1与1.0这类写法差异不影响; stream、stream_options、user、metadata、store不参与比较—— 流式和非流式共用一份缓存,非流式存下的结果也能以流式回放;max_completion_tokens与max_tokens视为同一个参数,n: 1与不写相同;- 其余任何字段不同(换了模型、改了一个标点、
temperature不同)都是另一个请求。
平台调整了这个模型背后的上游(换了接口或模型版本)之后,旧结果自动不再命中。
什么不会被缓存
- 要多个候选(
n大于 1)或要逐 token 概率(logprobs/top_logprobs)的请求:回放给不出这些,响应头是BYPASS; - 失败的调用,以及被截断(
finish_reason为length)或内容为空的回答; - 太大的结果(对话超过 256 KB、向量超过 2 MB)。
隔离与计费细节
- 缓存按 API Key 隔离:同一账号下的两把 Key 互相看不到对方的缓存。
- 命中也占每分钟请求数(RPM),不占每分钟 token 数与并发。
- 只对
/v1的调用生效;Playground 与 Chat 不走响应缓存(在那里点「重新生成」,就该真的重新生成)。
幂等:重试不重复扣费
网络抖动时你多半会重试。为了不让一次重试变成两次计费,给请求带一个你自己生成的 Idempotency-Key:
pythonimport uuid
key = str(uuid.uuid4()) # 同一个逻辑请求的所有重试用同一个值
response = client.chat.completions.create(
model="bio/protein-72b",
messages=[{"role": "user", "content": "帮我判断这段序列的二级结构倾向"}],
extra_headers={"Idempotency-Key": key},
)/chat/completions 与 /embeddings 都支持,规则是:
- 键最长 128 个字符,按 API Key 隔离,保留 24 小时;
- 同一个键、同一个请求体:直接返回第一次的响应,带响应头
Idempotent-Replayed: true,不再调用模型、不再计费; - 第一次还没处理完时,同一个键再来:返回 409
idempotency_conflict; - 同一个键、不同的请求体:也是 409
idempotency_conflict——这多半是你的代码把键复用到了别的请求上。 两种 409 的code相同,只有message不同;确定是重试同一个请求时,按退避等一会儿再试、并设上限; - 只记住成功(2xx)的响应。失败的请求可以用同一个键修正后重试;
- 你这边先断开的请求不算数:客户端超时断开时,服务端会取消这次调用、不保存结果, 之后同一个键的重试会重新调用模型、照常计费。所以客户端超时要设得比模型的正常耗时长。
流式请求的幂等只管「进行中」:流还没结束时,同一个键的重试会得到 409; 流结束之后,同一个键会重新执行一次(照常计费)。流式响应没法完整重放,这是有意的取舍。
幂等和响应缓存是两回事:幂等只认你给的键,保证「同一个请求只算一次」; 响应缓存认的是请求内容,让「不同时间发出的相同请求」共用结果。两个可以一起用。