如果你正在维护一个 LLM 在线服务,大概率见过这种诡异现象:接口平均延迟不算高,但每隔一段时间就出现一次几十秒的超时;P50 明明不到 300 毫秒,P99 却直接冲到 20 秒以上。更让人头疼的是,负载不高,GPU 没有跑满,日志里也没报错,唯一能看到的规律是:总有一两个请求特别“刺眼”——要么 prompt 特别长,要么 max_tokens 设得特别大,一个人把整个 batch 的节奏都带崩了。
这类问题常被笼统地归结为“模型推理太慢”,然后团队开始加 GPU、做量化、换推理框架。但很多时候,真正的根因不在模型本身,而在于请求调度和队列设计。尾部延迟(tail latency)是一个系统工程问题,容量只是其中的一个变量。
本文要讲的,是一个几乎不依赖硬件的“简单修复”思路:在请求入口层做分级、限制长度、快速失败,把拖垮 P99 的长尾请求从主队列里隔离出去。整套方案不优雅,但足够简单,适合绝大多数中小团队直接落地。
1. 这篇文章真正要解决的问题
先说清楚本文的边界。我们讨论的是“在线 LLM 推理服务”的尾延迟问题,比如你封装了一个 OpenAI 兼容的 chat/completions 接口,背后跑着 vLLM、TGI 或其他推理框架,客户端是 Web 应用或 Agent 程序。
这类服务最容易出现的一个典型症状是:
- P50 / P95 很好,P99 突然烂掉;
- 单看请求量并不大,GPU 利用率也不饱和;
- 偶尔有一两个请求耗时特别长,直接打爆客户端超时;
- 一旦客户端超时后自动重试,服务端雪上加霜,延迟进一步恶化。
如果只看表面,很容易得出“模型能力不行”“GPU 不够”的结论。但从调度视角看,这类问题的本质是:短请求和长请求混在同一个队列和同一个 batch 里,短请求被长请求的排队时间和解码时间拖累。
一个简单的比喻:银行柜台同时服务“存 100 块”和“办 2 小时公积金贷款”的客户。如果都排同一条队,存钱客户大概率会被贷款客户堵死。LLM 推理里的长 prompt 和超大 max_tokens,就是那个办 2 小时贷款的客户。
这篇博客适合以下读者:
- 正在自建 LLM API 服务的后端开发者;
- 负责推理服务稳定性、性能优化的 SRE 或平台工程师;
- 使用 vLLM/TGI 但对延迟曲线不满意,又不想一开始就上复杂架构的团队。
读完你至少能解决三件事:知道 LLM 服务尾延迟主要从哪里来;掌握一套不依赖推理框架内部实现的入口层修复方案;拿到可以直接改的 Python 网关代码和验证方法。
2. 先搞清楚 LLM 的延迟到底在哪里
要修复尾延迟,先得知道一次 LLM 请求的时间花在哪里。和传统 Web 接口不同,LLM 的延迟不是单一数字,而是由几个阶段叠加而成。
| 阶段 | 含义 | 对应指标 | 通常耗时量级 |
|---|---|---|---|
| 排队延迟 | 请求进入服务后等待被调度的时间 | Queueing Delay | 依赖负载,可能从毫秒到秒级 |
| Prefill 阶段 | 处理输入 prompt,生成第一个 token | TTFT(Time To First Token) | 随 prompt 长度线性增长 |
| Decode 阶段 | 逐个生成后续 token | TPOT(Time Per Output Token),也叫 TBT | 每个 token 毫秒级,总耗时随 max_tokens 线性增长 |
| 网络传输 | 请求从客户端到服务端、响应回传 | End-to-End Latency | 通常较小,跨地域时需要注意 |
对在线交互应用来说,最难受的往往不是排队的平均时间,而是 P99 的“拐点”。一次请求如果排队等了 2 秒,prefill 又因为 prompt 太长扫了 3 秒,decode 因为 max_tokens 设成 2048 再跑 10 秒,叠加起来就是一次灾难性的超时。
另一个容易混淆的地方是“并发越高越慢”。LLM 推理框架倾向于把多个请求拼成一个 batch 来提高吞吐,这是 GPU 计算特性决定的:批量计算比逐个计算更划算。但 batch 的代价是同一 batch 内的所有请求共享同一个调度节奏,任何一个慢请求都可能拖住其他请求的 step。
这里有一个新手容易误解的点:以为 tail latency 主要是“模型本身每次推理快慢不一致”。实际上,在动态批处理(continuous batching)机制下,模型单次 forward 的时间相对稳定,真正的不稳定来自请求之间的相互作用和队列策略。P99 高,往往不是模型抖动,而是调度抖动。
3. 为什么 batch 越大,P99 越难看
要理解 tail latency,必须理解主流的 LLM 推理框架是怎么“拼车”的。
早期的静态 batching 很简单:攒够 N 个请求,一起 forward,等所有请求都生成完,再把这批结果一起返回。这种方式吞吐稳定,但 tail latency 很惊人,因为一旦 batch 里有一个 max_tokens 特别大的请求,其他几个早就生成完的请求也只能干等,白白被拖累。
后来 vLLM、TGI 等框架引入了迭代级调度(iteration-level scheduling),也就是连续批处理(continuous batching)。它把“请求”粒度拆成“step”粒度:每执行完一个 decode step,就检查有没有请求已经结束,有就移除位置,并允许新请求插入。这大大提高了 GPU 利用率和响应速度,但副作用依然存在:
- 长序列请求长时间占用 slot。一个 max_tokens=2048 的请求哪怕 prompt 很短,也要连续占住一个 batch 位置几十甚至上百个 step。它不结束,新请求就无法插到相同位置。
- prefill 与 decode 竞争。新请求的 prefill 阶段计算量很大,会把正在进行的 decode 步长显著拉长,导致 batch 内所有请求同时变慢。
- 排队策略不公平。如果队列是简单的 FIFO,一个长 prompt 请求排在你前面,你就得等它 prefill 完。它 prefill 慢,会拉高后面所有短请求的 TTFT。
现实中,一个服务如果同时存在“几百 token 的聊天请求”和“几千 token 的文档总结请求”,后者的存在会系统性抬高前者的 P99。短请求不仅是“被延迟”,而是“被长请求锁住”。
这也是为什么说 tail latency 问题是“结构性”的:只要长短请求共享排队和 batch,P99 就很难好看。解决思路无非两条:要么从调度上隔离长短请求,要么从入口控制请求长度分布。本文的方案是后者为主,因为它实现成本最低,不需要改动推理框架。
4. 最容易走错的三个修复方向
在给出方案前,先列出我见过的大量团队(包括我自己早期踩过的坑)会走的弯路。
4.1 盲目加 GPU
很多人看到 P99 高,第一反应是“算力不够”,于是加卡。加卡能提升整体吞吐,但不一定能削平 tail latency。如果瓶颈是某个长请求长时间霸占 batch slot,或者队列策略不公平,再加多少 GPU,短请求照样可能排在长请求后面。加卡只会让平均延迟变好,不会修复调度问题。
4.2 无上限放开 max_tokens
很多后端为了灵活性,直接把客户端的max_tokens原样透传给推理框架,甚至不传时让框架用默认最大值。这相当于让每一个调用方都能申请“无限制的算力配额”。某个 Agent 程序一旦写错逻辑,可能出现长达几千 token 的循环生成,一次请求消耗的 GPU 时间等于几百次短请求。
4.3 客户端无限重试
客户端发现超时后立刻重试,是另一种常见的自伤行为。服务端一旦出现某个慢请求,客户端重试只会把更多请求压进队列,形成“重试风暴”。慢请求还没处理完,新的重试请求又把队列堆满,最终从 P99 恶化成整体不可用。
这三种做法都不是在“修复” tail latency,而是在“掩盖”或“放大”问题。真正的修复需要从请求生命周期的最前端下手:进入队列之前,就完成分类、限流和超时策略。
5. 核心方案:请求分级 + 长度上限 + 快速失败
本文推荐的“简单修复”由三个独立但互补的手段组成。它们不依赖推理框架内部实现,可以在网关层完成,风险低、可回滚、容易理解。
5.1 请求分级:让短请求先走
核心规则很简单:请求进入网关时,根据 prompt 长度和 max_tokens 大小分成两类:
- 短请求(高优先级):prompt 短、max_tokens 小,这些往往是交互式聊天、接口调用、简单问答。
- 长请求(低优先级):prompt 长或 max_tokens 大,这些往往是文档总结、离线分析、Agent 长任务。
短请求进入优先队列,长请求进入普通队列。只要短请求队列里有任务,worker 就优先处理短请求。
这个做法的价值在于把“长短请求互相拖累”变成“长短请求自然隔离”。短请求的 P99 会大幅下降,长请求的延迟会上升,但长请求本身对延迟敏感度低,用户本来就会等更久。对业务来说,这是一个合理的取舍。
需要注意,短请求优先并不是“完全饿死长请求”。实际实现中,可以给长请求设置一个最大排队超时,保证它最终会被处理,或者直接拒绝并让客户端稍后重试。
5.2 服务端强制长度上限
不要信任客户端的 max_tokens 参数。真正的兜底应该在服务端。
具体来说,需要在网关入口做两层限制:
- 输入长度限制:对 prompt 的总字符数或预估 token 数设上限,超过直接返回 4xx,或者要求客户端先做摘要/分块。
- 输出长度限制:对 max_tokens 设上限,例如默认 256,最大不超过 1024。如果客户端传的值大于上限,可以截断,也可以返回错误。更稳妥的做法是截断并用响应头告知客户端实际生效值。
这一步能直接掐掉“一个请求耗尽整机算力”的最坏情况。它看起来简单粗暴,但在生产环境中非常有效。
5.3 排队超时快速失败
第三件套是给排队加超时。
很多 tail latency 的极端值来自“请求孤零零地排在队列里,等到超时才报错”。与其让客户端傻等,不如在服务端设置一个明确的排队超时阈值。超过阈值,立即返回 503 或 429,并附上“请稍后重试”的说明。
这个做法把不确定的等待变成确定的快速失败。对客户端来说,收到一个明确错误比无限等待更容易处理;对服务端来说,排队长度被限制在可控范围,不至于出现“几百个请求堆在内存里慢慢腐烂”。
三件套合起来的思路是:把请求流量在入口处整理成一个“可控、可分类、可拒绝”的形状,而不是把所有的随机性都丢给推理框架去消化。
6. 代码实现:一个最小的 LLM 网关排队器
下面用 FastAPI + asyncio 实现一个最小可运行的网关。它在最前面加了双优先级队列、长度限制和排队超时,后端指向任意一个 OpenAI 兼容接口。
6.1 项目结构
整个 demo 只有两个文件:
. ├── gateway_demo.py # FastAPI 网关 └── requirements.txt # 依赖依赖文件内容如下:
fastapi uvicorn httpx6.2 完整代码
# 文件路径:gateway_demo.py import asyncio import itertools import os import time import uuid from contextlib import asynccontextmanager from dataclasses import dataclass, field import httpx from fastapi import FastAPI, Request from fastapi.responses import JSONResponse # 后端真实 LLM 推理服务地址,例如 vLLM BACKEND_URL = os.environ.get("BACKEND_URL", "http://127.0.0.1:8000") # 短请求判断阈值 SHORT_PROMPT_CHARS = int(os.environ.get("SHORT_PROMPT_CHARS", "2000")) SHORT_MAX_TOKENS = int(os.environ.get("SHORT_MAX_TOKENS", "128")) # 排队超时和端到端超时 QUEUE_TIMEOUT = float(os.environ.get("QUEUE_TIMEOUT", "5.0")) HTTP_TIMEOUT = float(os.environ.get("HTTP_TIMEOUT", "90.0")) # 并发 worker 数量 WORKER_COUNT = int(os.environ.get("WORKER_COUNT", "2")) # 输入输出硬限制 MAX_PROMPT_CHARS = int(os.environ.get("MAX_PROMPT_CHARS", "100000")) MAX_OUTPUT_TOKENS = int(os.environ.get("MAX_OUTPUT_TOKENS", "1024")) @dataclass(order=True) class LLMRequest: priority: int seq: int = field(compare=False) created_at: float = field(compare=False) request_id: str = field(compare=False) payload: dict = field(compare=False) class LLMGateway: def __init__(self) -> None: self.queue: asyncio.PriorityQueue = asyncio.PriorityQueue() self.counter = itertools.count() self.client = httpx.AsyncClient(timeout=HTTP_TIMEOUT) self.workers = [ asyncio.create_task(self._worker(i)) for i in range(WORKER_COUNT) ] async def submit(self, priority: int, payload: dict) -> dict: loop = asyncio.get_running_loop() future: asyncio.Future = loop.create_future() req = LLMRequest( priority=priority, seq=next(self.counter), created_at=time.monotonic(), request_id=uuid.uuid4().hex, payload=payload, ) await self.queue.put((req.priority, req.seq, req, future)) try: return await asyncio.wait_for(future, timeout=QUEUE_TIMEOUT + HTTP_TIMEOUT) except asyncio.TimeoutError: raise TimeoutError("request timeout in gateway") async def _worker(self, worker_id: int) -> None: while True: _, _, req, future = await self.queue.get() waiting = time.monotonic() - req.created_at # 排队时间已经超过阈值,不再调用后端,直接快速失败 if waiting > QUEUE_TIMEOUT: if not future.done(): future.set_exception(TimeoutError("dequeued after timeout")) self.queue.task_done() continue try: resp = await self.client.post( f"{BACKEND_URL}/v1/chat/completions", json=req.payload, ) resp.raise_for_status() if not future.done(): future.set_result(resp.json()) except Exception as exc: if not future.done(): future.set_exception(exc) finally: self.queue.task_done() gateway: LLMGateway | None = None @asynccontextmanager async def lifespan(app: FastAPI): global gateway gateway = LLMGateway() yield if gateway: await gateway.client.aclose() for t in gateway.workers: t.cancel() app = FastAPI(title="llm-tail-latency-gateway", lifespan=lifespan) def classify(payload: dict) -> int: """返回 0 表示短请求,放入优先队列;返回 1 表示长请求,放入普通队列。""" messages = payload.get("messages", []) prompt_chars = sum(len(m.get("content", "")) for m in messages) max_tokens = int(payload.get("max_tokens", 0) or 0) if prompt_chars <= SHORT_PROMPT_CHARS and max_tokens <= SHORT_MAX_TOKENS: return 0 return 1 @app.post("/v1/chat/completions") async def chat_completions(request: Request): if gateway is None: return JSONResponse(status_code=503, content={"error": "gateway not ready"}) payload = await request.json() # 1. 输入长度硬限制 total_chars = sum(len(m.get("content", "")) for m in payload.get("messages", [])) if total_chars > MAX_PROMPT_CHARS: return JSONResponse( status_code=413, content={"error": "prompt too long, please split or summarize"} ) # 2. 输出长度硬限制 max_tokens = int(payload.get("max_tokens", 0) or 0) if max_tokens > MAX_OUTPUT_TOKENS: payload["max_tokens"] = MAX_OUTPUT_TOKENS # 3. 分类并提交 priority = classify(payload) try: result = await gateway.submit(priority, payload) return result except TimeoutError: return JSONResponse( status_code=503, content={"error": "queue timeout, please retry later"} ) except Exception as exc: return JSONResponse(status_code=502, content={"error": str(exc)})这段代码的关键逻辑有三点:
classify负责把请求分成两类。这里的阈值先用SHORT_PROMPT_CHARS和SHORT_MAX_TOKENS两个环境变量控制,方便不同业务调整。LLMGateway.submit里用了asyncio.wait_for,对每个请求都设置了端到端超时;同时 worker 出队后还会再检查一次排队时长,超过QUEUE_TIMEOUT就不调用后端,直接抛错返回 503。这实现了“快速失败”。- 入口处对
max_tokens做了硬截断,防止客户端传一个巨大的值。
6.3 运行与接入
先用 uvicorn 启动网关:
BACKEND_URL=http://127.0.0.1:8000 \ SHORT_PROMPT_CHARS=2000 \ SHORT_MAX_TOKENS=128 \ QUEUE_TIMEOUT=5 \ python -m uvicorn gateway_demo:app --host 0.0.0.0 --port 8080注意:BACKEND_URL要指向你真实的推理服务,比如 vLLM 启动的 OpenAI 兼容服务。网关启动后,客户端只需把请求地址从原来的后端地址改成http://你的网关:8080/v1/chat/completions即可。请求体兼容 OpenAI 格式。
7. 在推理框架中的落地配置
网关只是入口层,真正的推理框架也需要配合调整。以下配置以 vLLM 为例,但思路适用于大多数推理框架。
7.1 vLLM 启动配置
vLLM 的常见启动命令有两种风格,取决于版本。较新的版本推荐使用vllm serve:
vllm serve Qwen/Qwen2.5-7B-Instruct \ --max-model-len 32768 \ --gpu-memory-utilization 0.9 \ --trust-remote-code如果你的版本是旧版api_server入口,命令类似:
python -m vllm.entrypoints.openai.api_server \ --model Qwen/Qwen2.5-7B-Instruct \ --max-model-len 32768 \ --gpu-memory-utilization 0.9这里需要注意,--max-model-len控制的是模型能接受的最大上下文长度,但这个值等于“输入长度 + 输出长度”。如果服务端不额外限制,客户端传一个prompt=20000、max_tokens=20000的请求,它仍然可能打满整个上下文窗口。因此网关层的MAX_PROMPT_CHARS和MAX_OUTPUT_TOKENS实际上比框架配置更重要,它们是第一道闸门。
另一个在 vLLM 中值得开启的功能是前缀缓存(prefix caching)。它对包含大量公共前缀的请求(比如系统提示词固定的 Agent 调用)有显著加速效果,但不同版本开关参数不同,建议查阅你所用版本的官方文档。这里不展开。
7.2 Nginx 超时配置
如果前面还挂了 Nginx,需要合理配置超时时间,避免 Nginx 提前切断请求,而网关层的超时策略完全失效。
upstream llm_gateway { server 127.0.0.1:8080; keepalive 32; } server { listen 443 ssl; server_name llm.example.com; location /v1/chat/completions { proxy_pass http://llm_gateway; proxy_connect_timeout 5s; proxy_send_timeout 300s; proxy_read_timeout 300s; proxy_http_version 1.1; proxy_set_header Connection ""; } }这里的核心原则是:Nginx 的超时时间必须大于网关到后端的超时时间,否则 Nginx 会先于网关切断请求,客户端拿到的错误信息也失去意义。建议把proxy_read_timeout设成网关端到端超时的 1.5 倍以上。
7.3 客户端约束
服务端修复只是兜底,客户端也必须配合:
- 所有调用方必须显式设置
max_tokens,不能依赖框架默认值; - 重试次数限制在 2 次以内,并采用指数退避;
- 对长文本任务,先做分块或摘要,而不是一次性提交整个文档;
- 客户端超时要大于服务端超时,否则你永远只能看到“客户端超时”而不是服务端真实错误。
8. 效果验证:怎么证明它真的有效
任何优化都要有验证。下面是一套不需要复杂压测平台的验证方法。
8.1 压测前先定指标
不要只看平均延迟,至少要对比四个数字:
- P50:正常情况下的体感延迟;
- P99:长尾请求的恶化程度;
- 错误率:特别是 5xx 和 4xx 比例;
- 排队超时比例:如果这个指标很高,说明后端容量确实不够,调度优化已经到极限。
8.2 构造混合流量压测
真实场景中,长短请求是混合出现的。用下面的 Python 脚本模拟:一部分请求模拟短问答,一部分模拟长文档总结。
# 文件路径:mix_load.py import json import random import threading import time import urllib.request GATEWAY_URL = "http://127.0.0.1:8080/v1/chat/completions" TOTAL = 200 CONCURRENCY = 10 SHORT_RATIO = 0.8 results = [] def send_one(idx: int): is_short = random.random() < SHORT_RATIO if is_short: prompt = "用一句话解释什么是数据库索引?" max_tokens = 64 else: prompt = "请对下面这段技术文档做一份 500 字以内的摘要:" + "数据库索引是..." * 200 max_tokens = 512 body = json.dumps({ "model": "test", "messages": [{"role": "user", "content": prompt}], "max_tokens": max_tokens, }).encode("utf-8") req = urllib.request.Request(GATEWAY_URL, data=body, headers={"Content-Type": "application/json"}) start = time.time() try: with urllib.request.urlopen(req, timeout=60) as resp: resp.read() status = "ok" except Exception as exc: status = type(exc).__name__ cost = time.time() - start results.append((idx, status, cost)) threads = [] for i in range(TOTAL): t = threading.Thread(target=send_one, args=(i,)) threads.append(t) for t in threads[:CONCURRENCY]: t.start() for t in threads[CONCURRENCY:]: t.start() threads[t - CONCURRENCY].join() # 简易信号量控制并发 for t in threads: t.join() costs = sorted([r[2] for r in results if r[1] == "ok"]) if costs: n = len(costs) print(f"成功请求数: {n}") print(f"P50: {costs[int(n * 0.50)]:.3f}s") print(f"P95: {costs[int(n * 0.95)]:.3f}s") print(f"P99: {costs[int(n * 0.99)]:.3f}s" if n >= 100 else "请求数不足,无法计算P99") error_rates = {} for _, status, _ in results: error_rates[status] = error_rates.get(status, 0) + 1 print(f"错误分布: {error_rates}")这个脚本比较粗糙,但足以观察出优化前后的差异。建议在同一个后端、同一批 GPU 负载下,分别测“没有网关直连后端”和“经过网关”两种模式,看 P50、P99 和错误率的变化。
8.3 判断标准
从设计上看,这套方案应该产生以下效果:
- 短请求的 P99 显著下降,因为不再被长请求抢在前头;
- 整体端到端 P99 会被削平,但代价是长请求的一部分从“超长耗时”变成“快速失败返回 503”;
- 服务端 GPU 利用率可能略微下降,因为有些长请求被拦截了,但这属于主动放弃低价值请求,换取了更稳定的服务质量;
- 如果压测中发现短请求 P99 没有改善,优先检查是否真的把请求分类对了,以及 worker 数量是否充足。
这里要特别说明:上面的“预期”是从机制上推断的,不是某次具体压测的结论。不同模型、不同框架、不同流量分布下,数字会有差异,但结构性改善的方向是一致的。
9. 常见问题与排查思路
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| P99 高但 P50 不高 | 长短请求混排,短请求被长请求拖累 | 看网关日志中短请求的排队等待时间 | 启用短请求优先队列 |
| 调整后大量 503 | 排队超时设置过短,或后端真的处理不过来 | 拆分排队耗时和后端耗时 | 先看后端 P95,再决定调大超时还是扩容 |
| 客户端反馈超时,但服务端日志没有对应请求 | 网关前的 Nginx 或负载均衡超时太短 | 检查所有代理层的 timeout 配置 | 将代理超时设为大于网关端到端超时 |
| 请求被截断后用户不满意 | 服务端强制截断 max_tokens | 查看实际响应中的 token 数量 | 根据业务场景调整 MAX_OUTPUT_TOKENS,或对长任务走单独异步接口 |
| 加机器后 P99 还是高 | 调度策略仍是 FIFO,长请求继续堵住队列 | 观察队列长度和慢请求占比 | 入口分级 + 快速失败 |
| 某客户端疯狂重试导致雪崩 | 客户端无限制重试 | 看网关日志中同一请求 id 的重复出现 | 客户端限制重试次数 + 指数退避 |
排查 tail latency 问题时,最重要的顺序是:先确认延迟发生在“排队”还是“生成”,再决定是调调度还是调容量。如果网关日志里排队时间只有几毫秒,大量时间都花在httpx调用后端上,那问题在后端推理;如果排队时间占了总耗时的一半以上,才轮到调度策略优化。
10. 最佳实践与工程建议
上面这套“简单修复”可以快速止血,但如果要在生产环境长期稳定运行,还需要补齐以下工程细节。
10.1 监控指标
至少采集四类指标:
- 每个请求的排队耗时、prefill 耗时、decode 耗时、端到端耗时;
- 短请求和长请求各自的 P50 / P95 / P99;
- 队列当前长度、worker 数量、超时拒绝次数;
- 后端推理服务的 GPU 利用率、batch 大小、每 step 耗时。
有了这些数据,你才能回答“今天 P99 变差了,是因为流量异常,还是某个长尾请求出现,还是后端变慢”。
10.2 超时分层设计
一套合理的超时配置应该是分层的:
- 客户端超时 > 网关端到端超时;
- 网关端到端超时 > Nginx 代理超时;
- 网关排队超时 < 后端单请求最大耗时。
如果各层超时互不匹配,就会出现“客户端等 30 秒,Nginx 10 秒就切断,网关还被蒙在鼓里”的尴尬局面。
10.3 长度限制要写入 API 文档
不要只做代码限制,还要把规则写进 API 文档。明确告诉调用方:
- 请求的最大输入长度是多少;
- max_tokens 的上限是多少;
- 超过限制会被拒绝还是截断;
- 长任务应该使用哪些异步接口。
这样能减少开发团队之间的沟通成本,也能让调用方提前对自己请求做裁剪。
10.4 留一个绕过通道
生产环境要留一个白名单通道,给少量确实需要超长上下文的高价值任务使用。比如在网关识别一个内部 header,跳过短请求分类,直接进入长请求队列。这样你可以在不修改代码的情况下,支持极少数特殊场景。
10.5 先小流量灰度
任何网关改动都建议先小流量灰度。把网关部署在测试环境,用录制回放或影子流量验证一段时间,确认不会引入错误后,再逐步切流量。
11. 总结与后续学习方向
回到最初的问题:LLM 在线服务的 tail latency 为什么难修?因为它的根因往往埋在请求调度和队列结构里,而不在模型计算本身。加 GPU、优化算法固然有效,但成本高、见效慢。相比之下,“请求分级 + 长度上限 + 快速失败”是一套几乎不依赖硬件的入口层方案,它通过改变进入推理框架的流量形状,让系统不会因为少数极端请求而整体失控。
本文给出的网关代码和验证方法可以直接跑通。你可以把它理解成给 LLM 服务装了一个“交通分流闸门”:短请求走快速通道,长请求走慢速通道,排队过久直接拒绝。它不优雅,却足够有效,适合大多数中小团队作为第一道防线。
如果想继续深入,下一步值得研究的方向有三个:
- Prefix Caching:对相同系统提示词或文档前缀做 KV Cache 复用,能明显减少 prefill 延迟;
- Chunked Prefill 和 PD 分离:把 prefill 和 decode 放在不同实例上,是更彻底的长短请求隔离方案;
- 基于延迟的弹性伸缩:根据 P99 和排队长度自动扩缩容,而不是只看 GPU 利用率。
把这套入口层修复先落地,再根据监控数据决定是否需要更进一步,是比较稳妥的节奏。希望这篇文章能帮你把 P99 的诡异峰值变成一个可控、可解释、可优化的工程问题。