OpenRouter 的响应缓存怎么开:命中与不命中的判定

2026-08-18

一、这东西是给什么处境准备的

有两类场景会让人反复付同一笔钱。

一类是 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/completionsOpenAI Chat Completions
/api/v1/responsesOpenAI Responses
/api/v1/messagesAnthropic Messages
/api/v1/embeddingsOpenAI 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 上开

如果你希望某一类请求全都走缓存,文档给的是在 presetopenrouter.ai/docs/guides/features/presets)上配两个字段:

字段类型说明
cache_enabledboolean对使用该 preset 的所有请求启用缓存
cache_ttl_secondsnumber缓存响应的默认 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 打架时听谁的

文档把这条列成了五条规则,值得逐条看,因为它不是「后写的赢」这种直觉顺序:

  1. preset 显式设了 cache_enabled: false,缓存就是关的,请求头覆盖不了这个 opt-out
  2. X-OpenRouter-Cache: false 头会关掉缓存,即使 preset 开着;
  3. X-OpenRouter-Cache: true 在 preset 没有配置缓存(即 cache_enabled 缺省)时能开启缓存,但覆盖不了显式的 cache_enabled: false(规则 1 优先);
  4. X-OpenRouter-Cache-TTL 头会覆盖 preset 的 cache_ttl_seconds
  5. 头和 preset 都没设,缓存是的。

反直觉的是第 1 条和第 3 条的组合:cache_enabled 缺省和显式写 false,对请求头来说是两种不同的结果。

四、缓存键由什么构成,什么会让它失效

这是这篇的正题。文档《Cache key details》一节写明,缓存键由五样东西派生:API keymodelendpoint typestreaming mode,以及请求体的 SHA-256 哈希

「两个请求算不算相同」的判定就落在这五样上。文档在 How it works 里说得很直白:改动其中任何一样——包括换模型、换端点、在流式与非流式之间切换——都会产生不同的缓存键,也就是一次 miss。流式和非流式是分开缓存的,stream: true 的请求不会拿到非流式的缓存结果,反过来也一样。

请求体这一项有几条细则,是最容易踩的地方:

  • 请求体在哈希前会被归一化,多余空白不影响缓存键。 这一条是好消息。
  • 但 JSON 的属性顺序是有意义的。 文档举的例子是 {"model":"x","messages":[]}{"messages":[],"model":"x"}——逻辑上一样,缓存键不一样。
  • 省略可选字段,和显式发送默认值,是两个不同的键。 文档举的例子是 temperature: 1.0。你今天用的 SDK 版本默不默认往请求体里塞这个字段,直接决定你还命不命中昨天的缓存。
  • Attribution headers(如 HTTP-RefererX-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;
  • 以数字开头的值会被接受,即使后面跟着非数字字符(文档的例子:60abc60 处理);
  • 小数会被截断(文档的例子:1.51 处理);
  • 数值超出有效范围会被钳制到 [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_tokensusage.completion_tokensusage.total_tokens 归零;Embeddings 端点归零的是 usage.prompt_tokensusage.total_tokens(该端点响应里没有 completion_tokens);Anthropic Messages 端点归零的是 usage.input_tokensusage.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-StatusHITMISSX-OpenRouter-Cache-Age 只在 HIT 时出现,表示这条已经被缓存了多久;X-OpenRouter-Cache-TTLHIT 时是剩余 TTL、在 MISS 时是完整 TTL;X-OpenRouter-Cache-Source-Id 只在 HIT 时出现,值是当初写入这条缓存的那次请求的 generation ID

于是有一组好用的判定:X-OpenRouter-Cache-Source-IdX-Generation-Id 应当是两个不同的值——文档写明 X-Generation-Id 每个响应上都有、并非缓存专有,而命中时这个 ID 是这次命中独有的,不会复用原始响应的。如果你只盯着响应体里的 id,会看到它每次都变,从而误以为没命中。

第二个交叉验证点是 usage:命中时对应端点的用量字段应当是 0。文档给的 HIT 响应体示例里,prompt_tokenscompletion_tokenstotal_tokens 全是 0

第三个是 Activity log(openrouter.ai/logs)。文档写明缓存的命中与未命中状态在这里可见,每个被缓存的请求作为单独条目出现并带缓存标记,日志可以按「只看缓存」或「只看非缓存」过滤。

如果一直是 MISS,回到第四节那张清单逐条排:请求体属性顺序有没有变、SDK 有没有偷偷补上默认字段、流式模式有没有切、端点有没有换、API key 是不是同一把、账号级 ZDR 是不是开着、preset 里是不是显式写了 cache_enabled: false。这几条之外,还有并发与淘汰这两种「不是你配错了」的情况,文档都写明了,别一头扎进配置里改。


本文依据 OpenRouter 官方文档(openrouter.ai/docs)于 2026-08-18 的公开内容整理。 该平台闭源,本文只复述官方文档写明的机制,不推断其内部实现我们没有对文中涉及的功能做过实测,因此不涉及界面外观与运行表现的任何描述。 该平台的供应商、模型与路由策略随时变动,文中不列具体供应商名单与模型清单; 价格、额度与限流的具体数值请以官方定价页与用量说明为准。

安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。

合规与许可条款请以官方原文与你所在组织的要求为准,本文不构成法律意见。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。