拓冰建站拓冰建站
首页 / 资讯中心 / 正文

从vLLM到专用架构:Embedding服务低延迟优化实践

1. pplx-embed 模型服务架构到底改了什么事Perplexity这次把pplx-embed模型服务架构摆到台前最扎眼的不是“模型效果”又涨了几个点而是那句“延迟显著低于vLLM”。我平时做AI Infra见惯了各种benchmark标题党但这个说法确实戳中了一个长期没人系统聊的点embedding模型的推理服务长期以来都在被当成LLM推理服务在将就。问题在于将就并非最优解。先把话说明白pplx-embed这种嵌入模型核心任务是把一句文本变成一个稠密向量供检索、召回、聚类、分类这类下游场景使用。它不是ChatGPT那样的自回归生成模型不需要一个token一个token往外蹦。绝大多数embedding模型请求进来之后就是一次前向传播最多在最后做一下pooling和归一化然后返回一个向量。这个工作负载和LLM生成的“奶油蛋糕式流水线”有本质区别。那vLLM为什么慢vLLM是真的为自回归大模型设计的PagedAttention、KV Cache管理、Continuous Batching全部围绕“一个序列要在显存里住很久、要反复被调度续写”这个假设展开。这些机制让长文本生成场景的吞吐大幅提升但也带来了沉重的调度和显存管理开销。你拿vLLM去跑embedding相当于开着满载弹药的重卡去送外卖能到但每一单都又慢又贵。下面我会把延迟差异到底来自哪里拆开讲然后给一条从vLLM基线起步、逐步改成专用低延迟服务的实操路径。整个过程不涉及Perplexity内部代码都是行业里常见的推理优化手段只不过这次被一个明星公司放大了。看完之后你应该能自己评估手里的embedding服务到底该不该换架构。1.1 先理解工作负载embedding 服务和 LLM 服务不是一回事先说个最容易被忽略的事实LLM服务优化的是“生成效率”embedding服务优化的是“前向效率”。这两套评价体系完全不同。LLM推理时如果生成200个token就要做200次前向传播吞吐高不高取决于能不能把多个请求的decoder阶段拼在一起以及KV Cache能不能省着用。因此vLLM的Continuous Batching可以做到“某个请求生成完了立刻把显存让给新请求”这对长文本并发是决定性的。但embedding服务只需要一次前向传播输入一个句子输出一个向量。没有KV Cache没有解码循环也没有“序列长度随时间增长”的问题。此时你的优化目标变成了单次前向延迟尽可能低、单位时间内能处理的样本数尽可能多。于是调度器的复杂度、显存碎片管理、Block Table查找这些原本为了生成场景服务的机制全部变成了纯开销。而且embedding服务通常是高并发短文本一次请求往往只有几十到几百个token一个batch塞几十条样本很容易但vLLM的调度决策周期和内存分配流程占了相当一部分时间。用食堂来打比方LLM服务像流水线打饭每个窗口的师傅要反复接单、盛菜、打包embedding服务像拍证件照你站好快门一响相片就出来了。给快门加上一套“排队取号、分餐、记账”的系统快门本身再快整体也快不起来。1.2 延迟差在哪调度、显存管理、内核启动三笔账那具体延迟差在哪我自己的经验是主要差三笔。第一笔是调度器。vLLM内部有一个全局调度器每个请求进来要经过队列、显存分配、preemption检查等逻辑。这个过程是CPU上异步跑的更新一次状态通常要几百微秒到几毫秒。embedding请求本身可能只需要一两毫秒的GPU计算调度器这层就显得很碍眼。而许多专用embedding服务走的是“请求到worker”的直通路径CPU侧只做最轻量的routing和batch聚合。第二笔是显存管理。vLLM会为每个请求分配KV Cache Block并维护Block Table。对于embedding任务根本没有KV Cache需求但这些代码路径仍然会在创建token序列、复用block时被执行。如果你用decoder-only模型做embedding这个开销更明显即便用encoder模型vLLM的显存池也会预先占用一大块显存导致显存利用率低下、创建Python对象的时间变长。在压测时你会发现显存明明还有空闲可新请求却要排半天队这部分就是Block Table维护和显存池预留带来的副作用。第三笔是内核启动。深度学习框架中一次Transformer前向由几十上百个CUDA kernel组成。vLLM在吞吐场景下会通过CUDA Graph把解码循环捕获成一张大图减少CPU launch开销但embedding模型如果不做专门处理默认是eager mode每个op都从CPU发一个kernel指令。这时候延迟大头往往不是算力而是kernel launch的CPU开销。专用架构会做算子融合和CUDA Graph捕获把几十个kernel合成几个延迟自然降下来。这三笔账加在一起最终就出现了标题里说的“显著低于vLLM”。下面我们来拆解具体怎么做到。2. 低延迟模型服务架构动态批处理、算子融合与量化怎么配合如果你打算在自建环境里复现pplx-embed这种低延迟效果不需要完全照搬Perplexity的内部方案但有两个原则必须抓住第一把embedding当独立工作负载别用LLM推理引擎将就第二把优化重点放在“单次前向的开销”和“批处理排队策略”上而不是KV Cache之类的生成侧技术。我见过不少团队为了让embedding服务也能“吃到”vLLM的生态红利强行上vLLM跑检索模型结果压测一上不了量P99还飙到几十毫秒。后来把服务切到轻量推理引擎同一块GPUP99直接砍半还多。这说明不是硬件不行是架构选型错位。下面我把一个低延迟embedding服务最常见的四个设计点拆开讲动态批处理、算子融合与CUDA Graph、量化、多实例部署。这四层不是互斥的而是层层叠加。2.1 请求入口与动态批处理不要无脑等要“时间窗 数量阈值”双触发动态批处理是embedding服务提升吞吐的标配但很多人的实现思路是错的等队列攒到一定数量才跑结果并发不高时每个请求都被硬生生拖出几十毫秒延迟。真正的做法是设置双触发条件任何一个满足就立刻开始推理。我常用的配置是max_batch_size和max_batch_delay两个参数配合。比如设置单batch最多8条请求最多等2毫秒。当新请求到达时如果当前累计请求数已经到8立即执行如果没到8但距离第一条请求已经等了2毫秒也立即执行。这样低峰期不会为了等batch而增加延迟高峰期又能把计算资源填满。在Triton Inference Server里对应的配置放在dynamic_batching段核心是max_queue_delay_microseconds。这个值设太大会放大长尾延迟设太小又会让batch形同虚设我一般从2000微秒开始调再根据P99表现上下浮动。还有个细节长短文本不要混在一个batch里。一条512token的长文本会把整个batch的耗时拉到同样水平后面那些32token的短文本全被拖累。按输入token长度做分桶比如0-64、64-128、128-256每个桶走不同的CUDA Graph这样batch内部计算时间更均匀P99会好看很多。2.2 算子融合与 CUDA Graph把几十次 kernel launch 压缩到几次GPU计算中有一个经常被低估的开销每次调用CUDA kernelCPU都要通过驱动把参数、启动指令发给GPU这个链路是微秒级的。一个Transformer层里有几十个op如果eager模式跑光kernel launch就要消耗几百微秒而真正的GPU计算可能只需要几百微秒甚至更少。延迟预算就是这样被浪费的。解决办法有两个层次。第一层是算子融合把相邻的矩阵乘、激活、LayerNorm、Residual合并成少量kernel减少显存读写和kernel launch。TensorRT和ONNX Runtime的CUDA EP都能做这类优化。第二层是CUDA Graph把整个模型前向过程捕获成一张graph之后每次推理只需要把输入拷进去、自动执行整张图。CUDA Graph的难点在于它对动态shape不友好所以需要和按长度分桶配合每个桶都用固定序列长度和固定batch size这样graph才能被复用。我见过一个容易踩的坑有人把模型包装好之后第一个请求延迟很高第二个请求也高就以为是CUDA Graph没生效。其实是因为没有warmupgraph需要先捕获和执行一遍才能建立。正确做法是服务启动后立刻用几条不同长度的假数据跑一遍把各个分桶的graph都预热起来再对外接受流量。否则线上第一个真实请求就会被拿去捕获graph延迟能到几十毫秒。2.3 量化与精度取舍不是越低越好关键是看向量质量量化对于embedding服务来说主要收益是降低显存带宽压力和计算量从而降低单次前向延迟。尤其当模型比较小、计算密度不高时显存带宽往往更容易成为瓶颈。INT8把权重和激活从FP16压成8bit显存访问量直接减半延迟自然下降。但embedding任务对量化有一个特殊要求最终输出的向量要用于语义相似度计算或检索数值分布一旦被破坏业务效果会断崖式下跌。所以不能只看P99涨了没有还要同时盯向量质量。我在实践中的经验是不要直接无脑INT8先用FP8或INT8只量化权重、不量化激活跑一遍之后用自己业务里的question-answer对算cosine相似度再看它与FP16版本的误差分布。如果相对误差超过2%-5%就要考虑混合精度或选择更高比特量化。另外要注意归一化。很多embedding模型最后会做L2归一化量化后的scale和zero point如果对最后的pooling层造成扰动归一化向量会在某个维度上偏移。这个很难通过直觉发现最靠谱的办法还是建一个小的标注集每次换精度都跑一遍用准确率或召回率说话。2.4 多实例部署一个小细节能让延迟再降三分之一很多低延迟架构还有一个容易忽略的设计多实例隔离。不是一个GPU上开一个大模型实例而是开多个独立的小worker实例每个实例绑定不同的CUDA Stream或不同的GPU。这样做的目的不是提高吞吐而是隔离故障和降低调度干扰。当一个worker在处理超大batch时其他worker仍然能响应低延迟请求避免被“半个慢请求”拖垮。在实际部署里我会为一个8卡机器部署8个worker进程每个进程绑定一张卡前端用一个极轻量的负载均衡层按请求数分发。这样即使某个worker被打满其他worker依然能保证延迟稳定。如果流量模型里长短文本差异很大还可以把短文本路由到专用worker长文本路由到另一组worker彻底避免混跑。这一步看着不起眼但对P99的改善非常明显尤其是高峰期流量波动大的时候。3. 动手复现从 vLLM 基线到低延迟服务的优化实操理论讲再多不如直接跑一轮实验。下面我以当前常见的开源embedding模型为例给你一条从“vLLM基线”到“专用低延迟服务”的完整路径。整个过程大概需要一块NVIDIA GPUA10、4090、L4都行以及Docker、Python环境。模型我建议用BAAI/bge-m3或Qwen3-Embedding-0.6B这种社区里成熟的选择资料多、踩坑少。如果你想对标的是轻量低延迟场景Qwen3-Embedding-0.6B更合适模型小但效果够用。3.1 第一步用 vLLM 搭建一个可复现的延迟基线先把vLLM跑起来。命令其实很简单vLLM新版已经支持embedding任务vllm serve BAAI/bge-m3 \ --task embedding \ --port 8000 \ --host 0.0.0.0 \ --dtype float16 \ --max-model-len 8192启动之后先不要急着测并发用curl确认服务正常curl http://localhost:8000/v1/embeddings \ -H Content-Type: application/json \ -d {model: BAAI/bge-m3, input: [hello world]}接下来写一个简单的压测脚本。不要用同步curl因为延迟和并发都测不准。我用Pythonaiohttp发异步请求测三个关键指标TP50、TP99、QPS。import asyncio import aiohttp import time URL http://localhost:8000/v1/embeddings TEXT The quick brown fox jumps over the lazy dog CONCURRENCY 16 REQUESTS 1000 async def send(session): payload {model: BAAI/bge-m3, input: [TEXT]} start time.perf_counter() async with session.post(URL, jsonpayload) as resp: await resp.json() return (time.perf_counter() - start) * 1000 async def main(): latencies [] async with aiohttp.ClientSession() as session: tasks [] for _ in range(REQUESTS): tasks.append(send(session)) if len(tasks) CONCURRENCY: results await asyncio.gather(*tasks) latencies.extend(results) tasks [] if tasks: results await asyncio.gather(*tasks) latencies.extend(results) latencies.sort() tp50 latencies[len(latencies) // 2] tp99 latencies[int(len(latencies) * 0.99) - 1] print(fTP50: {tp50:.2f} ms) print(fTP99: {tp99:.2f} ms) print(fTotal time: {sum(latencies)/1000:.2f} s) asyncio.run(main())这个脚本看着长核心逻辑就是按并发窗口发射请求收集延迟后排序算百分位。我在A10上跑bge-m3短文本并发16时TP50通常能稳定在8-15msTP99在20-40ms左右。如果你的环境响应更快那说明GPU比我的好如果慢很多先确认是不是CUDA Graph没生效或者模型还在加载。3.2 第二步换成 Triton TensorRT Engine让模型按“前向任务”优化vLLM基线测完之后下一步就是把同样的模型塞进Triton Inference Server里。我这里不是让你把整个模型从PyTorch导成ONNX再转TensorRT而是建议直接用官方提供好的PipelineBAAI/bge-m3转ONNX再用TensorRT构建engine。为了简化我们用Triton的ensemble模型把tokenizer和后处理放在Python backend把模型本体放在TensorRT backend。Triton的配置文件看起来复杂但只要抓住核心参数就行。首先是config.pbtxt模型实例和动态批处理在这里配置name: bge_m3_trt platform: tensorrt_plan max_batch_size: 8 input [ { name: input_ids data_type: TYPE_INT64 dims: [1] } ] output [ { name: sentence_embeddings data_type: TYPE_FP32 dims: [1024] } ] dynamic_batching { max_queue_delay_microseconds: 2000 preferred_batch_size: [1, 2, 4, 8] } instance_group { count: 2 kind: KIND_GPU }这里几个参数值得专门解释一下。max_batch_size是单次推理能合并的最大样本数设成8就表示最多8条请求共享一次GPU推理preferred_batch_size是Triton动态batch时优先凑成的大小优先按1、2、4、8逐级靠避免随机凑一个非2幂次batch导致kernel效率下降max_queue_delay_microseconds就是前面说的“最多等多久”。后处理不能省。bge-m3这类模型通常需要CLS pooling或mean pooling并且做L2归一化。如果你用的是纯TensorRT plan模型输出的是最后一层hidden state还需要在Python backend里补上pooling和归一化。这一步容易被忽略但直接影响检索效果。3.3 第三步参数怎么调才能让延迟和吞吐都好看模型跑通后真正的调参才刚刚开始。我的经验是不要一上来就追求极低延迟先压一轮并发看曲线的“拐点”在哪里。以Triton为例你可以先固定max_queue_delay_microseconds0意思是只要有请求就立即推理不合并batch。这时候测出来的延迟基本是模型单次前向的真实下限。然后再改成max_queue_delay_microseconds2000看P99和QPS的变化。通常你会看到QPS明显上升TP99可能只是小幅增加这时候就说明动态batch在起作用。调preferred_batch_size也有讲究。当请求很密集时立即凑满8条比等2毫秒更划算当请求稀疏时等2毫秒多凑一条也能提升吞吐。如果你发现P99经常超过10ms先看是不是max_queue_delay_microseconds设太大了再看分桶有没有生效。长短文本混跑是P99飙高的头号嫌疑所以最好还是按token长度拆成多个bge_m3_trt_short、bge_m3_trt_long实例前端路由分发。这一步能拉开你和“只会把所有请求塞进一个batch”的差距。3.4 第四步压测结果怎么对比才不会被标题误导压测对比最忌讳只看一个平均数。我在文章开头说的“延迟显著低于vLLM”放到你自己的环境里也要拆开看低并发下的P50/P99变化高并发下的QPS和P99变化以及极端长度下的表现。我建议你固定两个场景一个是4并发、每请求1000条短文本看延迟分布另一个是64并发、每请求1000条混合长度文本看吞吐和长尾。前者回答“延迟低不低”后者回答“抗不抗造”。对比两个服务时输入数据必须完全一致最好都用同一个数据文件避免垃圾回收或网络抖动造成干扰。我实测的一个参考结果是在A10上vLLM跑bge-m3短文本场景P99约25ms换成TritonTensorRT并开启动态batching后P99可以压到8ms以内。如果再加以长度分桶和CUDA Graph预热4并发下P50能达到3ms左右。不同环境会有差异但“专用路由专用kernel”带来的收益是实打实的。4. 常见问题、踩坑与排查技巧说句实话embedding服务架构优化的坑跟LLM推理优化完全是两套。很多朋友拿vLLM用出经验了转头就把那套经验套在embedding上结果处处别扭。这里我把实际踩过的坑整理成几类方便你直接对着排查。4.1 为什么 vLLM 第一次请求特别慢后面也不够稳定这个现象在vLLM里太常见了。首次请求慢是因为模型权重还没有完全加载到显存CUDA kernel没有预热Python端各种组件在懒加载。后面不够稳定则是因为vLLM的调度器在后台每隔一段时间做一次全局排序和显存整理瞬时延迟会被拉高。解决办法有两个一是vLLM启动后先发几条不同长度的warmup请求把各种路径都跑一遍二是如果你的服务不允许有任何长尾波动就尽量别把embedding放在vLLM上。vLLM的Continuous Batching本身就是“为了高吞吐可以牺牲部分延迟”的设计这跟embedding低延迟服务的诉求天然冲突。不要看到vLLM名字响就觉得它万能选型永远以工作负载为准。4.2 动态 batching 之后 P99 反而更高我该信延迟还是吞吐常见误区是一开动态batching就以为吞吐一定涨、延迟一定降。实际上如果max_queue_delay_microseconds设太大或者batch算法不够智能请求会在排队等待中被硬生生拖出高延迟。起初你会看到平均延迟还好但P99直接翻倍。我的排查顺序是先把max_queue_delay_microseconds降到500微秒看P99有没有回落。如果回落明显说明是排队延迟在作祟这时候再逐步增加batch size而不是等待时间。如果P99依然高那就去看是不是长短文本混跑按长度分桶或者分实例隔离。记住一个原则动态batching是在“很低的等待成本”和“很高的GPU利用率”之间取折中不是让用户多等。4.3 量化之后向量质量掉得厉害如何用最小成本发现问题有些朋友做完INT8量化跑几个case觉得语义还挺像就直接上生产结果线上检索效果崩了。原因往往是测试样本太“友好”覆盖不到量化误差大的边界case。我自己现在固定用“三件套”来验证量化质量先用300对问题-答案计算cosine相似度看量化前后相似度的平均差再用自己业务里最难的50条Query做召回人工判断排序是否变化最后随机抽200个短句对两版embedding算分布距离。哪个环节异常就先别上线。如果是INT8导致的问题可以试试把激活保留为FP16只量化权重或者回到FP8方案。量化不是只能二选一很多模型FP8的误差远小于INT8延迟和带宽也足够优秀。在pplx-embed这种低延迟架构里量化通常不是“要不要做”的问题而是“怎么做才不伤精度”的问题所以测试环节一定不能省。4.4 vLLM 相关的几个社区热点哪些跟 embedding 真的有关最近社区里关于vLLM的热点不少比如vLLM如何优化大模型的缓存命中率、vLLM 0.23.0的chunk_size bug、sglang和vLLM的对比这些讨论对LLM生成场景都是好素材。但放到embedding服务这里大部分相关性很低。缓存命中率优化的是KV Cache复用embedding没有KV Cachechunk_size bug影响的是长文本prefill分块embedding虽然也有prefill但通常不涉及生成受bug影响也小sglang和vLLM的调度竞赛核心是Generate循环对一次前向的embedding帮助有限。如果你看到有人在Windows上折腾vLLM部署我的建议是别在embedding低延迟这件事上跟风。Windows下的CUDA生态本就折腾vLLM也不是为embedding设计的不如直接用Triton或者ONNX Runtime的CUDA EP省心不少。另外很多人在vLLM里折腾Qwen3这种大模型的部署参数那是另一个世界的问题embedding服务的瓶颈从来不是“模型够不够大”而是“请求路径够不够短”。如果你也正在做embedding服务优化我的建议是先从vLLM基线开始把真实流量下的延迟分布测出来再决定动不动刀。千万别凭感觉换框架也别凭感觉把一批请求堆在一起。用数据说话低延迟架构其实就是把该省的步骤省掉、该合并的请求合并好听起来不复杂但做对了效果就是这么显著。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门