没有 trace 的 Agent,调试只能靠猜。这个教程把一次请求拆成树状 span,接到 Jaeger,并用一个真实故障走完「定位 → 复现 → 修复 → 回归」的流程。
目标
一次请求在 Jaeger 里长这样:
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:
services:
jaeger:
image: jaegertracing/all-in-one:1.62
ports:
- "16686:16686" # UI
- "4318:4318" # OTLP HTTPdocker compose up -d安装依赖:
pip install opentelemetry-sdk opentelemetry-exporter-otlp-proto-http打开 http://localhost:16686,服务名选 miniagent。
Step 1:初始化 Tracer
telemetry.py:
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
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
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:一次请求一棵树
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:
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:定位一个真实故障
用户投诉:「退款提交了两次。」
- 按
task_id搜索 trace; - 展开
agent.run,看到两次tool.create_refund; - 第一次 span 状态是 error,错误码
TIMEOUT; - 第二次成功;
- 根因:工具超时后循环重试,但退款接口不幂等。
修复:
- 工具加
idempotency_key; - 重试只对可重试错误;
- 写操作在 trace 中标记
tool.side_effect=true; - 对账任务检查重复退款。
回归:
- 评估集加入「工具超时后重试」案例;
- 断言
create_refund最多调用一次; - 监控重复退款单数量。
这就是 trace 的价值:它把「回答不对」变成「第 4 步工具超时,第 5 步重试导致重复写入」。
Step 7:指标与告警
Trace 适合个案,指标适合趋势。至少记录:
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、工具、错误。
用户投诉时找不到请求。task_id、session_id 要一路传下去。
体积大、含敏感信息。记录版本、哈希、长度。
长尾被掩盖。看 P95/P99 与失败分布。
小结
- Agent 调试依赖树状 trace,而不是一行日志。
- span 的字段设计决定你能不能定位问题。
- 脱敏在写入前做。
- trace 看个案,指标看趋势,评估看回归。
- 能回放,才能从「猜」变成「修」。
下一篇把这些能力落到一个真实业务:生产级客服 Agent。