Prhub

#27591 [router] Add request/TTFT/worker metrics + Grafana dashboard to experimental sgl-router

原始 PR 作者 Kangyan-Zhou 合并时间 2026-06-09 22:25 文件变更 10 提交数 1 评论 1 代码增减 +3042 / -48

执行摘要

sgl-router 新增请求 /TTFT/Worker 指标与 Grafana 仪表盘

Bring the experimental sgl-router's Prometheus surface toward parity with the SMG model gateway's applicable metrics, and add an operator-focused Grafana dashboard so the new router is observable out of the box.

建议精读该 PR,尤其是 Histogram 泛化、断路器快照和 SSE 首字节钩子的设计。三个设计模式(RAII 守卫、单锁快照、take-only-once 钩子)具有通用借鉴价值。

讨论亮点

无,该 PR 无 review 讨论。

实现拆解

  1. 指标注册表扩展 (experimental/sgl-router/src/server/metrics.rs) – 新增 request_durationttft(使用通用 Histogram,桶边界由常量 REQUEST_DURATION_BUCKETS 定义)、responses_total 计数器,以及 workersworker_healthworker_cb_stateworker_inflight_requests 四个 Gauge。Histogram 支持自定义桶边界,并带有 debug_assert 递增检查和非有限值静默丢弃保护。
  2. 断路器快照 (experimental/sgl-router/src/health/circuit_breaker.rs) – 新增 CircuitBreaker::snapshot() 方法,在单次锁获取内返回 (admit, state_code) 对,避免指标抓取时因两次锁读取到不一致的状态。定义 CircuitSnapshot 结构体。新加全套单元测试覆盖各种状态。
  3. SSE 泵首字节钩子 (experimental/sgl-router/src/proxy/sse.rs) – bytes_stream_to_body 新增 on_first_byte 参数,在循环中首次读到 Ok 字节块时触发一次(通过 take() 保证仅一次),错误优先流不会触发,确保 TTFT 指标只记录真实生成的 token。
  4. 聊天处理器集成 (experimental/sgl-router/src/server/routes/chat.rs) – 引入 RecordDurationOnDrop RAII 守卫,实现 Drop 时调用 observe_request_duration。对流式请求,该守卫被移入 SSE 泵的 stream_guards,在流结束时记录端到端延迟;非流式请求在分派处直接记录。TTFT 钩子仅安装在流式分支,且转发函数 forward_streaming_to 对非 2xx 响应丢弃钩子,避免错误体污染 TTFT。
  5. Worker 指标采样 (experimental/sgl-router/src/server/routes/metrics.rs) – /metrics 端点从 WorkerRegistry 实时采样所有 Worker,构造 WorkerSnapshot 并传给 render_with_workers,使 worker gauge 在抓取时反映最新状态,已移除的 Worker 立即停止输出序列。断路器状态通过单次 snapshot 获取,保证 health 和 state_code 一致。
  6. Grafana 仪表盘 (experimental/sgl-router/monitoring/) – 新增可导入的 grafana-dashboard.json,包含所有 13 个指标族的 Panels,支持 datasourcemodel_idworker_url 模板变量。配套 README.md 说明 Prometheus 抓取配置和导入步骤。
  7. 测试覆盖 – 包括 Histogram 桶/和/计数验证、+Inf 溢出、非有限值丢弃、断路器 snapshot 各状态正确性、SSE on_first_byte 仅触发一次且错误流不触发,以及端到端聊天路由测试验证 2xx 流式同时记录 TTFT 和 duration、5xx 流式仅记录 duration 和状态但不记录 TTFT(experimental/sgl-router/tests/proxy/chat_routing.rs)。
文件 模块 状态 重要度
experimental/sgl-router/src/server/metrics.rs 指标层 modified 9.05
experimental/sgl-router/src/health/circuit_breaker.rs 断路器 modified 8.54
experimental/sgl-router/src/proxy/sse.rs SSE 泵 modified 7.88
experimental/sgl-router/src/server/routes/chat.rs 聊天路由 modified 7.47
experimental/sgl-router/src/server/routes/metrics.rs 指标端点 modified 7.45
experimental/sgl-router/tests/proxy/chat_routing.rs 集成测试 modified 6.66
experimental/sgl-router/monitoring/grafana-dashboard.json 监控仪表盘 added 5.46

关键符号

MetricsRegistry::observe_request_duration MetricsRegistry::observe_ttft MetricsRegistry::record_response MetricsRegistry::render_with_workers Histogram::new (with bounds) Histogram::observe (with finite guard) CircuitBreaker::snapshot bytes_stream_to_body (with on_first_byte) RecordDurationOnDrop::drop chat_completions metrics (handler) WorkerRegistry::all_workers

关键源码片段

experimental/sgl-router/src/server/metrics.rs core-logic

核心指标注册表,新增 7 个指标族,泛化 Histogram,添加新渲染入口。

