大模型批量任务通常覆盖文本分类、信息抽取、Embedding 生成、内容审核等场景。当请求量从每分钟几十条增长到每秒几百条时,端到端吞吐并不会线性上升,反而会出现排队增加、超时率升高、GPU 利用率波动等问题。很多优化动作如果只盯着模型推理本身,很容易忽略客户端调度、网络连接和服务端批处理策略带来的影响。本文从调用链路的角度拆解这些问题,并给出可落地的调整方法。

先定位批量任务真正的吞吐瓶颈
优化批量任务前,需要先分清瓶颈到底在哪个环节。一个典型的大模型请求会经过客户端构造请求、TCP/TLS 连接、服务端排队、Prefill 计算、Decode 逐 Token 生成、响应回传等阶段。不同阶段的耗时占比差别很大,例如短输入长输出的任务,Decode 时间可能占总耗时的 80% 以上;而大量小请求场景下,队列等待和连接建立反而更突出。
可以用最简单的串行调用与并发调用对比来观察。下面代码模拟了同一批 Prompt 在串行与并发下的耗时差异。
import time
import asyncio
import httpx
PROMPTS = ["总结以下内容:" for _ in range(50)]
def sync_call(prompt):
# 模拟每次请求固定耗时 200ms
time.sleep(0.2)
return "result"
async def async_call(client, prompt):
await asyncio.sleep(0.2)
return "result"
async def main():
start = time.time()
for p in PROMPTS:
sync_call(p)
print("串行耗时:", time.time() - start)
async with httpx.AsyncClient() as client:
start = time.time()
await asyncio.gather(*[async_call(client, p) for p in PROMPTS])
print("并发耗时:", time.time() - start)
asyncio.run(main())
如果串行耗时接近并发耗时的 N 倍,说明请求之间存在明显等待,客户端并发策略就有较大优化空间。如果并发后耗时没有明显下降,则要重点排查服务端是否已经限流、GPU 是否过载,或者是否触发了队列保护机制。
关注指标时,除了整体 QPS,还建议记录 TTFT(首 Token 延迟)和 TPOT(每个输出 Token 延迟)。批量任务的用户体验通常对 TTFT 更敏感,而离线批处理更关注单位时间的 Token 吞吐。只有把指标分层记录,才能避免把平均时延优化与吞吐优化混为一谈。
客户端异步并发与连接复用
大模型服务大多基于 HTTP 或 gRPC 接口,客户端如果每次请求都重新建立连接,TLS 握手和 TCP 慢启动会带来不可忽视的开销。使用 httpx.AsyncClient 或 aiohttp.ClientSession 可以在同一连接上发送多个请求,降低连接建立成本。更重要的是要引入信号量控制并发,避免把服务端打爆。
import asyncio
import httpx
SEM_LIMIT = 20
async def process_one(client, prompt, sem):
async with sem:
resp = await client.post(
"http://127.0.0.1:8000/v1/chat/completions",
json={
"model": "deepseek-v3",
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.1,
"max_tokens": 128
},
timeout=30
)
return resp.json()
async def batch_process(prompts):
sem = asyncio.Semaphore(SEM_LIMIT)
async with httpx.AsyncClient(limits=httpx.Limits(max_connections=50)) as client:
tasks = [process_one(client, p, sem) for p in prompts]
results = await asyncio.gather(*tasks, return_exceptions=True)
return results
代码中的信号量限制可以让并发请求数稳定在服务端可承受的范围内,Limits 参数则控制连接池大小。通常连接数可以略大于并发请求数,但不能完全忽略服务端限流。批量任务如果直接使用无限并发,很容易触发 429 或被服务端主动断开连接,反而需要额外重试逻辑。
重试策略同样要结合接口特性设计。对于幂等的文本分类和抽取任务,可以使用指数退避;对于生成类任务,如果请求已经进入推理阶段,重试可能造成重复消费。合理设置超时时间,区分连接超时、读取超时和总超时,能够避免慢请求长期占用客户端资源。
服务端连续批处理与动态合包
服务端的批处理策略直接影响 GPU 计算效率。传统静态批处理会把一批请求组合成固定大小的 batch,一旦某个序列提前结束,其余位置仍要等待,造成算力浪费。连续批处理则允许在 Decode 阶段动态插入新请求,一个请求结束后立刻让出位置,新请求直接进入计算,从而减少空闲时间。
以 vLLM 为例,默认启用连续批处理,开发者在启动服务时可以通过 max_num_seqs 和 max_num_batched_tokens 控制并发序列数和单次批处理 Token 上限。下面的启动参数适合处理大量短文本任务。
python -m vllm.entrypoints.openai.api_server \
--model /data/models/qwen2.5-7b-instruct \
--max-num-seqs 64 \
--max-num-batched-tokens 8192 \
--gpu-memory-utilization 0.92
在 Triton 推理服务中,可以通过配置 dynamic_batching 将多个请求自动合并。例如将 max_queue_delay_microseconds 设置为 200,表示最多等待 200 微秒就启动一次批处理,而不是一直等待凑齐 batch。这种动态合包方式特别适合输入长度差异较大的场景,因为它可以在时延和吞吐之间取得平衡。
dynamic_batching {
max_queue_delay_microseconds: 200
preferred_batch_size: [8, 16, 32]
}
需要注意,动态批处理不是万能的。当请求到达速率很低时,动态合包会引入队列等待;当单条请求的 Prompt 很长或输出长度很长时,单个请求可能占据大量 KV Cache,拖累其他请求。需要结合业务流量形态,对 max_num_seqs、max_num_batched_tokens 做压测调整。
缓存与语义去重降低重复计算
批量任务中经常存在重复或高度相似的输入。例如电商评论分析、客服工单分类,很多文本只是标点符号或用户 ID 不同。精确缓存可以基于输入文本的哈希值直接返回历史结果,而对于相似的文本,则可以使用 Embedding 相似度做语义缓存。
精确缓存实现起来比较简单,先对 Prompt 做归一化,去掉首尾空白和不可见字符,再计算 SHA256 作为缓存键。下面示例展示如何用 Redis 保存结果。
import hashlib
import json
import redis
r = redis.Redis(host="127.0.0.1", port=6379, db=0)
def normalize_prompt(text):
return " ".join(text.lower().split())
def get_from_cache(prompt):
key = hashlib.sha256(normalize_prompt(prompt).encode("utf-8")).hexdigest()
value = r.get(key)
if value:
return json.loads(value)
return None
def set_to_cache(prompt, result, ttl=3600):
key = hashlib.sha256(normalize_prompt(prompt).encode("utf-8")).hexdigest()
r.setex(key, ttl, json.dumps(result, ensure_ascii=False))
语义缓存则适合输入不完全相同但语义接近的情况。先调用一个轻量级 Embedding 模型生成向量,使用 FAISS 或向量数据库做最近邻搜索,当相似度超过阈值时直接复用之前的推理结果。这种方式的成本是额外的向量计算和存储,适合 Embedding 本身成本低于大模型推理的场景。
在实际设计缓存时,要特别注意缓存键包含所有影响输出的参数。如果同一个 Prompt 使用不同的 temperature、max_tokens 或系统提示词,结果不应该混用。缓存过期时间也要根据业务对时效性的要求设置,避免返回过期的分类结果。
推理侧量化、KV缓存与采样参数调整
除了调用链路和批处理策略,模型本身的推理配置也会显著影响批量效率。如果显存有限,可以优先尝试 FP16 或 BF16 推理,通常比 FP32 快 2 到 3 倍,显存占用降低一半。对容量更敏感的场景,可以使用 INT8 或 INT4 量化,但需要评估量化对任务准确率的影响。
对于生成任务,采样策略也会影响吞吐。批量分类或抽取任务通常不需要高随机性,可以将 temperature 设置为 0 或接近 0,并减少 top_p 和 top_k 的候选范围。与此同时,合理限制 max_tokens 可以避免模型生成过长、无效内容,直接降低 Decode 阶段的计算量。
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
quant_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype="float16"
)
model = AutoModelForCausalLM.from_pretrained(
"/data/models/qwen2.5-7b-instruct",
device_map="auto",
quantization_config=quant_config
)
另一个容易被忽略的优化点是 KV Cache 复用。很多批量任务会共享相同的系统提示词或前缀,例如将同一份产品说明书作为上下文,对多个用户问题进行回答。启用前缀缓存后,相同前缀只需要计算一次,后续请求直接复用已缓存的 Key 和 Value,能够显著减少 Prefill 时间。
在 vLLM 中可以通过 enable_prefix_caching 开启前缀缓存;在部分推理框架中,则需要将共享前缀单独固定,并保证请求的前缀 Token 完全一致。对于长文档问答和知识库检索增强场景,这个优化往往比调高并发数更有效。