《AI Agent 工程实战》13.1 结构化日志与追踪:OTel GenAI 语义约定埋点

把一次 run 变成 span 树:agent.run 下挂 model.complete 与 tool.call,用 contextvars 传递当前 span 建立父子关系,并行工具的 span 并列成兄弟;日志走 JSON 行,带 trace_id/run_id/workspace_id。OTel 只装了 API 未装 SDK,故用标准库实现最小 span 模型并对齐 GenAI 字段名。

13.1 结构化日志与追踪:OTel GenAI 语义约定埋点

第 12 章给 ResearchCopilot 装上了评测门禁,但它管的是离线——评测集跑分不过就不让上线。线上真出事时,评测分数帮不了你:用户投诉「这次回答等了两分钟」,你手头只有一坨 print,既不知道慢在哪一轮,也不知道是模型慢还是检索慢,更不知道这条 run 属于哪个 workspace、烧了多少钱。

本节把 ResearchCopilot 推进到「线上可观测」:把一次 run 建模成 span 树,日志输出成带 trace_id / run_id / workspace_id 的 JSON 行,字段名逐条对齐 OpenTelemetry GenAI 语义约定,为后面两节的指标与告警铺好数据地基。

13.1.1 线上要回答的三个问题

一次线上故障,运维和开发反复问的无非三句话。这三句话恰好对应三种观测数据:

问题数据形态本节/后续
慢在哪span 树的耗时分布本节建树,14.1 深挖
错在哪结构化日志里 ok=false 的事件本节埋点
贵在哪span 上的 token 用量与成本13.2 聚合

卷一的 15.1 运行追踪 已经从零手写过 span 树,本节不重讲「为什么是树」,而是把它接进真实服务:树要多带 run_id / workspace_id 两个租户维度字段,日志要能和树互相跳转,字段名要和业界标准对齐——这样将来换任何追踪后端都不用改埋点。

13.1.2 先实测:OTel 在本机装到什么程度

按 BRIEF 的红线,先实测 OpenTelemetry 的可用性,不凭记忆:

/tmp/agentbook/venv/bin/python -c "import importlib.util as u; print(bool(u.find_spec('opentelemetry')))"
/tmp/agentbook/venv/bin/pip list | grep -i opentelemetry
True
opentelemetry-api         1.45.1

opentelemetry 顶层能 import,但 pip 里只有 opentelemetry-api,没有 opentelemetry-sdk。这个差别是决定性的,实测一下 API 单独工作的行为:

from opentelemetry import trace
t = trace.get_tracer("probe")
with t.start_as_current_span("probe") as s:
    s.set_attribute("gen_ai.system", "anthropic")
    print("span:", type(s).__module__ + "." + type(s).__name__)
    print("recording:", s.is_recording())
span: opentelemetry.trace.span.NonRecordingSpan
recording: False

结论如实记录:opentelemetry-api 单独存在时,get_tracer() 返回的是 ProxyTracer,开出来的 span 是 NonRecordingSpan,is_recording() 为 False——它什么都不记录。API 包只是接口契约,真正的采集要 opentelemetry-sdk 加 exporter。本机没装 SDK,所以本节不假装跑通 OTel SDK,主线用标准库实现一个最小 span 模型,再把每个字段对齐 GenAI 语义约定,讲清「接真 SDK 时这些值该填到哪」。

13.1.3 一次 run 的 span 树

ResearchCopilot 一次 run 的形状是固定的:root 是 agent.run,其下每一轮挂一个 model.complete,每轮触发的工具各挂一个 tool.call,多跳检索再挂 retrieval.search。

agent.run
├── model.complete        (第 1 轮,返回 tool_use)
│   ├── tool.call          gen_ai.tool.name=search_corpus
│   └── tool.call          gen_ai.tool.name=query_pg      ← 两个工具并列
└── model.complete        (第 2 轮,返回 end_turn)

