一句话总结

通过分析多 LLM 工作流中各模型执行时间占比的稳定性,Scepsy 实现了比独立优化方案高 2.4x 吞吐量、低 27x 延迟的智能体服务调度。


为什么需要这个?

问题:多 LLM 工作流下 GPU 资源严重浪费

现代智能体系统(如 AutoGPT、LangGraph、CrewAI)通常编排多个 LLM 协作完成任务:

用户请求
  ├── Planner LLM(规划任务)
  │     ├── Executor LLM × N(并行执行)
  │     │     └── Critic LLM(评估结果)
  │     └── Summarizer LLM(汇总)
  └── 返回结果

实际部署时有两个核心痛点:

  1. 执行路径不可预测:同一个 workflow,第一次调用 Executor 3 次,第二次可能调用 7 次,总延迟差 3 倍
  2. LLM 数量 > GPU 数量:一个生产系统可能有 8 个不同 LLM,但只有 4 张 GPU,必须分时复用

现有方案的问题:

方案 问题
给每个 LLM 分配固定 GPU 利用率低,LLM 等待各自的 GPU 而相互阻塞
按请求量等比分配 忽略了不同 LLM 计算量的本质差异
用户手工指定 需要专业知识,换个 workflow 就失效

核心原理:稳定的时间占比

直觉:混乱中有秩序

想象一家餐厅(GPU 集群)服务不同菜系(LLM)。虽然每桌客人点什么菜完全随机,但长期统计下来,中餐、日料、西餐各占厨房时间的比例相当稳定。

Scepsy 的关键洞察:

虽然单次工作流的端到端延迟不可预测,但各 LLM 占总计算时间的比例(share)在多次执行中高度稳定。

假设某工作流运行 100 次,统计每个 LLM 的 GPU 计算时间:

Planner LLM:    平均占 15% ± 2%   ← 非常稳定
Executor LLM:   平均占 60% ± 5%   ← 稳定
Critic LLM:     平均占 20% ± 3%   ← 稳定
Summarizer LLM: 平均占  5% ± 1%   ← 稳定

为什么稳定? 即使执行次数随机变化,分布本身是稳定的(大数定律)。这就是 Scepsy 能够工作的数学基础。

从时间占比到 GPU 分配

有了稳定的时间占比 $s_i$,GPU 分配问题就变成了一个约束优化问题

\[\text{最小化} \quad \text{E2E\_Latency}(a_1, a_2, ..., a_n)\] \[\text{约束} \quad \sum_i \text{GPU}(a_i) \leq \text{Total\_GPU}\]

其中 $a_i = (f_i, p_i, r_i)$ 是每个 LLM 的分配三元组

  • $f_i$:GPU 分数(fractional share,如 0.5 表示半张 GPU)
  • $p_i$:Tensor Parallelism 度(跨 GPU 拆分权重)
  • $r_i$:副本数(并行处理多个请求)

代码实现

Baseline:每个 LLM 独立分配,互不相知

# baseline_scheduler.py
# 朴素方案:每个 LLM 按请求量均分 GPU,完全忽略计算特性

class NaiveScheduler:
    def __init__(self, total_gpus: int):
        self.total_gpus = total_gpus
    
    def allocate(self, llm_names: list[str]) -> dict[str, float]:
        """均分 GPU,不考虑每个 LLM 的实际计算需求"""
        gpu_per_llm = self.total_gpus / len(llm_names)
        return {name: gpu_per_llm for name in llm_names}

# 问题:Planner LLM 只用了分配给它的 5% 时间
# 而 Executor LLM 严重过载,其他 LLM 在排队等待
scheduler = NaiveScheduler(total_gpus=4)
allocation = scheduler.allocate(["planner", "executor", "critic", "summarizer"])
# 结果: {'planner': 1.0, 'executor': 1.0, 'critic': 1.0, 'summarizer': 1.0}
# 每人一张卡,但 executor 的实际工作量是 planner 的 4 倍

性能分析:在真实 benchmark 中,这种方案导致 Executor LLM 队列堆积,平均等待时间占总延迟的 40%+。


第一步:构建时间占比 Profiler

# profiler.py
# 核心:通过采样估计各 LLM 的稳定时间占比

import time
import threading
from collections import defaultdict

