这是《Agent全栈开发实战》的第 10 篇。整个系列以 catbuddy(一个本地优先的 AI 编程助手,约 3.6 万行 TypeScript)为案例拆解 harness 设计。上一篇给 Agent 循环加了一套 Hook,让外部能力可以在模型调用、工具执行等节点接进来,却不用修改核心循环。这篇看它的第一个实际用途:接入 Langfuse,把 Agent 每一轮到底做了什么看清楚。
有一次,我让 CatBuddy 改一个配置文件。
文件不大,改动也不复杂,但界面转了十几秒才回来。最后文件改对了,回答也正常。问题是:这十几秒到底花在哪?
是模型响应慢?
是 Agent 读了太多文件?
还是某条 Shell 命令一直没有结束?
如果是普通接口,我大概会先看请求耗时和错误日志。但 Agent 不是"一次请求模型,一次返回结果"。它可能在模型和工具之间来回很多轮:
text
用户:帮我修改配置文件
第 1 轮 LLM:我得先看看文件
→ read_file
第 2 轮 LLM:还要确认这个配置在哪里使用
→ grep
第 3 轮 LLM:可以修改了
→ edit_file
第 4 轮 LLM:检查结果,回复用户
用户只发了一条消息,内部却跑了四次模型、三个工具。最终回答里不会告诉你每一步用了多久,也不会告诉你哪一轮消耗了最多 token。
这就是 Agent 可观测性要解决的问题:不是只知道它成功或失败,而是能把它刚才的思考和行动过程重新展开。
CatBuddy 选择用 Langfuse 记录这条过程。它会把一次用户请求拆成模型调用、工具执行、token、成本和质量指标,让原本藏在循环里的行为变成一条可以展开、筛选和比较的链路。
Langfuse 到底是什么
如果你用过后端链路追踪,可以把 Langfuse 理解成一套专门面向 LLM 应用的 tracing 系统。
普通链路追踪会记录:接口 A 调了服务 B,服务 B 又查了数据库 C,每一步用了多久。
Langfuse 在此基础上还会关心:
- 调用了哪个模型;
- 给模型传了什么;
- 模型返回了什么;
- 输入和输出用了多少 token;
- 这一轮调用了哪些工具;
- 从发起请求到第一个字返回用了多久;
- 整个任务大概花了多少钱;
- 最终结果好不好。
它不是模型,也不会替 Agent 做决策。它只是站在旁边,把 Agent 的执行过程记录下来,再提供查询、筛选和统计界面。
第一次看 Langfuse 时,最容易被 Trace、Generation、Span 这些词绕晕。先别背定义。继续看前面那次"修改配置文件"的请求,它在 Langfuse 里大致会长成这样:

