ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Batching 与 KV Cache 对照实验,这次用 TaoToken 让 Codex 走通 TTFT/TPOT 采集流程

Batching 与 KV Cache 对照实验,这次用 TaoToken 让 Codex 走通 TTFT/TPOT 采集流程 做 Batching 与 KV Cache 对照实验时流量脚本还没跑完压测工具先被限流是常见翻车现场。TaoToken 可以让 Codex 走通这套实验到 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_end 创建 API Key再把 Base URL 填成 https://taotoken.net/api后续 TTFT/TPOT 采集不会因为默认通道的 401 或 rate limit 中断。原文用 vegeta 和自定义 Python 脚本生成 Poisson 流量分别跑静态 Batching 与 Continuous PagedKV记录 TTFT、TPOT、吞吐、GPU 显存与失败/OOM。我的操作方式是先配好 Codex让它按原文第 3 节的调度器逻辑生成压测脚本再用同一把 Key 连续跑补全接口把每次请求的 prompt_tokens / completion_tokens 累计成消耗表。调度策略是否有效交给实验数据说话数据是否可靠交给 TaoToken 控制台的用量记录来复核。1. 高并发档位下静态 Batching 的 P99 为什么先崩压测刚开始的几档往往很安静问题全在并发升上去之后暴露。静态 Batching 的做法是把请求攒成固定大小的 Batch等 Batch 填满或超时才统一送进 GPU。请求长度一拉开这种攒批策略就会把延迟尾部分布彻底破坏。1.1 等 Batch 填满的代价静态 Batching 的一个典型场景Batch 里恰好有一个超长请求要输出 2000 个 Token而另外四个请求只需要 20 个 Token。四台短任务在 20 个 Decode 步内就能生成完 EOS但因为它们和长请求绑在同一个 Batch 里必须等长任务走完 2000 步GPU 才释放这一整组的资源。短请求的延迟被长请求直接拖到 2000 步量级P99 自然被拽上去。这不是偶尔发生的抖动。Poisson 到达流量下长请求会随机混入任意 Batch任何一个 Batch 里混进长尾巴整组的完成时间都被拉长。并发越高Batch 数量越多长请求出现在同一组里的概率越大P99 的恶化就越明显。原文把这称作队头阻塞Head-of-Line Blocking本质是「先到先等」的调度策略在长短请求混跑时失去并发效率。1.2 KV Cache 碎片是隐性炸弹延迟变差只是表象显存碎片才是真正让实验中断的原因。每个请求在进入 Decode 前推理引擎都要为它预留 KV Cache 空间。静态 Batching 给请求分配连续物理显存请求结束后释放释放出来的空间大小不一散落在显存各处。高并发下新请求进来要求一段足够大的连续空间存放 KV 矩阵分配器却找不到只能触发 CPU 与 GPU 之间的 KV Swap。KV Swap 的代价极其昂贵整块 KV 要从显存搬到内存等新一轮计算时再搬回来期间整条推理流水线都要停顿。压测脚本视角看到的现象就是TTFT 突然从几十毫秒跳到几秒紧接着请求超时或 OOM。所以 KV Cache 的分配方式直接决定高并发压测能跑多深这也是把 PagedKV 拉进对照实验的核心原因。2. 从 Request 粒度到 Step 粒度调度器为什么必须拆开 PagedKV静态 Batching 的粒度是整个 RequestContinuous Batching 把粒度收窄到 Decode 的一个 Step。这个改动听起来不大实际上改变了 GPU 每一轮迭代能塞进多少有效计算。2.1 Prefill 和 Decode 的计算性格相反Prefill 阶段是一次性把整段 prompt 吃进去做大矩阵乘法典型的计算密集型Tensor Core 能保持高利用率。Decode 阶段每生成一个 Token都要把模型权重和前面所有 Token 的 KV Cache 重新读一遍模型权重太大显存带宽成为天花板属于典型的内存带宽密集型。两类阶段的计算性格完全相反如果调度器按 Request 粒度把它们捆在一起Decode 阶段带宽吃紧时等待中的 Prefill 请求也不能插队GPU 的算力就空转了。Continuous Batching 的做法是在每个 Decode Step 结束后检查 BatchEOS 请求立刻出队并释放 KV Block同时从等待队列里补进新的 Prefill 请求填进刚空出来的 Slot 里。这样每一轮迭代 GPU 都在做有效计算而不是干等。2.2 Continuous Batching 与页表映射的配合PagedAttention 解决的是 KV Cache 的物理存储问题。它把 KV Cache 切成固定大小的 Block比如 16 个 Token 一个 Block再用一张物理 Block 表记录逻辑页到物理页的映射。请求不再需要连续显存碎片问题从根上被绕开。调度链路可以归纳成一条清晰的决策线请求入队记录到达时间调度器检查等待队列是否超过熔断阈值尝试为队首请求分配 KV Block分配失败就停止补入优先保护运行中的请求成功则进入 running batch每个 Decode Step 生成一个 Token检查 EOSEOS 请求出队并归还 Block空出的 Slot 让后续 Prefill 请求插入。这套逻辑把每毫秒的 GPU Slot 都放进管控范围静态 Batching 的等待浪费也被压缩到最小。3. 让 Codex 写调度器与压测脚本先把它指到 TaoToken调度器的理论清楚了代码部分交给 Codex 来写。我让 Codex 先读原文第 3 节里 ContinuousBatchScheduler 和 BlockAllocator 的实现逻辑再按同样的调度语义生成一版可运行的 Python 原型。Codex 本身通过 TaoToken 接入这样才能在长并发下稳定工作。3.1 Codex 接入 TaoToken 的配置配置前先准备两样东西打开 TaoToken 注册并创建 API Key记下 YOUR_API_KEY再到 TaoToken 模型广场查当前可用的模型 ID。模型 ID 不要照抄旧教程里的固定名字以 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_end 模型广场当时列表为准。先用 TaoToken 的 CLI 快速验证 Key 和模型 ID 是否可用再改 Codex 配置能少走弯路npm install -g taotoken/taotoken taotoken cc -k YOUR_API_KEY -u https://taotoken.net/api -m 模型广场上的模型IDCLI 能正常返回说明 Key 有效。接下来配置 Codex让它的模型请求走 TaoToken 通道export OPENAI_API_KEYYOUR_API_KEY在~/.codex/config.toml里加一个自定义 providermodel 模型广场上的模型ID model_provider taotoken [model_providers.taotoken] name TaoToken base_url https://taotoken.net/api wire_api chat这里的 base_url 填 https://taotoken.net/api末尾不要带 /v1。Codex 会自动按 OpenAI 兼容路径拼接请求。如果你本机 Codex 版本的配置字段稍有差异以 Codex 文档为准base_url 和 wire_api 这两个字段的作用就是把模型请求指到 TaoToken 的兼容通道。排障先看两件事。Codex 里报 401多半是 OPENAI_API_KEY 没 export或者环境变量名拼写不一致CLI 能通而 Codex 不通时优先检查这一步。报模型不存在基本是 model ID 过期回模型广场复制当前可用值再填。注意不要把 https://taotoken.net 这种官网地址填进 base_urlCodex 需要的是接口地址 https://taotoken.net/api 。3.2 让 Codex 生成 PagedAttention 调度器原型配置完成后我可以直接让 Codex 按原文调度器的行为生成代码省去手动翻译伪代码的时间。下面这段是我让 Codex 生成并通过本地测试的调度器原型只保留核心语义方便对照检查import threading import time class BlockAllocator: def __init__(self, total_blocks, block_size16): self.free_blocks set(range(total_blocks)) self.block_size block_size self.lock threading.Lock() def allocate(self, num_blocks): with self.lock: if len(self.free_blocks) num_blocks: raise MemoryError(KV Block 不足触发显存碎片保护) return [self.free_blocks.pop() for _ in range(num_blocks)] def free(self, block_ids): with self.lock: for block_id in block_ids: self.free_blocks.add(block_id) class ContinuousBatchScheduler: def __init__(self, max_batch_size, allocator): self.max_batch_size max_batch_size self.allocator allocator self.waiting [] self.running [] self.kv_map {} def add_request(self, req): if len(self.waiting) 500: return False # 队列积压熔断 self.waiting.append(req) return True def step(self): finished [] while len(self.running) self.max_batch_size and self.waiting: req self.waiting[0] blocks_needed ( len(req.prompt) self.allocator.block_size - 1 ) // self.allocator.block_size try: blocks self.allocator.allocate(blocks_needed) except MemoryError: break # 显存不够时停止补入优先保护运行中请求 self.kv_map[req.id] blocks self.running.append(self.waiting.pop(0)) for req in self.running: req.generated.append(101) # 模拟生成一个 token if len(req.generated) req.max_tokens: finished.append(req) for req in finished: self.running.remove(req) self.allocator.free(self.kv_map.pop(req.id, [])) return [req.id for req in finished]调度器原型本身不改动 TaoToken 的服务端它只是在本地复现 Continuious Batching 与 PagedAttention 的调度行为。要拿到真实 TTFT 和 TPOT 数据还需要一个客户端压测脚本把请求真正发到模型接口上同时把 token 消耗记录下来。4. Python 脚本生成 Poisson 流量TTFT/TPOT 采集与 token 记账压测脚本要能模拟真实并发到达模式。原文用 vegeta 和自定义 Python 脚本生成 Poisson 到达流量Poisson 过程的特点是到达间隔随机且相互独立更接近线上真实请求。这里我用 Python 指数分布间隔来模拟等价过程顺便在每次响应里把 usage 字段累积起来。4.1 TTFT 与 TPOT 的含义TTFT 是 Time To First Token从请求发出到流式返回第一个 Token 的时间它包含排队等待和 Prefill 阶段计算TPOT 是 Time Per Output Token指后续生成每个 Token 的平均耗时对应 Decode 阶段一轮迭代的时间。这两个指标分开记录比只看总延迟更能定位瓶颈。TTFT 异常高说明排队或 Prefill 有问题TPOT 异常高说明 Decode 阶段或 KV Cache 读取出了状况。4.2 压测脚本Poisson 到达与 usage 累计脚本每次调用都记录 TTFT、TPOT并把响应里的 prompt_tokens 和 completion_tokens 累加形成一个随实验进度更新的 token 消耗表。请求走 https://taotoken.net/api 模型 ID 从 TaoToken 模型广场复制。import json import time import numpy as np import requests BASE_URL https://taotoken.net/api API_KEY YOUR_API_KEY MODEL_ID 模型广场上的模型ID def poisson_sleep(rate): # 指数间隔等价于 Poisson 到达过程 time.sleep(np.random.exponential(1.0 / rate)) def one_call(session, prompt, max_tokens): start time.time() first_token_at None usage None with session.post( f{BASE_URL}/chat/completions, headers{Authorization: fBearer {API_KEY}}, json{ model: MODEL_ID, messages: [{role: user, content: prompt}], max_tokens: max_tokens, stream: True, stream_options: {include_usage: True}, }, streamTrue, ) as resp: for raw in resp.iter_lines(): if not raw: continue if first_token_at is None: first_token_at time.time() line raw.decode(utf-8) if not line.startswith(data:): continue payload line[5:].strip() if payload [DONE]: break chunk json.loads(payload) if chunk.get(usage): usage chunk[usage] end time.time() ttft_ms (first_token_at - start) * 1000 completion usage[completion_tokens] if usage else 1 tpot_ms ((end - first_token_at) / max(1, completion)) * 1000 return ttft_ms, tpot_ms, usage def run(rate, rounds, prompt, max_tokens): session requests.Session() ttfts, tpots [], [] total_prompt 0 total_completion 0 for _ in range(rounds): poisson_sleep(rate) ttft, tpot, usage one_call(session, prompt, max_tokens) ttfts.append(ttft) tpots.append(tpot) if usage: total_prompt usage[prompt_tokens] total_completion usage[completion_tokens] print(json.dumps({ ttft_ms: round(ttft, 2), tpot_ms: round(tpot, 2), usage: usage, })) print(json.dumps({ total_prompt_tokens: total_prompt, total_completion_tokens: total_completion, requests: rounds, })) return ttfts, tpots运行时把 rate、rounds、prompt 按实验档位替换。rate 代表每秒平均请求数rounds 代表这一档的总请求数。流式响应的 usage 字段由 stream_options 里的 include_usage 触发如果个别接口版本不返回 usage就以 TaoToken 控制台的用量记录为准。4.3 对照实验记录表压测脚本只负责采集对照逻辑要按原文第 4 节的实验设计组织。每一档负载分别跑静态 Batching 与 Continuous PagedKVTTFT、TPOT、吞吐、显存和 token 消耗逐项记录。调度器原型在本地分别加载两种策略压测脚本打向同一个 API 接口保证模型和网络环境一致。负载档位调度策略TTFT P50/P99TPOT P50/P99吞吐 Tokens/sGPU 显存/碎片失败与 OOMtoken 消耗合计基线静态 Batching 连续 KV记录记录记录记录记录累计基线Continuous PagedKV记录记录记录记录记录累计中档静态 Batching 连续 KV记录记录记录记录记录累计中档Continuous PagedKV记录记录记录记录记录累计容量边界静态 Batching 连续 KV记录记录记录记录是否 OOM累计容量边界Continuous PagedKV记录记录记录记录是否 OOM累计token 消耗合计这一列是本次实验额外关注的重点。它把「调度策略是否有效」和「实际算力开销」放在同一张表里看Continuous PagedKV 的 TTFT/TPOT 改善了多少对应的 completion_tokens 有没有明显变化两者交叉验证避免只看延迟得出片面结论。5. 降级防线与用量收口跑完去控制台对账单压测跑到容量边界时总会有几档出现超时或 OOM。原文第 5 节给的思路是不要等服务彻底崩溃才介入而是在调度器里预埋降级策略。5.1 队列积压时的三道防线第一道是 Prompt 长度动态裁切。队列积压超过阈值时调度器自动调低最大可接受输入 Token 数对非核心请求的过长上下文做尾部剪枝或直接拒绝相当于给 Prefill 阶段限流。第二道是 Prefix Caching。固定 System Prompt 或 Agent 提示词的前缀 KV Cache 在显存里持久化共享重复请求可以跳过 Prefill减少计算密集阶段的压力。第三道是超时熔断请求等待超过 timeouut 阈值就放弃防止无意义的算力占用。这三道防线不需要全部开启压测时从宽松到严格逐步调参记录每一档的失败率和吞吐变化。降级策略生效时TTFT 可能轻微上升但整体存活率会明显改善。5.2 压测结束用控制台复核 token 账实验跑完后脚本会输出 total_prompt_tokens 和 total_completion_tokens。这两个数字要在 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_end 控制台的用量记录里对得上。如果脚本统计和后台记录差很远说明有请求中途断流或被限流跳过这本身也是一个有效的实验信号。对账确认后可以先在 TaoToken 模型对话 里用同一把 Key 发一条短消息确认模型 ID 和 base_url 仍然有效。长期写代码的话可以看 Coding Plan 的套餐是否够用需要新 Key 直接去 控制台 API Keys 创建。如果这次实验打算用 Claude Code 而不是 Codex 跑环境变量对照见 Claude Code 接入文档。调度器示例只是为了讲清 PagedAttention 和 Continuous Batching 的协作方式真实引擎还要处理 CUDA 分配失败、请求取消和页表回收路径。压测脚本也一样它能帮你把 TTFT、TPOT 和 token 消耗收集齐但显存是否真正安全仍需要在真实负载下多跑几档重复验证。做完这一步再回头看调度参数该往哪个方向调心里就有底了。
返回列表