n8n Claude Code Langfuse 观测
# 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 1.88.x 自托管(Docker Compose,SQLite 存储)
- Langfuse v3 自托管(PostgreSQL 16,OTLP HTTP 端口 44381)
- Claude 3.5 Sonnet via OpenRouter($0.008/1K 输入 token)
- 编程语言:Python 3.12 + Langfuse SDK 2.18.0
🛠️ 部署架构
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 节点里手动设置:
- **Method**: POST
- **URL**: `http://langfuse:44381/api/public/ingestion`
- **Headers**:
Content-Type: application/x-ndjson
- **Body Content Type**: Raw
- **Specify Body**: JSON (NDJSON)
- **Body**:
{"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"
)
修复:利用 n8n 的 execution 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_tokens、completion_tokens、system_tokens(Claude 特有的 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 不知道自己是被谁调用的。
修复:通过 n8n 的Execute 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:
- 当单个 trace 的 total_tokens > 3000 → Slack 告警
- 当单日累计 token 消耗 > 50000 → 邮件告警
总结与内链
n8n + Langfuse 三件套的核心价值:让 LLM 调用从黑盒变成白盒。v1 解决的是能不能跑,v2 解决的是跑起来之后怎么看得清楚。
本文关联的踩坑经验:
- 如果你还没有跑起来 n8n + Langfuse,先看《n8n自托管生产环境5个致命踩坑》
- Claude Code 和 n8n 的集成配置,看《Claude Code + n8n + Langfuse 三件套实战》
下一步方向:
👉 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: