PERSONAL LAB / ai/agent开发
AI Agent 开发

10 可观测与线上运维

普通后端服务出了问题,一般会报错、超时、返回 500,监控马上能看到。Agent 出问题经常是”安静地错”:接口返回 200,模型很有礼貌地说”已完成”,实际上绕了三十步弯路、烧了几十万 token,或者调错了工具。评测管发布前,可观测管发布后,这两件事合起来才算闭环。

这篇要讲清楚:日志、指标、链路追踪三种数据各管什么;一个 Agent 每一步应该记下哪些东西;OpenTelemetry 给大模型调用定的字段名;延迟和成本怎么拆开看;线上看哪些指标、怎么告警;记录用户内容时怎么脱敏。最后用我项目真实的 288 次运行记录算一遍工具失败率、P95 耗时和成本。

怎么读:

前置:05 Agent 循环09 评测

第一部分 速记页

问题一句话答案
可观测是什么从系统对外输出的数据,推断它内部发生了什么,不用登上机器加打印
三种数据日志(发生了什么事)、指标(数量随时间的变化)、链路追踪(一次请求经过了哪些环节、各花多久)
trace 和 spantrace 是一次完整请求;span 是其中一个环节,有开始时间、耗时、属性和父 span
Agent 为什么特别需要会”安静地错”:返回成功但绕路、烧钱、调错工具;同一请求每次路径不同,没记录就复现不了
每次模型调用记什么模型名、输入输出 token、缓存命中 token、耗时、停止原因、错误类型
每次工具调用记什么工具名、参数、是否成功、结果(截断)、耗时、属于哪一步
整次运行记什么任务、开始结束时间、状态(成功 / 步数用完 / 卡住 / 崩溃)、总步数、总 token、代码与提示词版本
OpenTelemetry GenAI 约定OTel 给大模型调用定的统一字段名,如 gen_ai.usage.input_tokens;目前还是开发中状态
提示词和回答默认记吗约定里 gen_ai.input.messages 等内容字段是 Opt-In(默认不记),因为可能含隐私
为什么看 P95 不看平均平均值会被大量快请求拉低,掩盖少数很慢的请求;用户感受到的是慢的那部分
延迟怎么拆模型时间(首 token、生成)+ 工具时间 + 排队;Agent 还要乘以步数
成本怎么算未命中输入 × 单价 + 命中输入 × 缓存单价 + 输出 × 输出单价,按运行、用户、功能汇总
线上看什么完成率、人工接管率、重试率、用户反馈、每次运行的步数和成本、工具失败率
告警怎么设看趋势和连续失败,不看单次;按严重程度分级;每条告警要有人能处理
脱敏写入前把密钥、手机号等替换掉;限制谁能看原文;设定保留期限
常见工具Langfuse(开源可自建)、LangSmith(LangChain 家)、或 OTel 接自己的后端
我项目做到的SQLite 两张表记每次运行和每次工具调用,网页能按运行查看每一步
我项目没做到的没记模型调用耗时和缓存命中、没有 span 层级、没有成本换算、没有告警

第二部分 易混对照

容易混的两个区别一句话记法
监控 vs 可观测监控是盯着事先定好的指标;可观测是出了没预料到的问题时,还能从数据里查出原因仪表盘 vs 行车记录仪
日志 vs 链路追踪日志是一条条独立的事件;链路追踪把一次请求里的所有环节按父子关系串起来散的便签 vs 装订好的流水账
指标 vs 日志指标是聚合后的数字,便宜、适合看趋势和告警;日志保留细节,贵、适合查具体问题体温曲线 vs 病历
trace vs spantrace 是整次请求;span 是其中一段一次出差 vs 其中一段行程
Agent 轨迹 vs 链路追踪轨迹强调模型每一步想了什么、调了什么;链路追踪强调环节、耗时和父子关系。实际做法是用链路追踪的结构记轨迹内容 vs 结构
首 token 延迟 vs 总耗时前者是发出请求到收到第一个字;后者是整个回答生成完开口要多久 vs 说完要多久
P50 vs P95P50 是一半请求比它快;P95 是 95% 的请求比它快普通情况 vs 糟糕情况
离线评测 vs 线上监控前者用固定题目发布前跑;后者看真实流量发布后持续看09 篇 vs 本篇
采样 vs 全量采样只记录一部分请求,省存储;全量都记。出错的请求通常要全量保留抽查 vs 全检
脱敏 vs 加密脱敏是把敏感内容替换掉,原文不再保存;加密是保存原文但要钥匙才能看涂掉 vs 锁起来

第三部分 面试口述稿

3.1 “你的 Agent 上线后怎么知道它运行得好不好?”

我会从三个层面记录。第一是每次运行一条记录:任务、开始结束时间、最终状态、步数、总 token。状态我分得比较细,成功、模型自己停下但验收没过、步数用完、卡住重复调用、程序崩溃,这几种要分开,不然”失败率上升”不知道从哪查。

第二是每一步的记录:模型调用的 token、耗时、停止原因;工具调用的名字、参数、是否成功、结果和耗时。结构上用链路追踪的方式,整次运行是一个 trace,每次模型调用和工具调用是它下面的 span,这样能看出时间花在哪、哪一步出的错。

第三是汇总指标和告警:完成率、平均步数、工具失败率、P95 耗时、每次运行成本。

我项目里做了前两层的简化版,用 SQLite 两张表存每次运行和每次工具调用,网页上可以点开任意一次运行看每一步。评测里发现的验收 bug、安全漏洞,都是翻这些记录找到的。

3.2 “Agent 的链路追踪和普通服务有什么不一样?”

结构是一样的,都是 trace 套 span。不一样的主要有三点。

一是要记的内容不同。除了耗时和错误,还要记 token、缓存命中、模型停止原因、工具参数和结果,因为 Agent 的问题很多不是报错,而是”做了不该做的事”,只看状态码看不出来。

二是同一个请求每次路径不一样,步数不固定,所以 span 树的形状每次都不同,看板要能按运行展开看,而不只是看平均值。

三是内容敏感。提示词和工具结果里可能有用户数据、密钥,OpenTelemetry 的 GenAI 约定里,记录输入输出消息的字段默认是不开启的,要显式打开。打开的话要先脱敏,并控制谁能看。

3.3 “延迟太高怎么排查?”

先拆开看时间花在哪。Agent 一次运行的时间大致是:步数乘以每步的模型时间,加上所有工具执行时间。模型时间里再分首 token 延迟和生成时间。

然后分别处理。步数多,看轨迹是不是在绕路,是提示词或工具设计的问题;模型慢,看输入是不是太长、能不能用缓存、能不能换小模型处理简单步骤;工具慢,看是不是有可以并行或缓存的调用。

看的时候用 P50 和 P95,不看平均。我项目算过,run_command 的中位数只有 26 毫秒,P95 却接近 15 秒,因为大部分是 ls 这类秒回的命令,少数是真正的编译。平均值会把这两种情况混在一起,得出一个哪边都不像的数。

3.4 “怎么控制 Agent 的成本?”

先要算得出来。每次模型调用记下输入 token、缓存命中 token 和输出 token,乘以对应单价,按运行、用户、功能汇总。

Agent 的成本结构有个特点:输入远大于输出,因为每一步都要重发之前的全部历史。我项目 288 次运行,输入是输出的 41.6 倍。所以省钱的重点在输入:稳定的前缀放前面让缓存命中;工具结果截断、历史压缩;限制步数上限,因为失败的运行往往跑满步数,是最贵的。

然后是兜底:单次运行设 token 或金额上限,超了就停;每天设总预算,接近时告警。我项目有一次就是余额耗尽,DeepSeek 返回 402,评测脚本没有停,又连着崩了四个项目,要是有连续崩溃告警就能早点发现。

3.5 “日志里要记用户的输入吗?隐私怎么处理?”

