AI Agent 调试实战:链路追踪、Prompt 可视化与异常定位的系统方法

cover

一、Agent 调试的黑盒困境

AI Agent 系统的调试难度远高于传统软件。传统程序的执行路径是确定性的,输入相同则输出相同,断点加日志基本能定位问题。Agent 的执行路径由 LLM 的输出决定,而 LLM 的输出具有随机性——同一个 Prompt 两次调用可能产生不同的工具调用序列、不同的推理路径、不同的最终结果。

生产环境中的典型调试场景:一个数据分析 Agent,用户输入"分析上周的销售趋势",Agent 应该调用数据库查询工具获取数据,再调用图表工具生成可视化。但实际运行中,Agent 有时跳过数据查询直接编造数据,有时调用错误的工具,有时陷入工具调用的无限循环。这些问题的根因可能在 Prompt 设计、工具描述、上下文管理、LLM 温度参数等多个环节,传统调试手段无法快速定位。

更隐蔽的问题是"静默错误":Agent 调用了正确的工具,但传递了错误的参数,返回了不完整的数据,LLM 基于不完整数据生成了看似合理但实际错误的结论。这类错误不会抛异常,只有在业务结果出现偏差时才会被发现。

二、Agent 调试的架构:全链路可观测

Agent 调试的核心思路是全链路可观测:记录 Agent 执行的每一步(思考、工具调用、工具返回、最终输出),构建完整的执行轨迹(Trace),支持回放和对比分析。

graph TB
    subgraph Agent 执行链路
        A[用户输入] --> B[LLM 推理<br/>思考/决策]
        B --> C{需要工具调用?}
        C -->|是| D[工具调用1<br/>name + args]
        D --> E[工具返回1<br/>result + latency]
        E --> B
        C -->|否| F[最终输出]
    end

    subgraph 可观测层
        G[Span 记录<br/>每步的输入/输出/耗时] --> H[Trace 聚合<br/>完整执行轨迹]
        H --> I[异常检测<br/>循环/超时/参数错误]
        I --> J[回放与对比<br/>重现问题 / A/B 对比]
    end

    style A fill:#e1f5fe
    style B fill:#fff3e0
    style D fill:#e8f5e9
    style F fill:#f3e5f5
    style H fill:#ffebee

关键设计要素:

  • Span:记录单步操作(LLM 调用或工具调用)的输入、输出、耗时、token 消耗。
  • Trace:一次 Agent 执行的完整 Span 链路,包含所有步骤的时序关系。
  • 异常检测规则:自动识别常见异常模式(循环调用、参数缺失、输出格式错误)。

三、生产级代码:Agent 调试框架实现

3.1 Span 与 Trace 数据结构

import time
import uuid
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Optional
import json


class SpanType(Enum):
    LLM_CALL = "llm_call"
    TOOL_CALL = "tool_call"
    TOOL_RESULT = "tool_result"


class SpanStatus(Enum):
    OK = "ok"
    ERROR = "error"
    TIMEOUT = "timeout"


@dataclass
class Span:
    """单步操作记录"""
    span_id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
    trace_id: str = ""
    parent_id: Optional[str] = None
    span_type: SpanType = SpanType.LLM_CALL
    name: str = ""
    status: SpanStatus = SpanStatus.OK

    # 输入输出
    input_data: Any = None
    output_data: Any = None
    error: Optional[str] = None

    # 性能指标
    start_time: float = 0
    end_time: float = 0
    token_usage: dict = field(default_factory=dict)

    @property
    def duration_ms(self) -> float:
        if self.end_time and self.start_time:
            return (self.end_time - self.start_time) * 1000
        return 0

    def to_dict(self) -> dict:
        return {
            "span_id": self.span_id,
            "trace_id": self.trace_id,
            "parent_id": self.parent_id,
            "type": self.span_type.value,
            "name": self.name,
            "status": self.status.value,
            "input": _safe_serialize(self.input_data),
            "output": _safe_serialize(self.output_data),
            "error": self.error,
            "duration_ms": round(self.duration_ms, 2),
            "token_usage": self.token_usage,
        }


