Day 19 / 共 20 天 · 阶段5 进阶与收官

追踪与可观测:回调事件怎么拼成 Run 树、又怎么飞进 LangSmith

Day17 学了回调总线,Day18 每个配件都在 get_child(tag=...) 续树——今天看这棵树的"成品":tracers/ 目录。三个层次:Run——一条带血缘的运行记录(它其实就是 langsmith SDK 的 RunTree);②BaseTracer——一个特殊的回调 handler,把零散事件组装成树;③LangChainTracer——把树实时 POST 到 LangSmith 后台。看完你就懂:网页上那个能逐层展开的执行瀑布图,是怎么从你的一行 invoke 里长出来的。

📍 你在 20 天里的位置(阶段5:进阶与收官 · D17-20)
S1 全景/LCEL S2 模型/消息 S3 数据/RAG S4 工具/Agent D17 回调/流式 D18 LCEL 高级 D19 追踪 D20 收官
💡 先用两个类比兜住今天 类比一:追踪系统像飞机的黑匣子 + 空管雷达。回调事件是各仪表的原始读数(Day17);Tracer 是黑匣子——把读数按时间和因果组装成完整的飞行记录(Run 树);LangChainTracer 是实时回传的雷达链路——飞行中就把记录发回地面站(LangSmith),出事故不用捞黑匣子,地面早就看到全程了。类比二:Run 树像快递物流详情页——总单(根 Run:整条链)下挂着分段记录(子 Run:检索、拼 prompt、调模型),每段有自己的开始/结束时间、经手人(组件名)、异常备注;点开任何一段还能看"包裹内容"(inputs/outputs)。
L01

痛点:线上答错了一题,怎么复盘

🤔 痛点用户投诉"AI 答非所问"。你要回答一串问题:那次请求检索回了哪几篇文档?最终 prompt 拼成了什么样?模型吐了什么原始输出、解析器有没有掰坏它?重试过吗?花了多少 token、多少钱?——这一切发生在过去的某次请求里,print 大法救不了你。传统 APM(如 Jaeger)能看到"HTTP 调了 3 秒",但看不懂"prompt 里少了一段 context"这种 LLM 语义层的问题。
💡 本质:Tracer = 一个"把事件写成史书"的回调听众Day17 说过:追踪不需要新机制,它就是回调总线上的一个听众。BaseTracer 继承自 BaseCallbackHandlertracers/base.py:33 的类签名写得明明白白),它监听所有 on_*_start/end 事件,靠 run_id/parent_run_id 血缘把事件缝合成一棵树,并在关键时刻"持久化"。发到哪、怎么发,由子类决定——发给 LangSmith 的那个子类,就叫 LangChainTracer
L02

Run:一条运行记录长什么样

先认识树的节点。打开 tracers/schemas.py 你会看到一个惊喜——它几乎是空的:

# libs/core/langchain_core/tracers/schemas.py:1
"""Schemas for tracers."""

from __future__ import annotations

from langsmith import RunTree          # ★直接从 langsmith SDK 导入

# Begin V2 API Schemas

Run = RunTree  # For backwards compatibility   # ← schemas.py:10

__all__ = [
    "Run",
]
Run = RunTree★LangChain 的 Run 就是 langsmith 库的 RunTree 的别名(tracers/schemas.py:10)。这说明 langchain-core 直接依赖 langsmith SDK 的数据模型——追踪格式天生和 LangSmith 平台对齐,这层生态绑定 Day20 还会展开。
RunTree 的字段从 L04 将看到的构造调用可知每条 Run 携带:id / parent_run_id(血缘)、run_type(llm/chain/tool/retriever)、inputs / outputsstart_time / end_timeevents(时间线,如 start/new_token/end)、tags / extra(元数据——Day18 的 retry:attempt:2branch:default 都落在这)、error(带堆栈)。
child_runs父 Run 持有子 Run 列表(_TracerCore._add_child_runtracers/core.py:107parent_run.child_runs.append(child_run))——树形结构在内存里就这么一行拼起来的。
Day16 又闭环了还记得 _exit_history(run: Run, ...) 吗?护士收到的那个 run 就是这里的 Run——所以她能从 run.inputs/run.outputs 里精确取出该写进历史的消息。
大白话Run = "一次运行的完整档案袋":谁(name/run_type)、何时(start/end)、吃了什么(inputs)、吐了什么(outputs)、出过什么事(error/events)、爹是谁(parent_run_id)。树 = 档案袋里再套档案袋。
L03

