Skip to main content

01 - Agent 的 Trace 长什么样

需要了解分布式追踪的基本概念(trace 是一次请求的完整记录,span 是其中的一段操作)。不需要用过任何具体的可观测平台。

本篇回答:Agent 的一次执行应该被拆成哪些 span、有哪些指标是传统监控里没有的、最少埋哪几个点就能用。

本篇会用到的词

意思
gen_ai.operation.nameOpenTelemetry 用来区分 span 类型的那个属性。它的取值(chat / execute_tool / invoke_agent 等)就是下面要讲的十八种操作类型
根 span一棵 trace 最外层的那个 span,代表「一次完整的用户请求」。它的耗时等于用户实际等待的时间
TTFTTime To First Token,首字延迟 —— 从发出请求到吐出第一个 token 的时间,这段时间用户屏幕是空的
TPOTTime Per Output Token,也叫 ITL —— 吐出后续每个 token 之间的间隔,决定「吐字」快不快
prefill / decode模型推理的两个阶段:prefill 一次性处理完整个输入(决定 TTFT),decode 之后逐个吐 token(决定 TPOT)
PIIPersonally Identifiable Information,个人可识别信息。姓名、手机号、身份证号这类,落进 trace 之前要先脱敏

一、一次执行展开成一棵树

用户输入:"帮我查一下我们上周的销售数据,做个总结。"

用户问「帮我查一下上周的销售数据,做个总结」—— 这次执行在 trace 里长这样0s5s10s15sinvoke_agent整棵树的根 · 18.4schat1.2s · 850 token 决定要查数据库execute_tool3.1s query_databasechat1.5s · 2,400 token 决定�再查上周做对比execute_tool2.8s query_databaseretrieval0.6s 检索历史报告模板chat9.2s · 5,100 token 生成最终总结传统监控只会记下一条「POST /chat 耗时 18.4s」。这张图能立刻回答:一半的时间花在最后那次生成上,和数据库没关系。
注意六个子 span 是根 span 的孩子,不是彼此的下一步。画成瀑布而不是链条,是因为真实 trace 里它们可能并行 —— 而「有没有并行」本身就是一个要看的结论。

一次用户请求产生六个 span:三次模型调用、两次工具调用、一次检索。

1.1 与传统监控的差别

传统 APMAgent trace
记录内容POST /chat 耗时 18.4s六个 span 的树结构
能回答慢在哪一跳
调用次数代码写死,固定每次执行都可能不同
成本与调用次数无关与 token 用量直接相关

上图中最后一个 span 占了 18.4 秒里的 9.2 秒。没有这棵树,"慢"这个结论无法再往下分解一层。

二、十八种操作类型

GenAI 语义约定用 gen_ai.operation.name 区分 span 类型。截至 2026-08-21,model/gen-ai/registry.yaml 里的枚举值有十八个,分成四族。

第一族 · 模型调用,对应"把请求发给一个模型":

取值含义产生时机
chat对话补全每次模型调用,最常见的一个
generate_content生成内容多模态接口,Gemini 一类
text_completion文本补全旧式 completion 接口
embeddings向量化计算 embedding
fetch_response取回已提交的响应异步接口,先提交后轮询取结果

第二族 · Agent 编排,对应"谁在指挥这次执行":

取值含义产生时机
create_agent创建 Agent初始化,或调用远端 Agent 服务时的建会话动作
invoke_agent调用 Agent树根,或子 Agent 调用
invoke_workflow调用工作流编排层
plan规划Plan-and-Execute 类范式
execute_tool执行工具每次工具调用

第三族 · 检索:只有 retrieval 一个取值,对应 RAG 召回环节。

第四族 · 记忆,2026 年才补进枚举的一整族,共七个取值:

取值含义
search_memory从记忆库里查
create_memory写入新的记忆记录
update_memory更新已有记录
upsert_memory写入或更新,由被调方决定走哪条
delete_memory删除记录
create_memory_store建记忆库本身
delete_memory_store销毁记忆库

配套的属性有五个:gen_ai.memory.store.id(哪个记忆库)、gen_ai.memory.record.id(哪条记录)、gen_ai.memory.record.count(这次动了几条)、gen_ai.memory.query.text(查询词)、gen_ai.memory.records(记录内容本身)。

2.1 枚举值的扩张记录了应用形态的变化

枚举值从 2 个涨到 18 个,每一次扩张都对应一类新的失败场景变得需要排查阶段一 · 调模型chat embeddings心智模型:一次 API 调用要排查的:这一次慢不慢、贵不贵2 个取值阶段二 · 编排invoke_agent plan execute_tool心智模型:一次多步执行要排查的:绕了几步、哪一步错+ 编排族与检索阶段三 · 记忆search / create / update / delete_memory心智模型:跨会话的长期状态要排查的:它记错了什么、何时记的+ 记忆族 7 个,共 18 个第三族值得单独注意:记忆是跨请求的。一次执行行为异常,成因可能是三天前另一次执行写进去的一条记忆 ——这类问题在单条 trace 里根本看不出来,必须靠 gen_ai.memory.record.id 把两次执行串起来�。规范补上这一族,等于承认了「一次请求一棵树」这个模型不够用了。
横轴是规范演进而不是时间刻度 —— 三个阶段并非整齐地一年一次。图里想说的是排查诉求的变化:从「这一跳」到「这一次」再到「这几天」。

