服务层
用户看到的是 TTFT 和 token 间延迟,不是 FLOPs。API 层负责流式推送 SSE、处理取消,并导出你真正拿来调优的指标。
- SSE 流式输出
- 对话模板
- 请求取消
- TTFT/ITL/TPOT
- 负载均衡
为什么重要
问题
十八章的引擎,却还没有办法调用它。服务层是用户用来评判你的那一部分,相当多的生产事故也从这里起头。难的并不是 HTTP,而是引擎的种种性质会以别扭的方式从这一层漏出来。
回复要花几十秒,所以必须流式输出。客户端会在生成中途断开,引擎必须察觉,否则就会泄漏 KV block。对话模型需要把消息渲染成 prompt,并且用上它训练时的那套控制 token。而你在这里导出的指标,是你对上游一切唯一的可见性。
核心思路
解法
一个基于 server-sent events 的 OpenAI 兼容接口。在这里,兼容性比优雅更值钱:每一个客户端库、评测框架和代理都已经会说这门语言。
@app.post("/v1/chat/completions")
async def chat(req: ChatRequest, raw: Request):
prompt = apply_chat_template(req.messages, tokenizer)
async def stream():
try:
async for token_text in engine.generate(prompt, req.sampling):
if await raw.is_disconnected():
await engine.abort(req.id) # free the KV blocks
return
chunk = {"choices": [{"delta": {"content": token_text}}]}
yield f"data: {json.dumps(chunk)}\n\n"
yield "data: [DONE]\n\n"
finally:
# Runs on client disconnect, server shutdown and exceptions
# alike. This is the line that stops the memory leak.
await engine.abort(req.id)
return StreamingResponse(stream(), media_type="text/event-stream")那个 finally 块不是样板代码
工作原理
工作原理
对话模板是模型专属的,而且不留情面
对话模型是在某一种精确的会话序列化格式上微调出来的:特定的角色标记、特定的空白、结尾特定的生成提示。Llama-3、Qwen 和 Mistral 各不相同。
弄错了,模型照样会回答,只是永远略差一点。所以模板以 Jinja 字符串的形式放在 tokenizer 配置里,随模型本身一起分发,而不是硬编码在服务端。渲染这份模板就好,别自己拼字符串。
真正重要的指标
- TTFT —— 从请求到达那一刻起算,而不是从引擎开始处理它起算。排队时间恰恰是高负载下会出问题的那部分,把它排除掉,恰好就把你最需要看到的那种故障藏了起来。
- ITL / TPOT —— 相邻 token 之间的间隔:ITL 是各个间隔的分布,而 TPOT 是单个请求的平均值,即 (E2E − TTFT)/(token 数 − 1)。要报告分布:均值 20 ms 而 P99 900 ms 的系统是明显卡顿的,而均值永远不会告诉你。
- 吞吐 —— 总输出 token/秒。这是你的容量数字,也是与前两者互相拉扯的那一个。
- 队列深度、抢占率、缓存命中率 —— 先行指标。抢占率上升是 S10 里那种抖动最早的信号。
动手观察
动手试试
- TTFT(实测)
- —
- ITL(实测)
- —
- 输出 token/秒
- —
- 已用时
- 0.00 s
点上面的按钮
用户只用两个数字评判这个 API:第一个字出现前光标僵住了多久,以及之后的字是否稳定地到来。整个集群每秒多少 token 是你的指标,不是他们的。
(还没有事件)
Server-sent events:每个 token 一行 data:,最后以 data: [DONE] 结束。每个增量只携带新内容,而不是累积文本;拼接由客户端负责。
把并发拉到 128:每张 GPU 的吞吐好得多,而 TTFT 和 token 间延迟都明显变差。再关掉前缀缓存命中、换成 16k 的 prompt,TTFT 会压倒一切。这三个控件就是一个服务运维者拥有的全部调优面。
这个流是真的,时间来自一个会响应控件的延迟模型。分别在并发 1 和 128 下跑,看用户在意的那两个数字如何变差,换来的却是你看不见的吞吐。然后用 16k 的 prompt 关掉前缀缓存命中。TTFT 压倒一切,要论证 S07 的价值,没有比这更清楚的例子。
亲手实现
实现
停止字符串是最容易绊倒人的细节,因为它们定义在文本上,而生成发生在 token 上。
class StopChecker:
"""Stop strings are text; generation is tokens. Buffer accordingly."""
def __init__(self, stops: list[str]):
self.stops = stops
self.window = ""
self.keep = max((len(s) for s in stops), default=0)
def push(self, text: str) -> tuple[str, bool]:
self.window += text
for s in self.stops:
if (i := self.window.find(s)) != -1:
# Emit only what precedes the stop string, then finish.
return self.window[:i], True
if self.keep <= 1: # nothing can straddle a boundary
safe, self.window = self.window, ""
return safe, False
# Hold back the last keep-1 chars: a stop string may span chunks.
split = max(0, len(self.window) - (self.keep - 1))
safe, self.window = self.window[:split], self.window[split:]
return safe, False不要输出你可能需要撤回的文本
max(len(stop)) − 1 个字符扣住,直到确认它们是安全的。$ python code/s19_server.py只需要 NumPy — 查看环境准备.
生产实践
在生产环境中
- vLLM —— FastAPI + Uvicorn 暴露 chat、completions 和 embeddings,引擎跑在独立进程里,因此 HTTP 处理永远不会阻塞 GPU 循环。
- baseRT —— 一个 CLI 就能为多个模型提供 OpenAI 兼容的 HTTP API,这正是大多数本地部署想要的形态。
- llama.cpp server —— 同样的 API 面,装在一个 C++ 二进制里;「这东西最小能做到多小」的参考答案。
- 路由层 —— 在服务器之上,一个前缀缓存感知(而不是轮询)的负载均衡器,免得 S07 的缓存被部署拓扑毁掉。
练习
- 1
- 2加上按 key 的限流,以及一个基于队列深度的准入控制器:当无法在 SLO 内服务时返回 HTTP 429,而不是先接下来。
- 3写一个压测器,报告 TTFT 和 ITL 的分位数,然后用它找出你的 P99 token 间延迟越过 50 ms 时的并发数。那个数字才是你真正的容量。
继续学习
接下来
现在每个组件都齐了。S20 会把它们装成一个引擎,逐项做基准测试,看看这十九项技术中哪些真正配得上自己的位置。
自测
习题
先作答,再看解析。答错比答对更有价值,因为解析会指出你该回头重读哪一部分。
客户端在生成中途关掉了标签页,而没有任何东西中止该请求。后果是什么?