第八章 Agent黑盒的X光机

81 阅读15分钟

AI Agent工程化实录· 第八章

你的Agent上线了。用户说"结果不对"。你打开日志,看到的是:

[INFO] Agent received task: "分析Q3销售数据"
[INFO] Tool called: sql_query
[INFO] Tool called: chart_generator
[INFO] Task completed

然后呢?**为什么查了那张表?为什么选了折线图?哪一步出了问题?**一无所知。

这就是Agent的可观测性问题——传统软件有调用栈、有断点、有日志,Agent只有"调了什么工具"。中间的决策过程,完全黑盒。

01 Agent的可观测性,为什么比传统软件难10倍

传统软件的执行路径是确定的:输入A → 函数f → 输出B。出了bug,看调用栈就行。

Agent的执行路径是概率性的:同样的输入,可能走完全不同的路径。原因:

1. LLM的输出不确定:temperature > 0时,同样的prompt可能产生不同的推理链

2. 决策嵌套:Agent先决定用哪个工具,再决定传什么参数,参数又影响下一步决策——每一步都有分叉

3. 状态依赖上下文:第5步的决策依赖前4步积累的上下文,复现问题需要完整回放

fig0-why-agent-observability-is-hard.png

真实事故:数据发错API的4小时排查

一个客服Agent,负责处理用户退款请求。某天收到投诉:用户的订单号、手机号、退款金额被发送到了一个第三方分析平台的API,而不是内部的退款系统API。

只有行为层日志时的排查过程

14:00  收到用户投诉
14:15  翻日志,看到 tool_call: http_post, url: analytics.example.com/track
14:30  搜索所有 http_post 调用,发现过去3天有17次类似错误路由
15:00  不知道为什么Agent选了analytics API而不是refund API——两个API的描述都包含"submit"
15:45  开始逐条读prompt模板、工具描述,人工猜测LLM可能的理解偏差
17:30  终于发现:工具描述中refund API的description写的是"Submit refund request",
       analytics API写的是"Submit user event for tracking"——LLM在上下文包含
       "tracking order status"时,把"tracking"匹配到了analytics API
18:00  修改工具描述,上线修复

4.5小时,其中3小时在猜。

加了决策层日志后的等价排查

14:00  收到用户投诉
14:05  查trace决策层,step 2的decision字段:
       thought: "用户提到了tracking order,应该用analytics API提交tracking event"
       tool_choice_reasoning: "关键词tracking匹配analytics API的description"
14:10  定位根因:工具描述的命名冲突 + LLM的关键词匹配策略
14:20  修改工具描述,上线修复

20分钟。决策层日志直接告诉你LLM"怎么想的",不需要猜。

02 3层可观测性架构

照搬传统监控的三板斧(metrics + logs + traces),对Agent远远不够。你需要3层可观测性:

第1层:行为层——Agent做了什么

这是最基础的:记录每个工具调用、参数、返回值、耗时。

{
  "trace_id": "abc-123",
  "step": 3,
  "action": "tool_call",
  "tool": "sql_query",
  "input": {"query": "SELECT * FROM sales WHERE quarter='Q3'"},
  "output": {"rows": 847, "preview": "..."},
  "duration_ms": 320,
  "token_usage": {"input": 1200, "output": 85}
}

这是大部分人已经在做的。但只有行为层,你只知道"做了什么",不知道"为什么这样做"。

第2层:决策层——Agent为什么这样做

这是Agent可观测性的核心:记录LLM的推理过程。

{
  "trace_id": "abc-123",
  "step": 3,
  "decision": {
    "thought": "我需要查询Q3销售数据,使用sql_query工具",
    "tool_choice_reasoning": "销售数据在数据库中,sql_query是正确的工具",
    "parameter_reasoning": "quarter字段过滤Q3数据",
    "confidence": 0.92,
    "alternatives_considered": ["csv_reader", "api_call"]
  }
}

⚠️ ⚠️ 决策层不是免费的。让LLM输出推理过程,每个step多消耗200-500 token。但比起"不知道为什么出错"的调试成本,这是值得的。

决策层的数据从哪来

决策层数据不是凭空产生的,需要从LLM的输出中主动提取。三种主要来源:

1. Chain-of-Thought提取:在system prompt中要求LLM先输出推理过程再输出动作,用固定分隔符切分。例如要求输出格式为<thought>...</thought><action>...</action>,解析时按标签提取thought字段。这是最通用的方式,任何模型都能用,但依赖prompt工程,LLM不一定严格遵循格式。实际操作中建议用正则+容错解析,不要假设格式100%正确。

