文章类型技术长文 所属专栏Agent 观测 预计阅读68 分钟 文档状态已发布
返回

第 2 篇:Agent 埋点实现——OpenTelemetry、异步上下文与流式调用

围绕 Agent Loop 埋点、Span 类型、Trace Context 传播、流式模型调用、并行重试以及 Collector 治理,给出可落地的 OpenTelemetry 实现。

开始阅读全文13633 字 · 68 分钟 查看系列目录Agent 观测
关键词 Agent可观测性OpenTelemetryTrace Context流式调用
栏目 AgentObservability;专栏 Agent 观测;标签 Agent、可观测性、OpenTelemetry、Trace Context、流式调用

文章目标#

上一篇建立了 Agent 可观测性的统一对象模型:Session、Task、Run、Trial、Turn、Step、Trace、Span、Event、Artifact、State Snapshot 与 Score。本篇继续解决更具体的工程问题:

如何真正把 Trace 埋进 Agent Loop,而不是只在模型 API 外面包一层计时器?

仅给一次模型请求记录开始时间和结束时间,最多只能回答“模型调用耗时多久”。一个真正可用于诊断、评测和审计的 Agent Trace,还必须解释:

  • 用户任务何时进入系统;
  • 上下文由哪些部分组成;
  • 模型何时开始返回第一个 Token;
  • Tool Call 是何时、如何被增量解析出来的;
  • 工具调用是否需要审批;
  • 工具执行是否重试、回退或产生副作用;
  • Tool Result 如何回填进下一轮模型上下文;
  • 并行工具和子 Agent 之间是什么关系;
  • 网络断流、取消和超时分别发生在哪个阶段;
  • 观测后端不可用时,Agent 本身是否仍能继续工作。

本文继续使用同一个 Coding Agent 场景:

用户要求 Agent 修复代码仓库中的 failing test。Agent 读取仓库、检索代码、调用模型生成计划,执行文件读取和编辑工具,运行测试,并可能遇到 429、流式连接中断、工具超时、人工审批和任务恢复。

本文会给出:

  1. Agent Loop 的完整埋点边界;
  2. OpenTelemetry 信号与语义模型;
  3. Span 类型和父子关系设计;
  4. Python 与 TypeScript 的异步上下文传播方式;
  5. 流式模型调用的记录方法;
  6. 并行工具与重试 Attempt 的建模;
  7. Collector、队列、采样和强制 Flush 配置;
  8. 隐私、高基数和内容采集边界;
  9. 一套可直接改造成项目代码的实现骨架。

标准状态与实现边界#

在进入代码前,需要先明确本文使用的标准边界。

OpenTelemetry 的 Trace、Metric、Log、Resource、Context 与 OTLP 等核心能力已经相对稳定;但面向生成式 AI 和 Agent 的语义约定仍在持续演进。截至 2026 年 8 月 5 日,OpenTelemetry 的 GenAI Semantic Conventions 仍标记为 Development,并在独立的 semantic-conventions-genai 仓库中维护。它已经覆盖模型调用、Agent、工具、检索、记忆和 MCP 等对象,但字段名和具体要求仍可能发生变化。12

因此,本文采用以下策略:

  1. 能使用正式 OpenTelemetry 语义的地方,优先使用 service.*deployment.*error.typegen_ai.* 等标准字段;
  2. Agent 特有但尚无稳定标准的字段,放在自定义命名空间,例如 app.agent.*
  3. 自定义字段必须附带 app.agent.telemetry.schema.version
  4. 不重新定义标准字段已有的含义;
  5. 代码示例直接使用属性字符串,而不是依赖某个特定版本生成的常量包;
  6. 生产环境应锁定语义约定版本,并在升级时执行兼容性迁移。

MCP 方面,2026-07-28 规范已经为 _meta 中的 traceparenttracestatebaggage 预留了 OpenTelemetry 上下文传播键,并要求其值遵循 W3C Trace Context 与 W3C Baggage 格式。34

这意味着 Agent、MCP Client 和 MCP Server 之间已经可以使用标准 Trace Context 连接调用链,但实际 SDK 和 Server 是否完整支持,仍需要结合具体实现版本验证。


1. Agent Loop 中的埋点位置#

Agent Loop 埋点位置全景图

1.1 用户任务进入#

一次 Agent Trace 应从“用户任务进入系统”开始,而不是从第一次模型 API 请求开始。

假设用户输入:

修复 tests/test_order.py::test_discount 的失败问题,并确保没有修改无关模块。

系统在这一刻至少已经得到以下信息:

  • 用户目标;
  • 会话标识;
  • 任务标识;
  • Agent 名称和版本;
  • 当前部署环境;
  • 权限模式;
  • 可用工具集合;
  • 任务预算;
  • 是否属于线上运行或离线 Trial。

这些信息应挂在最外层的 Agent SpanRun Span 上。

一个推荐的根 Span 结构是:

invoke_agent coding-agent
├── context.compose
├── chat model-name
├── approval.wait
├── execute_tool code_search
├── execute_tool read_file
├── execute_tool edit_file
├── execute_tool run_tests
├── state.commit
└── outcome.verify

根 Span 不应该直接以完整用户 Prompt 命名,例如:

错误:修复王某某订单系统里 ID=938281 的折扣问题

这会同时造成:

  • Span 名称高基数;
  • 用户隐私泄漏;
  • 后端索引成本上升;
  • 查询和聚合失效。

更好的 Span 名称是稳定、低基数的:

invoke_agent coding-agent

具体任务内容通过引用、摘要、Hash 或受控内容字段保存。

创建根 Span 时,应该尽早放入可能参与采样和路由的属性:

root_attributes = {
"gen_ai.operation.name": "invoke_agent",
"gen_ai.agent.name": "coding-agent",
"app.agent.telemetry.schema.version": "1.0.0",
"app.agent.task.type": "code_fix",
"app.agent.execution.mode": "production",
"app.agent.permission.mode": "workspace_write",
}

凡是需要参与 Head Sampling 的字段,都必须在 Span 创建时可用。后续才调用 set_attribute() 的字段,Head Sampler 已经无法看到。为了减少不同采样方式之间的行为差异,Agent 名称、操作类型、环境、Provider 和模型等关键字段最好在创建 Span 时就设置。5

用户输入应该记录到什么程度#

默认情况下,根 Span 不应直接记录完整用户内容,而应先记录 Shape:

{
"app.agent.input.message_count": 1,
"app.agent.input.char_count": 68,
"app.agent.input.content_type": "text/plain",
"app.agent.input.content_hash": "sha256:...",
"app.agent.input.content_ref": "artifact://input/request-001"
}

只有在用户明确授权、环境允许并且后端访问控制完善时,才将完整输入作为受控内容保存。


1.2 上下文组装#

Agent 在调用模型前,通常会把多种信息组装成最终上下文:

  • System Prompt;
  • 用户消息;
  • 对话历史;
  • 工具定义;
  • 检索结果;
  • 长期记忆;
  • 当前计划;
  • 环境摘要;
  • 历史 Tool Result;
  • 上下文压缩后的 Summary。

如果只记录最终模型请求,而不记录这些来源的组成方式,就无法回答:

  • 错误内容来自用户输入还是检索结果;
  • 某条记忆是否被错误注入;
  • 工具定义是否因版本变化而改变;
  • 上下文是否在压缩时丢失关键约束;
  • Token 成本为什么突然上升。

因此,上下文组装应有独立的 Span:

context.compose

它不一定要保存全部内容,但至少应该保存结构信息:

字段含义
app.agent.context.message_count最终消息数量
app.agent.context.input_token_estimate组装后的输入 Token 估计
app.agent.context.system_prompt.versionSystem Prompt 版本
app.agent.context.tool_definition.count工具定义数量
app.agent.context.retrieval.item_count注入检索项数量
app.agent.context.memory.item_count注入记忆数量
app.agent.context.compacted是否发生过压缩
app.agent.context.content_hash最终上下文内容 Hash
app.agent.context.artifact_ref完整上下文的受控引用

上下文组装失败也需要单独归因。例如:

  • 检索服务超时;
  • Tool Schema 序列化失败;
  • Token 预算计算错误;
  • 记忆过滤器异常;
  • 上下文超过模型上限。

这些错误不能被笼统归入“模型调用失败”,因为模型请求可能根本还没有发出。


1.3 模型请求#

模型 Span 的边界应该覆盖:

从客户端真正开始向模型 Provider 发起请求,到流式响应结束、取消或失败。

它不应该覆盖上游的上下文组装,也不应该把后续工具执行包含进来。

推荐 Span 名称:

chat model-name

或按照所用 GenAI 语义约定采用对应的操作名。

模型 Span 应记录以下几类信息:

请求身份#

gen_ai.operation.name
gen_ai.provider.name
gen_ai.request.model
server.address

请求配置#

gen_ai.request.temperature
gen_ai.request.max_tokens
app.agent.prompt.version
app.agent.tool_schema_set.version

响应结果#

gen_ai.response.model
gen_ai.response.finish_reasons
gen_ai.usage.input_tokens
gen_ai.usage.output_tokens

流式运行数据#

app.agent.stream.ttft_ms
app.agent.stream.chunk_count
app.agent.stream.text_delta_count
app.agent.stream.tool_delta_count
app.agent.stream.termination

内容数据#

GenAI 语义约定已经定义了消息、工具参数和工具结果相关属性,但官方同时明确提示:输入消息、输出消息、Tool Arguments、Tool Result、检索查询和记忆记录都可能包含敏感信息,不应默认无条件采集。6

因此建议:

  • 默认只记录 Token、长度、数量、Hash、版本和引用;
  • 内容采集必须显式 Opt-in;
  • 内容字段在进入 SDK 前先脱敏;
  • Collector 再进行第二层删除或替换;
  • 观测后端实施独立权限和保留周期。

1.4 Tool Call 生成#

Tool Call 的产生和工具的真实执行不是同一个动作。

模型流式输出可能先产生这样的增量片段:

{"id":"call_1","name":"read_file","arguments":"{\"pa"}
{"id":"call_1","arguments":"th\":\"src/order"}
{"id":"call_1","arguments":"/service.py\"}"}

此时至少存在三个不同阶段:

  1. 模型正在生成 Tool Call;
  2. 客户端完成 Tool Call 拼装与 JSON 解析;
  3. 工具运行时真正开始执行。