BaseTracer:从零散回调拼出一棵树

拼树的骨架在 _TracerCorelibs/core/langchain_core/tracers/core.py:40,维护 run_map)+ BaseTracertracers/base.py:33):

# libs/core/langchain_core/tracers/base.py:33
class BaseTracer(_TracerCore, BaseCallbackHandler, ABC):   # ★本质:一个回调 handler
    """Base interface for tracers."""

    @abstractmethod
    def _persist_run(self, run: Run) -> None:
        """Persist a run."""                    # 子类决定"存到哪":打印/存内存/发 LangSmith

    def _start_trace(self, run: Run) -> None:   # base.py:40 每个 on_*_start 都会走到
        super()._start_trace(run)               # → _TracerCore:挂到父节点 + 登记进 run_map
        self._on_run_create(run)

    def _end_trace(self, run: Run) -> None:     # base.py:45 每个 on_*_end 都会走到
        if not run.parent_run_id:
            self._persist_run(run)              # ① ★只有根 Run 结束才"整树持久化"
        self.run_map.pop(str(run.id))           # ② 本节点出场,从"在飞航班表"删掉
        ...
        self._on_run_update(run)                # ③ 给子类的"节点更新"钩子
run_map_TracerCore.__init__tracers/core.py:86)里的 dict[str, Run]——"正在飞行的航班表"。收到 on_chain_start 就建 Run 登记;子事件来了按 parent_run_id 在表里找到爹、挂上去;结束就注销。
on_chat_model_startBaseTracer 实现了全套回调钩子(tracers/base.py:61 起):每个钩子 = "造 Run + _start_trace"或"填 outputs + _end_trace"。它就是把 Day17 的事件流翻译成树操作的翻译官。
根结束才 persist★默认策略:等整棵树完工(根 Run end)才调 _persist_run 一次性交付。但注意这只是默认——L04 的 LangChainTracer 会覆写成"边飞边报"。
_on_run_create/_update模板方法模式:骨架固定(拼树逻辑大家共享),细节开洞(子类在节点创建/更新时做自己的事)。整个 tracers 家族都是往这两个洞里填不同行为。
树的"因果编号"哪来的?回忆 Day17:CallbackManager 在广播 start 事件时发 run_id、并把自己的 run_id 作为子 manager 的 parent_run_id。Tracer 只是诚实地按编号拼装——发号在 manager,拼树在 tracer,分工清晰。
L04

LangChainTracer:把树实时投递给 LangSmith

真正连接平台的子类 LangChainTracerlibs/core/langchain_core/tracers/langchain.py:134):

# libs/core/langchain_core/tracers/langchain.py:134
class LangChainTracer(BaseTracer):
    """Implementation of the SharedTracer that POSTS to the LangChain endpoint."""

    run_inline = True                             # ★要求同步执行,保证事件顺序

    def __init__(self, example_id=None, project_name=None, client=None, tags=None, ...):
        super().__init__(**kwargs)
        self.project_name = project_name or ls_utils.get_tracer_project()  # 默认项目名
        self.client = client or get_client()      # langsmith.Client:真正发 HTTP 的人

# langchain.py:220(节选)
def _start_trace(self, run: Run) -> None:
    if self.project_name:
        run.session_name = self.project_name      # 归属到哪个项目
    super()._start_trace(run)                     # 正常拼树
    if get_tracing_context().get("enabled") is False:
        run.extra["__disabled"] = True            # 上下文里临时关了追踪 → 打标记不上报

# langchain.py:325
def _persist_run_single(self, run: Run) -> None:
    """Persist a run."""
    if run.extra.get("__disabled"):
        return
    run.extra["runtime"] = get_runtime_environment()  # 附带 py 版本/平台等运行环境
    run.tags = self._get_tags(run)
    run.post()                                    # ★★RunTree.post():POST 给 LangSmith