agent.run 是根,两个 tool.call 是同一层的兄弟——它们由同一轮模型并行发出,谁也不属于谁。这种「并列」不是画着好看:它让 13.2 能一眼算出「这一轮的工具是不是并行跑的」,也让 14.1 能把延迟拆到「模型占多少、工具占多少」。

13.1.4 用 contextvars 建立父子关系

最小 span 模型就一个 dataclass。字段名刻意对齐 OTel:span_id / parent_id 就是 trace 结构里的标识:

from contextvars import ContextVar
from dataclasses import dataclass, field

CURRENT_SPAN: ContextVar["Span | None"] = ContextVar("current_span", default=None)

@dataclass
class Span:
    name: str
    span_id: str
    parent_id: str | None
    start: float
    end: float | None = None
    attributes: dict = field(default_factory=dict)
    children: list["Span"] = field(default_factory=list)

    @property
    def duration_ms(self) -> float:
        return 0.0 if self.end is None else (self.end - self.start) * 1000.0

父子的建立不靠传参,靠上下文。start_span 从 CURRENT_SPAN 取出此刻的 span 当父亲,再把自己设为当前:

    def start_span(self, name, **attrs):
        parent = CURRENT_SPAN.get()
        s = Span(name, uuid.uuid4().hex[:8],
                 parent.span_id if parent else None,
                 time.perf_counter(), attributes=dict(attrs))
        (parent.children if parent else self._roots).append(s)
        self._tokens[s.span_id] = CURRENT_SPAN.set(s)   # 记住 token,退出时还原
        return s

    def end_span(self, s):
        s.end = time.perf_counter()
        CURRENT_SPAN.reset(self._tokens.pop(s.span_id))  # 必须还原,否则兄弟会串挂

配一个 @contextmanager 的 span(),业务代码写成 with tracer.span("tool.call", ...) 即可,主流程一行不用改——追踪是横切关注点。time.perf_counter() 是单调时钟,测时长不会被 NTP 对时拉出负数,这一点卷一 15.1 已经踩过。

13.1.5 并行工具:两个 span 怎么并列

一轮里模型可能同时发出多个 ToolCall,用 asyncio.gather 并发执行,每个调用各开一个 span:

async def one(tc):
    with tracer.span("tool.call", **{"gen_ai.tool.name": tc.name,
                                     "gen_ai.tool.call.id": tc.id}) as ts:
        out = await registry.call(tc)
        ts.attributes["ok"] = "error" not in out
    return out

outs = await asyncio.gather(*[one(tc) for tc in resp.message.tool_calls])

关键在每个协程任务拿到的是上下文的副本。asyncio 在创建任务时复制当前 contextvars 上下文,所以两个 one() 各自 CURRENT_SPAN.set(...) 互不干扰:两个 tool.call 的 parent_id 都指向同一个 agent.run,它们是兄弟而不是父子。如果改用「共享一个可变全局变量」,后启动的任务就会把前一个的 span 当成父亲,树立刻串成一串。这正是选 contextvars 而不是普通全局变量的原因。

13.1.6 结构化日志:三把关联钥匙

span 树是运行结束才成型的,查实时问题还得靠日志。日志必须是机器可读的 JSON 行,每条带上三把关联钥匙:

class JsonFormatter(logging.Formatter):
    def format(self, rec):
        base = {"ts": self.formatTime(rec, "%H:%M:%S"), "level": rec.levelname,
                "event": rec.getMessage(), "trace_id": TRACE_ID.get()}
        base.update(getattr(rec, "fields", {}))
        return json.dumps(base, ensure_ascii=False)
字段粒度作用
trace_id一次请求链路串起跨服务的所有 span
run_id一次 run还原这次运行的完整时间线
workspace_id一个租户第 4 章的行级隔离维度,也是成本归属
span_id一个调用与 span 树节点对齐,日志↔树互相跳转

三者嵌套:一个 workspace_id 下多次 run_id,一次 run_id 下多个 span_id。任意一个都能当检索入口。

13.1.7 字段名对齐 OTel GenAI 语义约定