如果把它们都压成一个 Tool Span,会丢失很多关键问题:

  • 模型是否生成了不完整 JSON;
  • Tool Call 是否因流式断开而只收到一半;
  • 参数是否在执行前被修复;
  • 审批人是否修改了参数;
  • 相同 Tool Call 是否被重复执行。

推荐做法是:

  • 在 Model Span 中记录 tool_call.completed Event;
  • 为每个逻辑 Tool Call 分配稳定的 tool_call_id
  • 参数完成拼装和 Schema 校验后,再创建 Tool Span;
  • 如果参数被修复,保留 raw_arguments_hashvalidated_arguments_hash
  • 不把未完成的参数片段直接当作可执行输入。

示例:

model_span.add_event(
"tool_call.completed",
{
"gen_ai.tool.call.id": tool_call_id,
"gen_ai.tool.name": tool_name,
"app.agent.tool.arguments.byte_count": len(arguments_json),
"app.agent.tool.arguments.valid": True,
},
)

这里仍然没有必要把完整参数写入 Event。完整参数可以放在加密 Artifact 中,通过 content_ref 引用。


1.5 工具审批#

高风险工具往往需要人工或策略引擎审批,例如:

  • 写文件;
  • 删除文件;
  • 执行 Shell;
  • 访问生产数据库;
  • 发送邮件;
  • 部署服务;
  • 调用付费或不可逆接口。

审批过程不应只记录成一个布尔值。至少要区分:

  • 是否要求审批;
  • 哪个策略触发审批;
  • 审批对象是什么;
  • 等待了多久;
  • 谁作出决定;
  • 参数是否被修改;
  • 结果是允许、拒绝、过期还是取消。

推荐使用独立的 approval.wait Span:

approval.wait

它的时长直接体现 Agent 被人类阻塞的时间。

建议字段:

app.agent.approval.required
app.agent.approval.policy.version
app.agent.approval.decision
app.agent.approval.actor.type
app.agent.approval.wait_ms
app.agent.approval.arguments_modified

需要注意:审批拒绝不一定是系统错误。

如果策略正常识别出高风险动作,用户也正常选择拒绝,那么这是一条合法业务路径。此时 Span 可以保持 UNSET 或正常结束,通过:

app.agent.approval.decision = "denied"

表达结果,而不是统一设置为 ERROR

只有审批服务不可用、审批状态丢失或协议异常,才属于技术错误。


1.6 工具执行#

工具执行 Span 应从“已经获得可执行参数”开始,到“工具返回结果或失败”结束。

推荐名称:

execute_tool read_file
execute_tool run_tests
execute_tool edit_file

对本地函数调用,Span Kind 通常可以是 INTERNAL;对远程 API 或外部服务调用,则通常更适合 CLIENT。对于 Agent 本身,同进程内的调用一般建模为 INTERNAL,远程 Agent 调用则可以使用 CLIENT。当前 GenAI Agent Span 语义也采用了类似区分。5

Tool Span 至少应记录:

类别字段示例
工具身份gen_ai.tool.nameapp.agent.tool.schema.version
调用身份gen_ai.tool.call.idapp.agent.operation.id
参数结构参数字节数、字段数量、校验结果、参数 Hash
权限权限模式、审批结果
副作用read_onlyreversible_writeirreversible
幂等性app.agent.tool.idempotency_key
结果状态、返回字节数、结果类型、Artifact 引用
错误error.type、错误类别、是否可重试
AttemptAttempt 编号、重试次数、Fallback 目标

工具返回“业务失败”与“调用失败”也要区分。

例如,执行测试工具返回:

exit_code = 1
tests_failed = 1

工具本身成功运行,只是测试结果失败。此时 Tool Span 不应该被标记为网络或调用错误;应记录:

app.agent.tool.execution.status = "completed"
app.agent.tool.business_outcome = "tests_failed"

而如果测试进程无法启动、超时或被操作系统杀死,才属于工具执行错误。


1.7 Tool Result 回填#

工具执行结束后,结果通常还不能直接交给模型。系统可能需要:

  • 截断过长输出;
  • 过滤 ANSI 控制字符;
  • 将二进制结果转为 Artifact;
  • 摘要压缩;
  • 隐私脱敏;
  • 结构化解析;
  • 将错误转换成统一 Tool Message;
  • tool_call_id 回填到正确位置。

因此,Tool Result 回填最好有独立的逻辑步骤,例如:

tool_result.normalize
tool_result.attach

它可以是短 Span,也可以是 Step 上的 Event,取决于是否存在明显耗时和失败可能。

建议记录:

app.agent.tool_result.original_bytes
app.agent.tool_result.final_bytes
app.agent.tool_result.truncated
app.agent.tool_result.redacted
app.agent.tool_result.summary_applied
app.agent.tool_result.content_hash
app.agent.tool_result.artifact_ref
gen_ai.tool.call.id

这一层对于排查“工具明明返回了正确内容,模型为什么还是答错”非常重要。

根因可能不是模型没有理解,而是:

  • Result 被截断;
  • Result 回填到了错误的 Tool Call;
  • 结构化解析丢失字段;
  • 摘要器删掉了关键错误信息;
  • 上下文预算不足导致结果未进入下一次模型请求。

1.8 状态更新和最终停止#

Agent 完成一轮工具调用后,通常会更新内部状态:

  • 当前计划;
  • 已完成步骤;
  • 对话历史;
  • 工作区状态;
  • 记忆候选;
  • Checkpoint;
  • Outcome;
  • Stop Reason。

这一步不能简单理解为“把模型回答返回给用户”。一个 Agent 真正停止前,至少应回答两个问题:

  1. 它为什么停止?
  2. 它声称完成的结果是否被环境证据验证?

常见 Stop Reason:

completed
max_steps_reached
budget_exhausted
user_cancelled
approval_denied
unrecoverable_error
handoff_to_human
waiting_for_external_event

推荐将“结果生成”和“结果验证”分开:

state.commit
outcome.verify

例如 Coding Agent 声称“修复完成”,但 outcome.verify 需要检查:

  • 测试是否真的通过;
  • Git Diff 是否只包含允许的文件;
  • 是否存在未提交冲突;
  • 是否产生了意外副作用。

只有验证完成后,根 Agent Span 才结束。

一条完整埋点链#

invoke_agent coding-agent
├── context.compose
│ ├── retrieval
│ └── memory.retrieve
├── chat model-name
│ ├── event: stream.first_token
│ ├── event: tool_call.completed
│ └── event: response.completed
├── approval.wait
├── execute_tool edit_file
│ ├── tool.attempt 1
│ └── state.diff
├── tool_result.normalize
├── chat model-name
├── execute_tool run_tests
│ ├── tool.attempt 1 [ERROR: timeout]
│ ├── event: retry.scheduled
│ └── tool.attempt 2 [OK]
├── state.commit
└── outcome.verify

这条链才真正描述了 Agent 如何完成任务,而不是只记录两次模型 API 延迟。


2. OpenTelemetry 在 Agent 中的作用#

2.1 Trace、Metric、Log 和 Event#

OpenTelemetry 提供多类信号。Agent 系统不应该把所有数据都塞进 Trace,也不应该只依赖 Log。

信号最适合回答的问题Agent 中的典型用途
Trace某一次任务为什么这样执行模型、工具、检索、审批、重试的因果链
Metric整体趋势是否异常成功率、TTFT、工具错误率、Token、费用
Log某个组件输出了什么诊断信息SDK 日志、工具 stderr、解析器错误
Event某个离散状态变化何时发生首 Token、审批决定、重试、压缩、恢复

OpenTelemetry 中“Event”存在两个容易混淆的层次:

  1. Span Event:附着在某个 Span 上的时间点注释;
  2. Event Log:具有稳定事件名称的 LogRecord。

例如“模型收到第一个 Token”通常适合作为 Model Span 的 Span Event:

stream.first_token

而“系统级审批策略发生配置变更”可能更适合作为独立 Event Log。

Trace 不适合替代 Metric#

如果要查询过去一小时平均 TTFT,不应该实时扫描所有 Trace,而应该同时记录 Histogram:

gen_ai.client.operation.duration
app.agent.stream.ttft

Trace 用于解释单次异常,Metric 用于发现整体异常。

Metric 不适合携带单次任务身份#

以下字段不应该成为 Metric Label:

session_id
task_id
run_id
trace_id
user_id
tool_call_id
完整文件路径
原始 Prompt

它们会制造极高基数。Metric 的作用是聚合,不是保存单次请求详情。


2.2 Resource、Span 和 Attribute#

Resource:谁产生了这些遥测数据#

Resource 描述产生遥测数据的实体,例如:

service.name = coding-agent
service.version = 1.4.2
service.instance.id = pod-7f89
deployment.environment.name = production
cloud.region = ap-northeast-1

这类字段在进程生命周期内通常较稳定,应放在 Resource,而不是每个 Span 上重复建模。OpenTelemetry 将 Resource 定义为产生遥测数据的实体表示。7

Span:哪个操作正在发生#

Span 表示一个有开始和结束的操作,例如:

invoke_agent
context.compose
chat
execute_tool
retrieval
approval.wait

一个 Span 通常包含:

  • 名称;
  • 开始和结束时间;
  • Span Kind;
  • Attributes;
  • Events;
  • Links;
  • Status;
  • Parent Span;
  • Resource;
  • Instrumentation Scope。

Attribute:这个操作有什么特征#

Attribute 应描述当前操作的结构化特征,例如:

gen_ai.provider.name = openai
gen_ai.request.model = model-x
gen_ai.tool.name = run_tests
app.agent.retry.count = 2
app.agent.tool.side_effect = reversible_write

属性值应尽量保持:

  • 稳定;
  • 有界;
  • 可查询;
  • 不包含大段文本;
  • 不包含不必要的敏感数据。

Event:操作过程中发生了什么#

Span Event 适合记录:

stream.first_token
tool_call.completed
approval.decided
retry.scheduled
context.compacted
checkpoint.created

不要为每个流式 Chunk 创建一个 Span,否则会产生极大的数据量和后端压力。

Link:除了父子关系,还依赖谁#

一个 Span 只能有一个直接 Parent,但 Agent 操作可能依赖多个并行结果。此时可以使用 Span Link 表示额外因果关系。

例如,第二轮模型调用依赖三个并行工具结果:

chat follow-up
├── parent: execute_parallel_tools
├── link: tool span A
├── link: tool span B
└── link: tool span C

