📎 配套脚本附件正文未内嵌 · 共 26 个 · 点开可在线查看
📚 另需公共库(正文未重复内嵌,点名字直接看):_agent_cfg.py、_bootstrap.py、_config.py、_plugin_util.py
📂 查看全部 761 个脚本附件 →
⚠️ 脚本内含明文凭据(与本页正文一致),请勿在公网环境直接运行。
Langfuse · Dify Tracing

Langfuse 连接 Dify 专题

密钥、监测配置、v4 dual、Observation ID 补丁。步骤未改,代码块已换成完整程序。

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 配一个大模型。两边各自已经能工作,缺的是一条「观测管道」。

验收时必须同时看到这三项:

  1. Trace:一条以 message 为名的调用记录,能点进去。
  2. Prompt:用户原话(以及模型回复)出现在 Trace / Generation 的 Input / Output。
  3. Token:Generation 上有 promptTokens / completionTokens / totalTokens。

二、本次环境一览

项值
服务器192.168.110.207
Dify1.16.1,入口 http://192.168.110.207(80/443)
Langfuse4.12.0,入口 http://192.168.110.207:3000
对接应用播城行政问答智能体
应用 ID40e95fd3-e7f5-44f2-a03b-dc3e60413540
Agent ID01a012e7-bed3-7574-b0d1-a5cc79a0d3de
应用类型新 Agent(mode=agent)
Langfuse 项目组织 dckj,项目 default
Langfuse 登录admin@example.com / Langfuse123456
Dify 控制台38205036@qq.com / Difyai123456
Langfuse Public Keypk-lf-84a76d89-b6b4-4943-b4ea-2dee48d4cda7
Langfuse Secret Keysk-lf-24aa6d73-ed36-4447-9eb6-3ebd446fb298
Dify 填的 Hosthttp://192.168.110.207:3000
验收 Tracehttp://192.168.110.207:3000/project/default/traces/1f339a2f-bbda-4042-8e99-1d66bbc6faba

相关容器:

容器作用
docker-api-1Dify API,保存 Tracing 配置、发起对话
docker-worker-1Dify Celery,真正把 Trace 推到 Langfuse(队列名 ops_trace)
docker-agent_backend-1新 Agent 运行时,本身不直接打 Langfuse
langfuse-langfuse-web-1Langfuse 控制台 + 接收 /api/public/ingestion
langfuse-langfuse-worker-1Langfuse 后台任务、dual-write 传播
langfuse-clickhouse-1真正存 Trace / Observation
langfuse-postgres-1Langfuse 用户、项目、API Key

三、先记住三件反直觉的事实

后面所有步骤都围绕这三件事。搞反了,界面上填得再对也看不到 Trace。

1. Dify 1.16 不是靠全局环境变量上报

很多人会在 Dify 的 .env 里写 LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY,以为重启就通。

Dify 1.16 官方对接路径是按应用配置,不是全局 env。

真正生效的位置:

.env 里那三项只是备查,不重启 Dify 也不会生效,重启了 Dify 1.16 也不会读它们来推 Trace。

2. Host 绝对不能写成 localhost 或 127.0.0.1

Dify 的 api / worker 跑在容器里。容器里的 localhost 是容器自己,不是 207 宿主机,也不是 Langfuse。

正确 Host:

text
http://192.168.110.207:3000

错误 Host(常见):

text
http://localhost:3000
https://cloud.langfuse.com
http://langfuse-web:3000          # Dify 和 Langfuse 不在同一个 docker network

3. Langfuse 4 默认拒收 Dify 现在用的旧上报接口

Dify 1.16 自带的 dify_trace_langfuse 插件,走的是 Langfuse v3 风格 REST:

text
POST /api/public/ingestion
Authorization: Basic <public_key:secret_key>

Langfuse 4 新部署默认 LANGFUSE_MIGRATION_V4_WRITE_MODE=events_only。这个模式下,旧 ingestion 会被直接丢掉,web 日志里会出现:

