用與結(jié)構(gòu)化輸出優(yōu)化)
從去年開始我陸續(xù)部署過好幾個LLM推理服務(wù)vLLM、TGI都用過一段。說實話模型越來越大業(yè)務(wù)方的要求也越來越刁既要一輪對話里多個分支并行采樣又要輸出嚴格合法的JSON還嫌首字延遲太高。有一個場景讓我印象特別深——同一段很長的系統(tǒng)提示詞配上不同用戶輸入在線服務(wù)同時涌進來幾十路請求顯存直接翻倍GPU利用率卻上不去。換哪個框架都差不多瓶頸不在算力而在重復(fù)計算。后來我認真梳理了一遍SGLang的架構(gòu)設(shè)計發(fā)現(xiàn)這個框架的解題思路和vLLM完全不同。它沒有把所有精力都花在顯存page管理上而是從頭到尾在處理一個問題讓推理過程里所有能復(fù)用的計算盡可能復(fù)用。SGLang全稱是Structured Generation Language for Large Language Models核心由前端語言層、運行時SRTSGLang Runtime和RadixAttention組成。這篇文章我想從架構(gòu)設(shè)計的視角把它為什么這樣做、每個核心組件怎么實現(xiàn)的、部署時哪些參數(shù)真正影響性能掰開揉碎講清楚。適合正在做推理服務(wù)選型、或者想優(yōu)化現(xiàn)有LLM服務(wù)吞吐的工程師參考。1. 從線上事故說起LLM推理服務(wù)的高延遲到底卡在哪先描述一個我真實遇到的場景。我們當(dāng)時做一個文檔問答助手系統(tǒng)提示詞有將近兩千個token用戶會在同一個文檔下連續(xù)追問。上線之前壓測結(jié)果還不錯一旦切到多輪對話加并發(fā)請求平均首字延遲從300毫秒飆升到2秒甚至出現(xiàn)請求排隊超時。1.1 前綴重復(fù)計算的算力浪費問題出在一個容易被忽略的環(huán)節(jié)——前綴重復(fù)計算。多輪對話里每輪請求都會把歷史對話拼接在用戶輸入前面。傳統(tǒng)推理服務(wù)把每條請求當(dāng)作獨立任務(wù)同一個文檔前綴、同樣的系統(tǒng)提示詞在每一輪、每一個并發(fā)請求里都要重新走一遍attention計算。我用一個簡單公式說明成本。假設(shè)請求總長L其中共享前綴長度為P則每條請求實際重復(fù)計算的前綴占比約P/L。在線服務(wù)的典型場景里P/L經(jīng)常超過70%。也就是說GPU每做10次FLOP7次在算一模一樣的東西。即使vLLM用PagedAttention把KV Cache的顯存利用率做得很高它也沒有從語義層面識別“前綴相同”這件事。1.2 結(jié)構(gòu)化輸出的落地困境另一個痛點是結(jié)構(gòu)化輸出。業(yè)務(wù)方要求返回JSON格式的結(jié)果之前我們只能在prompt里寫“請嚴格按JSON格式返回”然后在后處理環(huán)節(jié)用正則或者json.loads去解析。效果大家應(yīng)該都體會過模型心情好就規(guī)規(guī)矩矩給你JSON心情不好多輸出一句解釋整條結(jié)果就報廢了還得讓用戶再問一次。這本質(zhì)上不是模型能力問題而是推理框架沒有把“格式約束”滲透進解碼過程。每個token的采樣都是一個獨立的概率分布框架如果不干預(yù)模型自然傾向于自由文本。SGLang在架構(gòu)層面設(shè)計了約束解碼機制等于在每一步token生成前先根據(jù)JSON Schema或正則表達式算出一個合法token集合再從這個集合里采樣。這樣生成出來的結(jié)果格式一定是合法的只要prompt足夠清晰內(nèi)容質(zhì)量也不會因為格式約束而明顯下降。1.3 多次并行采樣的倍增效應(yīng)還有一個被低估的場景是并行采樣。做RLHF推理、或者用LLM做候選生成時同一個prompt往往要同時生成4到8個不同的回答。傳統(tǒng)框架里這8條請求互不感知前面那段共享的prompt被重復(fù)計算8次。SGLang把這類請求看成同一個樹狀結(jié)構(gòu)的多個分支共享路徑只需要計算一次分叉之后才真正并行。這種設(shè)計思路帶來的收益在多輪對話與并行采樣結(jié)合的場景里尤其明顯。2. 整體架構(gòu)分層前端語言、運行時與調(diào)度邏輯是如何協(xié)作的SGLang的架構(gòu)設(shè)計有一個很鮮明的特點前端和后端職責(zé)分離得非常清楚。前端是一個Python定義的領(lǐng)域特定語言DSL負責(zé)描述“生成流程”后端是一個高性能的C運行時負責(zé)把這些流程編譯成高效的token級調(diào)度策略。2.1 前端語言層用Python描述生成流程SGLang的前端允許你把一次推理過程定義成一個帶流程的function。比如我想實現(xiàn)“先讓模型判斷意圖再根據(jù)意圖生成回復(fù)”import sglang as sgl sgl.function def chat_pipeline(s, question): s sgl.user(請判斷以下問題的意圖回復(fù)“天氣”或“閑聊”\n question) s sgl.assistant(sgl.gen(intent, max_tokens16)) if s[intent].strip() 天氣: s sgl.user(請用一句話回答天氣問題 question) else: s sgl.user(請用輕松的語氣回應(yīng) question) s sgl.assistant(sgl.gen(answer, temperature0.7))這段代碼看起來是普通Python但實際執(zhí)行時SGLang會把整個流程編譯成一張token生成圖。if判斷是在拿到第一個gen結(jié)果的token之后才執(zhí)行的分支控制。這個能力很關(guān)鍵它意味著業(yè)務(wù)邏輯可以進入生成流程內(nèi)部而不是在框架外部做多次串行請求。2.2 Radical Attention緩存層請求與緩存樹的交互中樞前端負責(zé)生成邏輯真正執(zhí)行推理的是SRT運行時。運行時里最核心的模塊是RadixAttention緩存層它維護一棵全局的基數(shù)樹。每條請求進入系統(tǒng)后會把輸入token序列在樹上做一次最長前綴匹配。命中的路徑KV Cache直接復(fù)用未命中的路徑才需要重新計算。這個設(shè)計消除了前綴重復(fù)計算的浪費也是SGLang在架構(gòu)層面區(qū)別于vLLM最本質(zhì)的一點。2.3 調(diào)度器按緩存命中率動態(tài)排序請求光有緩存還不夠調(diào)度器必須知道怎么用緩存。SGLang的調(diào)度器采用cache-aware策略——不是簡單地按到達時間排隊而是優(yōu)先調(diào)度那些能命中更多緩存的請求。有相同前綴的請求會被聚合到同一個批次里共享一次前綴前向計算。這種調(diào)度策略在并發(fā)多分支采樣場景下能顯著降低GPU的空轉(zhuǎn)時間。3. RadixAttention把“重復(fù)計算”變成“緩存命中”的前綴復(fù)用機制聊完了整體架構(gòu)接下來該深入SGLang的核心組件——RadixAttention。這個機制是SGLang高性能的基礎(chǔ)我可以把它的工作原理說得更細一些。3.1 為什么是基數(shù)樹而不是哈希表RadixAttention底層用的數(shù)據(jù)結(jié)構(gòu)是基數(shù)樹Radix Tree。學(xué)過數(shù)據(jù)結(jié)構(gòu)的同學(xué)應(yīng)該記得基數(shù)樹是一種壓縮前綴樹。它和普通前綴樹最大的區(qū)別在于如果有多個孩子節(jié)點有公共前綴這個公共前綴會被合并到父節(jié)點中。放到KV Cache場景理解假設(shè)有三條請求共享同一個很長的系統(tǒng)提示詞提示詞內(nèi)部可能有幾個邊界不相同的分叉點。基數(shù)樹會把整個提示詞作為一條根路徑保存每條請求只需要記住自己從哪個節(jié)點開始分叉即可。如果用哈希表保存前綴的KV Cache本質(zhì)上只能精確匹配整段前綴無法處理“A請求和B請求共享前80%的token后20%不同”這種部分匹配的情況?;鶖?shù)樹天然支持任意粒度的前綴復(fù)用。# 偽代碼示意在Radix Tree上查找最長匹配前綴 def match_prefix(req_tokens, tree): node tree.root matched 0 while matched len(req_tokens): child node.find_child_starting_with(req_tokens[matched]) if child is None: break common child.longest_common_prefix(req_tokens[matched:]) if common 0: break matched common node child return node, matched這條查找路徑的時間復(fù)雜度近似O(P)P為匹配到的前綴長度。對LLM推理來說每個token的匹配只需要做一次查表開銷可以忽略不計。3.2 緩存替換策略與生命周期管理緩存節(jié)點不能無限增長。RadixAttention采用類似LRU的思路管理樹的規(guī)模每個節(jié)點會記錄引用計數(shù)。當(dāng)一條請求結(jié)束時從該請求的最后一個節(jié)點開始沿路徑回溯引用計數(shù)減一。只有引用計數(shù)歸零的節(jié)點才會被釋放。這個機制的妙處在于它把緩存的生命周期和實際請求的引用關(guān)系綁定在一起。如果某個系統(tǒng)提示詞被100個并發(fā)請求共享它路徑上節(jié)點的引用計數(shù)始終保持在高位那么即使整棵樹內(nèi)存緊張LRU也不會輕易淘汰它。相比之下普通LRU緩存只記錄訪問時間在高并發(fā)共享前綴場景下容易誤傷熱點數(shù)據(jù)。3.3 實際收益多輪對話場景的顯存與延遲對比我們在一個內(nèi)部測試場景里驗證過收益。用Llama-3.1-8B模型固定一個1000 token的系統(tǒng)提示詞模擬20個用戶并行發(fā)起多輪對話請求。同一批次下SGLang在首字延遲上比未開啟前綴緩存的基線降低約62%等效吞吐量提升約2.3倍。顯存方面由于前綴KV Cache被復(fù)用模型并發(fā)數(shù)可以開得更大整體顯存占用曲線明顯更平緩。4. 結(jié)構(gòu)化輸出引擎讓大模型生成“一定能被解析”的結(jié)果前面提到結(jié)構(gòu)化輸出這是SGLang另一個核心組件。很多開發(fā)者以為結(jié)構(gòu)化輸出只是在prompt里多加幾個約束詞其實真正的實現(xiàn)遠比這復(fù)雜。4.1 約束解碼的實現(xiàn)原理SGLang在做結(jié)構(gòu)化生成時會在解碼階段介入token選擇過程。給定一個JSON Schema或正則表達式SGLang會將其編譯成一個有限狀態(tài)機FSM。在生成每一步token之前系統(tǒng)根據(jù)當(dāng)前已生成的token序列查詢FSM得到下一個位置允許出現(xiàn)的合法字符集合再根據(jù)這個集合構(gòu)造一個mask把合法的token id篩選出來。# 偽代碼基于FSM的受限解碼 fsm compile_schema(json_schema) next_allowable_tokens fsm.get_allowable_tokens(prefix_tokens) logits model.forward(prefix_tokens) masked_logits logits.masked_fill(~next_allowable_tokens, float(-inf)) next_token sample(masked_logits, temperature0.2)這種方式的優(yōu)勢是硬約束。模型輸出的每一步都被限制在合法集合內(nèi)最終結(jié)果的JSON解析成功率接近100%徹底告別了“生成完再解析失敗重試”的循環(huán)。4.2 與JSON Mode、Outlines等方案的對比很多框架都提供JSON Mode功能但實現(xiàn)層級不太一樣。OpenAI的JSON Mode本質(zhì)是system prompt層面的軟引導(dǎo)模型可能偶爾輸出不合法JSON。部分第三方庫用constrained decoding實現(xiàn)但只關(guān)注輸出末尾的格式校驗。SGLang的結(jié)構(gòu)化輸出引擎和xgrammar這種庫的思路更接近把約束直接編譯進解碼過程在推理層強行保證合法性。如果你已經(jīng)用了SGLang那么結(jié)構(gòu)化輸出可以直接作為內(nèi)置能力啟用不需要額外接Outlines或Jsonformer。我們在實際項目中用這個特性替換掉了之前prompt軟引導(dǎo)加后處理校驗的方案解析失敗率從大約8%降到了0.1%以下。那0.1%還是因為模型提前生成了EOS token導(dǎo)致空結(jié)果而不是格式錯誤。4.3 結(jié)構(gòu)化輸出與采樣參數(shù)的配合這里有個實際操作層面的細節(jié)我踩過坑。結(jié)構(gòu)化輸出的約束越強解碼空間越小生成內(nèi)容越容易陷入重復(fù)。所以使用結(jié)構(gòu)化輸出時不建議把temperature設(shè)得太高或太低。我們測試下來temperature在0.2到0.5之間比較合適。設(shè)成0容易退化成模式化文本設(shè)成大于0.8則可能在合法集合內(nèi)做無意義抖動生成一些語義偏離的內(nèi)容。5. SGLang與vLLM的正面硬剛吞吐量、延遲、功能側(cè)重點的差異做推理框架選型繞不開的問題是SGLang和vLLM到底怎么選。這兩個框架都是當(dāng)前社區(qū)最活躍的LLM推理方案但設(shè)計哲學(xué)差別很大。5.1 吞吐量與延遲的真實差異vLLM的核心創(chuàng)新是PagedAttention它把KV Cache分割成固定大小的page像操作系統(tǒng)虛擬內(nèi)存一樣按頁分配解決的是顯存碎片化問題。這帶來一個直接好處顯存利用率更高可以塞下更大的batch從而提升整體吞吐量。SGLang的RadixAttention解決的是另一個問題——計算復(fù)用。它關(guān)注的是“同一個前綴被多條請求反復(fù)計算”的浪費。兩個框架的性能表現(xiàn)因此呈現(xiàn)不同趨勢如果請求之間幾乎沒有任何公共前綴比如純隨機短問題打流vLLM和SGLang的吞吐差距不大甚至vLLM可能略微領(lǐng)先。但在多輪對話、few-shot場景、或者并行采樣這些連續(xù)請求共享大量公共前綴的任務(wù)里SGLang的優(yōu)勢會被放大。我們實測一個20輪長對話的場景SGLang吞吐量比vLLM提高約41%首字延遲降低約22%。維度SGLangvLLM顯存管理RadixAttention基數(shù)樹緩存PagedAttention按頁管理核心優(yōu)化目標減少前綴重復(fù)計算提高顯存利用率最佳適用場景多輪對話、并行采樣、共享長前綴高并發(fā)短請求、大batch吞吐結(jié)構(gòu)化輸出內(nèi)置約束解碼依賴外部庫或prompt軟約束多模態(tài)支持支持LLaVA等支持部分多模態(tài)模型社區(qū)熱度增長快學(xué)術(shù)圈用得多生態(tài)成熟生產(chǎn)部署案例多5.2 工程成熟度的取舍vLLM畢竟發(fā)展時間長它的部署生態(tài)更成熟和Kubernetes、監(jiān)控系統(tǒng)、模型倉庫的集成案例更豐富。SGLang雖然性能亮眼但版本迭代速度極快API變化也比較頻繁。我剛開始接觸SGLang的時候啟動服務(wù)用的是python -m sglang.launch_server后來版本升級入口變成了sglang.launch_server模塊參數(shù)也有一些調(diào)整。如果你的團隊沒有專門的推理平臺工程師選擇SGLang之前要做好持續(xù)跟進版本更新的心理準備。5.3 我個人的選型建議我的建議分三種情況如果業(yè)務(wù)以短請求高并發(fā)為主請求之間沒有明顯公共前綴vLLM是穩(wěn)妥選擇如果業(yè)務(wù)有大量多輪對話、Agent規(guī)劃或者并行采樣任務(wù)SGLang的前綴復(fù)用能力會帶給你實打?qū)嵉男阅芴嵘绻麅烧叨家梢钥紤]按路由區(qū)分——把多輪問答流量導(dǎo)入SGLang服務(wù)短請求走vLLM服務(wù)。6. 從零拉起SGLang服務(wù)鏡像部署、啟動命令與顯存調(diào)優(yōu)紀要理論聊得差不多了接下來是實戰(zhàn)環(huán)節(jié)。這部分我直接給可復(fù)用的部署流程和參數(shù)調(diào)整經(jīng)驗。6.1 環(huán)境準備與鏡像部署SGLang的依賴比較重強烈建議直接用官方鏡像不要自己手動編譯。官方鏡像一般發(fā)布在lmsysorg/sglang。執(zhí)行前先確認硬件驅(qū)動支持CUDA 12.x鏡像內(nèi)自帶的CUDA版本要和你機器的驅(qū)動版本兼容。docker run -it --gpus all \ --shm-size 32g \ -p 30000:30000 \ -v /data/models:/models \ lmsysorg/sglang:latest \ python3 -m sglang.launch_server \ --model-path /models/Qwen2.5-7B-Instruct \ --port 30000鏡像里默認工作目錄為/sglang-workspace代碼可以直接掛載進去方便調(diào)試。不要忘了加--shm-size參數(shù)。如果不加Docker默認共享內(nèi)存只有64MB數(shù)據(jù)處理稍微大一點就會出現(xiàn)共享內(nèi)存不足的錯誤這個坑我踩過的次數(shù)已經(jīng)記不清了。如果不想用Docker也可以用pip安裝。需要注意SGLang的發(fā)布節(jié)奏偏激進穩(wěn)定版本和最新特性之間差距較大官方推薦的安裝命令里往往帶有預(yù)發(fā)布標記uv pip install --prereleaseallow sglang使用uv而不是pip的原因很簡單——SGLang依賴很多C擴展解析依賴時uv比pip快得多失敗率也更低。裝完之后可以用sglang.check_env檢查環(huán)境是否完整。6.2 啟動參數(shù)與顯存分配調(diào)整啟動服務(wù)時影響最大的幾個參數(shù)--mem-fraction-static控制靜態(tài)顯存占用比例默認0.9。這個參數(shù)設(shè)得太高容易OOM設(shè)得太低會頻繁做KV Cache的CPU offload導(dǎo)致延遲暴漲。--max-total-token限定KV Cache token總量防止顯存超賣。--schedule-conservativeness調(diào)度保守性默認是0.0。數(shù)值越高調(diào)度越保守在高并發(fā)場景下可以避免CPU和GPU負載抖動但會增加排隊延遲。--cuda-graph-max-batch-size控制CUDA Graph的最大batch。數(shù)值越大小請求的圖編譯開銷越低但顯存占用也會增加。一般建議設(shè)為系統(tǒng)最大并發(fā)請求數(shù)的70%到90%。6.3 多卡部署方案顯存不夠跑大模型時SGLang支持tensor parallel、data parallel和pipeline parallel。我們線上用兩張A100跑Qwen2.5-72B用的命令是加--tp 2。數(shù)據(jù)并行--dp適合多個請求完全獨立、不需要共享前綴的流量它把不同請求分發(fā)到不同GPU上互不干擾。調(diào)度器還支持--dp-size與--tp-size組合但配置復(fù)雜度會上升建議先用純TP跑通再根據(jù)壓測結(jié)果決定是否引入DP。7. 實戰(zhàn)中遇到的那些坑前綴緩存失敗、并發(fā)超時與顯存碎片最后記錄幾個我在實際使用SGLang時遇到過的問題和排查思路。這些內(nèi)容在官方文檔里很難一次找全遇到了只能自己摸索。7.1 前綴緩存命中率接近零我第一次把SGLang接入線上服務(wù)時發(fā)現(xiàn)RadixAttention的命中率低得可憐。排查了半天問題出在我們業(yè)務(wù)請求里帶了當(dāng)前時間戳字段比如“今天是2025年5月18日請幫我安排行程”。時間戳每次不同導(dǎo)致整條請求前綴全部失配。這個問題的解法有兩種一是從prompt中移除動態(tài)字段把時間等變量放到共享系統(tǒng)提示詞以外的位置二是在前綴長度不符合預(yù)期時不要對整條請求做緩存復(fù)用而是以更粗粒度的對話邊界為準。SGLang提供了自定義分塊邏輯的能力把每個請求的緩存ID設(shè)置成session級對話內(nèi)復(fù)用依然成立。7.2 并發(fā)峰值下的CPU調(diào)度瓶頸SGLang的調(diào)度器是單進程模型當(dāng)并發(fā)請求數(shù)超過150路時CPU側(cè)的調(diào)度開銷開始變得明顯表現(xiàn)為GPU利用率下降但請求排隊時間上升。定位方式很簡單看/metrics接口里的engine_accept_latency和schedule_queue_length指標。如果排隊長度持續(xù)增長優(yōu)先調(diào)大--schedule-conservativeness讓調(diào)度器更積極地批量處理請求實在不行再考慮引入DP緩解單進程壓力。7.3 多模態(tài)輸入導(dǎo)致的首字延遲波動我們后來接入了多模態(tài)模型發(fā)現(xiàn)帶圖片的請求首字延遲明顯高于純文本請求。根因是多模態(tài)輸入的圖像tokens數(shù)量不穩(wěn)定導(dǎo)致batch內(nèi)請求的實際序列長度差異巨大調(diào)度器為了兼顧最長序列讓其他請求也等了更久。后來我們按照輸入類型拆分了服務(wù)圖像請求單獨一個服務(wù)實例并給這個實例單獨配置了更大的--mem-fraction-static首字延遲才穩(wěn)定下來。7.4 一個小技巧用結(jié)構(gòu)化輸出給共享前綴補充緩存錨點在做Agent類的多步任務(wù)時我會故意在每步生成的結(jié)尾加上一個固定的分隔符比如把“最終結(jié)果”作為固定后綴。這樣下一次請求就能以這個分隔符為節(jié)點繼續(xù)復(fù)用前綴。這個技巧聽起來有點取巧但在實際壓測里它讓連續(xù)策略步驟之間的前綴命中率提升了約35%。以上這些經(jīng)驗是我在SGLang從0.2版本一路用過來沉淀下來的。這個框架還在快速迭代特性更新頻繁但它的核心設(shè)計思路——前端描述生成流程、運行時管理token級復(fù)用、解碼階段注入結(jié)構(gòu)化約束——已經(jīng)足夠成熟也足夠解決當(dāng)前LLM服務(wù)里最典型的幾個性能痛點。如果你正在被重復(fù)計算和格式解析問題困擾建議用一個真實業(yè)務(wù)場景跑一次對比觀察RadixAttention的命中率和整體吞吐曲線再決定要不要切過來。從我的經(jīng)驗看多輪對話場景下這個框架值得一試。