invoke_agentplanexecute_tool 的存在,说明规范的心智模型是 Agent 的生命周期,而不是 LLM API 的调用。记忆族的加入更进一步:它承认了 Agent 的状态会活得比一次请求长。

这对埋点有一个直接后果:记忆类 span 的父节点是本次执行,但它读写的对象跨越多次执行。查一个"Agent 为什么突然开始胡说"的问题时,光看当次 trace 不够,得靠 gen_ai.memory.store.idrecord.id 反查这条记忆是哪次执行写进去的 —— 这要求你的后端支持按属性跨 trace 检索,而不只是按 trace ID 查一棵树。

三、九个指标

比 span 更适合做告警的是指标。GenAI 约定下的 gen_ai.* 指标:

# 客户端侧:单次模型调用的性能与用量
gen_ai.client.operation.duration # 整体耗时
gen_ai.client.operation.time_to_first_chunk # 首字延迟,即 TTFT
gen_ai.client.operation.time_per_output_chunk # 每 token 间隔,即 TPOT
gen_ai.client.token.usage # token 用量

# Agent 侧:一次 Agent 执行的行为特征
gen_ai.invoke_agent.duration # Agent 整体耗时
gen_ai.invoke_agent.inference_calls # 本次执行调用了几次模型
gen_ai.invoke_agent.tool_calls # 本次执行调用了几个工具
gen_ai.invoke_workflow.duration # 工作流耗时
gen_ai.execute_tool.duration # 单个工具耗时

3.1 耗时被拆成三个独立指标

operation.durationtime_to_first_chunktime_per_output_chunk —— 规范用三个指标表达"快慢",而不是一个。

原因在于三者的关系:

规范用三个指标表达「快慢」,因为它们的优化方向不同总耗时operation.durationTTFT · 首字延迟time_to_first_chunkTPOT · 每 token 间隔time_per_output_chunk×输出长度受排队、prefill、缓存命中率影响受批次大小、decode 吞吐影响只记总耗时,等于把两个成因完全不同的量加在一起看 —— 数字变大了,你却不知道该去调队列还是调批次。
这也是为什么输出长度不同的两次请求不能直接比总耗时:一次 50 token 的回答和一次 5,000 token 的回答,即使 TTFT 和 TPOT 完全一样,总耗时也会差两个数量级。

只记总耗时,则输出长度不同的两次请求无法比较 —— 一次输出 100 token 用 3 秒和一次输出 2000 token 用 20 秒,后者其实更快。

这与 Agent 网关 · Token 速率与 QoS 从工程实践得出的结论一致:time_to_first_chunk 即 TTFT,time_per_output_chunk 即 TPOT。

3.2 两个传统监控里没有的指标

gen_ai.invoke_agent.inference_calls
gen_ai.invoke_agent.tool_calls

传统服务的下游调用次数由代码写死,因此"调了几次"不是一个需要监控的量。Agent 不同:同一个问题,模型今天可能两步解决,明天可能绕十步。

这两个指标是发现"模型开始绕远路"的唯一手段

观察到的现象可能原因
inference_calls 的 P99 翻倍换了模型版本,或改了提示词导致模型反复试探
tool_calls 均值上升而 inference_calls 不变工具返回质量下降,模型需要多次调用才能拿到可用结果
两者都上升、成本上升、成功率不变净损失,应当告警

成本失控通常不是单价上涨,而是步数增加。 步数只有这两个指标能观测到。

四、MCP 使用同一套词汇

规范仓库中有一份 docs/gen-ai/mcp.md(1,332 行):MCP 的工具调用与发起它的 Agent 共用同一套 trace 词汇。

两个进程、两套代码库,但 span 词汇是同一套 —— 所以它们本来就是一棵树Agent 编排进程invoke_agentgen_ai.agent.nameexecute_toolgen_ai.tool.name跨进程MCP Server 进程tools/callmcp.method.name外部 API 调用http.request.method四个 span 由两个进程分别产生,靠 traceparent 头把父子关系带过边界 —— 前提是两侧都埋了点(02 篇做法 B 或 C)。规范里 docs/gen-ai/mcp.md 有 1,332 行专门定义 MCP 那一段的词汇;OpenInference 没有对应约定,见 04 篇第二节。MCP 网关场景尤其吃这一点:网关正好站在两层之间,两边词汇不统一的话,它就得自己维护一张翻译表。
这张图省略了模型调用那一支。真实的树里 invoke_agent 下面通常还并列着若干 chat span,是它们决定了要调哪个工具。