text
Rejected ... event(s) from the legacy /api/public/ingestion endpoint
... this Langfuse v4 deployment runs in events_only mode

Celery 任务仍会显示 Processing trace tasks success,因为 Dify 只关心 HTTP 没抛异常,并不知道 Langfuse 把事件扔了。

解决办法:Langfuse web 和 worker 都改成 dual。


四、原理:数据到底怎么从 Dify 流到 Langfuse

4.1 一张图看完

text
浏览器
  |  问一句「公司请假流程是什么」
  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 异步补报的。所以:

4.2 Dify 侧两张「开关」

光有密钥不够,Dify 用两层配置。

第一层:这个应用开不开 Tracing、用哪家。

表 apps 的 tracing 列,JSON 形如:

json
{"enabled": true, "tracing_provider": "langfuse"}

对应控制台接口:

text
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_configJSON,里面的 public_key / secret_key 会按租户加密存放
is_active默认 true

对应控制台接口:

text
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=langfuse

POST / PATCH 的 body:

json
{
  "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 插件到底上报了什么

插件包在容器里:

text
/app/api/.venv/lib/python3.12/site-packages/dify_trace_langfuse/langfuse_trace.py

类 LangFuseDataTrace 用 Langfuse Python SDK 的 ingestion.batch,不是 OTLP。

一次普通问答至少会发两种事件:

事件Langfuse 里长什么样主要内容
trace-createTrace,name=message用户问题、回答、message_tokens / answer_tokens / total_tokens、会话 ID
generation-createObservation,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

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。结果是:

修复:在 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

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、中文回答)可以正常显示。若菜单要中文,用浏览器「翻译此页」。


五、前置条件

开始对接前确认:

  1. Langfuse 健康检查返回 OK:

完整脚本:scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_01.sh

运行/引用:bash /opt/workbuddy/scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_01.sh

bash
#!/usr/bin/env bash
set -euo pipefail

curl -s http://127.0.0.1:3000/api/public/health
# 期望: {"status":"OK","version":"4.12.0"}
  1. 浏览器能打开 http://192.168.110.207:3000,能用 admin@example.com 登录。
  2. Dify 能正常问答(模型、知识库已经通)。
  3. docker-api-1、docker-worker-1 在跑。

Langfuse 还没装的,先做 Langfuse_部署教程_cursor.md,再回到本文。


六、操作步骤(按顺序做,不要跳)

阶段与验收

阶段步骤做完应看到
环境0~1Langfuse+ Dify api/worker 都在
配置2~3trace-config 写入 pk/sk,trace 开关 true
补丁4~5worker/api 内 langfuse_trace.py 已 patch
验收6Langfuse UI 有 Trace;observations 含 tool
现象优先查
Trace 开关开了但没有数据§八 问题 1~3;Host 勿写 localhost
只有 Generation 无 Tool步骤 5 补丁+worker 重启;作业 3
Invalid CredentialsINIT pk/sk 与 Dify 填的一致
升级后全丢§十 重启复查清单

步骤 0:确认两边容器都在

在 207 上:

完整脚本:scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_02.sh

运行/引用:bash /opt/workbuddy/scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_02.sh

bash
#!/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'

至少要看到:

再测 Dify 容器访问 Langfuse:

完整脚本:scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_03.sh

运行/引用:bash /opt/workbuddy/scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_03.sh

bash
#!/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

bash
#!/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

bash
#!/usr/bin/env bash
set -euo pipefail

python3 - <<'PY'
import uuid
print("pk-lf-" + str(uuid.uuid4()))
print("sk-lf-" + str(uuid.uuid4()))
PY

本次实际值:

text
pk-lf-84a76d89-b6b4-4943-b4ea-2dee48d4cda7
sk-lf-24aa6d73-ed36-4447-9eb6-3ebd446fb298

