bug(sdk-python): LangchainCallbackHandler attributes only the root span in async runs — child observations lose user_id/session_id/tags

在异步(async)LangChain/LangGraph 运行中, LangchainCallbackHandler 只把 user_id 、 session_id 、 tags 、 trace_name 写到了根 span 上,子 observation(包括承载成本的 GENERATION)全

快速结论:在异步(async)LangChain/LangGraph 运行中,LangchainCallbackHandler 只把 user_id、session_id、tags、trace_name 写到了根 span 上,子 observation(包括承载成本的 GENERATION)全部丢失这些属性,导致 “cost by user” 面板和按 tag 过滤基本失效。优先排查是否有 async 调用、以及是否在应用层用 propagate_attributes 包住整段流式调用。

适用环境:Issue 中确认涉及的工具为 Langfuse Python SDK 的 LangChain 集成(langfuse.langchain.CallbackHandler,即 LangchainCallbackHandler)、LangChain / LangGraph 回调机制、OpenTelemetry context propagation。Issue 未给出具体 Python、CUDA、PyTorch、显卡或依赖版本号,故不补写。

最快修复方案:暂无确认的一步修复方案(SDK 侧修复方向仍在讨论)。Issue 中由 Dosu 确认的临时绕过方式:在应用自己的上下文中包裹 propagate_attributes(user_id=..., session_id=..., tags=..., trace_name=...),再在其中执行 graph.astream_events(..., config={"callbacks": [handler]}) 等异步调用。因为 LangChain 会把调用方(应用)的 context 复制进每次 executor 派发,所以这样能被子回调看到。

注意事项:该 workaround 属于临时方案,不是 SDK 内部修复;在异步路径下需要显式包裹整段调用,漏包则子 span 仍无归因。另一个被讨论的可选方向是给 handler 设置 run_inline = True,让回调直接在事件循环上下文中执行,但代价是回调会被串行化到事件循环上,回调中若有慢 I/O 或重序列化可能阻塞事件循环——该方向在 Issue 中只是分析,未确认为最终修复。

问题场景

用户在 async 的 LangChain / LangGraph 应用中使用 Langfuse 的 LangChain 回调处理器(LangchainCallbackHandler)做 tracing,通过运行元数据传入 langfuse_user_id、langfuse_session_id、langfuse_tags、langfuse_trace_name。同步(sync)运行同样代码时每个 observation 都能正确带上属性;换成 async 运行后,只有根 span 带属性,所有子 observation(含 LLM 的 GENERATION)导出的数据里没有 user.id、session.id 和 langfuse.trace.tags。在 v4 上问题更明显:v4 基于宽表 observations 聚合,未归因的 generation 就等于未归因的成本,内置 “cost by user” 面板会把几乎全部开销统计到 unknown user,按 trace tag 过滤 observations 表也只返回根 span。

报错原文

bug(sdk-python): LangchainCallbackHandler attributes only the root span in async runs — child observations lose user_id/session_id/tags

Issue 中给出的关键机制代码(LangChain AsyncCallbackManager 的 _ahandle_event_for_handler):

await asyncio.get_event_loop().run_in_executor(
    None,
    functools.partial(copy_context().run, event, *args, **kwargs),
)

原因分析

当前实现里,on_chain_start 在 parent_run_id is None(根 run)时通过 propagate_attributes(...) 上下文管理器进入并执行 otel_context_api.attach(context),同时把上下文管理器存到 root_run_state.propagation_context_manager。问题出在这个 context 的作用范围上:

LangchainCallbackHandler 是同步的 BaseCallbackHandler,且没有设置 run_inline,因此 run_inline 为 False。LangChain 的 AsyncCallbackManager 会把每个回调通过 copy_context().run(...) 丢到 executor 线程里执行——copy_context() 在事件循环中取的是调用方 context 的一个副本。_propagate_attributes 内部做的 attach() 只修改了这个副本,executor 调用返回副本即被丢弃。之后每个回调(on_chat_model_start、on_tool_start 等)拿到的是调用方 context 的又一个全新副本,从未见过那次 attach,于是 LangfuseSpanProcessor.on_start → _get_propagated_attributes_from_context(context) 取不到任何属性。只有根 span 因为在同一次回调内通过 _set_propagated_attribute 的 span.set_attribute 分支被直接打标,才保留了属性。

