Langfuse trace 是 agent 行为的回放器
一个 K12 场景的 debug 需求
Section titled “一个 K12 场景的 debug 需求”孩子家长来 issue:“今天下午 agent 跟我孩子说话有点奇怪,劝他买什么东西。”
你打开 OpenIM 后台,找到那段对话——agent 确实说了一句听起来像推销的话。
下一个问题:为什么?
agent 当时看到的 system prompt 是哪个版本? runtime context 里 scoreboard 显示什么? arrived 的是 user 直接消息还是 overhear 消息? agent 在 thinking 块里想了什么? 它调了什么工具?工具结果是什么?
如果你只有 OpenIM 聊天日志,每一个问题都没答案。你能看到 agent 说了什么,看不到 agent 为什么这么说。
debug 变成考古。
Trace 是把这场考古变成回放的工具。 每个 turn 留下一个完整的 trace——input、output、思考、工具调用、usage、metadata。事后任何人打开 Langfuse dashboard 都能精确复盘那一刻 agent 的脑子里发生了什么。
这就是为什么 trace 在 agent harness 里是 first-class infra,不是 nice-to-have。
为什么不用 auto-instrumentation
Section titled “为什么不用 auto-instrumentation”我们用 Claude Agent SDK + Langfuse Cloud (JP region)。最自然的接入方式是用 @arizeai/openinference-instrumentation-claude-agent-sdk——Arize AI 提供的自动 instrumentation 包,cookbook 写得很漂亮,看起来一行 register() 就完事。
我们试了。爆了。
错误信息:
TypeError: undefined is not an object (evaluating 'span.instrumentationScope.name') at LangfuseSpanProcessor.onEnd根因 chain 是这样的:
| 包 | OTel 依赖 |
|---|---|
@arizeai/openinference-instrumentation-claude-agent-sdk | @opentelemetry/core@^1.25.1 |
@langfuse/otel@^4.0.0 | @opentelemetry/core@^2.0.1 |
OTel v1 的 ReadableSpan 接口字段叫 instrumentationLibrary,v2 改名为 instrumentationScope。@langfuse/otel@v4 的 LangfuseSpanProcessor.onEnd 读 span.instrumentationScope.name——拿到 Arize 包产生的 v1 span,字段 undefined,throw。
npm 把两个版本都装进 node_modules,TypeScript 类型相对工具能编译过,运行时炸。
Cookbook 在 Deno 里跑过——Deno 的模块解析不同。Node ESM 下不通。
我们花了一天试各种 yarn resolutions / npm overrides,每次都修好一处冒出另一处。这套依赖链太深,不是用户层面能调和的。
结论:绕过它,手搓 instrumentation。
手搓的 instrumentation 三类 observation
Section titled “手搓的 instrumentation 三类 observation”我们的 packages/agent-engine/src/instrumentation.ts 大约 370 行,三类 observation:
/** * 三类 observation: * agent-turn (span) ← 最外层,每个 turn 一个,捕获 input + 终态 * └─ claude-agent-llm (gen) ← Claude Agent SDK 整轮 LLM 活动,含 model/usage * └─ tool/<name> (tool) ← 每次 PreToolUse → PostToolUse 一个 * * 当 LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY 任一缺失时整体 no-op; * agent-engine 行为完全不变(零运行时依赖加载、零 OTel context 开销)。 */1. agent-turn span(最外层)
withTurnObservation = async (name, attrs, fn) => { return startActiveObservation(name, async (span) => { span.update({ input: attrs.input }); return await propagateAttributes( { userId: attrs.userId, sessionId: attrs.sessionId, // = chatId tags: attrs.tags, // ["mode:user", "agent:abc"] metadata: { turn_id, constitution_hash, ... }, }, fn, ); }, { asType: "span" });};asType: "span" 是必须的——缺省会让 SDK 走默认路径,跟 NodeSDK 自动 root 共存出第二个 endTime=null 的幽灵根 span(曾经有过 issue)。
2. claude-agent-llm generation
这是真正的 LLM 调用记录。最重要的字段:
gen = startObservation( "claude-agent-llm", { input: params.input, // 用户消息 + runtime context(模型真正看到的) model: params.model, // "claude-opus-4-1-20250805" 或 "MiniMax-M2.7" modelParameters: { provider: env.ANTHROPIC_BASE_URL?.includes("minimax") ? "minimax" : "anthropic", base_url: env.ANTHROPIC_BASE_URL, }, metadata: { turn_id, ... }, }, { asType: "generation", startTime: new Date() },);end 时记录 usage(含 cache 命中):
const usageDetails = buildUsageDetails(usage);// {// input: 1234,// output: 567,// cache_read_input: 73456, ← prefix cache 命中的部分// cache_creation_input: 0, ← 写 cache 的部分// total: 75257,// }gen.update({ output, usageDetails });gen.end(new Date());cache_read_input 字段就是我们 I-1 那个 95% cache 命中率 的来源——直接读 Langfuse trace 算比例。
3. tool/<name> observation
每次 SDK 调工具,PreToolUse → 我们开一个 tool observation,PostToolUse → 关闭:
startToolObservation = (toolName, input) => { const span = startObservation( `tool/${toolName}`, // "tool/Read", "tool/write_to_board", etc. { input }, { asType: "tool" }, ); return { end({ output, error }) { if (error) span.update({ level: "ERROR", statusMessage: error.message }); else if (output !== undefined) span.update({ output }); span.end(); }, };};打开 Langfuse 一个典型的 turn 是这样:
agent-turn [span, 4.2s]├── claude-agent-llm [generation, 4.0s, in=1234 out=567 cache=73456]├── tool/Read [tool, 12ms, .learner/atlas/math.md]├── tool/Bash [tool, 87ms, class plan list]├── tool/write_to_board [tool, 5ms]└── tool/Edit [tool, 9ms, .learner/journal/2026-05-17.md]整个 turn 完整可见。
“不要假观测” 这条原则
Section titled ““不要假观测” 这条原则”写 trace 容易,写真的 trace 难。
我们经过的一次诱惑:III-1 Substrate + Evaluator 里讲过 evidence_snippet 字段——存 “为什么 agent 拿了这分”。最干净的来源是 LLM 当时看到的 input/output。但在某些 cron 场景下我们想 backfill 历史评分,那时候 trace 已经过期或者数据没存全。
诱惑:从 OpenIM 历史拉用户消息,当作 evidence_snippet。
理由听起来合理:
- IM 历史是用户跟 agent 说的话,跟 LLM input 应该差不多
- 加一个
evidence_source: "im_history"caveat 就行 - 总比没有强
我们 14 个小时后否决了。理由:
1. “差不多” 不等于 “一样”。 IM 历史是用户侧 view——可能经过 SDK 压缩、可能被截断、可能合并了多条短消息。LLM 真正看到的可能是 “你能给我讲个故事吗?我喜欢恐龙”,IM 历史可能存的是 “讲个故事”。差一个 “我喜欢恐龙” 就足够让评分逻辑做出不同判断。
2. caveat 注释会被忽略。 字段叫 evidence_snippet,下游 dashboard 显示 “evidence:“,看 dashboard 的人不会去读 schema 里 evidence_source: "im_history" 的注释。field name 比 schema doc 强 10 倍。
3. 假数据比没数据更危险。 如果 evidence_snippet 是 null,evaluator 知道这条不能 audit,不会做出 “based on evidence” 的决策。如果 evidence_snippet 是 IM 历史填进去的,evaluator 会信以为真地推理。
结论简化成一条规则:如果拿不到 ground truth,就 omit field——不要用 caveat-ed proxy 凑。
这条规则现在写在 evaluator 的 PR 模板里:
[ ] 所有新增 trace 字段是否都是 ground truth? [ ] 如果某个场景下拿不到,是 null/omit,不是 “近似值 + caveat”?
W3C trace_id 跨服务传播
Section titled “W3C trace_id 跨服务传播”c624aa46 (2026-05-13) 这个 PR 把 trace 提升到平台级——所有 TeachClaw 服务在 logEvent 里带 trace_id,通过 W3C traceparent header 传递:
[Browser] 用户点 send ↓ traceparent: 00-abc123...-span001-01[CF Worker (im-web API proxy)] ↓ traceparent: 00-abc123...-span002-01[TeachClaw Backend API] ↓ traceparent: 00-abc123...-span003-01[Temporal workflow signal] ↓ traceparent → activity context[Workspace 容器内 agent-engine] ↓ TRACEPARENT env → Langfuse root span整条链路 5 跳,一个 trace_id 串起来。
之前 debug 一个 “用户点 send → agent 5 秒后才回” 这种问题,得手动 grep 三个服务的日志、按时间窗口拼对。现在 dashboard 一个 trace_id 全看见。
唯一的纪律要求:每个 logEvent 必须从当前请求的 context 里拿 trace_id,不能自己生成。这是 feedback_no_fake_observability 那条原则的实现——日志的 trace_id 跟 Langfuse 的 root span trace_id 必须一致,不一致就是 bug。
不做采样的代价(我们接受)
Section titled “不做采样的代价(我们接受)”OTel 行业标准:production 采样 1%-10%,error 100%。
我们 K12 场景全采——每个 turn 100% trace,不丢。
理由:
- safety 维度需要 long-tail 数据。1% 采样意味着 99% 的违规 turn 看不到
- evaluator 用 trace 作输入,采样会扭曲分布
- 家长 ticket 永远是 “今天下午 3:42 那条对话”,不可能 “重现一下”——必须能精确翻出来
代价:
- Langfuse 账单。MiniMax-M2.7 配合,每个 turn input 平均 75K tokens,full 采样下流量很大。
TODO: 算一下 trace 上传带宽 + Langfuse 存储成本占总 LLM cost 的百分比 - batch flush 频率。
@langfuse/tracing默认每 5 秒一次 batch async flush。flush 时网络 spike,会影响延迟敏感的下游 - 长期归档策略。Langfuse Cloud 默认存 90 天。我们想要的是 “K12 safety 相关的 trace 永久存”。
TODO: 设计基于 metadata.safety_flag 的归档 pipeline
Eval signals piggyback:让 trace 自带 ground truth
Section titled “Eval signals piggyback:让 trace 自带 ground truth”III-1 evaluator 需要的不只是 “模型说了什么”,还有 “模型这一 turn 做了什么”——读了几次 .learner/、写了几次 atlas、调了几次 class CLI、设了几条 alarm。
我们做了一个 eval-signals.ts,挂在 PreToolUse hook 里 intercept 所有工具调用,turn 结束时拼成 13 个 metadata 字段塞进 generation:
toMetadata(): Record<string, unknown> { return { eval_tool_use_count: snapshot.toolUseCount, // 调了几个工具 eval_memory_read_count: snapshot.memoryReadCount, // .learner/ 读 eval_memory_write_count: snapshot.memoryWriteCount, // .learner/ 写 eval_distillation_write_count: snapshot.distillationWriteCount, // MEMORY.md / identity 写 eval_atlas_write_count: snapshot.atlasWriteCount, // .learner/atlas/ 写 eval_plan_read_count: snapshot.planReadCount, // class plan 查询 eval_plan_write_count: snapshot.planWriteCount, // class plan 修改 eval_class_tool_count: snapshot.classToolCount, // class CLI 调用 eval_alarm_set_count: snapshot.alarmSetCount, // 设的 alarm eval_updated_learner: snapshot.memoryWriteCount > 0, // boolean: 这 turn 写了 learner state? eval_updated_plan: snapshot.planWriteCount > 0, // boolean: 改了 plan? eval_scheduled_followup: snapshot.alarmSetCount > 0, // boolean: 设了 alarm? eval_tool_calls_summary: snapshot.toolCallsSummary.join(" | "), // 紧凑回放 };}这些全部是 ground truth——直接来自 SDK 的 PreToolUse 拦截,不是从聊天日志推的,不是从用户行为反推的。
Evaluator 看到 eval_updated_learner: false 连续 5 天,知道这个 agent 没在维护 learner state,可以扣分。看到 eval_alarm_set_count 7 天为 0,知道 agent 没有自调度行为。这些都是事实,不是估计。
还在难的地方
Section titled “还在难的地方”- trace diffing。 “为什么今天这个 turn 跟昨天那个看起来一样的 turn 输出不同?” 答案在两个 trace 的 input 对比里,但 Langfuse 没好用的 diff 工具。
TODO: 自己做一个 trace-side-by-side - write_to_board 内容的检索。 agent 写的板书是 markdown,存在 trace 里。“上周所有讲过分式运算的 turn” 这种查询需要 full-text search trace.output——Langfuse 上跑不动。
TODO: dump 到 Elasticsearch - LLM judge 成本。 Safety LLM-as-Judge 跑在每个 trace 上。MiniMax-M2.7 judge 大概 $0.0003/turn,看起来便宜,但 100% 采样 × 几十万 turn/天 是真钱。
TODO: 评估只 judge 高风险 mode(user / overhear),跳过 think/dream - trace as audit log 的法律地位。 K12 家长起诉场景下,trace 能不能作为证据?需要 immutable signing。目前没做。
III-1 substrate + evaluator 那篇说 “评估让 harness 进化有了根据”。这篇加一句:
评估的根据是 trace,trace 的根据是 “不要假数据”。
整个 III 系列其实是一个观察:harness 越往后做,越发现 “真” 比 “全” 重要。不全可以补,不真就是污染——会污染评分、污染决策、污染下次的 prompt 调整。
我们写这个 blog 也是同一条规则:有数据贴数据,没数据写 TODO——别凑。
相关文章:
- 评分系统怎么用 trace 作输入:III-1 Substrate + Evaluator
- prompt cache 命中率从 trace 怎么读:I-1 Claude Agent SDK 作 runtime 末尾
- W3C trace_id 跨多少层服务:I-2 三层切分 末尾的唤醒链路