1. 深夜告警:TTFT 飙到 3 秒,问题不在模型
线上 AI 助手白天跑得好好的,晚上八点流量一上来,用户开始反馈"转圈圈":首 token 迟迟不出来,或者打字打到一半突然卡住。翻监控能看到两个指标同时恶化——TTFT(首 token 延迟)从 400ms 涨到 3s,TPOT(每 token 生成时间)从 30ms 抖到 150ms。模型没换、权重没动、GPU 也没坏,问题出在推理引擎的调度策略上。
传统单引擎把 Prefill(读题)和 Decode(作答)混在同一批 GPU 上调度,而这两件事的计算特征完全相反。Prefill 把整段 prompt 一次性做 forward,计算量随输入长度线性增长,是典型的计算密集型任务,直接决定 TTFT;Decode 逐 token 生成,每步只处理最新一个 token,却要反复把模型权重从 HBM 搬到寄存器,是典型的内存带宽密集型任务,直接决定 TPOT。一个吃算力,一个吃带宽,塞进同一个引擎就像让短跑运动员和马拉松选手跑同一场比赛,互相拖累。
具体表现为两个痛点。第一是 Prefill Interruption:新到的 Prefill 批次频繁打断正在进行的 Decode 批次,Decode 刚算完一个 token 又被拉去处理长 prompt,TPOT 剧烈抖动,用户体感就是"打字卡顿"。第二是 DP Attention Imbalance:数据并行部署下,一个 DP Worker 可能在处理 Prefill,另一个在处理 Decode,负载不均把整体 Decode 延迟拉高。SGLang 官方文档把这两个问题列为 PD 分离要解决的核心痛点。
PD 分离(Prefill/Decode Disaggregation)的思路很直接:把 Prefill 和 Decode 拆成两套独立实例,中间通过高速网络传 KV Cache。Prefill 实例负责读题,算完把 KV Cache 交给 Decode 实例;Decode 实例只负责作答,从 KV Cache 续写。收益有三点:ITL 稳定,Decode Worker 不再被 Prefill 打断,token 间延迟可预测;独立扩展,Prefill 多配算力、Decode 多配带宽,各自按需伸缩;硬件利用率提升,每类负载针对性优化,不再互相迁就。
vLLM 与 AMD 合作的 MORI-IO 文章给过一组直观数据:单节点 8×MI300X,Qwen3-235B-A22B-FP8,ISL=2000、OSL=1000、8 req/s,标准 2×TP4 部署的 goodput(同时满足 TTFT<1s 且 ITL<50ms 的请求占比)只有 30/100,而 MORI-IO Write 模式达到 73/100,相对提升 2.5 倍。这就是为什么 2025 年以来主流引擎都在押注这个方向。
本文面向自建推理服务的开发者,给出 vLLM 与 SGLang 两套引擎的启动参数、分离部署配置模板,以及通过 TaoToken 统一 Key/API 通道接入后的连通性验证与压测对比动作,目标是让你复现一套可运行的分离架构基线。适合已经能单机跑起 vLLM 或 SGLang、正在被 TTFT 和 ITL 双指标困扰的同学。
2. TaoToken 前置:统一 Key 与 API 通道准备
在动手拆 PD 之前,先把"调用入口"这件事收敛掉。自建推理服务常见的麻烦是:vLLM 一套 OpenAI 兼容地址、SGLang 一套地址、线上业务又要接别家模型,Key 散落在各个配置文件里,压测脚本还得为每个后端写一份。TaoToken 在这里扮演的是统一接入层——一个 Key、一个 Base URL,把自建引擎和托管模型放在同一条通道后面,压测和灰度都省事。
先把账号和 Key 拿到。打开官网 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 注册,进入控制台后到 API Keys 页面创建密钥。控制台地址是 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console ,创建时建议按用途命名,比如pd-bench-prefill、pd-bench-decode,后面压测出问题好定位是哪个 Key 在打流量。
API 基地址统一用 https://taotoken.net/api ,注意这个地址不带任何查询参数,SDK 里填 Base URL 时不要自作主张加斜杠或路径。模型对话的调试入口在 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models ,接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc ,遇到 401 或路径 404 先翻文档里的路径规范,比在群里问快。
这里要说清楚一件事:TaoToken 是统一接入通道,不是让你把自建 vLLM/SGLang 的推理算力"托管"出去。你的 Prefill 实例和 Decode 实例仍然跑在自己的 GPU 上,TaoToken 负责的是请求入口、Key 管理和多后端路由这一层。压测时你可以让压测客户端统一打 TaoToken 的 Base URL,由它转发到你的自建引擎;也可以直连引擎做对照,两种方式都保留,数据才可信。
环境变量建议这样组织,避免 Key 硬编码进脚本:
export TAOTOKEN_BASE_URL="https://taotoken.net/api" export TAOTOKEN_API_KEY="sk-你的密钥" export VLLM_PREFILL_URL="http://127.0.0.1:8100/v1" export VLLM_DECODE_URL="http://127.0.0.1:8200/v1" export SGLANG_ROUTER_URL="http://127.0.0.1:30000"如果你打算长期跑编码类或 Agent 类负载,可以顺带看一下 Coding Plan 的入口 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan ,它和按量 Key 是两条计费路径,压测阶段用按量 Key 更灵活,稳定后再评估套餐。Claude Code 这类工具的接入说明在 https://taotoken.net/claudecode?utm_source=taotoken_aicg_blog_end&utm_content=claudecode ,和本文的 PD 分离是两件事,别混在一起配。
最后提醒一个容易踩的坑:TaoToken 的 Key 权限和自建引擎的鉴权是两层。自建 vLLM 如果加了--api-key,压测客户端要同时带 TaoToken 的 Key 和引擎的 Key,别只配一个然后对着 401 发呆。下一节的配置模板里我会把这两层都写清楚。
3. 可复制配置:vLLM 与 SGLang 分离部署模板
这一节给的是能直接落地的启动命令和配置文件。先说明硬件前提:PD 分离的 KV Cache 转移必须走高速互联,单机多卡用 NVLink,跨节点用 RDMA(RoCE/InfiniBand)。如果你只有一张卡或者卡间走 PCIe 且没有 P2P,分离的收益会被转移开销吃掉,建议先别上。
3.1 vLLM 的 PD 分离启动参数
vLLM 在单节点场景用 MORI-IO 做 KV Cache 转移,支持 Read 和 Write 两种模式。Write 是默认模式,Prefill 逐层通过 RDMA Write 直接写入 Decode 的预分配内存,Decode 可以尽早开始生成,流水线化程度更高。先起 Prefill 实例:
vllm serve Qwen/Qwen3-32B \ --host 0.0.0.0 --port 8100 \ --tensor-parallel-size 4 \ --kv-transfer-config '{ "kv_connector": "MoriIOConnector", "kv_role": "kv_producer", "kv_connector_extra_config": { "proxy_addr": "127.0.0.1", "proxy_port": 9000, "http_port": 8100, "transfer_mode": "write" } }' \ --api-key "prefill-internal-key"再起 Decode 实例,注意kv_role换成kv_consumer,端口和 TP 可以独立设置:
vllm serve Qwen/Qwen3-32B \ --host 0.0.0.0 --port 8200 \ --tensor-parallel-size 4 \ --kv-transfer-config '{ "kv_connector": "MoriIOConnector", "kv_role": "kv_consumer", "kv_connector_extra_config": { "proxy_addr": "127.0.0.1", "proxy_port": 9000, "http_port": 8200, "transfer_mode": "write" } }' \ --api-key "decode-internal-key"两边的proxy_addr/proxy_port必须一致,这是 ZMQ 元数据交换的通道,用来交换 KV 缓存基地址、块大小、层张量步幅,建立 RDMA 会话。transfer_mode选read时,Proxy 等 Prefill 完成后把 KV 块位置转发给 Decode,Decode 主动通过 RDMA 读取,适合 Prefill 和 Decode 不在同一信任域的部署。
调度侧还要配 chunked prefill 相关参数,否则长 prompt 会把 token budget 吃满:
--max-num-batched-tokens 8192 \ --long-prefill-token-threshold 4096 \ --scheduler-reserve-full-isl \ --watermark 0.01max_num_batched_tokens是单次 iteration 最大 token 数,默认 2048,压测时按显存往上调;long_prefill_token_threshold超过阈值视为长请求,默认 0 表示禁用;scheduler_reserve_full_isl在接纳前检查完整序列能否放进 KV cache,防止过度接纳导致 cache thrashing;watermark预留 KV cache 块比例,减少频繁 eviction 和 preemption。
3.2 SGLang 的 Router + Mooncake 配置
SGLang 通过--pd-disaggregation组合 Prefill 与 Decode 实例,传输引擎支持 Mooncake 和 NIXL 两个后端。先起 Prefill:
python -m sglang.launch_server \ --model-path Qwen/Qwen3-32B \ --host 0.0.0.0 --port 30001 \ --tp-size 4 \ --pd-disaggregation \ --prefill-only \ --disaggregation-transfer-backend mooncake \ --disaggregation-bootstrap-port 8998再起 Decode:
python -m sglang.launch_server \ --model-path Qwen/Qwen3-32B \ --host 0.0.0.0 --port 30002 \ --tp-size 4 \ --pd-disaggregation \ --decode-only \ --disaggregation-transfer-backend mooncake \ --disaggregation-bootstrap-port 8999最后起 Router,把两类实例注册进去:
python -m sglang_router.launch_router \ --pd-disaggregation \ --prefill http://127.0.0.1:30001 \ --decode http://127.0.0.1:30002 \ --host 0.0.0.0 --port 30000 \ --policy cache_awareMooncake 支持 NVLink 传输、KV 转移队列和心跳健康检查,队列大小用SGLANG_DISAGGREGATION_QUEUE_SIZE控制,默认值在高并发下可能偏小,压测时调到 512 以上观察。NIXL 后端默认用 UCX,需要换 LibFabric 时设SGLANG_DISAGGREGATION_NIXL_BACKEND=LIBFABRIC。
SGLang 还有一个值得用的特性:异构 TP 的 GPU Staging Buffer。当 Prefill 和 Decode 的 TP 大小不同(比如 Prefill 用 TP4、Decode 用 TP8),Mooncake 会通过 staging buffer 把 KV 头切片到连续缓冲区做批量 RDMA 传输,官方称高并发下比默认逐 token 切片有 2-5 倍吞吐提升,接近同构 TP 基线。配置方式是在启动参数里显式声明两边的 TP,Router 会自动协商。
Router 的负载均衡策略支持round_robin、total_tokens、total_requests,以及基于请求历史近似 Radix 树的 cache-aware 路由。在线对话场景建议用cache_aware,把请求路由到前缀匹配最高的 Worker,命中 KV 前缀缓存能显著降 TTFT。
3.3 通过 TaoToken 统一入口的 settings 片段
如果你希望压测客户端只认一个 Base URL,可以在 TaoToken 侧配置后端路由,把模型名映射到自建引擎。客户端配置保持 OpenAI 兼容格式:
{ "base_url": "https://taotoken.net/api", "api_key": "sk-你的TaoToken密钥", "model": "qwen3-32b-pd", "extra_headers": { "X-Backend-Hint": "vllm-pd" } }对应的 Python 客户端最小片段:
from openai import OpenAI import os client = OpenAI( base_url=os.environ["TAOTOKEN_BASE_URL"], api_key=os.environ["TAOTOKEN_API_KEY"], ) resp = client.chat.completions.create( model="qwen3-32b-pd", messages=[{"role": "user", "content": "用三句话解释 PD 分离"}], max_tokens=256, stream=True, ) for chunk in resp: delta = chunk.choices[0].delta.content if delta: print(delta, end="", flush=True)注意model字段要和 TaoToken 控制台里配置的模型名一致,不是引擎侧的原始模型路径。这一步配错是最常见的 404 来源。
4. 验证请求与压测:确认分离架构真的生效
配置写完不等于跑通,必须用请求验证 KV 转移链路和调度行为。先做连通性冒烟,再做压测对比。
4.1 冒烟验证
直连 Prefill 实例发一个短请求,确认引擎本身活着:
curl -s http://127.0.0.1:8100/v1/completions \ -H "Authorization: Bearer prefill-internal-key" \ -H "Content-Type: application/json" \ -d '{"model":"Qwen/Qwen3-32B","prompt":"hello","max_tokens":8}'再打 Router 或 TaoToken 入口,确认请求能穿过分离链路:
curl -s https://taotoken.net/api/chat/completions \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"qwen3-32b-pd","messages":[{"role":"user","content":"ping"}],"max_tokens":16}'返回里choices[0].message.content有内容、usage字段完整,说明链路通了。如果返回 200 但内容是空的,去 Decode 实例日志里找kv_transfer相关行,大概率是 KV 没接上,Decode 拿不到上下文只能吐空。
4.2 压测脚本与指标采集
用 vLLM 自带的 benchmark 或 evalscope 都行,关键是同时盯 TTFT 和 ITL 两个指标。下面是一个最小压测脚本,模拟 ISL=2000、OSL=1000 的在线对话负载:
import asyncio, time, statistics from openai import AsyncOpenAI client = AsyncOpenAI(base_url="https://taotoken.net/api", api_key="sk-你的密钥") PROMPT = "请详细说明" + "推理引擎调度" * 200 # 约 2000 token async def one_request(idx): t0 = time.perf_counter() ttft = None tokens = 0 stream = await client.chat.completions.create( model="qwen3-32b-pd", messages=[{"role": "user", "content": PROMPT}], max_tokens=1000, stream=True, ) async for chunk in stream: if chunk.choices[0].delta.content: if ttft is None: ttft = time.perf_counter() - t0 tokens += 1 total = time.perf_counter() - t0 itl = (total - ttft) / max(tokens - 1, 1) return ttft, itl async def main(): results = await asyncio.gather(*[one_request(i) for i in range(100)]) ttfts = [r[0] for r in results] itls = [r[1] for r in results] good = sum(1 for t, i in results if t < 1.0 and i < 0.05) print(f"TTFT p50={statistics.median(ttfts):.3f}s p99={sorted(ttfts)[98]:.3f}s") print(f"ITL p50={statistics.median(itls)*1000:.1f}ms p99={sorted(itls)[98]*1000:.1f}ms") print(f"goodput={good}/100") asyncio.run(main())跑三组对照:标准单引擎(不开 PD)、vLLM PD 分离、SGLang PD 分离。每组跑 100 个请求,记录 TTFT p50/p99、ITL p50/p99、goodput。参考 vLLM 官方数据,标准 2×TP4 的 goodput 大约 30/100,MORI-IO Write 模式能到 73/100,你的硬件和模型不同,绝对值会有差异,但相对趋势应该一致。
4.3 结果解读
如果 PD 分离组的 ITL p99 明显低于单引擎组,说明 Decode 不再被 Prefill 打断,分离生效。如果 TTFT 反而变差,检查 KV 转移是否走了 TCP——用nvidia-smi topo -m确认卡间是 NVLink 还是 PCIe,用ibstat确认 RDMA 网卡状态。转移开销吃掉收益是 PD 分离最常见的翻车方式。
如果 goodput 提升不明显但 ITL 改善了,可能是 Prefill 和 Decode 的实例比例不对。长 prompt 多的场景(RAG 问答)Prefill 压力大,要多配 Prefill 实例;对话轮次深的场景 Decode 压力大,要多配 Decode 实例。这个比例没有银弹,靠压测数据迭代。
5. 常见报错排查:401、local proxy failed 与 reading choices
分离架构的报错比单引擎多一层,因为多了 KV 转移和 Router 转发。下面按真实报错逐条拆。
5.1 401 Unauthorized
最常见,两层鉴权只配了一层。自建 vLLM 加了--api-key时,直连引擎的请求要带引擎 Key;走 TaoToken 的请求要带 TaoToken Key。压测脚本里如果同时打两个入口,检查环境变量有没有串。另外 TaoToken 的 Key 有权限范围,创建时如果只勾了部分模型,请求未授权模型也会返回 401 而不是 403,别被状态码误导。
5.2 local proxy failed / proxy connection refused
vLLM 的 MORI-IO 依赖 ZMQ proxy 做元数据交换。报这个错说明 Prefill 和 Decode 的proxy_addr/proxy_port不一致,或者 proxy 进程没起来。检查两边kv_connector_extra_config里的proxy_port是否相同,防火墙是否放行该端口。跨节点部署时proxy_addr不能填127.0.0.1,要填实际可达的 IP。
5.3 reading choices 相关报错
客户端解析响应时抛reading 'choices'或choices is undefined,通常是上游返回了错误 JSON 而不是标准 completion 结构。先用 curl 看原始响应体,常见原因是模型名不匹配(TaoToken 侧配置的模型名和请求里的model字段不一致),或者后端引擎返回了 503(KV 转移队列满)。SGLang 侧把SGLANG_DISAGGREGATION_QUEUE_SIZE调大能缓解队列满导致的 503。
5.4 OAuth / token 过期类错误
如果你在压测脚本里用了短期 token 或者从 Claude Code 那类工具复制的配置,可能遇到 token 过期。TaoToken 的 API Key 是长期有效的,但如果你在客户端里配了 OAuth 流程(比如某些 SDK 的默认认证),会走到错误的认证路径。确认客户端用的是api_key而不是 OAuth bearer 流程,Base URL 是https://taotoken.net/api而不是带/oauth的路径。
5.5 KV 转移超时
日志里出现kv_transfer timeout或RDMA connection failed,先确认ibstat里网卡是 Active,再确认 Prefill 和 Decode 的transfer_mode一致(一边 write 一边 read 会握手失败)。跨节点场景检查 RoCE 的 PFC 配置,丢包会导致重传把转移延迟拉高。
5.6 三件套自查清单
凡是涉及 CC Switch、Cline MCP、Codex auth.json 这类工具的接入,配置里必须同时出现三件套:Base URL、Key、Model ID。缺任何一个都会报鉴权或路由错误。以 Codex 的auth.json为例:
{ "base_url": "https://taotoken.net/api", "api_key": "sk-你的密钥", "model": "qwen3-32b-pd" }Cline 的 MCP 配置同理,baseUrl、apiKey、modelId三个字段一个不能少。CC Switch 切换配置时注意别把自建引擎的 Key 和 TaoToken 的 Key 混在同一个 profile 里。
6. 继续往下走:把基线跑成日常
PD 分离不是开箱即用的银弹,它要求集群有高速互联、运维能独立扩缩容两套实例、压测体系能同时盯住 TTFT 和 ITL。但一旦跑通,同样一批 GPU 的 goodput 能翻倍以上。本文给的配置模板和压测脚本可以直接复现基线,接下来要做的是把这个基线变成日常监控的一部分。
具体动作建议这样排:先把 vLLM 或 SGLang 的分离部署在测试环境跑通,用第 4 节的脚本拿到第一组 TTFT/ITL 数据;然后把 Prefill 和 Decode 的实例比例按业务流量特征调一轮,长 prompt 场景多配 Prefill,深对话场景多配 Decode;最后把压测脚本接进 CI,每次改调度参数都跑一遍回归,防止 chunked prefill 的 watermark 或 token budget 调错把 ITL 打回原形。
接入层这边,TaoToken 的 API Key 管理入口在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys ,建议给压测、灰度、生产各建一个 Key,出问题能按 Key 维度看流量。接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc ,路径规范和错误码都在里面,遇到 401 或 404 先翻文档再动手改配置。模型对话调试用 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models ,改完 Router 策略后在这里发几个请求确认路由生效。
长期跑编码类或 Agent 类负载的话,Coding Plan 入口在 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan ,和按量 Key 是两条计费路径,压测阶段用按量更灵活。Claude Code 的接入说明在 https://taotoken.net/claudecode?utm_source=taotoken_aicg_blog_end&utm_content=claudecode ,和 PD 分离是两套东西,别在同一个配置文件里混着改。
最后一个实操技巧:调max_num_batched_tokens时别一次跳太大,从 4096 开始每次加 2048,每档跑 100 个请求看 ITL p99 有没有拐点。拐点出现的位置就是你这套硬件的甜点区,比抄别人的参数靠谱。