要看场景,但默认应该谨慎。不记内容的话,出了问题很难复现;全记的话,有泄露风险,还可能不合规。

我的做法是分层:指标和结构信息全量记,比如 token、耗时、工具名、成功与否,这些不含内容。内容部分写入前先脱敏,把密钥、手机号、身份证号这类用规则替换掉;原文只对少数有权限的人可见,设保留期限,到期删除。出错的运行完整保留,正常的可以抽样。

还有一点容易漏:工具的结果里也可能有敏感内容,比如读到了配置文件,脱敏不能只做用户输入。

3.6 “Langfuse、LangSmith 这类工具你用过吗?”

我项目里没有接,是自己用 SQLite 记的,因为是单机项目,两张表够用,也方便直接写 SQL 分析评测数据。

这类工具解决的问题是一样的:把每次模型调用、检索、工具调用按 trace 记下来,自动算 token 和成本,提供看板、打分和数据集功能。Langfuse 是开源的,可以自己部署,数据不出公司;LangSmith 是 LangChain 团队的,和 LangChain、LangGraph 集成最顺,但不用 LangChain 也能手动接。

如果要上线,我会优先按 OpenTelemetry 的约定去埋点,这样后端换成哪个平台都不用改业务代码。

第四部分 逐个详解

4.1 为什么 Agent 特别需要可观测

先说”可观测”这个词是怎么来的。 它最早不是软件里的词,而是控制理论的概念:1960 年匈牙利裔美国工程师 Rudolf E. Kálmán 提出,一个系统如果能靠它的输出反推出内部状态,就叫”可观测”(Observability)。软件行业原来说的是”监控”:事先想好要看哪些数,CPU、内存、错误率,超过阈值就报警。单体程序时代这够用,出了事登上那台机器翻日志就行。后来一个请求要穿过几十个服务,问题常常是事先没想到的组合,“事先想好要看什么”就不够了。2013 年 9 月 Twitter 工程博客发了一篇 Observability at Twitter,介绍他们专门的 Observability 团队怎么收集、存储、查询指标,是这个词在互联网公司里比较早的公开用法。

可观测性(observability):能通过系统对外输出的数据,推断出它内部处于什么状态、为什么会这样。和”监控”的区别在于:监控回答事先想好的问题(CPU 高不高、错误率多少),可观测要能回答事先没想到的问题(为什么这个用户的请求花了 40 步)。

Agent 比普通服务更需要它,原因有四个:

特点带来的问题我项目里的例子
会安静地错接口成功返回,但结果不对或过程很糟验收旧标准下,json 一个目标都没编译也判了成功
路径不固定同一个任务每次走法不同,出了问题复现不了libpng 同配置 3 次分别用了 20、32、39 步
成本和步数挂钩绕路直接变成钱失败的运行往往跑满 40 步,是最贵的
行为有安全风险模型可能调用不该调的工具、访问不该访问的地方翻记录发现 ls /opt/homebrew/Cellar 执行成功,才发现命令参数没做路径检查

一个真实的例子能说明”没有告警”的代价。我项目 runs.db 里,run 208 跑到第 28 步时 DeepSeek 返回 402(余额不足),接下来 209 到 212 四个项目每个都是第 1 步就崩溃:

208 re2           crash:APIStatusError  28 步
209 libuv         crash:APIStatusError   1 步
210 curl          crash:APIStatusError   1 步
211 libjpeg-turbo crash:APIStatusError   1 步
212 freetype      crash:APIStatusError   1 步

评测脚本对每个项目单独捕获异常、继续跑下一个,这对”一个项目出错不影响其他项目”是合理的,但对”余额没了”这种全局问题,继续跑没有意义。如果有一条”连续 3 次崩溃就告警”的规则,在 210 的时候就能发现。4.7 节的实验会用真实数据把这条规则跑出来。

4.2 三种数据:日志、指标、链路追踪

日志(log)指标(metric)链路追踪(trace)
是什么带时间戳的一条条事件按时间聚合的数字一次请求经过的所有环节,带父子关系和耗时
例子12:00:03 run_command 失败: 找不到 zlib每分钟完成的运行数、工具失败率、P95 耗时运行 → 第 3 步模型调用 → 第 3 步工具调用
擅长查某件事的细节看趋势、设告警,存储便宜看时间花在哪、错在哪一环
不擅长量大时难以汇总没有细节,看不出单次请求怎么了全量存储贵

三者要能互相跳转:指标告警说”工具失败率上升了”,点进去看是哪些 trace,再看这些 trace 里失败 span 的日志。能跳转的前提是它们共用同一个 ID,比如日志里带上 trace_id。

先说链路追踪和 OpenTelemetry 是怎么来的。 日志和指标很早就有,链路追踪是分布式系统多了以后才被逼出来的。按 Dapper 论文里举的例子,Google 的一次搜索可能要用到上千台机器、很多个不同的服务,慢了不知道慢在哪一环。Google 的 Benjamin Sigelman 等人 2010 年 4 月发表了技术报告 Dapper,描述了他们内部的做法:给每个请求一个全局编号,经过的每一环记一段带父子关系的耗时记录,也就是后来的 trace 和 span。2012 年 Twitter 照着论文做了开源的 Zipkin(介绍文章),之后各家又做了一堆追踪系统。

问题跟着来了:每家后端的埋点代码都不一样,换一个后端就要把代码里的埋点全改一遍。为了统一埋点接口,社区先后出了两个标准,OpenTracing 和 Google 发起的 OpenCensus,两者功能大量重叠,开发者反而不知道该用哪个。2019 年 5 月两边宣布合并成 OpenTelemetry,Google 的公告里原话大意是”这两个项目最大的问题就是有两个”(公告)。它随即成为 CNCF 的项目,2026 年 5 月 21 日正式毕业(CNCF 公告),毕业是 CNCF 对项目成熟度的最高一档认定。

flowchart LR
    D["Dapper 论文<br/>Google 2010"] --> Z["Zipkin<br/>Twitter 2012 开源"]
    Z --> M["各家追踪系统<br/>埋点代码互不兼容"]
    M --> OT["OpenTracing<br/>统一追踪接口"]
    M --> OC["OpenCensus<br/>Google 发起"]
    OT --> OTel["OpenTelemetry<br/>2019 合并"]
    OC --> OTel
    OTel --> G["CNCF 毕业<br/>2026-05"]

OpenTelemetry(简称 OTel):一个开源的可观测标准和工具集,由 CNCF(云原生计算基金会)托管。它定义了日志、指标、链路追踪的数据格式和各种语言的 SDK,采集一次数据,可以发给不同的后端(Jaeger、Grafana、Datadog、Langfuse 等)。

链路追踪里的几个概念:

  • trace:一次完整请求,有一个全局唯一的 trace_id
  • span:请求中的一个环节,有自己的 span_id、开始时间、耗时、名字和一组属性(attributes),也就是键值对
  • 父 span:一个 span 可以包含子 span。子 span 记下父 span 的 ID,所有 span 就能拼回一棵树
  • 状态和错误:span 可以标记为出错,并记录错误类型

用在 Agent 上,一次运行大致长这样:

flowchart TD
    A["invoke_agent crossbuild<br/>整次运行 72s"] --> M1["chat deepseek-flash<br/>第 1 步 2.1s<br/>in=3200 out=80"]
    A --> T1["execute_tool list_files<br/>第 1 步 1ms"]
    A --> M2["chat deepseek-flash<br/>第 2 步 1.8s"]
    A --> T2["execute_tool run_command<br/>第 2 步 14.9s<br/>cmake --build"]
    A --> M3["……"]
    A --> V["verify<br/>独立验收"]

图里的数字是示意,不是某次真实运行。

4.3 一个 Agent 每一步该记什么

按三个层级列。“我项目”一列标出做到了哪些,数据来自 agent/trace.py 的两张表。

