技术教程 · 阅读约 8 分钟

用 50 行 Python 搭一个本地 LLM 推理服务——vLLM 原理拆解 + 可运行代码

用 50 行 Python 搭一个本地 LLM 推理服务——vLLM 原理拆解 + 可运行代码

很多人以为"高性能 LLM 推理"是大厂专属,自己跑个本地服务就只能用 llama.cpp 凑合。

其实不然。vLLM 的核心思路——PagedAttention——并不复杂,理解之后你会发现,哪怕不自己实现,也能在调用 API 时做出更聪明的工程决策。


为什么普通推理这么慢?

传统推理框架的瓶颈在 KV Cache。

每个 token 生成时,模型需要保存之前所有 token 的 Key/Value 矩阵。问题是:你不知道这次对话最终会有多长,所以框架只能预先分配最大长度的显存。

结果就是:

  • 一个 4096 token 的上下文窗口,哪怕你只用了 200 token,剩下 3896 token 的显存也被占着
  • 并发 10 个请求,显存直接爆

vLLM 的 PagedAttention 借鉴了操作系统的虚拟内存分页思路:KV Cache 不再连续分配,而是按需分配固定大小的"页",用一张页表做映射。显存利用率从 20-40% 直接拉到 90%+。


动手:用 OpenAI 兼容接口做一个流式推理客户端

不需要自己部署 vLLM(那需要 GPU 服务器),我们直接用兼容 OpenAI 格式的 API 来演示流式输出 + 并发请求的工程实践。

原理是一样的:理解了 streaming 和 async 的配合,你就理解了推理服务的核心调用模式。

环境准备


pip install openai httpx asyncio

完整代码(50 行)


import asyncio

import time

from openai import AsyncOpenAI



# 换一行 base_url,国内直连,不需要代理

client = AsyncOpenAI(

    api_key="your-api-key",

    base_url="https://api2everything.xyz/v1"

)



async def stream_completion(prompt: str, request_id: int) -> dict:

    """单个流式请求,记录首 token 延迟和总耗时"""

    start = time.perf_counter()

    first_token_time = None

    full_response = []



    stream = await client.chat.completions.create(

        model="gpt-4o-mini",

        messages=[{"role": "user", "content": prompt}],

        stream=True,

        max_tokens=200,

    )



    async for chunk in stream:

        delta = chunk.choices[0].delta.content

        if delta:

            if first_token_time is None:

                first_token_time = time.perf_counter() - start

            full_response.append(delta)



    total_time = time.perf_counter() - start

    text = "".join(full_response)



    return {

        "id": request_id,

        "ttft": round(first_token_time or 0, 3),   # Time To First Token

        "total": round(total_time, 3),

        "tokens": len(text.split()),

        "preview": text[:60] + "..."

    }



async def benchmark_concurrent(prompts: list[str]):

    """并发发送多个请求,模拟真实负载"""

    print(f"并发发送 {len(prompts)} 个请求...\n")

    tasks = [stream_completion(p, i) for i, p in enumerate(prompts)]

    results = await asyncio.gather(*tasks)



    for r in results:

        print(f"[{r['id']}] TTFT={r['ttft']}s | 总耗时={r['total']}s | "

              f"词数≈{r['tokens']} | {r['preview']}")



    avg_ttft = sum(r['ttft'] for r in results) / len(results)

    print(f"\n平均首 token 延迟: {avg_ttft:.3f}s")



if __name__ == "__main__":

    prompts = [

        "用一句话解释 PagedAttention 的核心思想",

        "Python asyncio 和多线程的本质区别是什么",

        "KV Cache 为什么会成为 LLM 推理的瓶颈",

        "解释 speculative decoding 的工作原理",

    ]

    asyncio.run(benchmark_concurrent(prompts))

跑起来之后你会看到什么


并发发送 4 个请求...



[0] TTFT=0.312s | 总耗时=1.847s | 词数≈38 | PagedAttention 将 KV Cache 拆分为固定大小的...

[2] TTFT=0.298s | 总耗时=1.923s | 词数≈41 | KV Cache 存储每个 token 的注意力键值对,随序列...

[1] TTFT=0.334s | 总耗时=2.105s | 词数≈44 | asyncio 是单线程事件循环,多线程依赖 OS 调度...

[3] TTFT=0.445s | 总耗时=2.341s | 词数≈52 | Speculative decoding 用小模型草稿,大模型验证...



平均首 token 延迟: 0.347s

4 个请求并发,总耗时和单个请求差不多——这就是异步 IO 的价值,也是推理服务能扛高并发的基础。


几个值得注意的工程细节

TTFT vs 总延迟:用户感知的"快不快"主要看 TTFT(首 token 延迟),而不是总耗时。流式输出的意义就在这里——让用户感觉响应立刻开始了。

asyncio.gather vs asyncio.wait:gather 会等所有任务完成再返回,适合 benchmark;生产环境如果需要"先完成先处理",用 as_completed。

连接池复用:AsyncOpenAI 客户端内部维护了 HTTP 连接池,不要在循环里反复初始化 client,那会把连接池优势全部抵消。

错误处理:高并发下偶发 429(限流)很正常,加指数退避重试:


import random



async def retry_with_backoff(prompt, request_id, max_retries=3):

    for attempt in range(max_retries):

        try:

            return await stream_completion(prompt, request_id)

        except Exception as e:

            if attempt == max_retries - 1:

                raise

            wait = (2 ** attempt) + random.uniform(0, 1)

            await asyncio.sleep(wait)


关于模型选择

代码里用的是 gpt-4o-mini,做 benchmark 和原型验证够用。如果你要测试推理能力上限,可以换 deepseek-r1 或 claude-3-5-sonnet,接口格式完全一样,改一个字段就行。

国内访问官方 API 需要代理,而且 Claude/Gemini 的充值本身就是个麻烦事。我自己现在用的是无量Api,300+ 模型国内直连,价格比官方便宜 60% 左右,OpenAI 格式兼容,就是把上面代码里的 base_url 换成他们的地址,其他一行不改。注册还送 ¥1 余额,拿来跑这个 benchmark 完全够。


如果你在跑这段代码时遇到问题,或者想聊聊 PagedAttention 的具体实现细节,评论区见。

觉得有用的话点个赞,后续还会写 speculative decoding 和 continuous batching 的实战拆解。