news 2026/9/10 9:07:00

Langfuse Prompt 缓存策略深度解析:基于 Redis 的版本化 Prompt 缓存与失效机制

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Langfuse Prompt 缓存策略深度解析:基于 Redis 的版本化 Prompt 缓存与失效机制

Langfuse Prompt 缓存策略深度解析:基于 Redis 的版本化 Prompt 缓存与失效机制

【免费下载链接】langfuse🪢 Open source AI engineering platform: LLM evals, observability, metrics, prompt management, playground, datasets. Integrates with OpenTelemetry, LangChain, OpenAI SDK, LiteLLM, and more. 🍊YC W23项目地址: https://gitcode.com/GitHub_Trending/la/langfuse

Langfuse 作为开源 AI 工程平台,其 Prompt 管理功能支持多版本、多标签(label)与跨 Prompt 依赖解析。为了在保证数据强一致的前提下降低 Postgres 读压力,Langfuse 在PromptService中实现了一套基于 Redis 的缓存机制:读取走缓存、写入直接失效、以"epoch 命名空间轮换"代替逐条删除。本文以仓库中的 prompts 模块缓存策略文档 为骨架,结合PromptService源码与createPrompt调用链,完整还原该缓存的设计原理、读写路径、失效策略与可观测性配置。

一、缓存机制概览:为什么需要为 Prompt 设计缓存

Prompt 是 Langfuse 中被高频读取的实体:LLM 应用每次调用都需要按projectId + promptName + version/label拉取 Prompt 内容;同时 Prompt 又支持多版本、多标签,且一个 Prompt 可以通过@@@langfusePrompt:name=xxx|version=xxx@@@之类的依赖标签引用其他 Prompt(即跨 Prompt 依赖解析)。这意味着一次"读 Prompt"最终可能要经过依赖图的递归解析,代价远高于一次简单的 Postgres 单行查询。

缓存策略正是为这一场景设计的:用 Redis 承载解析后的 Prompt 结果,让绝大多数读取命中缓存;而写入侧则采用"绝不更新缓存条目,而是整体失效"的策略,保证缓存与数据库永远一致。

缓存实现核心位于 packages/shared/src/server/services/PromptService/index.ts 中的PromptService类,并在 web/src/features/prompts/server/actions/createPrompt.ts 的createPrompt函数中投入使用。本文所引源码行号均以此两文件为准。

二、缓存结构:Redis Key 的组成与粒度

根据原文档,缓存使用 Redis 管理,Key 形如:

prompt:<project-id>:<prompt-name>:<prompt-<version ?? label>>

这意味着:

  • 同一个 Prompt 名称在 Redis 中会有多个 Key——每个versionlabel对应一个独立条目;
  • 一个 Prompt 挂多个 label 时,会在缓存中重复出现多次——因为每个 label 都是独立的可寻址入口。

2.1 源码中的实际 Key 生成逻辑

在 PromptService 的 getCacheKey / getCacheKeyPrefix 实现 中,实际的 Key 结构比文档示例多了一个 epoch 段:

prompt:${projectId}:${epoch}:${promptName}:${selector}

其中:

  • epoch是项目级命名空间令牌(详见下文第四节);
  • selectorgetCacheKey中的这段逻辑决定(源码):
// Numeric labels must not share a cache entry with the same prompt version. const selector = typeof params.version === "number" ? `version:${params.version}` : `label:${params.label}`;

注释明确指出:数字形式的 label 必须与同名同版本的缓存条目区分开,避免"数字 label"与"版本号"在 Key 上发生碰撞。这也印证了原文档中<prompt-<version ?? label>>二选一的设计:versionlabel不会同时作为 Key 的一部分。

2.2 PromptParams 的类型约束

PromptParams(types.ts)通过 TypeScript 联合类型强制保证这一点:

export type PromptParams = { projectId: string; promptName: string; resolve?: boolean; } & ( | { version: number; label: undefined } | { version: null | undefined; label: string } );

也就是说,一个读取请求要么按版本号(number)寻址,要么按标签(string)寻址,二者不可兼得,从类型层面杜绝了歧义 Key 的产生。

三、写入路径:创建与更新时绝不更新缓存

