背景痛点:当客服机器人“失联”时,我们在忙什么?
去年“618”大促,我们把 Dify 智能客服接进了 7 条业务线。凌晨 2 点,订单咨询量瞬间飙到 4 万 QPS,钉钉群里开始刷屏:“机器人答非所问!” 运维同学一头雾水:
- 是 LLM 推理慢,还是向量检索超时?
- 哪一次调用失败导致整个会话异常?
- 扩容了 30 个 Pod,为什么错误率依旧 5%?
根本原因是调用链不可见。Dify 官方只给了一个“总调用次数”面板,既没 TraceID,也没按业务会话拆指标。想定位问题,只能把几十台节点的日志拉到本地grep,再靠 Excel 拼调用关系——等找到根因,大促都结束了。痛定思痛,我们决定自研一套“看得见”的监控体系,让每一次 API 调用都有迹可循。
技术方案:Prometheus+Grafana 还是 ELK?一张表看懂取舍
| 维度 | Prometheus+Grafana | ELK(Elasticsearch+Logstash+Kibana) |
|---|---|---|
| 存储成本 | 时序压缩,64 bytes/sample | 原始日志,1 KB/条起步 |
| 查询速度 | 100ms 级聚合 | 秒级全文检索 |
| 扩展规则 | PromQL 内置 rate、increase | 需写 DSL,学习曲线陡 |
| 部署复杂度 | 单二进制,K8s 原生支持 | 至少 3 个组件,调优 GC 头疼 |
| 与 Dify 集成 | 官方暴露 /metrics,改两行代码即可 | 需额外走 HTTP/ beats 推日志 |
我们的决策逻辑简单粗暴:
- 要实时看板(<30s 延迟),而非事后搜日志。
- 要低成本长期存储(90 天指标 <200 GB)。
- 要按业务会话聚合(Prometheus 的 label 天然适合)。
于是拍板:指标走 Prometheus,日志走 Loki(轻量版 ELK),各取所长。
核心实现:让每一次调用都带“身份证”
1. 调用链追踪:OpenTelemetry Python 端
在 Dify 的chat/messages.py里埋 3 行代码,就能生成 TraceID 并注入返回头,前端拿到后写入会话日志,方便后续串联。
from opentelemetry import trace from opentelemetry.exporter.otlp.proto.grpc import OTLPSpanExporter from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor # 初始化一次,放在应用启动脚本 trace.set_tracer_provider(TracerProvider()) tracer = trace.get_tracer(__name__) otlp_exporter = OTLPSpanExporter(endpoint="otel-collector:4317", insecure=True) trace.get_tracer_provider().add_span_processor( BatchSpanProcessor(otlp_exporter) ) # 在真正调用 LLM 前生成 span def handle_user_query(session_id: str, user_text: str): with tracer.start_as_current_span("dify.llm.request") as span: span.set_attribute("session.id", session_id) span.set_attribute("user.text_length", len(user_text)) try: answer = call_llm_backend(user_text) span.set_status(trace.Status(trace.StatusCode.OK)) return answer except Exception as e: span.record_exception(e) span.set_status(trace.Status(trace.StatusCode.ERROR)) raise finally: # 保证 span 一定被结束 pass关键点
session.id与trace.id一起写入 span,后续 Grafana 里用${session_id}变量就能一键过滤。finally不写东西,但start_as_current_span的__exit__会自动结束,避免泄漏。
2. 自动指标采集:Spring Boot 注解法
业务侧很多微服务用 Java,不想改代码,就用注解一把梭:
# application.yml management: endpoints: web: exposure: include: prometheus,health metrics: export: prometheus: enabled: true tags: application: ${spring.application.name} region: ${REGION:us-east-1}@RestController @RequestMapping("/api/v1/bot") @Timed // 类级别一把梭,所有公有方法自动带指标 public class BotController { @GetMapping("/answer") @Timed(value = "dify.bot.answer", description = "Time taken to answer user") public Answer answer(@RequestHeader("X-Session-Id") String sessionId, @RequestParam("q") String question) { // 业务逻辑 } }启动后访问/actuator/prometheus即可看到:
dify_bot_answer_seconds_count{region="us-east-1",status="200",uri="/api/v1/bot/answer",} 201033. 业务会话 ID vs 系统调用 ID 的关联逻辑
- TraceID(系统):OpenTelemetry 自动生成,16 字节 hex,保证全局唯一。
- SessionID(业务):用户第一次打开聊天窗时由前端生成
UUIDv4,放在 HTTP HeaderX-Session-Id。
关联方式:
- 在入口网关(Nginx/Envoy)把
X-Session-Id镜像到session_idlabel。 - 同时在 OTel 的 span 里写
session.id属性。 - Grafana 变量联动:先选
SessionID,再下钻到TraceID,实现“业务→系统”双向跳转。
生产考量:省钱与高性能的平衡术
1. 采样策略
- Trace 采样:按“错误必采,成功 1%”规则,用
OTEL_TRACES_SAMPLER=traceidratio{0.01},再配合rate_limiting每秒上限 500。 - Metrics 采样:Prometheus 拉取间隔 15s,90 天总存储 ≈
(cardinality * 2 bytes * 4 * 24 * 90)。我们给高基数 label(如user_id)做哈希桶化,降到 1/20 基数后,磁盘占用 180 GB→9 GB。
2. 高并发聚合优化
- Recording Rule:提前把
rate(dify_bot_answer_seconds_count[5m])录成:dify:answer_qps,查询时直接读本地块,减少 80% 计算。 - 水平分片:Prometheus 联邦集群,上层 Global 只做
sum,下层边缘节点保留 2 小时本地盘,避免跨区网络抖动。
避坑指南:这 3 个坑我们踩得最深
标签爆炸
误把user_id直接当 label, cardinality 飙到 200 万,Prometheus OOM。
解法:哈希到user_bucket=hash(user_id)%100,既保留分布趋势,又控住基数。Trace 未结束导致内存泄漏
早期把start_span写在async协程里,但异常时忘记end(),Pod 内存 12h 涨 3 GB。
解法:统一用start_as_current_span上下文管理器,或try/finally手工end()。Grafana 查询不加
rate直接increase
面板看起来“阶梯状”,误报 QPS 掉零。
解法:永远rate(xx[5m]),时间窗口 ≥ 2× 采样间隔,平滑又准确。
完整可拷贝的 Docker-Compose 最小栈
version: "3" services: prometheus: image: prom/prometheus:v2.45 volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml ports: - "9090:9090" grafana: image: grafana/grafana:10.0 environment: - GF_SECURITY_ADMIN_PASSWORD=grafana ports: - "3000:3000" otel-collector: image: otel/opentelemetry-collector-contrib:0.82 volumes: - ./otel-config.yml:/etc/otelcol-contrib/otel-config.yml ports: - "4317:4317" # gRPC loki: image: grafana/loki:2.9 ports: - "3100:3100"把 Dify 的OTEL_EXPORTER_OTLP_ENDPOINT指向otel-collector:4317,再导入官方仪表盘 ID20526,10 分钟就能出图。
思考题:跨地域调用监控该怎么设计?
如果客服流量同时走 3 个大区,每个区都有独立的 Prometheus,但用户一次会话可能跨区漂移,TraceID 如何保持全局唯一?采样策略要不要按地域加权?欢迎留言聊聊你的方案,一起把“看不见”的调用链彻底照亮。