1.2 写入 /Langfuse/langfuse/.env,不要加引号

追加(已有则改值,不要重复两行):

完整 .env 片段:patches_cursor/Langfuse_连接Dify专题/Langfuse_内联_03.env

运行/引用:patches_cursor/Langfuse_连接Dify专题/Langfuse_内联_03.env

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

bash
#!/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

env
LANGFUSE_MIGRATION_V4_WRITE_MODE=dual
LANGFUSE_MIGRATION_V4_ALLOW_PREVIEW_OPT_IN=false

2.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

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

bash
#!/usr/bin/env bash
set -euo pipefail

cd /Langfuse/langfuse
docker compose -p langfuse up -d langfuse-web langfuse-worker

2.4 确认容器内真的是 dual

完整脚本:scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_08.sh

运行/引用:bash /opt/workbuddy/scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_08.sh

bash
#!/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)。

两步缺一不可:

  1. POST /console/api/apps/<app_id>/trace-config:写入 pk / sk / host(表 trace_app_config)
  2. POST /console/api/apps/<app_id>/trace:打开开关(表 apps.tracing)

只做第 1 步,Celery 不会推。只做第 2 步,没有密钥,保存时 check_trace_config_is_effective 会 Invalid Credentials。

3.1 字段(和 UI 表单同一套)

字段本次值
应用 ID40e95fd3-e7f5-44f2-a03b-dc3e60413540(播城行政问答智能体)
Secret Keysk-lf-24aa6d73-ed36-4447-9eb6-3ebd446fb298
Public Keypk-lf-84a76d89-b6b4-4943-b4ea-2dee48d4cda7
Hosthttp://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

python
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,配置存不进去。

成功回读时密钥打码,并出现:

text
http://192.168.110.207:3000/project/default

期望 HTTP:trace-config 为 200/201,/trace 为 200。GET /apps/<id>/trace 应类似:

