跳转到内容

Langfuse trace 是 agent 行为的回放器

孩子家长来 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。

我们用 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@v4LangfuseSpanProcessor.onEndspan.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 完整可见。

写 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”?

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。

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 没有自调度行为。这些都是事实,不是估计。

  • 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——别凑。


相关文章