返回归档
🔭LLM 与 Agent

Langfuse 架构:一次 trace 怎么进 ClickHouse

两个进程加四套存储。摄入先写 S3 再入队,HTTP 返回时 ClickHouse 里还没有合并后的行。高可用靠副本、PDB、按节点打散,以及集群外的托管存储。

文章目录

这篇说明 Langfuse 自托管的两个进程、四套存储、一次写入和一次读取,以及一套 Langfuse 4.4.0 实例怎样做高可用。

适合要读 trace,或要判断这套共享实例边界的工程师。部署命令和现网地址不在这里。

先看「两个进程和四套存储」,再看「一次写入」。高可用和容量对应 2026-10-09 对这套实例的只读观察:官方 Helm chart 1.5.41,镜像标签 4.4.0。组件分工引自 Langfuse 自托管说明,扩容差异引自 扩容说明。

记下来的对象

Langfuse 记下一次大模型调用的输入、输出、耗时和 token,以及调用之间的嵌套。查询单位是 observation。同一个 trace_id 把相关 observation 收在一起。

对象可以这样记:

  • trace 是一次任务或一次对话的关联 ID。
  • observation 是其中的一步,类型是 span、generation 或 event。一次模型调用、一次工具调用各是一行。
  • score 是对某次结果的评价。
  • session 把多条 trace 归到同一次会话。
  • prompt 是提示词原文,放在 PostgreSQL;热的读穿缓存放在 Redis。

v3 的基础设施演进把观测数据从 PostgreSQL 挪到 ClickHouse。ClickHouse 原地更新一行很贵,所以更新被做成再次插入。

v4 升级说明把读路径改成以 observation 为中心的宽表。基础设施没有多出一个进程,仍是 Web、Worker、PostgreSQL、ClickHouse、Redis 和 S3。

v4 用两张 ClickHouse 表替换 traces 和 observations 的读路径:

表 放什么 谁来读
events_full 完整的一行。用户、会话、标签、release、version 直接写在这行上 要看完整输入和输出的单条查询
events_core events_full 的轻量投影,大字段被截断,由物化视图写入 列表和图表

trace 不再是单独的一行,而是同一张表里的根 span。旧数据里的 trace 会变成类型为 SPAN、span ID 为 t-<trace_id> 的虚拟根 span,历史记录仍能打开。界面默认只显示根 observation,一行对应以前的一条 trace;去掉这个过滤才看到全部 observation。原先挂在 trace 上的 score 和评论,显示在对应的根 observation 上。score 的配置仍在 PostgreSQL,和用户、项目、prompt、dataset 一起,不跟着观测行搬家。

2026-03-10 的文章把这次改表画成下面这张图。左边是 v3,traces 和 observations 两张可变表,查询要 join。右边是 v4,原先在 trace 上的 user_id、session_id 和 trace metadata 写到 observation 行上。

Langfuse 公布的 schema 变化:v3 的 traces 与 observations 要 join,v4 把 trace 字段写进 observation 行

图把 v4 画成一张 observation 表。这套实例的镜像是 4.4.0,全文在 events_full,列表和图表读物化视图 events_core。

Langfuse Cloud 的公开材料里,截至 2026 年 3 月,大约 60% 的 observation 从 OpenTelemetry 端点进入,这类 span 按不可变行写入。这不是这套实例的测量。

工程说明记录了 v3 的合并原因:大约 90% 的更新会在 10 秒内到达,于是在 Worker 里把多次事件收成一行,而不是在 ClickHouse 里改已有行。这个比例只属于当时的 Cloud。

数据放在哪一张表,和请求先碰到哪个进程,是两件事:

flowchart LR
  obs["observation"] --> full["events_full 全文"]
  full --> core["events_core 列表与图表"]
  meta["用户、项目、prompt、score 配置"] --> pg["PostgreSQL"]
  hot["API key 与热 prompt"] --> redis["Redis"]

两个进程和四套存储

