← 返回首页

n8n Claude Code Langfuse 观测

n8n工作流Langfuse自托管LLM观测OTEL追踪Claude Code调试

# Claude Code + n8n + Langfuse 三件套实战:本地 LLM 全链路 OTEL 观测踩坑复盘 v2

上次写了 n8n + Langfuse 自托管的 5 个真实生产坑(502/Redis 强依赖/2GB VPS OOM/PosMQ 连接超时/S3 对象命名冲突),那篇主要解决的是「能不能跑起来」。这次 v2 专注解决「跑起来之后怎么调」,核心痛点:token 漂移、span 断裂、跨节点 trace 丢失

我在这套环境里踩了 2 周才全部解决。期间最崩溃的是:Langfuse dashboard 一片空白,但 API明明有请求进来——最后发现是 NDJSON 格式和 HTTP Request 节点默认 JSON 格式不匹配。另一个高频踩的坑是 Claude API 返回的 system_tokens 单独计费,导致 Langfuse 记录的 token 数和 OpenRouter 账单永远对不上。这些坑都有具体日志和修复方案,记录在下面。

背景:为什么需要全链路观测

n8n 内置执行日志只能看到节点级别的输入输出,一旦涉及多步 LLM 调用(分类 → 提取 → 生成的串行链路),单个节点的日志根本看不出 token 消耗在哪一步。Langfuse 的 span 视图可以精确还原每个 LLM 调用的 token 消耗和延迟,但前提是你的 trace 能正确串联起来

本文环境:

🛠️ 部署架构

n8n 和 Langfuse 通过 Docker Compose 同一网络内通信:

# docker-compose.yml(核心片段)
services:
  n8n:
    image: n8nio/n8n:1.88.2
    environment:
      - N8N_METRICS=true
      - N8N_TRUSTED_PROXIES=*
    volumes:
      - ./n8n-data:/home/node/.n8n

  langfuse:
    image: langfuse/langfuse:v3.9.1
    ports:
      - "3000:3000"
      - "44381:44381"   # OTLP HTTP 接收端口
    environment:
      - DATABASE_URL=postgresql://langfuse:langfuse_secure_pass@postgres:5432/langfuse
      - NEXTAUTH_SECRET=your-secret-here
      - SALT=your-salt-here
      - OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:44381

  postgres:
    image: postgres:16-alpine
    environment:
      - POSTGRES_DB=langfuse
      - POSTGRES_USER=langfuse
      - POSTGRES_PASSWORD=langfuse_secure_pass
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

Langfuse 的 OTLP HTTP 端点默认监听 44381(OTLP 协议规定),n8n 的 HTTP Request 节点直接 post 到这个端口。

💣 踩坑实录

坑一:n8n HTTP Request 节点发 OTLP 时 header 格式错误导致 trace 全部丢失

现象:Langfuse dashboard 里完全看不到任何 trace,但 Langfuse 日志显示有请求进来。

排查

# Langfuse 侧看 OTLP 请求
docker compose logs langfuse 2>&1 | grep -i "otlp\|ingestion\|422\|400"
# 输出:收到请求但返回 400

**原因**:Langfuse v3 的 OTLP HTTP 接收要求特定的 content-type 和 header 组合。n8n HTTP Request 节点默认 header Content-Type: application/json,但 OTLP 协议要求 application/x-protobuf(使用 gRPC)或 application/json(但 payload 格式不同)。

Langfuse 的 HTTP OTLP 接收(/api/public/ingestion)实际接受两种格式:

1. **NDJSON**(每行一个 JSON 对象):{"resourceSpans":[...]}

2. **JSON 数组**:[{"resourceSpans":[...]}]

n8n HTTP Request 节点默认把 body 当作普通 JSON 发,导致格式不匹配。

修复:在 n8n 的 HTTP Request 节点里手动设置:

  Content-Type: application/x-ndjson
{"resourceSpans":[{"scopeSpans":[{"spans":[{"name":"Claude 3.5 Sonnet","attributes":[{"key":"llm.token_usage.prompt","value":{"intValue":"{{ $json.token_usage.prompt_tokens }}}},{"key":"llm.token_usage.completion","value":{"intValue":"{{ $json.token_usage.completion_tokens }}}}]}]}]}]}

实际上更可靠的方式是不用 n8n 的 HTTP Request 节点手动拼 OTLP,而是用 Python Script 节点 + Langfuse SDK,这样 SDK 自动处理所有 header 和序列化。

坑二:Python Script 节点中 Langfuse SDK 初始化后 span 断裂

现象:同一个 workflow 里多个 Python Script 节点调用 Langfuse SDK,但 Langfuse dashboard 里每个节点显示为独立的 trace,而不是串联在同一个 workflow execution 下。

原因:Langfuse SDK 默认用内存中的 trace ID 来串联 span。如果你每个 Python Script 节点独立初始化 Langfuse client,它们互相不知道彼此的 trace ID。

# ❌ 错误做法:每个节点独立初始化
from langfuse import Langfuse
langfuse = Langfuse(
    public_key="...",
    secret_key="...",
    host="http://langfuse:3000"
)

