简化 Langfuse 的规模化应用
本文字数9182估计阅读时间23分钟作者Steffen Schmitz、Valeriy Meleshkin、Max Deichmann深入剖析我们将 Langfuse 迁移至“观测优先”数据模型observations-first data model的历程。Langfuse 致力于构建开源 AI 工程平台旨在帮助团队更快地开发生产级 LLM 应用。2024 年 12 月我们发布了 Langfuse V3在该版本中我们将所有追踪数据从 Postgres 迁移至 ClickHouse。今天我们宣布在产品性能和简化方面迈出了重要一步我们已转向以“观测为中心”的数据模型observations-centric data model。这意味着每一次 LLM 调用、工具执行和智能体agent步骤都将汇聚于一个可查询的表格中。随着智能体应用的日益复杂单个追踪可能包含数千个操作而关键信息往往深藏其中。通过使用过滤器和保存的视图您可以精准聚焦所需内容——无论是端到端地追踪单个请求还是在整个项目中发现模式例如识别哪些模型调用导致成本上升或哪些环节出现延迟峰值。有关本次发布rollout的摘要和升级指南请参阅更新日志。目前该功能已在 Langfuse Cloud 的所有区域上线 Beta 版。我们仍在为自托管用户开发升级路径并最终将其作为 v4 主要版本发布。此次调整后我们将采用以观测为中心的数据模型该模型基于一张全新的、宽表且大部分不可变的 ClickHouse 表。这不仅消除了读取时的连接和去重操作还针对 ClickHouse 性能最佳的访问模式进行了优化。处理大量数据时初始表加载时间从秒级缩短到毫秒级。对于大型项目其仪表板加载时间在更长的时间跨度上至少提升 10 倍。成长中的烦恼在我们的初始规模下将 Postgres 访问模式沿用到 ClickHouse 运行良好。我们将traces和observations作为独立的实体其中traces存储通用元数据而observations则代表独立的操作。然而到 2025 年下半年这种方法变得代价高昂。我们的数据模型已无法支撑业务发展大型用户在仪表板中只能查看几天的数据查询近期traces需要数秒并且一些用户在我们的公共 API 上遭遇了高延迟和错误率。更新的成本两年前LLM (Large Language Model) API (Application Programming Interface) 调用存在高延迟用户希望尽快在我们的用户界面 (UI) 中查看到其traces。因此我们设计了 SDK (Software Development Kit) 来发送多个事件包括一个用于 LLM API 调用的事件一个用于 LLM 响应的事件以及在用户更新元数据时可能发送的更多事件。由于尽早查看输入数据非常便捷我们选择快速提交它们并在输出和元数据抵达时进行更新。在业务规模较小时这种做法是完全可行的。在 Langfuse V2 版本中我们采用 Postgres实现 upsert插入或更新操作非常直接。然而于 2024 年 12 月迁移到 ClickHouse 后ReplacingMergeTree 成为处理更新的主要选择。它需要写入整行替换而非更新单个字段即通过插入一个具有相同标识符和新属性的新行来实现更新。当时ClickHouse 尚未引入轻量级更新或 CoalescingMergeTree 表引擎它们本可以成为我们的潜在替代方案。随着规模的扩大使用 ReplacingMergeTree 实现更新带来了更高的读取时间开销。ClickHouse 的 ReplacingMergeTree 将数据去重操作推迟到后台合并过程。因此为了保证读取时的数据正确性我们不得不在读取数据时对那些尚未完成合并的行进行实时去重。为了在返回单个结果之前扫描数十亿条记录以呈现每行的最新版本这消耗了大量的中央处理器 (CPU) 和内存 (Memory) 资源并导致 Langfuses 在长时间查询时API 延迟居高不下。虽然自我们采用 ClickHouse 以来LIMIT BY、FINAL和聚合等去重选项的速度显著提升但它们依然是查询性能上的一项隐性成本。每个涉及历史数据的查询都必然会承担这份开销。在 2025 年我们将专有的数据摄取方式切换到了 OpenTelemetry。该端点于一月首次发布后我们紧接着在夏季将所有第一方 SDK 迁移至 OpenTelemetry 协议。目前Langfuse Cloud 中约 60% 的观测数据通过 OpenTelemetry 端点传入。由于 OpenTelemetry span 是不可变的所有摄入到 Langfuse 的观测数据也都是不可变的。不过这些观测数据携带跟踪级别的信息用于更新跟踪表。对于使用仅支持 OpenTelemetry 客户端的自托管用户而言这实现了读取时的优化我们因此能够跳过observations表上的去重操作。连接的成本我们最初采用的双实体模型将数据拆分存储在两张表中traces和observations。traces表存储跟踪trace本身的输入、输出以及共享元数据例如user_id、session_id和tags。而observations表则记录跟踪trace中的单个操作例如 LLM API 调用或工具调用。要回答“该用户的总成本是多少”或“此会话中发生了多少次 LLM 调用”这类问题需要对包含数十亿条记录的两张表进行连接join操作。早期阶段这尚可应对。然而随着规模的扩大这类连接操作在任何数据库中都代价高昂并对大型项目从历史数据中查询的能力构成了持续的瓶颈。尽管我们尝试了不同的连接算法例如grace_hash_joins并改进了连接重排序和过滤器下推机制我们发现这些在 ClickHouse 中虽然能够稳定运行但仍无法达到我们期望的响应速度。我们最终决定通过反范式化denormalization将traces和observations合并到一张表中从而完全避免连接成本。尽管全年处理的数据量增长了 19 倍我们的 ClickHouse 节点规模也扩大了 15 倍。即便如此某些针对较长持续时间的查询仍然会因内存不足而终止。最终我们意识到单纯增加计算资源并非应对这种增长的实用策略。我们的 AWS S3 账单采用 ReplacingMergeTree 策略进行数据更新时我们将 S3 作为原始事件存储。每次更新只包含与上一条记录的差异并以一致的前缀进行存储。读取时我们列出、获取并合并该前缀下的所有文件以形成最终记录。这种方式可行因为 S3 具有出色的扩展性并支持读写一致性。然而S3 的 API 调用对于我们和自托管用户而言成本较高。我们在 V3 上线时选择了这种方案因为在我们的技术栈中添加 Cassandra 等其他数据库会使 Langfuse 对自托管用户来说更为复杂。此外对于流量波动较大的部署而言与固定基础设施组件相比吞吐量能实现线性扩展是一个非常有利的特性。成本优化的首个突破点是引入 OpenTelemetry。由于 Span (Span) 是不可变的我们可以将整个批次作为一个对象写入和读取从而无需进行合并操作。尽管跟踪级别的属性仍沿用原有路径但仅这一优化就为部分自托管用户和仅使用 OpenTelemetry 的项目节省了约 85% 的 S3 成本。同时在 Langfuse Cloud 上随着用户采纳新的 SDK (SDK)我们也观察到 S3 相对成本有所下降。转向不可变性AggregatingMergeTrees 实验在决定将跟踪属性 (trace attributes) 非规范化 (denormalize) 为观测数据 (observations) 之前我们曾尝试在保留两表结构的同时降低 S3 成本并提升性能。ClickHouse 中的 AggregatingMergeTrees 方案看起来很有前景它将跟踪更新从 S3 迁移到 ClickHouse 的原生聚合功能中同时使 Otel 原生观测数据变得不可变。最初我们在 ClickHouse 中使用了一个包含project_id, toDate(timestamp), trace_id的排序键 (ordering key)。这使得我们能够有效地按时间范围进行筛选并由于在排序键中共享共同属性能够高效地将跟踪表 (traces table) 与观测数据 (observations) 进行关联。然而一旦跟踪跨越多天我们就发现了数据不一致的问题。某些属性可能在某一天存在而在第二天则出现不同。解决这些不一致性需要额外的聚合操作这导致在大规模、跨多天的计算中性能显著下降。接下来我们使用project_id, trace_id作为排序键并结合表上的不同 TTL存活时间。例如我们使用了traces_amt_3d、traces_amt_7d等表。根据用户选择的时间范围我们会选择相应的表。这减少了数据不一致性但一旦查询跨越到下一个时间边界成本就会显著增加。当时我们即将推出这一方案但最终还是叫停了。我们的着眼点不应是既要优化成本又要保持相同体验而是应该简化数据模型。我们为何弃用数据更新操作我们梳理了一份清单列出了所有数据库访问模式及其开销。随后我们尝试提出了多种解决方案来解决高开销的查询模式。当时我们可用的基于去重的方案都存在一个共同的弊端数据库必须扫描足够多的数据以确认在生成结果之前不存在更新的行。在数百 GB 和数十亿条记录的规模下这种扫描所需的时间远超用户可接受的等待时长。自那时以来ClickHouse 持续改进了FINAL性能并引入了诸如 lightweight updates 等新选项。然而考虑到我们的规模我们正在寻求一切可能的优势无论是降低每一分成本还是减少每一毫秒的查询延迟。对我们来说最优路径是采用一种完全避免更新开销的设计。不可变记录Immutable records恰好满足了我们的需求。由于不存在重复数据排序键便具有决定性作用——数据可以按照磁盘顺序进行流式传输并且 ClickHouse 的 MergeTree 优化也能得到充分应用。表加载时间也从几秒钟缩短至几十毫秒。需要注意的是并非所有的更新操作都需要被消除。对于更新量可控的工作负载场景——例如追踪书签bookmarking a trace或通过用户界面UI发布——ClickHouse 的 lightweight updates 提供了一种便捷且简单的解决方案无需使用 ReplacingMergeTree 表引擎也无需处理读取时的去重问题。从两张表到单表取消关联查询joins意味着将所有数据整合到一张表中。挑战在于user_id、session_id、tags以及其他追踪级别trace-level的属性会在追踪的整个生命周期中于不同的时间点到达。将这些属性实时地关联到每一条观测记录上需要我们重新思考数据传播的方式。我们排除了将暂存表 (staging tables) 和批处理作业 (batch jobs) 作为长期策略的方案。因为它们会破坏工程师在调试 (debugging) 时赖以生存的实时数据可用性 (real-time data availability)。解决方案是在我们的 SDKs 中引入一种新的传播方法 (propagation method)该方法围绕 OpenTelemetry 的 Context 和 Baggages 设计。对于使用旧版 SDKs 的用户现有表仍提供即时访问同时延迟传播机制会将数据填充到新格式中。查询优势显而易见。现在按user_id过滤只需在一张表上进行列过滤 (column filter)。按session_id聚合也只需进行GROUP BY操作。无需连接规划器 (join planner)也无需跨表扫描 (cross-table scan)只需让 ClickHouse 充分发挥其所长。Traces从实体到关联标识符我们下一个主要版本 (v4) 中最显著的产品变化是转向了以观测为优先的用户界面 (UI)。对于许多用户而言追踪表 (traces table) 曾是调试时的主要入口。我们认为这更多是旧模型的局限性而非正确的默认选择。随着智能体应用 (agentic applications) 产生包含数百甚至数千个操作 (operations) 的追踪那种每追踪一行 (one-row-per-trace) 的视图正日益掩盖工程师所需的关键信号。调试多步骤智能体 (multi-step agent) 的开发人员不仅想知道某个追踪失败了他们更想找到具体哪个工具调用 (tool call) 出错了、哪个 LLM 调用开销过大、或者哪个检索 (retrieval) 返回了不相关结果。观测表 (observations table) 让他们可以直接从这些问题入手。凭借更快的加载时间、扩展的过滤能力 (filter capabilities) 和保存的视图 (saved views)这成为理解应用程序的卓越体验。trace_id也因此成为了一个关联句柄 (correlation handle)——类似于session_id或user_id——而非其自身的顶层实体 (top-level entity)。这也解决了围绕双实体模型 (two-entity model) 长期存在的困惑。这并非一个新颖的模式。整个可观测性 (observability) 社区一直在趋向这种方法。Ivan Burmistrov 的广为流传的论文《你所需要的是宽事件而非指标 (All you need is Wide Events)》指出将数据分为指标、日志和追踪是人为的——重要的是一个包含丰富属性事件的单一表格你可以对其进行自由切分和分析而无需预设的聚合。Uber 工程团队在基于 ClickHouse 重建其日志平台时也得出了相同的结论他们转向了一种模式灵活的列式模型其中宽泛的结构化事件是主要的分析单元。对于 Langfuse 用户而言这意味着新的表格现在是主要的交互工作区。筛选、排序和保存的视图取代了旧的追踪表浏览方式。追踪瀑布视图并未消失当你需要了解请求的全貌时它仍然可以从任何观测行一键访问。但探索始于扁平表格通过观测名称、类型、模型、成本、延迟、用户、会话或错误状态进行筛选以浮现出重要的操作然后深入追踪以获取上下文信息。迈向 v4实现难题双写 (Dual Write)在迁移历史数据之前我们必须将所有传入的记录同时写入旧格式和新格式。问题在于实时摄取 (live ingestion)追踪元数据可能在追踪的第一次观测之后才到达。我们需要在服务器端对所有观测记录保持强大的写入一致性同时不破坏现有的 SDK 协议。我们评估了逐事件延迟、共享元数据存储如 Cassandra、DynamoDB以及基于 S3 的重新处理方案。每种方案都会引入新的基础设施、新的成本模型和新的扩展模式。解决方案是一个暂存表它接收所有传入的观测和追踪记录以及一个微批处理 (micro-batch) 作业该作业以 5 分钟的延迟每 3 分钟将追踪元数据连接到观测记录上。根据观察到的数据分布这在将大部分追踪属性推送到各自的观测记录中同时在新用户界面 (UI) 中快速提供数据之间取得了理想的平衡。对于新传入的观测数据完整数据会得到传播但过去的观测数据保持不变。初期表现强劲。然而随后出现了部分记录丢失的情况。问题根源在于带有 TTL生存时间的频繁分区删除操作在 ClickHouse 的元数据存储 (Keeper) 中产生了大量无效数据分片dead parts这减缓了数据分片在节点间的传播速度。在最糟糕的情况下单个节点甚至在数据插入 25 分钟后才能感知到新数据。我们直接与 ClickHouse 团队合作识别出根本原因推出了缓解措施并将补丁提交至上游。另一个独立的问题是传播任务的运行时间从每次 25-45 秒延长到了数分钟。这与我们在新的不可变表上新增了一个物化视图有关该物化视图默认禁用了插入并行化。消除该瓶颈后运行时间恢复到约 45 秒。parallel_view_processing1在此发挥了奇效。在调试查询性能时EXPLAIN PIPELINE graph-1 … FORMAT LineAsString命令及其配套的 Graphviz 编辑器成为了我们的得力助手。禁用和启用parallel_view_processing时的插入流水线。数据迁移由于实时数据正被写入两种格式我们不得不将历史数据迁移到新的不可变表。在不同的表结构之间迁移数 PB 的数据远比想象中复杂。显而易见的方法——INSERT INTO events SELECT * FROM observations JOIN traces——执行缓慢、成本高昂且一旦出现错误反馈周期漫长。我们尝试了调整查询参数、启用磁盘溢出以及分块插入等方法但这些方案均无法满足速度或持久性的要求。我们甚至开发了一个 Rust 工具该工具负责将观测数据块加载到内存中获取匹配的追踪数据并写入合并后的事件数据随后将其部署在一台配备 100 核处理器和 1.5 TB 内存的 EC2 实例上运行。然而网络却成了瓶颈与内存相比网络瓶颈对吞吐量的影响更为严重。奏效的方法包括复制原始表并指定新的排序键冻结新表停止合并操作以防止 ClickHouse 中数据分片被修改然后一次处理一个数据分片。这种方法使我们能够扩展并发性、控制内存使用并能从任意检查点恢复。通过冻结数据分片我们能够确保后台合并操作不会产生新的数据分片从而避免了重复处理的问题。我们为 V3 构建的后台迁移框架负责协调和进度追踪。我们提前获取了完整的数据分片列表并同时并发处理和复制多达 15 个数据分片。这种方法使我们从基于分区 (per-partition) 的并发处理转变为基于数据分片 (per-part) 的并发处理在将吞吐量提升四倍的同时也提高了系统的稳定性。为什么我们仍然很慢随着新的数据模型部署到位后查询速度提升了 2-3 倍。虽然有所改善但仍远低于我们预期的 10-20 倍性能提升。经过与 ClickHouse 工程师的深入探讨我们发现了三个问题数据分片数量过高。每个分区拥有约 1,000 个数据分片而通常情况下150-200 个分片更为常见。这导致数据高度碎片化进而降低了扫描效率。ClickHouse 需要执行过多的文件读取操作才能高效地扫描大量数据。索引文件过大。粒度 (Granule) 大小受到默认限制使得索引密度过高。增加最大粒度大小有助于保持索引稀疏提升查找速度并实现更高效的数据剪枝 (data pruning)。我们维持默认的 8192 行但将每个粒度的最大大小从 10MiB 提升至 64MiB。行大小限制了合并进程。部分数据分片在达到约 100 GiB 时停滞不前接近 150 GiB 的合并大小限制导致后续合并无法触发。通过删除冗余数据例如原始事件副本、重复的元数据表示我们减小了行大小增加了每个数据分片的记录数量从而解除了合并阻塞。这主要是因为我们的单行数据非常庞大其中包含多兆字节 (Megabytes) 的字符串和映射 (maps) 数据。最终的解决方案是引入一个新的派生表 (derived table)。一个物化视图 (materialized view) 会将所有传入的事件数据在截断大型元数据 (metadata)、输入和输出字段后转发到一个独立的 ClickHouse 表中。核心表 (core table) 负责处理所有仪表板 (dashboard) 和表加载 (table-loading) 查询而完整表 (full table) 仅在检索单个记录时才会被访问。由于存储成本相对较低维护多个同步的数据表示 (synchronized representations) 已成为一种实用的调优手段。这些改动共同实现了我们一直以来设定的性能基准。新 API我们现有的公共 API 模式在小规模场景下运行良好但在大规模应用时则暴露出问题。例如GET /api/public/traces和GET /api/public/traces/:id接口会拉取相关的评分和观测数据但分页功能有限且未强制要求时间过滤器。这意味着任何调用都可能触发一次全表扫描 (full-table scan)。ClickHouse 最有效的数据裁剪 (pruning) 方式是基于其排序键该排序键以project_id和一个时间维度开头。如果查询中没有基于时间的过滤器ClickHouse 就无法跳过任何时间范围内的数据必须读取项目的全部分区才能找到匹配记录。为了维持 API 契约不变我们持续引入可选优化例如字段选择和去重排除选项。然而默认配置依然存在风险。我们的observations和metricsAPI 的 V2 版本带来了根本性的改变。新的观测数据和指标数据接口已在公开测试版中推出从单一数据表读取强制要求基于时间戳的过滤器支持细粒度字段选择并采用基于令牌的分页 (token-based pagination) 机制该机制与 ClickHouse 的数据裁剪机制协同而非阻碍其效率。大多数切换到新 API 的客户都立即获得了显著的性能提升。V1 API 并不会立即下线。但 V2 才是未来的发展方向。开源版和自部署版本发布本次发布是针对 Langfuse Cloud 的。我们深知自部署用户正在等待——我们一些最大的项目都运行在自部署基础设施上。我们正在开发自动化迁移工具并验证开源 ClickHouse 部署的双写配置。迁移指南和自部署版本将在未来几周内发布。关于我们ClickHouse 是全球速度最快资源利用最高效的在线分析列式数据库管理系统。现在ClickHouse可以作为一个安全可扩展的无服务器应用在云中提供服务。通过云服务ClickHouse使得任何人都能轻松获取高效的实时分析处理能力。