整次运行

字段为什么要记我项目
运行 ID、任务描述定位和检索有,runs.idruns.task
开始、结束时间算总耗时
最终状态区分失败原因有:successfailedmax_stepsstuckcrash:<异常名>
总步数判断是否绕路
总输入、输出 token算成本
缓存命中 token算真实成本、看缓存是否生效没有
模型名换模型后能对比
代码版本、提示词版本定位”从哪次发布开始变差”没有,评测结果文件里记了被测项目的 commit,但运行记录里没有 Agent 自身的版本
用户、会话 ID按用户汇总、串起多轮对话没有,单机项目用不上

每次模型调用

字段为什么要记我项目
输入、输出、缓存命中 token成本和上下文增长只累加到整次运行,没有按步记
耗时、首 token 延迟找慢在哪没有
停止原因区分正常结束、长度截断、调工具没有
错误类型429 限流、402 余额、超时要分开处理只在崩溃时记到运行状态里
输入输出内容复现问题没有单独存,工具参数和结果有

每次工具调用

字段为什么要记我项目
属于第几步和模型调用对应起来有,tool_calls.step
工具名、参数知道模型想做什么
是否成功算失败率有,ok,命令退出码非 0 也算失败
结果看模型拿到了什么有,截断到前 2000 字符
耗时找慢工具有,duration_ms
报错分类按类统计运行时算了(errors.classify),通过事件推给前端和评测汇总,但没存进数据库

一个经验:字段宁可多记一点,事后补不回来。我项目的”缓存命中”和”模型耗时”没记,写这一篇时想分析就没有数据了,只能用”总耗时减去工具耗时”粗略估计(见 4.8 节)。

4.4 OpenTelemetry 的 GenAI 语义约定

语义约定(semantic conventions):OTel 规定”某类操作的 span 叫什么名字、带哪些属性、属性叫什么”。有了统一名字,不同语言、不同框架埋的点,后端都能用同一套看板。

大模型相关的约定叫 GenAI semantic conventions。要注意两件事:

  1. 还在开发中。文档里的状态标记是 Development,不是 Stable,字段名以后可能改
  2. 已经搬家。原来在 OTel 主仓库的文档页,现在显示”已迁移、不再维护”,新位置是单独的 semantic-conventions-genai 仓库。网上很多文章引用的还是旧页面,比如旧版里提供方字段叫 gen_ai.system,新版叫 gen_ai.provider.name

下面是从新仓库 docs/gen-ai/ 下几份文档里核对到的内容(2026-09 查阅)。

span 名字和操作类型

操作span 名字gen_ai.operation.name
调用模型{操作名} {模型名},如 chat deepseek-flashchattext_completiongenerate_content
执行工具execute_tool {工具名}execute_tool
调用 Agentinvoke_agent {Agent 名}invoke_agent
检索retrieval
向量化embeddings

操作类型的已知值里还有 plan(规划或任务拆解)、create_memorysearch_memory 等和记忆相关的操作。

模型调用 span 的常用属性

属性要求含义
gen_ai.operation.name必填操作类型
gen_ai.provider.name必填提供方,如 openai
gen_ai.request.model有就填请求的模型名
gen_ai.conversation.id有就填会话 ID,串起多轮
gen_ai.usage.input_tokens推荐输入 token,包含缓存命中的部分
gen_ai.usage.cache_read.input_tokens适用时推荐从缓存读到的输入 token
gen_ai.usage.cache_write.input_tokens适用时推荐写入缓存的输入 token
gen_ai.usage.output_tokens推荐输出 token
gen_ai.usage.reasoning.output_tokens适用时推荐推理用掉的输出 token
gen_ai.response.finish_reasons推荐停止原因列表,如 ["stop"]
error.type出错时必填错误类别,如 timeout500
gen_ai.input.messagesOpt-In输入的对话历史
gen_ai.output.messagesOpt-In模型返回的消息
gen_ai.system_instructionsOpt-In系统提示词
gen_ai.tool.definitionsOpt-In可用的工具定义

Opt-In 是”需要主动开启才记录”的意思。提示词、回答、系统指令、工具定义这些内容类字段默认都不记,因为它们可能包含用户隐私和公司机密。

gen_ai.usage.input_tokens 要包含缓存命中的部分,这一点和有些厂商返回的字段不同,换算时要小心。约定里举的例子:100 个文本 token(其中 40 个命中缓存)加 200 个图片 token,input_tokens 记 300,cache_read.input_tokens 记 40。

工具调用 span:名字 execute_tool {gen_ai.tool.name},类型是 INTERNAL(进程内部操作);必填 gen_ai.tool.name,推荐 gen_ai.tool.call.id(模型给这次调用分配的 ID,用来和模型调用对上)。

指标:约定了几个直方图指标,包括 gen_ai.client.token.usage(token 用量)、gen_ai.client.operation.duration(操作耗时,单位秒)、gen_ai.client.operation.time_to_first_chunk(流式返回时收到第一块的时间)。

直方图(histogram):一种指标类型,不只记平均值,而是记录数值落在各个区间里的个数,这样才能算出 P50、P95。

4.5 动手实验 1:一个最小的 span 记录器

不装 OTel SDK,用标准库写一个最简单的链路追踪,把 trace、span、父子关系、属性、错误记录、脱敏、成本都串一遍。保存为 mini_span.py

import re
import time
import uuid
from contextlib import contextmanager

SPANS = []
STACK = []
SECRET = re.compile(r"(sk-[A-Za-z0-9]{6,}|(?i:password|token)=\S+)")


def redact(text):
    return SECRET.sub("[已脱敏]", text)


@contextmanager
def span(name, **attrs):
    parent = STACK[-1] if STACK else None
    record = {
        "trace_id": parent["trace_id"] if parent else uuid.uuid4().hex[:8],
        "span_id": uuid.uuid4().hex[:8],
        "parent_id": parent["span_id"] if parent else None,
        "name": name,
        "attrs": attrs,
        "start": time.perf_counter(),
    }
    STACK.append(record)
    try:
        yield record
    except Exception as e:
        record["attrs"]["error.type"] = type(e).__name__
        raise
    finally:
        record["ms"] = (time.perf_counter() - record["start"]) * 1000
        STACK.pop()
        SPANS.append(record)


def fake_model(step):
    time.sleep(0.03)
    if step == 1:
        return {"tool": "run_command", "args": "cmake -B build", "in": 1800, "cached": 0, "out": 40}
    return {"tool": None, "in": 2600, "cached": 1700, "out": 25}


def fake_tool(args):
    time.sleep(0.05)
    if "build" in args:
        raise RuntimeError("configure failed, token=abc123")


with span("invoke_agent crossbuild", **{"gen_ai.conversation.id": "run-42"}):
    for step in (1, 2):
        with span("chat deepseek-flash") as s:
            r = fake_model(step)
            s["attrs"].update({"gen_ai.usage.input_tokens": r["in"],
                               "gen_ai.usage.cache_read.input_tokens": r["cached"],
                               "gen_ai.usage.output_tokens": r["out"]})
        if r["tool"]:
            try:
                with span(f"execute_tool {r['tool']}", **{"gen_ai.tool.name": r["tool"]}) as t:
                    fake_tool(r["args"])
            except RuntimeError as e:
                t["attrs"]["result"] = redact(str(e))


def cost(a):
    miss = a["gen_ai.usage.input_tokens"] - a["gen_ai.usage.cache_read.input_tokens"]
    return (miss * 0.15 + a["gen_ai.usage.cache_read.input_tokens"] * 0.003
            + a["gen_ai.usage.output_tokens"] * 0.6) / 1e6


