增强 A · 可观测性(Metrics + 结构化日志)—— 开发文档(面试向)#
这篇讲为什么这么做、做了哪些取舍,不讲代码。读完能用大白话复述。 对应
docs/BACKEND_ENHANCEMENT.md的 A 块(后端增强第一波 P0,收缩版)。
一句话概括#
给系统装上「能看趋势的仪表盘」:暴露一个 /metrics 端点,把每个工具的耗时分布、调用量、错误率、fork 数、断路器熔断状态都暴露给 Prometheus 抓取;同时把日志升级成「带身份的结构化日志」——每条日志自动带上它属于哪个任务、哪个用户、第几层 fork。这一块最大的取舍是「该补什么、不该补什么」:没有去自建一套全链路 trace,因为那套我们已经有了。
打个比方:原来定位「为什么这次跑了 295 秒」全靠在黑盒里翻日志;现在仪表盘上一眼能看到「item_search 的 P99 翘起来了」「reranker 的断路器红了」。
1. 背景:黑盒在哪、已有什么、缺什么#
一次购物查询会 fork 出多个子 Agent 并发搜多个平台,每个子里又有好几次工具调用。延迟到底花在哪一步、哪个平台拖后腿、哪个外呼在抖——没有系统级的聚合指标就是黑盒(之前真的在黑盒里摸过一次 295 秒的延迟回归)。
但动手前先认清家底,避免重复造轮子:
- 调用树级追踪(trace)其实已经有了。项目接了 Langfuse,它本身就基于 OpenTelemetry,已经能把一次查询里主 Agent + 多个 fork 子 Agent 归并成一棵完整的调用树。「这一条查询,每一步花了多久」它答得很好。
- 真正缺的是「系统级聚合指标」:「过去 5 分钟 item_search 的 P99 是多少」「哪个外呼的错误率在涨」「现在几个断路器熔断了」——这些是「跨很多条查询的统计」,Langfuse 的单条 trace 答不了,正是定位延迟回归最需要的。
- 还缺带上下文的日志:并发 fork 时日志混在一起,分不清哪条属于哪个任务。
所以这块只补两样:Prometheus 指标 + 结构化日志。
2. 最大的取舍:为什么砍掉「自建 OTel system trace」#
方案初稿里有一条「用 OpenTelemetry 给每个 fork 建 span,做系统级 trace」。我主动把它砍了、降级到「以后真需要再说」。
原因很简单:会重复造轮子。Langfuse 已经基于 OpenTelemetry 把调用树建好了。再自己起一套 OTel,等于在同一个进程里维护两套 span 上下文、两份导出——投入不小,价值和已有的高度重叠。
工程判断不只是「会用什么工具」,更是「什么时候不该上某个工具」。这块我能讲的恰恰是**「我没做什么、以及为什么不做」**:trace 这层吃 Langfuse 现成的,我只补它没覆盖的 metrics 和日志。真要把 trace 脱离 Langfuse、对接 Jaeger 那套独立存储,是规模上来以后的事(写进了「毕业线」)。
3. Metrics:打点打在哪、为什么打在那#
指标分两类,打点策略不同:
① 累积类(实时累加)——耗时、调用数、错误数、fork 数。
- 工具耗时和调用数打在一个「咽喉」位置:所有工具调用都会经过中间件的同一个包装点(就是做安全护栏的那层)。在那里计时、计数,一处覆盖全部 9 个工具,不用每个工具各写一遍。这是「在共享基础设施上做,而不是到处打补丁」的好例子。
- 计时只包住工具真正执行的那一下:被护栏拦掉的(比如预算耗尽返回的哨兵)不算真执行、不计时;执行出错的单独记成 error 状态,于是错误率能算出来。
② 当前值类(抓取时刷新)——活跃任务数、并发槽占用、断路器状态。
- 这些是「此刻的快照」,没必要实时维护。做法是:Prometheus 来抓
/metrics的那一刻,才现算一次当前值填进去。读那一刻最准,也省得到处维护。
断路器状态怎么被指标看到:让每个断路器创建时自己登记到一个注册表,指标层抓取时遍历这个表读状态。注册表特意用「按名字索引」而不是「列表」——这样同名断路器是替换而不是累积,既符合「每个外呼一个断路器」的现实,也从机制上堵死了「将来有人动态建断路器导致列表无限涨、指标标签爆炸」的隐患(这点是代码 review 提醒的,顺手用数据结构选型解决,而不是靠注释提醒别犯错)。
4. 结构化日志:一套机制,两件事#
普通日志在并发 fork 下是一团乱麻——十几个子 Agent 的日志交织在一起,分不清谁是谁。结构化日志把每条日志变成「带字段的事件」,并自动给它附上「这条属于哪个任务(thread_id)、哪个用户、第几层 fork」。定位某个任务的问题时,按字段一筛就出来了。
这里有个能讲的设计点:复用同一套 ContextVar 机制。 项目本来就用 ContextVar(一种「每个异步任务各有一份、自动随 fork 继承」的存储)来做任务隔离——保证并发任务的 thread_id 不串台。我没有为日志再搞一套传播机制,而是在绑定 thread_id 的同一个地方,顺手把它也绑进日志上下文。于是「请求隔离」和「日志带上下文」走的是同一个入口:
- 进入一个任务作用域时,绑定 thread_id / user_id;
- 每 fork 一层时,绑定 fork_depth。
一句话叙事:ContextVar 不只做任务隔离,还做日志上下文传播——一个机制解决两个问题。
5. Langfuse trace 归并:一次请求 = 一棵调用树#
虽然 A 块砍掉了自建 OTel system trace,但项目在更早的 Mperf 阶段就接入了 Langfuse,这里值得讲一个跨 fork 的 trace 归并设计——它不在 A 块范围里,但和可观测性直接相关。
一次跨平台购物请求会 fork 出 3-5 个子 Agent 并行跑。每个子是独立的 ainvoke 调用,如果不做归并,Langfuse 里会看到 4-6 条散落的 root trace——无法一眼看出它们属于同一次请求、各自花了多少钱。
做法是用 ContextVar 共享 trace_id:主 Agent 进入 run_agent 时生成一个 trace_id 存进 ContextVar;fork 子 Agent 是 asyncio.create_task 出来的,ContextVar 自动继承父的副本,所以子也拿到同一个 trace_id。所有 ainvoke 调用都带 trace_context={"trace_id": <same_id>},于是 Langfuse 里一次请求归并成一棵完整的调用树——主 loop 的每一步、每个子 Agent 的每一步、包括工具内 LLM(planner/shopping_summary 的解码)全部可见。
这和前面「全树计数用 session_dir keyed dict」形成一个有趣的对比:trace_id 用 ContextVar 是对的(子只需要读父的 id,不需要写回;而预算计数子需要写回、ContextVar 写不回、才用模块级 dict)。同一个机制(ContextVar),用在两个方向(只读继承 vs 读写共享)时做出了不同的选型——讲得出为什么,比讲「我用了 ContextVar」有价值。
6. 范围与诚实标注#
- 这是「收缩版」:只做 metrics + 结构化日志,OTel system trace 砍到毕业线(理由见第 2 节)。
- 结构化日志是渐进迁移:基础设施搭好了、新代码用新的 logger 即自动带上下文;但存量少数老式日志调用没有强行一次性全替换——它们照常工作,只是暂不带结构化字段,以后逐步迁移。一次性重写徒增改动面和风险,不划算。诚实标注,不假装「全做完了」。
/metrics端点目前无鉴权(和项目其它接口一样,鉴权是后面 I 块的事)——内部指标暴露在公网是要注意的,但和当前 demo 的边界一致。
7. 面试可讲点小结#
- 「什么时候不该造轮子」:trace 这层 Langfuse 已基于 OTel 提供,主动砍掉自建 OTel,只补真正缺的 metrics + 日志。能讲清「重复在哪、为什么不值得」。
- 「在咽喉处打点」:工具计时打在中间件这个所有工具必经的单点,一处覆盖全部,而非每个工具各打一遍——共享基础设施 vs 到处打补丁。
- 「一套机制两用」:ContextVar 同时做任务隔离 + 日志上下文传播,不另起炉灶。
- 「当前值 vs 累积值的不同打点策略」:gauge 抓取时刷新、counter/histogram 实时累加,能讲出为什么分开处理。
- 「用数据结构选型消除隐患」:断路器注册表用按名索引的 dict 而非 list,从机制上避免「动态创建导致无界增长 + 指标标签爆炸」,而不是写句注释提醒。
- 诚实边界:收缩版、渐进迁移、/metrics 暂无鉴权——讲得出做了什么、没做什么、为什么。