Trace 里看不到关键 tool call:定位缺口并修复

LangSmith 或 Langfuse 的 trace 里只有结果、没有 tool call span。本文用一张判断表帮你锁定七种根因中的哪一种,并给出对应修复,让 trace 重新完整。

Agent 的最终输出写着「我删除了旧的 migration 文件并执行了 schema 更新」,但 trace 里一个 delete_file 调用都没有,也找不到任何数据库 tool 调用。操作确实发生了——文件没了、schema 变了——可 trace 里没有任何记录。你无法审计删了什么、无法重放这次运行,也无法证明 Agent 走的是正确流程。

最快的修复: 九成情况下,产生副作用的代码跑在了框架的 trace 路径之外——一个被直接调用的普通 Python 函数、一个 tracer 没跟进去的 asyncio.create_task(),或者一个在 serverless 进程退出前没执行的 flush()。先用下面的判断表确定你属于哪一种,再跳到对应步骤。

先说一个时效性问题:如果你用的是 LangSmith,而最近 tool span 突然抓不到了,先检查环境变量。截至 2026 年 6 月,规范的开关是 LANGSMITH_TRACING=true;旧的 LANGCHAIN_TRACING_V2=true 仍然能用但已弃用,而迁移到一半的项目有时会两个都没设。

你属于哪一种?

Trace 里的现象最可能的根因跳转
LLM span 里能看到 tool_use/工具调用参数,但没有独立的工具子 span工具是直接 Python 调用,没走 executorStep 1
外层调用瞬间返回,真正干活的部分没有 span工作被挪进了 tracer 没跟进的 async task / 线程Step 2
半截记录:有 tool_start,没有 tool_end异常在 end 回调触发前被吞掉了Step 3
应用侧统计的工具次数总是比平台多几条进程退出前 span 没 flush(serverless/Lambda)Step 4
Agent 跑代码动了文件/数据库,registry 日志里却没有通过 exec/代码执行调用了未注册的函数Step 5
升级 SDK 后次数掉了,或只有部分工具类型出现SDK 版本不匹配 / 采样丢弃Step 6-7

常见原因

1. 工具跑在了 trace 路径之外

最常见的根因。一个「就跑条 shell 命令而已」的辅助函数被直接从 Python 里调用,没有走 Agent 的 tool executor,于是绕过了 tracer。副作用发生了,trace 什么都没记。

在 LangChain/LangGraph 里这有个很典型的信号:父级 LLM span 里仍然有 tool_use block(模型发出的调用请求),但没有对应的 execute_tool 子 span 来记录真正的执行。模型提了请求、有东西跑了,却没有任何东西记录这次执行。

怎么判断:列出所有产生副作用的函数(文件写入、子进程调用、HTTP 请求、数据库写操作)。逐个检查它是通过框架的 tool executor 调用的(@tool 装饰的可调用对象、Tool/StructuredTool、或 @traceable 函数),还是作为普通 Python 函数被直接调用。任何直接调用都是不可见的。

2. 工具跑在 tracer 没跟进的 async task 或线程里

工具触发了 asyncio.create_task()threading.Thread().start()concurrent.futures.submit(),真正的工作跑在另一个执行上下文里。tracer 是通过 contextvars 携带当前 span 的;新的 task 或线程不会继承这个上下文,除非你显式复制。于是外层调用显示为瞬间返回,而真正的工作要么变成孤儿 span(没有父级),要么干脆什么都不显示。