def show(parent_id, depth):
    for s in sorted((x for x in SPANS if x["parent_id"] == parent_id), key=lambda x: x["start"]):
        extra = ""
        if "gen_ai.usage.input_tokens" in s["attrs"]:
            extra = f"  in={s['attrs']['gen_ai.usage.input_tokens']} cached={s['attrs']['gen_ai.usage.cache_read.input_tokens']} ${cost(s['attrs']):.6f}"
        if "error.type" in s["attrs"]:
            extra = f"  error={s['attrs']['error.type']} result={s['attrs']['result']}"
        print(f"{'  ' * depth}{s['name']:<28}{s['ms']:>6.0f}ms{extra}")
        show(s["span_id"], depth + 1)


print("trace", SPANS[-1]["trace_id"])
show(None, 0)

实际输出(Python 3.14,只用标准库;trace ID 是随机的,毫秒数每次略有不同):

trace c71fe2cf
invoke_agent crossbuild        122ms
  chat deepseek-flash             35ms  in=1800 cached=0 $0.000294
  execute_tool run_command        52ms  error=RuntimeError result=configure failed, [已脱敏]
  chat deepseek-flash             35ms  in=2600 cached=1700 $0.000155

逐段讲。

开头的全局变量。

  • SPANS = []:所有结束了的 span 都放进这个列表
  • STACK = []:当前”正在进行中”的 span,像一摞盘子,最上面那个是当前所在的环节。新开一个 span 时,它的父亲就是栈顶那个
  • re.compile(...)re 是正则表达式模块,compile 把规则预先编译好,反复用时更快
  • 正则 sk-[A-Za-z0-9]{6,} 匹配 sk- 开头、后面至少 6 个字母数字的字符串,很多 API 密钥长这样;(?i:password|token)=\S+ 匹配 password=token= 后面跟着的非空白字符,(?i:...) 表示括号里这一段不区分大小写,\S+ 是一个或多个非空白字符
  • SECRET.sub("[已脱敏]", text):把所有匹配到的部分替换成 [已脱敏]

span 函数。 这是整个实验的核心。

  • @contextmanager装饰器(decorator),写在函数定义上一行,给函数加上额外能力。contextmanager 来自标准库 contextlib,它把一个带 yield 的函数变成上下文管理器(context manager),就能用 with span(...) as s: 的写法
  • with 语句的执行顺序:先执行 yield 之前的代码 → yield recordrecord 交给 as s 里的 s → 执行 with 块里的代码 → 回来执行 yield 之后的代码。它保证”开始”和”结束”一定成对,哪怕中间出了异常
  • **attrs关键字可变参数,调用时写的 name=value 形式的参数全部收进一个字典 attrs。因为 OTel 属性名里带点号(gen_ai.tool.name),不能直接写成 gen_ai.tool.name="x",所以调用时用 **{"gen_ai.tool.name": ...} 把字典展开传进去
  • parent = STACK[-1] if STACK else NoneSTACK[-1] 取列表最后一个元素;栈为空时(最外层)没有父亲
  • trace_id:有父亲就沿用父亲的,没有就新生成一个。一棵树上所有 span 共用一个 trace_id
  • uuid.uuid4().hex[:8]:生成随机唯一 ID,.hex 转成 32 位十六进制字符串,取前 8 位让输出短一点。真实系统要用完整长度
  • time.perf_counter():高精度计时器,只适合算时间差,不代表当前几点
  • try / except / finallyexcept Exception as e 捕获异常,把异常类名记进属性后,用 raise 原样重新抛出,记录错误但不吞掉错误finally 里的代码无论成功还是出错都会执行,所以耗时一定会被记下来,栈也一定会弹出

假模型和假工具。

  • fake_model 第 1 步要求调用工具,第 2 步结束。第 2 步 2600 个输入 token 里有 1700 个命中缓存,模拟”前缀和上一步相同”的情况
  • fake_tool 故意抛出一个异常,报错信息里带着 token=abc123,用来演示脱敏

主流程。

  • 最外层 invoke_agent 是整次运行;循环里每步开一个 chat span,有工具调用再开一个 execute_tool span。因为它们在 with 块里面,自动成为最外层的子 span
  • s["attrs"].update({...}):字典的 update 方法,把另一个字典的键值合并进来。token 数要等模型返回后才知道,所以在 span 进行中补上
  • 工具 span 外面包了 try / except RuntimeError:span 内部记录了 error.type 并把异常抛出来,外层捕获后把脱敏后的报错写进属性。真实 Agent 里这一步就是”把报错作为工具结果回给模型”

成本和打印。

  • cost:未命中的输入按每百万 0.15 美元,命中的按 0.003 美元,输出按 0.6 美元。这是写这篇时核对的 DeepSeek 价格页上 deepseek-flash 非高峰价格,价格会变,只作演示
  • show递归函数,自己调用自己:先找出父 ID 等于给定值的所有 span,打印后再以它为父亲找下一层
  • (x for x in SPANS if ...)生成器表达式,和列表推导式写法一样但用圆括号,不会一次性生成整个列表
  • key=lambda x: x["start"]lambda 是一行写完的匿名函数,这里告诉 sorted 按开始时间排序。因为 span 是结束时才加进 SPANS 的,子 span 比父 span 先结束,不排序的话顺序是乱的
  • SPANS[-1] 是最后结束的 span,也就是最外层那个

看输出。

  1. 树形结构直接显示了父子关系,一眼能看出第 1 步工具失败了
  2. 算一下耗时:35 + 52 + 35 = 122 毫秒,和最外层的耗时对得上,说明时间全部花在这三个环节上。如果外层明显大于子 span 之和,就说明有没被记录的环节
  3. 第 2 步输入 token 更多(2600 对 1800),成本却更低(0.000155 对 0.000294),因为 1700 个命中了缓存。不记缓存命中,成本就算不准
  4. 报错里的 token=abc123 被替换掉了

自己改一改:

  1. fake_tool 里的报错改成 "key is sk-abcdef123456",看能不能被脱敏
  2. fake_model 加一个第 3 步,再调一次工具,看树形输出的变化
  3. span 函数里加一个 step 属性,打印时显示”第几步”
  4. 故意在最外层 with 块最后加一句 time.sleep(0.1),看外层耗时和子 span 之和的差距

4.6 我项目的记录是怎么做的

数据流:

flowchart LR
    LOOP["build_events<br/>Agent 循环"] -->|start / tool_call<br/>add_usage / finish| TR["Trace<br/>agent/trace.py"]
    TR --> DB[("runs.db<br/>runs + tool_calls")]
    LOOP -->|yield 事件| SSE["/api/build<br/>SSE 实时推送"]
    DB --> API["/api/runs<br/>/api/runs/{id}"]
    API --> WEB["RunHistory.vue<br/>运行列表 + 每步详情"]
    DB --> SQL["直接写 SQL<br/>评测分析"]

两条路:实时的事件通过 SSE 推给正在看的网页(14 篇细讲);持久化的记录写进 SQLite,事后通过接口查,或者直接写 SQL 分析。

表结构agent/trace.py):

