本节摘要:第 2.1 节的 span 树还原了一次请求的内部,但排查真实故障时,你要同时面对日志、token 账本、用户反馈三张桌子——让它们说同一件事的,是三个 ID:trace_id 标识"一次用户请求的全过程",span_id 标识"其中一段工作",parent_id 记录"谁调用了我"。本节讲三 ID 的分工、从用户到 token 的贯穿路径(含跨服务的标准写法 traceparent),并给出用
contextvars实现的传播代码:在异步并发下也能安全地让所有日志自动携带 trace_id。最后把 token 用量挂上 span——这一挂,成本归因(第 3 章)与质量回放(第 4 章)就都有了着落点。
contextvars 让日志自动携带 trace_id(异步安全)| ID | 粒度 | 生成时机 | 回答的问题 |
|---|---|---|---|
| trace_id | 一次用户请求的全过程(可跨多轮服务与子调用) | 请求进入系统边界时 | "这堆日志/账本/评分是同一次经历吗?" |
| span_id | 一段工作(检索、一次模型调用、一个工具) | 每段工作开始时 | "具体是哪一段?" |
| parent_id | 指向调用者的 span_id | 子 span 创建时 | "谁调用了谁?"(树的边) |
trace_id = T1(一次用户请求只有一个) ┌─ request(span S1)──────────────────────────────┐ │ ├─ retrieve(S2, parent=S1) │ │ │ └─ embed(S3, parent=S2) │ │ ├─ llm.call(S4, parent=S1) │ │ │ └─ retry(S5, parent=S4) │ │ └─ tool.crm(S6, parent=S1) │ └───────────────────────────────────────────────────┘
💡 会话(session)与 trace 的关系要说清:一次会话包含多轮请求,每轮一个 trace_id;会话 id 作为所有 trace 的公共属性。成本熔断按会话算(第 3.3 节),故障还原按 trace 算。
用户 ──trace_id=T1──▶ 网关 ──▶ 编排服务 ──┬──▶ 检索服务(请求带 T1) ├──▶ LLM 上游(请求带 T1) └──▶ 工具服务(请求带 T1) 所有产出共享 T1: span 树(S1..S6)、应用日志、token 账本行、抽样评分记录 ──▶ 用 T1 一次查出:这次请求花了 0.021 美元、慢在 S4、用户点了踩
跨服务传播的工业标准是 W3C Trace Context(官方规范):以 HTTP 头 traceparent: 00-{trace-id}-{parent-span-id}-01 传递(写法示意,以规范文本为准)。多数观测 SDK 默认识别它;即便用第 2.1 节的自建 tracer,也建议按这个格式自己透传——将来接任何 OTel 兼容后端都不用改语义。传播时随行携带的不只 trace_id,还有业务标签(user_id、feature、三方版本),否则下游产出的数据没法归因。
第 2.1 节用栈维护父子,适合同步单线程;一旦上异步(asyncio)或线程池,"全局栈"会串线。标准解法是 contextvars——每个异步任务有独立上下文:
# span_ids.py —— trace_id 传播:contextvars + 自动日志关联(纯标准库) import logging import uuid from contextvars import ContextVar trace_id_var: ContextVar[str] = ContextVar("trace_id", default="-") session_id_var: ContextVar[str] = ContextVar("session_id", default="-") def bind(trace_id: str, session_id: str): """在请求入口调用一次,本上下文内所有日志自动携带。""" trace_id_var.set(trace_id) session_id_var.set(session_id) class TraceFilter(logging.Filter): """把当前上下文的 ID 注入每条日志记录。""" def filter(self, record: logging.LogRecord) -> bool: record.trace_id = trace_id_var.get() record.session_id = session_id_var.get() return True logging.basicConfig( level=logging.INFO, format="%(asctime)s [trace=%(trace_id)s session=%(session_id)s] %(message)s", ) log = logging.getLogger("app") log.addFilter(TraceFilter()) if __name__ == "__main__": # 入口处生成并绑定(真实场景在网关/请求处理的第一行完成) bind(uuid.uuid4().hex[:16], "s-1001") log.info("收到用户提问") log.info("检索完成 hits=3") log.info("模型调用完成 tokens=1850")
输出(示意):
2026-09-29 10:00:00 [trace=9f2c4a1b8d3e7f20 session=s-1001] 收到用户提问 2026-09-29 10:00:00 [trace=9f2c4a1b8d3e7f20 session=s-1001] 检索完成 hits=3 2026-09-29 10:00:01 [trace=9f2c4a1b8d3e7f20 session=s-1001] 模型调用完成 tokens=1850
从此 grep 一个 trace_id,就能把散落的日志拼成一次请求的完整故事——这就是能力清单第 1 问"5 分钟还原"的地基。
成本归因的关键一步:token 用量必须写在 llm.call 的 span 上(而不是只写进独立计费表),且随行携带分摊键。一次调用的 span 属性应当长这样(示意):
# usage_on_span.py —— 在 llm.call span 上挂 token 用量与分摊键(示意) sp.attrs.update({ # 计量(来自供应商响应的 usage 字段,字段名以官方文档为准) "gen_ai.usage.input_tokens": 1820, # 其中含缓存命中部分见下行 "gen_ai.usage.output_tokens": 540, "cache_read_tokens": 1500, # 命中 prompt 缓存的输入(示意键名) # 分摊键(第 3.2 节三维分摊的原始数据) "team": "客服", "feature": "机器人回复", "user_id": "u-42", # 版本标签(第 1.1 节版本飞轮的解药) "gen_ai.request.model": "demo-large@2026-08", "prompt_ver": "v3", "kb_ver": "kb-2026-09-15", })
为什么坚持"挂在 span 上"?看两条查询路径的差距:
(文字流程图) 没挂:账单疑点 ──▶ 翻计费表 ──▶ 对时间戳猜请求 ──▶ 再翻日志 ──▶ 仍难定位(小时级) 挂了:账单疑点 ──▶ 按 feature+prompt_ver 聚合账本行 ──▶ 直接得到 Trace ──▶ 还原现场(分钟级)
同一份 usage 数据,稍后第 3.1 节会把它变成三本账与美元;第 4 章的质量评分也回写在同一棵 span 树上。一次记录,三处消费——这是"一份数据三种切法"(第 0.1 节)的机械实现。
⚠️ token 数必须用供应商响应里的 usage 字段(官方口径),不要自己拿分词器数——边界规则(工具调用、推理 token、图片折算)各家不同,自数必错。这也意味着:拿不到 usage 的调用(部分代理/网关会剥掉它)等于成本黑洞,选网关时要专门验证这一点。
上线前按此清单自查(每条对应一类常见断线):
contextvars 让日志自动带 trace_id,异步安全;grep 一个 id 还原一次请求。关联打通后,数据会像开了闸:全文、日志、账本行汹涌而来。第 2.3 节关上不必要的闸门——记什么、不记什么、留多久、怎么脱敏。