Langfuse Prompt Mutations:Prompts 服务端变更操作层的设计与源码解析
【免费下载链接】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 开源项目中,Prompts(提示词)功能涉及版本管理、标签(label)漂移、Redis 缓存、事件溯源与依赖图解析等复杂逻辑,直接调用 Prisma 很容易绕过这些约束、破坏数据一致性。本篇文章围绕web/src/features/prompts/server/actions/README.md定义的Prompt Mutations变更操作层,讲解其"禁止直接 Prisma 调用、统一走专用函数"的工程规则与五大设计动因,并结合createPrompt、updatePrompts、getPromptByName、getPromptsMeta等核心函数的真实源码,深入剖析缓存失效、事件溯源、标签管理、名称校验与事务安全的落地实现。读完本文,你将掌握 Langfuse Prompts 模块写入路径的完整调用链,并能直接对照源码理解其并发安全与一致性保障机制。
一、Prompt Mutations 是什么:一条"禁止直接 Prisma"的工程规则
web/src/features/prompts/server/actions/README.md是 Prompts 服务端写入逻辑的"行为守则",全文开宗明义地定义了一条硬性规则:
Always use functions in this directory. Never use direct Prisma calls.
即:凡是涉及 Prompt 的创建、更新、删除、读取等数据变更,都必须使用server/actions/目录下的封装函数,禁止在业务代码里直接调用 Prisma 操作prompts表。这条规则不是教条,而是因为每次 Prompt 变更都牵动多条一致性链条,只有收敛到单一入口才能统一处理。
从目录结构看,变更操作层实际包含以下文件(actions 目录):
| 文件 | 职责 |
|---|---|
createPrompt.ts | 创建新 Prompt 版本,同时实现duplicatePrompt、duplicateFolder |
updatePrompts.ts | 更新 Prompt 标签(label)/元数据 |
getPromptByName.ts | 按名称(可选 version/label)获取,走缓存 |
getPromptsMeta.ts | 分页列出项目内所有 Prompt 的元信息 |
deletePrompt.ts | 删除指定版本,含依赖保护与latest标签重挂 |
它们依赖同目录外的辅助模块:promptChangeEventSourcing.ts(事件溯源)、utils/updatePromptLabels.ts(标签迁移)、utils/updatePromptTags.ts(标签 tags 同步)、utils/checkHasProtectedLabels.ts、utils/authorizePromptRequest.ts(鉴权),以及共享包中的PromptService(缓存与依赖图解析)与校验 Schema。
二、五大设计动因:为什么每次变更都要"过一遍"函数层
README 明确列出了这些函数必须处理的问题,这正是"禁止直接 Prisma"的根本原因:
- Cache invalidation(缓存失效):每次 Prompt 变更后必须使 Redis 缓存失效,否则客户端会读到旧版本。
- Event sourcing(事件溯源):变更要通过
promptChangeEventSourcing()记录为事件,供审计与 analytics(如 Webhook)使用。 - Label management(标签管理):需要把
production、latest等标签在版本间搬移——同一标签在同一名称下必须唯一。 - Validation(校验):Prompt 名称校验、变量提取、依赖解析等。
- Transaction safety(事务安全):多个关联数据库操作(新增版本、写依赖、迁移标签、同步 tags)必须按正确顺序放入同一事务。
下面逐一结合源码展开。
2.1 缓存失效:以"缓存纪元轮换"实现全项目级失效
所有变更函数在事务提交后都会调用promptService.invalidateCache({ projectId }),例如createPrompt在事务成功后显式执行:
await promptService.invalidateCache({ projectId });值得注意的设计细节是deletePrompt.ts中的注释:
Rotate cache epoch only after successful commit.
即只在事务成功提交后才轮换缓存纪元(cache epoch)。这种"先落库、后失效"的顺序保证了:即使缓存失效操作本身失败(此时仅打日志、不抛错),数据库也已持久化,不会出现"缓存清了但数据没写进去"的脏状态。读取路径getPromptByName则通过PromptService(prisma, redis, recordIncrement)走缓存查询,形成"写路径失效、读路径命中"的完整闭环。
2.2 事件溯源:异步队列化,失败不阻断主流程
promptChangeEventSourcing.ts把每次变更包装成EntityChangeJob事件推入EntityChangeQueue(源码):
const event = { timestamp: new Date(), id: v4(), name: QueueJobs.EntityChangeJob, payload: { entityType: "prompt-version", projectId: promptData.projectId, promptId: promptData.id, action, // "created" | "updated" | ... prompt: { ...promptData, prompt: jsonSchemaNullable.parse(...), config: jsonSchemaNullable.parse(...) }, ...(user ? { user } : {}), }, }; await EntityChangeQueue.getInstance()?.add(QueueName.EntityChangeQueue, event);调用方使用Promise.allSettled批量发布事件,并且对单个事件失败只记 error 日志、不抛出——例如createPrompt中:
const eventResults = await Promise.allSettled(eventPromises); for (const result of eventResults) { if (result.status === "rejected") { logger.error(`Failed to publish prompt change event ...`, result.reason); } }注释给出了明确意图:一旦事务提交成功,副作用(副作用如 Webhook 事件)失败不能把已持久化的 Prompt 报为失败,否则调用方会重复创建新版本。这是典型的"主流程与副作用解耦"设计。
三、createPrompt:创建 Prompt 版本的主流程
createPrompt()(源码)是变更层最核心的函数,其签名如下:
export const createPrompt = async ({ projectId, name, prompt, type = PromptType.Text, labels = [], config, createdBy, prisma, tags, commitMessage, user, }: CreatePromptParams) => { ... }整个流程可以拆解为七个关键步骤:
3.1 类型一致性检查
先查出该名称下最新的版本:
const latestPrompt = await prisma.prompt.findFirst({ where: { projectId, name }, orderBy: [{ version: "desc" }], }); if (latestPrompt && latestPrompt.type !== type) { throw new InvalidRequestError( "Previous versions have different prompt type. Create a new prompt with a different name.", ); }同一名称的 Prompt 版本必须保持同一类型(Text / Chat),避免"同名不同型"造成消费端解析混乱。
3.2 变量与占位符命名冲突检查
对于 Chat 类型(type === PromptType.Chat且prompt为数组),会提取每条消息内容中的变量(extractVariables)与占位符(extractPlaceholderNames),若存在交集则拒绝创建:
const conflictingNames = variables.filter((v) => placeholders.includes(v)); if (conflictingNames.length > 0) { throw new InvalidRequestError( `Cannot create prompt, variables and placeholders must be unique, the following are not: ${conflictingNames.join(", ")}`, ); }3.3 标签与 tags 的默认行为
- 新版本总是被标记为
latest:const finalLabels = [...labels, LATEST_PROMPT_LABEL],源码注释明确"Newly created prompts are always labeled as 'latest'"。 - tags 继承:若未显式传入 tags,则沿用最新版本的 tags:
const finalTags = [...new Set(tags ?? latestPrompt?.tags ?? [])]。
3.4 依赖图解析
通过parsePromptDependencyTags(prompt)解析 Prompt 内容中的@@@langfusePrompt:...@@@引用标签,再调用PromptService.buildAndResolvePromptGraph构建依赖图;解析失败会包装成InvalidRequestError抛出(含具体错误信息),确保引用的子 Prompt 存在且可解析。
3.5 单事务内完成多写操作
事务中按顺序组装了多种写操作:
prisma.prompt.create创建新版本,version 为latestPrompt.version + 1(首个版本为 1);- 为每个依赖创建
promptDependency记录(version 依赖写childVersion,label 依赖写childLabel); - 若带标签,则调用
removeLabelsFromPreviousPromptVersions把相同标签从旧版本上移除(标签唯一性); - 若 tags 发生变化,则调用
updatePromptTagsOnAllVersions把所有版本的 tags 同步为新值。
其中第 3 步的标签迁移逻辑在 utils/updatePromptLabels.ts 中实现:查出所有带目标标签的旧版本,逐一生成prompt.update(从 labels 数组中过滤掉待移除标签),并返回被"触碰"的版本 id 列表供后续事件发布使用。
3.6 并发冲突兜底
整个事务包裹在try/catch中,专门识别 Prisma 的P2002唯一约束冲突,并核对冲突列是否为project_id + name + version:
const isPromptVersionConflict = (error: unknown): boolean => { if (!(error instanceof Prisma.PrismaClientKnownRequestError) || error.code !== "P2002") return false; const target = error.meta?.target; return Array.isArray(target) && ["project_id", "name", "version"].every((c) => target.includes(c)); };命中后抛出LangfuseConflictError,提示"A prompt version was created concurrently. Please retry."——这是对并发创建同名同版本场景的显式兜底。
3.7 提交后副作用:失效缓存 + 发布事件
事务成功后依次执行缓存失效(失败仅记日志)和事件发布(对createdPrompt发"created"事件,对所有被触碰的旧版本发"updated"事件)。
3.8 补充能力:duplicatePrompt 与 duplicateFolder
同一文件还实现了两个"批量复制"能力:
- duplicatePrompt:支持"单版本复制"(
isSingleVersion为 true 时复制为新的 v1)或"整名称复制"(所有版本+依赖全部复制),旧 id 到新 id 通过oldToNewIdMap映射,依赖关系同步重建,复制后同样执行缓存失效并逐个发布"created"事件。 - duplicateFolder:按
sourcePath/前缀查找整个文件夹下的 Prompt(含嵌套子目录),复制到targetPath,并支持rewritePromptReferences参数把内容中的依赖引用标签一并改写为新名称(借助escapeSqlLikePattern做 LIKE 模式转义)。若开启引用改写但只复制最新版本(isSingleVersion),会先校验被引用的依赖在目标文件夹中存在等价版本,否则抛出明确的InvalidRequestError。
四、updatePrompts:标签更新的并发安全实现
updatePrompt()(源码)专门处理标签更新,其并发安全设计极具参考价值。
4.1 行级锁:SELECT ... FOR UPDATE
函数在事务内先用原生 SQL 锁定目标行:
SELECT * FROM prompts WHERE project_id = ${projectId} AND name = ${promptName} AND version = ${promptVersion} FOR UPDATE -- Important! This will lock the row for concurrent updates注释直白地强调"这会把行锁住以应对并发更新"。锁住后,标签的读取-修改-写回(labels: { set: ... })在并发场景下不会互相覆盖。
4.2 标签移除的依赖保护
更新后的标签集是new Set([...newLabels, ...prompt.labels])(只增不减),但代码仍保留了一道防御性检查:若某标签确实被移除了,会查询prompt_dependencies表中以child_label引用它的依赖;一旦发现存在依赖,就抛出错误并列出具体依赖者:
throw new InvalidRequestError( `Other prompts are depending on the prompt label you are trying to remove:\n\n${dependencyMessages}\n\nPlease delete the dependent prompts first.`, );这保证了"被其他 Prompt 按 label 引用的标签不可移除"的引用完整性。
4.3 标签唯一性与事件发布
随后同样调用removeLabelsFromPreviousPromptVersions将新标签从旧版本剥离(保证同一名称下标签唯一),在同一事务内完成"移除旧标签 + 更新目标版本"。提交后执行缓存失效,并对所有被触碰的版本发布"updated"事件。源码注释还特别说明:由于该函数只处理标签变更、内容不变,Webhook 无需 before 状态,因此事件中 before 传undefined。
五、读取路径:getPromptByName 与 getPromptsMeta
README 将getPromptByName()和getPromptsMeta()也纳入本目录统一管理,二者的共同点是读取也必须经过统一封装(缓存、默认标签、鉴权)。
5.1 getPromptByName:version/label 二选一,默认 production
getPromptByName.ts 的逻辑非常清晰:
version与label同时传入直接抛InvalidRequestError("Cannot specify both version and label");- 传了
version就按版本精确取; - 传了
label就按标签取; - 什么都没传,默认取
PRODUCTION_LABEL("production")——这是 Langfuse 保证"线上稳定版本"语义的关键默认值。
函数签名中的resolve参数控制是否解析依赖:为false时返回未解析的原始 Prompt(不展开@@@langfusePrompt:...@@@引用),为true(默认)时返回已解析完整内容的版本。
5.2 getPromptsMeta:聚合查询 + 稳定分页
getPromptsMeta.ts 使用单条 SQL 完成"按名称聚合各版本"的元信息查询:
array_agg(DISTINCT p.version)汇总版本号数组;array_agg(DISTINCT label) FILTER (WHERE label IS NOT NULL)汇总标签,并用COALESCE(..., '{}'::text[])保证无标签时返回空数组;MAX(p.updated_at)作为lastUpdatedAt;- 子查询
latest按version DESC取最新版本的type与config。
分页采用ORDER BY p.name ... LIMIT ${limit} OFFSET ${limit * (page - 1)},并单独执行COUNT(DISTINCT p.name)计算总页数。响应同时返回meta与pagination两个同构字段,源码注释说明这是为兼容最初发布/v2/prompts时不符合 API 规范的结构而保留的(关联 issue #2068),属于向后兼容的刻意设计。
过滤条件通过统一的tableColumnsToSqlFilterAndPrefix(filters, promptsTableCols, "prompts")生成,支持name(等值)、version(数字等值)、label(数组 any-of)、tag(数组 any-of)、fromUpdatedAt/toUpdatedAt(时间区间)等筛选。
六、deletePrompt:删除时的依赖保护与 latest 重挂
虽然 README 函数清单未列出,但 deletePrompt.ts 与createPrompt等同样位于变更操作层,其一致性逻辑值得补充说明:
- 依赖检查:查询
prompt_dependencies中所有以该名称为child_name的依赖,再结合"本次删除的版本号集合"与"删除后仍存在的版本"计算真正会断裂的依赖——删除指定版本时若该版本被引用则阻断;删除按 label 引用的版本时,若删除后没有任何剩余版本保留该标签则阻断。阻断时同样抛出带依赖清单的InvalidRequestError。 - latest 重挂:如果删除的版本中带
latest标签,且删除后没有任何剩余版本带latest,则把latest自动重新附着到剩余的最高版本上,保持"latest 始终存在"的不变量。 - 先提交、后失效:
deleteMany成功后才调用promptService.invalidateCache({ projectId })轮换缓存纪元。
七、名称与标签校验规则:从常量到 Schema
README 将校验规则指向 packages/shared/src/features/prompts/validation.ts,该 Schema 被 API、tRPC 与客户端三端共用,是"名称合法性"的唯一事实来源。
7.1 PromptNameSchema 的三层约束
export const PromptNameSchema = withFolderPathValidation( StringNoHTMLNonEmpty.regex( PROMPT_NAME_PIPE_RESTRICTION_REGEX, // /^[^|]*$/ PROMPT_NAME_PIPE_RESTRICTION_ERROR, // "Prompt name cannot contain '|' character" ), ).refine( (name) => !(RESERVED_PROMPT_NAMES as readonly string[]).includes(name), { error: (issue) => `Prompt name cannot be '${issue.input}'` }, );- 管道符限制:名称不能包含
|(常量PROMPT_NAME_PIPE_RESTRICTION_REGEX = /^[^|]*$/),原因注释为"pipe character is used for prompt composition"——|是 Prompt 组合引用语法的保留字符; - 文件夹路径校验:通过
withFolderPathValidation约束/用法(不允许以/开头或结尾、不允许连续//,见测试); - 保留名称拦截:精确命中保留名时拒绝,但作为文件夹段或叶子段时仍允许(例如
metrics/foo、folder/new均合法),详见validation.test.ts中的分组断言。
7.2 保留名称清单
packages/shared/src/features/prompts/constants.ts中定义了保留名称:
export const RESERVED_PROMPT_NAMES = ["new", "metrics", "prompt-detail"] as const;原因是这些名称对应/prompts/[[...folder]]catch-all 路由下的静态页面(new、metrics、prompt-detail),Next.js 静态路由优先于 catch-all 解析,同名 Prompt 的链接会错误渲染到静态页。因此只拦截"精确单段名称",不误伤folder/metrics这类正常路径。
7.3 标签与长度约束
同一文件还定义了其他关键常量:
| 常量 | 值 | 说明 |
|---|---|---|
PRODUCTION_LABEL | "production" | 生产标签,读取默认值 |
LATEST_PROMPT_LABEL | "latest" | 最新版本标签,新建即自动附加 |
PROMPT_NAME_MAX_LENGTH | 255 | 名称最大长度 |
COMMIT_MESSAGE_MAX_LENGTH | 500 | 提交说明最大长度 |
PROMPT_LABEL_MAX_LENGTH | 36 | 标签最大长度 |
PROMPT_LABEL_REGEX | /^[a-z0-9_\-.]+$/ | 标签必须为小写字母数字,可含_、-、. |
7.4 测试佐证
validation.test.ts 用 vitest 参数化用例逐一验证了上述规则:保留名称(含首尾空白 trim 后的变体)一律拒绝;${name}/foo与folder/${name}一律放行;a|b、/a、a/、a//b、空字符串全部拒绝;合法名称my-prompt、metrics-v2、folder/sub-folder/prompt全部通过并 trim 空白。这些用例既是规则的回归保护,也是理解 Schema 行为最直接的注释。
八、实践建议与注意事项
结合上述源码分析,对 Langfuse Prompts 模块的二次开发或运维可提炼出以下要点:
- 新增任何 Prompt 数据操作都必须落在
actions/目录:否则会绕过缓存失效(读旧数据)、事件溯源(Webhook 丢失)与标签唯一性约束,直接破坏一致性。这是 README 中最核心、不可妥协的纪律。 - 写操作优先考虑事务 + 行锁:
updatePrompt的SELECT ... FOR UPDATE是标签并发更新的正确姿势;createPrompt则依赖唯一约束冲突检测(P2002 + 列核对)兜底并发创建。 - 标签是"可移动的指针":
latest、production语义由标签承载,任何创建、更新、删除操作都必须维护"标签唯一 + latest 始终存在"两个不变量。 - 副作用与主流程解耦:缓存失效与事件发布均在事务提交后进行,且失败只记日志不抛错,避免"数据已写入却报错"导致的重复写入。
- 名称即路由:命名时要避开
new、metrics、prompt-detail三个保留名,且不能含|;文件夹式命名(如folder/sub-folder/prompt)受支持,但注意version、label二选一查询的 API 语义。
总结
web/src/features/prompts/server/actions/README.md用极简的文字定义了一套高一致性的 Prompt 变更工程规范。从源码看,这套规范背后是Redis 缓存纪元轮换、EntityChangeQueue 异步事件溯源、标签唯一性迁移、FOR UPDATE行锁、依赖图解析与唯一约束冲突兜底六个机制的协同工作。理解"为什么禁止直接 Prisma 调用"这个问题,就等于理解了 Langfuse Prompts 在并发、缓存与审计三个维度上的全部设计取舍。对于希望深入 Langfuse 源码或在其上构建 Prompt 管理能力的开发者,actions/目录连同packages/shared/src/features/prompts/下的校验与常量定义,是最值得精读的入口。
【免费下载链接】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),仅供参考