OpenTelemetry 也建议在 Scatter/Gather 等不适合单一父子关系的场景使用 Links。8


2.3 OpenTelemetry GenAI 语义#

OpenTelemetry GenAI Semantic Conventions 为常见生成式 AI 操作提供统一字段和 Span 语义。当前范围已经包括:

  • 模型请求;
  • Agent 调用;
  • Workflow;
  • Planning;
  • Tool Execution;
  • Retrieval;
  • Memory 操作;
  • MCP;
  • GenAI Metric 和 Event。12

常见操作名包括:

invoke_agent
invoke_workflow
plan
execute_tool
retrieval
create_memory
retrieve_memory
update_memory
delete_memory

应优先复用的标准字段#

gen_ai.operation.name
gen_ai.provider.name
gen_ai.request.model
gen_ai.response.model
gen_ai.agent.name
gen_ai.agent.id
gen_ai.tool.name
gen_ai.tool.call.id
gen_ai.usage.input_tokens
gen_ai.usage.output_tokens
gen_ai.conversation.id
error.type
server.address

不要为了“字段完整”而伪造值#

例如 gen_ai.conversation.id 只有在 Provider 或应用本身确实存在稳定会话 ID 时才应填写,不应为了让 Dashboard 好看而临时生成一个 UUID 冒充 Provider Conversation ID。9

同理:

  • 没有 Agent ID 时不要伪造;
  • Provider 未返回 Response Model 时不要猜;
  • Token Usage 不完整时应标记缺失,不要用字符数冒充精确 Token;
  • Tool Result 未真实执行时不能标记成功。

标准字段与自定义字段的边界#

如果标准语义已经定义了:

gen_ai.tool.name

就不应再创建一个含义相同的:

app.agent.tool_name

自定义字段只补充标准没有覆盖的语义:

app.agent.step.id
app.agent.operation.id
app.agent.attempt.id
app.agent.tool.side_effect
app.agent.approval.decision
app.agent.context.compacted
app.agent.outcome.status

2.4 自定义 Agent 语义字段#

建议为项目建立一个独立的语义字段注册表,而不是让开发者随意命名。

推荐字段分层#

任务与运行#
app.agent.task.id
app.agent.task.type
app.agent.run.id
app.agent.trial.id
app.agent.turn.id
app.agent.step.id
逻辑操作与 Attempt#
app.agent.operation.id
app.agent.attempt.id
app.agent.attempt.number
app.agent.retry.count
app.agent.recovery.status
上下文#
app.agent.context.message_count
app.agent.context.input_token_estimate
app.agent.context.compacted
app.agent.context.content_hash
工具#
app.agent.tool.schema.version
app.agent.tool.arguments.valid
app.agent.tool.side_effect
app.agent.tool.idempotency_key
app.agent.tool.result.artifact_ref
审批#
app.agent.approval.required
app.agent.approval.policy.version
app.agent.approval.decision
app.agent.approval.wait_ms
结果#
app.agent.outcome.status
app.agent.outcome.verified
app.agent.stop.reason
app.agent.state.before.ref
app.agent.state.after.ref
app.agent.state.diff.ref

字段注册表至少包含什么#

字段类型允许值是否敏感可否进入 Metric版本
app.agent.task.typestring固定枚举可以1.0
app.agent.task.idstring任意 ID可能不可以1.0
app.agent.tool.side_effectstring固定枚举可以1.0
app.agent.approval.decisionstringapproved/denied/…可以1.0
app.agent.input.content_refstringArtifact URI不可以1.0

如果没有字段注册表,几个月后往往会同时出现:

agent.run_id
agent.run.id
runId
run_id
app.run.id

这会使查询、评测和数据迁移极其困难。


3. Span 类型设计#

3.1 Agent Span#

Agent Span 表示一个 Agent 被调用并推进任务的过程。

推荐边界#

从 Agent 开始处理目标,到:

  • 完成任务;
  • 交给另一个 Agent;
  • 等待外部事件;
  • 失败终止;
  • 用户取消。

推荐属性#

gen_ai.operation.name = invoke_agent
gen_ai.agent.name
gen_ai.agent.id
app.agent.task.type
app.agent.run.id
app.agent.stop.reason
app.agent.outcome.status

Span Kind#

  • 同一进程内部调用:INTERNAL
  • 远程 Agent 服务调用:客户端 CLIENT,服务端 SERVER

不应该放进去的内容#

  • 完整用户 Prompt 作为 Span 名;
  • 每一条工具结果全文;
  • 不受控的用户 ID;
  • 整段隐藏推理文本。

Agent Span 负责组织链路,不应该变成所有内容的垃圾桶。


3.2 Model Span#

Model Span 表示一次实际模型请求。

推荐边界#

从请求开始发送,到:

  • 非流式响应完成;
  • 流式响应正常结束;
  • 用户取消;
  • 超时;
  • 连接断开;
  • Provider 返回错误。

推荐属性#

gen_ai.operation.name
gen_ai.provider.name
gen_ai.request.model
gen_ai.response.model
gen_ai.request.temperature
gen_ai.usage.input_tokens
gen_ai.usage.output_tokens
server.address
error.type

推荐 Events#

request.sent
response.headers_received
stream.first_token
tool_call.completed
usage.received
stream.completed
stream.cancelled
stream.disconnected

不是每个系统都需要全部 Event。应根据诊断价值和数据量选择。


3.3 Tool Span#

Tool Span 表示一次逻辑工具操作。

推荐边界#

从参数校验和审批完成后开始,到逻辑操作:

  • 成功;
  • 最终失败;
  • 被取消;
  • 被 Fallback 替代。

如果内部发生多次尝试,Tool Span 作为外层逻辑操作,下面再创建 Attempt Span:

execute_tool run_tests
├── tool.attempt 1 [timeout]
├── retry.backoff
└── tool.attempt 2 [success]

推荐属性#

gen_ai.operation.name = execute_tool
gen_ai.tool.name
gen_ai.tool.call.id
app.agent.operation.id
app.agent.tool.schema.version
app.agent.tool.side_effect
app.agent.retry.count
app.agent.recovery.status

业务失败不等于调用错误#

例如:

pytest 正常退出,但存在失败用例

应记录为工具调用成功、业务 Outcome 失败,而不是 Provider Error。


3.4 Retrieval Span#

Retrieval Span 表示一次检索行为。

推荐边界#

从构造查询开始,到返回、过滤、重排完成。

复杂系统可以进一步拆分:

retrieval
├── query.rewrite
├── vector.search
├── keyword.search
├── merge
├── rerank
└── context.select

推荐 Shape 字段#

app.agent.retrieval.source
app.agent.retrieval.index.version
app.agent.retrieval.top_k
app.agent.retrieval.candidate_count
app.agent.retrieval.selected_count
app.agent.retrieval.query_hash

默认不记录原始查询和完整文档内容。检索内容可能包含用户数据、内部文档和商业机密。


3.5 Memory Span#

Memory Span 表示记忆的召回、过滤、注入、更新或删除。

推荐拆分:

retrieve_memory
memory.filter
memory.inject
update_memory
delete_memory

关键字段#

app.agent.memory.store
app.agent.memory.query_hash
app.agent.memory.candidate_count
app.agent.memory.filtered_count
app.agent.memory.injected_count
app.agent.memory.update_count

OpenTelemetry GenAI 属性说明中特别提醒,Memory Query 和 Memory Records 可能包含敏感信息,默认不应采集,除非明确 Opt-in。6

这也是为什么 Memory Span 应优先记录:

  • 数量;
  • ID 引用;
  • Hash;
  • 策略版本;
  • 过滤原因代码;

而不是默认把记忆原文复制到遥测系统。


3.6 MCP Span#

MCP 观测至少包含两层:

  1. Agent 侧的逻辑工具执行;
  2. MCP Client 到 MCP Server 的协议调用。

可能的结构:

execute_tool search_repository
└── mcp.client tools/call
└── mcp.server tools/call
└── internal repository search

但需要防止重复埋点。

如果上层 Agent Framework 已经创建了 execute_tool Span,而 MCP Instrumentation 又创建一个语义完全相同的 Tool Span,后端就会看到重复操作。当前 MCP 语义约定建议:如果外层已经存在对应的 GenAI Tool Execution Span,MCP 埋点应尽量补充 MCP 属性,而不是再生成一条重复逻辑 Span。10

MCP Trace Context#

MCP 请求的 _meta 可以携带:

{
"_meta": {
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01",
"tracestate": "...",
"baggage": "..."
}
}

Client 负责 Inject,Server 负责 Extract。3


3.7 Approval 与 Handoff Span#

Approval Span#

用于记录:

  • 等待人工审批;
  • 策略引擎决策;
  • 参数修改;
  • 拒绝或过期。

名称示例:

approval.wait
approval.evaluate

Handoff Span#

Handoff 表示任务或控制权从一个 Agent 转移到另一个 Agent。

建议记录:

app.agent.handoff.source
app.agent.handoff.target
app.agent.handoff.reason
app.agent.handoff.task_contract.version
app.agent.handoff.context_ref

Handoff 不只是一个函数调用,还涉及:

  • 交付了什么任务;
  • 传递了什么上下文;
  • 接收方是否接受;
  • 后续 Outcome 如何回挂到父任务。

4. Trace Context 传播#

Trace Context 传播路径图

Trace 的父子关系依赖 Context 传播。如果上下文传播错误,就会出现:

  • Span 全部变成独立根节点;
  • 不同用户的 Span 串到一起;
  • 并行工具互相成为父子;
  • 子 Agent 无法回挂;
  • MCP Client 和 Server Trace 断裂;
  • 后台任务继承了已经结束的错误上下文。

OpenTelemetry 的 Context Propagation 通过 Inject 和 Extract,在进程、服务和协议边界之间传递 Trace Context。11

需要区分两种上下文:

  1. OpenTelemetry Context:当前活动 Span、Baggage 等;
  2. Agent 领域上下文task_idrun_idstep_id、权限、预算等。

OpenTelemetry SDK 负责第一类;应用自己的 contextvarsAsyncLocalStorage 负责第二类。不要把 Span 对象塞进全局变量手动维护。


4.1 Python contextvars#

Python 的 contextvars 专门用于保存异步上下文局部状态,并原生支持 asyncio。每个线程维护自己的 Context Stack,ContextVar.set() 会返回 Token,随后应通过 reset() 恢复。12