对外只有 Web。UI、登录、公开 API、摄入和媒体上传都打到 langfuse-web。Worker 没有 Service,容器端口 3030 只给存活探针 GET /api/health。

两个进程不互相调用。交接物是 Redis 里的队列消息,消息带的是 S3 对象引用,原始事件在对象存储里。

自托管说明里的部署图如下。调用方只连 Web。Web 连 PostgreSQL、Redis / Valkey、ClickHouse 和 S3。Redis 把任务交给 Worker,Worker 再连 PostgreSQL、ClickHouse 和 S3。可选的 LLM API 画在 Web 的 playground 和 Worker 的评估上。

Langfuse 官方自托管架构:调用方连到 Web,Web 与 Worker 分别连到 PostgreSQL、Redis、ClickHouse 和 S3

这套实例把四套存储放在集群外。调用方只到 Web。Web 写原始事件和引用,Worker 再写 ClickHouse:

flowchart LR
  client["调用方"] --> web["Web"]
  web --> s3["S3 原始事件"]
  web --> redis["Redis 引用"]
  redis --> worker["Worker"]
  worker --> s3
  worker --> ch["ClickHouse"]
  web --> pg["PostgreSQL"]
  worker --> pg
  web -.->|未配置| llm["LLM 网关"]
  worker -.->|未配置| llm

Web 和 Worker 带着同一组存储环境变量,所以 Web 也能写 ClickHouse,Worker 也能读 PostgreSQL。差别是谁站在调用方的这次 HTTP 上。读查询从 Web 发出,摄入后的合并和批量插入从 Worker 发出。

存储 这套实例 放什么
PostgreSQL 集群外的 RDS。自动迁移开着 用户、组织、项目、API key、prompt、score 配置、媒体上传状态
Redis Serverless,集群模式,TLS,库号 0,用户名和口令都空 队列,以及 API key 和热 prompt 的缓存
ClickHouse ClickHouse Cloud 上的一个库。CLICKHOUSE_CLUSTER_ENABLED=false,应用只连一个地址。自动迁移开着,迁移连接使用 SSL observation 行。列表读 events_core,全文读 events_full
S3 集群外的一个桶。path-style 关闭。事件、媒体、批量导出共用这个桶和同一对访问密钥 原始事件、多模态对象、导出文件

集群内的 ClickHouse、PostgreSQL、Redis 和 MinIO 都不渲染。数据是否还在,取决于这四套托管服务,不取决于 Langfuse 的 Pod 还在不在。

可选的 LLM 网关在官方图里有两个用途:Web 上的 playground,Worker 上的评估。这套实例的 Pod 环境里没有单独的 LLM 网关地址。代码评估走的是另一条路,见下一节。

官方图卡片上的 Worker 写成 langfuse/worker。这套实例拉的是 Docker Hub 上的 langfuse/langfuse:4.4.0 和 langfuse/langfuse-worker:4.4.0。新 Pod 拉镜像依赖 Docker Hub,不依赖账号里的镜像仓库。

一次写入

SDK 的批量摄入打 POST /api/public/ingestion。OpenTelemetry 打 /api/public/otel,官方写入端点是 POST /api/public/otel/v1/traces。两条都只进现有的这组 Web,没有按路径再拆一组 Pod。

一次摄入里,HTTP 返回和查询可见是两个时刻:

sequenceDiagram
  participant SDK as 调用方
  participant Web as Web
  participant Redis as Redis
  participant PG as PostgreSQL
  participant S3 as S3
  participant Worker as Worker
  participant CH as ClickHouse

  SDK->>Web: POST 摄入或 OTel
  Web->>Redis: 查 API key
  alt 缓存未命中
    Web->>PG: 读 API key
    PG-->>Web: 身份
  end
  Web->>S3: 写入原始事件
  Web->>Redis: 放入引用
  Web-->>SDK: HTTP 返回
  Note over SDK,CH: 返回时 ClickHouse 里还没有合并后的行
  Worker->>Redis: 取出引用
  Worker->>S3: 读取原始事件
  Worker->>CH: 读取同一条旧行
  Note over Worker: 在内存里合并并攒批
  Worker->>CH: 批量插入
