追踪与可观测:回调事件怎么拼成 Run 树、又怎么飞进 LangSmith
Day17 学了回调总线,Day18 每个配件都在 get_child(tag=...) 续树——今天看这棵树的"成品":tracers/ 目录。三个层次:①Run——一条带血缘的运行记录(它其实就是 langsmith SDK 的 RunTree);②BaseTracer——一个特殊的回调 handler,把零散事件组装成树;③LangChainTracer——把树实时 POST 到 LangSmith 后台。看完你就懂:网页上那个能逐层展开的执行瀑布图,是怎么从你的一行 invoke 里长出来的。
痛点:线上答错了一题,怎么复盘
BaseTracer 继承自 BaseCallbackHandler(tracers/base.py:33 的类签名写得明明白白),它监听所有 on_*_start/end 事件,靠 run_id/parent_run_id 血缘把事件缝合成一棵树,并在关键时刻"持久化"。发到哪、怎么发,由子类决定——发给 LangSmith 的那个子类,就叫 LangChainTracer。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 / outputs、start_time / end_time、events(时间线,如 start/new_token/end)、tags / extra(元数据——Day18 的 retry:attempt:2、branch:default 都落在这)、error(带堆栈)。child_runs父 Run 持有子 Run 列表(_TracerCore._add_child_run,tracers/core.py:107:parent_run.child_runs.append(child_run))——树形结构在内存里就这么一行拼起来的。Day16 又闭环了还记得 _exit_history(run: Run, ...) 吗?护士收到的那个 run 就是这里的 Run——所以她能从 run.inputs/run.outputs 里精确取出该写进历史的消息。BaseTracer:从零散回调拼出一棵树
拼树的骨架在 _TracerCore(libs/core/langchain_core/tracers/core.py:40,维护 run_map)+ BaseTracer(tracers/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 家族都是往这两个洞里填不同行为。LangChainTracer:把树实时投递给 LangSmith
真正连接平台的子类 LangChainTracer(libs/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 页面上的费用统计就是这么来的。一个环境变量就开追踪的真相
用户体感最神奇的一点:代码一行不改,设 LANGSMITH_TRACING=true + LANGSMITH_API_KEY=... 就全量追踪。拼图的最后一块在 Day17 看过的 _configure(libs/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_enabled(tracers/context.py:132)两种开启方式:环境变量(委托 langsmith 的 tracing_is_enabled()),或代码里 with tracing_v2_enabled(): 上下文管理器(context.py:40)临时开启——后者靠 ContextVar,只影响 with 块里的调用。每次组装都检查Day17 讲过:每个组件开跑前都会 _configure 组装导播台。所以"开关"是在每次运行时动态判定的——不用重启进程,改环境上下文即刻生效。inherit 传全树挂上的 tracer 是可继承 handler——根链装一次,检索器/模型/工具的事件全都收得到。这就是"一个变量、全链路"的原理。invoke 完立刻退出,可能最后一批还没 flush,LangSmith 上看到"缺胳膊少腿"的 trace。解法:退出前调 tracer.wait_for_futures()/client.flush()(langchain.py:466-467 就是干这个的),或用官方建议的方式优雅收尾。长驻服务无此烦恼。Run 树全景图 + 一次 RAG 请求的真实值
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 就没法看这棵树了吗?
👨🏫 老师:树是在你进程里拼的,"投递到哪"只是子类的选择。同目录还有免费选项:ConsoleCallbackHandler(tracers/stdout.py,把树彩色打印到终端,set_debug(True) 就在用它)、RunCollectorCallbackHandler(tracers/run_collector.py,把 Run 收进内存 list 供你自己分析)、以及 Day17 见过的 event_stream(把树"直播"成事件流)。另外 LangSmith 的协议是 OTel 兼容方向演进的,也有人把回调桥接到自建观测栈。核心资产是这棵树,去哪展示随你。
tracers 家族其他成员 + 今日小结
| 成员 | 文件 | persist 的姿势 |
|---|---|---|
LangChainTracer | tracers/langchain.py:134 | 逐节点 post/patch 到 LangSmith(今天主角) |
ConsoleCallbackHandler | tracers/stdout.py | 彩色打印到终端(debug 模式默认) |
RunCollectorCallbackHandler | tracers/run_collector.py | 攒进内存 list(测试/自定义分析) |
RootListenersTracer | tracers/root_listeners.py:23 | 只盯根 Run,触发 on_start/on_end 监听器——Day16 with_listeners 的底层 |
_AstreamEventsCallbackHandler | tracers/event_stream.py:101 | 翻译成事件塞队列——Day17 astream_events 的底层 |
EvaluatorCallbackHandler | tracers/evaluation.py | 把 Run 喂给评估器打分(在线评估) |
🧠 今天你应该能回答
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
core / classic / v1 / partners 四个包与 LangGraph、LangSmith 的生态关系(今天 Run = RunTree 已经剧透了一角);给你一条读完源码之后的继续进阶路线。收官见。