下面定义一个不可变的 Agent 领域上下文:

from __future__ import annotations
from contextlib import contextmanager
from contextvars import ContextVar, Token
from dataclasses import dataclass
from typing import Iterator
@dataclass(frozen=True, slots=True)
class AgentExecutionContext:
session_id: str
task_id: str
run_id: str
turn_id: str | None = None
step_id: str | None = None
_AGENT_CONTEXT: ContextVar[AgentExecutionContext | None] = ContextVar(
"agent_execution_context",
default=None,
)
def current_agent_context() -> AgentExecutionContext:
value = _AGENT_CONTEXT.get()
if value is None:
raise RuntimeError("Agent execution context is not bound")
return value
@contextmanager
def bind_agent_context(
value: AgentExecutionContext,
) -> Iterator[AgentExecutionContext]:
token: Token[AgentExecutionContext | None] = _AGENT_CONTEXT.set(value)
try:
yield value
finally:
_AGENT_CONTEXT.reset(token)

使用:

async def handle_task(task: Task) -> Result:
execution_context = AgentExecutionContext(
session_id=task.session_id,
task_id=task.task_id,
run_id=task.run_id,
)
with bind_agent_context(execution_context):
return await run_agent(task)

为什么建议不可变对象#

asyncio.create_task() 会复制当前 Context,但如果 ContextVar 中保存的是可变字典,不同 Task 复制的仍可能是同一对象引用。并发修改会引发数据竞争。

因此更安全的方式是:

  • 使用不可变 dataclass
  • 每次 Step 创建新对象;
  • 不在共享 Context 中原地修改列表和字典。

例如:

from dataclasses import replace
base = current_agent_context()
step_context = replace(base, step_id="step_004")
with bind_agent_context(step_context):
await execute_step()

4.2 TypeScript AsyncLocalStorage#

Node.js 的 AsyncLocalStorage 可以在回调和 Promise 链中保持异步局部状态。Node 官方建议优先使用它,而不是直接基于底层 async_hooks 自己实现上下文系统,因为前者经过了性能和内存安全优化。13

import { AsyncLocalStorage } from "node:async_hooks";
type AgentExecutionContext = Readonly<{
sessionId: string;
taskId: string;
runId: string;
turnId?: string;
stepId?: string;
}>;
const agentContext = new AsyncLocalStorage<AgentExecutionContext>();
export function currentAgentContext(): AgentExecutionContext {
const value = agentContext.getStore();
if (!value) {
throw new Error("Agent execution context is not bound");
}
return value;
}
export async function withAgentContext<T>(
value: AgentExecutionContext,
fn: () => Promise<T>,
): Promise<T> {
return agentContext.run(value, fn);
}

使用:

await withAgentContext(
{
sessionId: task.sessionId,
taskId: task.taskId,
runId: task.runId,
},
async () => {
await runAgent(task);
},
);

Node 文档建议多数情况下优先使用 run(),而不是 enterWith()run() 的作用范围更明确,更不容易让后续不相关异步操作意外继承上下文。13

OpenTelemetry Node SDK 通常会使用基于 AsyncLocalStorage 的 Context Manager 来保存当前 Span。业务代码无需再自行把 Span 存进自己的 AsyncLocalStorage


4.3 Async Task#

Python asyncio.create_task() 默认会复制创建时的当前 Context。14

因此下面的创建位置非常重要:

with tracer.start_as_current_span("agent.step.execute_tools"):
async with asyncio.TaskGroup() as group:
group.create_task(execute_tool(call_a))
group.create_task(execute_tool(call_b))

两个 Tool Task 会继承 agent.step.execute_tools 作为活动父上下文。

而下面的写法容易产生错误:

coroutines = [
execute_tool(call_a),
execute_tool(call_b),
]
with tracer.start_as_current_span("agent.step.execute_tools"):
# 如果 Task 早已在其他位置创建,可能已经捕获错误上下文
await asyncio.gather(*coroutines)

关键原则是:

Task 应在正确的活动 Span 内创建,而不只是“在那个 Span 内等待”。

不要把并行执行误建成串行父子链#

错误:

tool A
└── tool B
└── tool C

如果三者实际并行,它们应该是同级兄弟:

execute_parallel_tools
├── tool A
├── tool B
└── tool C

完成顺序不应改变父子关系。


4.4 Thread 和子进程#

asyncio.to_thread#

Python 的 asyncio.to_thread() 会将当前 contextvars.Context 传播到工作线程。14

result = await asyncio.to_thread(blocking_file_scan, path)

如果 OpenTelemetry Python Context Manager 也基于 ContextVar,这通常能让活动 Trace Context 一起传播。

原始线程池#

使用原始 ThreadPoolExecutor.submit() 时,不应假设 Context 一定自动传播。可以显式复制:

from concurrent.futures import ThreadPoolExecutor
from contextvars import copy_context
pool = ThreadPoolExecutor(max_workers=4)
ctx = copy_context()
future = pool.submit(ctx.run, blocking_file_scan, path)
result = future.result()

子进程#

Context 不会自动跨进程传播。你需要显式 Inject:

from opentelemetry import propagate
carrier: dict[str, str] = {}
propagate.inject(carrier)
payload = {
"trace_context": carrier,
"task_id": current_agent_context().task_id,
"command": ["pytest", "-q"],
}

子进程或 Worker 收到后 Extract:

from opentelemetry import context as otel_context
from opentelemetry import propagate
extracted = propagate.extract(payload["trace_context"])
token = otel_context.attach(extracted)
try:
run_worker(payload)
finally:
otel_context.detach(token)

不要把完整 Prompt、Access Token 或敏感用户数据放进 Baggage。Baggage 会被传播到下游,传播范围通常比你想象得更广。


4.5 MCP Client 与 Server#

MCP 调用的标准传播过程如下:

Agent active Span
├── MCP Client: inject(traceparent, tracestate, baggage)
└── MCP request params._meta
MCP Server: extract
Server Span / Tool Span

Python 伪代码:

from opentelemetry import propagate
def inject_mcp_trace_context(params: dict) -> dict:
carrier: dict[str, str] = {}
propagate.inject(carrier)
meta = dict(params.get("_meta", {}))
for key in ("traceparent", "tracestate", "baggage"):
if key in carrier:
meta[key] = carrier[key]
return {
**params,
"_meta": meta,
}

Server:

from opentelemetry import context as otel_context
from opentelemetry import propagate
async def handle_mcp_request(request: dict) -> dict:
meta = request.get("params", {}).get("_meta", {})
carrier = {
key: meta[key]
for key in ("traceparent", "tracestate", "baggage")
if key in meta
}
extracted = propagate.extract(carrier)
token = otel_context.attach(extracted)
try:
return await dispatch_request(request)
finally:
otel_context.detach(token)

MCP 的两个常见错误#

错误一:没有 Extract 就创建 Server Span。
结果是 MCP Server 每次都产生新的根 Trace。

错误二:Agent Framework 和 MCP SDK 都生成相同 Tool Span。
结果是一次工具调用在 Trace 中出现两次。应明确哪一层负责逻辑 Tool Span,另一层只负责协议 Span或属性增强。


4.6 子 Agent 和后台任务#

同步子 Agent#

如果主 Agent 调用子 Agent,并等待结果,可以使用普通父子关系:

invoke_agent parent
└── invoke_agent child

脱离当前生命周期的后台任务#

如果后台任务:

  • 进入队列;
  • 稍后由其他 Worker 处理;
  • 可能在父任务结束后才执行;
  • 不适合被视为直接同步子调用;

可以创建新 Trace,并使用 Span Link 指向原始 Span。

from opentelemetry import trace
from opentelemetry.trace import Link
parent_span_context = trace.get_current_span().get_span_context()
# 将 SpanContext 序列化后的标准传播载体放入队列;
# Worker 创建新 Trace 时,通过 Link 保留逻辑来源。
links = [Link(parent_span_context)]

同时在任务消息中传递:

task_id
run_id
operation_id
trace_context
caused_by_event_id

需要注意:

  • Parent-child 表示明确生命周期隶属;
  • Link 表示引用、因果、批处理或汇聚关系;
  • 不要为了保持“一条大 Trace”无限延长跨小时、跨天的后台工作流;
  • 长生命周期任务更适合使用业务 Run ID 串联多个 Trace Segment。

5. 流式模型调用如何记录#

流式模型调用观测时序图

流式调用是 Agent 观测中最容易被错误实现的部分。一个流式请求不是简单的:

开始 → 结束

而是:

Request Start
→ Connection / Headers
→ First Byte
→ First Token
→ Text Deltas
→ Tool Call Deltas
→ Finish Reason
→ Usage
→ Stream Close

不同 Provider 的事件协议不同,但观测模型应该统一。


5.1 Request Start#

Model Span 应在客户端真正开始请求时创建,而不是在 Prompt 组装时创建。

with tracer.start_as_current_span(
"chat model-x",
kind=SpanKind.CLIENT,
attributes={
"gen_ai.operation.name": "chat",
"gen_ai.provider.name": "provider-x",
"gen_ai.request.model": "model-x",
"server.address": "api.provider.example",
},
):
stream = await client.create_stream(request)

如果把上下文组装也包进 Model Span,就无法区分:

  • 本地 Prompt 构造慢;
  • 网络建立慢;
  • Provider 排队慢;
  • 模型生成慢。

5.2 Time to First Token#

Time to First Token,简称 TTFT,表示从请求开始到收到第一个可展示 Token 的时间。

它通常包含:

  • 本地序列化;
  • 网络连接;
  • Provider 排队;
  • 输入处理;
  • 首 Token 生成;
  • 首个数据帧传输。

记录 TTFT 时,应先定义“第一个 Token”的口径:

  • 第一个流式事件;
  • 第一个文本 Delta;
  • 第一个非空文本 Token;
  • 第一个 Tool Call Delta。

推荐同时区分:

time_to_first_event
time_to_first_text_token
time_to_first_tool_delta

因为 Tool-use Agent 可能不输出任何文本,而是直接生成 Tool Call。