@dataclass
class Trace:
    """完整执行轨迹"""
    trace_id: str = field(default_factory=lambda: uuid.uuid4().hex[:16])
    agent_name: str = ""
    user_input: str = ""
    final_output: str = ""
    spans: list[Span] = field(default_factory=list)
    start_time: float = field(default_factory=time.time)
    end_time: float = 0
    metadata: dict = field(default_factory=dict)

    def add_span(self, span: Span) -> Span:
        span.trace_id = self.trace_id
        self.spans.append(span)
        return span

    @property
    def total_duration_ms(self) -> float:
        if self.end_time:
            return (self.end_time - self.start_time) * 1000
        return 0

    @property
    def total_tokens(self) -> int:
        return sum(
            s.token_usage.get("total", 0) for s in self.spans
        )

    def to_dict(self) -> dict:
        return {
            "trace_id": self.trace_id,
            "agent_name": self.agent_name,
            "user_input": self.user_input,
            "final_output": self.final_output,
            "spans": [s.to_dict() for s in self.spans],
            "total_duration_ms": round(self.total_duration_ms, 2),
            "total_tokens": self.total_tokens,
            "metadata": self.metadata,
        }


def _safe_serialize(data: Any) -> Any:
    """安全序列化,处理不可 JSON 化的对象"""
    try:
        json.dumps(data)
        return data
    except (TypeError, ValueError):
        return str(data)

3.2 可观测 Agent 执行器

class ObservableAgent:
    """带全链路追踪的 Agent 执行器"""

    def __init__(
        self,
        name: str,
        llm_client,
        tools: dict[str, callable],
        max_iterations: int = 10,
        trace_callback: Optional[callable] = None,
    ):
        self.name = name
        self.llm = llm_client
        self.tools = tools
        self.max_iterations = max_iterations
        self.trace_callback = trace_callback  # Trace 完成后的回调

    def run(self, user_input: str) -> dict:
        """执行 Agent,返回结果和 Trace"""
        trace = Trace(
            agent_name=self.name,
            user_input=user_input,
        )

        messages = [{"role": "user", "content": user_input}]
        final_output = ""
        tool_call_history: list[str] = []  # 检测循环调用

        for iteration in range(self.max_iterations):
            # 1. LLM 调用
            llm_span = Span(
                span_type=SpanType.LLM_CALL,
                name=f"llm_call_iter_{iteration}",
                input_data=messages[-1],
            )
            llm_span.start_time = time.time()

            try:
                response = self.llm.chat(
                    messages=messages,
                    tools=self._get_tool_schemas(),
                    temperature=0.1,  # 低温度,减少随机性
                )
                llm_span.output_data = response
                llm_span.status = SpanStatus.OK
            except Exception as e:
                llm_span.status = SpanStatus.ERROR
                llm_span.error = str(e)
                llm_span.end_time = time.time()
                trace.add_span(llm_span)
                break

            llm_span.end_time = time.time()
            llm_span.token_usage = {
                "prompt": response.get("usage", {}).get("prompt_tokens", 0),
                "completion": response.get("usage", {}).get("completion_tokens", 0),
                "total": response.get("usage", {}).get("total_tokens", 0),
            }
            trace.add_span(llm_span)

            # 2. 检查是否有工具调用
            tool_calls = response.get("tool_calls", [])
            if not tool_calls:
                # 无工具调用,LLM 给出最终回答
                final_output = response.get("content", "")
                break

            # 3. 执行工具调用
            assistant_msg = response
            messages.append(assistant_msg)

            for tc in tool_calls:
                tool_name = tc["function"]["name"]
                tool_args = tc["function"]["arguments"]

                # 循环调用检测
                call_sig = f"{tool_name}({tool_args})"
                tool_call_history.append(call_sig)
                if self._detect_loop(tool_call_history):
                    loop_span = Span(
                        span_type=SpanType.TOOL_CALL,
                        name=tool_name,
                        status=SpanStatus.ERROR,
                        error=f"检测到循环调用: {call_sig}",
                    )
                    trace.add_span(loop_span)
                    final_output = "错误:Agent 陷入工具调用循环"
                    break

                # 记录工具调用 Span
                tool_span = Span(
                    span_type=SpanType.TOOL_CALL,
                    name=tool_name,
                    input_data=tool_args,
                )
                tool_span.start_time = time.time()

                try:
                    result = self.tools[tool_name](**tool_args)
                    tool_span.output_data = result
                    tool_span.status = SpanStatus.OK
                except Exception as e:
                    tool_span.status = SpanStatus.ERROR
                    tool_span.error = str(e)
                    result = f"工具执行错误: {e}"

                tool_span.end_time = time.time()
                trace.add_span(tool_span)

                # 将工具结果加入消息
                messages.append({
                    "role": "tool",
                    "tool_call_id": tc["id"],
                    "content": str(result),
                })

            else:
                continue  # 正常完成工具调用,进入下一轮
            break  # 循环检测触发,退出

        trace.final_output = final_output
        trace.end_time = time.time()

        # 回调通知(用于持久化或实时展示)
        if self.trace_callback:
            self.trace_callback(trace)

        return {
            "output": final_output,
            "trace": trace.to_dict(),
        }

    def _detect_loop(self, history: list[str]) -> bool:
        """检测循环调用:最近 3 次调用是否完全相同"""
        if len(history) < 3:
            return False
        return (
            history[-1] == history[-2] == history[-3]
        )

    def _get_tool_schemas(self) -> list[dict]:
        """获取工具的 JSON Schema 描述"""
        # 实际实现中,从工具的 docstring 或装饰器提取
        return []

