线上的 LLM 调用看多了,会发现请求高度重复:客服机器人翻来覆去就那几问,RAG 内部问答也扎堆。
按 token 计费的上游对着重复问题一遍遍生成,钱白烧,延迟也压不下来。
传统精确匹配缓存在自然语言面前几乎无命中——「怎么退货」和「退货流程是什么」字面不同、语义相同。
prompt-cache 这个 Go 项目的源码通读了一遍,架构、每个模块的做法、以及它没说透的风险,都记在这里。

定位

PromptCache 是一个自托管的语义缓存网关:单个 Go 二进制,横在应用和 LLM 提供商之间,
对外暴露 OpenAI 兼容的 /v1/chat/completions,SDK 把 base_url 一改就算接入,业务代码零改动。
它不做字面匹配,而是把 prompt 变成向量,在缓存里找语义最近的一条;判定命中就直接回放缓存响应,上游一次生成请求都不发。

PromptCache 组件架构PromptCache 组件架构:单进程、嵌入式存储,右侧上游只在未命中时才被触达

图是静态的,交互版在这里(可缩放、可播放调用轨迹、明暗主题)。
整个系统是单进程的:HTTP 层、语义引擎、向量索引、响应缓存、嵌入式存储全在一个地址空间里,
唯一的外部依赖就是 LLM 提供商本身(embedding、灰区仲裁、未命中生成,三种调用都打给它)。

双阈值判定

核心机制说起来很简单:一次相似度检索,卡两道阈值。
top-1 余弦相似度 ≥ high(默认 0.70)直接命中;< low(默认 0.30)判未命中;落在两者之间的「灰区」,再花一次小模型调用做语义仲裁。

一次请求的判定路径一次请求的判定路径:三个出口——直接命中、灰区仲裁后命中、转发上游并回写

交互版)判定主干在 SemanticEngine.FindSimilar 里,删掉度量代码后骨架就这十几行:

gosemantic.go
if bestSim >= se.HighThreshold {
    return bestKey, bestSim, nil // 直接命中
}
if bestSim < se.LowThreshold {
    return "", bestSim, nil // 干脆未命中
}
// 灰区:取出缓存条目的原始 prompt,交给小模型仲裁
originalPrompt, err := se.Store.GetPrompt(ctx, hashKey)
isMatch, err := verifier.CheckSimilarity(ctx, text, originalPrompt)
if isMatch {
    return bestKey, bestSim, nil
}
return "", bestSim, nil

为什么要两道阈值:单阈值调高会漏掉大量换了说法的同义问题,调低又会把「北京天气」和「上海天气」这种高相似不同义的对子误判成命中。
灰区仲裁用的是提供商最便宜的模型(gpt-4o-mini / mistral-small / claude-3-haiku),system prompt 只让它回答 YES 或 NO——
拿一次廉价调用换掉一次昂贵生成,同时把误命中率压下去。这个权衡是整个项目里最值得借鉴的设计。

模块与实现

HTTP 入口与中间件

gin 起服务,中间件洋葱从外到内是 Recovery、RequestID、Logger、Metrics、RequestSizeLimit(默认 1MB 请求体上限)。
RequestID 层顺手把 X-Cache-Namespace 头哈希成分区 ID 塞进 context,后面所有存取都按它加前缀——这是多租户分区的全部入口。
推理端点从请求里只取最后一条 user 消息作为判定和缓存的依据,这个决定后面风险一节还要再说。
响应带 X-Cache: HIT/MISSX-Similarity-ScoreX-Provider 几个头,观测命中情况不用翻日志。

语义引擎

SemanticEngine 聚合了 embedding、检索、仲裁、转发四件事,阈值和开关用 RWMutex 保护,
所以 /v1/config 的 PATCH 能在不重启的情况下热更 high/low 阈值(校验 0 <= low < high <= 1)。
提供商也能热切换:POST /v1/config/provider 直接换掉引擎里的 Provider 实现。

向量索引

internal/ann 名字叫 ANN,实现其实是精确的暴力扫描:全部向量驻内存 map,查询时逐条算余弦再排序。
单次 1536 维余弦在仓库自带 benchmark 里是 441ns、零分配;千条量级一次全扫不到半毫秒,对单机缓存的常见规模够用,
作者也就没引 HNSW 之类的近似索引。代价在规模上限那头,风险一节再算这笔账。
索引不落盘,进程启动时从存储扫全部 emb: 键重建。

存储与键布局

持久层是嵌入式的 BadgerDB(LSM,纯 Go),一条缓存记录拆成三个键:

<sha256(prompt)>:完整的上游 JSON 响应,带 created_at 和 TTL 包一层
prompt:<hash>:原始 prompt 文本,灰区仲裁时取出来给小模型对照用
emb:<hash>:embedding 向量,float32 小端序列化,1536 维一条约 6KB

