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步积累的上下文,复现问题需要完整回放
真实事故:数据发错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的推理可能与事实不符
}
}
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_achieved和error_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需要自己的指标体系:
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,没有标准答案的考试