本节摘要:一次"用户问、系统答"的背后,是检索、模型调用、工具执行、重试与子 agent 的一连串工作;平铺日志回答不了"时间花在哪、错在哪一环"。本节建立四层 span 分层模型——request(用户请求)、pipeline(编排)、组件(检索/模型/工具)、子调用——并给出约 50 行纯标准库的最小 tracer:用
dataclass定义 span、用contextmanager自动建树、用栈维护父子关系,最后把整棵调用树连耗时打印出来。跑通它,你就理解了所有 tracing 工具(OTel、Langfuse、Phoenix)共同的核心抽象。本节同时约定 span 属性的"记什么"清单,为采样与脱敏(第 2.3 节)留好接口。
多数团队的第一版观测是"多打日志"。出了事 grep 一把,然后:
解法是给每段工作一个span:名称、起止时间、属性、父引用。span 树还原结构,耗时还原时间,属性承接归因。这个抽象在 OpenTelemetry 里叫 span,在 Langfuse 里叫 observation(内部同样是树),在 OTel GenAI 语义约定里给 LLM 调用类 span 规定了 gen_ai.* 属性族(实验态,官方)——名字不同,骨架一样。
第 1 层 request 用户请求/会话轮次的边界(带 user_id、会话 id) └─ 第 2 层 pipeline 一次编排:RAG、agent 循环、工作流 ├─ 第 3 层 组件:retrieve 检索 / llm.call 模型调用 / tool 工具执行 │ └─ 第 4 层 子调用:embed 向量化、retry 重试、子 agent 步骤 └─ (组件可多轮出现:agent 循环 = pipeline 下重复的 llm.call+tool)
分层的好处是问题有固定住址:延迟问题下钻第 3 层;成本问题集中在 llm.call;副作用问题看 tool;循环失控看 pipeline 下的重复模式。命名的原则是"动词.对象"(tool.crm_lookup、llm.call),让树可被模式匹配——第 5 章的排查路径会直接按层给检查顺序。
# mini_tracer.py —— 纯标准库最小 tracer:span 树 + 耗时 import time import uuid from dataclasses import dataclass, field from contextlib import contextmanager @dataclass class Span: name: str span_id: str = field(default_factory=lambda: uuid.uuid4().hex[:12]) parent_id: str | None = None start: float = 0.0 end: float = 0.0 attrs: dict = field(default_factory=dict) children: list = field(default_factory=list) class Tracer: """用栈维护父子:进入 span 压栈,退出弹栈并挂到父节点。""" def __init__(self): self.stack: list[Span] = [] self.finished: list[Span] = [] # 每次请求的根 span 归档处 @contextmanager def span(self, name: str, **attrs): parent = self.stack[-1] if self.stack else None sp = Span(name, parent_id=parent.span_id if parent else None, attrs=attrs) sp.start = time.perf_counter() self.stack.append(sp) try: yield sp # yield 出去,业务可往 attrs 补数据 finally: sp.end = time.perf_counter() self.stack.pop() (parent.children if parent else self.finished).append(sp) def render(sp: Span, depth: int = 0): ms = (sp.end - sp.start) * 1000 attrs = f" {sp.attrs}" if sp.attrs else "" print(" " * depth + f"- {sp.name} {ms:.1f}ms{attrs}") for child in sp.children: render(child, depth + 1) # ---- 用法示意:一次带检索与工具调用的 RAG 请求 ---- tracer = Tracer() def rag_answer(question: str) -> str: with tracer.span("request /answer", user="u-42", session="s-1001"): with tracer.span("pipeline rag", prompt_ver="v3"): with tracer.span("retrieve", hits=3): with tracer.span("embed"): time.sleep(0.02) # 向量化耗时(示意) time.sleep(0.03) # 检索本身耗时(示意) with tracer.span("llm.call", model="demo-large"): time.sleep(0.12) # 模型耗时(示意) with tracer.span("tool.crm_lookup", tool="crm"): time.sleep(0.04) # 工具耗时(示意) return "答案(示意)" if __name__ == "__main__": rag_answer("这个月的账单怎么查看?") render(tracer.finished[0])
运行输出(耗时因机器而异,实测):
- request /answer 213.5ms {'user': 'u-42', 'session': 's-1001'} - pipeline rag 211.9ms {'prompt_ver': 'v3'} - retrieve 51.2ms {'hits': 3} - embed 21.3ms - llm.call 120.4ms {'model': 'demo-large'} - tool.crm_lookup 40.1ms {'tool': 'crm'}
一眼可见:总耗时约 213 毫秒,模型调用占 120 毫秒——性能故事已经讲完了。注意两个设计:yield sp 让业务代码在 span 进行中补属性(比如检索完才知道 hits 数);finally 保证异常也会闭合 span(真实实现里应在此记录异常与状态码,见第五节)。
替换三处 time.sleep 即可:
sp.attrs(input_tokens / output_tokens / 缓存命中数,官方字段以供应商文档为准)——这就是第 3.1 节 token 三本账的数据来源;💡 属性键名建议现在就向 OTel GenAI 语义约定看齐(如
gen_ai.request.model、gen_ai.usage.input_tokens,实验态,官方;第 6.2 节展开),未来迁移到任何 OTel 兼容后端(Langfuse、Phoenix、OpenLLMetry)时不用改语义。
默认记(低敏、高价值,全量):
| 属性类别 | 示例 | 为什么 |
|---|---|---|
| 身份 | user_id、session_id、feature 标签 | 分摊与 TopN 异常识别的键(第 3.2 节) |
| 版本 | 模型版本、prompt_ver、检索库版本 | 版本飞轮归因(第 1.1 节) |
| 计量 | input/output/缓存 token 数、检索 hits 数 | 成本三本账的原始行 |
| 结果状态 | 成功/失败/超时、重试次数、工具返回码 | 区分故障责任层(第 5 章) |
| 时间 | start/end(含首 token 时间若可得) | 延迟分解(P95 归因) |
默认不记(高敏或超大,按需抽样): 用户原文全文、检索文档全文、模型输出全文、工具返回大对象——它们的哈希值或长度可以先记,正文按第 2.3 节的采样策略入库。异常必须记:finally 里捕获的异常类型与堆栈摘要属于"错误全采"范畴,不允许抽样。
⚠️ 一个常被忽略的字段:首 token 时间(TTFT)。总耗时相同,"2 秒出首字然后流式输出"与"8 秒憋一大段"是两种完全不同的用户体验。供应商流式接口通常给出分块时间(官方,以文档为准),哪怕粗记首块时间也值得。
Span dataclass、栈维护父子、contextmanager 自动开闭;异常也要在 finally 闭合。树有了,但此刻它还只是一次请求内的小世界:日志不知道它的存在,token 账本还挂着别的键。第 2.2 节请出 trace_id——让 span 树、日志、账本与质量评分共享同一根线。