3.3 异常检测与诊断报告

class AgentDiagnostics:
    """Agent 异常检测与诊断报告生成"""

    @staticmethod
    def analyze(trace: Trace) -> dict:
        """分析 Trace,生成诊断报告"""
        issues = []
        tool_spans = [
            s for s in trace.spans if s.span_type == SpanType.TOOL_CALL
        ]
        llm_spans = [
            s for s in trace.spans if s.span_type == SpanType.LLM_CALL
        ]

        # 1. 循环调用检测
        tool_names = [s.name for s in tool_spans]
        for i in range(len(tool_names) - 2):
            if tool_names[i] == tool_names[i+1] == tool_names[i+2]:
                issues.append({
                    "type": "loop",
                    "severity": "high",
                    "message": f"工具 {tool_names[i]} 连续调用 3 次以上",
                    "suggestion": "检查工具描述是否清晰,LLM 是否理解工具返回值",
                })

        # 2. 工具调用失败
        failed_tools = [
            s for s in tool_spans if s.status == SpanStatus.ERROR
        ]
        for ft in failed_tools:
            issues.append({
                "type": "tool_error",
                "severity": "medium",
                "message": f"工具 {ft.name} 执行失败: {ft.error}",
                "suggestion": "检查工具参数是否正确,工具实现是否有 bug",
            })

        # 3. LLM 调用超时
        slow_llm = [
            s for s in llm_spans if s.duration_ms > 10000
        ]
        for s in slow_llm:
            issues.append({
                "type": "slow_llm",
                "severity": "low",
                "message": f"LLM 调用耗时 {s.duration_ms:.0f}ms",
                "suggestion": "考虑减少上下文长度或使用更快的模型",
            })

        # 4. Token 消耗异常
        total_tokens = trace.total_tokens
        if total_tokens > 10000:
            issues.append({
                "type": "high_token_usage",
                "severity": "medium",
                "message": f"总 Token 消耗 {total_tokens},可能存在上下文膨胀",
                "suggestion": "检查是否需要截断历史消息或压缩上下文",
            })

        # 5. 迭代次数过多
        if len(llm_spans) > 5:
            issues.append({
                "type": "too_many_iterations",
                "severity": "medium",
                "message": f"Agent 执行了 {len(llm_spans)} 轮迭代",
                "suggestion": "检查 Prompt 是否明确指导 Agent 何时停止",
            })

        return {
            "trace_id": trace.trace_id,
            "agent_name": trace.agent_name,
            "total_duration_ms": round(trace.total_duration_ms, 2),
            "total_tokens": total_tokens,
            "num_tool_calls": len(tool_spans),
            "num_llm_calls": len(llm_spans),
            "issues": issues,
            "health": "unhealthy" if any(
                i["severity"] == "high" for i in issues
            ) else "healthy",
        }