CREATE TABLE IF NOT EXISTS runs (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    task TEXT NOT NULL,
    workdir TEXT NOT NULL,
    model TEXT,
    started_at REAL NOT NULL,
    finished_at REAL,
    status TEXT,
    steps INTEGER DEFAULT 0,
    prompt_tokens INTEGER DEFAULT 0,
    completion_tokens INTEGER DEFAULT 0,
    result TEXT
);
CREATE TABLE IF NOT EXISTS tool_calls (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    run_id INTEGER NOT NULL REFERENCES runs(id),
    step INTEGER NOT NULL,
    tool TEXT NOT NULL,
    args TEXT,
    ok INTEGER NOT NULL,
    result TEXT,
    duration_ms REAL NOT NULL,
    created_at REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tool_calls_run ON tool_calls(run_id);
  • runs 是一次运行一行,tool_calls 是一次工具调用一行,用 run_id 关联。这相当于一个只有两层的 span 树:运行是根,工具调用是叶子,模型调用这一层没有单独的行
  • started_at REAL:时间存成 Unix 时间戳(从 1970 年起的秒数),REAL 是浮点数类型
  • idx_tool_calls_run:按 run_id 建索引,查”某次运行的所有工具调用”时不用扫全表

Trace 类的四个方法对应运行的生命周期:

方法什么时候调做什么
start循环开始前插入一行 runs,拿到 run_id
add_usage每次模型返回后把本次的 prompt_tokenscompletion_tokens 累加到这次运行上
tool_call每个工具执行完插入一行 tool_calls,结果截断到 2000 字符
finish验收完成后,或崩溃时写结束时间、状态、步数、结果

几个值得讲的细节:

  1. 每次写入都立刻 commit。运行中途进程崩了,已经执行的步骤也都在库里。代价是每次写都要落盘,对一次几十步的运行来说可以忽略
  2. 崩溃也会记build_eventstry / except 包住整个循环,出异常时先 tracer.finish(f"crash:{type(e).__name__}", ...) 再重新抛出。402 余额不足那几次就是这样记下来的
  3. add_usage 只累加到运行上,没有按步记,也没有取缓存命中字段,所以事后算不出每一步的上下文增长,也算不出真实成本
  4. check_same_thread=False:Python 的 sqlite3 默认不允许在创建连接的线程之外使用这个连接。LangGraph 版本在线程池里跑节点,第一次运行(run 162)直接崩了,状态记为 crash:ProgrammingError,加上这个参数才解决

网页查看server/app.py + web/src/components/RunHistory.vue):/api/runs 返回最近 50 次运行的摘要,/api/runs/{id} 返回某次运行的全部工具调用。前端表格列出每次运行的状态、步数、token、耗时,点开一行显示每一步的工具名、参数、成功与否、耗时,再点可以展开结果。

这就是一个最简单的”轨迹查看器”。09 篇里提到的那些发现,大部分是这样一次次点开看出来的。

4.7 动手实验 2:从真实运行记录里算指标

用我项目真实的 runs.db(288 次运行、5625 次工具调用),算每个工具的失败率和耗时分位数,跑一遍”连续崩溃告警”规则,再粗估成本。保存为 trace_stats.py,放在项目根目录运行。

import sqlite3
import sys
from collections import defaultdict
from pathlib import Path

db = Path(sys.argv[1] if len(sys.argv) > 1 else "runs.db").resolve()
conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True)


def p95(values):
    values = sorted(values)
    return values[max(0, int(len(values) * 0.95 + 0.5) - 1)]


durations = defaultdict(list)
fails = defaultdict(int)
for tool, ok, ms in conn.execute("SELECT tool, ok, duration_ms FROM tool_calls"):
    durations[tool].append(ms)
    fails[tool] += 0 if ok else 1

print(f"{'工具':<18}{'调用':>6}{'失败率':>8}{'P50毫秒':>10}{'P95毫秒':>10}{'最长毫秒':>10}")
for tool, ms in sorted(durations.items(), key=lambda kv: -len(kv[1])):
    n = len(ms)
    print(f"{tool:<18}{n:>6}{fails[tool] / n:>8.1%}{sorted(ms)[n // 2]:>10.0f}{p95(ms):>10.0f}{max(ms):>10.0f}")

print()
rows = conn.execute("SELECT id, status, steps FROM runs ORDER BY id").fetchall()
streak = []
for run_id, status, steps in rows:
    if status and status.startswith("crash"):
        streak.append((run_id, steps))
        if len(streak) == 3:
            print(f"告警:从 run {streak[0][0]} 起连续 3 次崩溃")
    else:
        if len(streak) >= 3:
            print(f"  这一串共 {len(streak)} 次:{[r for r, _ in streak]},步数 {[s for _, s in streak]}")
        streak = []
if len(streak) >= 3:
    print(f"  这一串共 {len(streak)} 次:{[r for r, _ in streak]},步数 {[s for _, s in streak]}")

tin, tout = conn.execute("SELECT SUM(prompt_tokens), SUM(completion_tokens) FROM runs").fetchone()
print()
print(f"输入 {tin:,} token,输出 {tout:,} token,输入/输出 = {tin / tout:.1f}")
cost = tin / 1e6 * 0.15 + tout / 1e6 * 0.6
print(f"全按缓存未命中、非高峰价粗估:${cost:.2f}")

实际输出(Python 3.14 自带的 sqlite3,SQLite 3.53.4):

工具                    调用     失败率     P50毫秒     P95毫秒      最长毫秒
run_command         2724   17.5%        26     14936    120005
read_file           2151    0.8%         1         1        92
list_files           626    1.0%         0         1        11
write_file            74    0.0%         1         2         4
search_knowledge      50    0.0%         0       773      1464

告警:从 run 208 起连续 3 次崩溃
  这一串共 5 次:[208, 209, 210, 211, 212],步数 [28, 1, 1, 1, 1]

输入 48,704,845 token,输出 1,169,783 token,输入/输出 = 41.6
全按缓存未命中、非高峰价粗估:$8.01

中文字符在终端里占两格宽,表头会有点错位,不影响数字。

逐段讲。

打开数据库。

  • sys.argv:命令行参数列表,sys.argv[0] 是脚本名,sys.argv[1] 是第一个参数。没传就默认用 runs.db
  • Path(...).resolve():转成绝对路径
  • f"file:{db}?mode=ro"uri=True:用 URI 形式打开数据库,mode=ro只读模式。分析线上数据时养成只读打开的习惯,脚本写错了也不会改坏数据

