用 Jaeger + OpenTelemetry 给 AI Agent 加上分布式追踪

2026-05-26 19 预计阅读时间:1 分钟
来源:cncf.io AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:10 分钟

微服务时代,Jaeger 是工程师理解调用链的利器;如今 AI Agent 大量涌入生产环境,调用链从"服务 A → 服务 B"变成了"Agent 规划 → 调 LLM → 调工具 → 再规划"。链路更长、分支更多、失败模式更隐蔽。Jaeger 正在围绕 OpenTelemetry 做一轮针对性演进,让 Agent 的每一步决策都可观测。

Agent 追踪和微服务追踪不是同一件事

微服务调用链的典型特征:路径相对固定、每个节点是确定性的服务、延迟主要来自网络和 IO。Agent 链路完全不同——

  • 动态拓扑:Agent 每次运行可能选择不同的工具组合,链路形状随 prompt 和中间结果变化。
  • 嵌套递归:Agent 可以在循环中反复调用 LLM 和外部工具,span 层级可能很深。
  • 语义信息丢失:传统 trace 只记录耗时和状态码,但 Agent 追踪还需要记录 prompt 摘要、工具选择理由、token 消耗等业务语义。

如果只拿微服务那套 span 命名方式套上去,你会看到一串叫 agent_step 的扁平 span,既看不出 Agent 做了什么决策,也定位不了哪一步 token 爆了。Jaeger 的演进方向,正是要解决这些信息缺口。

OpenTelemetry 成为连接纽带

Jaeger 早期有自己的数据格式和客户端 SDK,现在全面转向 OpenTelemetry(OTel)作为数据摄入标准。这对 Agent 追踪是个关键利好——

  1. 统一语义约定:OTel 的 Semantic Conventions 机制允许社区为 AI/LLM 调用定义标准 attribute key(比如 gen_ai.request.modelgen_ai.response.finish_reason)。Jaeger 作为后端只需渲染这些 attribute,前端不用各自发明字段名。
  2. 跨语言 SDK:无论你的 Agent 用 Python(LangChain / CrewAI)还是 TypeScript(Vercel AI SDK)写,OTel 都有对应 SDK,trace 数据格式一致,Jaeger 统一收纳。
  3. Baggage 传递上下文:Agent 的任务 ID、session ID、用户意图标签可以通过 OTel Baggage 在跨进程调用中自动传播,不用在每个工具调用里手动塞 header。

实战:给一个 Python Agent 加上 Jaeger 追踪

下面用一个最小示例演示:启动 Jaeger + OTel Collector,给一个模拟 Agent 循环加上 trace,最终在 Jaeger UI 中看到带语义 attribute 的完整链路。

1. 用 Docker 启动 Jaeger 全部组件

docker run -d --name jaeger \
  -e COLLECTOR_OTLP_ENABLED=true \
  -p 16686:16686 \
  -p 4317:4317 \
  -p 4318:4318 \
  jaegertracing/all-in-one:1.60
  • 4317/4318:OTLP gRPC / HTTP 端口,Python SDK 会往这里发数据。
  • 16686:Jaeger UI,浏览器打开 http://localhost:16686 查看链路。

版本号 1.60 是当前最新稳定版,Jaeger 1.57 起全面支持 OTLP 摄入,all-in-one 镜像内置了 Collector + Query + UI。

2. Python Agent 代码(可直接运行)

先安装依赖:

pip install opentelemetry-api opentelemetry-sdk \
  opentelemetry-exporter-otlp-proto-grpc \
  opentelemetry-instrumentation-requests

然后运行以下 Agent 脚本:

import time, random
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource

# ── 初始化 OTel SDK,指向本地 Jaeger ──
resource = Resource.create({
    "service.name": "ai-agent-demo",
    "service.version": "0.1.0",
})
provider = TracerProvider(resource=resource)
provider.add_span_processor(
    BatchSpanProcessor(
        OTLPSpanExporter(endpoint="http://localhost:4317", insecure=True)
    )
)
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("agent-loop")

# ── 模拟 Agent 循环 ──
def call_llm(prompt: str, parent_ctx) -> str:
    """模拟一次 LLM 调用,带 gen_ai 语义 attribute"""
    with tracer.start_as_current_span("call_llm", parent_ctx) as span:
        span.set_attribute("gen_ai.request.model", "gpt-4o-mini")
        span.set_attribute("gen_ai.request.prompt_summary", prompt[:60])
        span.set_attribute("gen_ai.system", "openai")
        latency = random.uniform(0.3, 1.2)
        time.sleep(latency)
        tokens = random.randint(80, 300)
        span.set_attribute("gen_ai.response.finish_reason", "stop")
        span.set_attribute("gen_ai.response.token_count", tokens)
        span.set_attribute("gen_ai.response.latency_ms", int(latency * 1000))
        return f"LLM结果: {prompt[:20]}... (tokens={tokens})"