2. Function Call的reasoning字段:部分模型API在function_call响应中提供reasoning或thinking字段。例如Claude Sonnet 4.6的extended thinking输出、DeepSeek V4的reasoning_content字段。这些是模型原生输出的推理过程,格式稳定、不需要额外prompt,但只有特定模型支持。如果你的Agent只对接支持reasoning的模型,这是最干净的方案。

3. Structured Output中的rationale字段:在定义输出schema时,强制要求LLM先填rationale再填action。例如用JSON Schema定义{"rationale": string, "tool_name": string, "parameters": object},通过GPT-5.1的structured output或Gemini 2.5 Pro的controlled generation保证格式。这种方式格式最可靠,但rationale质量受schema约束——LLM可能写出敷衍的rationale来"交差"。

实际项目中建议组合使用:优先用模型原生reasoning字段(零额外成本),fallback到structured output的rationale字段(格式可靠),CoT提取作为兜底(通用但脆弱)。不要只依赖一种来源。

第3层:质量层——Agent做得对不对

事后评估:这次执行的质量如何?有没有更好的路径?

{
  "trace_id": "abc-123",
  "quality": {
    "goal_achieved": true,
    "steps_efficiency": 0.7,  // 7步完成,最优5"token_efficiency": 0.6,  // 用了12K token,最优8K
    "error_count": 1,       // 1次工具调用失败后重试
    "hallucination_risk": 0.15 // 步骤4的推理可能与事实不符
  }
}

Snipaste_2026-06-09_14-55-54.png

03 OpenTelemetry + Agent:怎么接入

OpenTelemetry已经是可观测性的事实标准。Agent的trace可以天然映射到OTel模型:

from opentelemetry import trace

tracer = trace.get_tracer("agent")

with tracer.start_as_current_span("agent.task") as task_span:
    task_span.set_attribute("task", user_input)
    task_span.set_attribute("model", "gpt-5.1")
    
    with tracer.start_as_current_span("agent.think") as think_span:
        think_span.set_attribute("thought", llm_thought)
        think_span.set_attribute("confidence", confidence)
    
    with tracer.start_as_current_span("agent.tool_call") as tool_span:
        tool_span.set_attribute("tool", tool_name)
        tool_span.set_attribute("input", tool_input)
        tool_span.set_attribute("output", tool_output)
        tool_span.set_attribute("duration_ms", duration)

接入OTel后,你可以用Jaeger/Zipkin看trace,用Prometheus看metrics,用Grafana做dashboard——复用整个可观测性生态

03.5 生产级Trace的3个设计原则

接入OTel只是第一步。真上生产,trace系统本身也需要设计。三个原则:

原则1:采样策略——全量还是采样

Agent的trace比传统请求大10-50倍(一个任务可能产生5-20个span,每个span带决策层数据),全量存储成本很高。策略:

  • 开发/ staging环境:全量采集。调试需要完整数据,成本不是问题。
  • 生产环境:按任务结果采样。完成的任务采样10%,失败的任务100%采集,涉及特定工具调用的任务100%采集。实现方式:在trace exporter前加一个Sampler,根据goal_achievederror_count字段决定是否export。这样既控制成本,又保证异常case不丢失。
  • 关键业务流:如涉及支付、数据删除的Agent任务,无论成败全量采集。一个支付事故的排查价值远超存储成本。

原则2:Trace存储选型——ClickHouse vs Elasticsearch

Agent trace有两个特征:写多读少(90%的trace写进去就不会再看)、单条数据大(决策层JSON可能1-5KB)。选型:

  • ClickHouse:写入吞吐高、压缩比好(JSON压缩后体积降80%+)、查询灵活。适合trace量大的场景(日增百万级trace)。缺点:不支持全文检索,查"哪个trace的thought包含refund"需要额外建索引。
  • Elasticsearch:全文检索强,直接搜thought字段。适合调试频繁、需要关键词搜索的场景。缺点:写入成本高、存储膨胀快,日增百万级trace时ES集群成本是ClickHouse的3-5倍。
  • 实际建议:ClickHouse做主存储 + ES做热数据索引(最近7天的trace同步到ES供搜索)。7天以上的trace只在ClickHouse中保留,按trace_id精确查询。

原则3:Trace与业务的关联——从trace反查用户和任务

trace_id是技术标识,但排查问题时你需要知道"这是哪个用户的哪个任务"。做法:在每个trace的root span上强制设置三个业务属性:

root_span.set_attribute("user_id", user_id)        # 哪个用户
root_span.set_attribute("task_id", task_id)          # 哪个任务
root_span.set_attribute("task_type", "refund_process")  # 任务类型

这样当用户反馈"我的退款处理有问题"时,你可以直接用user_id + task_type + 时间范围从存储中捞出完整trace,不需要让用户提供trace_id(用户根本不知道trace_id是什么)。

更进一步:在业务数据库的任务表中存trace_id,建立双向关联。业务侧查SELECT trace_id FROM tasks WHERE user_id=? AND status='failed',拿到trace_id后去trace存储查完整链路。

04 Agent专属的5个关键指标

传统软件看QPS、P99、错误率。Agent需要自己的指标体系:

fig2-five-agent-metrics.png 1. 任务完成率 Task Completion Rate 最终输出满足用户需求的比例。这是北极星指标,其他指标都为它服务。低于0.7的完成率意味着Agent在当前场景下不可靠,需要优先排查失败case的共性原因——是工具缺失、prompt模糊、还是场景本身超出Agent能力边界。

2. 步骤效率 Step Efficiency 最优步骤数 / 实际步骤数。0.5意味着Agent用了2倍的步骤。低效率 = 规划差或目标漂移。步骤效率低于0.6通常意味着规划模块需要调优——检查是不是工具描述导致LLM选错工具反复重试,或者system prompt缺少"用最少步骤完成任务"的约束。

3. Token效率 Token Efficiency 最优token消耗 / 实际token消耗。直接关联成本。Token效率低于0.4说明prompt工程有优化空间——常见原因:上下文窗口塞了过多无关历史消息、system prompt冗余、或LLM在每步都重复总结前文。一个典型优化:把历史步骤从完整文本改为摘要,token消耗可降40%。

4. 工具成功率 Tool Success Rate 工具调用成功的比例。低成功率 = 工具描述差、参数格式错、或工具本身不稳定。工具成功率低于0.85时必须介入——先区分是"参数错误导致失败"还是"工具本身报错"。前者优化工具描述和参数schema,后者加fallback机制。低于0.7的Agent基本不可用,用户会直接放弃。

5. 幻觉率 Hallucination Rate LLM生成与事实不符内容的比例。最难量化但最重要——可以用LLM-as-Judge抽样评估。幻觉率超过0.2是危险信号——意味着5次执行中至少1次产生虚假信息。在金融、医疗等场景,这个阈值应该压到0.05以下,通过RAG增强和事实校验工具实现。

指标联动关系

任务完成率 ↓ → 查步骤效率(规划问题?) 步骤效率 ↓ → 查工具成功率(工具调用失败导致重试?) Token效率 ↓ → 查步骤效率 + 每步token消耗(推理太长?通信冗余?) 幻觉率 ↑ → 查决策层日志(哪一步开始偏离事实?)

05 调试Agent:不只是看日志

传统debug是"找到出错的代码行"。Agent debug是"找到出错的决策点"。

3个调试技巧:

技巧1:Trace回放 把完整trace存下来,支持逐步回放。不是看最终结果,而是看每一步的输入输出和决策理由。

技巧2:注入断点 在特定步骤暂停执行,人工检查Agent的推理,决定是否继续。类似IDE的断点调试。

技巧3:A/B对比 同样的任务,跑两次:一次成功一次失败。对比trace差异,找到分叉点。

|| ❌ ❌ 传统debug思路 | ✅ ✓ Agent debug思路 | | --- | --- | | "哪行代码报错了?" | "哪个决策点走偏了?" | | → 看报错栈 | → 看决策层trace | | → 修代码 | → 优化prompt/工具描述 | | Agent:大部分时候没有"报错", | 关注的不是"错在哪", | | 只是"结果不对" | 而是"为什么选了这个方向" |

Case Study:一个目标漂移的排查过程

现象:一个数据分析Agent,用户要求"对比Q2和Q3的销售额",Agent最终输出了Q3各品类的详细分布——结果相关但不完整,缺少Q2对比。

Step 1:Trace回放

拉出完整trace,逐步检查决策层:

Step 1: thought="需要分别查询Q2和Q3的销售数据" → 正确
Step 2: tool_call=sql_query, query="SELECT * FROM sales WHERE quarter='Q3'" → 偏了!
        decision.tool_choice_reasoning="先查Q3数据,数据量可能更大,更有代表性"