阶段 在哪 调用方此时
鉴权 Web。API key 先查 Redis,未命中再查 PostgreSQL 未授权的请求在这里结束
写原始事件 Web 写 S3。事件上传开着 还在同一次 HTTP 里
入队 Web 向 Redis 放入 S3 引用 入队之后 HTTP 返回。这一刻 ClickHouse 里还没有合并后的行
合并 Worker 从 S3 取出事件,默认再读 ClickHouse 里同一条旧行,和这次事件合并 查询 API 仍可能看不到
插入 Worker 先放进内存缓冲,按批次写入 ClickHouse。官方工程说明里,ClickHouse 侧还可以再做 async_insert 刷写完成之后,下一次读才能看到

扩容说明写明:Worker 默认会读 ClickHouse 里的已有行再合并。全量历史已经在 S3 里、且项目不是从旧版本迁来时,这个读可以跳过,变量是 LANGFUSE_SKIP_INGESTION_CLICKHOUSE_READ_MIN_PROJECT_CREATE_DATE。这套实例没有设置这个变量,所以合并时的 ClickHouse 读还在。

插入和查询用的是同一个地址。环境变量里没有 CLICKHOUSE_READ_ONLY_URL,只有 CLICKHOUSE_URL。官方把只读地址用于把分析查询和插入分开,主要面向 ClickHouse Cloud 这种计算和存储分离的部署。这套实例没有拆。

原始事件先于队列写入 S3。Worker 在 HTTP 返回之后失败时,已接收的事件仍在桶里。官方说明据此可以重放,不依赖客户端再发一次。

另一类写入不进摄入队列:

flowchart TD
  record["项目、用户、API key、prompt"] --> pg["PostgreSQL"]
  media["媒体上传"] --> s3["S3"]
  media --> pg
  exportJob["批量导出"] --> s3
  codeEval["代码评估"] --> worker["Worker 进程"]
请求 路径
项目、用户、API key、prompt Web 直接写 PostgreSQL。热 prompt 的读穿缓存放在 Redis
媒体 Web 接 /api/public/media,文件进同一个 S3 桶。上传状态按内容哈希记在 PostgreSQL。单对象上限 1e9 字节,下载链接有效期 3600 秒
批量导出 LANGFUSE_S3_BATCH_EXPORT_ENABLED=true。导出文件进同一个桶,由后台任务写
代码评估 LANGFUSE_CODE_EVAL_DISPATCHER=insecure-local,队列消费者开着。TypeScript 或 JavaScript 在 Worker 进程里跑,不在 SDK 那次 HTTP 里

三类对象共用一个桶。官方不建议对媒体桶做按时间删除:trace 里的引用会断,同一文件再传也会失败,因为上传状态按哈希记在 PostgreSQL。事件可以按保留期清理,媒体要和事件分开看。这套实例没有把它们拆到不同的桶。

一次读取

列表、详情和公开查询 API 都在 Web 上结束。Worker 不参与这次 HTTP。

读请求按对象分到四套存储,不到 Worker:

flowchart TD
  req["读请求"] --> web["Web"]
  web --> ch["observation 行在 ClickHouse"]
  web --> redis["API key 与热 prompt 在 Redis"]
  web --> pg["用户、项目、prompt 原文在 PostgreSQL"]
  web --> s3["媒体是 S3 下载链接"]
读什么 先碰到 主体数据
列表和图表 Web 查 ClickHouse 的 events_core 行由 Worker 早先的批量插入写成,再由物化视图投影
单条的完整输入和输出 Web 查 events_full 同一条写入路径。HTTP 刚返回时,这行可能还没有
API key Redis 未命中则读 PostgreSQL
prompt Redis 读穿缓存 PostgreSQL
媒体 Web 签发 S3 下载链接 对象在同一个桶,链接有效期 3600 秒
登录会话 Web 走飞书 OIDC 用户和组织在 PostgreSQL。登录回调只到 Web

