Agent AtlasAI Agent 知识库
06.06教程 · Tutorials

Trace 与可观测性实战

OpenTelemetry、GenAI 语义约定、Jaeger 本地环境、span 设计、回放、告警与脱敏。

没有 trace 的 Agent,调试只能靠猜。这个教程把一次请求拆成树状 span,接到 Jaeger,并用一个真实故障走完「定位 → 复现 → 修复 → 回归」的流程。

目标

一次请求在 Jaeger 里长这样:

text
agent.run                     12.4s
├─ context.build               40ms
├─ llm.chat #1                 2.1s   in=4200 out=180
├─ tool.search_docs            620ms  ok, 3 results
├─ llm.chat #2                 1.8s
├─ tool.create_refund          410ms  ok, refund_id=...
├─ llm.chat #3                 3.2s
└─ output.guard                 9ms   blocked=false

出问题时,你能看到每一步的输入摘要、token、耗时、工具参数和错误。

本地环境

docker-compose.yml:

yaml
services:
  jaeger:
    image: jaegertracing/all-in-one:1.62
    ports:
      - "16686:16686"   # UI
      - "4318:4318"     # OTLP HTTP
bash
docker compose up -d

安装依赖:

bash
pip install opentelemetry-sdk opentelemetry-exporter-otlp-proto-http

打开 http://localhost:16686,服务名选 miniagent。

Step 1:初始化 Tracer

telemetry.py:

python
from __future__ import annotations

import os

from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor

resource = Resource.create(
    {
        "service.name": os.getenv("OTEL_SERVICE_NAME", "miniagent"),
        "service.version": os.getenv("APP_VERSION", "dev"),
        "deployment.environment": os.getenv("APP_ENV", "local"),
    }
)

provider = TracerProvider(resource=resource)
provider.add_span_processor(
    BatchSpanProcessor(
        OTLPSpanExporter(
            endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318/v1/traces")
        )
    )
)
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("miniagent")

生产环境一般用 collector,而不是直接把应用指向 Jaeger。应用只负责导出 OTLP,后端可以换 Jaeger、Tempo、Langfuse 或云厂商。

Step 2:给 LLM 调用加 span

python
from __future__ import annotations

import json
import time

from opentelemetry import trace

from .telemetry import tracer


def traced_llm(client, model: str, messages: list[dict], tools: list[dict] | None = None):
    with tracer.start_as_current_span("llm.chat") as span:
        span.set_attribute("gen_ai.operation.name", "chat")
        span.set_attribute("gen_ai.system", "openai-compatible")
        span.set_attribute("gen_ai.request.model", model)
        span.set_attribute("gen_ai.request.temperature", 0)
        span.set_attribute("gen_ai.request.max_tokens", 800)
        span.set_attribute("gen_ai.request.tool_count", len(tools or []))
        span.set_attribute("gen_ai.prompt.hash", hash_messages(messages))

        started = time.perf_counter()
        try:
            resp = client.chat.completions.create(
                model=model,
                messages=messages,
                tools=tools or None,
                temperature=0,
                max_tokens=800,
            )
            msg = resp.choices[0].message
            span.set_attribute("gen_ai.response.finish_reasons", [resp.choices[0].finish_reason or ""])
            if resp.usage:
                span.set_attribute("gen_ai.usage.input_tokens", resp.usage.prompt_tokens)
                span.set_attribute("gen_ai.usage.output_tokens", resp.usage.completion_tokens)
            span.set_attribute("llm.has_tool_calls", bool(msg.tool_calls))
            span.set_attribute("llm.latency_ms", int((time.perf_counter() - started) * 1000))
            return resp
        except Exception as e:
            span.record_exception(e)
            span.set_status(trace.Status(trace.StatusCode.ERROR, str(e)))
            raise

注意:

  • 不要记录完整提示词,记录哈希、长度、版本;
  • 记录模型、token、finish reason;
  • 异常要 record_exception + 设置状态,否则 trace 里看不到错误。

Step 3:给工具加 span

python
import json

from .telemetry import tracer
from .redact import redact


def traced_tool(name: str, args: dict, func):
    with tracer.start_as_current_span(f"tool.{name}") as span:
        span.set_attribute("tool.name", name)
        span.set_attribute("tool.args", json.dumps(redact(args), ensure_ascii=False)[:1000])
        span.set_attribute("tool.read_only", getattr(func, "read_only", True))

        try:
            result = func(**args)
            summary = str(result)[:300]
            span.set_attribute("tool.result_summary", summary)
            span.set_attribute("tool.ok", True)
            return result
        except Exception as e:
            span.record_exception(e)
            span.set_attribute("tool.ok", False)
            span.set_status(trace.Status(trace.StatusCode.ERROR, str(e)))
            raise

工具 span 是排查的重灾区:参数错、权限拒绝、超时、返回空,都会在这里留下证据。

Step 4:一次请求一棵树

