5.5. 性能监控与日志记录让 Skills 运行透明化
性能监控与日志记录让 Skills 运行透明化
预计完成时间:45 分钟 · 涉及工具:Docker、OpenTelemetry Collector、Prometheus、Grafana
2025 年 11 月的一个深夜,运维团队收到报警:某个部署了三周的 Agent Skills 突然开始间歇性超时。日志翻了三轮,只看到“调用失败”四个字——没有调用链上下文,没有工具级别耗时,没有任何可以定位瓶颈的数据。最终排查花了整整四天。
这不是个例。当 Skills 从单次对话扩展到成百上千的并发执行时,没有可观测性的系统就像在黑屋子里修手表——你知道它在走,但你永远不知道哪一颗齿轮正在磨损。
本章将带你把黑屋子里的灯打开。你会学到如何用统一的结构化日志为每个 Skill 调用打上“身份证”,如何定义那些真正值得报警的指标,以及如何用 Prometheus + Grafana 搭一套开箱即用的监控看板。读完这一章,你将拥有一个能够回答“谁、什么时候、调了什么、花了多久、结果如何”的完整观测体系。
你需要什么
开始之前,请确认你的开发环境满足以下条件:
| 组件 | 最低版本 | 用途 |
|---|---|---|
| Docker 及 Docker Compose | Docker 24+ | 运行监控基础设施 |
| Python 环境 | 3.10+ | 编写示例 Skill 代码 |
opentelemetry-api 和 opentelemetry-sdk |
1.28+ | 在 Skill 代码中埋点 |
| 一个可被远程写入的 Prometheus 端点 | 任意 | 存储指标数据(本地或云服务均可) |
| Grafana | 10.0+ | 构建和展示监控看板 |
如果你还没有现成的 Prometheus,本章会在步骤中提供 Docker Compose 一键部署配置,十分钟内即可拥有全套本地监控栈。
最终成果
完成本章所有步骤后,你将得到:
- 一套标准化的结构化日志格式,每条日志都包含
trace_id、skill_id、tool_name等关联字段,可以用一条 SQL 语句串联起一个 Skill 调用的完整生命周期。 - 四类核心指标的采集管线:调用延迟(P50/P95/P99)、错误率、工具级别成功率、Token 消耗量。
- 一个 Grafana 监控看板,包含指标趋势图、T谱(Trace Waterfall)和异常告警面板,能一眼判断整个 Skills 集群的健康状态。
为什么做这个:可观测性是生产级 Agent 系统的基础设施,而不是锦上添花的装饰。它能让你在用户发现故障之前定位问题,在扩容之前发现瓶颈,在预算失控之前看到成本分布。
步骤一:统一结构化日志并注入关联 ID
为什么结构化日志是第一步
传统日志让人类阅读,结构化日志让机器索引。当数百个 Skill 并发执行时,你不可能用 grep 在几十万行日志中手工追踪一个请求。解决这个问题的核心策略是 关联 ID(Correlation ID):在 Skill 入口生成一个唯一的 trace_id,并在每一个工具调用、模型调用、外部 API 请求中透传这个 ID。
从当前调研资料看,Claude Code 的遥测体系正是基于这一思路:Skills 的每次会话、每个工具调用都被打上统一的 Tracing 上下文,通过 OpenTelemetry 协议导出。本章的实现方式与之保持一致的语义。
动手实现
首先,在 Skill 的入口函数中初始化 OpenTelemetry 并生成 Trace ID:
# skill_tracing.py
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.resources import SERVICE_NAME, Resource
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.trace.export import BatchSpanProcessor
import uuid
import logging
import json
import time
# 1. 初始化 TracerProvider,绑定服务名称
resource = Resource(attributes={
SERVICE_NAME: "my-skill-service"
})
provider = TracerProvider(resource=resource)
trace.set_tracer_provider(provider)
# 2. 配置 OTLP 导出器(发送到本地 OpenTelemetry Collector)
otlp_exporter = OTLPSpanExporter(
endpoint="http://localhost:4317", # OTLP gRPC 端口
insecure=True # 本地开发环境不使用 TLS
)
provider.add_span_processor(BatchSpanProcessor(otlp_exporter))
tracer = trace.get_tracer(__name__)
class StructuredLogger:
"""统一的结构化日志记录器,自动注入 trace_id 和 skill_id"""
def __init__(self, skill_id: str):
self.skill_id = skill_id
def log(self, level: str, message: str, extra: dict = None):
# 3. 获取当前 Span 的 trace_id(如果存在)
current_span = trace.get_current_span()
trace_id = format(current_span.get_span_context().trace_id, '032x') \
if current_span else "no-trace"
# 4. 构建结构化日志条目
log_entry = {
"timestamp": int(time.time() * 1000),
"level": level,
"trace_id": trace_id,
"skill_id": self.skill_id,
"message": message,
**(extra or {})
}
# 输出为 JSON 行,便于后续收集
print(json.dumps(log_entry, ensure_ascii=False), flush=True)
def info(self, msg, **kwargs): self.log("INFO", msg, kwargs)
def error(self, msg, **kwargs): self.log("ERROR", msg, kwargs)
def warn(self, msg, **kwargs): self.log("WARN", msg, kwargs)
预期结果:每个 Skill 实例化时获得一个
StructuredLogger,所有后续日志都会自动带上trace_id和skill_id。在终端中你看到的日志是 JSON 行,可以用jq按trace_id筛选。
踩坑经验
⚠️ 注意:OTLP 环境变量不会自动传递给子进程
调研资料明确指出,Claude Code 设置的
OTEL_*环境变量不会传递给 Skills 调用的子进程(如 Bash 工具启动的外部程序)。如果你的 Skill 内部又调用了外部服务,需要在这些子进程的环境中显式设置相同的遥测变量,否则会导致 Tracing 链路断裂。一个可行的做法是在 Skill 代码中通过os.environ主动注入,或在 Docker 镜像中预设。从当前调研资料看,这种隔离设计是为了防止 Skills 运行环境被意外污染,但也意味着你需要自行维护跨进程的上下文传播。
步骤二:关键指标定义与告警规则
哪些指标真正值得关心
Agent Skills 的运行特征与传统微服务不同。一个 Skill 可能在一次调用中触发十几个工具,模型调用本身就是重操作,而错误有可能是部分降级而非整体失败。因此,指标设计必须反映“组合调用”的特性。
以下是经生产验证的四类核心指标:
| 指标分类 | 指标名称示例 | 计算方式 | 告警阈值建议 |
|---|---|---|---|
| 调用延迟 | skill_duration_seconds |
从入口 Span 开始到结束的耗时,分 P50/P95/P99 | P99 超过基准值 3 倍时告警 |
| 错误率 | skill_error_ratio |
含异常的调用数 / 总调用数,按 5 分钟窗口计算 | 超过 1% 时告警(注意区分整体失败和工具级降级) |
| 工具成功率 | tool_success_ratio |
按工具名分组的成功数 / 总调用数 | 任一工具成功率低于 95% 时单独告警 |
| Token 消耗 | token_usage_total |
模型调用的 input + output token 累计 | 单 Skill 每小时消耗超过预设预算时告警 |
这四类指标覆盖了“快不快”“对不对”“稳不稳”“贵不贵”四个维度。
动手实现:在 Skill 代码中暴露 Prometheus 指标
使用 prometheus_client 库定义上报点:
# skill_metrics.py
from prometheus_client import Counter, Histogram, Gauge, CollectorRegistry, push_to_gateway
import time
import functools
# 全局注册表(避免与默认注册表冲突)
registry = CollectorRegistry()
# 1. 定义指标
skill_calls = Counter(
'skill_calls_total',
'Total number of skill invocations',
['skill_id', 'status'], # status: success / error / partial_error
registry=registry
)
skill_duration = Histogram(
'skill_duration_seconds',
'Skill execution duration in seconds',
['skill_id'],
buckets=[0.1, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0], # 根据实际 Skill 耗时调整分桶
registry=registry
)
tool_calls = Counter(
'tool_calls_total',
'Total tool calls made by skills',
['skill_id', 'tool_name', 'status'],
registry=registry
)
token_usage = Counter(
'token_usage_total',
'Total token consumption',
['skill_id', 'model', 'type'], # type: input / output
registry=registry
)
# 2. 装饰器:自动记录每次 Skill 调用的耗时和状态
def instrument_skill(skill_id: str):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.time()
try:
result = func(*args, **kwargs)
duration = time.time() - start
# 记录成功调用
skill_calls.labels(skill_id=skill_id, status='success').inc()
skill_duration.labels(skill_id=skill_id).observe(duration)
return result
except Exception as e:
duration = time.time() - start
# 记录失败调用
skill_calls.labels(skill_id=skill_id, status='error').inc()
skill_duration.labels(skill_id=skill_id).observe(duration)
raise # 重新抛出异常,不影响上层处理
return wrapper
return decorator
# 3. 工具调用记录函数
def record_tool_call(skill_id: str, tool_name: str, success: bool):
status = 'success' if success else 'error'
tool_calls.labels(skill_id=skill_id, tool_name=tool_name, status=status).inc()
# 4. Token 消耗记录函数
def record_token_usage(skill_id: str, model: str, input_tokens: int, output_tokens: int):
token_usage.labels(skill_id=skill_id, model=model, type='input').inc(input_tokens)
token_usage.labels(skill_id=skill_id, model=model, type='output').inc(output_tokens)
# 5. 定期推送至 Pushgateway(适用于短期/批处理 Skill)
def push_metrics(pushgateway_url: str = "http://localhost:9091"):
push_to_gateway(pushgateway_url, job='skill_metrics', registry=registry)
预期结果:当你在 Skill 函数上使用
@instrument_skill("my-skill")装饰器,并在工具调用后调用record_tool_call(),Prometheus 将能采集到这些指标的时序数据。在 Prometheus 表达式浏览器中输入rate(skill_calls_total[5m]),你应该看到实时的调用速率曲线。
告警规则示例
在 Prometheus 配置中添加以下规则文件:
# prometheus_rules.yml
groups:
- name: skill_alerts
rules:
- alert: HighSkillErrorRate
expr: |
sum(rate(skill_calls_total{status="error"}[5m]))
/
sum(rate(skill_calls_total[5m])) > 0.01
for: 2m
labels:
severity: critical
annotations:
summary: "Skill error rate exceeds 1%"
description: "Current error rate is {{ $value | humanizePercentage }}"
- alert: HighP99Latency
expr: histogram_quantile(0.99,
sum(rate(skill_duration_seconds_bucket[5m])) by (le, skill_id)
) > 30
for: 5m
labels:
severity: warning
annotations:
summary: "P99 latency for skill {{ $labels.skill_id }} exceeds 30s"
步骤三:集成 Prometheus + Grafana 看板
快速搭建监控栈
完整部署文件如下,保存为 docker-compose.observability.yml:
# docker-compose.observability.yml
version: '3.9'
services:
# OpenTelemetry Collector:接收 Skills 的 trace 和 log 数据
otel-collector:
image: otel/opentelemetry-collector-contrib:0.112.0
command: ["--config=/etc/otel-collector-config.yaml"]
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
ports:
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
depends_on:
- prometheus
# Prometheus:抓取指标并存储
prometheus:
image: prom/prometheus:v2.53.0
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- ./prometheus_rules.yml:/etc/prometheus/prometheus_rules.yml
- prometheus_data:/prometheus
ports:
- "9090:9090"
# Grafana:可视化看板
grafana:
image: grafana/grafana:11.1.0
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin # 仅用于本地开发
volumes:
- grafana_data:/var/lib/grafana
ports:
- "3000:3000"
depends_on:
- prometheus
volumes:
prometheus_data:
grafana_data:
OpenTelemetry Collector 配置(otel-collector-config.yaml):
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
exporters:
# 将指标数据发送给 Prometheus
prometheus:
endpoint: "0.0.0.0:8889"
# 将日志数据输出到控制台(生产环境应替换为 Loki 等后端)
logging:
loglevel: debug
service:
pipelines:
traces:
receivers: [otlp]
exporters: [logging] # 简化为日志输出,实际应接入 Jaeger
metrics:
receivers: [otlp]
exporters: [prometheus]
logs:
receivers: [otlp]
exporters: [logging]
启动监控栈:
docker-compose -f docker-compose.observability.yml up -d
预期结果:访问
http://localhost:9090应看到 Prometheus UI,访问http://localhost:3000应看到 Grafana 登录页(用户名 admin,密码 admin)。从当前调研资料看,这种基于 OpenTelemetry 的统一采集方案是 Claude Code 生态推荐的可观测性模式——使用 OTLP 协议作为标准传输层,后端可按需替换。
搭建 Grafana 看板
登录 Grafana 后,进入 Data Sources → Add data source → Prometheus,输入 http://prometheus:9090 并保存。
然后创建 Dashboard,添加以下面板:
面板 1:Skill 调用 QPS 与错误率
# QPS(每秒查询数)
sum(rate(skill_calls_total[1m]))
# 错误率百分比
sum(rate(skill_calls_total{status="error"}[5m]))
/
sum(rate(skill_calls_total[5m])) * 100
面板 2:P50/P95/P99 延迟折线图
# P99 延迟
histogram_quantile(0.99,
sum(rate(skill_duration_seconds_bucket[5m])) by (le, skill_id)
)
# 分别替换分位数为 0.50 和 0.95
面板 3:工具调用成功率热力图
sum(rate(tool_calls_total{status="success"}[5m])) by (tool_name)
/
sum(rate(tool_calls_total[5m])) by (tool_name) * 100
面板 4:Token 消耗趋势
sum(increase(token_usage_total{type="input"}[1h])) by (skill_id)
sum(increase(token_usage_total{type="output"}[1h])) by (skill_id)
预期结果:四个面板实时刷新,展示 Skills 集群的整体健康画像。当任何一个告警规则触发时,你可以在 Grafana 的 Alerting 页面看到活动警报。
回顾
本章中,你完成了三件事:
- 统一了日志格式:引入结构化日志与
trace_id关联机制,让每个 Skill 调用的所有日志可被索引和关联。 - 定义了关键指标:围绕延迟、错误率、工具成功率和 Token 消耗建立了四维度监控体系,并设定了合理的告警阈值。
- 搭建了生产级监控看板:用 OpenTelemetry + Prometheus + Grafana 构建了端到端的可观测性管线。
截至当前调研资料,Claude Code 官方正在推动以 OpenTelemetry 为统一标准的遥测方案,本章的实现路径与此保持技术栈一致,便于后续无缝接入 Claude Code 的官方遥测体系。
下一步,你需要确保这些“受监控的 Skills”在逻辑上也是正确的——单元测试是让每一个 Skill 的行为可验证、可回归的基础工程。下一章《Skills 单元测试的核心难点是模拟模型行为》将直面如何 Mock 大模型调用这个棘手问题。
行动清单
- 将
StructuredLogger类集成到你当前的 Skill 项目中,替换所有print()调用。 - 在 3 个核心 Skill 上使用
@instrument_skill装饰器,运行后检查 Prometheus 是否有新指标摄入。 - 根据你的业务场景调整告警阈值(至少调整错误率阈值和 P99 延迟阈值)。
- 运行一次压测(模拟 50 并发),观察 Grafana 看板的延迟分位数变化。
- 确认所有外部工具调用都已调用
record_tool_call(),补齐遗漏的埋点。
agent skills 入门到精通
关于 LearnKu