Step 3: thought="Q3数据已获取,分析各品类分布" → 完全偏离原始目标
Step 4-6: 持续在Q3数据上深挖,再也没回到Q2

Step 2:定位分叉点

分叉点在Step 2:Agent决定"先查Q3"而不是"同时查Q2和Q3"。这个"先"字是关键——Agent把并行任务序列化了,然后序列化后只执行了第一个子任务就忘了第二个。

Step 3:发现prompt问题

检查system prompt,发现规划指令写的是"逐步执行任务,每步聚焦一个目标"。"逐步"和"聚焦一个"直接导致了Agent把双季度对比拆成两步,然后执行完第一步就认为任务完成了。

修复:将规划指令改为"识别任务中的所有并行子目标,在单次工具调用中尽可能批量完成"。同时在Step 2的决策层加入self-check:"是否所有子目标都已覆盖?"。修复后步骤效率从0.5提升到0.83,任务完成率从0.7提升到0.95。

这个case的核心教训:目标漂移的根因往往不在工具层,而在规划层的prompt指令。没有决策层日志,你只会看到"Agent查了Q3没查Q2",但不知道为什么它选择只查Q3。

06 实战:最小可观测性方案

如果你现在什么都没做,这是最小成本接入方案:

import json
import time
import logging
from dataclasses import dataclass, field, asdict
from typing import Any, Optional

logger = logging.getLogger("agent.observability")


@dataclass
class StepRecord:
    """单步记录:覆盖3层可观测性"""
    step: int
    thought: str                          # 决策层:LLM的推理
    action: str                           # 行为层:执行的动作
    action_input: Any                     # 行为层:动作参数
    observation: Any                      # 行为层:执行结果
    tokens_input: int = 0                 # 质量层:输入token
    tokens_output: int = 0                # 质量层:输出token
    tokens_reasoning: int = 0             # 质量层:推理token(o4/GPT-5.5 Thinking等)
    duration_ms: float = 0                # 行为层:耗时
    error: Optional[str] = None           # 行为层:错误信息
    confidence: float = 0.0               # 决策层:置信度


@dataclass
class AgentTrace:
    """完整trace:一个任务的全生命周期"""
    trace_id: str
    task: str
    user_id: str = ""
    task_type: str = ""
    model: str = ""
    steps: list[StepRecord] = field(default_factory=list)
    start_time: float = 0
    end_time: float = 0
    total_tokens_input: int = 0
    total_tokens_output: int = 0
    total_tokens_reasoning: int = 0
    goal_achieved: bool = False
    error_summary: Optional[str] = None

    def add_step(self, step: StepRecord):
        self.steps.append(step)
        self.total_tokens_input += step.tokens_input
        self.total_tokens_output += step.tokens_output
        self.total_tokens_reasoning += step.tokens_reasoning

    def compute_metrics(self) -> dict:
        """计算质量层指标"""
        error_count = sum(1 for s in self.steps if s.error is not None)
        tool_success_rate = (
            (len(self.steps) - error_count) / len(self.steps)
            if self.steps else 0
        )
        return {
            "goal_achieved": self.goal_achieved,
            "step_count": len(self.steps),
            "tool_success_rate": round(tool_success_rate, 2),
            "total_tokens": self.total_tokens_input + self.total_tokens_output + self.total_tokens_reasoning,
            "reasoning_token_ratio": round(
                self.total_tokens_reasoning /
                max(1, self.total_tokens_input + self.total_tokens_output + self.total_tokens_reasoning),
                2
            ),
            "duration_s": round(self.end_time - self.start_time, 2),
        }

    def to_json(self) -> str:
        return json.dumps(asdict(self), ensure_ascii=False, default=str)