Issue 提到这与 langfuse/langfuse#13023 描述的异步派发机制相同,但那个 Issue 只涉及根 span 与 trace_name,“向子节点传播”这一半并未被处理。

环境排查

  • 确认调用路径是异步的(astream_events、ainvoke、astream 等),而同步路径表现正常——这是本 Issue 的典型特征。
  • 确认使用的是 Langfuse Python SDK 的 LangChain 集成 CallbackHandler / LangchainCallbackHandler,并确认 handler 是否为同步 BaseCallbackHandler、是否设置了 run_inline。
  • 确认 Langfuse SDK 版本,Issue 未给出具体版本号,建议对照当前 langfuse/langchain/CallbackHandler.py 与 langfuse/_client/propagation.py 源码确认 propagate_attributes 的实现是否仍为上述形态。
  • 确认用户/会话/tag/trace_name 是通过运行元数据(langfuse_user_id 等)传入,而不是通过 propagate_attributes 在应用层显式包裹。
  • 确认 LangChain / LangGraph 版本中 AsyncCallbackManager 仍以 run_in_executor + copy_context() 派发回调。
  • Issue 未提供 Python、CUDA、PyTorch、显卡信息,无需排查这些项。

解决步骤

  1. 先用 Issue 提供的自包含复现脚本(InMemorySpanExporter,无需网络与凭据)分别跑 sync 与 async 两条路径,打印每个 span 的 user.id / session.id / langfuse.trace.tags,确认只有 async 下子 span 为空。
  2. 确认根因:对照 on_chain_start 中 if parent_run_id is None: 分支,确认 propagate_attributes() 的 attach() 是在 executor 线程的 context 副本中执行的,且后续回调各自拿到新的副本。
  3. 应用层临时绕过(Issue 中由 Dosu 确认为正确的 interim solution,属可优先尝试):在应用自己的上下文中包裹整段流式调用,例如:
    from langfuse import propagate_attributes
    
    with propagate_attributes(user_id=..., session_id=..., tags=..., trace_name=...):
        async for event in graph.astream_events(..., config={"callbacks": [handler]}):
            ...

    因为 LangChain 会把调用方(应用)的 context 复制进每次 executor 派发,这样属性就能被所有子回调看到。

  4. 若评估接受回调串行化的代价,可尝试给 handler 设置 run_inline = True。这会让 AsyncCallbackManager 直接在事件循环的 context 中调用回调,而不是走 run_in_executor,从而使 on_chain_start 中的 attach() 对后续回调可见。注意这只是 Issue 中讨论的方向,未经确认是最终修复。
  5. 关注 SDK 侧修复。Issue 中讨论的另一方向是:不依赖跨 executor 副本的 OTel context 传播,而是把解析出的 trace 属性存进 _RootRunState(共享实例状态,非 context-local),在每个子回调(on_chat_model_start、on_tool_start 等)中当 run 属于已有属性的根时,直接用 span.set_attribute 重新应用。这可以完全绕开 executor 副本问题,但同样属于提议方向,非已验证方案。
  6. 如维护者决定单独处理文档缺口,可参考 Issue 建议:在 user tracking 文档页注明 async LangChain/LangGraph 应用需要 propagate_attributes 包裹才能获得完整的子 span 归因。

验证方法

复用 Issue 的复现脚本,在 async 路径下用 InMemorySpanExporter 读取已结束的 span,逐条检查子 observation(尤其是 langfuse.observation.type 为 GENERATION 的 span)的 user.id、session.id、langfuse.trace.tags 是否与根 span 一致,而不再只是根 span 有值。另一个端到端验证方式:在 Langfuse 的 “cost by user” 面板确认开销不再集中到 unknown user,并按 trace tag 过滤 observations 表,确认返回的不只是根 span。

参考来源

langfuse/langfuse #16177

GamsGo AI

AI 工具推荐

想把多个 AI 模型放在一个入口?

GamsGo AI 集成 ChatGPT、DeepSeek、Gemini、Claude、Midjourney、Veo 等常用模型,适合写作、绘图、视频和日常 AI 工作流。

了解 GamsGo AI

推广链接:通过此链接购买,我可能获得佣金,不影响你的价格。

这个方案解决了吗?

celebrityanime
celebrityanime
文章: 25642

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注