json
{"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

bash
#!/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;"

期望:

tracing_config 在库里是加密的,不要用 SQL 改密钥,改走上面的 POST/PATCH。

3.4 对应的原始路径(给 curl / 其它客户端)

dify_api.py 会把下面的 path 接到 http://127.0.0.1/console/api:

text
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

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 改哪个文件、哪两个函数

容器内路径(两个容器里路径相同):

text
/app/api/.venv/lib/python3.12/site-packages/dify_trace_langfuse/langfuse_trace.py
项值
容器docker-worker-1(必须改,真正推送)+ docker-api-1(一起改,避免以后行为不一致)
类LangFuseDataTrace
函数 1add_span
函数 2add_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

bash
#!/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

python
    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

python
            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

python
            # 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

python
    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

python
    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

python
            # 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

python
    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

python
    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

bash
#!/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

python
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

bash
#!/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。

bash
docker restart docker-worker-1

盯日志,等到 ready 再问下一题:

bash
docker logs -f docker-worker-1

看到类似下面这行就可以 Ctrl+C 停掉 follow:

text
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

bash
#!/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,不在宿主机绑定目录。以下动作会丢掉补丁:

重建后从 5.3 再走一遍:确认没有 marker → 方法 B 再打 → 重启 worker。


步骤 6:验收

6.1 在 Dify 问一句能打到知识库的问题

例如:

text
请用一两句话说明公司请假流程,这是 Langfuse 完整链路验收。

等回答完整结束。Trace 是对话结束后由 Celery 异步推的,通常 几秒到十几秒 才出现,不是点发送的同一瞬间。

6.2 打开 Langfuse

  1. 浏览器打开 http://192.168.110.207:3000
  2. 登录 admin@example.com / Langfuse123456
  3. 进入组织 DCKJ → 项目 default
  4. 左侧 Tracing → Traces
  5. 应看到最新一条 name=message,tags 含 agent / message

点进去应看到:

本次验收 Trace 直链:

text
http://192.168.110.207:3000/project/default/traces/1f339a2f-bbda-4042-8e99-1d66bbc6faba

6.3 用 API 复核(不依赖 UI)

把 pk/sk 换成自己的:

完整脚本:scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_14.sh

运行/引用:bash /opt/workbuddy/scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_14.sh

bash
#!/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

bash
#!/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

bash
#!/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

bash
#!/usr/bin/env bash
set -euo pipefail

docker logs --since 10m docker-worker-1 2>&1 | grep ops_trace

成功时类似:

text
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

6.6 看 Langfuse web 有没有拒收

完整脚本:scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_18.sh

运行/引用:bash /opt/workbuddy/scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_18.sh

bash
#!/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 ID1f339a2f-bbda-4042-8e99-1d66bbc6faba
Trace namemessage
tagsagent, message
Observation0cc1e325-c128-4e05-b80a-afe7b4c50dcd,type=GENERATION,name=llm
模型qwen3.6-27b
Prompt(用户话)请用一两句话说明公司请假流程,这是 Langfuse 完整链路验收。
promptTokens6601
completionTokens654
totalTokens7255
费用字段calculatedTotalCost ≈ 0.052625
延迟约 12.5 秒

这满足验收标准:有 Trace、有 Prompt、有 Token。


八、本次实战遇到的问题和对应的解决方案

下面只记 这次 207 对接时真实踩过的坑。现象、根因、完整配置/代码、验证命令都写全,方便以后原样复用。

总览:

序号当时看到的现象真正原因解决方案
坑 1在 Dify .env / 容器 env 里找不到 LANGFUSE_*Dify 1.16 按应用配 Tracing,不读全局环境变量走步骤 3 的 Console API,写入 trace_app_config
坑 2Langfuse 能登录,但没有任何 API Key初始化时没设 LANGFUSE_INIT_PROJECT_PUBLIC_KEY/SECRET_KEY写入 INIT 密钥并重建 langfuse-web
坑 3Dify 已启用 Tracing,Celery 也 success,后台仍空Langfuse 4 默认 events_only,拒收旧 /api/public/ingestionweb/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
坑 6GET /api/public/traces 提示 v4 events_only还在新读路径/拒收旧表dual + ALLOW_PREVIEW_OPT_IN=false
坑 7Host 填 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。

根因

官方路径是:

  1. POST /console/api/apps/<app_id>/trace-config 写入表 trace_app_config
  2. POST /console/api/apps/<app_id>/trace 把 apps.tracing 写成 {"enabled": true, "tracing_provider": "langfuse"}
  3. 对话结束后 Celery 队列 ops_trace 读这两处配置再推送

解决方案

不要指望全局 env。按本文第六章步骤 3 用 控制台 API 写入 pk/sk/host 并打开开关。.env 里那三项只是备查。新 Agent 工作室里往往没有「监测」菜单。

本次落库结果:

sql
-- 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):

json
{
  "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"
  }
}

启用开关:

json
{
  "enabled": true,
  "tracing_provider": "langfuse"
}

坑 2:Langfuse 项目在,API Key 是空的

现象

组织 dckj、项目 default、用户 admin@example.com 都有,但:

sql
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

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

bash
#!/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

bash
#!/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 日志:

text
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 返回:

text
This endpoint is not available on deployments running in Langfuse v4 events_only mode.

langfuse-langfuse-web-1 日志才是真相:

text
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

env
LANGFUSE_MIGRATION_V4_WRITE_MODE=dual
LANGFUSE_MIGRATION_V4_ALLOW_PREVIEW_OPT_IN=false

ALLOW_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

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

yaml
    environment:
      <<: *langfuse-worker-env

3)重建两个服务:

完整脚本:scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_21.sh

运行/引用:bash /opt/workbuddy/scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_21.sh

bash
#!/usr/bin/env bash
set -euo pipefail