现在再解释术语就简单了。
Trace 是用户的一次完整请求。从消息进入 CatBuddy,到最终回答结束,都属于同一条 Trace。
Generation 是一次真正的 LLM 调用。Agent 跑了四轮,就会有四个 Generation。每一个都可以记录模型、输入、输出、token 和耗时。
Span 是链路中的普通步骤。read_file、grep、edit_file 不是模型调用,所以用 Span 记录它们的参数、结果、状态和耗时。
还有一个 Session,表示一段连续对话。用户先让 CatBuddy 分析代码,接着让它修改,最后让它跑测试,这三条 Trace 可以放进同一个 Session。
四个概念放在一起就是:
text
Session:一段对话
└─ Trace:用户的一次请求
├─ Generation:一次 LLM 调用
└─ Span:一次工具或普通步骤
Langfuse 官方的数据模型也是按 Session、Trace 和 Observation 组织的;Generation 与 Span 都属于 Observation,也就是一条 Trace 中可以被观察的具体步骤。Langfuse 数据模型
CatBuddy 怎么把这条链路记下来
上一章讲的 Hook 在这里真正派上了用场。
AgentRunner 每跑一轮,都会经过几个固定节点:调用模型前、收到流式内容时、执行工具前、这一轮结束后。LangfuseAgentHook 在这些节点收集数据,再写进 Langfuse。
| Agent 运行到这里 | LangfuseHook 做什么 |
|---|---|
| 一轮开始前 | 创建一个 Generation |
| 收到第一个流式字符 | 记录首字到达时间 |
| 模型要求调用工具 | 记录工具名和本轮 token |
| 工具执行完成 | 为每个工具创建 Span |
| 最终回答完成 | 汇总整条 Trace |
在 loop.ts 里,接入代码只有这一小段:
ts
const hook = this._langfuseClient?.enabled
? new LangfuseAgentHook({
client: this._langfuseClient,
sessionKey: ctx.sessionKey,
workspace: this.workspace,
model: this.model,
userMessage: ctx.msg.content,
})
: undefined
await this.runner.run({ ...spec, hook })
Langfuse 开启,就创建 LangfuseAgentHook;没开启,就传 undefined。
AgentRunner 不知道背后接的是 Langfuse。它只知道在生命周期节点通知 Hook。这样哪天换追踪平台,或者某个用户不允许上传追踪数据,都不用改模型与工具循环。
这个设计还有一个实际好处:子代理也能复用。
CatBuddy 的子代理既要把状态同步给 UI,又要写 Langfuse。CompositeHook 会把状态 Hook 和 Langfuse Hook 组合起来。一轮执行结束,两个 Hook 分别处理自己的事情,谁也不用塞进 AgentRunner。
在 Langfuse 里怎么找到"慢"的原因
回到那条用了七秒的请求。
如果 Langfuse 展示的数据是:
text
第 1 轮 Generation 420ms
read_file Span 8ms
第 2 轮 Generation 510ms
grep Span 20ms
第 3 轮 Generation 380ms
exec Span 5200ms
第 4 轮 Generation 430ms
问题就很清楚了:不是模型慢,而是 exec 执行了五秒多。
这比一个 totalTime=7s 有用得多。
不过,Generation 的总耗时仍然不够。流式模型还有一个很影响体感的指标:TTFT,Time to First Token,也就是首字延迟。
假设两个请求都用了八秒:
text
请求 A:300ms 后开始输出,持续生成到第 8 秒
请求 B:7 秒没有动静,最后 1 秒突然全部输出
总耗时一样,用户感受完全不同。
CatBuddy 在 onStream() 第一次收到内容时记下时间:
ts
override async onStream(): Promise<void> {
if (!this.firstTokenTime && this.generationStartTime) {
this.firstTokenTime = new Date()
}
}
Generation 结束后,用首字时间减去开始时间,就得到 ttftMs。
于是一次慢请求可以继续拆成:
text
Trace 总耗时高 整个任务慢
Generation 耗时高 某轮模型慢
TTFT 高 模型排队、网络或超长输入
Tool Span 耗时高 某个工具慢
Generation 数量多 Agent 绕了太多轮
这才是"可观测"的意义:不是收集一个数字,而是能沿着数字找到下一层原因。
Token 和成本是怎么记的
Agent 的成本不是一条模型调用的成本,而是整条 Trace 中所有 Generation 的成本总和。
CatBuddy 会读取 Provider 返回的真实 usage:
text
inputTokens:发给模型的 token
outputTokens:模型生成的 token
每一轮单独记录,最后再汇总到 Trace。这样可以看到某次任务到底是输入上下文太大,还是模型输出太长。
美元成本则根据模型价目表估算:
ts
const inputCost = inputTokens / 1_000_000 * price.input
const outputCost = outputTokens / 1_000_000 * price.output
如果模型名能在本地价目表里找到,就按对应价格计算;找不到,就用默认单价粗估,并标记为 estimated。
这里有个容易误解的地方:token 是 Provider 返回的使用量,美元成本只是按当前价目表换算出来的估值。 模型厂商会调价,缓存 token、图片 token、批量折扣也可能有不同计费规则,所以 Langfuse 里的成本适合做趋势和异常排查,不应该直接当财务账单。
有了这些数据后,可以回答以前很难回答的问题:
- 为什么这个会话特别贵;
- 哪个模型平均每个任务成本更高;
- 改了 system prompt 后,输入 token 增加了多少;
- 子代理并发缩短了时间,但成本增加了多少;
- 哪些请求属于明显的成本异常点。
Langfuse 本身也支持对 Generation 记录不同类型的 usage 和 cost,不只局限于输入、输出两类 token。Langfuse Token 与成本追踪
看见过程之后,还要判断结果好不好
到这里,Langfuse 已经能告诉我们 Agent 做了什么、用了多久、花了多少钱。
但它还不能回答一个更重要的问题:任务做对了吗?
所有工具都执行成功,不代表修改就是正确的。Agent 只跑了两轮,也不代表它比跑四轮的 Agent 更好。这个时候要用到 Langfuse 的 Score。
Score 可以理解成挂在 Trace 上的一张成绩单。
CatBuddy 当前会上报三个运行指标:
text
tool-success-rate 工具成功率
iterations Agent 迭代次数
response-latency 整轮响应时间
这些指标能发现执行异常,但还不是真正的任务质量。下一步可以继续加入:
text
user-feedback 用户点赞或点踩
tests-passed 修改后测试是否通过
typecheck-passed TypeScript 检查是否通过
task-completed 任务是否真的完成
前两个运行命令就能得到,不需要再调一个模型。像回答是否相关、解释是否完整这类主观问题,才适合让另一个 LLM 按明确规则评分,也就是常说的 LLM-as-a-Judge。
Langfuse 的 Score 可以来自程序、用户反馈、人工标注或 LLM 评估,并且能在 Dashboard 中按模型、版本、Prompt 继续比较。Langfuse Scores
走到这一步,Langfuse 就不只是"出问题时看一眼"的调试工具了。它开始回答模型选型和版本迭代问题:
text
模型 B 便宜了 30%,任务成功率有没有下降?
新 Prompt 让迭代次数减少了,用户反馈有没有变好?
加了子代理以后速度快了,工具失败率是否升高?
把对话和代码上传到追踪平台,安全吗
这是 CatBuddy 接 Langfuse 时最不能绕过去的问题。
CatBuddy 的特点是代码和文件操作都在本地。可如果为了观察 Agent,把完整对话、工具参数和文件内容原样传到云端,那"本地优先"就只剩一句口号了。
当前实现先做数据缩减:
- Generation 只保留最近 20 条消息的摘要;
- 单条消息最多保留 500 个字符;
- 最终输出、reasoning、工具参数和结果都有限长;
- 观测需要的是排障线索,不是复制一份完整会话。
缩减之后,还要经过 maskSensitiveData() 脱敏:

对象里名为 secret、password、token、credential 等字段会直接替换;字符串中长得像 API Key、JWT、GitHub token 或 Bearer token 的内容也会打码。
但正则脱敏不是万能的。它能识别已知格式,识别不了任意源码中的商业秘密,也不能保证覆盖每种内部凭证。
所以敏感项目还有两个选择。
一个是只上传耗时、token、模型和状态,不上传输入输出。另一个是自托管 Langfuse,把追踪数据留在自己的服务器里。
Langfuse 官方同样建议:如果敏感数据不能离开应用边界,应当在客户端发送前完成脱敏;服务端脱敏只能作为第二道防线。Langfuse 数据脱敏
CatBuddy 怎么自托管 Langfuse
仓库里的 langfuse-docker 已经准备了一套自托管环境:
text
Langfuse Web 页面和 API
Langfuse Worker 异步处理追踪数据
PostgreSQL 元数据
ClickHouse Trace 与统计数据
Redis 队列和缓存
MinIO 对象存储
部署入口是:
bash
pnpm deploy:langfuse
应用侧配置服务地址和密钥:
| 配置 / 环境变量 | 作用 |
|---|---|
LANGFUSE_ENABLED |
是否启用 |
LANGFUSE_PUBLIC_KEY |
项目公钥 |
LANGFUSE_SECRET_KEY |
项目密钥 |
LANGFUSE_BASE_URL |
Cloud 或自托管地址 |
自托管的好处是数据位置可控,但不代表部署完就安全了。访问权限、HTTPS、备份、数据保留时间和版本升级仍然要自己负责。Langfuse 自托管文档
Langfuse 挂了,不能拖垮 Agent
可观测性是旁路能力。它可以失效,但不能让用户的任务跟着失败。
CatBuddy 在几处做了隔离:
- 没启用或没有密钥时,不创建 Langfuse Hook;
- 创建 Trace、Generation 或 Span 失败,只记录 warning;
- Score 异步上报,失败不阻塞回答;
- SDK 按批次发送,减少主流程等待;
- 应用退出时 flush,尽量把缓冲区里的数据发完。
这也是上一篇为什么先讲 Hook,再讲 Langfuse。只有可观测能力和核心循环真正解耦,追踪平台出问题时,Agent 才能继续工作。
当前实现还有一个可以继续改的地方
CatBuddy 现在直接在 Trace 下创建 Generation 和工具 Span:
text
Trace
├─ Generation 1
├─ read_file Span
├─ Generation 2
└─ edit_file Span
靠名称和时间顺序可以看懂,但层级还不够清楚。
更理想的结构是给每一轮加一个 agent-step:
text
Trace
├─ Agent Step 1
│ ├─ Generation
│ └─ read_file Span
└─ Agent Step 2
├─ Generation
└─ edit_file Span
这样能直接看出"哪一轮模型决定调用哪个工具",也方便以后按 Agent Step 统计耗时和质量。Langfuse 的 tracing best practices 也建议把 Generation 和它触发的工具调用放在同一个编排 Span 下,而不是全部平铺在 Trace 根节点。Langfuse Tracing 最佳实践
这不是当前功能的阻塞问题,但它是从"能看到数据"走向"Trace 结构准确"的下一步。
回到开头那次用了十几秒的请求。
打开 Langfuse 中对应的 Trace 后,我看到几轮模型调用都不慢,真正耗时的是一条 exec Span。问题不在 Provider,也不在上下文,而是命令本身。
如果只看最终回答,这次任务没有任何异常;只有把 Trace 展开,才会发现时间究竟消失在哪一步。Langfuse 的价值就在这里:它把 Agent 内部的模型和工具循环,从一个黑盒变成一条可以解释的工程链路。
这篇最需要记住的不是那些英文名词,而是这条关系:一次对话属于 Session,一次用户请求是一条 Trace,每次模型调用是 Generation,每个工具步骤是 Span,结果好不好再用 Score 衡量。
当这些数据通过 Hook 从 Agent 生命周期里自然产生,我们才真正拥有了一双能看清 Agent 的眼睛。
下一篇:Langfuse 的链路里已经出现了子代理。接下来正式拆开它:
SubagentManager怎么派生子任务、限制并发、回收结果,以及主 Agent 怎么知道这些子任务正在做什么。