慢查询时,先看 ClickHouse 的资源和时间过滤。观测数据按项目和时间建索引,加上这两项条件通常比先加机器有效。过滤已经加上仍然慢,再加大 ClickHouse 的内存。官方对较大部署的建议是至少 16 GiB。这套实例没有记录 Cloud 服务的规格。

登录、查 trace 和摄入写入可以走同一组 Web Pod。扩容说明里的拆法,是把 /api/public/ingestion*、/api/public/media*、/api/public/otel* 分到另一套 Web。这套实例没有拆。摄入把这组 Pod 的 CPU 或内存打满时,页面和查询 API 一起变慢。

高可用挡住哪类故障

进程高可用靠每个 Deployment 至少两个副本、PodDisruptionBudget,以及按节点打散。数据高可用交给集群外的 RDS、Redis Serverless、S3 和 ClickHouse Cloud。集群里不再跑这四套存储。

公网入口和内网入口都只到同一个 Service,不是两套 Langfuse:

flowchart TD
  publicEntry["公网入口"] --> svc["langfuse-web Service"]
  privateEntry["内网入口"] --> svc
  svc --> web["Web Pod"]
  web --> outside["集群外的四套存储"]
  worker["Worker 无 Service"]

两条 Ingress 的 class 都是 alb,target-type 都是 ip,路径都是 /,后端都是 Service langfuse-web 的 3000 端口。健康检查都是 GET /api/public/health。公网入口听 HTTP 80 和 HTTPS 443,80 转到 443。内网入口只听 HTTP 80。登录回调地址 NEXTAUTH_URL 固定为公网入口的名字,所以从内网打开页面时,登录往返仍走公网这个名字。

Service 是 ClusterIP,选择子是 app=web。Web 的存活探针是 GET /api/public/health,就绪探针是 GET /api/public/ready。初始延迟 20 秒,周期 10 秒,超时 5 秒,失败阈值 3。没有 startup 探针。未就绪的 Web 不会留在 Service 后面。

Worker 的存活探针是 GET /api/health。没有就绪探针,也没有 Service。少一个 Worker 时,没有入口会把流量送进一个还没就绪的 Pod;另一个副本继续从同一条 Redis 队列取任务。

副本、打散和托管存储各挡一类故障。可用区不是硬约束:

flowchart TD
  entry["两条入口"] --> web["Web 至少两副本"]
  web --> pdb["PDB 至少留一个"]
  web --> host["按节点打散"]
  web --> queue["同一条 Redis 队列"]
  queue --> worker["Worker 至少两副本"]
  worker --> host
  web --> managed["RDS、Redis、S3、ClickHouse Cloud"]
  worker --> managed
  zone["可用区 ScheduleAnyway"] -.-> web
  zone -.-> worker

观察时 Web 和 Worker 都是 2 个副本。资源请求是 500m CPU、2Gi 内存,限制是 2 CPU、4Gi。滚动更新的 maxSurge 和 maxUnavailable 都是 25%。

HPA 只看 CPU,目标是请求的 70%,也就是 350m。minReplicas 是 2,maxReplicas 是 4。对象里没有 behavior。KEDA 和 VPA 关着。观察时利用率只有请求的几个百分点,副本停在 2。

PDB langfuse-web-pdb 和 langfuse-worker-pdb 的 minAvailable 都是 1。主动驱逐时,每个 Deployment 至少留一个 Pod。

拓扑约束按 hostname 和 zone 各一条,maxSkew 都是 1。hostname 不满足时 DoNotSchedule,zone 不满足时 ScheduleAnyway。打散按各自的 Deployment 计算,不要求 Web 和 Worker 互相避开。观察时两个 Web 不在同一节点,两个 Worker 也不在同一节点;有一台节点上同时有一个 Web 和一个 Worker。四个 Pod 都在同一个可用区。