python
def run(goal: str):
    with tracer.start_as_current_span("agent.run") as span:
        span.set_attribute("agent.goal", goal[:200])
        span.set_attribute("agent.max_steps", 8)

        with tracer.start_as_current_span("context.build"):
            messages = build_context(goal)

        for step in range(1, 9):
            with tracer.start_as_current_span("agent.step") as step_span:
                step_span.set_attribute("step", step)

                resp = traced_llm(client, MODEL, messages, TOOLS)
                msg = resp.choices[0].message

                if not msg.tool_calls:
                    span.set_attribute("agent.completed", True)
                    span.set_attribute("agent.steps_used", step)
                    return msg.content

                for call in msg.tool_calls:
                    args = json.loads(call.function.arguments or "{}")
                    result = traced_tool(call.function.name, args, TOOL_IMPL[call.function.name])
                    messages.append(
                        {
                            "role": "tool",
                            "tool_call_id": call.id,
                            "content": json.dumps(result, ensure_ascii=False)[:4000],
                        }
                    )

        span.set_attribute("agent.completed", False)
        return "达到最大步数"

因为 span 是嵌套的,Jaeger 会自动还原父子关系。不要手动拼 trace id。

Step 5:脱敏

redact.py:

python
import re

PATTERNS = [
    (re.compile(r"1[3-9]\d{9}"), "[PHONE]"),
    (re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+"), "[EMAIL]"),
    (re.compile(r"\b\d{17}[\dXx]\b"), "[ID]"),
    (re.compile(r"sk-[A-Za-z0-9]{20,}"), "[KEY]"),
]


def redact(value):
    if isinstance(value, dict):
        return {k: redact(v) for k, v in value.items()}
    if isinstance(value, list):
        return [redact(v) for v in value]
    if isinstance(value, str):
        for pattern, mask in PATTERNS:
            value = pattern.sub(mask, value)
        return value
    return value

日志和 trace 都可能成为泄露点。在写入前脱敏,而不是事后删除。

Step 6:定位一个真实故障

用户投诉:「退款提交了两次。」

  1. 按 task_id 搜索 trace;
  2. 展开 agent.run,看到两次 tool.create_refund;
  3. 第一次 span 状态是 error,错误码 TIMEOUT;
  4. 第二次成功;
  5. 根因:工具超时后循环重试,但退款接口不幂等。

修复:

  • 工具加 idempotency_key;
  • 重试只对可重试错误;
  • 写操作在 trace 中标记 tool.side_effect=true;
  • 对账任务检查重复退款。

回归:

  • 评估集加入「工具超时后重试」案例;
  • 断言 create_refund 最多调用一次;
  • 监控重复退款单数量。

这就是 trace 的价值:它把「回答不对」变成「第 4 步工具超时,第 5 步重试导致重复写入」。

Step 7:指标与告警

Trace 适合个案,指标适合趋势。至少记录:

text
agent_requests_total{status}
agent_steps_histogram
agent_tokens_total{model}
agent_cost_total{tenant}
tool_calls_total{tool,status}
tool_latency_ms{tool}
llm_ttft_ms{model}
eval_pass_rate{version}

告警建议:

信号阈值示例可能原因
平均步数突增+50%循环、工具变差
工具错误率> 10%下游 API 变更
拒答率+20%检索/提示回归
单任务成本> 预算上下文膨胀
P95 延迟> SLO串行调用、限流
评估通过率低于基线发版回归

告警要带 trace 链接和最近失败样本,否则值班的人只能重新查。

采样与保留

  • 错误全量保留;
  • P95 以上慢请求全量;
  • 正常请求 1%–10% 采样;
  • 高风险写操作全量;
  • 原始 trace 保留 7–30 天,聚合指标长期保留。

采样不能只看比例:一个 1% 的采样可能刚好漏掉罕见的越权路径。按「错误/慢/高风险」分层。

验收清单

  • 一次请求能还原成树状 trace
  • LLM span 有模型、token、延迟、finish reason
  • 工具 span 有参数摘要、结果、错误
  • 提示词不落原文,敏感信息脱敏
  • Jaeger 能看到完整链路
  • 错误与慢请求全量保留
  • 关键指标有面板与告警
  • 能从 trace 复现并回归

常见错误

只记录最终输出

定位不到具体一步。至少记录 LLM、工具、错误。

trace id 不贯穿业务

用户投诉时找不到请求。task_id、session_id 要一路传下去。

记录完整提示词

体积大、含敏感信息。记录版本、哈希、长度。

只做平均指标

长尾被掩盖。看 P95/P99 与失败分布。

小结

  • Agent 调试依赖树状 trace,而不是一行日志。
  • span 的字段设计决定你能不能定位问题。
  • 脱敏在写入前做。
  • trace 看个案,指标看趋势,评估看回归。
  • 能回放,才能从「猜」变成「修」。

下一篇把这些能力落到一个真实业务:生产级客服 Agent。

读完了?进度会保存在本地浏览器。
内容会持续修订。发现错误或想补充,欢迎反馈。 遇到问题?查排查手册 →