run_inline = True覆写了 Day17 见过的开关:追踪器必须在主线程按序执行——"start 先于 end 到达"这种顺序不能乱,否则树会拼错。
_on_llm_start / _on_chat_model_start★与 L03 的默认"根结束才交付"不同,它在每个节点开始时就 _persist_run_single 发出去langchain.py:353/380),结束时再 _update_run_single 补 outputs(langchain.py:342,内部 run.patch())。所以你在 LangSmith 网页上能看到正在运行中的 run 逐步点亮——不是跑完才出现。
run.post() / run.patch()发送逻辑不在 langchain 里——Run 就是 langsmith 的 RunTree(L02),post/patch 是它自带的方法,底层由 langsmith.Client后台批量+压缩上传,不阻塞你的链。
_persist_run(覆写版)根结束时(langchain.py:281)只留一份轻量副本到 self.latest_run(去掉 child_runs 防内存膨胀)——因为每个节点早就单独发过了,根结束不需要再发整树。
_on_llm_end 抓用量langchain.py:392)从 generations 里抽 usage_metadata(token 数)塞进 metadata——LangSmith 页面上的费用统计就是这么来的。
💡 取舍:为什么"每节点即时发"而不是"整树打包发"?即时发的好处:①实时性——长链跑 5 分钟,你第 1 秒就能在网页看到进度;②抗崩溃——进程半路挂了,已发出的节点还在,事故现场保住了。代价:HTTP 请求变多——所以 langsmith Client 用后台线程攒批发送对冲。"黑匣子"与"实时雷达"之间,LangSmith 选了雷达。
L05

一个环境变量就开追踪的真相

用户体感最神奇的一点:代码一行不改,设 LANGSMITH_TRACING=true + LANGSMITH_API_KEY=... 就全量追踪。拼图的最后一块在 Day17 看过的 _configurelibs/core/langchain_core/callbacks/manager.py:2390)里:

# libs/core/langchain_core/callbacks/manager.py:2497
tracing_v2_enabled_ = _tracing_v2_is_enabled()        # ← tracers/context.py:132
#   其内部:ls_utils.tracing_is_enabled()——读 LANGSMITH_TRACING 等环境变量

# libs/core/langchain_core/callbacks/manager.py:2524
if tracing_v2_enabled_ and not any(
    isinstance(handler, LangChainTracer)              # 已挂过就不重复挂
    for handler in callback_manager.handlers
):
    ...
    handler = LangChainTracer(                        # ★悄悄挂上记录员
        project_name=tracer_project,                  #   LANGSMITH_PROJECT 或 "default"
        client=..., tags=tracing_tags, metadata=tracing_metadata,
    )
    callback_manager.add_handler(handler)
_tracing_v2_is_enabledtracers/context.py:132)两种开启方式:环境变量(委托 langsmith 的 tracing_is_enabled()),或代码里 with tracing_v2_enabled(): 上下文管理器(context.py:40)临时开启——后者靠 ContextVar,只影响 with 块里的调用。
每次组装都检查Day17 讲过:每个组件开跑前都会 _configure 组装导播台。所以"开关"是在每次运行时动态判定的——不用重启进程,改环境上下文即刻生效。
inherit 传全树挂上的 tracer 是可继承 handler——根链装一次,检索器/模型/工具的事件全都收得到。这就是"一个变量、全链路"的原理。
⚠️ 坑:短命脚本结束时 trace 没发完上传是后台批量的——脚本 invoke 完立刻退出,可能最后一批还没 flush,LangSmith 上看到"缺胳膊少腿"的 trace。解法:退出前调 tracer.wait_for_futures()/client.flush()langchain.py:466-467 就是干这个的),或用官方建议的方式优雅收尾。长驻服务无此烦恼。
L06

Run 树全景图 + 一次 RAG 请求的真实值

一次 RAG invoke 的 Run 树:回调事件 → 树 → LangSmith 根 Run · chain · RunnableSequence run_id=A · parent=None · 2.98s Run · retriever · parent=A · 0.31s outputs: 4 篇 Document Run · prompt · parent=A · 0.002s outputs: ChatPromptValue(2 条消息) Run · llm · parent=A · 2.51s tags: [retry:attempt:2] · usage: 913 tokens Run · parser · parent=A · 0.001s outputs: "LCEL 是……" start 即 post() end 即 patch() LangSmith 平台 瀑布图:逐层展开每个 Run 看 prompt 全文 / 模型原始输出 token 费用统计 · 延迟分析 错误堆栈 · retry/branch 标签 数据集回放 · 在线评估 langsmith.Client 后台批量上传
图注:左边的树在你进程内存里由 BaseTracer 拼装;LangChainTracer 每建一个节点就 post、每完成一个就 patch——右边网页近实时点亮。
📝 真实值:环境变量 + 两行代码 = 全链路追踪 export LANGSMITH_TRACING=true; export LANGSMITH_API_KEY=lsv2_pt_...; export LANGSMITH_PROJECT=rag-demo,然后照常 chain.invoke({"question": "LCEL 是什么?"})。背后自动发生:_configure 检测到开关 → 挂 LangChainTracer(project_name="rag-demo") → 根链 start:造 Run(id=A, run_type="chain") 并 POST → 检索/‌prompt/模型/解析器各自 start/end 时 post+patch → LangSmith 网页 rag-demo 项目里出现一条 trace,点开是上图那棵树,llm 节点上还挂着 usage: 913 tokens 和重试标签。全程你的业务代码零改动