started_ns = time.perf_counter_ns()
first_event_seen = False
first_text_seen = False
async for frame in stream:
now_ns = time.perf_counter_ns()
if not first_event_seen:
first_event_seen = True
span.add_event(
"stream.first_event",
{
"app.agent.stream.time_to_first_event_ms":
(now_ns - started_ns) / 1_000_000,
},
)
if frame.type == "text_delta" and frame.text and not first_text_seen:
first_text_seen = True
ttft_ms = (now_ns - started_ns) / 1_000_000
span.set_attribute("app.agent.stream.ttft_ms", ttft_ms)
span.add_event("stream.first_text_token")

TTFT 既应该作为单条 Trace 属性,也应该写入 Histogram Metric,用于观察整体分布。


5.3 Streaming Chunk#

不要为每个 Chunk 创建 Span。

假设每次模型调用返回 500 个 Chunk,一天 100 万次调用,就会制造 5 亿个 Span 或 Event,成本和噪声都不可接受。

更合理的方式是:

  • 聚合 Chunk 数量;
  • 记录首个 Event;
  • 记录工具调用完成;
  • 记录异常 Chunk;
  • 必要时对少量 Trace 开启详细 Chunk 调试;
  • 原始流写入短期受控 Artifact,而不是全量 Trace 属性。

建议字段:

app.agent.stream.chunk_count
app.agent.stream.text_delta_count
app.agent.stream.tool_delta_count
app.agent.stream.output_bytes
app.agent.stream.empty_chunk_count

需要定位协议问题时,可以记录有限 Event:

stream.first_event
stream.first_text_token
stream.tool_call_started
stream.tool_call_completed
stream.usage_received
stream.completed

5.4 增量 Tool Call 解析#

多个 Tool Call 的参数 Delta 可能交错到达,因此不能只维护一个全局字符串 Buffer。

需要按:

  • Tool Call ID;
  • Tool Call Index;
  • 工具名称;
  • 当前解析状态;

分别聚合。

from dataclasses import dataclass, field
import json
@dataclass
class ToolCallBuffer:
tool_name: str | None = None
fragments: list[str] = field(default_factory=list)
def append(self, fragment: str) -> None:
self.fragments.append(fragment)
def finalize(self) -> dict:
payload = "".join(self.fragments)
value = json.loads(payload)
if not isinstance(value, dict):
raise ValueError("Tool arguments must be a JSON object")
return value

管理多个调用:

buffers: dict[str, ToolCallBuffer] = {}
async for frame in stream:
if frame.type == "tool_call_delta":
buffer = buffers.setdefault(
frame.tool_call_id,
ToolCallBuffer(tool_name=frame.tool_name),
)
buffer.append(frame.arguments_delta)
elif frame.type == "tool_call_done":
arguments = buffers[frame.tool_call_id].finalize()
validate_against_tool_schema(
tool_name=buffers[frame.tool_call_id].tool_name,
arguments=arguments,
)

何时可以执行工具#

正常情况下,应等待:

  1. Tool Call 完成;
  2. JSON 完整;
  3. Schema 校验通过;
  4. 审批完成;
  5. 幂等信息生成;

然后才执行。

不要因为已经收到一部分参数就提前执行,除非协议明确支持可恢复的增量执行,并且工具本身具备严格事务语义。


5.5 Usage 延迟返回#

有些 Provider 会在最后一个 Usage 事件中才返回 Token 用量;也有 Provider 在流结束后才能读取 Usage。

正确处理方式:

  • Span 在 Usage 到达前不要过早结束;
  • 如果协议明确保证 Usage 作为终止事件的一部分,就等待该事件;
  • 如果 Usage 可能永远缺失,设置合理的终止边界;
  • Usage 缺失时记录 usage_available=false
  • 不用字符数冒充精确 Token 数。

如果 Span 已结束,OpenTelemetry Span 通常不应再被修改。此时可以:

  • 产生一条带 trace_id / span_id 的 Log;
  • 更新业务侧 Run Record;
  • 通过 Artifact 或数据仓库补充;
  • 不应创建一条伪造的第二个模型调用 Span。

5.6 Cancel、Timeout 与 Stream Disconnect#

这三者必须区分。

Cancel#

用户主动停止、上游任务取消或应用关闭连接。

app.agent.stream.termination = client_cancel

主动取消通常不代表 Provider 故障。可以保留 Span 的正常或未设置状态,并通过终止类型表达。

Timeout#

客户端等待超过配置阈值。

error.type = timeout
app.agent.stream.termination = timeout

Timeout 通常是错误,应记录 Timeout 阶段:

connect_timeout
first_token_timeout
idle_timeout
overall_timeout

Stream Disconnect#

连接在正常 Finish Event 前意外断开。

error.type = stream_disconnected
app.agent.stream.termination = disconnect
app.agent.stream.partial_output = true

还应该记录:

  • 已收到多少 Chunk;
  • 是否已收到完整 Tool Call;
  • 是否可能安全重试;
  • 是否存在部分外部副作用;
  • 重试时是否会重复生成 Tool Call。

一个完整的流式观测函数#

from __future__ import annotations
import asyncio
import time
from collections.abc import AsyncIterator
from typing import Any
from opentelemetry import metrics, trace
from opentelemetry.trace import SpanKind, Status, StatusCode
tracer = trace.get_tracer("example.agent")
meter = metrics.get_meter("example.agent")
ttft_histogram = meter.create_histogram(
"app.agent.stream.ttft",
unit="ms",
description="Time from model request start to first text token",
)
async def consume_model_stream(
*,
client: Any,
request: Any,
provider_name: str,
model_name: str,
) -> tuple[str, list[dict[str, Any]], dict[str, int] | None]:
attributes = {
"gen_ai.operation.name": "chat",
"gen_ai.provider.name": provider_name,
"gen_ai.request.model": model_name,
}
with tracer.start_as_current_span(
f"chat {model_name}",
kind=SpanKind.CLIENT,
attributes=attributes,
) as span:
started_ns = time.perf_counter_ns()
first_event_seen = False
first_text_seen = False
chunk_count = 0
text_delta_count = 0
tool_delta_count = 0
output_parts: list[str] = []
tool_buffers: dict[str, ToolCallBuffer] = {}
completed_tool_calls: list[dict[str, Any]] = []
usage: dict[str, int] | None = None
try:
async for frame in client.stream(request):
chunk_count += 1
now_ns = time.perf_counter_ns()
if not first_event_seen:
first_event_seen = True
span.add_event(
"stream.first_event",
{
"app.agent.stream.time_to_first_event_ms":
(now_ns - started_ns) / 1_000_000,
},
)
if frame.type == "text_delta":
text_delta_count += 1
output_parts.append(frame.text)
if frame.text and not first_text_seen:
first_text_seen = True
ttft_ms = (now_ns - started_ns) / 1_000_000
span.set_attribute("app.agent.stream.ttft_ms", ttft_ms)
span.add_event("stream.first_text_token")
ttft_histogram.record(
ttft_ms,
{
"gen_ai.provider.name": provider_name,
"gen_ai.request.model": model_name,
},
)
elif frame.type == "tool_call_delta":
tool_delta_count += 1
buffer = tool_buffers.setdefault(
frame.tool_call_id,
ToolCallBuffer(tool_name=frame.tool_name),
)
buffer.append(frame.arguments_delta)
elif frame.type == "tool_call_done":
buffer = tool_buffers[frame.tool_call_id]
arguments = buffer.finalize()
completed_tool_calls.append(
{
"id": frame.tool_call_id,
"name": buffer.tool_name,
"arguments": arguments,
}
)
span.add_event(
"tool_call.completed",
{
"gen_ai.tool.call.id": frame.tool_call_id,
"gen_ai.tool.name": buffer.tool_name or "unknown",
"app.agent.tool.arguments.valid": True,
},
)
elif frame.type == "usage":
usage = {
"input_tokens": frame.input_tokens,
"output_tokens": frame.output_tokens,
}
span.add_event("stream.usage_received")
elif frame.type == "done":
span.set_attribute(
"gen_ai.response.finish_reasons",
[frame.finish_reason],
)
span.set_attribute("app.agent.stream.chunk_count", chunk_count)
span.set_attribute(
"app.agent.stream.text_delta_count",
text_delta_count,
)
span.set_attribute(
"app.agent.stream.tool_delta_count",
tool_delta_count,
)
span.set_attribute("app.agent.stream.termination", "completed")
if usage is not None:
span.set_attribute(
"gen_ai.usage.input_tokens",
usage["input_tokens"],
)
span.set_attribute(
"gen_ai.usage.output_tokens",
usage["output_tokens"],
)
span.set_attribute("app.agent.usage.available", True)
else:
span.set_attribute("app.agent.usage.available", False)
return "".join(output_parts), completed_tool_calls, usage
except asyncio.CancelledError:
span.set_attribute(
"app.agent.stream.termination",
"client_cancel",
)
span.add_event("stream.cancelled")
raise
except asyncio.TimeoutError:
span.set_attribute("error.type", "timeout")
span.set_attribute(
"app.agent.stream.termination",
"timeout",
)
span.set_status(Status(StatusCode.ERROR))
span.add_event("stream.timeout")
raise
except Exception as exc:
span.set_attribute("error.type", type(exc).__name__)
span.set_attribute(
"app.agent.stream.termination",
"disconnect_or_protocol_error",
)
span.set_status(Status(StatusCode.ERROR))
span.record_exception(exc)
raise

生产环境中,record_exception() 可能捕获异常消息和 Stack Trace。若异常文本可能包含 Prompt、Tool Arguments 或凭证,应在进入遥测管道前做专门的异常脱敏。


6. 并行与重试#

并行 Tool Call 与 Retry Attempt 建模图

6.1 并行 Tool Call 的父子关系#

模型一次返回多个 Tool Call:

[
{"name": "read_file", "arguments": {"path": "a.py"}},
{"name": "read_file", "arguments": {"path": "b.py"}},
{"name": "search_code", "arguments": {"query": "discount"}}
]

这三个调用应是同一个 Step 下的兄弟 Span。

agent.step.execute_tools
├── execute_tool read_file
├── execute_tool read_file
└── execute_tool search_code

Python:

import asyncio
from typing import Any
from opentelemetry import trace
tracer = trace.get_tracer("example.agent")
async def execute_tools_in_parallel(
calls: list[ToolCall],
runtime: ToolRuntime,
) -> list[Any]:
results: list[Any] = [None] * len(calls)
async def run_one(index: int, call: ToolCall) -> None:
results[index] = await execute_tool_observed(
call=call,
runtime=runtime,
)
with tracer.start_as_current_span(
"agent.step.execute_tools",
attributes={
"app.agent.tool_call.count": len(calls),
"app.agent.execution.parallel": True,
},
):
async with asyncio.TaskGroup() as group:
for index, call in enumerate(calls):
group.create_task(run_one(index, call))
return results

