上周在RTX 4090上部署Qwen2.5-72B-Instruct做内部代码审查服务,初始性能惨不忍睹:首Token延迟3.2秒,生成速度只有5 tokens/s。一个稍长的PR diff要等将近2分钟才能审完,开发团队直接弃用了。

折腾了三天,最终把TTFT压到0.4秒,生成速度提到40 tokens/s。这篇文章记录整个优化过程,每一步的原理和效果都有数据对比。

性能瓶颈定位

优化之前先搞清楚慢在哪。用vLLM自带的metrics端点采集了一组数据:

指标                    原始值      说明
TTFT                    3.2s        首Token延迟
生成速度                 5.2 tok/s   输出速度
KV Cache使用率           98%         显存几乎打满
GPU计算利用率             35%         核心大量空转
排队请求数               12          经常排队

GPU利用率才35%但KV Cache已经98%,说明瓶颈在显存而不是计算。72B模型的KV Cache是大头,每层128个头、每头128维,32层算下来单请求的KV Cache就要吃掉好几GB显存。

优化一:量化模型

第一步是把模型从FP16量化到GPTQ-Int4。这是最直接的显存优化:

# 用AutoGPTQ量化
python -m auto_gptq.quantize \
    Qwen/Qwen2.5-72B-Instruct \
    --bits 4 \
    --group_size 128 \
    --desc_act True \
    --output_dir Qwen2.5-72B-Instruct-GPTQ-Int4

量化后显存占用从144GB降到42GB,一张A100-80G就能放下。但精度损失需要验证——在内部代码审查测试集上跑了评估:

任务                FP16准确率    Int4准确率    差异
Bug检测             91.2%        89.8%        -1.4%
代码规范            87.5%        86.1%        -1.4%
安全漏洞            83.3%        81.9%        -1.4%

精度损失在2%以内,完全可接受。关键收益是显存降了67%,意味着可以开更大的并发。

优化二:连续批处理配置

vLLM的连续批处理是吞吐量的核心。调整了几个关键参数:

vllm serve Qwen2.5-72B-Instruct-GPTQ-Int4 \
    --max-num-seqs 8 \
    --max-num-batched-tokens 16384 \
    --gpu-memory-utilization 0.92 \
    --enable-prefix-caching \
    --enable-chunked-prefill \
    --max-model-len 8192

逐个解释:

  • max-num-seqs 8:最大并发请求数。从默认的256改成8,因为72B模型每个请求的KV Cache很大,256个请求根本放不下。
  • max-num-batched-tokens 16384:单批次最大token数。配合并发数,确保GPU能充分利用。
  • gpu-memory-utilization 0.92:GPU显存使用率上限。留8%给系统和临时缓冲。
  • enable-prefix-caching:开启前缀缓存,相同system prompt的请求复用KV Cache。
  • enable-chunked-prefill:分块预填充,长输入不会阻塞其他请求。
  • max-model-len 8192:最大上下文长度。代码审查场景8K够用,开到32K会大幅增加KV Cache占用。

优化三:Prompt精简

原始system prompt有2000多个token,里面塞了一大堆通用规则。分析后发现真正有用的核心指令只有500多token,剩下都是冗余的示例和说明。

精简策略:

优化项                      效果
删除冗余示例                 -800 token
合并重复规则                 -300 token
移除"你是一个AI助手"废话     -200 token
压缩输出格式说明             -400 token

System prompt从2000 token压到500 token。这不只是省token的问题——更短的prompt意味着更少的prefill计算,TTFT直接降了0.5秒。

优化四:投机采样

投机采样(Speculative Decoding)是用一个小模型先猜几个token,大模型再验证。猜对了就直接用,猜错了回退。实测能提升2-3倍的生成速度:

vllm serve Qwen2.5-72B-Instruct-GPTQ-Int4 \
    --speculative-model Qwen/Qwen2.5-7B-Instruct \
    --num-speculative-tokens 5 \
    --speculative-max-model-len 4096

原理是7B模型推理速度快10倍,它先生成5个候选token,72B模型一次性验证。如果命中率高(实测60-70%),生成速度就接近7B模型了。

优化五:请求预处理

在请求到达vLLM之前加了一层预处理:

class RequestPreprocessor:
    def __init__(self, max_diff_chars=5000):
        self.max_diff_chars = max_diff_chars

    def preprocess(self, request):
        """预处理请求,减少不必要的token消耗"""
        diff = request["diff"]

        # 1. 截断过长的diff
        if len(diff) > self.max_diff_chars:
            diff = self._smart_truncate(diff)
            request["diff"] = diff
            request["truncated"] = True

        # 2. 移除无意义的变更(空行、格式化、import排序)
        diff = self._remove_noise(diff)
        request["diff"] = diff

        # 3. 估算token数,超长请求用低优先级队列
        est_tokens = len(diff) // 3
        if est_tokens > 4000:
            request["priority"] = "low"

        return request

    def _smart_truncate(self, diff):
        """智能截断,保留变更头和关键文件"""
        lines = diff.split('\n')
        # 保留文件头信息
        headers = [l for l in lines if l.startswith('diff --git') or l.startswith('@@')]
        # 保留变更内容
        content = [l for l in lines if not l.startswith('diff --git') and not l.startswith('@@')]
        # 截断内容但保留头部
        truncated = '\n'.join(content[:self.max_diff_chars // 50])
        return '\n'.join(headers) + '\n' + truncated

    def _remove_noise(self, diff):
        """移除无意义的变更"""
        lines = diff.split('\n')
        filtered = []
        for line in lines:
            # 跳过纯空行变更
            if line.startswith('+') and line[1:].strip() == '':
                continue
            # 跳过纯格式化变更(只有空格/tab变化)
            if line.startswith('+') and line.startswith('-'):
                old = line[1:].strip()
                new = line[1:].strip()
                if old.replace(' ', '') == new.replace(' ', ''):
                    continue
            filtered.append(line)
        return '\n'.join(filtered)

优化效果

每一步优化的效果对比:

优化项                  TTFT        生成速度      并发能力
基线                    3.2s        5.2 tok/s     1-2请求
Int4量化                2.8s        5.8 tok/s     4-6请求
连续批处理配置           2.5s        12.3 tok/s    8请求
Prompt精简              2.0s        14.1 tok/s    8请求
投机采样                1.8s        35.2 tok/s    8请求
请求预处理              0.4s        40.1 tok/s    8请求

最终效果:TTFT从3.2秒降到0.4秒,生成速度从5.2 tokens/s提升到40.1 tokens/s,提升近8倍。一个中等长度的PR diff(3000字符)从原来的110秒降到15秒。

监控告警

上线后配置了性能监控:

ALERT_RULES = {
    "ttft_p95": {"threshold": 2.0, "severity": "warning"},
    "ttft_p99": {"threshold": 5.0, "severity": "critical"},
    "generation_speed": {"threshold": 20, "severity": "warning", "direction": "below"},
    "kv_cache_usage": {"threshold": 0.95, "severity": "warning"},
    "queue_depth": {"threshold": 16, "severity": "critical"},
}

写在最后

LLM性能优化是一个系统工程,不是改一个参数就能解决的。从量化、批处理配置、prompt精简、投机采样到请求预处理,每一步都有明确的效果。

关键心得:先定位瓶颈再动手优化。GPU利用率低就调批处理配置,显存不够就量化,TTFT高就精简prompt,生成速度慢就上投机采样。盲目改参数只会浪费时间。