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

这里是“一人公司”的成长家园。我们提供从产品曝光、技术变现到法律财税的全栈内容,并连接云服务、办公空间等稀缺资源,助你专注创造,无忧运营。

更多推荐