自己造字段名一时爽,将来接 Grafana Tempo / Jaeger 就傻眼。业界已有 OTel GenAI 语义约定,把模型与工具的关键信息定成了标准属性名。本节的自定义属性逐条映射过去:

本节字段OTel GenAI 属性值示例
模型供应商gen_ai.systemanthropic
请求模型gen_ai.request.modelclaude-sonnet-4-5
输入 tokengen_ai.usage.input_tokens820
输出 tokengen_ai.usage.output_tokens64
停止原因gen_ai.response.finish_reasons["tool_use"]
操作类型gen_ai.operation.nameinvoke_agent
工具名gen_ai.tool.namesearch_corpus
调用 IDgen_ai.tool.call.idc1

接真 OTel SDK 时,代码几乎不变,只是把 tracer.span(...) 换成 tracer.start_as_current_span(...),attributes 里的键原样就是这些属性名——这正是提前对齐的价值。span 名也要跟上约定:agent 根 span 叫 invoke_agent,模型调用叫 chat,工具叫 execute_tool(本节为了可读用了 agent.run / model.complete / tool.call,映射关系记录在 gen_ai.operation.name 里)。

13.1.8 真跑:带两个工具的 run

假模型第一轮同时发出 search_corpus 与 query_pg 两个调用,第二轮收尾。真跑(Python 3.14.6,结构化日志六行 + span 树):

{"ts": "03:07:06", "level": "INFO", "event": "run.start", "trace_id": "1b1779ff3bbd482f", "run_id": "run_9f3c1a", "workspace_id": "ws_acme", "input": "向量检索召回率怎么样?"}
{"ts": "03:07:06", "level": "INFO", "event": "model.call", "trace_id": "1b1779ff3bbd482f", "run_id": "run_9f3c1a", "step": 0, "span_id": "0dadc520", "duration_ms": 0.01, "gen_ai.request.model": "claude-sonnet-4-5", "gen_ai.usage.input_tokens": 820, "gen_ai.usage.output_tokens": 64}
{"ts": "03:07:06", "level": "INFO", "event": "tool.call", "trace_id": "1b1779ff3bbd482f", "run_id": "run_9f3c1a", "span_id": "d412b144", "gen_ai.tool.name": "search_corpus", "gen_ai.tool.call.id": "c1", "duration_ms": 14.1, "ok": true}
{"ts": "03:07:06", "level": "INFO", "event": "tool.call", "trace_id": "1b1779ff3bbd482f", "run_id": "run_9f3c1a", "span_id": "b15f1f56", "gen_ai.tool.name": "query_pg", "gen_ai.tool.call.id": "c2", "duration_ms": 31.56, "ok": true}
{"ts": "03:07:06", "level": "INFO", "event": "model.call", "trace_id": "1b1779ff3bbd482f", "run_id": "run_9f3c1a", "step": 1, "span_id": "b3820502", "duration_ms": 0.01, "gen_ai.request.model": "claude-sonnet-4-5", "gen_ai.usage.input_tokens": 1210, "gen_ai.usage.output_tokens": 210}
{"ts": "03:07:06", "level": "INFO", "event": "run.end", "trace_id": "1b1779ff3bbd482f", "run_id": "run_9f3c1a", "workspace_id": "ws_acme", "duration_ms": 31.97, "ok": true}
=== span 树 ===
agent.run [31.97 ms] {'run_id': 'run_9f3c1a', 'workspace_id': 'ws_acme', 'gen_ai.operation.name': 'invoke_agent', 'gen_ai.usage.total_tokens': 2030}
  model.complete [0.01 ms] {'step': 0, 'gen_ai.system': 'anthropic', 'gen_ai.request.model': 'claude-sonnet-4-5', 'gen_ai.usage.input_tokens': 820, 'gen_ai.usage.output_tokens': 64, 'gen_ai.response.finish_reasons': ['tool_use']}
  tool.call [14.10 ms] {'gen_ai.tool.name': 'search_corpus', 'gen_ai.tool.call.id': 'c1', 'ok': True}
  tool.call [31.56 ms] {'gen_ai.tool.name': 'query_pg', 'gen_ai.tool.call.id': 'c2', 'ok': True}
  model.complete [0.01 ms] {'step': 1, 'gen_ai.system': 'anthropic', 'gen_ai.request.model': 'claude-sonnet-4-5', 'gen_ai.usage.input_tokens': 1210, 'gen_ai.usage.output_tokens': 210, 'gen_ai.response.finish_reasons': ['end_turn']}

