OpenRouter 的响应缓存怎么开:命中与不命中的判定
一、这东西是给什么处境准备的
有两类场景会让人反复付同一笔钱。
一类是 agent 工作流。前面六步都跑通了,第七步挂了,你改完代码重跑,前六步的请求一模一样,又原样打一遍给上游。另一类是测试。你想让测试套件里的模型调用每次返回同样的东西,可模型本身是随机的,测试就飘。
OpenRouter 官方文档《Response Caching》页(openrouter.ai/docs/guides/features/response-caching)写明:开启后,对完全相同的 API 请求,命中缓存时会立即从缓存返回结果,不计费,所有可计费用量计数器都报 0。文档同时写明这层缓存是 model-agnostic 的——它工作在 OpenRouter 这一层、在请求被转发到任何 provider 之前,所以不需要 provider 侧提供任何支持。
这里要先把一个容易混的东西分开。文档专门有一条 Note 说明:有些 provider 自己也提供 prompt caching,那是 provider 基础设施内部的机制,与 OpenRouter 的响应缓存是两套独立的东西,可以同时使用。本文讲的只是后者。
二、前置条件
开之前先确认四件事,缺一件都会让你在后面排查得莫名其妙。
第一,端点得在支持范围内。 文档列出的支持端点共四个:
| 端点 | API 格式 |
|---|---|
/api/v1/chat/completions | OpenAI Chat Completions |
/api/v1/responses | OpenAI Responses |
/api/v1/messages | Anthropic Messages |
/api/v1/embeddings | OpenAI Embeddings |
文档还补了一句:缓存键里带有端点类型的区分位(endpoint type discriminator),所以请求体完全一样但打到不同端点,不会互相串。
第二,账号级 ZDR 会直接把这个功能关掉。 文档的 Limitations 一节写明:当账号级 ZDR(Zero Data Retention)被强制开启时,响应缓存不可用,理由是文档自述的——缓存需要临时存储响应数据。同一段还写明:per-request 的 provider.zdr 不影响缓存资格。这两条是分开的,别把它们当成一回事。
第三,缓存按 API key 隔离。 文档写的是 cache is scoped to your API key:不同的 API key,哪怕在同一个账号或同一个组织下,也不共享缓存。轮换 API key 之后,新 key 面对的是一个空缓存。
第四,只有成功响应会被缓存。 文档写明只有 200 OK 的响应进缓存;错误响应、限流响应、部分结果一律不缓存。带 tool calls 的响应属于正常完成的一部分,照常缓存。流式与非流式请求都有资格被缓存。
三、两种开启方式
3.1 按请求加头
在单次请求上加 X-OpenRouter-Cache 头。以下为官方文档的 cURL 示例,原样抄录:
curl -i https://openrouter.ai/api/v1/chat/completions \
-H "Authorization: Bearer <OPENROUTER_API_KEY>" \
-H "Content-Type: application/json" \
-H "X-OpenRouter-Cache: true" \
-d '{
"model": "google/gemini-2.5-flash",
"messages":
[
{
"role": "user",
"content": "What is the meaning of life?"
}
]
}'
示例里的那个 model 值只是官方文档当时用的示例值,平台上有哪些模型随时在变,别当成清单用。
Python 侧文档给的是 requests 直发,头部同样是这一个:
import requests
response = requests.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={
"Authorization": f"Bearer <OPENROUTER_API_KEY>",
"Content-Type": "application/json",
"X-OpenRouter-Cache": "true",
},
json={
"model": "google/gemini-2.5-flash",
"messages": [
{"role": "user", "content": "What is the meaning of life?"}
],
},
)
Windows 侧要多绕一步(以下为通用做法,不是官方文档内容):PowerShell 里的 curl 默认是 Invoke-WebRequest 的别名,参数语义和上面这条命令对不上;单引号包裹的 JSON 在 PowerShell 与 cmd 里的转义规则也和 POSIX shell 不同。要照抄官方那条命令,请显式调用 curl.exe,或者干脆改用上面的 Python 版本——请求头和请求体是一样的,避开引号转义反而省事。
3.2 在 preset 上开
如果你希望某一类请求全都走缓存,文档给的是在 preset(openrouter.ai/docs/guides/features/presets)上配两个字段:
| 字段 | 类型 | 说明 |
|---|---|---|
cache_enabled | boolean | 对使用该 preset 的所有请求启用缓存 |
cache_ttl_seconds | number | 缓存响应的默认 TTL(1-86400 秒,默认 300) |
文档给的 preset 配置示例:
{
"name": "cached-tests",
"cache_enabled": true,
"cache_ttl_seconds": 600
}
文档写明:preset 上设了 cache_enabled 之后,引用该 preset 的每个请求都自动应用缓存,不需要再带 X-OpenRouter-Cache 头。
3.3 优先级:头和 preset 打架时听谁的
文档把这条列成了五条规则,值得逐条看,因为它不是「后写的赢」这种直觉顺序:
- preset 显式设了
cache_enabled: false,缓存就是关的,请求头覆盖不了这个 opt-out; X-OpenRouter-Cache: false头会关掉缓存,即使 preset 开着;X-OpenRouter-Cache: true在 preset 没有配置缓存(即cache_enabled缺省)时能开启缓存,但覆盖不了显式的cache_enabled: false(规则 1 优先);X-OpenRouter-Cache-TTL头会覆盖 preset 的cache_ttl_seconds;- 头和 preset 都没设,缓存是关的。
反直觉的是第 1 条和第 3 条的组合:cache_enabled 缺省和显式写 false,对请求头来说是两种不同的结果。
四、缓存键由什么构成,什么会让它失效
这是这篇的正题。文档《Cache key details》一节写明,缓存键由五样东西派生:API key、model、endpoint type、streaming mode,以及请求体的 SHA-256 哈希。
「两个请求算不算相同」的判定就落在这五样上。文档在 How it works 里说得很直白:改动其中任何一样——包括换模型、换端点、在流式与非流式之间切换——都会产生不同的缓存键,也就是一次 miss。流式和非流式是分开缓存的,stream: true 的请求不会拿到非流式的缓存结果,反过来也一样。
请求体这一项有几条细则,是最容易踩的地方:
- 请求体在哈希前会被归一化,多余空白不影响缓存键。 这一条是好消息。
- 但 JSON 的属性顺序是有意义的。 文档举的例子是
{"model":"x","messages":[]}和{"messages":[],"model":"x"}——逻辑上一样,缓存键不一样。 - 省略可选字段,和显式发送默认值,是两个不同的键。 文档举的例子是
temperature: 1.0。你今天用的 SDK 版本默不默认往请求体里塞这个字段,直接决定你还命不命中昨天的缓存。 - Attribution headers(如
HTTP-Referer、X-Title)和 provider 相关的请求头不参与缓存键。 也就是说改这些头不会打散缓存。 - 多模态请求(图片、音频、视频、文件附件)有资格被缓存,完整请求体(含 base64 编码内容)都进哈希。
把上面几条连起来看,有一个实践含义值得写进你自己的备忘:如果你用某个 SDK 或某层封装发请求,请求体的字段顺序和默认字段是这层封装决定的,不是你写的那几行决定的。 换一个客户端库、升一个小版本,都可能让命中率变成零,而这件事在响应里没有任何报错——你只会看到 MISS。
另外还有两种「本该命中却没命中」的情况,文档也写明了:
- 并发相同请求:两个完全相同的请求同时到达、第一个响应还没写进缓存时,两个都是
MISS,都分别计费。文档明说没有请求合并(no request coalescing)。 - 缓存淘汰:在内存压力下,缓存条目可能在 TTL 到期前就被淘汰。文档写明缓存条目数量没有上限,但也因此明说条目不保证活满整个 TTL。
五、TTL 与主动清除
TTL 控制缓存条目多久算有效。文档写明默认 300 秒(5 分钟),可取范围是 1 秒到 86400 秒(24 小时),可以用 X-OpenRouter-Cache-TTL 头按请求设置,也可以在 preset 里设默认值。这里的默认值与取值范围都是官方文档写明的口径,随版本可能变动,落地前请以官方文档最新内容为准;默认值也只是「你不设时按什么算」,不是「你的条目一定能活这么久」——上一节那条淘汰规则依然管用。
X-OpenRouter-Cache-TTL 的取值解析规则文档写得很细,抄在这里,因为它决定了你写错值时是报错还是默默走默认:
- 无法按整数解析(即不以数字开头)的值会被忽略,落回 preset 或默认 TTL;
- 以数字开头的值会被接受,即使后面跟着非数字字符(文档的例子:
60abc按60处理); - 小数会被截断(文档的例子:
1.5按1处理); - 数值超出有效范围会被钳制到
[1, 86400]。
要强制刷新某一条,文档给的是 X-OpenRouter-Cache-Clear: true,需要和 X-OpenRouter-Cache: true(或一个 cache_enabled: true 的 preset)一起发。它会删掉当前缓存键对应的那一条、向 provider 发一次新请求、把新响应存起来。两点要注意:缓存没开启时这个头不起作用;它只清当前请求对应的那一条,不是清空全部。
三个请求头合起来用的写法:
curl -i https://openrouter.ai/api/v1/chat/completions \
-H "Authorization: Bearer <OPENROUTER_API_KEY>" \
-H "Content-Type: application/json" \
-H "X-OpenRouter-Cache: true" \
-H "X-OpenRouter-Cache-TTL: 600" \
-H "X-OpenRouter-Cache-Clear: true" \
-d '{"model": "google/gemini-2.5-flash", "messages": [{"role": "user", "content": "What is the meaning of life?"}]}'
以上为按官方文档中的参数语义组合的示例,未经实测,以官方文档与 --help 的实际输出为准。
六、边界:文档明说不保证的那几处
- 缓存返回是逐字的,和随机性参数无关。 文档的 Note 写明:缓存响应会被原样返回,不管
temperature这类随机参数是什么。需要新鲜结果就用X-OpenRouter-Cache-Clear: true或者短 TTL。文档在单测场景里给的配套建议是:想让第一次运行就是确定的,用temperature: 0或固定的seed。 - 命中的那次响应,标识是新的。 文档写明
id字段、created时间戳、以及每个 chunk 里的X-Generation-Id,反映的是这次命中新生成的记录,不是原始那次。流式请求命中时,缓存响应会通过同一条流式管道重放,客户端收到的内容分块是一样的。 - 计费口径按端点不同。 文档写明命中免费、不消耗 token,chat completions 与 Responses 端点的
usage.prompt_tokens、usage.completion_tokens、usage.total_tokens归零;Embeddings 端点归零的是usage.prompt_tokens与usage.total_tokens(该端点响应里没有completion_tokens);Anthropic Messages 端点归零的是usage.input_tokens与usage.output_tokens。只有产生缓存的那次MISS会被计费。 - 文档还写明:缓存命中不计入 provider 侧的速率限制,因为请求根本没到 provider。
- 数据留存:文档写明缓存响应存放在 edge 基础设施上,只保留 TTL 时长,到期自动淘汰;缓存数据只能由触发缓存的那个 API key 取回,其它 key、账号或组织都取不到;不用于训练、不与第三方共享。
- 这一页里没有把响应缓存标注为
beta/preview/experimental/deprecated。
七、怎么确认自己配对了
判定动作全在响应头上,所以上面示例里的 curl -i 不要省掉那个 -i。
第一次请求应当是 MISS,文档给的响应头形如:
HTTP/2 200
X-OpenRouter-Cache-Status: MISS
X-OpenRouter-Cache-TTL: 300
同一请求再发一次应当是 HIT,文档给的响应头形如:
HTTP/2 200
X-OpenRouter-Cache-Status: HIT
X-OpenRouter-Cache-Age: 12
X-OpenRouter-Cache-TTL: 288
X-Generation-Id: gen-def456
X-OpenRouter-Cache-Source-Id: gen-abc123
四个响应头的语义,文档列得很清楚:X-OpenRouter-Cache-Status 是 HIT 或 MISS;X-OpenRouter-Cache-Age 只在 HIT 时出现,表示这条已经被缓存了多久;X-OpenRouter-Cache-TTL 在 HIT 时是剩余 TTL、在 MISS 时是完整 TTL;X-OpenRouter-Cache-Source-Id 只在 HIT 时出现,值是当初写入这条缓存的那次请求的 generation ID。
于是有一组好用的判定:X-OpenRouter-Cache-Source-Id 和 X-Generation-Id 应当是两个不同的值——文档写明 X-Generation-Id 每个响应上都有、并非缓存专有,而命中时这个 ID 是这次命中独有的,不会复用原始响应的。如果你只盯着响应体里的 id,会看到它每次都变,从而误以为没命中。
第二个交叉验证点是 usage:命中时对应端点的用量字段应当是 0。文档给的 HIT 响应体示例里,prompt_tokens、completion_tokens、total_tokens 全是 0。
第三个是 Activity log(openrouter.ai/logs)。文档写明缓存的命中与未命中状态在这里可见,每个被缓存的请求作为单独条目出现并带缓存标记,日志可以按「只看缓存」或「只看非缓存」过滤。
如果一直是 MISS,回到第四节那张清单逐条排:请求体属性顺序有没有变、SDK 有没有偷偷补上默认字段、流式模式有没有切、端点有没有换、API key 是不是同一把、账号级 ZDR 是不是开着、preset 里是不是显式写了 cache_enabled: false。这几条之外,还有并发与淘汰这两种「不是你配错了」的情况,文档都写明了,别一头扎进配置里改。
本文依据 OpenRouter 官方文档(openrouter.ai/docs)于 2026-08-18 的公开内容整理。
该平台闭源,本文只复述官方文档写明的机制,不推断其内部实现;
我们没有对文中涉及的功能做过实测,因此不涉及界面外观与运行表现的任何描述。
该平台的供应商、模型与路由策略随时变动,文中不列具体供应商名单与模型清单;
价格、额度与限流的具体数值请以官方定价页与用量说明为准。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。
合规与许可条款请以官方原文与你所在组织的要求为准,本文不构成法律意见。