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.system | anthropic |
| 请求模型 | gen_ai.request.model | claude-sonnet-4-5 |
| 输入 token | gen_ai.usage.input_tokens | 820 |
| 输出 token | gen_ai.usage.output_tokens | 64 |
| 停止原因 | gen_ai.response.finish_reasons | ["tool_use"] |
| 操作类型 | gen_ai.operation.name | invoke_agent |
| 工具名 | gen_ai.tool.name | search_corpus |
| 调用 ID | gen_ai.tool.call.id | c1 |
接真 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.run | run_id / workspace_id / input 摘要 | 无法按租户归因、无法定位是哪次提问 |
model.complete | gen_ai.system / gen_ai.request.model | 分不清是哪个模型慢、降级是否生效 |
model.complete | gen_ai.usage.input_tokens / output_tokens | 算不出成本,14.2 无从优化 |
model.complete | gen_ai.response.finish_reasons | 看不出是正常收敛还是撞 max_tokens |
tool.call | gen_ai.tool.name / ok | 13.2 的工具错误率算不出来 |
| 所有 span | duration_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_id | trace_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 指标与看板:延迟/成功率/成本/工具错误 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。