def call_tool(tool_name: str, parent_ctx) -> str:
    """模拟一次外部工具调用"""
    with tracer.start_as_current_span(f"call_tool.{tool_name}", parent_ctx) as span:
        span.set_attribute("tool.name", tool_name)
        span.set_attribute("tool.type", "api")
        time.sleep(random.uniform(0.1, 0.5))
        if tool_name == "search_web" and random.random() < 0.15:
            span.set_attribute("tool.error", "timeout")
            span.set_status(trace.StatusCode.ERROR, "tool timeout")
            return "ERROR: timeout"
        return f"{tool_name} 返回数据"

def agent_run(task: str, max_steps: int = 4):
    """Agent 主循环:规划 → 调 LLM → 调工具 → 再规划"""
    with tracer.start_as_current_span("agent_run") as root:
        root.set_attribute("agent.task", task)
        root.set_attribute("agent.max_steps", max_steps)
        ctx = trace.get_current_span().get_span_context()
        parent_ctx = trace.SpanContext(
            trace_id=ctx.trace_id, span_id=ctx.span_id,
            trace_flags=ctx.trace_flags, is_remote=False
        )

        for step in range(1, max_steps + 1):
            with tracer.start_as_current_span(f"agent_step.{step}", parent_ctx) as step_span:
                step_span.set_attribute("agent.step_number", step)
                # 先调 LLM 做规划
                llm_result = call_llm(f"步骤{step}: 处理任务'{task}'", parent_ctx)
                # 根据步骤选不同工具
                tool = random.choice(["search_web", "read_db", "send_email"])
                tool_result = call_tool(tool, parent_ctx)
                step_span.set_attribute("agent.chosen_tool", tool)
                step_span.set_attribute("agent.llm_output_summary", llm_result[:40])
                if "ERROR" in tool_result:
                    step_span.set_attribute("agent.step_outcome", "tool_failed")
                    break
                step_span.set_attribute("agent.step_outcome", "success")

if __name__ == "__main__":
    agent_run("分析用户投诉并自动回复")
    time.sleep(2)  # 等待 BatchSpanProcessor 刷出数据
    print("Trace 已发送,打开 http://localhost:16686 查看")

3. 在 Jaeger UI 中你会看到什么

运行后打开 Jaeger UI,选择 ai-agent-demo 服务,你会看到:

  • 一个 agent_run 根 span,带 agent.task attribute。
  • 嵌套的 agent_step.1agent_step.2 … 子 span,每个记录了选了哪个工具。
  • 再下一层是 call_llmcall_tool.search_web,LLM span 里能直接看到模型名、token 数、延迟毫秒数。
  • 如果工具超时,对应 span 标红,状态为 ERROR。

这比一串扁平的 agent_step 信息密度高得多——你一眼就能定位"第 3 步调 search_web 超时了,那一步 LLM 花了 900ms、返回了 200 token"。

追踪 Agent 的几个实操要点

给 span 取有结构的名字。不要全部叫 agent_step,用 agent_step.{n}agent.plan / agent.execute / agent.reflect 这样的层级命名,Jaeger 的瀑布图才能展示决策分支。

把业务语义写进 attribute 而不是 span name。模型名、工具名、token 数、finish_reason 这些用 OTel Semantic Conventions 定义的标准 key,Jaeger UI 会自动在 span 详情里展示,搜索过滤也靠这些字段。

控制 span 数量。Agent 循环 20 步就会产生 60+ span(每步一个 step span + 一个 LLM span + 一个 tool span)。如果你的 Agent 是长跑型,考虑用 BatchSpanProcessor 并设置合理的 max_queue_sizeschedule_delay,避免内存撑爆或 Collector 压力过大。

Baggage 传 session ID。如果 Agent 调了外部服务(比如通过 HTTP 调搜索 API),用 OTel Baggage 把 session.id 自动注入请求 header,下游服务也能挂到同一条 trace 上:

from opentelemetry.baggage import set_baggage, get_baggage
from opentelemetry.context import attach, detach

ctx = set_baggage("session.id", "sess-abc123")
token = attach(ctx)
# 后续 HTTP 调用自动携带 baggage
# ...
detach(token)

还不完美,但方向明确

Jaeger 对 Agent 追踪的适配仍在进行中。目前几个现实限制——

  • LLM Semantic Conventions 还在草案阶段:OTel GenAI Working Group 的约定尚未最终发布,字段名可能微调,代码里用的 key 要跟进变更。
  • Jaeger UI 对长链路的渲染:超过 50 层嵌套的瀑布图会变得拥挤,需要配合筛选和折叠功能。
  • Agent 内部状态追踪:比如 Agent 的记忆、规划树,目前只能靠手动 attribute,还没有标准化的可视化方案。

尽管如此,用 OTel SDK + Jaeger 给 Agent 加追踪已经是当前最务实的路径。标准在成型,工具在跟进,越早把 attribute 写规范,未来迁移成本越低。

上手清单

  1. 用 all-in-one 镜像本地跑起 Jaeger,确认 OTLP 端口可用。
  2. 在 Agent 入口初始化 OTel TracerProvider,指向 Jaeger Collector。
  3. 给 Agent 循环、LLM 调用、工具调用各加一层 span,写进 gen_ai.*tool.* attribute。
  4. 跑一次完整任务,在 Jaeger UI 验证链路形状和语义字段是否可读。
  5. 上生产前换成 Jaeger Operator + OTel Collector 的正式部署,加上采样策略控制数据量。

相关推荐