// experimental/sgl-router/src/server/metrics.rs — 泛化 Histogram 及新指标字段/// 通用直方图,桶边界由调用方传入,支持 +Inf 桶和非有限值丢弃。
pub struct Histogram {
    buckets: Vec<u64>, // 非累积计数,长度 = bounds.len() + 1
    sum: f64,
    count: u64,
}impl Histogram {
    pub fn new(bounds: &'static [f64]) -> Self {
        // 确保桶边界严格递增
        debug_assert!(bounds.windows(2).all(|w| w[0] < w[1]),
            "bucket bounds must be strictly ascending");
        Self { buckets: vec![0; bounds.len() + 1], sum: 0.0, count: 0 }
    }    pub fn observe(&mut self, value: f64) {
        if !value.is_finite() { return; } // NaN / Inf 不记录
        self.sum += value;
        self.count += 1;
        for (i, &bound) in self.bounds.iter().enumerate() {
            if value <= bound { self.buckets[i] += 1; return; }
        }
        self.buckets[self.bounds.len()] += 1; // +Inf 桶
    }
}/// 指标注册表(节选新增字段)
pub struct MetricsRegistry {
    // ... 原有字段 ...
    request_duration: Mutex<HashMap<String, Histogram>>,
    ttft_seconds: Mutex<HashMap<String, Histogram>>,
    responses_total: Mutex<HashMap<u16, Arc<AtomicU64>>>,
}impl MetricsRegistry {
    pub fn observe_request_duration(&self, model: &str, value: f64) {
        let mut map = self.request_duration.lock().unwrap();
        map.entry(model.to_string())
            .or_insert_with(|| Histogram::new(REQUEST_DURATION_BUCKETS))
            .observe(value);
    }    pub fn observe_ttft(&self, model: &str, value: f64) {
        let mut map = self.ttft_seconds.lock().unwrap();
        map.entry(model.to_string())
            .or_insert_with(|| Histogram::new(REQUEST_DURATION_BUCKETS))
            .observe(value);
    }    pub fn record_response(&self, status: u16) {
        let mut map = self.responses_total.lock().unwrap();
        let counter = map.entry(status)
            .or_insert_with(|| Arc::new(AtomicU64::new(0)));
        counter.fetch_add(1, Ordering::Relaxed);
    }
}
experimental/sgl-router/src/health/circuit_breaker.rs core-logic

新增 snapshot 方法,提供单锁一致的状态快照,支撑 worker_gauge 渲染。

// experimental/sgl-router/src/health/circuit_breaker.rs — 一致性快照/// 快照结果:admit 为是否允许请求,state_code 编码当前状态。
#[derive(Debug, Clone, Copy)]
pub struct CircuitSnapshot {
    pub admit: bool,
    pub state_code: u8, // 0=closed, 1=open, 2=half_open
}impl CircuitBreaker {
    /// 在单次锁获取内返回 (admit, state_code) 一致对,
    /// 用于指标抓取,避免两次锁间状态变化导致矛盾。
    pub fn snapshot(&self) -> CircuitSnapshot {
        let g = self.inner.lock().unwrap();
        let (admit, state_code) = match g.state {
            State::Closed => (true, 0),
            State::Open { opened_at } =>
                (opened_at.elapsed() >= self.config.cool_down, 1),
            State::HalfOpen { probe_in_flight } => (!probe_in_flight, 2),
        };
        CircuitSnapshot { admit, state_code }
    }
}
experimental/sgl-router/src/proxy/sse.rs core-logic

添加 on_first_byte 钩子,支撑 TTFT 指标采集。

// experimental/sgl-router/src/proxy/sse.rs — 首字节钩子集成到 SSE 泵// bytes_stream_to_body 新增 on_first_byte 参数
pub fn bytes_stream_to_body<S, E>(
    stream: S,
    stream_guards: Option<Box<dyn Send + 'static>>,
    on_complete: Option<Box<dyn FnOnce(bool) + Send + 'static>>,
    on_first_byte: Option<Box<dyn FnOnce() + Send + 'static>>, // <-- 新增
) -> Body { ... }// 在泵循环内,仅对第一个 Ok 块触发一次
let mut on_first_byte = on_first_byte;
while let Some(chunk) = s.next().await {
    // ... 错误处理 ...
    let is_err_chunk = item.is_err();
    if !is_err_chunk {
        if let Some(hook) = on_first_byte.take() { // take 保证只触发一次
            hook(); // 记录 TTFT
        }
    }
    // ...
}

评论区精华

没有提炼出高价值讨论线程

当前评论区没有形成足够清晰的争议点或结论,后续有更多讨论时会体现在这里。

风险与影响

  • metrics.rs:Histogram 使用 Mutex,在高 QPS 下可能成为竞争点,但作者基于 15s 抓取间隔和亚秒级测试判断可接受。在更高压力下可能需要无锁结构。
  • sse.rs:on_first_byte 逻辑嵌套在流循环中,如果 on_first_byte.take() 后意外多次触发(虽已通过 take 保证),可能影响正确性,但测试覆盖了仅触发一次。
  • circuit_breaker.rssnapshot 返回的 admitstate_code 可能看似矛盾(例如 Open 但 admit=true),但这是设计意图,需要运维人员理解。
  • chat.rsRecordDurationOnDrop 的 drop 在 tokio 任务中执行,可能被延迟释放,但 20ms sleep 已用于测试同步;生产环境延迟在可接受范围内。
  • 用户:操作员可以直接用 Grafana 监控路由器,无需自行编写抓取规则。
  • 系统:新增约 10 种指标存储,内存开销有限;CPU 开销集中在指标渲染(15s 一次)。
  • 团队:实验性路由器的可观测性达到生产级别,降低运维门槛。
指标锁竞争 SSE 钩子回归 断路器快照一致性

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论