修复:利用 n8nexecution ID 作为 parent trace ID:

# ✅ 正确做法:跨节点共享 trace ID
import os

# 从 n8n 环境变量获取当前 execution ID
execution_id = os.environ.get('N8N_EXECUTION_ID', 'default')

langfuse = Langfuse(
    public_key=os.environ.get('LANGFUSE_PUBLIC_KEY'),
    secret_key=os.environ.get('LANGFUSE_SECRET_KEY'),
    host=os.environ.get('LANGFUSE_HOST', 'http://langfuse:3000'),
    tags=['n8n', f'execution-{execution_id}']
)

# 获取或创建当前 span
parent_trace = langfuse.trace(
    name="n8n-workflow",
    metadata={
        "execution_id": execution_id,
        "workflow_name": os.environ.get('N8N_WORKFLOW_NAME', 'unknown')
    }
)

# 在当前节点创建 span
with parent_trace.span(
    name="classify-email",
    input={"email": input_data}
) as span:
    result = classify_email(input_data)
    span.update(
        output=result,
        metadata={"model": "claude-3-5-sonnet", "cost_usd": 0.001}
    )

n8n 里,每个 Python Script 节点通过 N8N_EXECUTION_ID 环境变量共享同一个 execution,用这个 ID 作为 trace 的关联键。

坑三:Claude API 返回的 token 用量和 Langfuse 记录的不一致(token 漂移)

现象:Langfuse 显示某个 LLM 调用的 total_tokens 是 3200,但 OpenRouter 账单显示实际消耗了 4800 tokens。差值 1600 来自哪里?

**排查**:在 Claude API 的原始响应里,usage 字段包含 prompt_tokenscompletion_tokenssystem_tokensClaude 特有的 system prompt 独立计数):

{
  "usage": {
    "prompt_tokens": 1500,
    "completion_tokens": 1700,
    "system_tokens": 800
  }
}

如果你的解析代码只读了 completion_tokens,而 Langfuse SDK 的回调自动记录了完整的 total_tokens = prompt + completion,但没有记录 system_tokens,就会产生漂移。

修复:在 HTTP Request 节点或 Python Script 里,显式提取并推送完整的 usage:

# 手动上报完整 usage 到 Langfuse
api_response = call_claude_api(messages)

# 解析 usage
usage = api_response.get('usage', {})
prompt_tokens = usage.get('prompt_tokens', 0)
completion_tokens = usage.get('completion_tokens', 0)
system_tokens = usage.get('system_tokens', 0)  # Claude 特有
total_tokens = prompt_tokens + completion_tokens + system_tokens

# 手动记录到 Langfuse(绕过 SDK 自动记录的可能不完整数据)
langfuse.score(
    name="token_usage",
    value=total_tokens,
    trace_id=parent_trace.id,
    metadata={
        "prompt_tokens": prompt_tokens,
        "completion_tokens": completion_tokens,
        "system_tokens": system_tokens,
        "model": "claude-3-5-sonnet"
    }
)

坑四:n8n 子 workflow 的 trace context 没有传递给父 workflow

现象:父 workflow 调用子 workflow(n8n Execute Workflow 节点),Langfuse 里能看到父 workflow 的 trace,但子 workflow 的 span 是孤立的,没有挂在父 trace 下面。

原因n8n 的 Execute Workflow 节点默认不传递 context(环境变量、N8N_EXECUTION_ID 等)。子 workflow 不知道自己是被谁调用的。

修复:通过 n8nExecute Workflow 节点的「Pass on」配置显式传递 metadata:

1. 在父 workflow 的 Execute Workflow 节点:

- Mode: Reactive(推荐)

- **Pass on**: ={{ JSON.stringify({ "parent_trace_id": $vars.LANGFUSE_TRACE_ID, "parent_execution_id": $execution.id }) }}

2. 在子 workflow 的第一个节点(Set 节点)里接收:

   {{ JSON.parse($json.parent_trace_id) }}

然后在 Python Script 节点里用这个 parent_trace_id 继续 span:

   parent_data = $input.first().json
   trace_id = parent_data.get('parent_trace_id')

   if trace_id:
       # 继续父 trace,而不是创建新 trace
       generation = langfuse.generation(
           name="child-workflow-step",
           trace_id=trace_id,  # 显式指定 parent trace
           input=input_data,
           model="claude-3-5-sonnet"
       )

坑五:Langfuse ingestion API 的 rate limit 导致深夜批量运行时 trace 丢失

现象:白天测试完全正常,但凌晨跑日结 batch workflow(100+ 个 LLM 调用)时,Langfuse dashboard 里只有前 20 条 trace,后面全丢了。

**原因**:Langfuse v3 自托管版的 ingestion API 有默认 rate limit(默认 100 req/min,由 INGESTION_RATE_LIMIT 环境变量控制)。深夜 batch 瞬间发出 100+ 请求,超过限制后直接丢弃。

诊断

# 查看 Langfuse 日志里的 rate limit 记录
docker compose logs langfuse 2>&1 | grep -i "rate\|limit\|throttle"
# 输出:WARN  [ingestion] Rate limit exceeded for key: default, dropping 47 spans

