推理實踐:KV Cache優(yōu)化與參數調優(yōu)指南)
vLLM這幾年的熱度但凡做過大模型推理落地的人都深有體會。所謂高吞吐推理引擎核心目標就是在有限的GPU顯存里把本地高并發(fā)推理服務的單位時間請求處理量做到最大化。我最初被vLLM吸引就是因為一個很現(xiàn)實的問題同樣的模型用普通框架部署時并發(fā)一上來就顯存爆炸、請求排隊換成vLLM之后吞吐直接翻了幾倍而GPU利用率終于跑到了該有的水平。這篇文章我打算圍繞vLLM在本地高并發(fā)推理服務中的落地實踐把核心原理、部署方式、參數調優(yōu)和踩坑記錄一次性講清楚。不管你是在做內部大模型API還是在折騰個人推理服務只要被并發(fā)和顯存卡過這篇內容應該能直接幫你省掉不少彎路。1. 項目核心vLLM到底解決了什么問題1.1 傳統(tǒng)推理框架的瓶頸出在哪先聊聊我最早用原生transformers代碼部署推理時的痛苦。模型加載沒問題單條請求推理速度也能接受但只要并發(fā)請求一多整套服務就像得了腸胃炎——要么顯存溢出直接OOM要么請求全部卡在隊列里慢慢等。問題本質不在計算而在KV Cache。Transformer模型在生成每個token時都要讀取歷史token的Key和Value向量這些向量存在顯存里就是KV Cache。傳統(tǒng)推理框架為了簡單通常按請求的最大序列長度一次性把顯存預留出來。比如一個模型最大支持4096 token的上下文那每個請求的KV Cache就按4096個token的體量去占顯存哪怕這個請求實際只生成了幾十個token預留的空間也不會釋放。我粗略算過一筆賬。一個70億參數模型假設40層、隱層維度4096FP16精度下單個token的KV Cache大約占 40 × 4096 × 2 × 2 字節(jié)也就是600多KB。如果按滿上下文4096 token預留單請求就要吃掉256MB顯存。32路并發(fā)同時進來光KV Cache就是8GB。顯存被白白浪費并發(fā)能力自然上不去。除此之外傳統(tǒng)框架的批處理方式也很粗糙。多個請求湊成一個batch后必須等batch里最慢的那個序列生成完整個batch才能釋放中途GPU會出現(xiàn)大量空閑氣泡。在長文本生成場景里這種浪費尤其明顯。1.2 vLLM的三個關鍵設計vLLM針對上面兩個痛點做了三件核心事PagedAttention、Continuous Batching和顯存感知調度。第一PagedAttention把KV Cache分頁管理。不再為每個請求預占整塊連續(xù)顯存而是像操作系統(tǒng)管理內存一樣按固定大小的塊分配用到多少分配多少。顯存碎片的利用率大幅提高同樣的顯存能容納更多并發(fā)請求。第二Continuous Batching實現(xiàn)了迭代級調度。傳統(tǒng)方案是請求級調度一個batch落地后直到所有序列結束才換下一批vLLM則在每次decode迭代之后完成的請求立即退出新進來的請求馬上補位GPU永遠在滿負荷工作。第三調度器會做顯存感知。每個請求進來時vLLM會估算它需要的KV Cache塊數量顯存足夠就接收不夠就掛起等待而不是等到解碼中途才發(fā)現(xiàn)顯存不夠導致崩潰。這套設計讓服務在高并發(fā)下具備可預期的兜底行為。1.3 適合什么場景不適合什么場景直接說結論。vLLM最擅長的是高并發(fā)API服務、多輪對話、長上下文推理以及離線批量跑分這類場景特征是并發(fā)高、token吞吐量大、對單請求延遲要求不是極端苛刻。但如果你的場景是單請求超低延遲比如語音助手那種要求幾十毫秒內響應的實時交互vLLM未必比專門優(yōu)化過的低延遲方案更合適。另外如果你需要對解碼過程做大量自定義算子改動或者要接入某些特殊的采樣邏輯vLLM的調度框架反而會帶來限制。選型時先看業(yè)務形態(tài)別盲目追新。2. 核心原理PagedAttention與連續(xù)批處理2.1 PagedAttention給KV Cache做“虛擬內存”PagedAttention是vLLM最核心的設計理解它就理解了vLLM的一大半。傳統(tǒng)顯存管理可以類比成訂酒店為了讓客戶住得舒服直接包下一整層樓不管客戶實際住幾間。請求數量一多樓層不夠用后面的客戶只能在樓下排隊。PagedAttention的做法變成了按房間按天訂客戶住幾天就訂幾間房間分布在樓層不同位置也沒關系只需要記錄房間號就行。具體到實現(xiàn)vLLM把KV Cache劃分成固定大小的邏輯塊默認每個塊可以存16個token的KV向量。每個請求有一個邏輯塊表記錄它當前占用哪些物理塊。當請求生成了新的token就往表里追加對應的物理塊如果物理塊不夠了調度器會先分配新的塊分配不到就暫停請求。這里有個關鍵收益因為塊是等大小的顯存中幾乎不存在碎片問題。一塊內存不管屬于哪個請求只要沒被用上就能立刻分配給其他請求。傳統(tǒng)方案中預留后不可復用的情況徹底消失了。2.2 Continuous Batching迭代級調度讓GPU一直“滿座”再講Continuous Batching。vLLM對批次的處理粒度非常細調度發(fā)生在每一次decode迭代之后。比如當前batch里有8個請求在生成其中3個已經生成到結束標記它們立刻退出騰出的顯存塊馬上可以被新進入的請求使用。這和傳統(tǒng)batching的差別非常大。傳統(tǒng)batching是短板效應batch里有一個請求要生成500個token其他幾個只要50個GPU也得陪著跑完500步。vLLM則讓短請求提前下車新請求隨時上車GPU在每一輪迭代都處于相對滿負荷的狀態(tài)。我自己的體會是這個機制對混合負載尤其有效。實際業(yè)務里請求長度參差不齊有的問一句就結束有的要生成一大段代碼。Continuous Batching能把長短請求混在一起調度互不拖累GPU平均利用率能上一個臺階。2.3 為什么整體提升能到幾十倍很多人看到公開benchmark里vLLM吞吐提升5倍、10倍甚至20倍的數字覺得夸張。其實這些數字不是玄學而是幾個因素疊加的結果。第一顯存利用率提高直接轉化為更大的并發(fā)批大小。以前只能并發(fā)4路請求現(xiàn)在能并發(fā)16路吞吐自然漲。第二decode階段的batch更大GPU矩陣運算的并行效率更高。第三PagedAttention對prefill階段也有優(yōu)化超長prompt可以按塊切分處理首token時間有所改善。我實測過的效果是在顯存受限的高并發(fā)場景提升幅度最明顯三四倍是保底如果你的請求普遍很長、上下文很大那收益會更夸張接近公開數據里的上限。反過來短請求、低并發(fā)、算力本來就閑的場景提升幅度沒那么大。所以判斷收益前先看清自己的負載特征。3. 本地部署實操從安裝到啟動服務3.1 環(huán)境準備與安裝部署vLLM之前先確認三件事GPU型號和顯存、GPU驅動版本、Python環(huán)境。我的建議是至少8GB顯存起步否則很多主流開源模型放進去之后剩下的顯存連KV Cache都擠不出多少并發(fā)根本沒戲。Python版本建議3.9到3.12之間。CUDA方面vLLM打包的預編譯版本通常對應CUDA 11.8或12.1你本地的GPU驅動需要支持對應的CUDA版本。安裝時最省心的方式就是用官方軟件倉庫直接裝pip install vllm裝完驗證一下版本python -c import vllm; print(vllm.__version__)如果網絡受限配置國內鏡像源pip install vllm -i https://pypi.tuna.tsinghua.edu.cn/simple這里有個坑如果你的機器上之前裝過舊版vLLM或者一堆深度學習的依賴包建議先建一個干凈的虛擬環(huán)境再裝。vLLM對torch、transformers的版本要求比較嚴格混裝容易出現(xiàn)各種莫名其妙的沖突。我在內網機器上踩過不少次最后統(tǒng)一用虛擬環(huán)境隔離才穩(wěn)定下來。3.2 啟動服務核心參數逐個講vLLM裝好之后啟動推理服務的方式很簡潔。官方入口是vllm serve命令直接指定模型路徑或模型名它就會拉起一個OpenAI兼容的HTTP服務。我常用的一條啟動命令是vllm serve Qwen/Qwen2.5-7B-Instruct \ --host 0.0.0.0 \ --port 8000 \ --gpu-memory-utilization 0.92 \ --max-model-len 8192 \ --max-num-seqs 128 \ --tensor-parallel-size 1 \ --enable-prefix-caching這些參數每一個都影響并發(fā)能力挨個說清楚--gpu-memory-utilization控制KV Cache最多占用多少比例的GPU顯存。我通常設0.85到0.95之間不建議拉滿到0.99因為CUDA context、計算圖、激活值也要占顯存拉滿容易在運行時碰壁。--max-model-len是支持的最大上下文長度包括輸入加輸出。它會影響KV Cache塊的分配策略和prefill的切分方式。不是越大越好得按業(yè)務實際需求來盲目設32K會讓單請求占用顯存大幅上升壓縮并發(fā)空間。--max-num-seqs是單個批次最多容納的序列數直接決定并發(fā)上限。設太小請求來了只能排隊設太大batch過大后每個序列的解碼速度會下降。初始建議64或128后續(xù)通過壓測再微調。--tensor-parallel-size是多卡并行參數。當單卡放不下模型時設為2或4vLLM會通過Tensor Parallel把模型切到多張卡上。--enable-prefix-caching開啟前綴緩存多個請求共用相同prompt前綴時可以復用KV Cache多輪對話場景收益很大。3.3 調用接口與本機驗證服務啟動后先驗證是否健康curl http://127.0.0.1:8000/v1/models這個接口會返回當前加載的模型信息能通就說明服務起來了。vLLM提供了OpenAI兼容的接口格式意味著業(yè)務側可以用標準的SDK直接調用幾乎零成本切換。之前我在內部項目里接多個推理框架業(yè)務代碼完全不用改只換了base_url就把vLLM接入進去了。調用示例from openai import OpenAI client OpenAI( base_urlhttp://127.0.0.1:8000/v1, api_keylocal, ) resp client.chat.completions.create( modelQwen/Qwen2.5-7B-Instruct, messages[{role: user, content: 用一句話解釋什么是KV Cache}], max_tokens256, temperature0.7, ) print(resp.choices[0].message.content)流式輸出也是一樣的用法只需要加streamTrue。vLLM對流式場景的優(yōu)化也做得不錯迭代級的Continuous Batching在流式輸出下更能體現(xiàn)吞吐優(yōu)勢。4. 高并發(fā)實戰(zhàn)吞吐量分析與參數調優(yōu)4.1 壓測腳本與關鍵指標啟動服務之后不能憑感覺判斷性能一定要做壓測。我用過最簡單的并發(fā)壓測腳本基于httpx的異步客戶端可以模擬不同并發(fā)度下的壓力。核心代碼如下import asyncio import time import statistics import httpx URL http://127.0.0.1:8000/v1/chat/completions MODEL Qwen/Qwen2.5-7B-Instruct async def send_one(client, payload, semaphore): async with semaphore: start time.time() resp await client.post(URL, jsonpayload) resp.raise_for_status() data resp.json() ttft data.get(timings, {}).get(first_token_time, None) return time.time() - start, ttft async def run_load(concurrency, total_requests, payload, semaphore): async with httpx.AsyncClient(timeout300) as client: tasks [] for _ in range(total_requests): tasks.append(send_one(client, payload, semaphore)) results await asyncio.gather(*tasks) return results if __name__ __main__: payload { model: MODEL, messages: [{role: user, content: 寫一個200字的自我介紹}], max_tokens: 256, temperature: 0.7, } for concurrency in [8, 16, 32, 64, 128]: semaphore asyncio.Semaphore(concurrency) results asyncio.run(run_load(concurrency, 200, payload, semaphore)) latencies [r[0] for r in results] tps len(results) / (sum(latencies) / concurrency) avg_latency statistics.mean(latencies) print(fconcurrency{concurrency}, TPS{tps:.2f}, avg_latency{avg_latency:.2f}s)這里的TPS計算方式比較簡單準確的壓測應該統(tǒng)計從開始到所有請求完成的完整窗口而不是用平均延遲除以并發(fā)數。建議在實際壓測時用固定時間窗口來統(tǒng)計完成的請求數。需要重點關注的指標有三個TTFT也就是首Token時間。它反映排隊和prefill的開銷多輪對話場景下這個指標尤其重要。如果TTFT飆升說明請求在調度器里排隊太久了。TPOT也就是每個Token的生成耗時反映decode階段的batch負載。如果TPOT從20ms漲到100ms說明一個batch里塞了太多序列每個序列分到的算力被稀釋了。TPS和整體吞吐單位時間完成的請求數或生成的token總數。這兩個指標是最終的性能標尺所有調優(yōu)最后都要落到它們上面。4.2 調優(yōu)經驗從吞吐曲線找最佳值我之前在單張80GB顯存的顯卡上測試過一套70億參數的模型把并發(fā)數從8一路加到256TPS的變化趨勢讓我印象很深。在低并發(fā)區(qū)間TPS基本線性增長到達某個拐點后增速放緩繼續(xù)加壓TPS不再增加反而平均延遲明顯惡化。這個拐點附近就是這臺機器的合理并發(fā)上限。針對不同負載特征調優(yōu)優(yōu)先級不一樣。短輸出場景比如分類、抽取、關鍵詞生成輸出token數少瓶頸往往是prefill。優(yōu)先調大--max-num-seqs把更多請求塞進一個有prefill能力的batch同時開啟--enable-prefix-caching。這類場景里--max-model-len不用設太大反而應該壓到能覆蓋業(yè)務輸入上限即可省下的顯存全給KV Cache。長輸出場景比如生成文章、代碼、報告decode是主要耗時每個序列長期占著顯存塊。這時優(yōu)先保證單序列的decode速度--max-num-seqs不宜無限放大否則TPOT惡化用戶體感會明顯變差。最佳做法是壓測不同并發(fā)下的TPOT曲線找到一個“TPS還在漲、TPOT還能接受”的平衡點。--gpu-memory-utilization的調優(yōu)也講究策略。我一般先設0.90跑一輪壓測確認不OOM后再適當往0.95試。如果業(yè)務高峰期請求排隊嚴重而是KV Cache不夠導致的可以調大這個值。前提是留出足夠余量給其他顯存開銷我見過有人設0.99然后運行中崩潰的。4.3 多卡并行與量化方案單卡放不下模型或者想進一步擴大并發(fā)時兩個方向可以參考。多卡方面--tensor-parallel-size 2或4vLLM會把模型權重和計算切到多張卡上。注意顯存帶寬成為新的瓶頸跨卡通信有開銷不是卡越多越快4卡以上的收益增長會明顯放緩。有條件的話建議優(yōu)先用NVLink互聯(lián)的卡效果更好。量化方面對這個場景幫助很大。70億參數的模型FP16權重要14GB顯存用AWQ或GPTQ量化為4bit后只剩約4GB剩下的顯存幾乎全給KV Cache并發(fā)能力可以翻不少。量化方式不影響OpenAI接口模型推理照常走。我自己的實測是4bit量化在對話類任務上質量損耗不大但吞吐收益很實在。量化模型啟動方式和普通模型一致vllm serve ./models/coder-7b-awq \ --quantization awq \ --gpu-memory-utilization 0.92 \ --max-num-seqs 128有一點要留意量化模型如果本身是手工轉化或社區(qū)版本精度差異可能不小上線前一定要跑一批評測集做對比不能只看指標好看就切過去。5. 常見問題與排查實錄5.1 顯存不足與KV Cache耗盡最典型的錯誤是啟動或運行時報 “No available memory for the cache blocks”。這表示KV Cache的可用塊已經被占滿了。遇到這個問題排查順序我一般是這樣第一把--max-num-seqs調小觀察是否恢復。第二把--gpu-memory-utilization從0.95降到0.85附近給其他開銷留出空間。第三檢查--max-model-len是否設得過大如果業(yè)務并不需要32K上下文壓到8K或4K能釋放大量KV Cache。最后一個隱藏點如果你開啟了多卡Tensor Parallel每張卡上還要額外留一部分顯存給通信緩沖這時顯存利用率更不該拉滿。5.2 首Token時間高企與請求排隊如果壓測時TPS不高但TTFT持續(xù)走高大概率是請求在調度器里排隊而不是生成太慢。通過vLLM日志里的running和waiting數量可以確認如果waiting一直很高說明--max-num-seqs限制了并發(fā)窗口。先說結論優(yōu)先調大--max-num-seqs。如果已經是256還排隊那就是算力或顯存真的飽和了這時候可以開--enable-prefix-caching減少重復prefill或者上多卡和量化。記住一個判斷技巧TPS上不去但TTFT高是排隊問題TPS在漲但TPOT明顯變差是batch內過度競爭兩個問題的解決方向完全不同。5.3 依賴與版本兼容問題vLLM對底層依賴的要求出了名的嚴格。我遇到過幾次很典型的問題。一個是啟動時提示CUDA不可用報錯類似“Torch is not compiled with CUDA enabled”。這通常是因為環(huán)境里裝了一個CPU版或CUDA版本不匹配的torch。解決辦法很直接把當前環(huán)境清理干凈重新安裝一份與vLLM匹配的torch和CUDA版本。一個是FlashAttention相關的報錯。vLLM在許多GPU上依賴FlashAttention加速而FlashAttention對GPU架構和CUDA版本非常敏感。如果啟動時崩潰在FlashAttention初始化優(yōu)先考慮升級GPU驅動、更換CUDA版本或者安裝vLLM預編譯版本。不建議自己編譯編譯時間很長而且不同硬件平臺差異很大。還有一個通用建議不要輕易把vLLM和相關依賴一起批量升級。vLLM的小版本更新經常附帶底層算子變更升級前仔細讀更新說明并且在測試環(huán)境跑一遍回歸再動生產。5.4 問題速查表問題現(xiàn)象可能原因解決辦法啟動報 No available memory for cache blocksKV Cache占用已滿降低 max-num-seqs、降低 gpu-memory-utilization、縮小 max-model-lenTTFT高但TPS不高請求在排隊調大 max-num-seqs、開啟 prefix-cachingTPOT明顯變差batch內序列數過多適當減小 max-num-seqs平衡吞吐與單請求速度報 Torch CUDA 相關錯誤torch 與 CUDA 版本不匹配重建虛擬環(huán)境重裝匹配版本的 torch 和 vLLMFlashAttention 初始化崩潰GPU架構或 CUDA 版本不兼容升級驅動、更換 CUDA 版本或用預編譯安裝包多卡啟動報 NCCL 錯誤多卡通信異常檢查顯卡互聯(lián)方式、驅動版本降低 tensor-parallel-size 測試量化模型輸出質量下降量化精度損失或校驗集不足換用不同量化位寬先跑評測對比再上線寫在最后的一點經驗整個項目折騰下來我最深的體會是vLLM的收益上限取決于你的負載特征而不是參數本身。抄網上的配置沒有任何意義必須自己壓測、畫曲線、找拐點。啟動服務一句vllm serve就能跑起來但真正把它調成一臺穩(wěn)定扛住高并發(fā)推理的服務靠的是對顯存、調度和業(yè)務特征的綜合理解。個人項目建議從單卡加70億參數模型起步max-num-seqs從64開始壓測再逐步往上加。后續(xù)如果要擴展可以把vLLM接入統(tǒng)一的模型網關做多模型路由、自動擴縮容那時候你會發(fā)現(xiàn)高并發(fā)推理這件事真正的大頭全在調度和資源管理上。