AI Agent 的可观测性体系:从 Prometheus 监控到离线评估系统
AI Agent 的可观测性体系:从 Prometheus 监控到离线评估系统
前两篇我们聊了 LangGraph 的状态图设计和 WebSocket 中断恢复机制。但一个 AI Agent 上线后,"能跑"只是起点,"能看清它跑得怎么样"才是长期运营的关键。本文将深入剖析我们为会议室预定 Agent 构建的三层可观测性体系:运行时监控(Prometheus)→ 结构化日志(Loguru)→ 离线评估(Eval System)。
一、为什么 AI Agent 需要专门的可观测性?
传统 Web 服务的可观测性已经很成熟了——QPS、延迟、错误率,三板斧足够。但 AI Agent 有几个独特特征,让传统方案力不从心:
- 决策不可预测:同样的输入,LLM 可能给出不同的决策。你无法像传统服务那样"看日志就能复现 bug"。
- 多轮状态流转:一个会话可能经历 5 个阶段、10 轮对话,中间任何一次 LLM 调用都可能出错。你需要追踪整个会话的生命周期,而不仅仅是单次请求。
- Token 即成本:每次 LLM 调用都在烧钱。你需要精确知道每个会话消耗了多少 Token,才能做成本核算。
- 能力需要量化:Agent 能不能完成预定?槽位提取准不准?这些问题需要一个离线评估系统来回答。
二、监控架构总览
┌─────────────────────────────────────────────────────┐
│ FastAPI Application │
│ │
│ WebSocket Handler ──> LangGraph ──> Tool Nodes │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────┐ │
│ │ SessionTracker (单例) │ │
│ │ - 会话生命周期追踪 │ │
│ │ - Token 消耗记录 │ │
│ │ - 阶段转换记录 │ │
│ │ - 错误记录 │ │
│ └──────────────┬───────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Prometheus Metrics │ │
│ │ Counter / Histogram / Gauge / Summary │ │
│ └──────────────┬───────────────────────────────┘ │
│ │ │
│ ▼ │
│ GET /metrics ──> Prometheus Server ──> Grafana │
│ GET /api/monitoring/stats ──> 内置监控面板 │
└─────────────────────────────────────────────────────┘
三、Prometheus 指标设计:四类指标覆盖全场景
Prometheus 提供了四种指标类型,我们每种都用到了:
3.1 Counter(计数器)—— 只会增加的累计值
# 会话总数(按状态分维度)
meeting_session_total = Counter(
'meeting_session_total',
'Total number of meeting booking sessions',
['status'] # success, failed, cancelled, timeout
)
# LLM Token 消耗(按模型和类型分维度)
llm_tokens_total = Counter(
'llm_tokens_total',
'Total LLM tokens consumed',
['model', 'type'] # type: prompt, completion, total
)
# 阶段转换次数
phase_transition_total = Counter(
'phase_transition_total',
'Total phase transitions',
['from_phase', 'to_phase']
)
# 错误次数(按错误类型和阶段分维度)
error_total = Counter(
'meeting_error_total',
'Total errors in meeting booking',
['error_type', 'phase']
)
维度(Label)设计是指标设计的核心。以 error_total 为例,error_type 和 phase 两个维度可以让你精确回答"在槽位收集阶段,LLM 调用出错了多少次?"这类问题。
3.2 Histogram(直方图)—— 分布统计
# 会话耗时(按状态和最终阶段分维度)
session_duration_seconds = Histogram(
'meeting_session_duration_seconds',
'Duration of meeting booking sessions',
['status', 'final_phase'],
buckets=[1, 5, 10, 30, 60, 120, 300, 600]
)
# 单轮对话耗时
turn_duration_seconds = Histogram(
'meeting_turn_duration_seconds',
'Duration of each conversation turn',
['action_type'],
buckets=[0.1, 0.5, 1, 2, 5, 10, 30]
)
# LLM 响应时间
llm_request_duration_seconds = Histogram(
'llm_request_duration_seconds',
'LLM API request duration',
['model'],
buckets=[0.5, 1, 2, 5, 10, 20, 30]
)
Bucket 的选择是一门艺术。session_duration_seconds 的 bucket 从 1s 到 10min,覆盖了"用户秒回"到"用户想了很久"的所有场景。llm_request_duration_seconds 从 0.5s 开始,因为 LLM 调用不可能比这更快。
Histogram 的价值在于它可以计算 P50、P95、P99 分位数:
- P50 = 一半用户的会话在 X 秒内完成
- P95 = 95% 用户的会话在 X 秒内完成
- P99 = 极端情况下的延迟上界
3.3 Gauge(仪表盘)—— 可上可下的实时值
# 活跃会话数
active_sessions = Gauge(
'meeting_active_sessions',
'Number of active booking sessions'
)
# WebSocket 连接数
websocket_connections = Gauge(
'meeting_websocket_connections',
'Number of active WebSocket connections'
)
Gauge 适合表示瞬时状态。active_sessions 和 websocket_connections 理论上应该始终相等——如果不等,说明有连接泄漏或会话追踪 bug。
3.4 Summary(摘要)—— 客户端计算的分位数
# 会话轮次统计
session_turns = Summary(
'meeting_session_turns',
'Number of turns per session',
['status']
)
Summary 和 Histogram 类似,但分位数在客户端计算。适合"会话轮次"这种不需要高精度分位数的场景。
四、SessionTracker:会话生命期的全链路追踪
4.1 设计思路
SessionTracker 是一个单例模式的会话追踪器,它追踪每个会话从创建到结束的完整生命周期:
@dataclass
class SessionMetrics:
"""单个会话的指标数据"""
session_id: str
start_time: float # 会话开始时间
end_time: Optional[float] # 会话结束时间
status: str # in_progress/success/failed/cancelled/timeout
final_phase: str # 最终阶段
turn_count: int # 对话轮次
total_prompt_tokens: int # Prompt Token 总消耗
total_completion_tokens: int # Completion Token 总消耗
errors: list # 错误列表
4.2 追踪 API 设计
追踪 API 遵循最小侵入原则——业务代码只需要在关键节点调用一行追踪方法:
# 会话开始
session_tracker.start_session(session_id)
# 每轮对话
session_tracker.record_turn(session_id)
# 阶段转换(在 BookingContext.set_phase 中自动触发)
session_tracker.record_phase_transition(session_id, from_phase, to_phase)
# LLM Token 消耗(在 react_planner 中自动触发)
session_tracker.record_llm_tokens(session_id, model, prompt_tokens, completion_tokens)
# 错误记录
session_tracker.record_error(session_id, error_type, phase, error_msg)
# 会话结束(必须调用)
session_tracker.end_session(session_id, status='success')
4.3 阶段转换的自动追踪
一个巧妙的设计:阶段转换追踪嵌入在 BookingContext.set_phase() 方法中,无需业务代码显式调用:
class BookingContext:
def set_phase(self, phase: Phase):
old_phase = self.all_infos['phase']
new_phase = phase.value
if old_phase != new_phase:
# 自动记录阶段转换
session_tracker.record_phase_transition(
session_id=self.session_id,
from_phase=old_phase,
to_phase=new_phase
)
self.all_infos['phase'] = new_phase
这样做的效果是:任何节点调用 ctx.set_phase() 时,Prometheus 的 phase_transition_total 计数器都会自动 +1,完全对业务代码透明。
4.4 会话结束时的指标刷写
def end_session(self, session_id: str, status: str):
session = self._active_sessions[session_id]
session.end_time = time.time()
session.status = status
# 刷写到 Prometheus
meeting_session_total.labels(status=status).inc()
session_duration_seconds.labels(
status=status,
final_phase=session.final_phase
).observe(session.duration)
session_turns.labels(status=status).observe(session.turn_count)
active_sessions.dec()
# 清理
del self._active_sessions[session_id]
指标刷写只在会话结束时发生,而非实时更新。这是一个性能优化:如果实时更新,每轮对话都要写 4-5 个指标,高频场景下会成为瓶颈。延迟到会话结束时一次性刷写,减少了 90% 的指标写入次数。
五、Loguru 结构化日志:request_id 贯穿全链路
5.1 为什么选 Loguru 而非 logging?
Python 标准库的 logging 模块功能强大但配置繁琐。Loguru 的优势:
- 零配置:
from loguru import logger即可使用 - 自动格式化:默认带时间、级别、文件名、行号
- Rotation + Retention:自动按大小/时间切分,自动清理过期日志
- 异常捕获:
exc_info=True自动附带完整堆栈
5.2 request_id 上下文注入
在并发场景下,多个会话的日志会交错在一起。我们通过 contextvars 实现 request_id 的上下文隔离:
# app/core/context.py
import contextvars
request_id_ctx_var = contextvars.ContextVar('request_id', default='-')
# app/core/log.py
def inject_request_id(record):
request_id = request_id_ctx_var.get()
record["extra"]["request_id"] = request_id
logger = logger.patch(inject_request_id)
# 日志格式
log_format = (
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
"<level>{level: <8}</level> | "
"<magenta>request_id - {extra[request_id]}</magenta> | "
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - "
"<level>{message}</level>"
)
效果:
2026-06-08 14:30:00.123 | INFO | request_id - abc-123 | websocket_handler:process_message:191 - 开始处理消息
2026-06-08 14:30:00.124 | INFO | request_id - abc-123 | ingest:run:21 - [ingest] 开始处理消息
2026-06-08 14:30:00.125 | INFO | request_id - xyz-456 | websocket_handler:process_message:191 - 开始处理消息
2026-06-08 14:30:00.126 | INFO | request_id - abc-123 | react_planner:run:25 - [react_planner] 开始规划
通过 request_id 过滤,可以精确提取某个会话的完整日志链路。
5.3 节点级别的耗时日志
每个节点都遵循统一的日志模式:
def run(ctx: BookingContext) -> BookingContext:
start_time = datetime.now()
logger.info(f"[search_rooms] 开始查询: session_id={ctx.session_id}")
try:
# 业务逻辑...
logger.info(f"[search_rooms] 查询成功: 找到 {len(rooms)} 间会议室")
return ctx
except Exception as e:
logger.error(f"[search_rooms] 查询异常: {e}", exc_info=True)
return ctx
finally:
elapsed = (datetime.now() - start_time).total_seconds()
logger.debug(f"[search_rooms] 耗时: {elapsed:.3f}s")
finally 块中的耗时日志确保了无论成功还是异常,都有执行时间记录。
六、监控数据 API:为前端面板提供数据
除了 Prometheus 标准的 /metrics 端点,我们还实现了一个 JSON API 供内置监控面板使用:
@router.get("/api/monitoring/stats")
def get_monitoring_stats():
stats = {
'timestamp': time.time(),
'active_sessions': active_sessions._value.get(),
'websocket_connections': websocket_connections._value.get(),
'session_metrics': _get_session_metrics(),
'token_metrics': _get_token_metrics(),
'error_metrics': _get_error_metrics(),
'phase_metrics': _get_phase_metrics()
}
return {'success': True, 'data': stats}
解析 Prometheus 内部数据结构
Prometheus 客户端库的指标对象不是简单的字典,需要通过 collect() 方法获取样本数据:
def _get_session_metrics():
metrics = {
'total_sessions': 0,
'success_count': 0,
'failed_count': 0,
'success_rate': 0,
}
# 遍历 Counter 的所有样本
for sample in meeting_session_total.collect()[0].samples:
if 'success' in sample.labels.get('status', ''):
metrics['success_count'] = int(sample.value)
elif 'failed' in sample.labels.get('status', ''):
metrics['failed_count'] = int(sample.value)
metrics['total_sessions'] = metrics['success_count'] + metrics['failed_count'] + ...
if metrics['total_sessions'] > 0:
metrics['success_rate'] = metrics['success_count'] / metrics['total_sessions'] * 100
return metrics
这段代码看起来有点"hack"——直接访问 ._value.get() 和 .collect()[0].samples。这是因为 Prometheus 客户端库的设计初衷是给 Prometheus Server 抓取的,不是给应用层读取的。但对于内置监控面板,这种"hack"是必要的。
七、离线评估系统:量化 Agent 的能力
监控告诉你"线上跑得怎么样",评估系统告诉你"Agent 本身能力怎么样"。
7.1 评估系统架构
DatasetLoader ──> OfflineEvalRunner ──> TaskCompletionEvaluator
│ ──> SlotFillingEvaluator
│
├──> MetricsAggregator ──> MarkdownReportGenerator
│
└──> 报告输出
四个核心组件:
- DatasetLoader:加载测试用例(JSON 格式)
- TaskCompletionEvaluator:评估任务是否完成
- SlotFillingEvaluator:评估槽位提取准确性
- MetricsAggregator:聚合所有指标,生成总结数据
7.2 测试用例设计
测试用例用 JSON 定义,每个用例包含对话步骤和期望结果:
{
"case_id": "TC001",
"description": "完整预定流程 - 明确参数",
"conversation": [
{
"turn": 1,
"user_message": "明天下午2点,8人会议室",
"expected_slots": {
"date": "tomorrow",
"start_time": "14:00",
"capacity": 8
},
"expected_action": "search_rooms",
"expected_phase": "SEARCHING"
},
{
"turn": 2,
"user_message": "选1",
"expected_action": "book_room",
"expected_phase": "CONFIRMING"
},
{
"turn": 3,
"user_message": "确认",
"expected_result": {"success": true}
}
]
}
关键设计:期望值是声明式的,不是命令式的。你只需要声明"用户说'明天下午2点8人会议室'后,date 应该被解析为 tomorrow",而不需要写代码去调用 ingest 节点。评估框架会自动模拟对话流程并验证结果。
7.3 槽位填充评估:模糊匹配
槽位评估不是简单的 expected == actual。以日期为例:
def _compare_dates(self, expected: Any, actual: Any) -> bool:
expected_str = str(expected).strip().lower()
# 支持相对日期
if expected_str == 'tomorrow':
expected_date = today + timedelta(days=1)
elif expected_str == 'today':
expected_date = today
else:
expected_date = datetime.strptime(expected_str, '%Y-%m-%d').date()
# 同理处理 actual
...
return expected_date == actual_date
测试用例中可以写 "date": "tomorrow",评估器会自动将其转换为具体日期再与实际值比较。这避免了测试用例因为"今天是几号"而失效。
类似地,时间比较支持多种格式标准化:
def _normalize_time(self, time_str: str) -> str:
# 支持 "14:00"、"14:00:00"、"02:00 PM"、"02:00PM"
formats = ['%H:%M', '%H:%M:%S', '%I:%M %p', '%I:%M%p']
for fmt in formats:
try:
dt = datetime.strptime(time_str, fmt)
return dt.strftime('%H:%M')
except ValueError:
continue
return time_str
7.4 加权准确率:必需槽位更重要
不是所有槽位同等重要。我们引入了加权准确率:
def calculate_weighted_accuracy(self, results: Dict[str, SlotFillingResult]) -> float:
total_weight = 0.0
weighted_correct = 0.0
for slot_name, result in results.items():
# 必需槽位权重 1.0,可选槽位权重 0.5
weight = 1.0 if slot_name in self.REQUIRED_SLOTS else 0.5
total_weight += weight
if result.is_correct:
weighted_correct += weight
return weighted_correct / total_weight
一个把 date 搞错的 Agent,比一个把 building 搞错的 Agent,问题严重得多。加权准确率能更真实地反映 Agent 的能力。
7.5 任务完成度评估
任务完成度评估不仅看最终结果,还看走过的阶段:
def evaluate(self, ctx: BookingContext) -> TaskCompletionResult:
success = ctx.done and ctx.get_phase() == Phase.BOOKED
completed_phases = self._extract_completed_phases(ctx)
if not success:
failed_phase = ctx.get_phase().value
error_type = self._determine_error_type(ctx)
return TaskCompletionResult(
success=success,
completed_phases=completed_phases,
failed_phase=failed_phase,
error_type=error_type,
turns_count=ctx.turn_id,
)
_determine_error_type 方法根据当前阶段推断错误类型:
def _determine_error_type(self, ctx: BookingContext) -> str:
if ctx.last_tool_error:
return 'tool_error'
phase = ctx.get_phase()
if phase == Phase.COLLECTING_SLOTS:
return 'slot_collection_incomplete' # 槽位没收齐
elif phase == Phase.SEARCHING:
return 'search_failed' # 搜索失败
elif phase == Phase.AWAITING_SELECTION:
return 'selection_timeout' # 用户没选
elif phase == Phase.CONFIRMING:
return 'confirmation_failed' # 用户取消确认
这使得失败分析变得精确——你可以知道"10个失败的会话中,有6个是因为槽位没收齐,3个是因为搜索失败,1个是用户取消"。
7.6 指标聚合
@dataclass
class AggregatedMetrics:
task_success_rate: float # 任务完成率
slot_filling_accuracy: float # 槽位填充准确率
avg_turns: float # 平均对话轮次
total_sessions: int # 总会话数
successful_sessions: int # 成功会话数
failed_sessions: int # 失败会话数
error_distribution: Dict[str, int] # 错误类型分布
三个核心指标的行业基准:
| 指标 | 目标值 | 含义 |
|---|---|---|
| 任务完成率 | ≥ 85% | Agent 能否可靠地完成预定 |
| 槽位填充准确率 | ≥ 90% | Agent 能否正确理解用户意图 |
| 平均对话轮次 | ≤ 5.0 | Agent 能否高效地完成任务 |
7.7 Markdown 报告自动生成
评估运行结束后,自动生成 Markdown 报告:
# 评估报告
## 核心指标
| 指标 | 值 | 目标 | 状态 |
|------|-----|------|------|
| 任务完成率 | 80.00% | ≥85% | ⚠️ |
| 槽位填充准确率 | 91.67% | ≥90% | ✅ |
| 平均对话轮次 | 3.00 | ≤5.0 | ✅ |
## 失败用例分析
### 1. TC002: 完整预定流程 - 逐步提供信息
- **错误类型**: slot_collection_incomplete
- **失败阶段**: COLLECTING_SLOTS
- **对话轮次**: 3
- **填充错误的槽位**: duration_minutes
报告中的 ✅/⚠️/❌ 状态标识,让结果一目了然。
八、三层可观测性的协同
三个层次不是孤立的,它们形成了一个发现-定位-验证的闭环:
Prometheus 告警:成功率从 90% 跌到 70% ← 发现问题
│
▼
Loguru 日志:通过 request_id 追踪失败会话 ← 定位问题
│ 发现 LLM 在 "duration_minutes" 槽位上频繁解析失败
│
▼
离线评估:添加对应的测试用例,验证修复效果 ← 验证修复
│ 修复后重新跑评估,确认成功率回到 90%
│
▼
部署上线:继续通过 Prometheus 监控验证线上效果
九、工程经验总结
1. 指标设计要回答业务问题
不要只收集技术指标(CPU、内存)。meeting_session_total、phase_transition_total、slot_filling_accuracy 这些指标直接回答"Agent 做得好不好"这个业务问题。
2. Histogram 的 Bucket 选择决定数据质量
Bucket 太粗,看不出性能问题;Bucket 太细,存储和计算成本高。建议先根据经验设置,上线后根据实际数据分布调整。
3. 日志追踪需要上下文标识
在高并发场景下,没有 request_id 的日志等于废纸。contextvars + Loguru 的 patch 机制是 Python 生态中最优雅的解决方案。
4. 评估系统是 Agent 的"考试"
没有评估系统的 Agent,就像没有考试的学生——你不知道他学得怎么样。声明式的测试用例 + 模糊匹配的评估器 + 自动生成的报告,构成了一个可持续运行的质量保障体系。
5. 可观测性是设计出来的,不是补丁打出来的
可观测性不能在项目结束后"补上去"。从 BookingContext 的 set_phase() 自动触发阶段追踪,到 react_planner 中自动记录 Token 消耗,这些都是在架构设计阶段就规划好的。好的可观测性对业务代码的侵入应该接近于零。
浙公网安备 33010602011771号