原文档给出了一条非常关键的设计原则:

We never update prompts in the cache. Instead, we remove all cache entries for a prompt name of a project when a prompt is updated.

即:缓存条目从不被"原地更新",而是创建/更新 Prompt 时把该项目下该 Prompt 名称的全部缓存条目整体失效。文档同时描述了写入侧的经典流程:

  1. 在 Redis 中获取锁;
  2. 失效缓存;
  3. 在 Postgres 中执行操作;
  4. 释放锁。

3.1 createPrompt 中的实际调用链

在 createPrompt.ts 中,PromptService的接入方式如下:

const promptService = new PromptService(prisma, redis); const promptDependencies = parsePromptDependencyTags(prompt); // 1. 写库前先校验依赖图(循环依赖、嵌套深度等) await promptService.buildAndResolvePromptGraph({ projectId, parentPrompt: { id: newPromptId, prompt, version: ..., name, labels }, dependencies: promptDependencies, }); // 2. 所有 Postgres 写操作打包进一个事务 transactionResult = (await prisma.$transaction(create)) as [...]; // 3. 事务提交成功后失效缓存 await promptService.invalidateCache({ projectId });

对照原文档描述的"锁 → 失效 → 写库 → 释放锁"四步,可以推断当前实现的对应关系为:

  • "锁"的角色由 Postgres 事务承担prisma.$transaction保证创建 Prompt、写依赖关系、迁移标签等操作原子提交,事务内部的其他读取不会看到中间态;
  • "失效缓存"发生在事务提交之后invalidateCache),即数据库先成为新的事实来源,再让缓存失效,保证"先更新 DB、后失效缓存"的顺序;
  • 并发写冲突由数据库唯一约束兜底createPrompt中通过isPromptVersionConflict捕获 PrismaP2002唯一约束冲突(project_idnameversion三列),抛出LangfuseConflictError("A prompt version was created concurrently. Please retry.")(源码),避免并发创建导致版本错乱。

此外,invalidateCache失败不会回滚已提交的事务——源码中将其包在 try/catch 里只记录错误日志(源码),注释明确说明"side-effect failures must not report the persisted prompt as failed"。这是一个典型的"缓存失效是尽力而为"的设计取舍:缓存最坏情况是多存一段时间旧值(受 TTL 约束),而不会造成数据丢失。

同样的失效调用也出现在duplicatePromptduplicateFolder中(源码 与 L677-L679)。

3.2 失效的粒度:按项目还是按 Prompt?

invalidateCache的签名看,它只接收projectId(源码):

