
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/langfuseLangfuse 作为开源 AI 工程平台其 Prompt 管理功能支持多版本、多标签label与跨 Prompt 依赖解析。为了在保证数据强一致的前提下降低 Postgres 读压力Langfuse 在PromptService中实现了一套基于 Redis 的缓存机制读取走缓存、写入直接失效、以epoch 命名空间轮换代替逐条删除。本文以仓库中的 prompts 模块缓存策略文档 为骨架结合PromptService源码与createPrompt调用链完整还原该缓存的设计原理、读写路径、失效策略与可观测性配置。一、缓存机制概览为什么需要为 Prompt 设计缓存Prompt 是 Langfuse 中被高频读取的实体LLM 应用每次调用都需要按projectId promptName version/label拉取 Prompt 内容同时 Prompt 又支持多版本、多标签且一个 Prompt 可以通过langfusePrompt:namexxx|versionxxx之类的依赖标签引用其他 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——每个version或label对应一个独立条目一个 Prompt 挂多个 label 时会在缓存中重复出现多次——因为每个 label 都是独立的可寻址入口。2.1 源码中的实际 Key 生成逻辑在 PromptService 的 getCacheKey / getCacheKeyPrefix 实现 中实际的 Key 结构比文档示例多了一个 epoch 段prompt:${projectId}:${epoch}:${promptName}:${selector}其中epoch是项目级命名空间令牌详见下文第四节selector由getCacheKey中的这段逻辑决定源码// 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二选一的设计version与label不会同时作为 Key 的一部分。2.2 PromptParams 的类型约束PromptParamstypes.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 名称的全部缓存条目整体失效。文档同时描述了写入侧的经典流程在 Redis 中获取锁失效缓存在 Postgres 中执行操作释放锁。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_id、name、version三列抛出LangfuseConflictError(A prompt version was created concurrently. Please retry.)源码避免并发创建导致版本错乱。此外invalidateCache失败不会回滚已提交的事务——源码中将其包在 try/catch 里只记录错误日志源码注释明确说明side-effect failures must not report the persisted prompt as failed。这是一个典型的缓存失效是尽力而为的设计取舍缓存最坏情况是多存一段时间旧值受 TTL 约束而不会造成数据丢失。同样的失效调用也出现在duplicatePrompt与duplicateFolder中源码 与 L677-L679。3.2 失效的粒度按项目还是按 Prompt从invalidateCache的签名看它只接收projectId源码public async invalidateCache( params: PickPromptParams, projectId, ): Promisevoid { 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.即读取时无锁且缓存命中 → 直接返回缓存并重置该条目的 TTL以保证其驻留有锁或缓存未命中 → 回源 Postgres并把结果写回缓存。4.1 getPrompt 的完整流程PromptService.getPrompt 的源码实现了这一缓存优先的读取模型public async getPrompt(params: PromptParams): PromisePromptResult | 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按version或labels has label查询见 findPrompt 实现再resolvePrompt递归解析依赖图随后cachePrompt以EX 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): Promisestring | 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; }其工作原理可以概括为三步每个项目在 Redis 中持有一个prompt_cache_epoch:projectId键值为 8 字符的随机令牌48 bit 熵TTL 为 7 天epochTtlSeconds 7 * 24 * 60 * 60见 index.ts#L26-L28所有缓存 Key 都带有当前 epoch 令牌prompt:projectId:epoch:promptName:version|label:...失效缓存时仅更新 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_ENABLEDtrue/falsetrue是否启用 Prompt 缓存LANGFUSE_CACHE_PROMPT_TTL_SECONDS数字36001 小时缓存条目的过期时间秒在 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.tsexport enum PromptServiceMetrics { PromptCacheHit prompt_cache_hit, PromptCacheMiss prompt_cache_miss, }每次getPrompt无论命中与否都会通过incrementMetric计数index.ts#L56-L61metricIncrementer通过构造函数注入。在 getPromptByName.ts 中可以看到实际注入的是recordIncrement即读取路径Web 端按名称读取 Prompt 的 action会持续上报prompt_cache_hit/prompt_cache_miss两个指标。你可以据此在监控面板上计算缓存命中率hit / (hit miss)并评估是否需要调整 TTL 或检查失效是否过于频繁。八、读取入口getPromptByName 如何组装参数getPromptByName.ts 是缓存读取的典型入口展示了参数组装规则同时传入version和label会直接抛出InvalidRequestError(Cannot specify both version and label)只传version→ 按版本号寻址只传label→ 按标签寻址两者都不传 → 默认按PRODUCTION_LABEL寻址即生产标签等价于读取当前生产环境使用的 Prompt 版本。每次调用都会新建PromptService(prisma, redis, recordIncrement)实例因此缓存开关、TTL 与指标上报逻辑在 Web 与 Worker 的所有读取路径上保持一致。九、边界与一致性保证小结综合原文档与源码这套缓存策略的一致性保证可以归纳为读多写少场景下的强一致写入路径createPrompt、duplicatePrompt、duplicateFolder在 Postgres 事务提交后立即按项目粒度失效缓存任何随后到来的读取都会落入新 epoch 命名空间而回源并发读不阻塞读取路径完全无锁——未命中时直接回源并回填多个并发未命中只会产生重复的 DB 查询不会互相阻塞旧数据有界过期即便失效操作失败如 Redis 短暂不可用旧缓存条目也会在LANGFUSE_CACHE_PROMPT_TTL_SECONDS默认 1 小时内自然过期不会无限期返回陈旧数据依赖解析一致epoch 按项目而非按 Prompt 粒度轮换正是为了覆盖修改被依赖 Prompt 会波及所有引用方的传递依赖场景类型约束前置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),仅供参考