TaskGroup 内创建的 Task 会继承创建时的 Context。不要根据工具完成顺序决定父子关系。

Fan-in 后的下一次模型调用#

下一轮模型请求依赖所有工具结果。可以:

  • 将下一轮模型 Span 设为 agent.step.execute_tools 的后继子 Span;
  • 对具体工具 Span 添加 Links,表达它依赖多个结果。

Links 必须在 Span 创建时提供,因此应在工具调用结束后保存它们的 SpanContext


6.2 一个逻辑操作与多个 Attempt#

一次逻辑工具操作可能经历:

第 1 次调用 → 429
等待 1 秒
第 2 次调用 → 网络断开
等待 2 秒
第 3 次调用 → 成功

如果每次重试都生成完全独立的 Tool Span,就很难看出它们属于同一个逻辑操作。

推荐两层结构:

execute_tool remote_search ← 逻辑操作
├── tool.attempt 1 [429]
├── tool.attempt 2 [disconnect]
└── tool.attempt 3 [success]

稳定字段:

app.agent.operation.id = op_123

每次尝试字段:

app.agent.attempt.id = att_001
app.agent.attempt.number = 1

外层 Tool Span 记录最终 Outcome 和总耗时;Attempt Span 记录每次真实网络或工具执行。


6.3 429、5xx 和网络中断#

错误类型必须是稳定、可聚合的类别,不能直接把完整异常消息作为 error.type

推荐:

rate_limit
provider_5xx
connect_timeout
read_timeout
stream_disconnected
dns_error
tls_error
connection_reset
tool_process_crashed

完整异常消息可以存在受控 Log 或 Artifact 中,但 Metric Label 只使用稳定类别。

重试策略不能只看状态码#

是否可重试还取决于:

  • 操作是否幂等;
  • 是否已经产生副作用;
  • 是否收到部分响应;
  • Tool Call 是否已经执行;
  • Provider 是否支持 Idempotency Key;
  • 当前预算是否允许;
  • 用户是否已经取消任务。

例如:

  • 模型请求在首 Token 前 503:通常较安全地重试;
  • 模型已生成完整支付 Tool Call,但客户端在收到确认前断流:不能盲目再次执行支付;
  • 只读检索工具超时:通常可重试;
  • 发送邮件接口返回不确定状态:应先查询发送状态,而不是直接重发。

6.4 Retry Backoff 和 Fallback#

重试等待时间应作为 Event 记录:

operation_span.add_event(
"retry.scheduled",
{
"app.agent.attempt.number": attempt_number,
"app.agent.retry.delay_ms": delay_ms,
"app.agent.retry.reason": error_category,
"app.agent.retry.strategy": "exponential_jitter",
},
)

推荐退避公式:

delay = min(max_delay, base_delay × 2^(attempt-1)) + jitter

Fallback 也应显式记录:

app.agent.fallback.type = model
app.agent.fallback.from = model-a
app.agent.fallback.to = model-b
app.agent.fallback.reason = rate_limit

不要把 Fallback 后的成功当作“原模型成功”。

带 Attempt 的工具执行示例#

from __future__ import annotations
import asyncio
import random
import uuid
from typing import Any, Callable, Awaitable
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode
tracer = trace.get_tracer("example.agent")
class RetryableToolError(Exception):
def __init__(self, category: str, message: str = "") -> None:
super().__init__(message)
self.category = category
async def execute_with_retry(
*,
operation_name: str,
tool_name: str,
action: Callable[[], Awaitable[Any]],
max_attempts: int = 3,
base_delay_seconds: float = 0.5,
max_delay_seconds: float = 8.0,
) -> Any:
operation_id = f"op_{uuid.uuid4().hex}"
with tracer.start_as_current_span(
operation_name,
attributes={
"gen_ai.operation.name": "execute_tool",
"gen_ai.tool.name": tool_name,
"app.agent.operation.id": operation_id,
},
) as operation_span:
last_error: Exception | None = None
for attempt_number in range(1, max_attempts + 1):
attempt_id = f"att_{uuid.uuid4().hex}"
with tracer.start_as_current_span(
f"{tool_name}.attempt",
attributes={
"app.agent.operation.id": operation_id,
"app.agent.attempt.id": attempt_id,
"app.agent.attempt.number": attempt_number,
},
) as attempt_span:
try:
result = await action()
attempt_span.set_attribute(
"app.agent.attempt.outcome",
"success",
)
operation_span.set_attribute(
"app.agent.retry.count",
attempt_number - 1,
)
if attempt_number > 1:
operation_span.set_attribute(
"app.agent.recovery.status",
"recovered",
)
else:
operation_span.set_attribute(
"app.agent.recovery.status",
"not_needed",
)
return result
except RetryableToolError as exc:
last_error = exc
attempt_span.set_attribute("error.type", exc.category)
attempt_span.set_attribute(
"app.agent.attempt.outcome",
"retryable_error",
)
attempt_span.set_status(Status(StatusCode.ERROR))
attempt_span.record_exception(exc)
if attempt_number == max_attempts:
break
exponential = base_delay_seconds * (
2 ** (attempt_number - 1)
)
delay = min(max_delay_seconds, exponential)
delay += random.uniform(0, delay * 0.2)
operation_span.add_event(
"retry.scheduled",
{
"app.agent.attempt.number": attempt_number,
"app.agent.retry.delay_ms": delay * 1000,
"app.agent.retry.reason": exc.category,
"app.agent.retry.strategy":
"exponential_backoff_with_jitter",
},
)
await asyncio.sleep(delay)
except Exception as exc:
last_error = exc
attempt_span.set_attribute(
"error.type",
type(exc).__name__,
)
attempt_span.set_attribute(
"app.agent.attempt.outcome",
"non_retryable_error",
)
attempt_span.set_status(Status(StatusCode.ERROR))
attempt_span.record_exception(exc)
break
operation_span.set_attribute(
"app.agent.recovery.status",
"failed",
)
operation_span.set_attribute(
"app.agent.retry.count",
max_attempts - 1,
)
operation_span.set_attribute(
"error.type",
type(last_error).__name__ if last_error else "unknown",
)
operation_span.set_status(Status(StatusCode.ERROR))
if last_error is not None:
raise last_error
raise RuntimeError("Tool execution failed without an error")

真实系统还需要将:

  • HTTP 状态码;
  • Provider 错误码;
  • 幂等性;
  • Partial Result;
  • Timeout 阶段;
  • Circuit Breaker;

纳入重试判断。


6.5 最终成功不能覆盖中间失败证据#

如果第 3 次 Attempt 成功:

  • 外层逻辑 Tool Span 可以是成功;
  • 第 1、2 次 Attempt Span 仍然应保持 ERROR
  • 外层记录 retry.count=2
  • 外层记录 recovery.status=recovered
  • Trace 应能被 Tail Sampling 策略保留。

错误做法:

最终成功 → 把前两次失败 Span 改成 OK

这会让系统无法发现:

  • Provider 近期是否频繁限流;
  • 网络是否不稳定;
  • 成本是否因重试放大;
  • 恢复能力是否真正发挥作用。

恢复成功和从未失败不是同一件事。


7. Collector 和导出链路#

Collector 导出链路与隐私治理图

推荐的生产链路是:

Agent Process
│ OTLP
Local / Sidecar Collector
Gateway Collector
├── Tail Sampling
├── Redaction
├── Routing
└── Multi-backend Export

应用进程负责:

  • 创建 Span;
  • 传播 Context;
  • 基础脱敏;
  • 批量导出;
  • 不阻塞主业务。

Collector 负责:

  • 接收 OTLP;
  • 二次脱敏;
  • 队列与重试;
  • Tail Sampling;
  • 路由到多个后端;
  • 统一治理。

Collector 中定义了 Processor 并不代表它会自动生效,必须将它加入 service.pipelines15


7.1 Batch#

Batch 的作用是将多个遥测对象聚合后再导出,从而减少:

  • 网络连接次数;
  • 请求头开销;
  • 后端写入次数;
  • 压缩损耗。

OpenTelemetry Collector 的 Batch Processor 通常应放在:

memory_limiter
sampling / filtering / redaction
batch
exporter

的后部。官方文档也建议将 Batch 放在 memory_limiter 和采样 Processor 之后。16

Batch 关注的是“如何成批发送”,不是“后端不可用时能缓存多久”。

典型配置:

processors:
batch:
timeout: 1s
send_batch_size: 1024
send_batch_max_size: 2048

参数需要按:

  • Span 平均大小;
  • 请求延迟目标;
  • Collector 内存;
  • 后端批量写入限制;

进行压测,而不是直接复制默认值。


7.2 Queue 与 Backpressure#

Sending Queue 位于 Exporter 侧,用于吸收后端短时抖动。

Processor → Sending Queue → Export Worker → Backend

需要关注:

  • Queue 是否有界;
  • 满时是丢弃还是阻塞;
  • Queue 单位是请求、批次还是字节;
  • 是否启用持久化;
  • 消费者并发数;
  • 后端恢复后的排空速度。

Exporter Helper 默认提供重试和 Sending Queue 能力;如果队列无法入队,数据可能被丢弃,因此必须监控对应失败指标。17

Agent 不应使用无界内存队列#

如果后端宕机数小时,无界队列会让 Agent 或 Collector 内存持续增长,最后把业务本身拖垮。

正确优先级通常是:

  1. Agent 任务继续执行;
  2. 保留必要的本地审计记录;
  3. 遥测队列有界;
  4. 超过容量后按策略丢弃非关键遥测;
  5. 对丢弃本身产生 Collector Internal Metric。

7.3 Retry#

Exporter Retry 与 Agent Tool Retry 是两套完全不同的重试。

Agent Tool Retry#

重试的是业务操作:

模型请求
工具调用
MCP 调用
外部 API

Exporter Retry#

重试的是遥测数据导出:

OTLP Export → Observability Backend

不能把二者混为一谈。

推荐 Exporter 配置:

exporters:
otlphttp/backend:
endpoint: ${env:OTEL_BACKEND_ENDPOINT}
headers:
Authorization: "Bearer ${env:OTEL_BACKEND_TOKEN}"
sending_queue:
enabled: true
queue_size: 10000
num_consumers: 8
retry_on_failure:
enabled: true
initial_interval: 1s
max_interval: 30s
max_elapsed_time: 5m

