简化 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 运行良好。我们将 tracesobservations 作为独立的实体,其中 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 BYFINAL 和聚合等去重选项的速度显著提升,但它们依然是查询性能上的一项隐性成本。每个涉及历史数据的查询都必然会承担这份开销。

在 2025 年,我们将专有的数据摄取方式切换到了 OpenTelemetry。该端点于一月首次发布后,我们紧接着在夏季将所有第一方 SDK 迁移至 OpenTelemetry 协议。目前,Langfuse Cloud 中约 60% 的观测数据通过 OpenTelemetry 端点传入。由于 OpenTelemetry span 是不可变的,所有摄入到 Langfuse 的观测数据也都是不可变的。不过,这些观测数据携带跟踪级别的信息,用于更新跟踪表。

对于使用仅支持 OpenTelemetry 客户端的自托管用户而言,这实现了读取时的优化,我们因此能够跳过 observations 表上的去重操作。

连接的成本

我们最初采用的双实体模型将数据拆分存储在两张表中:tracesobservationstraces 表存储跟踪(trace)本身的输入、输出以及共享元数据,例如 user_idsession_idtags。而 observations 表则记录跟踪(trace)中的单个操作,例如 LLM API 调用或工具调用。

要回答"该用户的总成本是多少?"或"此会话中发生了多少次 LLM 调用?"这类问题,需要对包含数十亿条记录的两张表进行连接(join)操作。早期阶段,这尚可应对。然而,随着规模的扩大,这类连接操作在任何数据库中都代价高昂,并对大型项目从历史数据中查询的能力构成了持续的瓶颈。尽管我们尝试了不同的连接算法,例如 grace_hash_joins,并改进了连接重排序和过滤器下推机制,我们发现这些在 ClickHouse 中虽然能够稳定运行,但仍无法达到我们期望的响应速度。我们最终决定,通过反范式化(denormalization)将 tracesobservations 合并到一张表中,从而完全避免连接成本。

尽管全年处理的数据量增长了 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_3dtraces_amt_7d 等表。根据用户选择的时间范围,我们会选择相应的表。这减少了数据不一致性,但一旦查询跨越到下一个时间边界,成本就会显著增加。

当时我们即将推出这一方案,但最终还是叫停了。我们的着眼点不应是既要优化成本又要保持相同体验,而是应该简化数据模型。

我们为何弃用数据更新操作

我们梳理了一份清单,列出了所有数据库访问模式及其开销。随后,我们尝试提出了多种解决方案来解决高开销的查询模式。当时我们可用的基于去重的方案都存在一个共同的弊端:数据库必须扫描足够多的数据,以确认在生成结果之前,不存在更新的行。在数百 GB 和数十亿条记录的规模下,这种扫描所需的时间远超用户可接受的等待时长。

自那时以来,ClickHouse 持续改进了 FINAL 性能,并引入了诸如 lightweight updates 等新选项。然而,考虑到我们的规模,我们正在寻求一切可能的优势:无论是降低每一分成本,还是减少每一毫秒的查询延迟。对我们来说,最优路径是采用一种完全避免更新开销的设计。

不可变记录(Immutable records)恰好满足了我们的需求。由于不存在重复数据,排序键便具有决定性作用------数据可以按照磁盘顺序进行流式传输,并且 ClickHouse 的 MergeTree 优化也能得到充分应用。表加载时间也从几秒钟缩短至几十毫秒。

需要注意的是:并非所有的更新操作都需要被消除。对于更新量可控的工作负载场景------例如追踪书签(bookmarking a trace)或通过用户界面(UI)发布------ClickHouse 的 lightweight updates 提供了一种便捷且简单的解决方案,无需使用 ReplacingMergeTree 表引擎,也无需处理读取时的去重问题。

从两张表到单表

取消关联查询(joins)意味着将所有数据整合到一张表中。挑战在于:user_idsession_idtags 以及其他追踪级别(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_iduser_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_processing=1 在此发挥了奇效。在调试查询性能时,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/tracesGET /api/public/traces/:id 接口会拉取相关的评分和观测数据,但分页功能有限,且未强制要求时间过滤器。这意味着任何调用都可能触发一次全表扫描 (full-table scan)。ClickHouse 最有效的数据裁剪 (pruning) 方式是基于其排序键,该排序键以 project_id 和一个时间维度开头。如果查询中没有基于时间的过滤器,ClickHouse 就无法跳过任何时间范围内的数据,必须读取项目的全部分区才能找到匹配记录。为了维持 API 契约不变,我们持续引入可选优化,例如字段选择和去重排除选项。然而,默认配置依然存在风险。

我们的 observationsmetrics API 的 V2 版本带来了根本性的改变。新的观测数据和指标数据接口(已在公开测试版中推出)从单一数据表读取,强制要求基于时间戳的过滤器,支持细粒度字段选择,并采用基于令牌的分页 (token-based pagination) 机制,该机制与 ClickHouse 的数据裁剪机制协同,而非阻碍其效率。大多数切换到新 API 的客户都立即获得了显著的性能提升。

V1 API 并不会立即下线。但 V2 才是未来的发展方向。

开源版和自部署版本发布

本次发布是针对 Langfuse Cloud 的。我们深知自部署用户正在等待------我们一些最大的项目都运行在自部署基础设施上。我们正在开发自动化迁移工具,并验证开源 ClickHouse 部署的双写配置。迁移指南和自部署版本将在未来几周内发布。

关于我们

ClickHouse 是全球速度最快,资源利用最高效的在线分析列式数据库管理系统。现在,ClickHouse可以作为一个安全可扩展的无服务器应用在云中提供服务。通过云服务,ClickHouse使得任何人都能轻松获取高效的实时分析处理能力。

相关推荐
中微极客1 小时前
从提示词到产品级视频:生成式AI的工程化实践与工具栈选型(LTX Studio实战)
人工智能·chatgpt·音视频
@陈小鱼1 小时前
MATLAB+Python:基于小样本卷积神经网络的 PPG 血压预测
人工智能·python·机器学习·matlab·脉搏波·血压·一维卷积神经网络
大鱼>1 小时前
AI+快递分拣:视觉识别+自动分拣+异常检测
人工智能·深度学习·算法·机器学习
具身新纪元1 小时前
ECCV 2026|MATCH用Flow Matching重做多视角工业质检
人工智能·计算机视觉·视觉检测
AIHR数智引擎1 小时前
15%对撞40%:WEF白皮书里的AI组织隐忧
数据库·人工智能·经验分享·职场和发展·aihr
记忆停留w1 小时前
Celery+Redis 分布式异步任务队列工程落地业务逻辑
大数据·人工智能·redis·分布式·缓存·架构·wpf
Token炼金师1 小时前
架构的下一程:堆参数退潮,提效率登场 —— GQA、MTP、原生多模态与稀疏融合
人工智能·深度学习·llm
sunneo1 小时前
S16.6第一性原理做产品(6·收官):从《定位》到“品类设计“——AI时代如何定义新品类
人工智能·产品运营·产品经理·用户运营·用户体验·ai-native
QN1幻化引擎1 小时前
DalinX 自述:从认识自己到成为自己-黎明前的第七杯咖啡
人工智能·机器学习