失败 这套实例怎么挡 仍然叠在一起的部分
一个 Web Pod 不可用 另一副本在不同节点。未就绪 Pod 不进 Service。PDB 在主动驱逐时留一个 两个 Web 同时不可用时,入口没有后端
一个 Worker Pod 在取任务之后退出 另一副本继续消费同一条队列。HTTP 返回前,原始事件已经在 S3 队列里还没取走的引用要等剩余副本
一台节点不可用 同一 Deployment 的两个 Pod 被 hostname 约束分开 这台节点上若同时有 Web 和 Worker,两种进程各少一个副本
滚动发布 maxSurge 和 maxUnavailable 都是 25% 发布窗口里新旧 Pod 共用同一个请求上限
CPU 升高 HPA 可以把该 Deployment 从 2 扩到 4 只剩一个副本时,上限是限制值 2 CPU / 4Gi,不是请求值 500m / 2Gi
整个可用区不可用 zone 约束存在,但不强制 ScheduleAnyway,且观察时四个 Pod 都在同一个可用区
RDS、Redis、S3 或 ClickHouse 不可用 这四套在集群外,由各自的托管服务负责 chart 里没有第二套集群内存储可以接过写入
摄入流量升高 尖峰先变成 Redis 队列深度,而不是立刻打满 ClickHouse 登录和查询共用这组 Web。队列变长本身不会增加副本,见下一节

官方容器最低配置是 Web 和 Worker 各 2 CPU、4Gi 内存。这套实例的限制与这个数相同,请求低于这个数。HPA 按请求的 70% 触发,官方另有一个经验:2 CPU 的容器负载超过 50% 就可以加副本。两个阈值不是同一个 CPU 数。350m 就会开始扩容;官方那个 50% 指的是 2 CPU 容器上的 1 CPU。

容量先看队列还是 CPU

摄入变多时,先变长的是队列,不是 ClickHouse 的连接数。现网 HPA 看不到这个深度:

flowchart LR
  spike["摄入变多"] --> depth["队列 waiting 深度"]
  cpu["CPU 超过请求的 70%"] --> hpa["副本从 2 到 4"]
  depth -.-> skipped["未接入这套 HPA"]

官方用 Worker 的 CPU 做常规扩容,并用 StatsD 指标 langfuse.queue.ingestion.depth、标签 type:waiting 做队列扩容。从 Langfuse 3.170.0 起,发到 CloudWatch 时这条序列的名字是 langfuse.queue.ingestion.depth.type_waiting,开关是 ENABLE_AWS_CLOUDWATCH_METRIC_PUBLISHING。这套实例没有设置这个开关。两个 HPA 只看 CPU。队列积压本身不会把 Worker 从 2 扩到 4。

合并时的 ClickHouse 读仍在,扩容说明把这记成吞吐上限的一个来源。跳过它的条件写在上一节。另外,队列分片变量 LANGFUSE_INGESTION_QUEUE_SHARD_COUNT 和 LANGFUSE_TRACE_UPSERT_QUEUE_SHARD_COUNT 都没有设置,分片数用镜像里的默认值。官方把分片当作 Redis CPU 已经很高时的后一步:先把 Redis 放到至少 4 CPU 并打开集群模式,仍超过 90% 再分片。一旦调大,不要再调小。处理并发按分片计算,官方默认目标是每个 Worker 20。这套 Redis 已经是集群模式。

S3 的并发读和并发写都是 50。官方默认写并发是 50。写并发耗尽时,处理摄入和媒体的 Web 内存会涨,日志里会出现 socket 容量打满。再调大 LANGFUSE_S3_CONCURRENT_WRITES 时,官方要求逐级加,并同时看内存。

S3 用访问密钥,不用 Pod 身份。ServiceAccount langfuse 的 automountServiceAccountToken 是 false,注解里没有 IRSA。命名空间里没有 NetworkPolicy。这两条是访问边界,不是高可用手段。