带命名空间的请求,三类键都插入 ns:<sha256(namespace)>: 段做物理隔离。

响应缓存治理

TTL 默认 24 小时,读到过期条目惰性删除,另有每小时一轮的后台清扫兜底;
容量到 10 万条上限时按访问序淘汰最久未用的。灰区仲裁依赖的 prompt: 键和向量键随响应一起写、一起删。

提供商适配

OpenAI、Mistral、Anthropic/Claude 三家实现同一个接口:EmbedCheckSimilarityForwardChatCompletion 加流式变体。
Claude 没有自家 embedding 接口,向量走 Voyage AI(voyage-3),所以选它要配两把 key。
出口 HTTP 统一走一个重试客户端:429/5xx 指数退避加抖动,最多 3 次,基础等待 500ms,封顶 30 秒。
流式是两头都支持的:未命中时把上游 SSE 边转发边缓冲,收满再落缓存;命中时反过来,把缓存的完整 JSON 合成三段 OpenAI 兼容的 SSE chunk 吐给客户端——调用方感知不到自己拿的是缓存。

管理面

/metrics(Prometheus 文本格式)、/v1/stats/v1/config/v1/cache(列出/清空/删单条)、/v1/cache/warm(批量预热)
全部挂在 Bearer token 后面,比较用 crypto/subtle 常量时间实现。API_AUTH_TOKEN 不设则管理面裸奔,启动时只打一行警告——生产环境务必设上。
预热接口值得一提:导入历史「问答对」时同步算 embedding 入索引,embedding 失败会回滚已写的响应,不留半截数据。

接入

bash
export EMBEDDING_PROVIDER=openai
export OPENAI_API_KEY=your-key
export API_AUTH_TOKEN=your-secret
docker-compose up -d

客户端只改一行:

python
client = OpenAI(base_url="http://localhost:8080/v1", api_key="your-key")

第一次问「Explain quantum physics」走上游,换个说法再问,看响应头里的 X-Cache: HIT 和相似分就知道生效了。

能力

语义命中:双阈值 + 可选灰区小模型仲裁,阈值运行时可调
OpenAI 兼容:非流式与 SSE 流式都支持,命中时合成流式响应
三家提供商:OpenAI / Mistral / Claude(+Voyage),运行时热切换
持久化:BadgerDB 嵌入式存储,重启不丢缓存,索引自动重建
缓存治理:TTL、容量淘汰、单条删除、整体清空、批量预热
观测:Prometheus 指标、结构化日志、请求级 X-Cache 头
分区:X-Cache-Namespace 头做哈希前缀隔离(注意事项见下节)

边界与风险

这类工具的风险比收益更值得写清楚,逐条数:

语义相似不是语义等价。 余弦分数是概率信号,0.70 的阈值挡不住所有高相似不同义的对子,灰区仲裁也只是降险不是保险。
对答案正确性敏感的场景(数值、时效、法务),要么把 high 阈值调到 0.9 以上,要么干脆别在这条链路上做缓存。

判定和缓存键都只看「最后一条 user 消息」:system prompt、多轮历史、modeltemperature 全都不参与。
这意味着同一个问题带不同上下文会串答案,请求 gpt-4o 的调用方可能拿到 gpt-4o-mini 生成的缓存——
部署前提基本上是「单应用、单模型、单轮问答」,README 没有把这条约束讲透,读源码才看得出来。

它不是授权边界。 缓存进程级共享,X-Cache-Namespace 依赖调用方自觉带头,网关自己不做认证映射;
项目文档也明说:多租户数据不能靠语义匹配隔离,要么分部署,要么在它前面加一层可信的分区代理。
另外显式命名空间会退化成过滤后的线性扫描(ANN 索引只覆盖默认分区),分区越多退化越明显。

延迟有下限:命中也要先付一次 embedding 网络调用(几十到一两百毫秒),省掉的是生成时间和 token 费,不是全部往返。
灰区再多付一次小模型调用。命中率低的负载装上它反而变慢,先测重复率再决定。

暴力扫描是 O(n·d):10 万条 1536 维就是每查约 1.5 亿次乘加,几十毫秒的 CPU 尖峰,容量上限不是摆设。

读源码还撞见一处实打实的簿记缺陷:访问序列表在中段删除后不回写其余键的下标缓存,
之后按下标删除会错删别的键——量小无感,量大时淘汰谁基本靠缘分。工程成熟度大概就在这个位置,选型时心里要有数。

哪天真要部署,第一件事大概是先把那个下标簿记修掉,第二件是把 model 名拼进缓存键——顺手的事,就当过路费了。