3.4 Trace 可视化输出

class TraceFormatter:
    """Trace 格式化输出,用于日志和调试"""

    @staticmethod
    def to_text(trace: Trace) -> str:
        """将 Trace 格式化为可读文本"""
        lines = [
            f"=== Agent Trace: {trace.trace_id} ===",
            f"Agent: {trace.agent_name}",
            f"Input: {trace.user_input[:100]}...",
            f"Duration: {trace.total_duration_ms:.0f}ms",
            f"Tokens: {trace.total_tokens}",
            "",
        ]

        for i, span in enumerate(trace.spans):
            icon = "🤖" if span.span_type == SpanType.LLM_CALL else "🔧"
            status_icon = "✅" if span.status == SpanStatus.OK else "❌"
            lines.append(
                f"  {icon} [{i+1}] {span.name} "
                f"{status_icon} ({span.duration_ms:.0f}ms)"
            )
            if span.error:
                lines.append(f"      Error: {span.error}")
            if span.span_type == SpanType.TOOL_CALL:
                lines.append(
                    f"      Input: {str(span.input_data)[:80]}"
                )
                if span.output_data:
                    lines.append(
                        f"      Output: {str(span.output_data)[:80]}"
                    )

        lines.append("")
        lines.append(f"Final Output: {trace.final_output[:200]}")
        return "\n".join(lines)

四、Agent 调试的权衡与边界

4.1 Trace 存储成本

每次 Agent 执行产生一个 Trace,包含多个 Span。每个 Span 记录输入输出,可能包含大量文本。按每次执行平均 5KB 计算,日活 10 万次的服务每天产生 500MB Trace 数据。建议设置保留策略(如 7 天热数据 + 30 天冷数据),仅对异常 Trace 做长期存储。

4.2 敏感数据脱敏

Trace 中可能包含用户输入的敏感信息(如身份证号、密码)。记录前必须做脱敏处理。建议在 Span 记录层统一脱敏,而非依赖业务层。

4.3 LLM 随机性的影响

同一输入多次执行可能产生不同结果,Trace 对比时需要考虑随机性。建议固定 temperature=0 用于调试,生产环境使用低温度(0.1~0.3)。对于必须复现的问题,记录 LLM 的 seed 参数(如 OpenAI 的 seed 字段)。

4.4 适用与禁用场景

场景 调试策略 原因
工具调用错误 Trace + 参数检查 定位参数构造问题
循环调用 循环检测 + Prompt 优化 根因在 Prompt 设计
输出质量差 Prompt A/B 对比 需要控制变量
延迟过高 Span 耗时分析 定位慢步骤
幻觉问题 工具返回值 vs 最终输出对比 检查 LLM 是否忽略工具结果

五、总结

Agent 调试的核心是全链路可观测。通过 Span 记录每步操作的输入、输出、耗时和状态,构建完整的 Trace 轨迹。异常检测自动识别循环调用、工具失败、Token 消耗异常等常见问题。诊断报告提供问题定位和修复建议。Trace 的存储成本需要通过保留策略控制,敏感数据必须脱敏。LLM 的随机性通过固定温度参数和 seed 来缓解。调试不是事后补救,而是 Agent 系统的基础设施——没有可观测性的 Agent 在生产环境中就是黑盒。

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