Prhub

#32734 [metrics] Split tokenizer request metrics by stream

原始 PR 作者 houseroad 合并时间 2026-08-05 03:53 文件变更 2 提交数 4 评论 3 代码增减 +24 / -12

执行摘要

按流式与非流式拆分 tokenizer 五项核心指标

PR body 明确指出:流式与非流式请求的延迟分布本质不同(流式请求按设计常驻),但 tokenizer 的按请求指标缺少 stream 维度,导致请求计数、e2e 延迟与 token 吞吐无法按流量类别分段,e2e 延迟直方图混叠两种分布形态,影响容量评估与 SLO 判读。PR 同时明确排除 num_aborted_requests_total(调用点只有 rid,abort_all 场景 flag 有歧义)与 per-batch 调度器指标(混合批次无法归类),避免在语义不清晰处引入伪维度。

值得精读,但重点不在代码量而在两个设计决策:一是可观测性维度如何与下游存储 schema 对齐(thrift 保留字导致标签改名),二是 straggler 判定为何固定 stream=true。合并后建议补充一条针对 labelnames 与上报值一致性的单测,并核对现有 TTFT 告警是否仍符合预期。若要精读,聚焦 TokenizerMetricsCollector 的 observe_one_finished_request 与 check_time_to_first_token_straggler 两个函数即可。

讨论亮点

唯一有效 review 评论来自合并者 merrymercy,要求把 tokenizer_manager.py 调用处的关键字参数从 stream 重命名为 is_streaming(与指标 label 命名统一),最终在最后一次 commit 4c84034 中落地。更有价值的设计讨论体现在 commit 23ccd17 的提交说明里:标签名 stream 与下游存储冲突——stream 是 thrift 保留关键字,无法成为 schema 列名;label 名不匹配时依赖 alias 解析不可靠,会导致按任务维度的 windowed max 聚合低估计数器。此外 PR body 对拆分范围做了明确取舍:不拆 num_aborted_requests_total(调用点只有 rid,abort_all 时无法确定 stream 属性)与 per-batch 调度器指标(混合批次无法归类)。

实现拆解

  1. 指标定义扩展(python/sglang/srt/observability/metrics_collector.py):TokenizerMetricsCollector.init 将 prompt_tokens_total、generation_tokens_total、num_requests_total、histogram_time_to_first_token、histogram_e2e_request_latency 的 labelnames 统一扩展为 list(labels.keys()) + ["is_streaming"];spec_verify_calls_total、cached_tokens_total、inter-token latency 等其余指标保持原标签集,控制 series 膨胀面。
  2. 请求维度透传(python/sglang/srt/managers/tokenizer_manager.py):TokenizerManager.collect_metrics 在请求结束上报处新增 is_streaming=getattr(state.obj, "stream", False),用 getattr 兜底保证兼容未设置该字段的请求对象。
  3. 上报与判定逻辑调整(metrics_collector.py):observe_one_finished_request 新增 is_streaming: bool = False 参数,函数内构造 stream_labels 字典,用于 5 个目标指标的 inc / observe;observe_time_to_first_token 的 label 从 stream 改为 is_streaming;check_time_to_first_token_straggler 固定以 is_streaming="true" 的直方图作为 p99 基准,因为流式请求才是首 token 尾延迟(straggler)的观察对象。
  4. 标签命名对齐下游存储:中途 commit(23ccd17)将 label 从 stream 改名 is_streaming。原因是下游指标存储把该维度固化为列名,而 stream 是 thrift 保留关键字无法作为列名;若 label 名与存储不匹配,依赖 alias 解析不可靠,会导致同一 task 内各 stream 计数器系列交错,windowed max 聚合会低估计数。该约束以注释形式固化在 histogram_time_to_first_token 定义处,防止未来误改。
  5. 测试与验证配套:本 PR 未附带自动化测试;作者说明已在内部部署验证 5 个指标带新标签导出且可抓取,既有基于 base 标签的 dashboard 在新维度上仍可聚合。
文件 模块 状态 重要度
python/sglang/srt/observability/metrics_collector.py 指标采集 modified 6.61
python/sglang/srt/managers/tokenizer_manager.py 请求管理 modified 4.35

关键符号