官方 Exporter Helper 的重试默认使用指数退避,并提供最大间隔和最大持续时间等配置。17


7.4 Tail Sampling#

Head Sampling 在 Trace 开始时决定保留还是丢弃;但此时你还不知道:

  • 最终是否失败;
  • 是否发生重试;
  • 是否高延迟;
  • 是否高成本;
  • 是否产生危险副作用;
  • 是否被人工接管。

Agent 特别适合使用 Tail Sampling,因为采样决策可以在看到 Trace 结果后作出。

推荐保留策略:

  • 所有 ERROR Trace;
  • 所有恢复后成功 Trace;
  • 所有高延迟 Trace;
  • 所有高 Token / 高费用 Trace;
  • 所有审批拒绝或人工接管 Trace;
  • 所有不可逆副作用 Trace;
  • 正常 Trace 随机保留少量基线样本。

Tail Sampling 的两个关键约束#

第一,完整 Trace 必须路由到同一个 Sampling Collector 实例。

如果同一 trace_id 的 Span 被分散到多个 Gateway,每个实例都只能看到部分链路,采样决策会错误。Agent-to-Gateway 部署通常需要按 Trace ID 做一致路由。18

第二,长 Agent Trace 容易产生 Late Span。

Tail Sampling 需要在内存中等待 decision_wait。如果:

  • Agent 运行时间长;
  • 子 Agent 延迟返回;
  • 后台 Span 晚到;
  • num_traces 太小;

Trace 可能在采样决策前被过早移出,或者晚到 Span 得到不同决策。官方 Tail Sampling 文档要求重点监控 sampling_trace_dropped_too_early 和 Late Span 等指标。19

因此,不能机械设置:

decision_wait: 10s

然后假设它适合所有 Agent。应根据实际 Trace 时长分布和网关内存进行调优。


7.5 短生命周期 CLI 的强制 Flush#

Coding Agent、命令行工具和一次性 Worker 经常在任务完成后立即退出。如果使用 BatchSpanProcessor,进程退出前未导出的 Span 可能丢失。

Python:

from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
def flush_and_shutdown(timeout_millis: int = 5000) -> None:
provider = trace.get_tracer_provider()
if not isinstance(provider, TracerProvider):
return
flushed = provider.force_flush(
timeout_millis=timeout_millis,
)
if not flushed:
# 写入本地 stderr 或应用日志,不要递归发送 OTel Log。
print("OpenTelemetry force_flush timed out")
provider.shutdown()

force_flush() 会尝试导出已经结束、但尚未导出的 Span,并返回是否在超时内完成。20

什么时候调用#

  • CLI 正常结束;
  • 收到 SIGTERM;
  • Worker 结束任务准备退出;
  • Serverless Invocation 收尾;
  • 测试进程结束。

不要在每个 Span 结束时调用 force_flush(),否则会破坏批处理并显著增加延迟。


7.6 观测后端不可用时的 Fail-open#

Agent 的主任务不应因为观测后端不可用而失败。

如果 Langfuse、Tempo、Jaeger、Phoenix 或其他后端暂时不可用,正常的代码修复任务仍应继续。

Fail-open 的实现原则#

  1. 使用 BatchSpanProcessor,而不是同步逐 Span 导出;
  2. 应用只发送到本地 Collector;
  3. Collector 使用有界 Queue;
  4. Exporter 异步重试;
  5. 队列满时允许丢弃非关键遥测;
  6. 对丢弃率、Queue Length 和 Export Failure 建立内部告警;
  7. 禁止遥测异常向上冒泡影响 Agent Loop。

但审计不能完全依赖 Best-effort Trace#

对于支付、删除生产数据、部署和发送消息等不可逆动作,可能存在强审计要求。此时应单独维护:

  • 本地 Append-only Audit Log;
  • 事务内 Outbox;
  • 业务数据库审计表;
  • 不可抵赖操作记录。

OpenTelemetry Trace 仍用于诊断,但不应是唯一合规凭证。


Collector 完整示例#

下面是一份 Gateway Collector 的参考配置。实际字段应根据使用的 Collector Distribution 和版本验证。

extensions:
file_storage:
directory: /var/lib/otelcol/file_storage
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
memory_limiter:
check_interval: 1s
limit_mib: 512
spike_limit_mib: 128
# 第二道内容治理防线。
# 第一层脱敏仍应在应用进程内完成。
transform/redact:
error_mode: ignore
trace_statements:
- context: span
statements:
- delete_key(attributes, "gen_ai.input.messages")
- delete_key(attributes, "gen_ai.output.messages")
- delete_key(attributes, "gen_ai.tool.call.arguments")
- delete_key(attributes, "gen_ai.tool.call.result")
- delete_key(attributes, "app.agent.input.raw")
- delete_key(attributes, "app.agent.output.raw")
tail_sampling:
decision_wait: 30s
num_traces: 50000
policies:
- name: errors
type: status_code
status_code:
status_codes: [ERROR]
- name: slow-agent-runs
type: latency
latency:
threshold_ms: 15000
- name: recovered-after-retry
type: string_attribute
string_attribute:
key: app.agent.recovery.status
values: [recovered]
- name: baseline
type: probabilistic
probabilistic:
sampling_percentage: 5
batch:
timeout: 1s
send_batch_size: 1024
send_batch_max_size: 2048
exporters:
otlphttp/backend:
endpoint: ${env:OTEL_BACKEND_ENDPOINT}
headers:
Authorization: "Bearer ${env:OTEL_BACKEND_TOKEN}"
sending_queue:
enabled: true
storage: file_storage
queue_size: 10000
num_consumers: 8
retry_on_failure:
enabled: true
initial_interval: 1s
max_interval: 30s
max_elapsed_time: 5m
service:
extensions: [file_storage]
pipelines:
traces:
receivers: [otlp]
processors:
[
memory_limiter,
transform/redact,
tail_sampling,
batch,
]
exporters: [otlphttp/backend]

生产部署中的进一步拆分#

本地 Agent Collector:

memory_limiter → batch → gateway

Gateway Collector:

memory_limiter → redaction → tail_sampling → batch → backends

Tail Sampling 不建议在每个 Sidecar 上独立执行,否则同一 Trace 的跨服务 Span 可能无法被统一决策。


8. 隐私和高基数字段治理#

8.1 默认记录 Shape,不默认记录完整 Content#

Agent 可观测性经常需要处理最敏感的数据:

  • 用户 Prompt;
  • 代码;
  • 文件内容;
  • 数据库结果;
  • Tool Arguments;
  • Tool Result;
  • 检索文档;
  • 长期记忆;
  • 浏览器页面;
  • API 凭证;
  • 邮件和消息内容。

“为了调试方便全部记录”不是可接受的默认策略。

Shape 与 Content#

对象默认 Shape可选 Content
Prompt消息数、字符数、Token、Hash、模板版本完整消息
Tool Arguments字节数、字段数、Schema 版本、校验结果完整参数
Tool Result状态、字节数、行数、MIME、Hash完整返回
Retrievaltop-k、候选数、索引版本Query 和文档
Memory候选数、注入数、策略版本记忆原文
File路径类别、大小、Hash、Diff 统计完整代码
BrowserURL Origin、DOM 节点数、截图引用完整页面

内容引用优于内容复制#

推荐:

{
"app.agent.tool.result.byte_count": 38420,
"app.agent.tool.result.content_hash": "sha256:...",
"app.agent.tool.result.artifact_ref": "artifact://run-123/tool-result-9"
}

而不是将 38 KB Tool Result 直接写进 Span Attribute。

Artifact Store 可以具备:

  • 加密;
  • 更短保留周期;
  • 独立访问控制;
  • 访问审计;
  • 按需删除;
  • 大对象存储优化。

8.2 Prompt、工具参数和工具结果脱敏#

脱敏最好分为三层。

第一层:应用内脱敏#

在数据进入 OpenTelemetry SDK 前处理:

SENSITIVE_KEYS = {
"authorization",
"api_key",
"access_token",
"refresh_token",
"password",
"cookie",
"secret",
}
def redact_mapping(value: object) -> object:
if isinstance(value, dict):
result: dict[str, object] = {}
for key, item in value.items():
if key.lower() in SENSITIVE_KEYS:
result[key] = "[REDACTED]"
else:
result[key] = redact_mapping(item)
return result
if isinstance(value, list):
return [redact_mapping(item) for item in value]
return value

实际系统还需要:

  • PII 识别;
  • 正则与结构化字段组合;
  • 代码 Secret Scanner;
  • SQL / URL 参数清洗;
  • 大小限制;
  • Allowlist 优先。

第二层:Collector 过滤#

Collector 删除任何不应离开环境的字段。即使某个应用漏做脱敏,Collector 仍能提供第二道防线。

第三层:后端治理#

  • RBAC;
  • 多租户隔离;
  • 内容访问审计;
  • 数据保留周期;
  • 删除请求;
  • 导出限制;
  • Production 与 Development 项目隔离。

不要把脱敏只放在 UI#

如果只是前端页面把字段隐藏,原始敏感内容仍已:

  • 进入网络;
  • 写入存储;
  • 被索引;
  • 出现在备份;
  • 可能被管理员 API 查询。

真正的脱敏必须发生在采集和存储前。


8.3 Trace 字段与 Metric Label 的边界#

Trace Attribute 和 Metric Attribute 虽然都使用键值对,但用途不同。

适合进入 Metric 的字段#

应是低基数、稳定枚举:

service.name
service.version
deployment.environment.name
gen_ai.provider.name
gen_ai.request.model
gen_ai.operation.name
app.agent.task.type
app.agent.tool.category
app.agent.tool.side_effect
app.agent.outcome.status
app.agent.recovery.status
error.type

其中 tool.name 是否适合,要看工具集合是否有界。若 MCP Server 可以动态创建任意工具名,更安全的是使用 tool.category 或受控映射。

只适合 Trace / Log / Artifact 的字段#

session_id
task_id
run_id
trial_id
turn_id
step_id
trace_id
span_id
tool_call_id
artifact_id
user_id
原始文件路径
完整 URL
Prompt
Tool Arguments
Tool Result
异常消息

这些字段可能高基数或敏感。

