Langfuse 连接 Dify 专题
本文记录在
192.168.110.207上,把已经跑起来的 Langfuse 4.12.0 接到 Dify 1.16.1,把应用问答的日志和 Trace 推到 Langfuse 后台。Langfuse 本身怎么安装,见同目录
Langfuse_部署教程_cursor.md。本文只写「连接」:原理、密钥、Dify 配置、v4 兼容、补丁、验收和排错。本次实际结果:在 Dify 问一句知识库问题后,Langfuse Traces 里能看到完整调用、用户 Prompt 和 Token 消耗。
实战踩坑、完整配置和补丁代码见第八章。
运行环境:业务脚本均在 207 固定目录
/opt/workbuddy执行,Python 为/usr/bin/python3。 Windows 开发机仅保留_probe_*调试脚本;同步命令:python scripts_cursor/完整代码约定/00_部署_workbuddy到207.py。
一、本文解决什么问题
目标只有一件事:
Dify 里问一个问题 → Langfuse 后台能看到这条完整调用链路(Trace),能看到 Prompt 内容和 Token 消耗。
不是把 Dify 日志文件拷过去,也不是给 Langfuse 配一个大模型。两边各自已经能工作,缺的是一条「观测管道」。
验收时必须同时看到这三项:
- Trace:一条以
message为名的调用记录,能点进去。 - Prompt:用户原话(以及模型回复)出现在 Trace / Generation 的 Input / Output。
- Token:Generation 上有
promptTokens/completionTokens/totalTokens。
二、本次环境一览
| 项 | 值 |
|---|---|
| 服务器 | 192.168.110.207 |
| Dify | 1.16.1,入口 http://192.168.110.207(80/443) |
| Langfuse | 4.12.0,入口 http://192.168.110.207:3000 |
| 对接应用 | 播城行政问答智能体 |
| 应用 ID | 40e95fd3-e7f5-44f2-a03b-dc3e60413540 |
| Agent ID | 01a012e7-bed3-7574-b0d1-a5cc79a0d3de |
| 应用类型 | 新 Agent(mode=agent) |
| Langfuse 项目 | 组织 dckj,项目 default |
| Langfuse 登录 | admin@example.com / Langfuse123456 |
| Dify 控制台 | 38205036@qq.com / Difyai123456 |
| Langfuse Public Key | pk-lf-84a76d89-b6b4-4943-b4ea-2dee48d4cda7 |
| Langfuse Secret Key | sk-lf-24aa6d73-ed36-4447-9eb6-3ebd446fb298 |
| Dify 填的 Host | http://192.168.110.207:3000 |
| 验收 Trace | http://192.168.110.207:3000/project/default/traces/1f339a2f-bbda-4042-8e99-1d66bbc6faba |
相关容器:
| 容器 | 作用 |
|---|---|
docker-api-1 | Dify API,保存 Tracing 配置、发起对话 |
docker-worker-1 | Dify Celery,真正把 Trace 推到 Langfuse(队列名 ops_trace) |
docker-agent_backend-1 | 新 Agent 运行时,本身不直接打 Langfuse |
langfuse-langfuse-web-1 | Langfuse 控制台 + 接收 /api/public/ingestion |
langfuse-langfuse-worker-1 | Langfuse 后台任务、dual-write 传播 |
langfuse-clickhouse-1 | 真正存 Trace / Observation |
langfuse-postgres-1 | Langfuse 用户、项目、API Key |
三、先记住三件反直觉的事实
后面所有步骤都围绕这三件事。搞反了,界面上填得再对也看不到 Trace。
1. Dify 1.16 不是靠全局环境变量上报
很多人会在 Dify 的 .env 里写 LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY,以为重启就通。
Dify 1.16 官方对接路径是按应用配置,不是全局 env。
真正生效的位置:
- 本次实操:控制台 API(新 Agent 工作室经常找不到「监测」菜单,见步骤 3)
- 旧版聊天助手 / 工作流:应用里可能有 监测(Monitoring) → Tracing app performance
- 数据库:
apps.tracing+trace_app_config
.env 里那三项只是备查,不重启 Dify 也不会生效,重启了 Dify 1.16 也不会读它们来推 Trace。
2. Host 绝对不能写成 localhost 或 127.0.0.1
Dify 的 api / worker 跑在容器里。容器里的 localhost 是容器自己,不是 207 宿主机,也不是 Langfuse。
正确 Host:
http://192.168.110.207:3000错误 Host(常见):
http://localhost:3000
https://cloud.langfuse.com
http://langfuse-web:3000 # Dify 和 Langfuse 不在同一个 docker network3. Langfuse 4 默认拒收 Dify 现在用的旧上报接口
Dify 1.16 自带的 dify_trace_langfuse 插件,走的是 Langfuse v3 风格 REST:
POST /api/public/ingestion
Authorization: Basic <public_key:secret_key>Langfuse 4 新部署默认 LANGFUSE_MIGRATION_V4_WRITE_MODE=events_only。这个模式下,旧 ingestion 会被直接丢掉,web 日志里会出现:
Rejected ... event(s) from the legacy /api/public/ingestion endpoint
... this Langfuse v4 deployment runs in events_only modeCelery 任务仍会显示 Processing trace tasks success,因为 Dify 只关心 HTTP 没抛异常,并不知道 Langfuse 把事件扔了。
解决办法:Langfuse web 和 worker 都改成 dual。
四、原理:数据到底怎么从 Dify 流到 Langfuse
4.1 一张图看完
浏览器
| 问一句「公司请假流程是什么」
v
Dify Nginx :80
| POST /console/api/agent/<agent_id>/chat-messages
v
docker-api-1
| 新 Agent 实际推理交给 agent_backend
| 对话结束后,看 apps.tracing 是否 enabled + langfuse
v
把 trace 任务丢进 Redis 队列 ops_trace
v
docker-worker-1 (Celery: tasks.ops_trace_task.process_trace_tasks)
| 从 trace_app_config 取出并解密 public_key / secret_key / host
| LangFuseDataTrace.add_trace() -> type=trace-create
| LangFuseDataTrace.add_generation() -> type=generation-create
v
HTTP Basic Auth
POST http://192.168.110.207:3000/api/public/ingestion
v
langfuse-langfuse-web-1
| dual 模式下写入旧表 traces / observations
v
ClickHouse
v
浏览器打开 Langfuse Traces 页面看到这条链路新 Agent 的 agent_backend 没有 Langfuse SDK。它只负责跑模型和工具。观测是 Dify API 在对话结束后,由 Celery worker 异步补报的。所以:
- 只重启
api、不重启worker,配置改了也可能暂时推不出去。 worker必须能访问192.168.110.207:3000。本次已验证可以。
4.2 Dify 侧两张「开关」
光有密钥不够,Dify 用两层配置。
第一层:这个应用开不开 Tracing、用哪家。
表 apps 的 tracing 列,JSON 形如:
{"enabled": true, "tracing_provider": "langfuse"}对应控制台接口:
GET /console/api/apps/<app_id>/trace
POST /console/api/apps/<app_id>/trace
Body: {"enabled": true, "tracing_provider": "langfuse"}这一层关掉,Celery 根本不会去推。
第二层:Langfuse 的具体密钥和地址。
表 trace_app_config:
| 列 | 含义 |
|---|---|
app_id | 应用 ID |
tracing_provider | 固定 langfuse |
tracing_config | JSON,里面的 public_key / secret_key 会按租户加密存放 |
is_active | 默认 true |
对应控制台接口:
GET /console/api/apps/<app_id>/trace-config?tracing_provider=langfuse
POST /console/api/apps/<app_id>/trace-config
PATCH /console/api/apps/<app_id>/trace-config
DELETE /console/api/apps/<app_id>/trace-config?tracing_provider=langfusePOST / PATCH 的 body:
{
"tracing_provider": "langfuse",
"tracing_config": {
"public_key": "pk-lf-...",
"secret_key": "sk-lf-...",
"host": "http://192.168.110.207:3000"
}
}Dify 保存前会做 check_trace_config_is_effective:用这对密钥去调 Langfuse 的 auth_check / 拉 project。密钥错、Host 不通、Langfuse 未启动,这里会直接返回 Invalid Credentials,配置存不进去。
读取配置给前端看时,密钥会被打码,例如 pk-lf-************a7。
4.3 Dify 插件到底上报了什么
插件包在容器里:
/app/api/.venv/lib/python3.12/site-packages/dify_trace_langfuse/langfuse_trace.py类 LangFuseDataTrace 用 Langfuse Python SDK 的 ingestion.batch,不是 OTLP。
一次普通问答至少会发两种事件:
| 事件 | Langfuse 里长什么样 | 主要内容 |
|---|---|---|
trace-create | Trace,name=message | 用户问题、回答、message_tokens / answer_tokens / total_tokens、会话 ID |
generation-create | Observation,type=GENERATION,name=llm | 模型名、Input(用户话)、Output(回答)、usage.input/output/total |
如果这次调用还检索了知识库或跑了工具,理论上还会有 span-create(dataset_retrieval / 工具名)。新 Agent 当前这条路径里,稳定能看到的是 Trace + llm Generation。
必须说清楚的一点:
Dify 上报的 Prompt 是用户输入 trace_info.inputs,不是拼好的完整 system prompt。 Generation 上的 Token 数(例如 input 6601)才反映真实送进模型的长度。不要期望在 Langfuse 里看到 RAG 拼进去的整份制度原文,那是 Dify 插件的设计,不是配置漏了。
4.4 Langfuse 4 的三种写入模式
环境变量:LANGFUSE_MIGRATION_V4_WRITE_MODE
| 值 | 行为 | 和 Dify 的关系 |
|---|---|---|
events_only(4.x 新部署默认) | 只接受新 SDK / OTLP;旧 /api/public/ingestion 的 trace/generation 被拒绝 | Dify 1.16 不可用 |
dual | 旧接口继续写入 traces / observations,同时向新表做传播 | 本次采用,Dify 立即可用 |
legacy | 只走旧表 | 也能接 Dify,但丢掉 v4 新能力 |
本次还加了:
完整 .env 片段:patches_cursor/Langfuse_连接Dify专题/Langfuse_内联_01.env
运行/引用:patches_cursor/Langfuse_连接Dify专题/Langfuse_内联_01.env
LANGFUSE_MIGRATION_V4_ALLOW_PREVIEW_OPT_IN=false目的是让控制台继续走 v3 读体验,Traces 列表能马上看到刚写入的旧表数据,而不必等 dual-write 传播到新 events 表(旧 SDK 传播大约有十几分钟延迟)。
这两个变量必须同时进 web 和 worker。官方 compose 里 worker 的环境块被 web 用 YAML <<: *langfuse-worker-env 继承,所以加在 worker 环境锚点上即可。
4.5 为什么 Generation 一开始会丢
Langfuse 4 处理 ingestion 时:body.id 为空的 observation 直接跳过,不入库。
Dify 插件创建 Generation / Span 时,经常不填 id,只填 trace_id。结果是:
- Trace 能进(Trace 用了
message_id当 id) - llm Generation 进不去
- web 日志可能只有一句很干的
Error processing events - 页面上只能看到一条没有子节点的 Trace,Token 只出现在 Trace.input 的 JSON 里,Observation 列表是空的
修复:在 add_span / add_generation 里,如果没有 id 就生成 UUID。这是容器内 site-packages 补丁,重建 docker-api-1 / docker-worker-1 会丢,重建后要再打一次。
4.6 密钥是怎么来的
Langfuse 项目 Settings 里可以点 Create API Keys。本次用的是无头初始化,避免再走一遍 UI:
完整 .env 片段:patches_cursor/Langfuse_连接Dify专题/Langfuse_内联_02.env
运行/引用:patches_cursor/Langfuse_连接Dify专题/Langfuse_内联_02.env
LANGFUSE_INIT_PROJECT_PUBLIC_KEY=pk-lf-...
LANGFUSE_INIT_PROJECT_SECRET_KEY=sk-lf-...这两个必须成对,前缀必须是 pk-lf- 和 sk-lf-。写进 /Langfuse/langfuse/.env 后 重建 langfuse-web,启动脚本会在项目 default 下插入一条 Provisioned API Key。Secret 只在创建时完整可见,库里存的是哈希,display_secret_key 只留末几位。
4.7 控制台语言
Langfuse 4.12 界面是写死的英文,没有中文包,也没有 LANGFUSE_LOCALE。数据(中文 Prompt、中文回答)可以正常显示。若菜单要中文,用浏览器「翻译此页」。
五、前置条件
开始对接前确认:
- Langfuse 健康检查返回 OK:
完整脚本:scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_01.sh
运行/引用:bash /opt/workbuddy/scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_01.sh
#!/usr/bin/env bash
set -euo pipefail
curl -s http://127.0.0.1:3000/api/public/health
# 期望: {"status":"OK","version":"4.12.0"}- 浏览器能打开
http://192.168.110.207:3000,能用admin@example.com登录。 - Dify 能正常问答(模型、知识库已经通)。
docker-api-1、docker-worker-1在跑。
Langfuse 还没装的,先做 Langfuse_部署教程_cursor.md,再回到本文。
六、操作步骤(按顺序做,不要跳)
阶段与验收
| 阶段 | 步骤 | 做完应看到 |
|---|---|---|
| 环境 | 0~1 | Langfuse+ Dify api/worker 都在 |
| 配置 | 2~3 | trace-config 写入 pk/sk,trace 开关 true |
| 补丁 | 4~5 | worker/api 内 langfuse_trace.py 已 patch |
| 验收 | 6 | Langfuse UI 有 Trace;observations 含 tool |
| 现象 | 优先查 |
|---|---|
| Trace 开关开了但没有数据 | §八 问题 1~3;Host 勿写 localhost |
| 只有 Generation 无 Tool | 步骤 5 补丁+worker 重启;作业 3 |
| Invalid Credentials | INIT pk/sk 与 Dify 填的一致 |
| 升级后全丢 | §十 重启复查清单 |
步骤 0:确认两边容器都在
在 207 上:
完整脚本:scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_02.sh
运行/引用:bash /opt/workbuddy/scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_02.sh
#!/usr/bin/env bash
set -euo pipefail
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' | grep -E 'langfuse|docker-api-1|docker-worker-1'至少要看到:
langfuse-langfuse-web-1Up,端口0.0.0.0:3000langfuse-langfuse-worker-1Updocker-api-1Updocker-worker-1Up
再测 Dify 容器访问 Langfuse:
完整脚本:scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_03.sh
运行/引用:bash /opt/workbuddy/scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_03.sh
#!/usr/bin/env bash
set -euo pipefail
docker exec docker-api-1 curl -s -m 8 http://192.168.110.207:3000/api/public/health
docker exec docker-worker-1 curl -s -m 8 http://192.168.110.207:3000/api/public/health两边都应返回 {"status":"OK","version":"4.12.0"}。worker 不通的话,后面 Celery 推送会静默失败。
步骤 1:生成 Langfuse Public Key / Secret Key
如果库里已经有 key,可以跳过生成,直接查:
完整脚本:scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_04.sh
运行/引用:bash /opt/workbuddy/scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_04.sh
#!/usr/bin/env bash
set -euo pipefail
docker exec langfuse-postgres-1 psql -U postgres -d postgres -c \
"SELECT id, public_key, note, project_id, display_secret_key FROM api_keys;"本次当时是空的,用初始化变量创建。
1.1 生成一对符合前缀的密钥
完整脚本:scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_05.sh
运行/引用:bash /opt/workbuddy/scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_05.sh
#!/usr/bin/env bash
set -euo pipefail
python3 - <<'PY'
import uuid
print("pk-lf-" + str(uuid.uuid4()))
print("sk-lf-" + str(uuid.uuid4()))
PY本次实际值:
pk-lf-84a76d89-b6b4-4943-b4ea-2dee48d4cda7
sk-lf-24aa6d73-ed36-4447-9eb6-3ebd446fb2981.2 写入 /Langfuse/langfuse/.env,不要加引号
追加(已有则改值,不要重复两行):
完整 .env 片段:patches_cursor/Langfuse_连接Dify专题/Langfuse_内联_03.env
运行/引用:patches_cursor/Langfuse_连接Dify专题/Langfuse_内联_03.env
LANGFUSE_INIT_PROJECT_PUBLIC_KEY=pk-lf-84a76d89-b6b4-4943-b4ea-2dee48d4cda7
LANGFUSE_INIT_PROJECT_SECRET_KEY=sk-lf-24aa6d73-ed36-4447-9eb6-3ebd446fb298注意:官方踩过坑,Docker Compose 里这些值 不要用双引号包起来,否则可能被当成非法邮箱/非法 key 一类问题。
1.3 重建 web,让初始化脚本跑一遍
完整脚本:scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_06.sh
运行/引用:bash /opt/workbuddy/scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_06.sh
#!/usr/bin/env bash
set -euo pipefail
cd /Langfuse/langfuse
docker compose -p langfuse up -d langfuse-web等健康检查 OK 后再查 api_keys。note 应为 Provisioned API Key,project_id 为 default。
Secret Key 只在这一刻完整持有。以后 UI 里只能看到 sk-lf-...xxxx。请把完整 sk 存到安全的地方,本文和 langfuse_deploy_creds.txt 已记下本次值。
也可以在 Langfuse UI 里创建:登录 → 选项目 default → Settings → API Keys → Create new API keys。效果相同。
步骤 2:把 Langfuse 4 改成 dual 写入
这一步不做,Dify 推上去的事件会被拒。
2.1 .env 追加
完整 .env 片段:patches_cursor/Langfuse_连接Dify专题/Langfuse_内联_04.env
运行/引用:patches_cursor/Langfuse_连接Dify专题/Langfuse_内联_04.env
LANGFUSE_MIGRATION_V4_WRITE_MODE=dual
LANGFUSE_MIGRATION_V4_ALLOW_PREVIEW_OPT_IN=false2.2 docker-compose.yml 必须显式传入
官方 compose 不会自动把所有 .env 变量灌进容器,只传它列出的那些。要在 langfuse-worker 的 environment: &langfuse-worker-env 里加上(web 会继承):
完整 compose 片段:patches_cursor/Langfuse_连接Dify专题/Langfuse_内联_01.yaml
运行/引用:patches_cursor/Langfuse_连接Dify专题/Langfuse_内联_01.yaml
LANGFUSE_MIGRATION_V4_WRITE_MODE: ${LANGFUSE_MIGRATION_V4_WRITE_MODE:-dual}
LANGFUSE_MIGRATION_V4_ALLOW_PREVIEW_OPT_IN: ${LANGFUSE_MIGRATION_V4_ALLOW_PREVIEW_OPT_IN:-false}建议插在 TELEMETRY_ENABLED 后面。
2.3 重建 web 和 worker
完整脚本:scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_07.sh
运行/引用:bash /opt/workbuddy/scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_07.sh
#!/usr/bin/env bash
set -euo pipefail
cd /Langfuse/langfuse
docker compose -p langfuse up -d langfuse-web langfuse-worker2.4 确认容器内真的是 dual
完整脚本:scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_08.sh
运行/引用:bash /opt/workbuddy/scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_08.sh
#!/usr/bin/env bash
set -euo pipefail
docker exec langfuse-langfuse-web-1 printenv LANGFUSE_MIGRATION_V4_WRITE_MODE
docker exec langfuse-langfuse-worker-1 printenv LANGFUSE_MIGRATION_V4_WRITE_MODE都必须输出 dual。只改 .env 却没改 compose、或只重建了 web 没重建 worker,都会继续拒收。
步骤 3:给应用打开 Langfuse Tracing(本次实操走 API)
先说结论:这一步不是在新 Agent 工作室里点菜单完成的。
本环境应用是 Dify 1.16 新 Agent(mode=agent,工作室是配置 / 知识 / 工具,没有旧版「监测 / Tracing app performance」入口)。在工作室里找不到这段 UI 是正常的。本次把密钥和开关写进去,用的是 控制台 API,脚本是仓库根目录的 configure_dify_langfuse.py(内部走 dify_api.py:密码 base64、cookie + X-CSRF-Token)。
两步缺一不可:
POST /console/api/apps/<app_id>/trace-config:写入 pk / sk / host(表trace_app_config)POST /console/api/apps/<app_id>/trace:打开开关(表apps.tracing)
只做第 1 步,Celery 不会推。只做第 2 步,没有密钥,保存时 check_trace_config_is_effective 会 Invalid Credentials。
3.1 字段(和 UI 表单同一套)
| 字段 | 本次值 |
|---|---|
| 应用 ID | 40e95fd3-e7f5-44f2-a03b-dc3e60413540(播城行政问答智能体) |
| Secret Key | sk-lf-24aa6d73-ed36-4447-9eb6-3ebd446fb298 |
| Public Key | pk-lf-84a76d89-b6b4-4943-b4ea-2dee48d4cda7 |
| Host | http://192.168.110.207:3000(不要写成 localhost) |
3.2 完整 API 步骤(照这个做)
在 Windows 工作区、仓库根目录执行(dify_api.py 会 SSH 到 207 上 curl):
完整程序:/opt/workbuddy/scripts_cursor/Langfuse_连接Dify专题/Langfuse_内联_01.py
运行/引用:/usr/bin/python3 /opt/workbuddy/scripts_cursor/Langfuse_连接Dify专题/Langfuse_内联_01.py
from dify_api import Dify, pp
APP_ID = "40e95fd3-e7f5-44f2-a03b-dc3e60413540"
PK = "pk-lf-84a76d89-b6b4-4943-b4ea-2dee48d4cda7"
SK = "sk-lf-24aa6d73-ed36-4447-9eb6-3ebd446fb298"
HOST = "http://192.168.110.207:3000"
d = Dify("38205036@qq.com", "Difyai123456")
ok, _, _ = d.login()
print("login", ok)
# 1) 看当前开关
pp("current trace", d.get("/apps/%s/trace" % APP_ID))
pp("current config", d.get("/apps/%s/trace-config?tracing_provider=langfuse" % APP_ID))
# 2) 写入密钥。没有配置则 POST,已有则 PATCH
body = {
"tracing_provider": "langfuse",
"tracing_config": {
"public_key": PK,
"secret_key": SK,
"host": HOST,
},
}
cfg = d.get("/apps/%s/trace-config?tracing_provider=langfuse" % APP_ID)
if (cfg.get("json") or {}).get("has_not_configured"):
r = d.post("/apps/%s/trace-config" % APP_ID, body=body)
pp("create trace-config", r)
else:
r = d.req("PATCH", "/apps/%s/trace-config" % APP_ID, body=body)
pp("update trace-config", r)
if r["code"] not in ("200", "201"):
r = d.post("/apps/%s/trace-config" % APP_ID, body=body)
pp("create after failed patch", r)
# 3) 打开总开关
r = d.post(
"/apps/%s/trace" % APP_ID,
body={"enabled": True, "tracing_provider": "langfuse"},
)
pp("enable tracing", r)
# 4) 回读:密钥会被打码,并出现 project_url
pp("trace after", d.get("/apps/%s/trace" % APP_ID))
pp("config after", d.get("/apps/%s/trace-config?tracing_provider=langfuse" % APP_ID))
d.close()保存时 Dify 会立刻用这对密钥访问 Langfuse。Host 不通、密钥错、Langfuse 未启动,这里就会 Invalid Credentials,配置存不进去。
成功回读时密钥打码,并出现:
http://192.168.110.207:3000/project/default期望 HTTP:trace-config 为 200/201,/trace 为 200。GET /apps/<id>/trace 应类似:
{"enabled": true, "tracing_provider": "langfuse"}3.3 用 SQL 核对落库
完整脚本:scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_09.sh
运行/引用:bash /opt/workbuddy/scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_09.sh
#!/usr/bin/env bash
set -euo pipefail
docker exec docker-db_postgres-1 psql -U postgres -d dify -c \
"SELECT id, name, tracing FROM apps;"
docker exec docker-db_postgres-1 psql -U postgres -d dify -c \
"SELECT app_id, tracing_provider, is_active FROM trace_app_config;"期望:
apps.tracing={"enabled": true, "tracing_provider": "langfuse"}trace_app_config有一行tracing_provider=langfuse
tracing_config 在库里是加密的,不要用 SQL 改密钥,改走上面的 POST/PATCH。
3.4 对应的原始路径(给 curl / 其它客户端)
dify_api.py 会把下面的 path 接到 http://127.0.0.1/console/api:
GET /console/api/apps/<app_id>/trace
POST /console/api/apps/<app_id>/trace
GET /console/api/apps/<app_id>/trace-config?tracing_provider=langfuse
POST /console/api/apps/<app_id>/trace-config
PATCH /console/api/apps/<app_id>/trace-config登录必须:密码 base64;后续请求带 cookie 和头 X-CSRF-Token。缺一则 403。完整登录见专题九。
3.5 旧版应用如果看得到 UI,可以当对照
仅 聊天助手 / 工作流 / 旧 Agent 左侧或顶部可能有 监测 / Monitoring / Tracing app performance。字段与 3.1 相同,保存后再开总开关。新 Agent 工作室找不到该页时,不要在配置页里乱点发布,直接走 3.2。
步骤 4:(可选)把密钥抄进 Dify .env 备查
文件:/dify/dify-main/docker/.env
完整 .env 片段:patches_cursor/Langfuse_连接Dify专题/Langfuse_内联_05.env
运行/引用:patches_cursor/Langfuse_连接Dify专题/Langfuse_内联_05.env
LANGFUSE_HOST=http://192.168.110.207:3000
LANGFUSE_PUBLIC_KEY=pk-lf-84a76d89-b6b4-4943-b4ea-2dee48d4cda7
LANGFUSE_SECRET_KEY=sk-lf-24aa6d73-ed36-4447-9eb6-3ebd446fb298再次强调:Dify 1.16 不会用这三项推 Trace。不要以为写了 env 就可以不配应用监测。也不必为了这三项去重启整个 Dify。
步骤 5:给 Dify 插件补上 Observation ID
不打补丁时:Trace 在,llm Generation 经常不在,Token 只能在 Trace.input JSON 里看到,页面不像「完整调用链路」。
本节给出 完整操作步骤、修改位置、改前/改后完整函数。第八章坑 5 是同一件事的复盘;这里按「照着做」来写。
5.1 要改什么、为什么
Langfuse 4 接收 generation-create / span-create 时,如果 body.id 为空,事件会被直接丢掉。
Dify 1.16 插件上报 Trace 时用了 message_id 当 Trace id,所以 Trace 能进。Generation / Span 经常不填 id,只填 trace_id,所以子节点进不去。
补丁只做一件事:构造 CreateSpanBody / CreateGenerationBody 之前,如果没有 id,就生成一个 UUID。
5.2 改哪个文件、哪两个函数
容器内路径(两个容器里路径相同):
/app/api/.venv/lib/python3.12/site-packages/dify_trace_langfuse/langfuse_trace.py| 项 | 值 |
|---|---|
| 容器 | docker-worker-1(必须改,真正推送)+ docker-api-1(一起改,避免以后行为不一致) |
| 类 | LangFuseDataTrace |
| 函数 1 | add_span |
| 函数 2 | add_generation |
| 文件顶部 | 已经有 import uuid,不要再加 import |
| 本次 1.16.1 里大约行号 | add_span 在 def add_span,add_generation 在 def add_generation(以 grep 为准,不要死记行号) |
5.3 改前先确认:文件在、还没打过补丁
在 207 上执行:
完整脚本:scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_10.sh
运行/引用:bash /opt/workbuddy/scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_10.sh
#!/usr/bin/env bash
set -euo pipefail
# 1. 两个容器里文件都存在
docker exec docker-worker-1 ls -l /app/api/.venv/lib/python3.12/site-packages/dify_trace_langfuse/langfuse_trace.py
docker exec docker-api-1 ls -l /app/api/.venv/lib/python3.12/site-packages/dify_trace_langfuse/langfuse_trace.py
# 2. 看有没有已经打过补丁(有这行就不用再改)
docker exec docker-worker-1 grep -n 'auto-assign if missing' \
/app/api/.venv/lib/python3.12/site-packages/dify_trace_langfuse/langfuse_trace.py
docker exec docker-api-1 grep -n 'auto-assign if missing' \
/app/api/.venv/lib/python3.12/site-packages/dify_trace_langfuse/langfuse_trace.py
# 3. 定位两个函数(记下行号)
docker exec docker-worker-1 grep -n 'def add_span\|def add_generation\|body = CreateSpanBody\|body = CreateGenerationBody' \
/app/api/.venv/lib/python3.12/site-packages/dify_trace_langfuse/langfuse_trace.py如果第 2 步已经打出 auto-assign if missing,跳到 5.9 重启 worker 确认进程用的是新代码。如果没有,继续 5.4。
5.4 修改位置(锚点)
两处锚点都是「try: 的下一行、body = CreateXxxBody( 的上一行」。
位置 A:add_span
在这段 改前 代码里,箭头标的就是插入点:
完整程序:/opt/workbuddy/scripts_cursor/Langfuse_连接Dify专题/Langfuse_内联_02.py
运行/引用:/usr/bin/python3 /opt/workbuddy/scripts_cursor/Langfuse_连接Dify专题/Langfuse_内联_02.py
def add_span(self, langfuse_span_data: LangfuseSpan | None = None):
data = filter_none_values(langfuse_span_data.model_dump()) if langfuse_span_data else {}
try:
body = CreateSpanBody( # ← 不要动这一行,在它上面插入
id=data.get("id"),位置 B:add_generation
在 Usage(...) 构造结束之后、body = CreateGenerationBody( 之前插入。改前片段:
完整程序:/opt/workbuddy/scripts_cursor/Langfuse_连接Dify专题/Langfuse_内联_03.py
运行/引用:/usr/bin/python3 /opt/workbuddy/scripts_cursor/Langfuse_连接Dify专题/Langfuse_内联_03.py
if usage_data:
usage = Usage(
input=usage_data.get("input", 0) or 0,
output=usage_data.get("output", 0) or 0,
total=usage_data.get("total", 0) or 0,
unit=usage_data.get("unit"),
input_cost=usage_data.get("inputCost"),
output_cost=usage_data.get("outputCost"),
total_cost=usage_data.get("totalCost"),
)
body = CreateGenerationBody( # ← 不要动这一行,在它上面插入
id=data.get("id"),两处插入的代码完全相同:
完整程序:/opt/workbuddy/scripts_cursor/Langfuse_连接Dify专题/Langfuse_内联_04.py
运行/引用:/usr/bin/python3 /opt/workbuddy/scripts_cursor/Langfuse_连接Dify专题/Langfuse_内联_04.py
# Langfuse v4 requires observation body.id; auto-assign if missing
if not data.get("id"):
data["id"] = str(uuid.uuid4())缩进必须和旁边的 body = CreateXxxBody( 对齐(12 个空格,和 try: 下第一层语句一致)。
5.5 改前 / 改后完整对比
下面是本次环境里两个函数的完整对比。除了插入的 3 行,其余一行都不要改。
add_span 改前(原版,会丢 Span):
完整程序:/opt/workbuddy/scripts_cursor/Langfuse_连接Dify专题/Langfuse_内联_05.py
运行/引用:/usr/bin/python3 /opt/workbuddy/scripts_cursor/Langfuse_连接Dify专题/Langfuse_内联_05.py
def add_span(self, langfuse_span_data: LangfuseSpan | None = None):
data = filter_none_values(langfuse_span_data.model_dump()) if langfuse_span_data else {}
try:
body = CreateSpanBody(
id=data.get("id"),
trace_id=data.get("trace_id"),
name=data.get("name"),
start_time=data.get("start_time"),
end_time=data.get("end_time"),
input=data.get("input"),
output=data.get("output"),
metadata=data.get("metadata"),
level=data.get("level"),
status_message=data.get("status_message"),
parent_observation_id=data.get("parent_observation_id"),
version=data.get("version"),
)
event = IngestionEvent_SpanCreate(
body=body,
id=self._make_event_id(),
timestamp=self._now_iso(),
)
self.langfuse_client.api.ingestion.batch(batch=[event])
logger.debug("LangFuse Span created successfully")
except Exception as e:
raise ValueError(f"LangFuse Failed to create span: {str(e)}")add_span 改后(补上 id):
完整程序:/opt/workbuddy/scripts_cursor/Langfuse_连接Dify专题/Langfuse_内联_06.py
运行/引用:/usr/bin/python3 /opt/workbuddy/scripts_cursor/Langfuse_连接Dify专题/Langfuse_内联_06.py
def add_span(self, langfuse_span_data: LangfuseSpan | None = None):
data = filter_none_values(langfuse_span_data.model_dump()) if langfuse_span_data else {}
try:
# Langfuse v4 requires observation body.id; auto-assign if missing
if not data.get("id"):
data["id"] = str(uuid.uuid4())
body = CreateSpanBody(
id=data.get("id"),
trace_id=data.get("trace_id"),
name=data.get("name"),
start_time=data.get("start_time"),
end_time=data.get("end_time"),
input=data.get("input"),
output=data.get("output"),
metadata=data.get("metadata"),
level=data.get("level"),
status_message=data.get("status_message"),
parent_observation_id=data.get("parent_observation_id"),
version=data.get("version"),
)
event = IngestionEvent_SpanCreate(
body=body,
id=self._make_event_id(),
timestamp=self._now_iso(),
)
self.langfuse_client.api.ingestion.batch(batch=[event])
logger.debug("LangFuse Span created successfully")
except Exception as e:
raise ValueError(f"LangFuse Failed to create span: {str(e)}")差异只有 try: 后面多了这 3 行:
完整程序:/opt/workbuddy/scripts_cursor/Langfuse_连接Dify专题/Langfuse_内联_07.py
运行/引用:/usr/bin/python3 /opt/workbuddy/scripts_cursor/Langfuse_连接Dify专题/Langfuse_内联_07.py
# Langfuse v4 requires observation body.id; auto-assign if missing
if not data.get("id"):
data["id"] = str(uuid.uuid4())add_generation 改前(原版,会丢 Generation / Token):
完整程序:/opt/workbuddy/scripts_cursor/Langfuse_连接Dify专题/Langfuse_内联_08.py
运行/引用:/usr/bin/python3 /opt/workbuddy/scripts_cursor/Langfuse_连接Dify专题/Langfuse_内联_08.py
def add_generation(self, langfuse_generation_data: LangfuseGeneration | None = None):
data = filter_none_values(langfuse_generation_data.model_dump()) if langfuse_generation_data else {}
try:
usage_data = data.pop("usage", None)
usage = None
if usage_data:
usage = Usage(
input=usage_data.get("input", 0) or 0,
output=usage_data.get("output", 0) or 0,
total=usage_data.get("total", 0) or 0,
unit=usage_data.get("unit"),
input_cost=usage_data.get("inputCost"),
output_cost=usage_data.get("outputCost"),
total_cost=usage_data.get("totalCost"),
)
body = CreateGenerationBody(
id=data.get("id"),
trace_id=data.get("trace_id"),
name=data.get("name"),
start_time=data.get("start_time"),
end_time=data.get("end_time"),
model=data.get("model"),
model_parameters=data.get("model_parameters"),
input=data.get("input"),
output=data.get("output"),
usage=usage,
metadata=data.get("metadata"),
level=data.get("level"),
status_message=data.get("status_message"),
parent_observation_id=data.get("parent_observation_id"),
version=data.get("version"),
completion_start_time=data.get("completion_start_time"),
)
event = IngestionEvent_GenerationCreate(
body=body,
id=self._make_event_id(),
timestamp=self._now_iso(),
)
self.langfuse_client.api.ingestion.batch(batch=[event])
logger.debug("LangFuse Generation created successfully")
except Exception as e:
raise ValueError(f"LangFuse Failed to create generation: {str(e)}")add_generation 改后(补上 id):
完整程序:/opt/workbuddy/scripts_cursor/Langfuse_连接Dify专题/Langfuse_内联_09.py
运行/引用:/usr/bin/python3 /opt/workbuddy/scripts_cursor/Langfuse_连接Dify专题/Langfuse_内联_09.py
def add_generation(self, langfuse_generation_data: LangfuseGeneration | None = None):
data = filter_none_values(langfuse_generation_data.model_dump()) if langfuse_generation_data else {}
try:
usage_data = data.pop("usage", None)
usage = None
if usage_data:
usage = Usage(
input=usage_data.get("input", 0) or 0,
output=usage_data.get("output", 0) or 0,
total=usage_data.get("total", 0) or 0,
unit=usage_data.get("unit"),
input_cost=usage_data.get("inputCost"),
output_cost=usage_data.get("outputCost"),
total_cost=usage_data.get("totalCost"),
)
# Langfuse v4 requires observation body.id; auto-assign if missing
if not data.get("id"):
data["id"] = str(uuid.uuid4())
body = CreateGenerationBody(
id=data.get("id"),
trace_id=data.get("trace_id"),
name=data.get("name"),
start_time=data.get("start_time"),
end_time=data.get("end_time"),
model=data.get("model"),
model_parameters=data.get("model_parameters"),
input=data.get("input"),
output=data.get("output"),
usage=usage,
metadata=data.get("metadata"),
level=data.get("level"),
status_message=data.get("status_message"),
parent_observation_id=data.get("parent_observation_id"),
version=data.get("version"),
completion_start_time=data.get("completion_start_time"),
)
event = IngestionEvent_GenerationCreate(
body=body,
id=self._make_event_id(),
timestamp=self._now_iso(),
)
self.langfuse_client.api.ingestion.batch(batch=[event])
logger.debug("LangFuse Generation created successfully")
except Exception as e:
raise ValueError(f"LangFuse Failed to create generation: {str(e)}")差异同样只有 CreateGenerationBody( 上面多了那 3 行。event.id(ingestion 信封 id)和 body.id(observation id)不是一回事:原代码已经有 _make_event_id(),缺的是 body.id。
5.6 方法 A:进容器手工改(适合看一眼就改)
worker 和 api 各做一遍。以 worker 为例:
完整脚本:scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_11.sh
运行/引用:bash /opt/workbuddy/scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_11.sh
#!/usr/bin/env bash
set -euo pipefail
# 没有 vi 时用 sed 先确认锚点附近的原文
docker exec docker-worker-1 sed -n '/def add_span/,/def update_span/p' \
/app/api/.venv/lib/python3.12/site-packages/dify_trace_langfuse/langfuse_trace.py | head -40
# 有编辑器就进容器改;没有就用下面的 docker cp 把文件拷出来改
docker cp docker-worker-1:/app/api/.venv/lib/python3.12/site-packages/dify_trace_langfuse/langfuse_trace.py \
/tmp/langfuse_trace.py
# 在 /tmp/langfuse_trace.py 里按 5.5 改两处,保存后再拷回去
docker cp /tmp/langfuse_trace.py \
docker-worker-1:/app/api/.venv/lib/python3.12/site-packages/dify_trace_langfuse/langfuse_trace.py
docker cp /tmp/langfuse_trace.py \
docker-api-1:/app/api/.venv/lib/python3.12/site-packages/dify_trace_langfuse/langfuse_trace.py手工改容易缩进错。更稳的是方法 B。
5.7 方法 B:用脚本打补丁(推荐,可重复执行)
把下面完整脚本存到 207 的 /tmp/patch_lf_id.py(Windows 上也可以先写好再 scp)。脚本按原文锚点替换,打过一次再跑会打印 already patched 然后退出。
完整程序:/opt/workbuddy/scripts_cursor/Langfuse_连接Dify专题/Langfuse_内联_10.py
运行/引用:/usr/bin/python3 /opt/workbuddy/scripts_cursor/Langfuse_连接Dify专题/Langfuse_内联_10.py
from pathlib import Path
p = Path("/app/api/.venv/lib/python3.12/site-packages/dify_trace_langfuse/langfuse_trace.py")
t = p.read_text()
marker = 'Langfuse v4 requires observation body.id; auto-assign if missing'
if marker in t:
print("already patched")
raise SystemExit(0)
old_span = """ data = filter_none_values(langfuse_span_data.model_dump()) if langfuse_span_data else {}
try:
body = CreateSpanBody(
id=data.get("id"),
"""
new_span = """ data = filter_none_values(langfuse_span_data.model_dump()) if langfuse_span_data else {}
try:
# Langfuse v4 requires observation body.id; auto-assign if missing
if not data.get("id"):
data["id"] = str(uuid.uuid4())
body = CreateSpanBody(
id=data.get("id"),
"""
old_gen = """ usage_data = data.pop("usage", None)
usage = None
if usage_data:
usage = Usage(
input=usage_data.get("input", 0) or 0,
output=usage_data.get("output", 0) or 0,
total=usage_data.get("total", 0) or 0,
unit=usage_data.get("unit"),
input_cost=usage_data.get("inputCost"),
output_cost=usage_data.get("outputCost"),
total_cost=usage_data.get("totalCost"),
)
body = CreateGenerationBody(
id=data.get("id"),
"""
new_gen = """ usage_data = data.pop("usage", None)
usage = None
if usage_data:
usage = Usage(
input=usage_data.get("input", 0) or 0,
output=usage_data.get("output", 0) or 0,
total=usage_data.get("total", 0) or 0,
unit=usage_data.get("unit"),
input_cost=usage_data.get("inputCost"),
output_cost=usage_data.get("outputCost"),
total_cost=usage_data.get("totalCost"),
)
# Langfuse v4 requires observation body.id; auto-assign if missing
if not data.get("id"):
data["id"] = str(uuid.uuid4())
body = CreateGenerationBody(
id=data.get("id"),
"""
if old_span not in t:
raise SystemExit("span block not found")
if old_gen not in t:
raise SystemExit("gen block not found")
t = t.replace(old_span, new_span, 1).replace(old_gen, new_gen, 1)
p.write_text(t)
print("patched ok")应用到两个容器:
完整脚本:scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_12.sh
运行/引用:bash /opt/workbuddy/scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_12.sh
#!/usr/bin/env bash
set -euo pipefail
docker cp /tmp/patch_lf_id.py docker-worker-1:/tmp/patch_lf_id.py
docker exec docker-worker-1 python /tmp/patch_lf_id.py
# 期望: patched ok
docker cp /tmp/patch_lf_id.py docker-api-1:/tmp/patch_lf_id.py
docker exec docker-api-1 python /tmp/patch_lf_id.py
# 期望: patched ok如果打印 span block not found / gen block not found,说明镜像里这段原文和 1.16.1 不一致,改回方法 A,用 grep 对一下锚点再手工插 3 行。
5.8 改完必须重启 worker(这一步漏了等于没改)
Celery 启动时已经把 langfuse_trace.py load 进内存。只改磁盘文件、不重启进程,下一题仍会丢 Generation。
docker restart docker-worker-1盯日志,等到 ready 再问下一题:
docker logs -f docker-worker-1看到类似下面这行就可以 Ctrl+C 停掉 follow:
celery@6a47a2527234 ready.docker-api-1 一般不用重启:推送走的是 worker。api 里那份是为了和 worker 保持同一份源码。
5.9 验证补丁真的在、进程也换过了
完整脚本:scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_13.sh
运行/引用:bash /opt/workbuddy/scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_13.sh
#!/usr/bin/env bash
set -euo pipefail
# 文件里有补丁
docker exec docker-worker-1 grep -n 'auto-assign if missing' \
/app/api/.venv/lib/python3.12/site-packages/dify_trace_langfuse/langfuse_trace.py
docker exec docker-api-1 grep -n 'auto-assign if missing' \
/app/api/.venv/lib/python3.12/site-packages/dify_trace_langfuse/langfuse_trace.py
# worker 是补丁之后才 ready 的(看重启时间)
docker inspect docker-worker-1 --format '{{.State.StartedAt}} {{.State.Status}}'然后去做步骤 6 的验收问答。成功时 Langfuse 这条 Trace 下应出现 Observation llm,并带 promptTokens / completionTokens / totalTokens。
5.10 重建容器后要重做
补丁在容器内 .venv/site-packages,不在宿主机绑定目录。以下动作会丢掉补丁:
docker compose up --force-recreate重建docker-worker-1/docker-api-1- 重拉
langgenius/dify-worker/dify-api镜像
重建后从 5.3 再走一遍:确认没有 marker → 方法 B 再打 → 重启 worker。
步骤 6:验收
6.1 在 Dify 问一句能打到知识库的问题
例如:
请用一两句话说明公司请假流程,这是 Langfuse 完整链路验收。等回答完整结束。Trace 是对话结束后由 Celery 异步推的,通常 几秒到十几秒 才出现,不是点发送的同一瞬间。
6.2 打开 Langfuse
- 浏览器打开
http://192.168.110.207:3000 - 登录
admin@example.com/Langfuse123456 - 进入组织 DCKJ → 项目
default - 左侧 Tracing → Traces
- 应看到最新一条 name=
message,tags 含agent/message
点进去应看到:
- Input:用户原话
- Output:模型回答
- 一条 Observation:
llm,模型qwen3.6-27b - Usage:例如 input 6601 / output 654 / total 7255
本次验收 Trace 直链:
http://192.168.110.207:3000/project/default/traces/1f339a2f-bbda-4042-8e99-1d66bbc6faba6.3 用 API 复核(不依赖 UI)
把 pk/sk 换成自己的:
完整脚本:scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_14.sh
运行/引用:bash /opt/workbuddy/scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_14.sh
#!/usr/bin/env bash
set -euo pipefail
curl -s -u 'pk-lf-84a76d89-b6b4-4943-b4ea-2dee48d4cda7:sk-lf-24aa6d73-ed36-4447-9eb6-3ebd446fb298' \
'http://127.0.0.1:3000/api/public/traces?limit=5'events_only 模式下这个接口会报 v4 deprecation / 不可用。dual 且 ALLOW_PREVIEW_OPT_IN=false 时,应返回 data 数组。
看某一条的 Generation:
完整脚本:scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_15.sh
运行/引用:bash /opt/workbuddy/scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_15.sh
#!/usr/bin/env bash
set -euo pipefail
curl -s -u 'pk-lf-...:sk-lf-...' \
'http://127.0.0.1:3000/api/public/observations?traceId=<trace_id>'6.4 看 ClickHouse(最底层)
完整脚本:scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_16.sh
运行/引用:bash /opt/workbuddy/scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_16.sh
#!/usr/bin/env bash
set -euo pipefail
docker exec langfuse-clickhouse-1 clickhouse-client \
--user clickhouse --password <CLICKHOUSE_PASSWORD> \
--query "SELECT id, name, timestamp FROM traces ORDER BY timestamp DESC LIMIT 5"
docker exec langfuse-clickhouse-1 clickhouse-client \
--user clickhouse --password <CLICKHOUSE_PASSWORD> \
--query "SELECT id, type, name, trace_id FROM observations ORDER BY start_time DESC LIMIT 10"ClickHouse 密码在 /Langfuse/langfuse/.env 的 CLICKHOUSE_PASSWORD。
6.5 看 Dify Celery 是否真的跑了任务
完整脚本:scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_17.sh
运行/引用:bash /opt/workbuddy/scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_17.sh
#!/usr/bin/env bash
set -euo pipefail
docker logs --since 10m docker-worker-1 2>&1 | grep ops_trace成功时类似:
Task tasks.ops_trace_task.process_trace_tasks[...] received
new tracing_instance for app_id: 40e95fd3-e7f5-44f2-a03b-dc3e60413540
Processing trace tasks success, app_id: 40e95fd3-e7f5-44f2-a03b-dc3e60413540
Task ... succeeded6.6 看 Langfuse web 有没有拒收
完整脚本:scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_18.sh
运行/引用:bash /opt/workbuddy/scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_18.sh
#!/usr/bin/env bash
set -euo pipefail
docker logs --since 10m langfuse-langfuse-web-1 2>&1 | grep -E 'Rejected|Error processing|events_only'验收通过时,这里不应再出现 events_only 拒收。偶发 Error processing events 且 ClickHouse 里没有对应 GENERATION,多半是 observation 缺 id,回到步骤 5。
七、本次 207 实战结果
配置完成后,用「请假流程」那句验收,得到:
| 项 | 值 |
|---|---|
| Trace ID | 1f339a2f-bbda-4042-8e99-1d66bbc6faba |
| Trace name | message |
| tags | agent, message |
| Observation | 0cc1e325-c128-4e05-b80a-afe7b4c50dcd,type=GENERATION,name=llm |
| 模型 | qwen3.6-27b |
| Prompt(用户话) | 请用一两句话说明公司请假流程,这是 Langfuse 完整链路验收。 |
| promptTokens | 6601 |
| completionTokens | 654 |
| totalTokens | 7255 |
| 费用字段 | calculatedTotalCost ≈ 0.052625 |
| 延迟 | 约 12.5 秒 |
这满足验收标准:有 Trace、有 Prompt、有 Token。
八、本次实战遇到的问题和对应的解决方案
下面只记 这次 207 对接时真实踩过的坑。现象、根因、完整配置/代码、验证命令都写全,方便以后原样复用。
总览:
| 序号 | 当时看到的现象 | 真正原因 | 解决方案 |
|---|---|---|---|
| 坑 1 | 在 Dify .env / 容器 env 里找不到 LANGFUSE_* | Dify 1.16 按应用配 Tracing,不读全局环境变量 | 走步骤 3 的 Console API,写入 trace_app_config |
| 坑 2 | Langfuse 能登录,但没有任何 API Key | 初始化时没设 LANGFUSE_INIT_PROJECT_PUBLIC_KEY/SECRET_KEY | 写入 INIT 密钥并重建 langfuse-web |
| 坑 3 | Dify 已启用 Tracing,Celery 也 success,后台仍空 | Langfuse 4 默认 events_only,拒收旧 /api/public/ingestion | web/worker 都改成 dual,compose 必须显式传变量 |
| 坑 4 | 只改了 .env,容器里还是 events_only | 官方 compose 不会自动灌入未声明的环境变量 | 在 &langfuse-worker-env 里加上两行再 up -d |
| 坑 5 | 有 Trace,没有 llm Observation / Token 面板 | Generation/Span 没带 body.id,Langfuse 4 直接丢弃 | 给 langfuse_trace.py 补 UUID,并重启 worker |
| 坑 6 | GET /api/public/traces 提示 v4 events_only | 还在新读路径/拒收旧表 | dual + ALLOW_PREVIEW_OPT_IN=false |
| 坑 7 | Host 填 localhost 会连错 | Dify 容器里的 localhost 不是宿主机 | Host 必须是 http://192.168.110.207:3000 |
| 坑 8 | 重建 Dify worker 后 Generation 又没了 | 补丁在容器内 site-packages,随镜像重建丢失 | 重建后重新打补丁并重启 worker |
坑 1:在 Dify 里配「环境变量」不生效
现象
按习惯去翻 /dify/dify-main/docker/.env 和 docker-api-1 / docker-agent_backend-1 的 env | grep -i langfuse,几乎什么都没有(只有 Sentry 的 TRACES_SAMPLE_RATE)。即使用户口头说「配环境变量」,Dify 1.16 也不会靠它推 Trace。
根因
官方路径是:
POST /console/api/apps/<app_id>/trace-config写入表trace_app_configPOST /console/api/apps/<app_id>/trace把apps.tracing写成{"enabled": true, "tracing_provider": "langfuse"}- 对话结束后 Celery 队列
ops_trace读这两处配置再推送
解决方案
不要指望全局 env。按本文第六章步骤 3 用 控制台 API 写入 pk/sk/host 并打开开关。.env 里那三项只是备查。新 Agent 工作室里往往没有「监测」菜单。
本次落库结果:
-- apps.tracing
{"enabled": true, "tracing_provider": "langfuse"}
-- 查询
SELECT id, name, tracing FROM apps;
SELECT app_id, tracing_provider, is_active FROM trace_app_config;应用监测保存时的完整 JSON(控制台 API body):
{
"tracing_provider": "langfuse",
"tracing_config": {
"public_key": "pk-lf-84a76d89-b6b4-4943-b4ea-2dee48d4cda7",
"secret_key": "sk-lf-24aa6d73-ed36-4447-9eb6-3ebd446fb298",
"host": "http://192.168.110.207:3000"
}
}启用开关:
{
"enabled": true,
"tracing_provider": "langfuse"
}坑 2:Langfuse 项目在,API Key 是空的
现象
组织 dckj、项目 default、用户 admin@example.com 都有,但:
SELECT id, public_key FROM api_keys;
-- 0 rows没有 pk/sk,Dify 保存监测配置时会 Invalid Credentials。
根因
当时 .env 只初始化了 org/project/user,没有 LANGFUSE_INIT_PROJECT_PUBLIC_KEY 和 LANGFUSE_INIT_PROJECT_SECRET_KEY。Langfuse 启动时会跳过建 Key。
解决方案(完整配置)
/Langfuse/langfuse/.env 追加(不要加引号):
完整 .env 片段:patches_cursor/Langfuse_连接Dify专题/Langfuse_内联_06.env
运行/引用:patches_cursor/Langfuse_连接Dify专题/Langfuse_内联_06.env
LANGFUSE_INIT_PROJECT_PUBLIC_KEY=pk-lf-84a76d89-b6b4-4943-b4ea-2dee48d4cda7
LANGFUSE_INIT_PROJECT_SECRET_KEY=sk-lf-24aa6d73-ed36-4447-9eb6-3ebd446fb298密钥必须成对,前缀必须是 pk-lf- / sk-lf-。然后重建 web:
完整脚本:scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_19.sh
运行/引用:bash /opt/workbuddy/scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_19.sh
#!/usr/bin/env bash
set -euo pipefail
cd /Langfuse/langfuse
docker compose -p langfuse up -d langfuse-web验证:
完整脚本:scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_20.sh
运行/引用:bash /opt/workbuddy/scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_20.sh
#!/usr/bin/env bash
set -euo pipefail
docker exec langfuse-postgres-1 psql -U postgres -d postgres -c \
"SELECT id, public_key, note, project_id, display_secret_key FROM api_keys;"期望 note=Provisioned API Key,project_id=default。Secret 只在创建时完整可见,请立刻抄走。
坑 3:Celery 显示 success,Langfuse 后台仍是空的
现象
docker-worker-1 日志:
Task tasks.ops_trace_task.process_trace_tasks[...] received
new tracing_instance for app_id: 40e95fd3-e7f5-44f2-a03b-dc3e60413540
Processing trace tasks success, app_id: 40e95fd3-e7f5-44f2-a03b-dc3e60413540
Task ... succeeded in 0.48s但 ClickHouse traces / observations 都是 0 行。GET /api/public/traces 返回:
This endpoint is not available on deployments running in Langfuse v4 events_only mode.langfuse-langfuse-web-1 日志才是真相:
Rejected 1 event(s) from the legacy /api/public/ingestion endpoint for project default
because this Langfuse v4 deployment runs in events_only mode. These events were not stored.
Upgrade the client or integration to a v4-compatible SDK or OTLP ingestion path.
As a temporary migration bridge, set LANGFUSE_MIGRATION_V4_WRITE_MODE=dual
on both the web and worker services and redeploy.根因
Dify 1.16 插件走旧接口 POST /api/public/ingestion。Langfuse 4 新部署默认 events_only,把这些事件直接丢掉。Dify 只判断 HTTP 没抛异常,所以 Celery 仍报 success。
解决方案(完整配置)
1)/Langfuse/langfuse/.env 追加:
完整 .env 片段:patches_cursor/Langfuse_连接Dify专题/Langfuse_内联_07.env
运行/引用:patches_cursor/Langfuse_连接Dify专题/Langfuse_内联_07.env
LANGFUSE_MIGRATION_V4_WRITE_MODE=dual
LANGFUSE_MIGRATION_V4_ALLOW_PREVIEW_OPT_IN=falseALLOW_PREVIEW_OPT_IN=false 让控制台继续走 v3 读体验,Traces 列表能马上看到旧表数据。
2)官方 compose 必须显式声明,只改 .env 不够。在 langfuse-worker 的环境锚点 &langfuse-worker-env 里、TELEMETRY_ENABLED 后面加上两行。web 通过 <<: *langfuse-worker-env 继承,所以两处都会生效。
本次 docker-compose.yml 实际片段:
完整 compose 片段:patches_cursor/Langfuse_连接Dify专题/Langfuse_内联_02.yaml
运行/引用:patches_cursor/Langfuse_连接Dify专题/Langfuse_内联_02.yaml
environment: &langfuse-worker-env
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
DATABASE_URL: ${DATABASE_URL:-postgresql://postgres:postgres@postgres:5432/postgres} # CHANGEME
SALT: ${SALT:-mysalt} # CHANGEME
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-0000000000000000000000000000000000000000000000000000000000000000} # CHANGEME: generate via `openssl rand -hex 32`
TELEMETRY_ENABLED: ${TELEMETRY_ENABLED:-true}
LANGFUSE_MIGRATION_V4_WRITE_MODE: ${LANGFUSE_MIGRATION_V4_WRITE_MODE:-dual}
LANGFUSE_MIGRATION_V4_ALLOW_PREVIEW_OPT_IN: ${LANGFUSE_MIGRATION_V4_ALLOW_PREVIEW_OPT_IN:-false}
LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES: ${LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES:-false}web 服务里保持:
完整 compose 片段:patches_cursor/Langfuse_连接Dify专题/Langfuse_内联_03.yaml
运行/引用:patches_cursor/Langfuse_连接Dify专题/Langfuse_内联_03.yaml
environment:
<<: *langfuse-worker-env3)重建两个服务:
完整脚本:scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_21.sh
运行/引用:bash /opt/workbuddy/scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_21.sh
#!/usr/bin/env bash
set -euo pipefail
cd /Langfuse/langfuse
docker compose -p langfuse up -d langfuse-web langfuse-worker4)确认容器内真是 dual(本次踩过「文件改了、容器没吃到」):
完整脚本:scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_22.sh
运行/引用:bash /opt/workbuddy/scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_22.sh
#!/usr/bin/env bash
set -euo pipefail
docker exec langfuse-langfuse-web-1 printenv LANGFUSE_MIGRATION_V4_WRITE_MODE
docker exec langfuse-langfuse-worker-1 printenv LANGFUSE_MIGRATION_V4_WRITE_MODE
# 都必须输出 dual改完后再问一句,几秒到十几秒后 ClickHouse traces 会有行。
坑 4:只改 .env,compose 没传变量
现象
.env 里已经写了 LANGFUSE_MIGRATION_V4_WRITE_MODE=dual,但:
完整脚本:scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_23.sh
运行/引用:bash /opt/workbuddy/scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_23.sh
#!/usr/bin/env bash
set -euo pipefail
docker exec langfuse-langfuse-web-1 printenv LANGFUSE_MIGRATION_V4_WRITE_MODE仍是空,或行为仍是 events_only。
根因
官方 docker-compose.yml 用的是「列出哪些 env 就传哪些」。未列出的变量不会进容器。
解决方案
就是坑 3 第 2 步:把两行写进 &langfuse-worker-env,再 up -d langfuse-web langfuse-worker。不要以为 docker compose up 会把整个 .env 倒进容器。
坑 5:有 Trace,没有 llm 子节点,看不到独立 Token
现象
dual 打开后,Traces 列表有 message,Input 里也能看到用户问题和 message_tokens。但是:
- Observation 列表为空,或只有手工测试那条
- web 日志出现
Error processing events(没有更细的堆栈) GET /api/public/observations?traceId=...返回data: []
用 curl 手动发一条带 id 的 generation-create 却能成功入库。说明接口没坏,是 Dify 上报的 payload 缺字段。
根因
Langfuse 4 处理 ingestion 时:observation 的 body.id 为空就直接跳过,不入库。
Dify 插件 LangFuseDataTrace.add_generation() / add_span() 创建事件时经常不填 id,只填 trace_id。Trace 用了 message_id 当 id,所以 Trace 能进;Generation 没有 id,被丢掉。
补丁文件(两个容器都要改):
/app/api/.venv/lib/python3.12/site-packages/dify_trace_langfuse/langfuse_trace.py解决方案:改后的完整函数(本次 worker 内实际代码)
只改了两处:在构造 CreateSpanBody / CreateGenerationBody 之前,如果没有 id 就 uuid.uuid4()。import uuid 文件里本来就有。
完整程序:/opt/workbuddy/scripts_cursor/Langfuse_连接Dify专题/Langfuse_内联_11.py
运行/引用:/usr/bin/python3 /opt/workbuddy/scripts_cursor/Langfuse_连接Dify专题/Langfuse_内联_11.py
def add_span(self, langfuse_span_data: LangfuseSpan | None = None):
data = filter_none_values(langfuse_span_data.model_dump()) if langfuse_span_data else {}
try:
# Langfuse v4 requires observation body.id; auto-assign if missing
if not data.get("id"):
data["id"] = str(uuid.uuid4())
body = CreateSpanBody(
id=data.get("id"),
trace_id=data.get("trace_id"),
name=data.get("name"),
start_time=data.get("start_time"),
end_time=data.get("end_time"),
input=data.get("input"),
output=data.get("output"),
metadata=data.get("metadata"),
level=data.get("level"),
status_message=data.get("status_message"),
parent_observation_id=data.get("parent_observation_id"),
version=data.get("version"),
)
event = IngestionEvent_SpanCreate(
body=body,
id=self._make_event_id(),
timestamp=self._now_iso(),
)
self.langfuse_client.api.ingestion.batch(batch=[event])
logger.debug("LangFuse Span created successfully")
except Exception as e:
raise ValueError(f"LangFuse Failed to create span: {str(e)}")
def add_generation(self, langfuse_generation_data: LangfuseGeneration | None = None):
data = filter_none_values(langfuse_generation_data.model_dump()) if langfuse_generation_data else {}
try:
usage_data = data.pop("usage", None)
usage = None
if usage_data:
usage = Usage(
input=usage_data.get("input", 0) or 0,
output=usage_data.get("output", 0) or 0,
total=usage_data.get("total", 0) or 0,
unit=usage_data.get("unit"),
input_cost=usage_data.get("inputCost"),
output_cost=usage_data.get("outputCost"),
total_cost=usage_data.get("totalCost"),
)
# Langfuse v4 requires observation body.id; auto-assign if missing
if not data.get("id"):
data["id"] = str(uuid.uuid4())
body = CreateGenerationBody(
id=data.get("id"),
trace_id=data.get("trace_id"),
name=data.get("name"),
start_time=data.get("start_time"),
end_time=data.get("end_time"),
model=data.get("model"),
model_parameters=data.get("model_parameters"),
input=data.get("input"),
output=data.get("output"),
usage=usage,
metadata=data.get("metadata"),
level=data.get("level"),
status_message=data.get("status_message"),
parent_observation_id=data.get("parent_observation_id"),
version=data.get("version"),
completion_start_time=data.get("completion_start_time"),
)
event = IngestionEvent_GenerationCreate(
body=body,
id=self._make_event_id(),
timestamp=self._now_iso(),
)
self.langfuse_client.api.ingestion.batch(batch=[event])
logger.debug("LangFuse Generation created successfully")
except Exception as e:
raise ValueError(f"LangFuse Failed to create generation: {str(e)}")可重复执行的完整打补丁脚本 patch_lf_id.py
把这个文件拷进容器再执行。已打过会打印 already patched 后退出。
完整程序:/opt/workbuddy/scripts_cursor/Langfuse_连接Dify专题/Langfuse_内联_12.py
运行/引用:/usr/bin/python3 /opt/workbuddy/scripts_cursor/Langfuse_连接Dify专题/Langfuse_内联_12.py
from pathlib import Path
p = Path("/app/api/.venv/lib/python3.12/site-packages/dify_trace_langfuse/langfuse_trace.py")
t = p.read_text()
marker = 'Langfuse v4 requires observation body.id; auto-assign if missing'
if marker in t:
print("already patched")
raise SystemExit(0)
old_span = """ data = filter_none_values(langfuse_span_data.model_dump()) if langfuse_span_data else {}
try:
body = CreateSpanBody(
id=data.get("id"),
"""
new_span = """ data = filter_none_values(langfuse_span_data.model_dump()) if langfuse_span_data else {}
try:
# Langfuse v4 requires observation body.id; auto-assign if missing
if not data.get("id"):
data["id"] = str(uuid.uuid4())
body = CreateSpanBody(
id=data.get("id"),
"""
old_gen = """ usage_data = data.pop("usage", None)
usage = None
if usage_data:
usage = Usage(
input=usage_data.get("input", 0) or 0,
output=usage_data.get("output", 0) or 0,
total=usage_data.get("total", 0) or 0,
unit=usage_data.get("unit"),
input_cost=usage_data.get("inputCost"),
output_cost=usage_data.get("outputCost"),
total_cost=usage_data.get("totalCost"),
)
body = CreateGenerationBody(
id=data.get("id"),
"""
new_gen = """ usage_data = data.pop("usage", None)
usage = None
if usage_data:
usage = Usage(
input=usage_data.get("input", 0) or 0,
output=usage_data.get("output", 0) or 0,
total=usage_data.get("total", 0) or 0,
unit=usage_data.get("unit"),
input_cost=usage_data.get("inputCost"),
output_cost=usage_data.get("outputCost"),
total_cost=usage_data.get("totalCost"),
)
# Langfuse v4 requires observation body.id; auto-assign if missing
if not data.get("id"):
data["id"] = str(uuid.uuid4())
body = CreateGenerationBody(
id=data.get("id"),
"""
if old_span not in t:
raise SystemExit("span block not found")
if old_gen not in t:
raise SystemExit("gen block not found")
t = t.replace(old_span, new_span, 1).replace(old_gen, new_gen, 1)
p.write_text(t)
print("patched ok")应用到两个容器并 重启 worker(Celery 已把旧模块 load 进内存,不重启等于没改):
完整脚本:scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_24.sh
运行/引用:bash /opt/workbuddy/scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_24.sh
#!/usr/bin/env bash
set -euo pipefail
# 假设脚本在宿主机 /tmp/patch_lf_id.py
docker cp /tmp/patch_lf_id.py docker-worker-1:/tmp/patch_lf_id.py
docker exec docker-worker-1 python /tmp/patch_lf_id.py
docker cp /tmp/patch_lf_id.py docker-api-1:/tmp/patch_lf_id.py
docker exec docker-api-1 python /tmp/patch_lf_id.py
docker restart docker-worker-1
# 等到日志出现: celery@... ready.验证补丁在:
完整脚本:scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_25.sh
运行/引用:bash /opt/workbuddy/scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_25.sh
#!/usr/bin/env bash
set -euo pipefail
docker exec docker-worker-1 grep -n 'auto-assign if missing' \
/app/api/.venv/lib/python3.12/site-packages/dify_trace_langfuse/langfuse_trace.py本次打补丁后再问「请假流程」,Observation llm 入库,Token 为 input 6601 / output 654 / total 7255。
坑 6:用旧 Traces API 查不到数据
现象
完整脚本:scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_26.sh
运行/引用:bash /opt/workbuddy/scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_26.sh
#!/usr/bin/env bash
set -euo pipefail
curl -u 'pk:sk' 'http://127.0.0.1:3000/api/public/traces?limit=5'返回 v4 deprecation,并提示改用 GET /api/public/v2/observations。v2 在 events_only 下也是空的,因为事件根本没存。
根因
新部署默认走 events 表。Dify 写入的是旧 traces/observations 表,而且在 events_only 时旧写入会被拒。
解决方案
先按坑 3 切到 dual。查数用旧接口(dual 下可用):
完整脚本:scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_27.sh
运行/引用:bash /opt/workbuddy/scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_27.sh
#!/usr/bin/env bash
set -euo pipefail
curl -s -u 'pk-lf-84a76d89-b6b4-4943-b4ea-2dee48d4cda7:sk-lf-24aa6d73-ed36-4447-9eb6-3ebd446fb298' \
'http://127.0.0.1:3000/api/public/traces?limit=5'
curl -s -u 'pk-lf-84a76d89-b6b4-4943-b4ea-2dee48d4cda7:sk-lf-24aa6d73-ed36-4447-9eb6-3ebd446fb298' \
'http://127.0.0.1:3000/api/public/observations?traceId=<trace_id>'或直接查 ClickHouse:
完整脚本:scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_28.sh
运行/引用:bash /opt/workbuddy/scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_28.sh
#!/usr/bin/env bash
set -euo pipefail
docker exec langfuse-clickhouse-1 clickhouse-client \
--user clickhouse --password <CLICKHOUSE_PASSWORD> \
--query "SELECT id, name, timestamp FROM traces ORDER BY timestamp DESC LIMIT 5"坑 7:Host 写成 localhost / 官方云地址
现象
Dify 保存监测时报 Invalid Credentials,或保存成功但数据打到别处。
根因
docker-api-1 / docker-worker-1 里的 localhost:3000 是容器自己,Langfuse 不在那里。两边也不在同一个 docker network,不能写 http://langfuse-web:3000。
解决方案
Host 固定为宿主机局域网地址(本次已验证两个 Dify 容器都能通):
http://192.168.110.207:3000探测命令:
完整脚本:scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_29.sh
运行/引用:bash /opt/workbuddy/scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_29.sh
#!/usr/bin/env bash
set -euo pipefail
docker exec docker-api-1 curl -s -m 8 http://192.168.110.207:3000/api/public/health
docker exec docker-worker-1 curl -s -m 8 http://192.168.110.207:3000/api/public/health
# 期望: {"status":"OK","version":"4.12.0"}坑 8:重建 Dify worker 后 Generation 再次丢失
现象
某次 docker compose up --force-recreate 或重拉 langgenius/dify-worker 镜像后,Traces 又只剩光杆 Trace。
根因
坑 5 的补丁打在容器内 .venv/site-packages,不在宿主机绑定目录里。容器一重建就回到镜像原版。
解决方案
重建 docker-api-1 / docker-worker-1 之后,把坑 5 的 patch_lf_id.py 再跑一遍,并 docker restart docker-worker-1。这是目前必须记住的运维动作。
这些坑的先后顺序(以后照这个排)
- Host 能否从
docker-worker-1curl 通 3000。 api_keys表是否有 pk。apps.tracing.enabled是否 true。- 两个 Langfuse 容器
printenv LANGFUSE_MIGRATION_V4_WRITE_MODE是否dual。 langfuse_trace.py是否含auto-assign if missing,worker 是否在补丁之后重启过。- 再问一句,等十几秒,查
traces+observations。
Celery 的 Processing trace tasks success 不能单独当验收通过。
九、排错手册
按「你看见的现象」对,不要跳着改。带完整配置和补丁代码的复盘见 第八章。
| 现象 | 先查 | 处理 |
|---|---|---|
| Dify 保存 Tracing 报 Invalid Credentials | Host、pk/sk、Langfuse 是否 Up | Host 改成 http://192.168.110.207:3000;用 curl -u pk:sk 打 /api/public/projects |
| 问了问题,Langfuse 完全没有 Trace | apps.tracing 是否 enabled;Celery 有没有 ops_trace 任务 | 打开应用监测开关;看 docker-worker-1 日志;确认 worker 能 curl 通 3000 端口 |
| Celery 显示 success,但 Langfuse 仍空 | web 日志 Rejected / events_only | 步骤 2,确认两个容器都是 dual |
| 有 Trace,没有 llm Observation,没有独立 Token 面板 | 插件没带 body.id | 步骤 5 补丁 + 重启 worker |
| Host 填了 localhost | 容器访问的是自己 | 改成宿主机 IP |
填了 https://cloud.langfuse.com | 数据打到官方云,本地后台当然没有 | 改成内网 Host |
| 重建 Dify worker 后 Generation 又没了 | 容器内补丁丢失 | 再打步骤 5 |
| 重建 Langfuse web 后密钥失效 | INIT key 被改掉或没带上 | 查 api_keys 表;必要时重新 INIT 或 UI 新建一把,并回写 Dify 监测配置 |
| Traces API 提示 v4 events_only / v2 observations | 仍在 events_only,或 UI 走了新读路径 | 回到 dual,并保持 ALLOW_PREVIEW_OPT_IN=false |
| 想看完整 system prompt / 检索原文,但只有用户一句话 | Dify 插件设计如此 | Token 数可以证明真实 prompt 很长;完整拼装 prompt 不在这条上报路径里 |
| 控制台全是英文 | 产品没有 i18n | 浏览器翻译;不要改镜像硬汉化 |
快速自检命令汇总:
完整脚本:scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_30.sh
运行/引用:bash /opt/workbuddy/scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_30.sh
#!/usr/bin/env bash
set -euo pipefail
# 1. Langfuse 活着
curl -s http://127.0.0.1:3000/api/public/health
# 2. 写入模式
docker exec langfuse-langfuse-web-1 printenv LANGFUSE_MIGRATION_V4_WRITE_MODE
docker exec langfuse-langfuse-worker-1 printenv LANGFUSE_MIGRATION_V4_WRITE_MODE
# 3. Dify 开关
docker exec docker-db_postgres-1 psql -U postgres -d dify -c "SELECT name, tracing FROM apps;"
# 4. 密钥还在
docker exec langfuse-postgres-1 psql -U postgres -d postgres -c "SELECT public_key, project_id FROM api_keys;"
# 5. Dify 容器出网
docker exec docker-worker-1 curl -s -m 8 http://192.168.110.207:3000/api/public/health十、重启、升级后要复查什么
| 动作 | 会丢什么 | 要做什么 |
|---|---|---|
docker restart docker-worker-1 | 不丢补丁(只是进程重启) | 等 celery ready,下一题即可 |
重建 docker-worker-1 / docker-api-1 | Observation ID 补丁丢失 | 步骤 5 再打,再重启 worker |
重建 langfuse-web / langfuse-worker | 若 compose/.env 还在,dual 和 INIT key 还在 | 复查 printenv LANGFUSE_MIGRATION_V4_WRITE_MODE |
| 把 Langfuse 升到更新的 4.x | dual 仍是过渡模式,将来可能被移除 | 升级前先读官方 v3→v4 指南;Dify 若仍用旧 ingestion,暂时不要切回 events_only |
| 在 Dify 应用里关掉监测再打开 | 配置还在 trace_app_config | 只要 apps.tracing.enabled 再打开即可 |
| 换了一把 Langfuse API Key | 旧 key 失效 | 到 Dify 监测里更新 pk/sk 并保存(会再做一次凭证校验) |
不建议做的事:
- 不要把 Dify 的 Postgres/Redis 和 Langfuse 的复用。
- 不要为了「配环境变量」去重启整套 Dify。
- 不要把 Host 改成
https://却没有证书。 - 不要在未打补丁时,只凭「Celery success」宣布验收通过。
十一、关键路径和配置清单
11.1 文件
| 路径 | 用途 |
|---|---|
/Langfuse/langfuse/.env | Langfuse 密钥、INIT key、dual 模式 |
/Langfuse/langfuse/docker-compose.yml | 必须显式传入 LANGFUSE_MIGRATION_V4_* |
/dify/dify-main/docker/.env | 仅备查的 LANGFUSE_* |
容器内 .../dify_trace_langfuse/langfuse_trace.py | Observation ID 补丁 |
11.2 本次必须存在的 Langfuse .env 片段
完整 .env 片段:patches_cursor/Langfuse_连接Dify专题/Langfuse_内联_08.env
运行/引用:patches_cursor/Langfuse_连接Dify专题/Langfuse_内联_08.env
LANGFUSE_INIT_ORG_ID=dckj
LANGFUSE_INIT_PROJECT_ID=default
LANGFUSE_INIT_PROJECT_PUBLIC_KEY=pk-lf-84a76d89-b6b4-4943-b4ea-2dee48d4cda7
LANGFUSE_INIT_PROJECT_SECRET_KEY=sk-lf-24aa6d73-ed36-4447-9eb6-3ebd446fb298
LANGFUSE_MIGRATION_V4_WRITE_MODE=dual
LANGFUSE_MIGRATION_V4_ALLOW_PREVIEW_OPT_IN=false11.3 Dify 应用监测填写值
Secret Key = sk-lf-24aa6d73-ed36-4447-9eb6-3ebd446fb298
Public Key = pk-lf-84a76d89-b6b4-4943-b4ea-2dee48d4cda7
Host = http://192.168.110.207:3000
开关 = 启用,provider = langfuse11.4 和「部署教程」的分工
Langfuse_部署教程_cursor.md:怎么把 Langfuse 4 在 207 上跑起来(镜像、端口、.env、登录)。- 本文:怎么让 Dify 把 Trace 推过去(密钥、监测配置、dual、补丁、验收)。
先部署,后连接。连接失败时,先确认部署教程里的健康检查仍然 OK,再按本文第八章(实战问题)和第九章(排错表)处理。
十二、结语
这套对接能工作,靠的不是「在 Dify 里写几个环境变量」,而是四件事同时成立:
- Langfuse 项目里有一把
pk-lf-/sk-lf-密钥。 - Dify 这个应用打开了 Langfuse Tracing,Host 指向宿主机
192.168.110.207:3000。 - Langfuse 4 处于
dual,愿意收 Dify 的旧 ingestion。 - Dify 插件给 Generation/Span 补了
id,否则只有 Trace 没有 llm 子节点。
四条都满足后,在 Dify 问一句,去 Langfuse Traces 刷新,就能看到完整调用、Prompt 和 Token。
本专题 Python 程序一览
| 文件 | 用途 | |---|---|| Langfuse_00_确认容器与健康.py | Langfuse 专题 · 步骤 0:确认 Langfuse / Dify 容器与健康检查。 | | Langfuse_01_打langfuse_trace补丁_修改后.py | Langfuse 专题 · 步骤 5 · 修改后:给 langfuse_trace.py 补 Observation UUID,重启 worker。 | | Langfuse_01_确认langfuse_trace_修改前.py | Langfuse 专题 · 步骤 5 · 修改前:确认 langfuse_trace.py 是否已打 Observation ID 补丁。 | | Langfuse_02_配置Dify_Tracing.py | Langfuse 专题 · 步骤 3:POST trace-config + /trace 打开应用 Langfuse Tracing。 | | Langfuse_03_验收Trace与对话.py | Langfuse 专题 · 步骤 6:发验收对话 + 查 Celery ops_trace + Langfuse traces API。 |
Langfuse_00_确认容器与健康.py
运行:/usr/bin/python3 /opt/workbuddy/scripts_cursor/Langfuse_连接Dify专题/Langfuse_00_确认容器与健康.py
完整补丁:patches_cursor/Langfuse_连接Dify专题/Langfuse_内联_01.py
运行/引用:见 patches_cursor/Langfuse_连接Dify专题/Langfuse_内联_01.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Langfuse 专题 · 步骤 0:确认 Langfuse / Dify 容器与健康检查。"""
from __future__ import annotations
import sys
import _bootstrap # noqa: F401
import _config as C
from dify_api import Dify
def main() -> int:
d = Dify(C.DIFY_EMAIL, C.DIFY_PASSWORD)
try:
print("=== 容器 ===")
out, err = d.sh(
"docker ps --format 'table {{.Names}}\t{{.Status}}' | grep -E 'langfuse|docker-api-1|docker-worker-1'"
)
print(out or err)
print("\n=== Langfuse health ===")
out, err = d.sh("curl -s %s" % C.LANGFUSE_HEALTH)
print(out or err)
for ctr in (C.DIFY_API_CTR, C.DIFY_WORKER):
print("\n=== %s -> Langfuse ===" % ctr)
out, err = d.sh(
"docker exec %s curl -s -m 8 %s" % (ctr, C.LANGFUSE_HEALTH)
)
print(out or err)
return 0
finally:
d.close()
if __name__ == "__main__":
sys.exit(main())Langfuse_01_打langfuse_trace补丁_修改后.py
运行:/usr/bin/python3 /opt/workbuddy/scripts_cursor/Langfuse_连接Dify专题/Langfuse_01_打langfuse_trace补丁_修改后.py
完整补丁:patches_cursor/Langfuse_连接Dify专题/Langfuse_内联_02.py
运行/引用:见 patches_cursor/Langfuse_连接Dify专题/Langfuse_内联_02.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Langfuse 专题 · 步骤 5 · 修改后:给 langfuse_trace.py 补 Observation UUID,重启 worker。"""
from __future__ import annotations
import sys
import time
import _bootstrap # noqa: F401
import _config as C
import _plugin_util as PU
from dify_api import Dify
LF_TRACE_PATH = (
"/app/api/.venv/lib/python3.12/site-packages/dify_trace_langfuse/langfuse_trace.py"
)
def patch_container(d: Dify, container: str) -> None:
content = PU.container_read(d, container, LF_TRACE_PATH)
if PU.LF_TRACE_MARKER in content:
print(container, "already patched")
return
new_content = PU.apply_langfuse_trace_patch(content)
PU.container_write(d, container, LF_TRACE_PATH, new_content)
out, err = d.sh(
"docker exec %s python -m py_compile %s" % (container, LF_TRACE_PATH)
)
print(container, "py_compile:", out or err or "ok")
def main() -> int:
d = Dify(C.DIFY_EMAIL, C.DIFY_PASSWORD)
try:
d.login()
for ctr in (C.DIFY_WORKER, C.DIFY_API_CTR):
patch_container(d, ctr)
print("\n=== restart docker-worker-1 ===")
out, err = d.sh("docker restart %s" % C.DIFY_WORKER, t=120)
print(out or err)
for i in range(15):
out, _ = d.sh("docker logs --since 30s %s 2>&1 | tail -5" % C.DIFY_WORKER)
if "ready" in (out or "").lower():
print("worker ready")
break
time.sleep(2)
else:
print("[WARN] 未在日志中看到 celery ready,请手动 docker logs -f", C.DIFY_WORKER)
for ctr in (C.DIFY_WORKER, C.DIFY_API_CTR):
print(PU.grep_in_container(d, ctr, LF_TRACE_PATH, "auto-assign if missing"))
return 0
finally:
d.close()
if __name__ == "__main__":
sys.exit(main())Langfuse_01_确认langfuse_trace_修改前.py
运行:/usr/bin/python3 /opt/workbuddy/scripts_cursor/Langfuse_连接Dify专题/Langfuse_01_确认langfuse_trace_修改前.py
完整补丁:patches_cursor/Langfuse_连接Dify专题/Langfuse_内联_03.py
运行/引用:见 patches_cursor/Langfuse_连接Dify专题/Langfuse_内联_03.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Langfuse 专题 · 步骤 5 · 修改前:确认 langfuse_trace.py 是否已打 Observation ID 补丁。"""
from __future__ import annotations
import sys
import _bootstrap # noqa: F401
import _config as C
import _plugin_util as PU
from dify_api import Dify
LF_TRACE_PATH = (
"/app/api/.venv/lib/python3.12/site-packages/dify_trace_langfuse/langfuse_trace.py"
)
def main() -> int:
d = Dify(C.DIFY_EMAIL, C.DIFY_PASSWORD)
try:
for ctr in (C.DIFY_WORKER, C.DIFY_API_CTR):
print("\n=== %s ===" % ctr)
out, err = d.sh("docker exec %s ls -l %s" % (ctr, LF_TRACE_PATH))
print(out or err)
grep = PU.grep_in_container(d, ctr, LF_TRACE_PATH, "auto-assign if missing")
if grep.strip():
print("[OK] 已打补丁:\n", grep)
else:
print("[WARN] 未打补丁 — 运行 Langfuse_01_打langfuse_trace补丁_修改后.py")
return 0
finally:
d.close()
if __name__ == "__main__":
sys.exit(main())Langfuse_02_配置Dify_Tracing.py
运行:/usr/bin/python3 /opt/workbuddy/scripts_cursor/Langfuse_连接Dify专题/Langfuse_02_配置Dify_Tracing.py
完整补丁:patches_cursor/Langfuse_连接Dify专题/Langfuse_内联_04.py
运行/引用:见 patches_cursor/Langfuse_连接Dify专题/Langfuse_内联_04.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Langfuse 专题 · 步骤 3:POST trace-config + /trace 打开应用 Langfuse Tracing。"""
from __future__ import annotations
import sys
import _bootstrap # noqa: F401
import _config as C
from dify_api import Dify, pp
def main() -> int:
d = Dify(C.DIFY_EMAIL, C.DIFY_PASSWORD)
try:
ok, _, _ = d.login()
print("login", ok)
if not ok:
return 1
pp("current trace", d.get("/apps/%s/trace" % C.APP_ID))
cfg = d.get("/apps/%s/trace-config?tracing_provider=langfuse" % C.APP_ID)
pp("current config", cfg)
body = {
"tracing_provider": "langfuse",
"tracing_config": {
"public_key": C.LANGFUSE_PK,
"secret_key": C.LANGFUSE_SK,
"host": C.LANGFUSE_URL,
},
}
if (cfg.get("json") or {}).get("has_not_configured"):
r = d.post("/apps/%s/trace-config" % C.APP_ID, body=body)
pp("create trace-config", r)
else:
r = d.req("PATCH", "/apps/%s/trace-config" % C.APP_ID, body=body)
pp("update trace-config", r)
if r.get("code") not in ("200", "201"):
r = d.post("/apps/%s/trace-config" % C.APP_ID, body=body)
pp("create after failed patch", r)
r = d.post(
"/apps/%s/trace" % C.APP_ID,
body={"enabled": True, "tracing_provider": "langfuse"},
)
pp("enable tracing", r)
pp("trace after", d.get("/apps/%s/trace" % C.APP_ID))
pp("config after", d.get("/apps/%s/trace-config?tracing_provider=langfuse" % C.APP_ID))
out, err = d.sh(
"docker exec %s psql -U postgres -d dify -c "
"\"SELECT id, name, tracing FROM apps WHERE id='%s';\""
% (C.PG_CONTAINER, C.APP_ID)
)
print(out or err)
return 0 if r.get("code") in ("200", "201") else 1
finally:
d.close()
if __name__ == "__main__":
sys.exit(main())Langfuse_03_验收Trace与对话.py
运行:/usr/bin/python3 /opt/workbuddy/scripts_cursor/Langfuse_连接Dify专题/Langfuse_03_验收Trace与对话.py
完整补丁:patches_cursor/Langfuse_连接Dify专题/Langfuse_内联_05.py
运行/引用:见 patches_cursor/Langfuse_连接Dify专题/Langfuse_内联_05.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Langfuse 专题 · 步骤 6:发验收对话 + 查 Celery ops_trace + Langfuse traces API。"""
from __future__ import annotations
import sys
import time
import _bootstrap # noqa: F401
import _config as C
from dify_api import Dify
def main() -> int:
d = Dify(C.DIFY_EMAIL, C.DIFY_PASSWORD)
try:
d.login()
print("=== 1/3 发验收对话 ===")
payload = {
"inputs": {},
"query": C.PROBE_QUERY_LANGFUSE,
"response_mode": "streaming",
"files": [],
"conversation_id": "",
}
r = d.post(
"/agent/%s/chat-messages" % C.AGENT_ID,
body=payload,
extra="-m 420 --max-time 420",
t=450,
)
print("HTTP", r.get("code"))
print((r.get("text") or "")[-800:])
print("\n=== 2/3 worker ops_trace 日志 ===")
time.sleep(5)
out, err = d.sh(
"docker logs --since 5m %s 2>&1 | grep ops_trace | tail -10" % C.DIFY_WORKER
)
print(out or err or "(无 ops_trace 行)")
print("\n=== 3/3 Langfuse GET /api/public/traces ===")
out2, err2 = d.sh(
"curl -s -u '%s:%s' '%s/api/public/traces?limit=5'"
% (C.LANGFUSE_PK, C.LANGFUSE_SK, C.LANGFUSE_URL)
)
print(out2 or err2)
return 0 if r.get("code") == "200" else 1
finally:
d.close()
if __name__ == "__main__":
sys.exit(main())