
Langfuse 官方给了一份
docker-compose.yml,docker compose up一行就能跑起来。但那是 demo,不是生产。这篇讲清楚:为什么生产环境不能直接用它、web/worker 怎么拆开部署、把存储换成腾讯云托管服务会踩哪些坑(ClickHouse 的 cluster 名和时区是重灾区),以及怎么把它接进公司内部的账号系统。都是踩过的坑,建议收藏。
Langfuse 是一套 LLM 可观测平台(trace / 评估 / prompt 管理)。它的官方 docker-compose 把应用和所有有状态组件塞进了一台机器:
# 官方 compose 里同机起的容器(简化) langfuse-web # 应用:UI + API langfuse-worker # 应用:后台消费 postgres # 元数据库 clickhouse # trace 分析库(单机) redis # 队列 / 缓存 minio # 对象存储(S3 兼容)一句话:官方 compose 的价值是「五分钟看到效果」,代价是「所有鸡蛋放一个篮子、且这个篮子你得自己看着」。
它不能直接上生产,核心是三件事:

所以生产方案的总思路就一句话:应用层(web/worker)自己以镜像方式部署、可弹性伸缩;有状态组件全部交给云上托管服务。
Langfuse 应用层是两个独立镜像,职责分明,必须分开部署:
组件 | 官方镜像 | 职责 | 伸缩依据 |
|---|---|---|---|
langfuse-web | langfuse/langfuse:3 | 提供 UI、tRPC、对外 REST 摄取接口 | 看用户请求 / SDK 上报 QPS |
langfuse-worker | langfuse/langfuse-worker:3 | 消费队列、批量写 ClickHouse、跑评估等后台任务 | 看摄取量 / 队列积压 |
为什么必须拆:web 是「前台接客」,worker 是「后厨出餐」。把它们分成两个 Deployment,各自配 HPA,前台流量高就加 web、上报量大就加 worker,互不拖累。
它俩共享同一套环境变量(连同一个 PG / ClickHouse / Redis / 对象存储),但作为两个 workload 独立调度。我们的做法是在容器平台上各起一个 Deployment + Service,web 前面挂网关/Ingress 暴露 3000 端口,worker 不对外。
Langfuse v3 的摄取是异步的,这决定了组件怎么连:

四个有状态组件,缺一不可。看清这条链路,就知道为什么后面 ClickHouse 的坑最致命——它是 trace 的最终归宿。
我们把四个有状态组件全部换成腾讯云的托管产品:
组件 | 替换为 | 关键收益 |
|---|---|---|
PostgreSQL | 云数据库 PostgreSQL | 主备 / 备份 / 监控开箱即用 |
Redis | 云数据库 Redis | 高可用、免运维 |
ClickHouse | 腾讯云数据仓库 TCHouse-C | 集群化、副本、扩容托管 |
MinIO | 对象存储 COS(S3 兼容) | 海量、低成本、生命周期管理 |
应用侧只是改一组连接环境变量,把 DATABASE_URL、CLICKHOUSE_URL、REDIS_HOST、LANGFUSE_S3_* 指向云上实例即可——理论上。实际上,ClickHouse 这一项让我们折腾了好几天。
结论先行:托管 ClickHouse 和官方默认假设不一致的两个点——集群名不叫
default、服务器时区不是 UTC——会分别让你「初始化建表直接失败」和「界面上所有时间戳偏移 8 小时」。
default,迁移脚本写死了现象:跑 ClickHouse 初始化迁移(建表)直接报错,建不出表。
根因:Langfuse 的集群版迁移 SQL 里,建表语句把集群名硬编码成了 ON CLUSTER default:
CREATE TABLE traces ON CLUSTER default ( ... ) ENGINE = ReplicatedMergeTree ...而我们用的腾讯云数据仓库 TCHouse-C,默认集群名并不是 default,是 default_cluster。集群名对不上,ON CLUSTER 找不到目标,迁移全线失败。
怎么改:迁移脚本本来已经接受一个 CLICKHOUSE_CLUSTER_NAME 环境变量(用于连接串),但 SQL 文件里的 ON CLUSTER default 没跟着它走。我们的改造思路是——不动上游 SQL 源文件,而是在执行迁移前把它们「渲染」一遍:
clustered/*.sql 全部拷到一个临时目录;sed 把里面的 ON CLUSTER default 替换成 ON CLUSTER ${CLICKHOUSE_CLUSTER_NAME};trap 清理临时目录。# up.sh / down.sh:执行迁移前渲染集群名(核心片段) RENDERED_DIR="$(mktemp -d)" trap 'rm -rf "$RENDERED_DIR"' EXIT for f in clickhouse/migrations/clustered/*.sql; do sed "s|ON CLUSTER default|ON CLUSTER ${CLICKHOUSE_CLUSTER_NAME}|g" \ "$f" > "${RENDERED_DIR}/$(basename "$f")" done migrate -source "file://${RENDERED_DIR}" -database "$DATABASE_URL" up
为什么这么干而不是直接改 SQL:上游 SQL 是会随版本升级的,直接改源文件会和每次升级冲突。渲染方案做到了对上游零侵入——配置名还是
default时整个替换是 no-op,行为完全不变;只有你显式设了别的集群名才生效。
现象:trace 接进来后,界面上显示的时间统一比真实时间晚/早 8 小时,dashboard 的时间筛选、now() 比较全乱。
根因(这个最隐蔽):
一句话:Langfuse 全程假设「ClickHouse 说的是 UTC」,但 ClickHouse 的
DateTime列不带时区,一旦服务器时区是Asia/Shanghai,它就用本地时区去格式化这些「裸时间」,于是凭空多出 8 小时偏移。
拆开说:
Date.toISOString()(UTC),读出时给字符串补个 Z 当 UTC 解析——两端都按 UTC;DateTime / DateTime64 列没有显式时区;Asia/Shanghai,于是它在读出这些裸时间时,按服务器本地时区格式化字符串;Z,就偏了 8 小时。
踩坑过程(绕了一圈,最后回到配置):
一开始我们都在应用代码侧找补:试过在读取解析处按一个时区环境变量把裸时间反推回 UTC(用 Intl 做 DST 感知换算),也试过在 ClickHouse 客户端会话上加 session_timezone: "UTC"。这些都能修对,但本质上都是在迁就一个时区不对的服务器——逻辑绕、覆盖面零碎,还得当心边界。
最终发现:根因既然是「服务器时区不是 UTC」,那最干净的解法就是把服务器时区改成 UTC,而这跟 Langfuse 代码一点关系都没有。TCHouse-C 实例默认 <timezone>Asia/Shanghai</timezone>,在控制台的配置参数里把它改成 UTC 即可:
# ClickHouse 服务端配置(config.xml)—— 改这一行,重启生效 - <timezone>Asia/Shanghai</timezone> + <timezone>UTC</timezone>改完 ClickHouse 自己就说 UTC 了,裸 DateTime 的格式化、now() 比较、分区全部回归 UTC,应用侧零改动、那些代码侧的找补也可以撤掉。
绕了一圈的教训:「整体偏 8 小时」这类问题,根因在 ClickHouse 服务端时区,就别在应用层到处补换算——直接把服务端
<timezone>参数改成 UTC,一个配置项解决,还和代码彻底解耦。
结论先行:不要去改 Langfuse 的登录代码。Langfuse 基于 NextAuth,原生支持「自定义 OIDC 身份提供方」。对接任何账号系统的正确姿势,是给它喂一个标准 OIDC Provider,而不是侵入它的认证逻辑。
Langfuse 内置了一个 CustomSSOProvider,只要配齐几个环境变量,它就会把你的身份系统当成一个标准 OIDC 登录入口:
AUTH_CUSTOM_NAME="公司账号" # 登录按钮显示名 AUTH_CUSTOM_CLIENT_ID=... # OIDC client AUTH_CUSTOM_CLIENT_SECRET=... AUTH_CUSTOM_ISSUER=https://your-idp/oidc # 关键:指向你的 IdP AUTH_CUSTOM_SCOPE="openid email profile" AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING=true # 同 email 自动关联账号AUTH_CUSTOM_ISSUER 指向哪儿,Langfuse 就会去拉那个地址的 /.well-known/openid-configuration,按标准 OIDC 流程走授权码、换 token、取 userinfo。所以问题就转化成:搭一个 IdP,对外说标准 OIDC。
很多公司内网有统一的接入网关:用户在网关侧已经登录了,网关会把身份信息透传给后端——但用的是私有协议(比如一个加密的自定义 HTTP 头),而不是 OIDC。
于是我们做了一个 OIDC Bridge(桥接服务):它的唯一职责,就是把网关的私有身份协议,翻译成标准 OIDC。Langfuse 完全不知道网关的存在,它只看到一个规规矩矩的 OIDC IdP。

Bridge 对外暴露的就是一套标准 OIDC 端点:
端点 | 作用 | 桥接做的事 |
|---|---|---|
/.well-known/openid-configuration | OIDC 元数据发现 | 返回自己的端点清单 |
/jwks | 验签公钥集 | 暴露 RS256 公钥 |
/authorize | 授权 | 读网关透传的身份头 → 直接 302 回调,无登录页 |
/token | 换 token | 授权码 + PKCE 换发 id_token |
/userinfo | 取用户信息 | 反查并返回用户档案 |
整套对接的精髓是「无感登录」:用户在网关侧已经是登录态,Bridge 的
/authorize直接拿网关透传的身份、跳过登录页 302 回去。用户点一下「公司账号登录」,唰地就进来了,不用再输一次密码。
实现上有几个要点值得说(不依赖任何具体账号系统):
工号/登录名@公司域名),再靠 ALLOW_ACCOUNT_LINKING 让同 email 自动关联到 Langfuse 账号;id_token,并通过 /jwks 暴露公钥,Langfuse 按标准 OIDC 验签,全程不需要私有 SDK。接进公司账号后,所有员工都能一键登录了,但你通常不希望人人都能创建顶级组织。Langfuse 把「创建组织」的能力归在企业版 entitlement 里,社区版不好单独控制。
我们的做法是加一个不依赖企业版的白名单开关:用环境变量 SSV_ALLOWED_ORG_CREATORS 配一组允许创建组织的 email,把判定收敛成单一数据源,API 层做 403 兜底、前端非白名单用户直接禁用入口。
# 留空 = 不限制;配了就只有名单内的人能创建组织 SSV_ALLOWED_ORG_CREATORS=alice@corp.com,bob@corp.com一句话:账号系统负责「谁能进来」,白名单负责「进来后谁能开新组织」,两层分开,互不耦合。

问题 | 现象 | 根因 | 解法 |
|---|---|---|---|
架构 | compose 不可上生产 | 应用与存储同机、无 HA/备份 | web/worker 拆镜像部署,存储全用云托管 |
集群名 | 迁移建表失败 | SQL 写死 ON CLUSTER default | 迁移前 sed 渲染成 ${CLICKHOUSE_CLUSTER_NAME} |
时区 | 时间偏 8 小时 | 服务器非 UTC,裸 DateTime 被本地化 | 改 TCHouse-C 服务端 <timezone> 为 UTC |
登录 | 要接内部账号 | 网关用私有协议非 OIDC | OIDC Bridge 翻译 + AUTH_CUSTOM_* 配置 |
权限 | 人人可建组织 | 能力绑在企业版 | 加 SSV_ALLOWED_ORG_CREATORS 白名单 |
把 Langfuse 跑起来只要一行 compose;把它做成生产可用,要做的是「应用与存储解耦、有状态组件托管化、对接好账号体系」,再把云厂商和上游默认假设之间的缝(集群名、时区)一个个填平。
三条经验留给后来者:
AUTH_CUSTOM_*)配置说明<timezone> 参数与 DateTime 列的时区语义