cd /Langfuse/langfuse
docker compose -p langfuse up -d langfuse-web langfuse-worker

4)确认容器内真是 dual(本次踩过「文件改了、容器没吃到」):

完整脚本:scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_22.sh

运行/引用:bash /opt/workbuddy/scripts_bash_cursor/Langfuse_连接Dify专题/Langfuse_内联_22.sh

bash
#!/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

bash
#!/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。但是:

用 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,被丢掉。

补丁文件(两个容器都要改):

text
/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

python
    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

python
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

bash
#!/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

bash
#!/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

bash
#!/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

bash
#!/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

bash
#!/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 容器都能通):

text
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

bash
#!/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。这是目前必须记住的运维动作。


这些坑的先后顺序(以后照这个排)

  1. Host 能否从 docker-worker-1 curl 通 3000。
  2. api_keys 表是否有 pk。
  3. apps.tracing.enabled 是否 true。
  4. 两个 Langfuse 容器 printenv LANGFUSE_MIGRATION_V4_WRITE_MODE 是否 dual。
  5. langfuse_trace.py 是否含 auto-assign if missing,worker 是否在补丁之后重启过。
  6. 再问一句,等十几秒,查 traces + observations。

Celery 的 Processing trace tasks success 不能单独当验收通过。


九、排错手册

按「你看见的现象」对,不要跳着改。带完整配置和补丁代码的复盘见 第八章。

现象先查处理
Dify 保存 Tracing 报 Invalid CredentialsHost、pk/sk、Langfuse 是否 UpHost 改成 http://192.168.110.207:3000;用 curl -u pk:sk 打 /api/public/projects
问了问题,Langfuse 完全没有 Traceapps.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

bash
#!/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-1Observation ID 补丁丢失步骤 5 再打,再重启 worker
重建 langfuse-web / langfuse-worker若 compose/.env 还在,dual 和 INIT key 还在复查 printenv LANGFUSE_MIGRATION_V4_WRITE_MODE
把 Langfuse 升到更新的 4.xdual 仍是过渡模式,将来可能被移除升级前先读官方 v3→v4 指南;Dify 若仍用旧 ingestion,暂时不要切回 events_only
在 Dify 应用里关掉监测再打开配置还在 trace_app_config只要 apps.tracing.enabled 再打开即可
换了一把 Langfuse API Key旧 key 失效到 Dify 监测里更新 pk/sk 并保存(会再做一次凭证校验)

不建议做的事:


十一、关键路径和配置清单

11.1 文件

路径用途
/Langfuse/langfuse/.envLangfuse 密钥、INIT key、dual 模式
/Langfuse/langfuse/docker-compose.yml必须显式传入 LANGFUSE_MIGRATION_V4_*
/dify/dify-main/docker/.env仅备查的 LANGFUSE_*
容器内 .../dify_trace_langfuse/langfuse_trace.pyObservation ID 补丁

11.2 本次必须存在的 Langfuse .env 片段

完整 .env 片段:patches_cursor/Langfuse_连接Dify专题/Langfuse_内联_08.env

运行/引用:patches_cursor/Langfuse_连接Dify专题/Langfuse_内联_08.env

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=false

11.3 Dify 应用监测填写值

text
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 = langfuse

11.4 和「部署教程」的分工

先部署,后连接。连接失败时,先确认部署教程里的健康检查仍然 OK,再按本文第八章(实战问题)和第九章(排错表)处理。


十二、结语

这套对接能工作,靠的不是「在 Dify 里写几个环境变量」,而是四件事同时成立:

  1. Langfuse 项目里有一把 pk-lf- / sk-lf- 密钥。
  2. Dify 这个应用打开了 Langfuse Tracing,Host 指向宿主机 192.168.110.207:3000。
  3. Langfuse 4 处于 dual,愿意收 Dify 的旧 ingestion。
  4. 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

python
#!/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

python
#!/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

python
#!/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

python
#!/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

python
#!/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())