class WorkflowProfiler:
    def __init__(self, warmup_runs=10, profile_runs=50):
        self.warmup_runs = warmup_runs
        self.profile_runs = profile_runs
        self._times: dict[str, list[float]] = defaultdict(list)
        self._lock = threading.Lock()
    
    def record(self, llm_name: str, duration_ms: float):
        """记录单次 LLM 调用的 GPU 时间"""
        with self._lock:
            self._times[llm_name].append(duration_ms)
    
    def get_shares(self) -> dict[str, float]:
        """计算各 LLM 的时间占比(归一化)"""
        # 取最近 profile_runs 次的平均值,忽略 warmup
        avg_times = {}
        for name, times in self._times.items():
            recent = times[-self.profile_runs:] if len(times) > self.profile_runs else times
            avg_times[name] = sum(recent) / len(recent) if recent else 0
        
        total = sum(avg_times.values())
        if total == 0:
            return {k: 1.0/len(avg_times) for k in avg_times}
        
        # 关键:返回相对占比,而非绝对时间
        return {name: t / total for name, t in avg_times.items()}
    
    def is_stable(self, threshold=0.05) -> bool:
        """检查占比是否已收敛(标准差 < threshold)"""
        shares_history = self._compute_shares_history(window=10)
        return all(std < threshold for std in shares_history.values())
    
    def _compute_shares_history(self, window=10):
        import statistics
        result = {}
        for name, times in self._times.items():
            if len(times) < window * 2:
                result[name] = float('inf')
                continue
            # 计算最近两个窗口的占比,看波动
            recent_share = sum(times[-window:]) / sum(sum(v[-window:]) for v in self._times.values())
            older_share = sum(times[-2*window:-window]) / sum(sum(v[-2*window:-window]) for v in self._times.values())
            result[name] = abs(recent_share - older_share)
        return result

第二步:Aggregate LLM Pipeline——轻量级延迟预测器

这是 Scepsy 的核心创新。它不预测单次请求延迟,而是预测给定 GPU 分配下的系统级延迟

# aggregate_pipeline.py
# 基于 roofline 模型的简化延迟预测器

from dataclasses import dataclass
import numpy as np

@dataclass
class LLMProfile:
    name: str
    model_size_b: float    # 参数量(十亿)
    avg_input_tokens: int
    avg_output_tokens: int
    time_share: float      # 由 profiler 得到

@dataclass  
class AllocationConfig:
    gpu_fraction: float    # 分配的 GPU 分数
    tensor_parallel: int   # TP 度
    replicas: int          # 副本数

class AggregateLLMPipeline:
    """
    核心预测器:给定 GPU 分配,预测工作流延迟
    基于排队论(M/D/1 队列)建模
    """
    # 硬件常数(A100 80GB 为例)
    GPU_PEAK_TFLOPS = 312.0     # BF16
    MEM_BW_GB_S = 2000.0        # HBM3
    INTER_GPU_BW_GB_S = 600.0   # NVLink
    
    def predict_latency(
        self, 
        profiles: list[LLMProfile],
        allocations: list[AllocationConfig],
        target_qps: float
    ) -> float:
        """
        预测给定分配方案下的端到端 P99 延迟
        
        核心思路:
        1. 每个 LLM 是一个独立的排队系统
        2. 总延迟由 critical path 决定
        3. 考虑 TP 的通信开销
        """
        total_latency = 0.0
        
        for llm, alloc in zip(profiles, allocations):
            # Step 1: 计算单 LLM 的吞吐能力
            effective_tflops = self.GPU_PEAK_TFLOPS * alloc.gpu_fraction * alloc.tensor_parallel
            # TP 通信开销(all-reduce 代价)
            tp_overhead = 1.0 + 0.1 * (alloc.tensor_parallel - 1)
            effective_tflops /= tp_overhead
            
            # Step 2: 计算单请求延迟(roofline)
            compute_flops = 2 * llm.model_size_b * 1e9 * llm.avg_output_tokens
            compute_latency = compute_flops / (effective_tflops * 1e12)  # 秒
            
            # Step 3: 考虑副本带来的并行度收益
            effective_qps_per_replica = (target_qps * llm.time_share) / alloc.replicas
            # M/D/1 排队延迟近似:ρ/(2(1-ρ)) * service_time
            rho = effective_qps_per_replica * compute_latency  # 利用率
            if rho >= 1.0:
                return float('inf')  # 系统过载
            queue_latency = (rho / (2 * (1 - rho))) * compute_latency
            
            total_latency += compute_latency + queue_latency
        
        return total_latency * 1000  # 转为 ms

第三步:分配搜索——找到最优 GPU 分配

# profiler.py
# 核心:通过采样估计各 LLM 的稳定时间占比

from collections import defaultdict

class WorkflowProfiler:
    def __init__(self, warmup_runs=10, profile_runs=50):
        self.profile_runs = profile_runs
        self._times: dict[str, list[float]] = defaultdict(list)

    def record(self, llm_name: str, duration_ms: float):
        self._times[llm_name].append(duration_ms)

    def get_shares(self) -> dict[str, float]:
        # 取最近 profile_runs 次均值,忽略 warmup
        avg_times = {
            name: sum(times[-self.profile_runs:]) / len(times[-self.profile_runs:])
            for name, times in self._times.items() if times
        }
        total = sum(avg_times.values())
        # 关键:返回相对占比,而非绝对时间
        return {name: t / total for name, t in avg_times.items()} if total else {}

    def is_stable(self, threshold=0.05) -> bool:
        # 比较最近两个窗口的占比波动,判断是否收敛
        # ... (窗口统计代码省略)
        pass

完整使用示例

from dataclasses import dataclass

@dataclass
class LLMProfile:
    model_size_b: float
    avg_output_tokens: int
    time_share: float

@dataclass
class AllocationConfig:
    gpu_fraction: float
    tensor_parallel: int
    replicas: int