怎么判断:在所有产生副作用的函数里搜 asyncio.create_task(Thread(.submit(。在 Langfuse/LangSmith 界面里找没有父级 trace 的「孤儿」span——那些就是上下文丢失的工具调用。

3. 异常在 end 回调触发前被吞掉

工具执行到一半抛了异常,一个宽泛的 except Exception: pass 在 tracer 的 on_tool_end 回调运行前就把它吃掉了。你只会拿到一个 tool_start,没有对应的 tool_end——半截记录,或者什么都没有。

怎么判断:在 tool wrapper 里 grep except Exception: passexcept Exception: continue。如果 on_tool_end 回调位于一个可能被跳过的 try 块里,就会出现不完整的记录。

4. 进程退出前 span 没被 flush

Langfuse 和 LangSmith 会把 span 批量缓存、在后台异步发送,以保证应用的响应速度。如果进程在队列发完之前就退出了,最后那批 span 就丢了。这在短生命周期场景里最严重:AWS Lambda、Cloud Run、Vercel/Cloudflare Workers、批处理脚本、Jupyter cell。

怎么判断:应用侧统计的工具调用次数稳定地比平台显示的多几条,而且缺的那几条总是出现在一次运行的末尾。

5. Agent 通过代码执行调用了未注册的函数

在 LangGraph、CrewAI、或任何带 execute_code/run_python 能力的 Agent 里,模型可以写 Python 直接调用某个函数,完全不经过 tool registry。框架 trace 的是工具调用,不是任意代码。

怎么判断:检查 Agent 是否有任何代码执行能力。如果有,它能 import 的任何敏感函数都可以在没有 registry 条目、也没有 span 的情况下被调用。

6. Tracing SDK 版本被钉死,缺少更新的 span 类型

你的技术栈把 LangSmith(或某个 OpenTelemetry GenAI instrumentation)钉在了一个旧版本上,这个版本早于 Agent 现在会发出的某种工具/span 类型。旧 SDK 会静默丢弃它不认识的事件。2026 年 6 月的 OpenTelemetry GenAI semantic conventions 把工具执行记成一个名为 execute_tool {gen_ai.tool.name} 的 span,并带 gen_ai.operation.name = execute_tool;早于这套约定的 instrumentation 可能根本不会产生这个 span。

怎么判断:把 requirements.txt 里的 SDK 版本和「加入你所依赖的 span 类型」那个版本的 release notes 对一下。

7. 采样把 span 丢了

生产环境的 tracer 把采样率设在 100% 以下来省成本,于是一个低概率但关键的调用(比如一次破坏性删除)正好被采样丢弃。它执行了,却没留下记录。

怎么判断:检查采样器配置。任何低于 1.0 的采样率都意味着破坏性或安全敏感的调用覆盖不全。

最短修复路径

Step 1:让所有产生副作用的调用都走 tracer

包装裸函数,让框架能看见它。在 LangChain/LangGraph 里用 @tool 装饰器或 StructuredTool;对任意代码用 @traceable

from langchain_core.tools import tool, StructuredTool

# 之前——直接调用,没有 span
def delete_file(path: str):
    os.remove(path)

# 之后——会被记成 execute_tool span
@tool
def delete_file(path: str) -> str:
    """删除指定路径的文件。"""
    os.remove(path)
    return f"deleted {path}"

# 或者显式注册一个已有函数
delete_tool = StructuredTool.from_function(
    func=delete_file, name="delete_file",
    description="删除指定路径的文件",
)

对于框架之外的副作用,直接装饰源函数,这样无论谁调用它都会发出 span:

import functools
from langsmith import traceable

@traceable(run_type="tool", name="delete_file")
def delete_file(path: str):
    os.remove(path)

把这套用到每个写文件、跑子进程、调 API、改数据库的函数上。

Step 2:把 trace 上下文传播进 async task

新的 task 或线程不会继承当前 span。要么复制上下文,要么用会替你做这件事的装饰器(Langfuse 的 @observe、LangSmith 的 @traceable,只要你待在被装饰的协程内部,它们就会处理)。

import asyncio, contextvars

async def spawn_tool_task(tool_fn, **kwargs):
    ctx = contextvars.copy_context()  # 带上当前活跃的 span
    loop = asyncio.get_running_loop()
    return await loop.run_in_executor(None, lambda: ctx.run(tool_fn, **kwargs))

不要 fire-and-forget 一个产生副作用的协程。要 trace 它、await 它:

from opentelemetry import trace as otel_trace
tracer = otel_trace.get_tracer(__name__)

async def traced_async_tool(tool_name: str, coro):
    with tracer.start_as_current_span(f"execute_tool {tool_name}") as span:
        span.set_attribute("gen_ai.tool.name", tool_name)
        try:
            result = await coro
            span.set_attribute("gen_ai.tool.result", str(result)[:500])
            return result
        except Exception as e:
            span.record_exception(e)
            raise

result = await traced_async_tool("run_migration", run_migration_coro())

Step 3:绝不在 tracer 回调里吞异常

# 错误——静默丢掉 trace 事件
def on_tool_end(self, output, **kwargs):
    try:
        self.log_tool_output(output)
    except Exception:
        pass

# 正确——至少打日志,让你知道 trace 失败了
def on_tool_end(self, output, **kwargs):
    try:
        self.log_tool_output(output)
    except Exception as e:
        logger.error("Tracer failed to record tool_end: %s", e)

Step 4:进程退出前 flush

在每次短生命周期运行结束时强制把队列发完。两个 SDK 都有 flush();如果你要销毁客户端就调 shutdown()

from langfuse import get_client
langfuse = get_client()

# AWS Lambda / Cloud Run——在 finally 里 flush
def lambda_handler(event, context):
    try:
        return run_agent(event)
    finally:
        langfuse.flush()  # 必须在函数返回前完成

在 Vercel 和 Cloudflare Workers 上,响应一返回后台发送就被掐断。用 waitUntil 让运行时撑到 flush 完成:

// Vercel / Cloudflare Workers
ctx.waitUntil(langfuse.flushAsync());

长生命周期的服务器不需要手动 flush,但仍然建议注册一个退出钩子:

import atexit, signal, sys
atexit.register(langfuse.flush)
signal.signal(signal.SIGTERM, lambda *_: (langfuse.flush(), sys.exit(0)))

Step 5:强制所有工具走 registry

ALLOWED_TOOLS = {"delete_file", "write_file", "run_bash", "call_api"}

def execute_tool(tool_name: str, inputs: dict):
    if tool_name not in ALLOWED_TOOLS:
        raise PermissionError(f"Unregistered tool call blocked: {tool_name!r}")
    return TOOL_REGISTRY[tool_name](**inputs)

如果 Agent 有代码执行能力,给每次进入敏感模块的调用加一条审计日志,这样即便绕过了 registry 也仍然留有记录。

Step 6:升级并钉住 tracing SDK

pip show langsmith | grep Version   # 当前版本
pip install --upgrade langsmith     # 升到最新
# requirements.txt:钉一个范围,避免静默回退悄悄溜进来
# langsmith>=0.4,<0.5

升级后重放一次有代表性的运行,确认 tool span 的数量符合预期。

Step 7:破坏性调用按 100% 采样

通过过滤低价值工具来降低数据量,而不是随机采样。每一次破坏性、不可逆、安全敏感的调用都要保留。

def should_trace(tool_name: str) -> bool:
    LOW_VALUE = {"health_check", "ping", "get_timestamp"}
    return tool_name not in LOW_VALUE

用 OpenTelemetry 时,写一个始终保留破坏性 span 的自定义采样器:

from opentelemetry.sdk.trace.sampling import ParentBased, ALWAYS_ON

class DestructiveAlwaysSampler(ParentBased):
    def should_sample(self, parent_context, trace_id, name, *args, **kwargs):
        if any(kw in name for kw in ("delete", "drop", "truncate", "destroy")):
            return ALWAYS_ON.should_sample(parent_context, trace_id, name, *args, **kwargs)
        return super().should_sample(parent_context, trace_id, name, *args, **kwargs)

如何确认已修复

  1. 跑一个工具调用序列已知的脚本任务(例如 write_filedelete_filecall_api)。
  2. 打开 trace,数 execute_tool span 的数量。数量必须等于你实际发起的调用数,且没有孤儿(无父级)span。
  3. 每个 tool span 都必须同时有 start 和 end 事件,以及它的输入和输出。
  4. 写一个断言 trace 完整性的自动化测试把结果锁住:
def test_trace_has_all_tool_calls():
    trace = run_agent_and_fetch_trace(task="delete then migrate")
    tool_spans = [s for s in trace.spans if s.run_type == "tool"]
    assert {s.name for s in tool_spans} >= {"delete_file", "run_migration"}
    assert all(s.end_time for s in tool_spans)  # 没有半截记录

预防建议

  • 装饰每个产生副作用的函数(文件写入、子进程、API、数据库),让它无论被怎么调用都会发出 span。
  • 把 trace 上下文传播进 async task 和线程;绝不 fire-and-forget 一个有副作用的协程。
  • 绝不在 tracer 回调里吞异常——打日志并告警;tracer 静默失败和没有 tracer 一样糟。
  • 强制有副作用的工具走注册过的 executor,而不是从 Agent 代码里直接 Python 调用。
  • 在每个短生命周期/serverless 场景里都在退出前 flush;Vercel/Cloudflare 上用 waitUntil
  • 破坏性、不可逆、安全敏感的调用保持 100% 采样;降低数据量靠过滤低价值的只读工具。
  • 在每行日志里带上 trace ID,这样即便 trace 本身不完整,你也能把日志和 span 对应起来。
  • 钉住 SDK 版本,每次升级后都重跑 trace 完整性测试。

常见问答 (FAQ)

Q:某次工具调用的输入出现在 LLM span 里,却没有独立的工具 span,为什么? A:模型发出了 tool_use 请求(所以参数出现在父级 span 里),但执行跑在了 trace 路径之外——通常是一次直接的 Python 调用,而不是注册过的工具。把它改成走 executor,或用 @traceable/@tool 包装(Step 1)。这是 LangGraph 报告里最常见的形态。

Q:本地 trace 正常,但在 Lambda / Cloud Run 里丢掉最后几条 tool call。 A:典型的 flush 问题。进程在后台发送队列发完之前就退出了。在 finally 块里调 flush()(Step 4);Vercel/Cloudflare 上用 ctx.waitUntil(...)

Q:能重建一个 trace 里缺失的 tool call 做了什么吗? A:能重建一部分,靠副作用。查 git 历史(git log --all --diff-filter=D -- path)、数据库审计日志、操作系统文件访问日志(macOS 用 fs_usage,Linux 用 auditd)。这些能告诉你发生了什么,但说不清 Agent 的意图和确切输入——所以才要在源头做埋点。

Q:LangSmith 会自动捕获一切吗? A:它会捕获走 LangChain 工具和 chain 抽象层的调用。直接的 Python 调用、子进程、exec() 执行的代码都不可见,除非你加 @traceable 包装,或对裸 SDK 调用用 wrap_openai/wrap_anthropic

Q:怎么跨微服务追踪 tool call? A:用基于 W3C TraceContext 的分布式追踪。在每次服务间 HTTP 调用上传 traceparent header。标准做法是 OpenTelemetry 配 Jaeger 或 Tempo 后端;所有服务发出的 span 用同一个 trace ID 串联,工具执行遵循 execute_tool {tool_name} 这套 GenAI span 约定。

Q:对破坏性工具做 100% 采样的开销有多大? A:可以忽略。一次文件删除或数据库 drop 本身就很重;span 的开销通常在 1ms 以内。一次未被追踪的破坏性操作的代价远超于此。

相关阅读

标签: #AI 编程 #Agents #排查