分位数。

  • 分位数(percentile):把所有数从小到大排好,P95 就是排在 95% 位置上的那个数,意思是”95% 的调用不比它慢”
  • p95 用的是最近排名法:位置 = 数量 × 0.95,四舍五入后取那个位置的数。int(x + 0.5) 是四舍五入的一种简单写法;列表下标从 0 开始,所以要减 1;max(0, ...) 防止只有一个数时下标变成负数
  • P50(中位数)直接用 sorted(ms)[n // 2]// 是整除
  • 分位数的算法有好几种(线性插值、最近排名等),数据少时结果会有差别。看板上的 P95 和自己算的对不上时,先查是不是算法不同

按工具汇总。

  • defaultdict(list):来自 collections 的字典,访问不存在的键时自动创建一个空列表,不用先判断键在不在。defaultdict(int) 则自动创建 0
  • for tool, ok, ms in conn.execute(...):查询结果可以直接遍历,每行是一个元组(tuple),拆成三个变量
  • fails[tool] += 0 if ok else 1:数据库里 ok 存的是 1 或 0
  • sorted(durations.items(), key=lambda kv: -len(kv[1])):按调用次数从多到少排序,取负号就是倒序
  • {fails[tool] / n:>8.1%}:格式说明 .1% 把小数乘 100 显示成百分比,保留 1 位小数

连续崩溃告警。

  • 按运行 ID 顺序遍历,streak 列表记录当前连续崩溃的运行
  • 状态以 crash 开头就追加进去;刚好攒够 3 次时打印告警(只在第 3 次打印一次,不会每次都打);遇到非崩溃的运行就清空
  • 循环结束后还要再检查一次,因为连续崩溃可能一直持续到最后一条记录

成本。

  • fetchone():只取查询结果的第一行
  • {tin:,}:格式说明里的逗号表示千分位分隔

看结果,能读出几件事。

  1. run_command 的 P50 只有 26 毫秒,P95 却接近 15 秒。大部分命令是 lscmake -E 这种秒回的,少数是真正的编译。这种两极分化的分布,平均值哪边都不代表,所以要看分位数
  2. 最长 120005 毫秒是一次命令超时被终止(cmake -P _findabsl.cmake,120 秒上限)。超时这类极端值要单独统计
  3. run_command 失败率 17.5%,远高于其他工具。这很正常:编译报错是 Agent 获取信息的主要途径,失败不等于有问题。失败率要结合工具的性质看,读文件工具失败率 0.8% 就值得看一下是什么情况
  4. 告警规则在 run 210 时就会触发,而实际上这一串崩溃一直持续到 212。每个崩溃的项目都只跑了 1 步,说明第一次调模型就失败了,是全局问题而不是项目问题
  5. 成本粗估约 8 美元。这是按写这篇时的价格页、全部按缓存未命中、非高峰价算的。实际上缓存会命中一部分(Agent 每一步的前缀和上一步大量重复),高峰时段价格翻倍,而且跑评测时的价格也未必和现在相同。因为没记缓存命中,我算不出真实成本,只能给一个粗略的数量级

自己改一改:

  1. 把连续崩溃的阈值改成 2,看会不会多出告警
  2. 加一段:按 status 分组,统计每种状态的平均步数和平均输入 token,看失败的运行是不是更贵
  3. 只统计 tool_callsresult 包含”超时”的调用,看有多少次

没有 runs.db 的话,可以用 4.5 节的思路自己造一个小库练习。

4.8 延迟怎么拆开看

一次模型调用的时间:

sequenceDiagram
    participant C as 客户端
    participant M as 模型服务
    C->>M: 发请求(带完整上下文)
    Note over M: 排队<br/>处理输入(输入越长越慢)
    M-->>C: 第一个 token
    Note over C,M: 首 token 延迟 TTFT
    M-->>C: 后续 token……
    M-->>C: 最后一个 token
    Note over C,M: 总耗时 = TTFT + 输出 token 数 × 每 token 时间
  • 首 token 延迟(TTFT,time to first token):发出请求到收到第一个 token 的时间,主要受输入长度、排队、缓存影响
  • 每 token 生成时间:流式输出时相邻两块之间的间隔。OTel 约定里有对应的指标 gen_ai.client.operation.time_per_output_chunk

一次 Agent 运行的时间:

总耗时 ≈ Σ(每步模型时间) + Σ(每个工具耗时) + 其他(准备、验收)

我项目的真实拆分:268 次成功运行,总耗时 14,676 秒,其中工具耗时 7,632 秒,占 52%。剩下的部分平均每步 1.9 秒。

要说清楚这个数的局限:项目没有单独记模型调用耗时,“剩下的部分”是用总耗时减去工具耗时得来的,里面除了模型调用,还包括启动时的项目探测和结束时的独立验收(验收要重新跑一遍构建)。所以 1.9 秒是每步模型耗时的上限,不是精确值。

排查思路:

flowchart TD
    S[运行太慢] --> Q1{步数多吗}
    Q1 -->|多| A1[读轨迹看绕路<br/>改提示词 / 工具设计 / 加知识]
    Q1 -->|不多| Q2{模型时间占比高吗}
    Q2 -->|高| A2[输入太长?压缩历史、截断工具结果<br/>缓存命中了吗?稳定前缀放前面<br/>简单步骤换小模型]
    Q2 -->|不高| Q3{哪个工具慢}
    Q3 --> A3[能并行吗?能缓存吗?<br/>超时设置合理吗?]

步数永远是第一个要看的,因为它是乘数:每多一步,就多一次模型调用,而且每次的输入都比上一次更长。

4.9 成本看板

算法:

单次调用成本 = (输入 token − 缓存命中 token) × 未命中单价
             + 缓存命中 token × 命中单价
             + 输出 token × 输出单价

有的厂商写缓存还要额外收费(比如 Anthropic 写入 5 分钟缓存按 1.25 倍价格计费,见 06 篇),要按厂商的规则加上。

看板上放什么:

维度看什么为什么
按天总成本趋势发现突然上涨
按运行状态成功和失败的平均成本失败的运行往往跑满步数,最贵
按功能 / 任务类型哪类任务最花钱决定优化优先级
按用户有没有异常用户发现滥用或死循环
按模型各模型花费评估换模型的收益
缓存命中率命中 token / 总输入 token提示词结构一改,命中率可能大幅下降
输入输出比输入 / 输出Agent 通常很高,我项目 41.6;突然升高说明上下文在膨胀

控制手段:

  1. 单次运行上限:步数上限、token 上限或金额上限,超了就停。我项目的步数上限 40 就是这个作用。09 篇里说过,步数上限本质上是”愿意为一次失败付多少钱”
  2. 每日预算:接近时告警,超了降级(换便宜模型或暂停非核心功能)
  3. 余额监控:我项目就是没有这一条,余额耗尽后才从 402 报错里知道

4.10 线上指标和告警

先看通用的四个黄金信号。 Google SRE 书 Monitoring Distributed Systems 一章提出,监控面向用户的系统,至少要看四个信号:延迟、流量、错误、饱和度。其中”错误”包括显式的(比如 HTTP 500)、隐式的,以及按策略认定的。Agent 的大部分问题都是隐式错误:状态码正常,结果不对。

Agent 要加上的指标:

指标怎么算说明问题
任务完成率成功的运行 / 总运行核心指标;“成功”要尽量用外部检查判断,不能只看模型说完成
状态分布成功 / 步数用完 / 卡住 / 崩溃 各占多少完成率下降时知道从哪查
人工接管率转人工的会话 / 总会话客服类 Agent 最重要的指标之一
用户重试率同一问题重新问、点重新生成的比例隐式的”不满意”
用户反馈点赞点踩比例显式信号,样本偏
平均步数、P95 步数绕路
工具失败率按工具分工具坏了或者模型用错了
每次运行成本见 4.9 节

告警怎么设。 同一章里有几条原则很适合 Agent:

  • 每条告警都要能处理。收到后只需要机械操作的,就不该发告警,应该自动化
  • 优先盯症状,不盯原因。用户看得到的问题(完成率下降、延迟变高)优先;原因类的告警只设那些非常确定、马上要出事的
  • 看尾部延迟,不看平均。和 4.7 节看到的一样

具体到 Agent,几条常见规则:

规则级别例子
连续 N 次崩溃 / 模型接口连续报错紧急我项目 402 那一串
完成率比过去 7 天同时段低很多紧急发布了一个把提示词改坏的版本
余额或当日预算用掉 80%重要
某工具失败率突然升高重要下游服务挂了
P95 步数、P95 耗时持续升高一般模型行为变化、上下文膨胀

不要对单次失败告警。 Agent 本来就有随机性,单次失败很正常,告警太多大家就不看了,这叫告警疲劳

4.11 隐私、脱敏和保留

Agent 的记录里敏感内容比普通服务多得多:用户的原始提问、检索到的内部文档、工具读到的文件、模型的回答。

flowchart LR
    RAW[原始事件] --> SPLIT{是内容吗}
    SPLIT -->|结构和数字<br/>token 耗时 工具名 状态| ALL[(全量保存<br/>长期)]
    SPLIT -->|提示词 回答 工具结果| RED[脱敏]
    RED --> KEEP{出错了吗}
    KEEP -->|是| FULL[(完整保留<br/>限制访问 设期限)]
    KEEP -->|否| SAMPLE[(抽样保存<br/>限制访问 设期限)]

要点:

  1. 结构信息和内容分开。token、耗时、工具名、状态这些不含内容,全量存;内容单独处理。这和 OTel 约定里”内容字段默认不记”是一个思路
  2. 写入前脱敏。用规则替换密钥、手机号、身份证号、邮箱,4.5 节实验演示了最简单的做法。规则总会漏,所以脱敏之外还要有第 3、4 条
  3. 访问控制。能看原文的人要少,看的操作要有记录
  4. 保留期限。到期删除;用户要求删除自己的数据时,记录也要能删
  5. 工具结果也要脱敏。只盯着用户输入是不够的,Agent 读到的配置文件、环境变量、数据库查询结果都可能有敏感内容
  6. 采样。正常的运行抽一部分存内容,出错的全部保留

我项目在这方面的情况:tool_calls.result 存的是工具结果原文(截断到 2000 字符),没有脱敏。工具层对凭据文件做了拦截(read_file 拒绝读 .env 等文件名,11 篇细讲),这减少了敏感内容进入记录的机会,但不是脱敏。单机自用可以接受,要给别人用就得补上。

4.12 工具怎么选

方案是什么适合
自己存数据库像我项目这样,自己设计表,自己写页面单机、原型、需要直接写 SQL 做分析
Langfuse开源、可以自己部署的 LLM 应用观测平台。记录 LLM 调用、检索、工具执行等每个操作的耗时、输入输出和元数据;有成本统计、提示词管理、数据集和实验、模型当裁判的评测数据不能出公司、想要现成看板
LangSmithLangChain 团队的平台。一个 run 是一个工作单元(一次模型调用或工具调用),同一次操作的 run 组成 trace,多轮对话的 trace 用 thread 串起来;不用 LangChain 也可以通过装饰器等方式手动接已经用 LangChain / LangGraph
OTel + 通用后端按 GenAI 语义约定埋点,发到 Jaeger、Grafana 等公司已有可观测体系,想统一

选型时想清楚几件事:

  1. 数据能不能出公司。用户对话、内部文档是不是允许发给第三方 SaaS
  2. 会不会被绑定。按 OTel 约定埋点,后端可以换;直接用某个平台的 SDK,换平台要改代码
  3. 性能影响。记录要异步发送、批量上报,不能拖慢主流程。Langfuse 文档里提到它的 SDK 是本地排队、批量发送的
  4. 和评测打通。线上发现的坏例子能不能一键加进评测数据集,这是 09 篇里”失败回流”的落地方式

关于我项目为什么没接:单机项目,一次评测几十次运行,SQLite 两张表足够,而且评测分析大量依赖直接写 SQL(统计 search_knowledge 调用次数、查某类报错出现在哪些运行里)。如果要上线给多人用,我会先补缓存命中和模型耗时这两个缺的字段,再按 OTel 约定改造成三层 span,这样后端选哪个都行。

第五部分 对照项目

本篇知识点项目里的位置做到了什么没做到或可以改进的
运行记录agent/trace.pyruns任务、模型、起止时间、状态、步数、token、结果没有 Agent 代码版本和提示词版本
工具调用记录tool_calls步骤、工具名、参数、成功与否、结果(截断 2000 字符)、耗时报错分类没存进库
模型调用记录add_usage输入输出 token 累加到运行上没有按步记;没有缓存命中、耗时、停止原因
span 层级两张表运行 → 工具调用两层模型调用没有单独一层;没有 trace_id / span_id,不符合 OTel 约定
状态区分build_eventssuccessfailedmax_stepsstuckcrash:<异常名> 分开
崩溃也记录try / except 里先 finish 再抛出402 余额不足、线程错误都留下了记录
写入可靠性每次写入立即 commit进程中途被杀,已完成的步骤仍在高并发时会成为瓶颈
轨迹查看/api/runs/api/runs/{id}RunHistory.vue列表 + 每步展开没有筛选、搜索、按状态统计
报错分类agent/errors.py8 类正则,运行时分类推送给前端,评测汇总里按类计数compile_error 太粗;没持久化
延迟分析能算工具耗时分位数模型耗时只能用总耗时减工具耗时粗估
成本能汇总 token没有换算金额;没有预算和余额监控
告警没做402 余额耗尽后又连续崩了 4 个项目才停
脱敏工具层拦截凭据文件减少敏感文件进入记录记录本身没脱敏,没有保留期限

对照开源实现:pi

pi05 篇第五部分介绍过)的可观测分两部分,成熟程度差别很大。源码以 commit 7b4cfd6 为准。