TokenizerMetricsCollector.__init__ TokenizerMetricsCollector.observe_one_finished_request TokenizerMetricsCollector.observe_time_to_first_token TokenizerMetricsCollector.check_time_to_first_token_straggler TokenizerManager.collect_metrics

关键源码片段

python/sglang/srt/observability/metrics_collector.py core-logic

核心改动文件:5 个指标新增 is_streaming 维度、上报方法与 straggler 判定逻辑调整,标签命名(stream -> is_streaming)的约束也固化在此处的代码注释中。

以下是 TokenizerMetricsCollector.observe_one_finished_request 的完整实现(head 版本整理),核心是 stream_labels 的构造与使用:

# TokenizerMetricsCollector 类方法:请求完成时上报按请求维度的指标
def observe_one_finished_request(
    self,
    labels: Dict[str, str],
    prompt_tokens: int,
    generation_tokens: int,
    cached_tokens: int,
    e2e_latency: float,
    has_grammar: bool,
    cached_tokens_details: Optional[Dict[str, Any]] = None,
    spec_verify_ct: int = 0,
    # 新增参数:标记该请求是否为流式请求,默认 False 以兼容旧调用方
    is_streaming: bool = False,
):
    # 在基础标签之上叠加 is_streaming 维度,
    # 值为 "true"/"false" 字符串,符合 Prometheus label value 约定
    stream_labels = {
        **labels,
        "is_streaming": "true" if is_streaming else "false",
    }
    self.prompt_tokens_total.labels(**stream_labels).inc(prompt_tokens)
    self.generation_tokens_total.labels(**stream_labels).inc(generation_tokens)
    if spec_verify_ct > 0:
        self.spec_verify_calls_total.labels(**labels).inc(spec_verify_ct)
​
    # 缓存命中 token 按来源拆分上报,与 stream 维度无关,
    # 因此沿用原始 labels,避免无谓的 series 增长
    if cached_tokens > 0:
        if cached_tokens_details:
            def report_cache_source(source: str, value: int):
                if value > 0:
                    source_labels = {**labels, "cache_source": source}
                    self.cached_tokens_total.labels(**source_labels).inc(value)
​
            report_cache_source("device", cached_tokens_details.get("device", 0))
            report_cache_source("host", cached_tokens_details.get("host", 0))
​
            # storage 字段仅在启用 L3 存储后端时出现
            if "storage" in cached_tokens_details:
                storage_tokens = cached_tokens_details.get("storage", 0)
                if storage_tokens > 0:
                    backend = (
                        cached_tokens_details.get("storage_backend") or "unknown"
                    )
                    report_cache_source(f"storage_{backend}", storage_tokens)
        else:
            # 向后兼容兜底路径:无明细时统一记到 cache_source = "total"
            labels_total = {**labels, "cache_source": "total"}
            self.cached_tokens_total.labels(**labels_total).inc(cached_tokens)
​
    # 请求计数与 e2e 延迟直方图按流量类别拆分,
    # 解决流式与非流式分布形态不同导致的直方图混叠问题
    self.num_requests_total.labels(**stream_labels).inc(1)
    if has_grammar:
        self.num_so_requests_total.labels(**labels).inc(1)
    self.histogram_e2e_request_latency.labels(**stream_labels).observe(
        float(e2e_latency)
    )
    self.prompt_tokens_histogram.labels(**labels).observe(float(prompt_tokens))
    self.uncached_prompt_tokens_histogram.labels(**labels).observe(
        float(prompt_tokens - cached_tokens)
    )
    self.generation_tokens_histogram.labels(**labels).observe(
        float(generation_tokens)
    )

TTFT 上报与 straggler 判定是另一处关键语义——判定基准固定为流式请求:

# TTFT 观察与首 token straggler 判定:基准只统计流式请求
def observe_time_to_first_token(
    self, labels: Dict[str, str], value: float, *, stream: bool
):
    # TTFT 直方图上报,is_streaming 维度与 e2e 延迟直方图保持一致
    self.histogram_time_to_first_token.labels(
        **labels, is_streaming="true" if stream else "false"
    ).observe(value)