修复

1. 方案 A:调高 Langfuse 的 rate limit(在 docker-compose.yml 里):

langfuse:
  environment:
    - INGESTION_RATE_LIMIT=1000
    - INGESTION_BURST_LIMIT=2000

2. 方案 B(推荐生产用):在 n8n 侧加限速,使用 Loop Over Items 节点 + Sleep 节点 控制每秒请求数:

HTTP Request (call LLM) → Sleep (500ms) → Loop继续

3. 方案 C:用 Langfuse cloud 版本(原生无限 rate limit),或者在自托管版前面加 Redis 队列做缓冲。

实战:5 步搭建可调试的 n8n + Claude Code + Langfuse 工作流

Step 1:在 n8n 里配置 Langfuse 连接器(Python Script 节点)

n8n 的 **Python Script 节点**(Community Nodes,需要安装 @n8n/n8n-nodes-python)可以直接跑 Langfuse SDK。在 workflow 开头配置一次,后续节点复用同一个 client:

import os
import json

# 初始化 Langfuse(整个 workflow 只初始化一次)
langfuse = Langfuse(
    public_key=os.environ.get('LANGFUSE_PUBLIC_KEY'),
    secret_key=os.environ.get('LANGFUSE_SECRET_KEY'),
    host=os.environ.get('LANGFUSE_HOST', 'http://langfuse:3000'),
    flush_at=1,          # 每发生一次就 flush,生产环境可调大
    flush_interval=1,    # 间隔 1 秒
    sdk_metadata={"n8n_version": "1.88"}
)

# 获取当前 execution 的 trace
trace = langfuse.trace(
    name="email-classification-pipeline",
    metadata={
        "execution_id": os.environ.get('N8N_EXECUTION_ID'),
        "trigger": "schedule"  # 定时触发
    }
)

return [{"json": {"trace_id": trace.id, "langfuse_initialized": True }}]

Step 2:构建分类 → 提取 → 生成串行链路

n8n workflow 结构:

[Schedule Trigger: 每天 9AM]
       ↓
[Set: 加载待处理邮件列表]
       ↓
[Split In Batches: 逐条处理]
       ↓
[Python Script: 分类 + Langfuse generation]
       ↓
[IF: 分类结果 = "技术问题"]
       ↓         ↓
[分支A]      [分支B: 非技术问题]
[Python Script: 提取关键信息]
       ↓
[Python Script: 生成回复草稿 + Langfuse generation]
       ↓
[HTTP Request: 发送邮件回复]
       ↓
[Loop Continue]

Step 3:在 Claude Code 里调试这个 workflow

Claude Code 连接这个 n8n instance:

# 在 Claude Code 里连接 n8n MCP
claude mcp add-json n8n-mcp
# 配置 endpoint: http://your-n8n:5678/rest/mcp
# 添加 auth: Bearer token

现在可以用自然语言调试:

帮我看一下昨天 9AM 执行的那个 email-classification-pipeline,
哪些 trace 的 total_tokens 超过 5000?我怀疑有 token 浪费

Claude Code 通过 n8n MCP 直接查执行日志,结合 Langfuse 的 span 数据给出分析。

Step 4:验证 trace 完整性

# 查 Langfuse API 看某次 execution 的完整 trace
curl -s "http://localhost:3000/api/public/traces?execution_id=N8N_EXECUTION_ID" \
  -H "Authorization: Bearer $LANGFUSE_PUBLIC_KEY:$LANGFUSE_SECRET_KEY" | \
  jq '.data[] | {name, id, observations: (.observations | length)}'

预期输出:每个 span 的 parent_id 正确指向,形成完整树状结构。

Step 5:设置 token 预算告警

在 Langfuse dashboard 里设置 soft budget

总结与内链

n8n + Langfuse 三件套的核心价值:让 LLM 调用从黑盒变成白盒。v1 解决的是能不能跑,v2 解决的是跑起来之后怎么看得清楚。

本文关联的踩坑经验

下一步方向

👉 Join MiniMax Token Plan: AI coding acceleration for businesses

👉 Join Zhipu Coding Plan: GLM-4.6/GLM-5 coding packages, China-stable, pay-per-token unlimited

👉 Join Aliyun AI: Top AI products with exclusive coupons for business innovation

📌 This article was AI-assisted generated and human-reviewed | TechPassive — An AI-driven content testing site focused on real tool reviews

🔗 Recommended Tools

These are carefully selected tools. Using our affiliate links supports us to keep producing quality content:

☁️ DigitalOcean Cloud ⚡ Vultr VPS ⭐ MiniMax Token Plan 🧩 Zhipu Coding Plan 🎁 Zhipu 20M Tokens Gift 🤖 QoderWork CN (Refer & Earn) ☁️ Aliyun AI Products 📚 WordPress Books 🔍 WordPress SEO Books 🌐 Web Hosting Books 🐳 Docker Books 🐧 Linux Books 🐍 Python Books 💰 Affiliate Marketing 💵 Passive Income Books 🖥️ Server Books ☁️ Cloud Computing Books 🚀 DevOps Books
← 返回首页