public async invalidateCache( params: Pick<PromptParams, "projectId">, ): Promise<void> { if (!this.cacheEnabled) return; // Rotate the epoch token to move all prompt reads/writes to a fresh namespace. // Old keys remain untouched and naturally expire via TTL. await this.redis?.set( this.getEpochKey(params), this.newEpochToken(), "EX", this.epochTtlSeconds, ); }

失效操作以项目为粒度。原因在 getEpochKey 的注释 中写得很清楚:

Important: epoch is project-scoped (not prompt-scoped) because resolved prompts can include transitive dependencies across multiple prompt names.

也就是说,一个 Prompt 的解析结果可能内含其他 Prompt 的内容(依赖解析)。修改任何一个被依赖的 Prompt,都会影响所有依赖它的 Prompt 的缓存值,因此必须对整个项目的缓存做命名空间级失效,而不是只删某个 Prompt 名下的 Key。

四、读取路径:缓存优先 + TTL 续期

原文档对读取路径的描述是:

When reading prompts, we check whether a lock exists. If it does not, we proceed to read the prompt from the cache. Thereby, we reset the ttl of the cache entry to ensure it remains in the cache. If the lock exists, or the entry is not in Redis, we read the prompt from Postgres and store it in the cache.

即读取时:

  1. 无锁且缓存命中 → 直接返回缓存,并重置该条目的 TTL以保证其驻留;
  2. 有锁或缓存未命中 → 回源 Postgres,并把结果写回缓存。

4.1 getPrompt 的完整流程

PromptService.getPrompt 的源码实现了这一"缓存优先"的读取模型:

public async getPrompt(params: PromptParams): Promise<PromptResult | null> { if (params.resolve === false) { return this.getRawPrompt(params); // 不解析依赖,直接走 DB } if (this.cacheEnabled) { const cachedPrompt = await this.getCachedPrompt(params); this.incrementMetric( cachedPrompt ? PromptServiceMetrics.PromptCacheHit : PromptServiceMetrics.PromptCacheMiss, ); if (cachedPrompt) { return cachedPrompt; } } const dbPrompt = await this.getDbPrompt(params); // findPrompt + resolvePrompt if (this.cacheEnabled && dbPrompt) { await this.cachePrompt({ ...params, prompt: dbPrompt }); } return dbPrompt; }

要点解读:

  • 命中即返回:命中缓存后不再触碰 Postgres,直接返回解析完成的PromptResult
  • 未命中则回源并回填getDbPrompt内部先findPrompt(按versionlabels has label查询,见 findPrompt 实现),再resolvePrompt递归解析依赖图,随后cachePromptEX this.ttlSeconds写回 Redis(源码);
  • resolve === false时完全不缓存:读取"未解析"的原始 Prompt 时走getRawPrompt,直接查库且不经缓存层——因为未解析结果不含依赖图,缓存收益低,且避免与已解析结果混存。

4.2 关于"重置 TTL"的说明

需要客观指出的是:原文档中"读取命中时重置 TTL"的描述,与当前源码的getCachedPrompt实现存在细微差异——当前实现 仅执行this.redis?.get(key),并未显式调用touch/expire续期。可以推断 TTL 续期在当前版本中主要由"每次写入都重新设置EX"来承担,即每次回源写回都会刷新 TTL。如果你的部署依赖"热点条目长期驻留缓存",应以当前仓库源码(index.ts)为准进行验证。

五、失效策略深挖:epoch 命名空间轮换

这是PromptService缓存设计中最值得展开的实现细节。invalidateCache并没有去 Redis 里枚举并删除prompt:*前缀的 Key,而是采用**"轮换命名空间令牌(epoch token)"**的策略(源码):

private newEpochToken(): string { // 48 bits of entropy in a compact URL-safe string (8 chars). return randomBytes(6).toString("base64url"); } private async getOrCreateEpoch(params): Promise<string | null> { const epochKey = this.getEpochKey(params); // `prompt_cache_epoch:${projectId}` const currentEpoch = await this.redis?.get(epochKey); if (currentEpoch) return currentEpoch; const newEpoch = this.newEpochToken(); await this.redis?.set(epochKey, newEpoch, "EX", this.epochTtlSeconds, "NX"); // Return the winner value in case multiple requests initialize concurrently. return (await this.redis?.get(epochKey)) ?? newEpoch; }

其工作原理可以概括为三步:

  1. 每个项目在 Redis 中持有一个prompt_cache_epoch:<projectId>键,值为 8 字符的随机令牌(48 bit 熵),TTL 为 7 天(epochTtlSeconds = 7 * 24 * 60 * 60,见 index.ts#L26-L28);
  2. 所有缓存 Key 都带有当前 epoch 令牌:prompt:<projectId>:<epoch>:<promptName>:<version|label:...>
  3. 失效缓存时,仅更新 epoch 令牌本身set一个新随机值)。此后所有新读写都落到新的命名空间;旧 Key 无人引用,依赖自身的 TTL 自然过期清除。

这一设计的优势在于:

  • 失效成本 O(1):不需要KEYS prompt:*扫描或批量删除,避免了大项目下清空缓存的高开销与 Redis 阻塞风险;
  • 并发安全getOrCreateEpoch使用SET ... NX保证多实例同时初始化时只有一个令牌生效,注释明确说明"Return the winner value in case multiple requests initialize concurrently";
  • 自然收敛:旧命名空间的数据在 TTL 到期后被 Redis 自动回收,无需额外清理任务。

六、配置项:缓存开关与 TTL

缓存行为由两个环境变量控制,定义在 packages/shared/src/env.ts:

环境变量类型默认值说明
LANGFUSE_CACHE_PROMPT_ENABLED"true"/"false""true"是否启用 Prompt 缓存
LANGFUSE_CACHE_PROMPT_TTL_SECONDS数字3600(1 小时)缓存条目的过期时间(秒)

在 PromptService 构造函数 中,缓存是否启用由两个条件共同决定:

this.cacheEnabled = Boolean(redis) && env.LANGFUSE_CACHE_PROMPT_ENABLED === "true"; this.ttlSeconds = env.LANGFUSE_CACHE_PROMPT_TTL_SECONDS;

即:

  • 必须配置了 Redis(构造函数传入的redis实例非空);
  • 环境变量显式开启(默认即为开启)。

两个条件缺一不可。注意cacheEnabled还预留了测试注入入口(构造参数cacheEnabled?: boolean,注释标注 "used for testing"),方便单元测试直接控制缓存开关而不依赖环境变量。

七、可观测性:缓存命中/未命中指标

PromptService内置了两个 OTel 指标,定义在 types.ts:

export enum PromptServiceMetrics { PromptCacheHit = "prompt_cache_hit", PromptCacheMiss = "prompt_cache_miss", }

每次getPrompt无论命中与否都会通过incrementMetric计数(index.ts#L56-L61),metricIncrementer通过构造函数注入。在 getPromptByName.ts 中可以看到实际注入的是recordIncrement,即读取路径(Web 端按名称读取 Prompt 的 action)会持续上报prompt_cache_hit/prompt_cache_miss两个指标。你可以据此在监控面板上计算缓存命中率(hit / (hit + miss)),并评估是否需要调整 TTL 或检查失效是否过于频繁。

八、读取入口:getPromptByName 如何组装参数

getPromptByName.ts 是缓存读取的典型入口,展示了参数组装规则:

  • 同时传入versionlabel会直接抛出InvalidRequestError("Cannot specify both version and label")
  • 只传version→ 按版本号寻址;
  • 只传label→ 按标签寻址;
  • 两者都不传 → 默认按PRODUCTION_LABEL寻址(即"生产"标签,等价于读取当前生产环境使用的 Prompt 版本)。

每次调用都会新建PromptService(prisma, redis, recordIncrement)实例,因此缓存开关、TTL 与指标上报逻辑在 Web 与 Worker 的所有读取路径上保持一致。

九、边界与一致性保证小结

综合原文档与源码,这套缓存策略的一致性保证可以归纳为:

  1. 读多写少场景下的强一致:写入路径(createPromptduplicatePromptduplicateFolder)在 Postgres 事务提交后立即按项目粒度失效缓存,任何随后到来的读取都会落入新 epoch 命名空间而回源;
  2. 并发读不阻塞:读取路径完全无锁——未命中时直接回源并回填,多个并发未命中只会产生重复的 DB 查询,不会互相阻塞;
  3. 旧数据有界过期:即便失效操作失败(如 Redis 短暂不可用),旧缓存条目也会在LANGFUSE_CACHE_PROMPT_TTL_SECONDS(默认 1 小时)内自然过期,不会无限期返回陈旧数据;
  4. 依赖解析一致:epoch 按项目而非按 Prompt 粒度轮换,正是为了覆盖"修改被依赖 Prompt 会波及所有引用方"的传递依赖场景;
  5. 类型约束前置version/label二选一的类型联合与"数字 label 独立 Key"的 selector 逻辑,从源头防止了缓存 Key 歧义。

如果你正在为 Langfuse 做二次开发或自托管调优,建议从 PromptService 源码 出发,结合 createPrompt 写入链、getPromptByName 读取链 以及 服务端环境变量定义 三处代码,即可完整掌握该缓存机制的全部行为边界。

【免费下载链接】langfuse🪢 Open source AI engineering platform: LLM evals, observability, metrics, prompt management, playground, datasets. Integrates with OpenTelemetry, LangChain, OpenAI SDK, LiteLLM, and more. 🍊YC W23项目地址: https://gitcode.com/GitHub_Trending/la/langfuse

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/10 9:06:53

深度神经网络的数学本质:函数逼近、流形学习与梯度几何

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/10 9:02:42

Vercel AI SDK 文件交付全攻略:从上传到验收的闭环实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/10 8:59:11

电机起动方式与降压控制详解:星三角、软起动与变频器

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/10 8:58:23

基于Python Django与Vue的前后端分离分类信息网站开发实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华