2.2 关联与传播:从用户到 token


2.2 关联与传播:从用户到 token

本节摘要:第 2.1 节的 span 树还原了一次请求的内部,但排查真实故障时,你要同时面对日志、token 账本、用户反馈三张桌子——让它们说同一件事的,是三个 ID:trace_id 标识"一次用户请求的全过程",span_id 标识"其中一段工作",parent_id 记录"谁调用了我"。本节讲三 ID 的分工、从用户到 token 的贯穿路径(含跨服务的标准写法 traceparent),并给出用 contextvars 实现的传播代码:在异步并发下也能安全地让所有日志自动携带 trace_id。最后把 token 用量挂上 span——这一挂,成本归因(第 3 章)与质量回放(第 4 章)就都有了着落点。

学习目标

  • 说清 trace_id / span_id / parent_id 各自的粒度与用途
  • 画出"用户 → 网关 → 编排 → 组件 → token 账本"的贯穿路径
  • 用 contextvars 让日志自动携带 trace_id(异步安全)
  • 会把 usage 与三方版本标签写进 llm.call 的 span,为第 3 章铺路

一、三个 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 算。

二、贯穿路径:从用户到 token

用户 ──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、三方版本),否则下游产出的数据没法归因。

三、代码:让日志自动带 trace_id

第 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:把账本行挂上 span

成本归因的关键一步: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 的调用(部分代理/网关会剥掉它)等于成本黑洞,选网关时要专门验证这一点。

五、传播检查清单

上线前按此清单自查(每条对应一类常见断线):

  1. 入口生成:每个外部请求都拿到新 trace_id(没有复用旧 id 的 bug);
  2. 跨服务透传:出站 HTTP 调用都带上 traceparent 头与业务标签(网关尤其容易吞头);
  3. 异步安全:并发任务不串线(用 contextvars,不用全局变量);
  4. 账本挂钩:每行 token 账本都带 trace_id 与分摊键(第 3.2 节的未打标率应近 0);
  5. 反馈挂钩:用户的点赞点踩事件也带 trace_id(第 4 章质量信号才能回放现场);
  6. 版本随行:三方版本标签在所有下游可见。

本节要点回顾

  • 三 ID 分工:trace_id 一次经历、span_id 一段工作、parent_id 树的边;会话 id 是跨轮公共属性。
  • 跨服务传播用 W3C Trace Context 的 traceparent 头(写法示意);业务标签与版本要随行。
  • contextvars 让日志自动带 trace_id,异步安全;grep 一个 id 还原一次请求。
  • token 用量必须来自供应商 usage 字段并挂在 llm.call span 上——一次记录,成本与质量三处消费。

关联打通后,数据会像开了闸:全文、日志、账本行汹涌而来。第 2.3 节关上不必要的闸门——记什么、不记什么、留多久、怎么脱敏。


作者与出处
原作者: 灏天文库
整理: 灏天文库整理
本站整理收录,版权归原作者/开源协议所有;欢迎通过原文链接访问源仓库。
发布者: 作者: 灏天文库 转发
评论区 (0)
U