跳到正文
LLM 推理
S19分布式服务·363

服务层

用户看到的是 TTFT 和 token 间延迟,不是 FLOPs。API 层负责流式推送 SSE、处理取消,并导出你真正拿来调优的指标。

  • SSE 流式输出
  • 对话模板
  • 请求取消
  • TTFT/ITL/TPOT
  • 负载均衡

为什么重要

问题

十八章的引擎,却还没有办法调用它。服务层是用户用来评判你的那一部分,相当多的生产事故也从这里起头。难的并不是 HTTP,而是引擎的种种性质会以别扭的方式从这一层漏出来。

回复要花几十秒,所以必须流式输出。客户端会在生成中途断开,引擎必须察觉,否则就会泄漏 KV block。对话模型需要把消息渲染成 prompt,并且用上它训练时的那套控制 token。而你在这里导出的指标,是你对上游一切唯一的可见性。

核心思路

解法

一个基于 server-sent events 的 OpenAI 兼容接口。在这里,兼容性比优雅更值钱:每一个客户端库、评测框架和代理都已经会说这门语言。

流式端点python
@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 块不是样板代码

如果客户端在生成中途关掉标签页,而没有任何东西中止该请求,引擎就会继续往一个没人在读的 socket 里解码,并一直占着它的 KV block,直到撞上 token 上限。在一群没耐心的用户面前,这是一个表现得像容量问题的内存泄漏,而加 GPU 修不好它。

工作原理

工作原理

示意图请求进来,token 出去
请求路径HTTP POST/v1/chat/completions校验参数、上限chat templatemessages → prompt分词文本 → id引擎队列S10 调度器响应路径 —— 每个 TOKEN 走一遍引擎步1 个 token解码增量、UTF-8 安全停止检查eos、停止串、预算SSE deltadata: {...}\n\n重复直到停止要导出的三个数字TTFT排队 + prefill + 首 tokenITL / TPOTtoken 之间的间隔吞吐整个集群的 token/s盯分位数,别盯均值。P50 是 200 ms 而 P99 是 8 s 的系统,大多数用户会认为它坏了。排队时间必须算进 TTFT。从「引擎开始」计时,恰好会盖住你最需要看到的那个故障。取消客户端断开中止 → 释放 KV block漏掉这一步,用户每刷新一次你就泄漏一次显存。
响应路径每个 token 跑一次。里面的一切都必须便宜,而 detokenizer 必须是增量式的。

对话模板是模型专属的,而且不留情面

对话模型是在某一种精确的会话序列化格式上微调出来的:特定的角色标记、特定的空白、结尾特定的生成提示。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] 结束。每个增量只携带新内容,而不是累积文本;拼接由客户端负责。

延迟预算
TTFT
decode(22 个 token)

把并发拉到 128:每张 GPU 的吞吐好得多,而 TTFT 和 token 间延迟都明显变差。再关掉前缀缓存命中、换成 16k 的 prompt,TTFT 会压倒一切。这三个控件就是一个服务运维者拥有的全部调优面。

这个流是真的,时间来自一个会响应控件的延迟模型。分别在并发 1 和 128 下跑,看用户在意的那两个数字如何变差,换来的却是你看不见的吞吐。然后用 16k 的 prompt 关掉前缀缓存命中。TTFT 压倒一切,要论证 S07 的价值,没有比这更清楚的例子。

亲手实现

实现

停止字符串是最容易绊倒人的细节,因为它们定义在文本上,而生成发生在 token 上。

code/s19_server.py(节选)python
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

不要输出你可能需要撤回的文本

一个停止字符串可能横跨两个 token。贪婪地输出、事后才发现停止串,客户端早已渲染出本不该发送的字符,而 SSE 没有撤销。把最后 max(len(stop)) − 1 个字符扣住,直到确认它们是安全的。
在本地运行
仅用标准库运行一个真实的 HTTP 服务器,暴露 OpenAI 兼容的流式接口,包含对话模板、跨 token 边界的停止字符串、请求取消和一个指标接口。还会跑一次压测,报告 TTFT 和 ITL 的分位数。
$ python code/s19_server.py
预期输出: 一个在 localhost:8000 上、能被 curl 和任意 OpenAI 客户端库使用的服务器;一段演示取消会释放 KV block;以及一次并发扫描:吞吐上升而两个用户可见延迟都在变差。

只需要 NumPy — 查看环境准备.

生产实践

在生产环境中

  • vLLM —— FastAPI + Uvicorn 暴露 chat、completions 和 embeddings,引擎跑在独立进程里,因此 HTTP 处理永远不会阻塞 GPU 循环。
  • baseRT —— 一个 CLI 就能为多个模型提供 OpenAI 兼容的 HTTP API,这正是大多数本地部署想要的形态。
  • llama.cpp server —— 同样的 API 面,装在一个 C++ 二进制里;「这东西最小能做到多小」的参考答案。
  • 路由层 —— 在服务器之上,一个前缀缓存感知(而不是轮询)的负载均衡器,免得 S07 的缓存被部署拓扑毁掉。

练习

  1. 1
    S06 的写时复制 block 共享实现 n>1 参数,并验证四份采样消耗的 KV 大约是一份 prompt 而不是四份。
  2. 2
    加上按 key 的限流,以及一个基于队列深度的准入控制器:当无法在 SLO 内服务时返回 HTTP 429,而不是先接下来。
  3. 3
    写一个压测器,报告 TTFT 和 ITL 的分位数,然后用它找出你的 P99 token 间延迟越过 50 ms 时的并发数。那个数字才是你真正的容量。

继续学习

接下来

现在每个组件都齐了。S20 会把它们装成一个引擎,逐项做基准测试,看看这十九项技术中哪些真正配得上自己的位置。

自测

习题

先作答,再看解析。答错比答对更有价值,因为解析会指出你该回头重读哪一部分。

自测 1 题 / 共 6

客户端在生成中途关掉了标签页,而没有任何东西中止该请求。后果是什么?

得分 0/6