class AggregateLLMPipeline:
    GPU_PEAK_TFLOPS = 312.0  # A100 BF16

    def predict_latency(self, profiles, allocations, target_qps) -> float:
        total_latency = 0.0
        for llm, alloc in zip(profiles, allocations):
            # Roofline: 有效算力(含 TP 通信开销)
            effective_tflops = self.GPU_PEAK_TFLOPS * alloc.gpu_fraction * alloc.tensor_parallel
            effective_tflops /= (1.0 + 0.1 * (alloc.tensor_parallel - 1))

            # 单请求计算延迟
            compute_latency = (2 * llm.model_size_b * 1e9 * llm.avg_output_tokens) / (effective_tflops * 1e12)

            # M/D/1 排队延迟:ρ/(2(1-ρ)) * service_time
            rho = (target_qps * llm.time_share / alloc.replicas) * compute_latency
            if rho >= 1.0:
                return float('inf')
            total_latency += compute_latency + (rho / (2 * (1 - rho))) * compute_latency

        return total_latency * 1000  # ms

性能实测(论文数据,A100 集群)

实现版本 吞吐量 (req/s) P99 延迟 (s) GPU 利用率
均分分配(Naive) 4.2 87.3 41%
用户手工指定 6.8 52.1 58%
独立优化各 LLM 7.1 49.6 62%
Scepsy 10.1 3.2 81%

测试环境:8× A100 80GB,4 个 LLM 的 RAG 推理工作流,CUDA 12.1

延迟差距尤其显著(27x),原因在于 Naive 方案的排队级联效应:一个 LLM 的阻塞会向下游传播,而 Scepsy 通过精准分配避免了任何节点成为瓶颈。


常见踩坑

坑 1:时间占比随 batch size 变化

class ScepsyAllocator:
    def __init__(self, total_gpus: int, pipeline: AggregateLLMPipeline):
        self.total_gpus = total_gpus
        self.pipeline = pipeline

    def search(self, profiles: list[LLMProfile], target_qps: float) -> list[AllocationConfig]:
        best_latency, best_allocs = float('inf'), None
        base_gpu_fracs = np.array([p.time_share for p in profiles]) * self.total_gpus

        for tp_combo in self._enumerate_tp(len(profiles), [1, 2, 4, 8]):
            allocs, remaining = [], self.total_gpus
            for i, (profile, tp) in enumerate(zip(profiles, tp_combo)):
                # GPU 分数按时间占比初始化,向上取整到 TP 倍数
                gpu_frac = min(max(tp, round(base_gpu_fracs[i] / tp) * tp), remaining)
                replicas = max(1, int(gpu_frac / tp))
                remaining -= replicas * tp
                allocs.append(AllocationConfig(gpu_fraction=gpu_frac / self.total_gpus,
                                               tensor_parallel=tp, replicas=replicas))

            latency = self.pipeline.predict_latency(profiles, allocs, target_qps)
            if latency < best_latency:
                best_latency, best_allocs = latency, allocs

        return best_allocs

    def _enumerate_tp(self, n, options, max_combos=200):
        # 随机采样避免指数爆炸
        combos = list(itertools.product(options, repeat=n))
        return random.sample(combos, max_combos) if len(combos) > max_combos else combos

坑 2:TP 通信开销被低估

不同 GPU 互联拓扑下,TP 扩展效率差异巨大:

  • NVLink(A100 机内):TP=4 效率约 92%
  • PCIe(跨机):TP=4 效率可能只有 60%

Scepsy 的 tp_overhead 参数需根据实际互联测试调整,不能直接用论文默认值。

坑 3:工作流结构变化导致占比漂移

如果工作流逻辑随时间更新(如增加了一个新的 LLM 节点),需要触发重新 profile,否则旧的 share 数据会导致错误的分配决策。


什么时候用 / 不用?

适用场景 不适用场景
多 LLM 工作流(≥3 个模型) 单 LLM 推理服务
LLM 数量 > GPU 数量 GPU 资源充足(每模型独占)
工作流结构相对稳定 极度动态的工作流(每次结构变化大)
吞吐量敏感场景 超低延迟(< 100ms)场景
离线/批处理任务 实时交互(用户每次等待)

调试技巧

验证时间占比是否稳定:在上线前运行至少 50 次 profile,绘制各 LLM 时间占比的滑动平均曲线,确认收敛后再部署 Scepsy 分配方案。

检查 TP 效率:用 NCCL 的 all_reduce benchmark 测量实际 GPU 间带宽,与理论峰值对比,校准 tp_overhead 参数。

监控排队深度:生产环境中持续监控每个 LLM 的请求队列长度,若某节点持续积压,说明分配还有优化空间,可触发重新 search。


延伸阅读

  • Scepsy 原论文:Section 4(Aggregate LLM Pipeline 的数学推导)值得深读
  • vLLM 的 Continuous Batching:理解 LLM serving 的基础,是 Scepsy 的底层假设
  • Orca (OSDI’22):最早系统性研究 LLM serving 调度的工作,提供了排队论视角的基础
  • Sarathi-Serve:处理 prefill/decode 分离的调度,与 Scepsy 正交,可以组合使用