class ObservableAgent:
    """最小可观测性Agent——每个step记录 thought + action + observation + tokens"""

    def __init__(self, llm_client, trace_store, trace_id: str):
        self.llm = llm_client
        self.trace_store = trace_store  # 可以是文件、ClickHouse、ES
        self.trace = AgentTrace(
            trace_id=trace_id,
            task="",
            model=getattr(llm_client, "model_name", "unknown"),
        )

    def run(self, task: str, user_id: str = "", task_type: str = ""):
        self.trace.task = task
        self.trace.user_id = user_id
        self.trace.task_type = task_type
        self.trace.start_time = time.time()

        step_num = 0
        done = False
        max_steps = 20  # 防止无限循环

        while not done and step_num < max_steps:
            step_num += 1
            step_start = time.time()

            try:
                # 决策层:获取LLM推理
                llm_response = self.llm.chat(
                    messages=self._build_messages(),
                    response_format={
                        "type": "json_schema",
                        "json_schema": {
                            "name": "agent_step",
                            "schema": {
                                "type": "object",
                                "properties": {
                                    "rationale": {"type": "string"},
                                    "action": {"type": "string"},
                                    "action_input": {"type": "object"},
                                    "confidence": {"type": "number"},
                                    "done": {"type": "boolean"},
                                },
                                "required": ["rationale", "action", "action_input", "done"]
                            }
                        }
                    }
                )

                parsed = json.loads(llm_response.content)
                thought = parsed.get("rationale", "")
                action = parsed.get("action", "")
                action_input = parsed.get("action_input", {})
                confidence = parsed.get("confidence", 0.0)
                done = parsed.get("done", False)

                # 行为层:执行动作
                if not done and action:
                    try:
                        observation = self._execute_tool(action, action_input)
                        error = None
                    except Exception as e:
                        observation = None
                        error = f"{type(e).__name__}: {str(e)}"
                        logger.error(f"Step {step_num} tool error: {error}")
                else:
                    observation = llm_response.content
                    error = None

                # 质量层:token追踪
                usage = getattr(llm_response, "usage", None)
                tokens_input = usage.prompt_tokens if usage else 0
                tokens_output = usage.completion_tokens if usage else 0
                tokens_reasoning = getattr(usage, "completion_tokens_details", {}).get("reasoning_tokens", 0) if usage else 0

                step = StepRecord(
                    step=step_num,
                    thought=thought,
                    action=action,
                    action_input=action_input,
                    observation=observation,
                    tokens_input=tokens_input,
                    tokens_output=tokens_output,
                    tokens_reasoning=tokens_reasoning,
                    duration_ms=(time.time() - step_start) * 1000,
                    error=error,
                    confidence=confidence,
                )
                self.trace.add_step(step)

            except json.JSONDecodeError as e:
                # 结构化输出解析失败——记录原始输出,不丢数据
                step = StepRecord(
                    step=step_num,
                    thought="[PARSE_ERROR] structured output failed",
                    action="",
                    action_input={},
                    observation=llm_response.content if 'llm_response' in dir() else "",
                    duration_ms=(time.time() - step_start) * 1000,
                    error=f"JSONDecodeError: {str(e)}",
                )
                self.trace.add_step(step)
                logger.error(f"Step {step_num} parse error: {e}")
                break

            except Exception as e:
                step = StepRecord(
                    step=step_num,
                    thought="",
                    action="",
                    action_input={},
                    observation=None,
                    duration_ms=(time.time() - step_start) * 1000,
                    error=f"UnexpectedError: {type(e).__name__}: {str(e)}",
                )
                self.trace.add_step(step)
                logger.error(f"Step {step_num} unexpected error: {e}")
                break

        # 收尾
        self.trace.end_time = time.time()
        self.trace.goal_achieved = done and step_num < max_steps
        if step_num >= max_steps:
            self.trace.error_summary = f"Exceeded max steps ({max_steps})"

        # 持久化——别只在console打
        self._persist_trace()
        return self._get_result()

    def _persist_trace(self):
        """持久化trace,失败不影响主流程"""
        try:
            self.trace_store.save(self.trace.to_json())
        except Exception as e:
            logger.error(f"Trace persistence failed: {e}")
            # fallback:写本地文件
            with open(f"/tmp/agent_trace_{self.trace.trace_id}.json", "w") as f:
                f.write(self.trace.to_json())

    def _build_messages(self):
        """构建LLM消息——子类覆写"""
        raise NotImplementedError

    def _execute_tool(self, action: str, action_input: dict):
        """执行工具调用——子类覆写"""
        raise NotImplementedError

    def _get_result(self):
        """获取最终结果"""
        if self.trace.steps:
            last = self.trace.steps[-1]
            return last.observation
        return None

关键:每个step记录 thought + action + observation + tokens。这4个字段覆盖了3层可观测性的核心。后续想加OTel、加dashboard、加自动评估,都有数据基础。

这个实现相比最简版本多了三样东西:error handling(工具调用失败和JSON解析失败都不会丢数据,全部记入trace)、结构化输出(用JSON Schema约束LLM输出格式,rationale字段直接作为决策层数据)、token追踪(区分input/output/reasoning三类token,reasoning_token_ratio指标可以暴露推理模型的隐藏成本)。

下一篇:第9章——评估Agent,没有标准答案的考试