现在的命令行产品:没有链路追踪,靠会话文件和界面底栏。

  • 每条模型回复都带完整用量:输入、输出、缓存读、缓存写、推理 token,以及按模型价格表算好的每项金额,跟着会话 JSONL 一起存下来(07 篇),事后可以重新汇总
  • 界面底栏实时显示本次会话累计的输入、输出、缓存读写 token,最近一次的缓存命中率,花费,上下文用了多少;累计值包括工具内部调模型的用量和压缩时生成摘要的用量
  • 不往任何监控后端发数据

正在做的新运行时:先把 span 结构定义好。 遥测接口单独成包 packages/telemetry,不绑定任何后端,要接 OpenTelemetry、Sentry,由使用者写一个适配器。Agent 这边在 agent/src/harness/telemetry.ts 里用代码定义了每种 span 的名字、允许的父级和属性。设计文档自己注明了,大部分运行时 span 还在设计或实现中,所以这里只看它的设计:

pi.harness.run              一次运行
├─ pi.harness.turn          一轮:一次模型回复加上它发起的一批工具
│  ├─ pi.harness.step       一次尝试,重试时每次一个
│  ├─ pi.harness.tool       一次工具执行
│  └─ pi.harness.sleep      一次重试前的等待
└─ pi.harness.checkpoint    一次检查点

可以挂在任何一层下面的:
pi.ai.request               一次模型请求
pi.harness.hook             一次钩子调用
pi.session.write            一次会话写入

模型请求这个 span 记的属性(L45-L117),基本就是本篇 4.3 节”每次模型调用记什么”那张表:服务商、请求的模型名和实际响应的模型名、结束原因、HTTP 状态码、输入 / 输出 / 缓存读 / 缓存写 / 推理 token、费用、流式收到的块数、收到第一块的耗时、错误类型。工具 span 记工具名、调用编号、是否出错,还记了这个工具声明的”能不能安全重做”和”这次是不是恢复执行”(07 篇讲过这两个概念)。

和本篇、和我的项目对比:

本篇讲的pi我的项目
缓存命中 token按条记在会话里,底栏显示命中率没记
模型调用耗时、首 token 延迟新运行时的 span 里有首块耗时;现在的产品没有没记
费用每条回复按价格表算好金额只有 token,没换算
span 层级新运行时设计了运行 → 轮 → 尝试 / 工具的树运行 → 工具调用两层
字段名自己的 pi.ai.*pi.harness.*,接 OTel 时由适配器转换自己的表结构
内容要不要记规定属性里默认不放提示词、回答、工具参数和输出、文件内容、请求头、凭据、原始报错文字工具参数和结果原文都存了,截断到 2000 字符

值得讲的点:

1. 默认不记内容,和 OTel 约定是同一个思路。 本篇 4.4 节说 OTel 约定里提示词、回答这些内容字段是 Opt-In。pi 的遥测包说得更具体,连”原始报错文字”都默认不放,因为报错里经常带路径、参数甚至密钥。span 里只放结构化的数字和短标签,内容要看就去会话文件里查。本篇 4.11 节的”结构信息和内容分开”,这就是一个现成例子。

2. 记录失败不能影响业务。 遥测包对适配器的要求里写着:记录方法不能抛异常,后端出错要吞掉,业务逻辑必须照常执行且只执行一次。对应本篇 4.12 节”记录不能拖慢主流程”,而且更进一步:连出错都不能影响主流程。我项目是同步写 SQLite,写入失败会直接抛异常中断运行。

3. 先定结构,后接后端。 pi 没有先选 Langfuse 还是 LangSmith,而是先把”记哪些 span、每个 span 有哪些字段”定义成代码,后端通过适配器接。这和本篇 4.12 节”按 OTel 约定埋点,后端可以换”是一个方向,只不过字段名是自己定的。

第六部分 追问清单