👶 小白:不买 LangSmith 就没法看这棵树了吗?

👨‍🏫 老师:树是在你进程里拼的,"投递到哪"只是子类的选择。同目录还有免费选项:ConsoleCallbackHandlertracers/stdout.py,把树彩色打印到终端,set_debug(True) 就在用它)、RunCollectorCallbackHandlertracers/run_collector.py,把 Run 收进内存 list 供你自己分析)、以及 Day17 见过的 event_stream(把树"直播"成事件流)。另外 LangSmith 的协议是 OTel 兼容方向演进的,也有人把回调桥接到自建观测栈。核心资产是这棵树,去哪展示随你。

L07

tracers 家族其他成员 + 今日小结

成员文件persist 的姿势
LangChainTracertracers/langchain.py:134逐节点 post/patch 到 LangSmith(今天主角)
ConsoleCallbackHandlertracers/stdout.py彩色打印到终端(debug 模式默认)
RunCollectorCallbackHandlertracers/run_collector.py攒进内存 list(测试/自定义分析)
RootListenersTracertracers/root_listeners.py:23只盯根 Run,触发 on_start/on_end 监听器——Day16 with_listeners 的底层
_AstreamEventsCallbackHandlertracers/event_stream.py:101翻译成事件塞队列——Day17 astream_events 的底层
EvaluatorCallbackHandlertracers/evaluation.py把 Run 喂给评估器打分(在线评估)
💡 三天连起来看D17 回调(事件从哪来)→ D18 组合器(每个配件老老实实 get_child 续血缘)→ D19 追踪(事件按血缘拼树、投递平台)。可观测不是外挂,是 LangChain 从 core 层就织进纤维里的能力——这也是它和"自己手写 openai 调用"拉开差距的地方之一。

🧠 今天你应该能回答

  • Run 是什么?定义在哪?(一次运行的档案:血缘/时间/输入输出/错误;Run = RunTree,直接复用 langsmith SDK 的模型)
  • Tracer 和回调系统什么关系?(Tracer 就是一个 BaseCallbackHandler,把 on_* 事件按 run_id 血缘拼成树)
  • run_map 是干嘛的?("在飞航班表":start 登记、子事件找爹、end 注销)
  • LangChainTracer 什么时候上报?(节点 start 就 post、end 就 patch——近实时;非"跑完打包")
  • 为什么设个环境变量就有追踪?(_configure 每次组装回调时检测开关、自动挂可继承的 LangChainTracer)
  • 不用 LangSmith 怎么看树?(ConsoleCallbackHandler 打终端 / RunCollector 收内存 / astream_events 直播)

✋ 10 分钟动手

cd /Users/bitmart/work/codes/github/AI_WORK/langchain/libs/core/langchain_core

# 1. Run 的真身:langsmith 的 RunTree
cat tracers/schemas.py                        # 全文不到 15 行

# 2. 拼树骨架
sed -n '33,60p'  tracers/base.py              # BaseTracer:_start_trace/_end_trace
sed -n '86,102p' tracers/core.py              # run_map 登记簿

# 3. 投递 LangSmith
sed -n '134,175p' tracers/langchain.py        # LangChainTracer.__init__
sed -n '325,352p' tracers/langchain.py        # _persist_run_single → run.post()

# 4. 自动挂载的开关
sed -n '2497,2545p' callbacks/manager.py      # _configure 里挂 tracer
sed -n '132,136p'   tracers/context.py        # _tracing_v2_is_enabled
明日预告 · Day 20 收官:20 天走到最后一站。明天不啃新代码,做三件事:把 20 天的知识点拼成一张全景地图;讲清 core / classic / v1 / partners 四个包与 LangGraph、LangSmith 的生态关系(今天 Run = RunTree 已经剧透了一角);给你一条读完源码之后的继续进阶路线。收官见。
← Day 18 LCEL 高级组合 Day 20 · 收官:全景与生态 →