这意味着一棵 trace 树可以同时覆盖 Agent 编排层和 MCP 工具层,不需要在两套体系间做转换 —— 对 Agent 网关 · MCP 网关描述的工具聚合场景尤其重要,因为网关本身就处在两层之间。

上面那句「靠 traceparent 头把父子关系带过边界」,展开看就是这么回事:

上下文��传播示例:Frontend 服务里两个并发请求各自开出一个 span,各带 Trace ID、Parent ID 与 Span ID;两个 span 分别向 Product 服务发出请求,请求头里带上 traceparent,值是 00 加自己的 Trace ID 加自己的 Span ID;下游据此把新 span 的 Parent ID 填成上游的 Span ID
出处:opentelemetry.io docs/concepts/context-propagation,CC-BY-4.0。跨进程传的其实只有一个 HTTP 头traceparent 里装着调用方的 trace ID 和自己的 span ID,下游拿它当 parent 接着往下长。所以「一棵树」不是谁在中央拼出来的,而是每个进程各自记一段、靠这个头串起来的。

这也解释了断链为什么那么常见:只要链路上有一环没读或没转发这个头,它下游的所有 span 就会另起一棵树。多数「trace 只有半截」的排查,最后都落到某个中间件把请求头洗掉了。

五、规范尚未稳定

前四节列出的属性名,没有一个是稳定的:gen_ai.* 下 118 个属性,stability 全部标着 development,写成 stable 的一个都没有,span 与指标同理。

development 在 OpenTelemetry 的稳定性分级里意味着:允许在任何一次发布中被重命名或删除,不保证向后兼容。 这不是理论上的风险 —— changelog.d/ 目录下带 .breaking.md 后缀的条目已经有六个(#217、#242、#257、#289、#322、#440);v1.42.0(2026-06-12)还把全部 gen_ai.*semantic-conventions 主仓库拆了出去,成立独立的 semantic-conventions-genai 仓库,照着旧地址取规范数据的脚本会在这个版本上断掉。

属性数量与稳定性标记随版本变化,当前值可以直接从规范仓库数出来:

# 注意是 genai 仓库不是主仓库,2026 年才拆分出来,旧地址上没有这个文件
curl -sL https://raw.githubusercontent.com/open-telemetry/\
semantic-conventions-genai/main/model/gen-ai/registry.yaml \
| grep -c "stability: development" # → 118

5.1 三处规范自己承认还没写完

不稳定不只是"字段名可能变",规范里有几处直接标着 TODO:

位置状态影响
gen-ai-spans.md 的 Streaming chunks 一节正文只有一个 TODO流式响应该怎么在 span 里记录分块,规范没给答案,各家实现各写各的
同文件"记录外部存储引用"一节TODO: document a common approach把 prompt 存到对象存储后,span 上那个引用字段该叫什么,没有统一答案
结构化属性依赖 OTEP 4485 落地gen_ai.input.messages 是个嵌套对象,但多数语言的 span 属性还不支持复杂值,只能先序列化成 JSON 字符串塞进去

第三条尤其值得记住:你现在在 span 上看到的 gen_ai.input.messages,多半是一个 JSON 字符串而不是结构化对象,后端能不能在它内部检索,取决于后端自己解不解这层 JSON。

5.2 应对方式

不稳定不等于不能用,但需要隔离变更影响:

# ❌ 直接在业务代码里写属性名
# 规范一改,所有调用点都要跟着改
span.set_attribute("gen_ai.usage.input_tokens", n)

# ✅ 收敛到一个薄封装层
# 规范变更时只改这一处;也便于同时输出两套约定(见 04 篇)
class AgentSpan:
"""对埋点属性的唯一出口。业务代码不直接接触属性名。"""

def set_input_tokens(self, n: int) -> None:
# 规范未稳定,属性名可能变化 —— 变更只影响这一行
self._span.set_attribute("gen_ai.usage.input_tokens", n)

不过这只是自己手写埋点时的做法。更省事的路线是根本不自己写这一层 —— 02 篇会说明现成的埋点库如何把这层封装替你做掉,04 篇会说明 Collector 侧现在已经有官方组件能在管道里改写属性名,连封装层都可以不要。

六、最小可用埋点清单

从零开始时,下面这组覆盖大部分排查与计费需求:

优先级埋什么解决什么问题
1invoke_agent 根 span,含 inference_calls / tool_calls发现步数异常
2每次 chat span,含 token 用量成本归因的原料
3time_to_first_chunktime_per_output_chunk回答"为什么用户说慢"
4每次 execute_tool span,含工具名与耗时定位工具侧瓶颈
5租户 / 团队标识(在网关侧注入,见 06 篇账单可拆解

前四项在应用侧埋,第五项必须在网关侧注入 —— 应用侧传入的身份标识可被伪造,不能作为计费依据。

这五项都还没说"具体怎么埋"。可选的做法有四种,改动面从"每个调用点加两行"到"一行代码都不动",覆盖率和失效模式各不相同 —— 那是下一篇的内容。

下一篇02 - 埋点的四种做法

← 回到 专题索引  ·  Agent Infra 板块总览