OpenTelemetry Metric SDK 会为每一种 Attribute 组合维护聚合状态。用户 ID 和原始 URL Path 等高基数字段可能造成无界内存增长。当前 OpenTelemetry Metric 文档描述的默认 Cardinality Limit 为每个 Metric Stream 2000 个唯一组合;超过后会聚合进 otel.metric.overflow=true 数据点,这会导致按原属性分组的查询失真。21

一个容易被忽略的组合爆炸#

即使单个字段看起来不多,组合也会快速膨胀:

20 个模型
× 50 个工具
× 30 个错误类型
× 10 个 Agent 版本
× 4 个环境
= 1,200,000 种组合

所以 Metric 设计不能只看单字段基数,还要看属性组合。


8.4 为什么用户 ID、Prompt 不能直接作为指标维度#

用户 ID#

假设有 1000 万用户,每个用户 ID 都成为 Metric Label:

agent_task_total{user_id="..."}

Metric 后端需要为大量用户分别维护时间序列。即使单用户请求很少,聚合状态和索引成本仍会持续膨胀。

如果要分析用户级问题,应:

  • 使用 Trace 搜索;
  • 使用受控数据仓库;
  • 使用抽样;
  • 使用租户级或用户分群级 Metric;
  • 不在实时 Metric 中逐用户展开。

Prompt#

Prompt 不仅高基数,还可能非常长和敏感。将它作为 Label 会造成:

  • 每条请求几乎一条新时间序列;
  • 巨大网络和存储成本;
  • 用户数据泄漏;
  • 后端查询不可用;
  • Dashboard 完全无法聚合。

正确方式是:

app.agent.prompt.version = planner_v7
app.agent.prompt.template_id = code_fix
app.agent.prompt.char_count = 3280
app.agent.prompt.content_hash = sha256:...

其中:

  • prompt.versiontemplate_id 可以进入 Metric;
  • char_count 作为测量值或 Trace 属性;
  • content_hash 只留在 Trace;
  • Prompt 原文保存到受控 Artifact,默认关闭。

9. 一套可落地的 Agent 埋点骨架#

前面分别介绍了各部分,下面把它们组合成一个简化的 Agent Loop。

from __future__ import annotations
import asyncio
import uuid
from dataclasses import dataclass, replace
from typing import Any
from opentelemetry import trace
from opentelemetry.trace import SpanKind, Status, StatusCode
tracer = trace.get_tracer("example.coding_agent")
@dataclass(frozen=True, slots=True)
class Task:
session_id: str
task_id: str
run_id: str
goal: str
@dataclass(frozen=True, slots=True)
class AgentState:
step_number: int
messages: tuple[dict[str, Any], ...]
completed: bool = False
stop_reason: str | None = None
async def run_agent(task: Task) -> AgentState:
domain_context = AgentExecutionContext(
session_id=task.session_id,
task_id=task.task_id,
run_id=task.run_id,
)
root_attributes = {
"gen_ai.operation.name": "invoke_agent",
"gen_ai.agent.name": "coding-agent",
"app.agent.telemetry.schema.version": "1.0.0",
"app.agent.task.type": "code_fix",
"app.agent.run.id": task.run_id,
}
with bind_agent_context(domain_context):
with tracer.start_as_current_span(
"invoke_agent coding-agent",
kind=SpanKind.INTERNAL,
attributes=root_attributes,
) as root_span:
state = AgentState(
step_number=0,
messages=(
{
"role": "user",
"content": task.goal,
},
),
)
try:
while not state.completed:
if state.step_number >= 20:
state = replace(
state,
completed=True,
stop_reason="max_steps_reached",
)
break
step_id = f"step_{state.step_number + 1:03d}"
step_context = replace(
current_agent_context(),
step_id=step_id,
)
with bind_agent_context(step_context):
state = await run_agent_step(state)
with tracer.start_as_current_span(
"outcome.verify",
attributes={
"app.agent.stop.reason":
state.stop_reason or "unknown",
},
) as outcome_span:
verified = await verify_environment_outcome(state)
outcome_span.set_attribute(
"app.agent.outcome.verified",
verified,
)
if not verified:
outcome_span.set_status(
Status(StatusCode.ERROR),
)
root_span.set_attribute(
"app.agent.outcome.status",
"verification_failed",
)
root_span.set_status(
Status(StatusCode.ERROR),
)
else:
root_span.set_attribute(
"app.agent.outcome.status",
"success",
)
root_span.set_attribute(
"app.agent.stop.reason",
state.stop_reason or "completed",
)
return state
except asyncio.CancelledError:
root_span.set_attribute(
"app.agent.stop.reason",
"user_or_parent_cancelled",
)
root_span.set_attribute(
"app.agent.outcome.status",
"cancelled",
)
raise
except Exception as exc:
root_span.set_attribute(
"error.type",
type(exc).__name__,
)
root_span.set_attribute(
"app.agent.outcome.status",
"failed",
)
root_span.set_status(Status(StatusCode.ERROR))
root_span.record_exception(exc)
raise
async def run_agent_step(state: AgentState) -> AgentState:
context = current_agent_context()
with tracer.start_as_current_span(
"agent.step",
attributes={
"app.agent.step.id": context.step_id or "unknown",
"app.agent.step.number": state.step_number + 1,
},
):
with tracer.start_as_current_span("context.compose") as compose_span:
request = await compose_model_request(state)
compose_span.set_attribute(
"app.agent.context.message_count",
len(request.messages),
)
compose_span.set_attribute(
"app.agent.context.tool_definition.count",
len(request.tools),
)
output, tool_calls, usage = await consume_model_stream(
client=model_client,
request=request,
provider_name="provider-x",
model_name="model-x",
)
if tool_calls:
approved_calls = await approve_tool_calls(tool_calls)
results = await execute_tools_in_parallel(
approved_calls,
tool_runtime,
)
with tracer.start_as_current_span(
"tool_result.attach",
attributes={
"app.agent.tool_result.count": len(results),
},
):
next_messages = attach_tool_results(
messages=state.messages,
tool_calls=approved_calls,
results=results,
)
return replace(
state,
step_number=state.step_number + 1,
messages=next_messages,
)
return replace(
state,
step_number=state.step_number + 1,
messages=(
*state.messages,
{
"role": "assistant",
"content": output,
},
),
completed=True,
stop_reason="completed",
)

这段代码刻意保留了以下分层:

  • invoke_agent:整个任务;
  • agent.step:Agent Loop 的逻辑步骤;
  • context.compose:上下文组装;
  • chat:模型请求;
  • approval:工具审批;
  • execute_tool:工具执行;
  • tool_result.attach:结果回填;
  • outcome.verify:环境结果验证。

它比“只给模型 SDK 包一个 Decorator”多做了关键的一步:

把 Agent 的控制流、状态变化和环境结果都纳入 Trace,而不仅仅观测模型。


10. 最小落地清单#

如果团队暂时无法一次完成全部设计,至少先实现以下十项:

  1. 每个 Task 创建一个稳定的根 Agent Span;
  2. 上下文组装、模型、工具和 Outcome Verification 分开建 Span;
  3. 使用 tool_call_id 将模型 Tool Call 与 Tool Result 关联;
  4. Python 使用 contextvars,Node 使用 AsyncLocalStorage 或 OTel Context Manager;
  5. 并行 Task 在正确的活动父 Span 内创建;
  6. 流式请求记录 TTFT、Chunk 数量、终止类型和 Usage 是否可用;
  7. 逻辑 Tool Operation 与每个 Retry Attempt 分开;
  8. Collector 使用 Batch、有限 Queue、Retry 和必要的 Tail Sampling;
  9. CLI 在退出前 force_flush()shutdown()
  10. 默认只记录 Shape,内容采集显式 Opt-in。

完成这十项后,系统才具备最基本的 Agent 级可观测性。


结语#

Agent 埋点的难点不在于调用 start_span(),而在于划分正确的语义边界

一次模型请求只是 Agent 执行链中的一个节点。真正完整的观测需要覆盖:

任务进入
→ 上下文组装
→ 模型流式响应
→ Tool Call 拼装
→ 审批
→ 工具执行
→ Result 回填
→ 状态更新
→ Outcome 验证

同时还要正确处理:

  • 异步 Context;
  • 并行 Tool Call;
  • 跨线程和跨进程传播;
  • MCP Client / Server;
  • 子 Agent 和后台任务;
  • 429、5xx 和网络断流;
  • Retry Attempt 与 Fallback;
  • Collector Queue 和 Tail Sampling;
  • CLI Flush;
  • 隐私与高基数。

可以把本文的核心原则概括为一句话:

Trace 应围绕 Agent 的逻辑操作和因果关系建立,而不是围绕某个 SDK 函数建立。

当这套埋点落地后,下一篇才能进一步回答:模型、工具、RAG、记忆和 MCP 各自到底应该记录哪些字段,如何从这些字段中重建错误传播路径。


参考资料#

Footnotes#

  1. OpenTelemetry GenAI Semantic Conventions 2

  2. OpenTelemetry GenAI Agent Spans 2

  3. Model Context Protocol Draft Specification:OpenTelemetry Trace Context 2

  4. Model Context Protocol 2026-07-28 Changelog

  5. OpenTelemetry GenAI Agent Span Conventions 2

  6. OpenTelemetry GenAI Attribute Registry 2

  7. OpenTelemetry Resources

  8. OpenTelemetry Specification Overview

  9. OpenTelemetry GenAI Attribute Registry:Conversation ID

  10. OpenTelemetry Semantic Conventions for MCP

  11. OpenTelemetry Context Propagation

  12. Python contextvars

  13. Node.js AsyncLocalStorage 2

  14. Python asyncio Tasks and Threads 2

  15. OpenTelemetry Collector Configuration

  16. OpenTelemetry Collector Batch Processor

  17. OpenTelemetry Collector Exporter Helper 2

  18. OpenTelemetry Collector Agent-to-Gateway Pattern

  19. OpenTelemetry Collector Tail Sampling Processor

  20. OpenTelemetry Python Trace SDK

  21. OpenTelemetry Metrics:Cardinality Limits

第 2 篇:Agent 埋点实现——OpenTelemetry、异步上下文与流式调用
https://jupiter-ws.cn/posts/agent-observability/02-agent-instrumentation-opentelemetry/
作者
Jupiter
发布于
2026-08-05
许可协议
CC BY-NC-SA 4.0