​
​
def check_time_to_first_token_straggler(self, value: float) -> bool:
    # 仅基于流式请求的 TTFT 分布做尾延迟判定:
    # 非流式请求一次性返回全部输出,不构成首 token straggler 观察对象。
    # 注意此处直接读取 prometheus_client 的 bucket 内部字段,
    # 在 multiprocess 模式下只反映当前进程的本地视图
    his = self.histogram_time_to_first_token.labels(
        **self.labels, is_streaming="true"
    )
    total_observations = sum(bucket._value for bucket in his._buckets)
    if total_observations < 100:
        return False
    p99_threshold = total_observations * 0.99
    cumulative_count = 0
    for i, bucket in enumerate(his._buckets):
        cumulative_count += bucket._value
        if cumulative_count > p99_threshold:
            return value >= his._upper_bounds[i]
    return False
python/sglang/srt/managers/tokenizer_manager.py core-logic

指标数据入口:请求完成时把请求对象上的 stream 标志透传给采集器,是 is_streaming 维度的数据来源。

# TokenizerManager.collect_metrics:请求完成分支,透传 stream 标志
if state.finished:
    # 请求对象上的 stream 字段标记是否为流式请求;
    # 使用 getattr 兜底,兼容未设置该字段的请求对象
    self.metrics_collector.observe_one_finished_request(
        labels,
        recv_obj.prompt_tokens[i],
        completion_tokens,
        recv_obj.cached_tokens[i],
        state.time_stats.get_e2e_latency(),
        self._request_has_grammar(state.obj),
        cached_tokens_details,
        spec_verify_ct=spec_verify_ct,
        is_streaming=getattr(state.obj, "stream", False),
    )

评论区精华

调用处关键字参数重命名 stream -> is_streaming style

merrymercy 在 tokenizer_manager.py 的 diff 上评论:rename the argument `stream` -> `is_streaming`,要求调用处关键字参数与指标 label 命名保持一致。

结论:已采纳,最后一次 commit 将调用改为 is_streaming=getattr(state.obj, "stream", False)。 · 已解决

标签名与下游存储对齐(thrift 保留字问题) 设计

commit 23ccd17 提交说明揭示:metric label 早期命名 stream,但下游存储 schema 中 stream 是 thrift 保留关键字无法作为列名;依赖 alias 解析不可靠,会导致同一 task 内各 stream 计数器系列交错,windowed max 聚合低估计数,因此 label 必须直接用存储列名 is_streaming。

结论:采用 is_streaming 作为 5 个指标的统一维度名,并在代码注释中固化该约束。 · 已解决

拆分范围取舍:abort 计数与 per-batch 指标不拆 设计

PR body 说明有意不拆 num_aborted_requests_total(调用点只有 rid,abort_all 时 stream 属性有歧义)与调度器 per-batch 指标(混合批次无法归类),避免在语义不清晰处引入伪维度。

结论:维持原样,仅拆 5 个按请求维度清晰的指标。 · 已解决

风险与影响

  1. 指标基数翻倍:5 个指标各新增一个 bool 枚举标签,series 数量翻倍;在指标量大的集群可观测性开销有所上升,但影响可控。
  2. straggler 判定语义:check_time_to_first_token_straggler 的 p99 基准固定为 is_streaming="true" 的流式请求分布,非流式请求的 TTFT 不再参与判定;依赖全量 TTFT 尾延迟告警的团队需复核预期。
  3. 缺少测试覆盖:没有新增单测或 e2e 测试验证 labelnames 与上报值一致;若后续调用方漏传 is_streaming,Prometheus client 会在 .labels() 处抛 MissingLabelName 异常,回归风险集中在采集链路。
  4. 消费端兼容性:新增 label 改变 series 身份,依赖旧 label 集合的告警表达式需复查;PR 声称基于 base 标签的 dashboard 聚合不受影响。
  5. 即时生效:无 server_args 开关,合并后所有部署立即启用新维度。

影响面集中在 tokenizer 指标链路:5 个按请求维度的指标从单一 series 拆分为流式/非流式两类,运维与容量规划可按流量类别独立观察请求数、TTFT、e2e 延迟与 token 吞吐,SLO 与告警更加精准。对系统性能影响可忽略(每次请求完成仅多构造一个含一个键的 dict 与一次 getattr)。对团队而言,本次敲定了指标 label 与下游存储列名对齐的约束(thrift 保留字问题),后续新增维度时应沿用 is_streaming 这类存储安全命名;同时 TTFT straggler 检查的语义被收紧到流式流量。

指标基数翻倍 无测试覆盖 straggler 判定语义变化 label 命名影响消费端

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论