三处可直接读出的结论:

  • 两个 tool.call 是并列的兄弟,parent_id 都指向同一个 agent.run,谁也没挂在谁下面——contextvars 的上下文隔离生效了。
  • agent.run 总耗时 31.97 ms ≈ query_pg 的 31.56 ms,而不是两个工具之和(14.10 + 31.56 = 45.66 ms)。总耗时等于最慢的那个,这行数字直接证明了工具是并行跑的。
  • finish_reasons 从 tool_use 变到 end_turn,这串值就是「Agent 为什么停下来」的完整轨迹;哪天卡在连续 tool_use 上,一看便知。

13.1.9 必须埋点的字段清单

埋点不是越多越好,但下面这些字段是线上排障的刚需,缺一个都会在关键时刻卡住:

埋点位置必埋字段缺失的后果
agent.runrun_id / workspace_id / input 摘要无法按租户归因、无法定位是哪次提问
model.completegen_ai.system / gen_ai.request.model分不清是哪个模型慢、降级是否生效
model.completegen_ai.usage.input_tokens / output_tokens算不出成本,14.2 无从优化
model.completegen_ai.response.finish_reasons看不出是正常收敛还是撞 max_tokens
tool.callgen_ai.tool.name / ok13.2 的工具错误率算不出来
所有 spanduration_ms / parent_id树不成树,延迟拆不开

真实环境 / 真实模型下会怎样:本节模型耗时是假模型的 0.01 ms,工具是 asyncio.sleep 撑的十几毫秒,量级毫无参考价值。真实模型单次调用是数百毫秒到数十秒且方差极大,span 树上 model.complete 会稳定占掉 90% 以上,tool.call 常是零头。真实系统还有两个本节没覆盖的工程点:其一是采样——高流量下不可能全量导出 span,常见做法是错误 run 全留、成功 run 按比例采,但必须保证同一个 run_id 要么整体留要么整体丢;其二是 trace 上下文跨进程传播——run 在 API 进程发起、在 worker 进程执行(第 7 章的队列),trace_id 必须随任务消息带过去,否则一次 run 的 span 会断成两截。

13.1.10 与卷一 15.1 的差异

同样一棵 span 树,卷一的版本和本节服务化后的版本差别集中在「多带了什么」:

维度卷一 15.1本节(服务化)
字段来源自定义 query / stop_reason对齐 gen_ai.* 语义约定
租户维度无workspace_id(第 4 章隔离维度)
关联 ID只有 span_idtrace_id / run_id / workspace_id 三把钥匙
日志独立讲结构化日志日志与 span 共用 span_id,互相跳转
并行未涉及两个 tool.call 并列成兄弟
落地后端内存导出预留接真 OTel SDK 的字段映射

一句话:卷一的树是为了讲清结构,本节的树是为了线上排障——多了租户、成本与跨进程三样真实服务绕不开的东西。

验收清单:

  • 每个 span 都有 duration_ms 与 parent_id,能还原成树
  • 并行工具的 span 是兄弟,不是互相嵌套
  • 每条日志都带 trace_id / run_id / workspace_id
  • 属性名对齐 GenAI 语义约定,接真 SDK 时无需重命名
  • 明确记录 OTel SDK 未装、用标准库实现(不谎报跑过 SDK)

阅读导航:上一节:12.3 CI 质量门:分数下降即拦截 · 下一节:13.2 指标与看板:延迟/成功率/成本/工具错误 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距