你刚讲完下一个追问回答方向
日志、指标、链路追踪三者怎么关联起来共用 trace_id;指标告警 → 找 trace → 看 span 日志
span 层级Agent 的 span 树怎么设计运行 → 每步模型调用、工具调用;子 Agent、检索也是子 span
OTel GenAI 约定稳定了吗还是开发中状态,文档已迁到单独仓库,字段名改过(gen_ai.systemgen_ai.provider.name
记录提示词默认要记吗约定里是 Opt-In;记的话要脱敏、控制访问、设期限
P95为什么不看平均分布两极分化;run_command P50 26ms、P95 约 15s
分位数看板上的 P95 和你算的对不上分位数算法不同;直方图分桶精度
延迟首 token 延迟受什么影响输入长度、排队、缓存命中、模型大小
成本你项目花了多少钱输入 4870 万、输出 117 万 token;按现价全未命中粗估约 8 美元;没记缓存命中算不出真实值
成本输入为什么是输出的 41 倍每步重发完整历史;工具结果长
告警怎么避免告警太多不对单次失败告警;看连续和趋势;每条告警要能处理
完成率怎么判断”成功”尽量用外部检查;用户侧看重试、接管、反馈
隐私用户要求删除数据怎么办记录按用户可检索、可删除;设保留期限
平台为什么没用 Langfuse单机项目,SQLite 够用、方便写 SQL;上线会按 OTel 埋点
性能记录会不会拖慢 Agent异步批量上报;我项目同步写 SQLite,相对模型调用可忽略
线上问题用户说结果不对,怎么查用会话 ID 找 trace → 看每步输入输出 → 定位是检索、模型还是工具 → 加入评测集
记录内容span 里该不该放提示词和工具输出默认不放;只放数字和短标签,内容去会话记录里查;pi 连原始报错文字都默认不放
记录可靠性监控后端挂了会不会影响 Agent不能影响:记录方法不抛异常、后端错误吞掉、业务照常执行;pi 的遥测接口把这条写成了适配器必须遵守的规则

第七部分 闭卷自测

1. 日志、指标、链路追踪各擅长什么?用 Agent 的例子各举一个。

答案

日志擅长记录单个事件的细节,如”第 3 步 run_command 失败:找不到 zlib”;指标擅长看趋势和告警,如”每小时任务完成率”;链路追踪擅长看一次请求的时间分布和出错环节,如”这次运行 72 秒里哪一步最慢”。

2. trace、span、父 span 分别是什么?一次 Agent 运行的 span 树大致长什么样?

答案

trace 是一次完整请求,有全局 trace_id;span 是其中一个环节,有 span_id、开始时间、耗时、属性;子 span 记录父 span 的 ID,拼成一棵树。Agent 运行:根是 invoke_agent,下面依次是每步的 chat 模型调用和 execute_tool 工具调用,还可以有检索、验收等。

3. 为什么说 Agent 会”安静地错”?举项目里的例子。

答案

接口正常返回,模型说完成了,但结果不对或过程很糟,普通的错误监控看不到。例子:旧验收标准下 json 一个目标都没编译也判成功;ls /opt/homebrew/Cellar 执行成功,暴露了路径检查漏洞。

4. OTel GenAI 约定里,模型调用的 span 名字怎么起?输入 token 是否包含缓存命中的部分?

答案

{gen_ai.operation.name} {gen_ai.request.model},如 chat deepseek-flashgen_ai.usage.input_tokens 要包含缓存命中的部分,命中数另记在 gen_ai.usage.cache_read.input_tokens

5. gen_ai.input.messages 为什么是 Opt-In?开启后要注意什么?

答案

提示词和对话内容可能含用户隐私和机密,默认不记。开启后要写入前脱敏、限制访问、设保留期限;工具结果也要脱敏。

6. 实验 1 里,第 2 步输入 token 更多,为什么成本反而更低?

答案

第 2 步 2600 个输入里有 1700 个命中缓存,命中部分单价 0.003 美元每百万,远低于未命中的 0.15 美元。不记缓存命中就会把成本算错。

7. 实验 1 里,span 函数为什么用 try / finallyexcept 里为什么要 raise

答案

finally 保证无论成功还是出错都记下耗时、弹出栈,不会让后面的 span 挂错父亲。except 里记下错误类型后 raise 重新抛出,是为了只记录不吞掉异常,业务代码仍然能按原来的方式处理错误。

8. 实验 2 里 run_command P50 是 26 毫秒、P95 约 15 秒,说明什么?这时看平均耗时有什么问题?

答案

分布两极分化:大部分是秒回的简单命令,少数是耗时的编译。平均值会落在两者之间,既不代表简单命令也不代表编译,还会被极端值(120 秒超时)拉偏。

9. 我项目 402 余额耗尽那一串崩溃,从数据上怎么看出是全局问题而不是项目问题?该用什么规则告警?

答案

run 208 在第 28 步崩溃后,209 到 212 四个不同项目都在第 1 步就崩溃,状态相同,说明第一次调模型就失败,和项目无关。规则:连续 N 次(如 3 次)崩溃或模型接口连续报错就告警,另外加余额监控。

10. 我项目 268 次成功运行里工具耗时占 52%,剩下的平均每步 1.9 秒。这个 1.9 秒能直接当作模型调用耗时吗?

答案

不能。项目没单独记模型耗时,这个数是总耗时减工具耗时得来的,还包含启动探测和结束时的独立验收(要重新构建),所以只是每步模型耗时的上限。

11. Agent 运行太慢,排查顺序是什么?为什么先看步数?

答案

先看步数 → 再看模型时间占比(输入长度、缓存、模型大小)→ 再看哪个工具慢(并行、缓存、超时)。步数是乘数,每多一步多一次模型调用,而且输入越来越长。

12. 设计告警时,为什么不对单次失败告警?SRE 书里对告警有哪几条原则?

答案

Agent 有随机性,单次失败正常,告警太多会导致告警疲劳、没人看。原则:每条告警都要能处理,机械操作应自动化;优先盯用户能感受到的症状,原因类只盯确定且紧迫的;看尾部延迟而不是平均。

13. 我项目的记录要改成符合 OTel 约定,至少要补哪些东西?

答案

加 trace_id / span_id 和父子关系;模型调用单独成 span,记 gen_ai.usage.input_tokenscache_read.input_tokensoutput_tokens、耗时、停止原因、error.type;工具调用 span 名改成 execute_tool {工具名},记 gen_ai.tool.namegen_ai.tool.call.id;根 span 用 invoke_agent。另外补 Agent 代码和提示词版本。

14. pi 的遥测规定 span 属性默认不放提示词、回答、工具输出,连原始报错文字也不放。为什么报错文字也要排除?那出了问题去哪看内容?

答案

报错文字经常带着文件路径、请求参数,甚至密钥或用户数据,放进 span 就会跟着监控数据发到第三方后端、被更多人看到、保留更久。span 里只记结构化信息,比如错误类型、状态码。需要看具体内容时,去访问受控的会话记录里查,pi 就是把完整对话存在本地会话文件里。

延伸阅读

  1. OpenTelemetry GenAI 语义约定仓库docs/gen-ai/ 下有模型调用 span、Agent span、指标、MCP 等文档;目前是开发中状态
  2. Google SRE 书:Monitoring Distributed Systems — 四个黄金信号、告警原则
  3. Langfuse 可观测概览 — 开源 LLM 观测平台的功能范围
  4. LangSmith 可观测概念 — run、trace、thread、project 的定义
  5. Anthropic:Demystifying evals for AI agents(2026-01)— 本篇和 09 篇共用的背景:为什么要读轨迹、评测怎么持续迭代
  6. pi:telemetry 包Agent 遥测定义(commit 7b4cfd6)— 不绑定后端的遥测接口、适配器必须遵守的规则、Agent 的 span 结构和字段

下一篇:11 安全——记录能让你看到 Agent 做了什么,安全要保证它做不了不该做的事。

Related · Agent 开发
⎇ main ai/agent开发 26 节 230 notes UTF-8