OpenRouter 这名字不少做 AI 应用的人应该不陌生,它本质上是一个聚合了多家大模型 API 的网关,你只要接一个地址,就能按需求切换不同厂商的模型。但网关只是解决了接入的问题,真正让项目跑起来之后你会发现,OpenRouter 数据通讯里的重复请求比你想象中多得多,缓存要是不做,钱和时间都在悄悄溜走。这篇文章我就围绕 OpenRouter 的请求链路,聊聊缓存怎么设计、怎么落地、怎么避坑,适合已经在用或者准备用 OpenRouter 做产品后端的开发者参考。
1. 为什么要在 OpenRouter 数据通讯中谈缓存
1.1 一次 OpenRouter 请求的完整成本拆解
很多人以为调用 OpenRouter 的成本就是"模型单价 × token 数",这是只算了显性成本。一次请求在数据通讯层面的开销其实包含四个部分:客户端到你所在区域的网关节点需要一次网络往返,网关再路由到上游模型服务商又是一次往返,模型供应商内部的排队和推理时间才是大头,最后响应还要按流式分段传回来。这一整套走下来,端到端延迟能做到 3 秒以内都算快的,慢的时候十几秒也是家常便饭。
我给一个模拟项目 X 做过统计,那是一个面向内部员工的 FAQ 问答工具,每天大概有 1.2 万次请求。上线初期没做缓存,平均每次请求消耗输入 token 约 800、输出 token 约 400,选用的是某家中等成本的模型,折合下来单次成本大约 0.012 美元。看着单次不贵,但一天就是 144 美元,一个月轻松破 4000 美元。更头疼的是,这 1.2 万次请求里,有将近 35% 是完全相同或仅措辞微调的重复提问,也就是说每个月有上千美元花在了已经回答过的问题上。
再算延迟账。没缓存时,用户问一个问题平均要等 4.8 秒,这在内部工具里还能接受,但如果是面向 C 端的对话产品,首字延迟超过 1 秒用户就开始流失。缓存的价值就在这里:它把"网络通讯 + 模型推理"变成"本地查询 + 向量比较",命中一次大概 50 毫秒,成本几乎可以忽略。
1.2 数据通讯场景中缓存的定位
缓存不是简单地"把上次的答案记下来",而是要放在数据通讯链路的正确位置上。OpenRouter 本身是一个路由网关,它不会替你缓存任何业务请求结果,所以缓存的宿主只能是你自己的服务端。按作用层次分,至少有三层可以做:
- 传输层缓存:在网关前面加一层反向代理,把相同 URL 和 Header 的请求直接截住,但这只适用于完全相同的请求,对于 AI 对话这种带随机性或语义变化的请求价值有限。
- 响应语义缓存:对模型返回的答案做语义向量索引,用户再问类似问题时直接复用。这是 AI 应用里最值得投入的一层。
- 预热层:针对高频问题,在低峰期用 OpenRouter 提前把答案算好存进缓存,高峰期直接取。
定位上有个前提要先讲清楚:缓存只适用于幂等、确定性、可重复的场景。FAQ 问答、产品介绍、政策查询、知识库检索这类场景天然适合。但涉及用户个性化推荐、实时行情、需要感知上下文的连续对话,就不能盲目套缓存,否则你会拿到一个"看起来很对但根本不贴切"的答案。
2. 缓存方案的整体设计与选型逻辑
2.1 缓存层级怎么选
我见过不少人一上来就想搞"语义缓存",觉得精确缓存太低端。实际上,工程上最稳的做法是精确缓存和语义缓存分两层并行部署。
精确缓存就是把请求文本哈希成一个 key,存到表里,完全一致才命中。它的优势是零误判、极快、实现成本几乎为零;缺点是用户只要换个标点、换个说法,就命中不了。
语义缓存则是用嵌入模型把请求文本转成向量,再计算当前请求和缓存记录的余弦相似度,超过阈值就认定语义相同。这能解决"同义不同词"的问题,但代价是要处理阈值误判,而且嵌入计算本身也有开销。
我的建议是:第一版先做精确缓存,让它把完全重复的那 30% 请求接住,已经能省下大头成本。运行一两周后,从日志里找出措辞相近但没命中精确缓存的请求,再引入语义缓存补漏。这样每一层都有明确的收益数据支撑,出了问题也好定位。
2.2 语义缓存的核心原理
想理解语义缓存,可以拿图书馆打个比方。精确缓存是你要借一本具体的书,必须报出准确的书号;语义缓存是你只要说出大概意思,图书员就能帮你在书架上找到内容相近的几本书供你选。
实现上,语义缓存依赖嵌入模型。请求文本先经过嵌入模型变成一个数值向量,比如某个开源嵌入模型输出的是 768 维向量,它的含义是把这个文本的语义压缩成一组坐标。然后我们用余弦相似度来衡量两个向量的夹角。夹角越小,说明语义越接近。余弦相似度的计算公式是:
similarity = (A · B) / (|A| * |B|)A 和 B 分别是两个向量,分子是点积,分母是模长的乘积。结果范围在 -1 到 1 之间,在文本语义场景里通常落在 0 到 1 之间。0.95 以上一般视为高度相似,0.9 到 0.95 之间可能存在轻微语义差异,0.85 以下基本不能当作同一问题。
阈值怎么定是语义缓存里最关键的决策。设得太高,比如 0.99,能命中但命中率很低;设得太低,比如 0.8,误判会大量涌现,用户问"退款政策是什么"和"退货流程是什么"虽然相似但完全是两件事,合在一起复用答案就出事故了。我习惯用 0.93 作为一个初始值,然后跑一批真实日志调参,后面会细说调法。
2.3 技术栈选型参考
搭建这套缓存,可选技术栈有很多,但我不建议一上来就上重型组件。前期用轻量方案跑通逻辑,比追新更重要。
- 存储:SQLite 足够撑住前十万条缓存记录,按平均每条响应 500 token 估算,数据量也就在几百 MB 级别。再往后数据量上来了,迁移到 PostgreSQL 的 pgvector 或者独立向量库也不难。
- 嵌入向量生成:有两种路线,一是调用远程嵌入 API,优点是精度高、不用管部署,缺点是每次取向量都有网络延迟和额外费用;二是本地跑开源嵌入模型,一次性加载到内存后推理速度很快,适合高频场景。我建议前期用远程 API 快速验证,稳定后再切本地模型降成本。
- OpenRouter 调用:直接用 Python 的 requests 或 httpx 都可以,OpenRouter 的接口风格与主流大模型 API 兼容,传一个 API Key 就能调。
- 语言和框架:Python 是第一选择,生态里向量计算、嵌入模型、SQLite 都有成熟库,实验成本低。
这套组合的核心优势是:所有组件都能在一个普通服务器上跑起来,不需要引入消息队列、专用向量数据库这类额外基础设施。把成本省下来才是缓存的本职工作,别还没省到钱就先搭进去一堆机器的钱。
3. 实操:搭建一套 OpenRouter 语义缓存系统
3.1 环境准备与依赖安装
先准备一个干净的 Python 虚拟环境,避免依赖冲突。需要安装的库如下:
pip install requests numpy sentence-transformers三个库的分工是:requests 负责和 OpenRouter 做数据通讯,numpy 负责向量运算和序列化,sentence-transformers 负责本地语义嵌入。如果你的嵌入方案选择远程 API,那 sentence-transformers 可以换成对应的 API 客户端,但下面的示例我以本地嵌入为主线,因为生产环境跑本地嵌入最省钱。
环境准备好后,把 OpenRouter 的 API Key 写入环境变量,建议不要硬编码在代码里:
export OPENROUTER_API_KEY="你的密钥"你还需要选一个本地嵌入模型。这里有个经验:中文场景不要盲目选英文优化过的模型,可以找一个在中文语料上表现较好的开源嵌入模型,模型体积控制在几百 MB 左右,CPU 也能跑,平均单条文本向量化耗时在 20 到 50 毫秒,完全够用。
3.2 核心环节:请求指纹的计算
精确缓存层的 key 不是直接拿原始请求文本做哈希,那样太脆弱。用户输入里经常带换行、多余空格、中英文标点混用,这些都会导致同样的语义内容生成不同的哈希值。所以要先做一次归一化处理,再计算指纹。
归一化规则我常用这几条:
- 统一把全角标点转半角(中文里常见的问题)。
- 把所有连续空白字符(空格、换行、tab)压缩成单个空格。
- 去除字符串首尾的空格。
- 如果请求文本里有明显的动态参数,比如时间、随机 ID、用户名,先替换成固定占位符。
这里有个容易踩坑的点:对话模板里的动态参数千万要处理好。我见过一个项目,提示词模板里带了"今天是 {date},请回答用户问题",结果 date 每天变一次,精确缓存天天全量失效。正确的做法是在计算指纹之前,先把模板中的日期替换成统一的占位符,比如 {DATE},这样语义相同但日期不同的两条内容就不会被当成两条完全不同的请求。
归一化完成之后,用 sha256 生成一个 64 位十六进制字符串作为指纹,这就是精确缓存的 key。
3.3 缓存存储结构设计
我用 SQLite 建一张缓存表,字段设计如下:
CREATE TABLE IF NOT EXISTS cache_entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, request_fingerprint TEXT UNIQUE, request_text TEXT, request_embedding BLOB, response_text TEXT, model TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, last_hit_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, hit_count INTEGER DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_cache_model ON cache_entries(model);解释几个字段的目的。request_fingerprint 是精确匹配的 key,加了唯一约束,防止同一条请求反复插入。request_text 存原始文本,用于调试和语义可视化。request_embedding 是请求文本的向量,以 BLOB 二进制格式存储,用来做语义匹配。response_text 是 OpenRouter 返回的完整响应文本。model 记录用的是哪个模型,因为同一个问题不同模型回答质量有差异,缓存按模型维度隔离更合理。last_hit_at 和 hit_count 是为了后续做热度和淘汰策略。
有人会问为什么不直接用 JSON 文件存,或者直接用内存字典。原因很简单:SQLite 支持条件查询、索引、持久化,还能方便地统计命中率,这些是内存字典给不了的。查询时把 request_embedding 从 BLOB 读出来,用 numpy 的 frombuffer 还原成向量数组,计算量在几千条记录的场景下完全可以忽略。
3.4 命中判断与相似度阈值
命中判断是整个缓存系统的核心流程。完整的判断逻辑是:
- 对用户输入做归一化,计算请求指纹,先去精确缓存表查一遍。
- 如果精确命中且未过期,直接返回缓存结果。
- 如果精确没命中,对请求文本做嵌入,生成向量。
- 遍历同 model 下所有缓存记录的向量,计算余弦相似度。
- 取相似度最高的一条,如果它超过设定阈值,判定为语义命中。
- 语义命中后更新 last_hit_at 和 hit_count。
余弦相似度计算用 numpy 可以写得很简洁:
import numpy as np def cosine_similarity(vec_a: np.ndarray, vec_b: np.ndarray) -> float: dot = np.dot(vec_a, vec_b) norm_a = np.linalg.norm(vec_a) norm_b = np.linalg.norm(vec_b) if norm_a == 0.0 or norm_b == 0.0: return 0.0 return float(dot / (norm_a * norm_b))阈值调参我提供一个接地气的方法:从线上日志里抽样 200 条真实用户请求,两两组合后人工标注"语义相同"和"语义不同"两类。然后写个脚本,在 0.85 到 0.98 之间每隔 0.005 扫一遍,分别计算精确率和召回率。最终取加权 F1 最高的那个点。实际经验是,FAQ 类场景阈值集中在 0.93 到 0.96 区间,低于 0.93 误判会明显增多,高于 0.96 大量相似表达又会被漏掉。
对了,这里要特别提醒一下:阈值会受到嵌入模型影响。不同嵌入模型的向量分布不一样,A 模型上表现很好的 0.95,换到 B 模型可能就偏松了。所以每次切换嵌入模型后,都要重新标定阈值,不能图省事沿用旧值。
3.5 缓存回写与淘汰策略
什么时候把一条结果写入缓存,这不是无脑"请求成功就写"。我定了三条硬性规则:
- 只有 OpenRouter 返回 HTTP 200 且内容完整的响应才允许写缓存。流式响应必须等完整接收后再写,否则缓存里存的是半截文本。
- 请求内容里如果包含用户隐私字段、密码、身份证号等敏感信息,直接跳过缓存,不做任何存储。
- 模型参数带有随机性(比如 temperature 设置为 1.0 的创意生成任务)时不建议缓存,否则用户拿到的永远是同一个答案,体验反而变差。
淘汰策略我采用的是"LRU 近似 + 容量上限"组合。SQLite 里没有现成的 LRU,但我可以用 hit_count 和 last_hit_at 模拟:
DELETE FROM cache_entries WHERE id IN ( SELECT id FROM cache_entries ORDER BY CASE WHEN hit_count = 0 THEN 1 ELSE 0 END DESC, last_hit_at ASC LIMIT 500 );这条 SQL 会优先删除那些从未命中过、且最近没有被使用的记录。每次删除 500 条,避免单次删除过多导致锁表时间过长。容量上限我建议根据业务量设定,初期定为 5 万条,每天跑一个定时任务做清理。
3.6 完整代码实现与调用流程
把上面的逻辑串起来,我写了一个精简版的缓存客户端,完整结构如下:
import hashlib import os import re import sqlite3 import time import numpy as np import requests from sentence_transformers import SentenceTransformer class OpenRouterCachedClient: def __init__(self, api_key: str, db_path: str = "cache.db", threshold: float = 0.94): self.api_key = api_key self.api_url = "https://openrouter.ai/api/v1/chat/completions" self.threshold = threshold self.model_name = "your-default-model" self.embedder = SentenceTransformer("your-embedding-model") self.conn = sqlite3.connect(db_path, check_same_thread=False) self._init_db() def _init_db(self): self.conn.execute(""" CREATE TABLE IF NOT EXISTS cache_entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, request_fingerprint TEXT UNIQUE, request_text TEXT, request_embedding BLOB, response_text TEXT, model TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, last_hit_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, hit_count INTEGER DEFAULT 0 ) """) self.conn.commit() def _normalize_text(self, text: str) -> str: text = text.replace(",", ",").replace("。", ".").replace("?", "?") text = re.sub(r"\s+", " ", text).strip() text = re.sub(r"今天是 \{?\w+\}?", "", text) return text def _fingerprint(self, text: str) -> str: normalized = self._normalize_text(text) return hashlib.sha256(normalized.encode("utf-8")).hexdigest() def _embed_text(self, text: str) -> np.ndarray: normalized = self._normalize_text(text) return self.embedder.encode(normalized) def _cosine_similarity(self, vec_a: np.ndarray, vec_b: np.ndarray) -> float: dot = np.dot(vec_a, vec_b) norm_a = np.linalg.norm(vec_a) norm_b = np.linalg.norm(vec_b) if norm_a == 0.0 or norm_b == 0.0: return 0.0 return float(dot / (norm_a * norm_b)) def get_cached_response(self, prompt: str) -> dict | None: fp = self._fingerprint(prompt) cursor = self.conn.execute( "SELECT response_text, last_hit_at, hit_count FROM cache_entries WHERE request_fingerprint = ?", (fp,), ) row = cursor.fetchone() if row: return {"response": row[0], "source": "exact_cache"} embed = self._embed_text(prompt) cursor = self.conn.execute( "SELECT id, request_embedding, response_text FROM cache_entries WHERE model = ?", (self.model_name,), ) candidates = [] for cid, blob, response_text in cursor.fetchall(): vec = np.frombuffer(blob, dtype=np.float32) sim = self._cosine_similarity(embed, vec) candidates.append((sim, cid, response_text)) if not candidates: return None best_sim, best_cid, best_response = max(candidates, key=lambda x: x[0]) if best_sim >= self.threshold: self.conn.execute( "UPDATE cache_entries SET hit_count = hit_count + 1, last_hit_at = ? WHERE id = ?", (time.time(), best_cid), ) self.conn.commit() return {"response": best_response, "source": "semantic_cache", "similarity": best_sim} return None def set_cached_response(self, prompt: str, response: str) -> None: fp = self._fingerprint(prompt) embed = self._embed_text(prompt) vector_bytes = embed.astype(np.float32).tobytes() self.conn.execute( "INSERT OR REPLACE INTO cache_entries (request_fingerprint, request_text, request_embedding, response_text, model) VALUES (?, ?, ?, ?, ?)", (fp, prompt, vector_bytes, response, self.model_name), ) self.conn.commit() def chat(self, messages: list) -> dict: prompt = messages[-1]["content"] cached = self.get_cached_response(prompt) if cached: return cached headers = { "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json", } payload = { "model": self.model_name, "messages": messages, "temperature": 0.2, } resp = requests.post(self.api_url, headers=headers, json=payload, timeout=60) resp.raise_for_status() result = resp.json() content = result["choices"][0]["message"]["content"] self.set_cached_response(prompt, content) return {"response": content, "source": "openrouter"}调用示例很简单:
client = OpenRouterCachedClient(api_key="your_key") res = client.chat([{"role": "user", "content": "你们的退款政策是什么?"}]) print(res)第一次调用会走 OpenRouter 请求,第二次用完全相同或相似措辞问,就会从缓存返回,首字响应基本在 100 毫秒以内。
这里还要多提一句并发问题。上面的代码在单线程下没问题,但如果你的服务是异步框架,同一个线程里可能同时写 SQLite,要注意加锁,或者改用连接池。我在生产环境中遇到过一次"cache.db is locked"报错,就是因为没有对写入做串行化。简单的做法是维护一个全局 threading.Lock,写入操作前先获取锁。
4. 常见问题与排查技巧实录
4.1 缓存命中率低下的原因分析
第一版缓存上线后,大部分人首先会面临的问题是:命中率怎么这么低?日志里全是 miss。按我的排查经验,原因通常出在三个方面。
第一个原因是提示词模板带了动态参数。日期、用户名、订单号这些一变,指纹就变了,精确缓存直接失效。这类问题的特征是日志里 request_text 高度相似,但 request_fingerprint 完全不同。修复方法就是在归一化阶段把动态部分替换成占位符。
第二个原因是语义阈值定得太高。很多人都想把阈值设到 0.97、0.98 来保证不出错,但代价就是大量相似问题被拒之门外。解决方法是按我前面说的抽样调参,用实际数据把阈值降到一个合理区间。
第三个原因是当前嵌入模型和你业务的领域文本分布不匹配。比如你要做的是售后客服,却选了一个在新闻语料上训练的嵌入模型,那它对你业务里的句子理解就可能不到位。换一个在对话语料上表现更好的模型,命中率会明显改善。
4.2 上下文关联请求造成缓存误命中的处理
对话场景里有一个很隐蔽的坑:用户说了"那这个呢",单独看这句话语义是残缺的,但如果你把整轮对话拼接起来,它其实是完整的。问题在于,我们不能把整段多轮对话塞进缓存 key,因为那会让同一个问题的不同上下文版本都生成不同指纹,缓存命中率暴跌。
我的做法是在消息层做判断:只对"自包含"的那条消息做缓存。怎么判断自包含?当消息里包含明确的疑问对象或业务关键词时,认为是自包含的。比如"你们的退款政策是什么"是自包含的,"那这个呢"不是。非自包含的消息直接跳过缓存查询,正常走 OpenRouter 请求。
实践上,我会在进入缓存层之前加一个轻量的关键词检查,发现消息里没有名词性主体时直接放行到模型请求。虽然会损失一部分缓存机会,但能避免非常尴尬的误命中,比如用户上一句问退款,下一句说"那这个呢",结果你给他返回了一条款式推荐,这种体验比慢几秒要糟糕得多。
4.3 缓存成本与收益如何估算
缓存做了之后到底值不值,要用数据说话。我建议在正式接入缓存之前,先拿到一周的原始请求日志做分析。统计口径有三个:
- 完全相似请求占比:归一化后请求文本完全相同。
- 语义相似请求占比:嵌入向量相似度超过 0.93。
- 平均单次请求成本:用 OpenRouter 的 token 计费明细估算。
收益公式很简单:
周节省成本 = 周总成本 × (完全相似占比 + 语义相似占比 × 0.8)乘以 0.8 是因为语义命中存在一定的阈值误差,实际不会全部命中。拿我那个模拟项目 X 的数据举例,周总成本 1000 美元,完全相似占比 30%,语义相似占比约 12%,那周节省成本大概是 1000 × (0.30 + 0.12 × 0.8) = 396 美元。扣掉嵌入模型的推理电费和开发成本,收益依然非常可观。
我整理过一个小型对比表供参考:
| 场景 | 请求量/天 | 重复率 | 月成本 | 加缓存后月成本 | 节省比例 |
|---|---|---|---|---|---|
| 内部 FAQ 工具 | 5000 | 40% | 1500 美元 | 900 美元 | 40% |
| 客服辅助系统 | 15000 | 25% | 5200 美元 | 3900 美元 | 25% |
| API 聚合层 | 50000 | 15% | 18000 美元 | 15300 美元 | 15% |
这里可以看出一个规律:请求越垂直、业务越固定,缓存收益越高。如果做的是一锤子买卖的创意写作,重复率很低,缓存帮助不大。
4.4 实践中的几个易踩坑点
最后分享几个我在实际调试中踩过的坑,都是文档里很难找到的细节。
第一个坑是流式响应缓存写半截。OpenRouter 默认支持流式返回,如果你用 SSE 方式接收数据,中间网络抖动只收到一半就断连,直接当成完整响应写入缓存,那后续所有同一问题的用户都会拿到被截断的答案。我的解决办法是在写缓存前先做一次完整性校验,比如检查 response 是否以预期的结束标记结尾,不完整一律丢弃。
第二个坑是把模型名混在缓存键里导致成本翻倍。有两种做法:一种是把 model 当作缓存键的一部分,这样同样的问题在不同模型下会存多份,看似浪费空间,实则合理,因为不同模型回答质量差异很大;另一种是完全不区分模型,结果 A 模型的高质量回答被 B 模型的低质量覆盖,体验波动加大。我建议明确选择第一种,并且存储时记录 model 字段。
第三个坑是隐私数据被缓存。如果业务里有用户提交的工单内容、账号信息,千万别走统一的缓存逻辑。我的做法是维护一个敏感字段匹配列表,命中敏感词就直接跳过缓存,宁可多花钱也不把用户隐私落在 SQLite 文件里,这是底线。
第四个坑是缓存服务本身挂了影响主链路。我在代码里给缓存读取包了一层 try-except,缓存查询任何异常都直接放行到 OpenRouter 请求。缓存的价值是锦上添花,绝不能因为缓存故障把核心请求链路搞挂。
这套方案落地之后,那个模拟项目 X 的月度成本从 4000 美元降到 2800 美元左右,首字响应时间从 4.8 秒降到约 300 毫秒。我个人最大的体会是,缓存不是写完就完事的,它需要持续观察命中率和误判率,每隔一段时间根据新的请求日志重新标定一次阈值。前期多花一点时间把请求指纹的归一化和敏感性判断做扎实,后面能省掉非常多排查精力。如果你正准备给 OpenRouter 数据通讯层加缓存,建议先跑一周日志做热力图分析,让数据告诉你重复请求在哪里,再动手写代码。