从0开始搭建本地 AI 服务(学习用)

这篇教程的目标不是"装个软件点两下",而是理解一个 AI 推理服务由哪些部分组成、每一部分在做什么。学完之后你会拥有:

  1. 一个完全离线、跑在自己电脑上的大模型服务,接口和 OpenAI 官方兼容;

  2. 能解释清楚推理服务的完整链路:请求进来后发生了什么、token 是什么、为什么显存会不够用。

一个"AI 服务"由什么组成

先把整个系统的骨架画出来,后面每一步都是往这张图里填东西:

+---------+   HTTP 请求(OpenAI 兼容)   +----------------+   权重加载    +------------+
|  客户端  | ------------------------> |    推理引擎     | <----------- |  模型文件   |
| (curl/  | <------------------------ | (llama.cpp/    |              | (GGUF 格式) |
|  网页/   |   HTTP 响应(SSE 流式)     |  Ollama/vLLM)  |              +------------+
| 你的程序)|                           +-------+--------+
+---------+                                   | 读写
                                       +------v-------+
                                       |  GPU / CPU   |
                                       +--------------+

四个组成部分:

  1. 模型文件:训练好的权重,本地部署通常用 GGUF 格式(后面细说);

  2. 推理引擎:把模型加载进显存、接收请求、生成 token 的程序。llama.cpp、Ollama、vLLM 都属于这一类;

  3. HTTP API:引擎对外暴露的服务接口。行业标准是 OpenAI 的 /v1/chat/completions——你的服务"兼容 OpenAI 接口",意味着任何 OpenAI SDK 都能直接连上来;

  4. 客户端:任何会发 HTTP 请求的东西——curl、网页、你自己的程序。

前置知识:token 与显存

token 是什么

模型不认字,只认数字。输入文本要先被 tokenizer(分词器) 切成一个个 token,每个 token 对应词表里的一个编号:

"北京天气怎么样"  ->  [北京, 天气, 怎么, 样]   # 中文 1 个字 ≈ 1~2 个 token
"Hello world"     ->  [Hello, " world"]      # 英文 1 个词 ≈ 1.3 个 token

模型做的事情从头到尾只有一件:根据前面的 token,预测下一个 token。你的问题、模型的回答、甚至下一篇要讲的"调用工具"动作,全部都是 token 序列。

显存估算

模型要跑起来,权重必须全部装进显存(或内存)。粗略公式:

显存 ≈ 模型权重 + KV cache + 激活值(通常再预留 10%~20%)
模型权重 ≈ 参数量 × 每参数字节数(由量化精度决定)

7B 参数的模型,权重在 fp16 下是 7B × 2 字节 = 14GB。家用显卡(8~24GB)直接装不下,所以本地部署普遍用量化。

量化就是把权重从 fp16(每参数 2 字节)压缩到更低精度:

量化格式

每参数约

7B 模型大小

质量损失

备注

Q8_0

8 bit

~7.2 GB

极小

接近原始精度

Q6_K

6 bit

~5.9 GB

很小

显存紧张时的折中

Q4_K_M

4.5 bit

~4.7 GB

较小

日常使用最推荐

Q2_K

2.6 bit

~2.9 GB

明显

只有显存实在不够才用

备注

量化损失的是"细节"而不是"知识":数字精度变低,但模型的组织结构、语言能力还在。Q4_K_M 是社区公认的性价比甜点——质量几乎无感下降,体积砍到 1/3。

第一步:下载模型

安装 huggingface-cli

HuggingFace 是模型文件的事实标准仓库,官方提供了命令行工具:

pip install -U "huggingface_hub[cli]"

下载 GGUF 格式的模型

# 国内网络建议加这一行,走镜像站
export HF_ENDPOINT=https://hf-mirror.com

hf download Qwen/Qwen2.5-7B-Instruct-GGUF qwen2.5-7b-instruct-q4_k_m.gguf

说明:

  • Qwen/Qwen2.5-7B-Instruct-GGUF 是仓库名,社区把量化好的 GGUF 文件放在这种"模型名-GGUF"仓库里;

  • 后面是文件名,同一个仓库里通常有 Q2_K 到 Q8_0 各种量化版本,按上表的推荐选 Q4_K_M;

  • 本教程统一用 Qwen2.5-7B-Instruct 作为示例模型:7B 大小适合学习和家用硬件,中文能力强,支持工具调用(下一篇 Agent 教程要用)。

safetensors 与 GGUF 的区别

下载模型时你会见到两种格式:

格式

用途

形态

safetensors

训练/微调/研究

一个目录,多个分片文件,权重未量化或半精度

GGUF

推理部署

单文件,内嵌 tokenizer + 量化权重 + 元数据

GGUF 是 llama.cpp 生态的标准格式:一个文件包含跑模型所需的全部东西,拷贝到任何机器上直接就能推理。

第二步:从源码编译 llama.cpp

Ollama 之类的工具可以一键装好一切,但既然是"从0开始",我们先亲手编译推理引擎,看看一个推理服务最简形态是什么样。

llama.cpp 是 Georgi Gerganov 开源的 C/C++ 推理框架,目标是"让大模型跑在消费级硬件上"。整个项目没有 Python 依赖,编译产物里有一个 llama-server 程序,就是我们要的推理服务。

git clone https://github.com/ggml-org/llama.cpp
cd llama.cpp

# 有 NVIDIA 显卡:开 CUDA 支持
cmake -B build -DGGML_CUDA=ON

# 编译(-j 并行,数量填 CPU 核心数)
cmake --build build --config Release -j

没有显卡也可以继续:去掉 -DGGML_CUDA=ON 就是纯 CPU 推理,7B 模型能跑,只是慢(每秒几个 token)。

小技巧

编译完成后 build/bin/ 下有几个工具,和我们的主题相关的是:

  • llama-server:推理服务(HTTP API);

  • llama-cli:命令行交互式推理;

  • llama-quantize:把 fp16 模型量化成 GGUF。

可选:亲手把模型量化成 GGUF

如果你不只满足于"下载现成的量化文件",可以自己走一遍量化流程:

# 1. 下载 fp16 的原始模型(safetensors 格式,约 15GB)
hf download Qwen/Qwen2.5-7B-Instruct --local-dir Qwen2.5-7B-Instruct

# 2. 转换成 GGUF(需要 pip install -r requirements.txt,里面主要是 torch)
python convert_hf_to_gguf.py Qwen2.5-7B-Instruct --outfile qwen2.5-7b-f16.gguf

# 3. 量化
./build/bin/llama-quantize qwen2.5-7b-f16.gguf qwen2.5-7b-q4_k_m.gguf Q4_K_M

每一步都在做什么:

  1. 下载的是模型"出厂状态"——fp16 权重,14GB;

  2. 转换只改文件格式(容器),权重数值不变;

  3. 量化才真正压缩数值:统计每层权重的分布,把 fp16 数字映射到 4.5 bit 的离散刻度上。信息有损,所以叫"量化损失"。

第三步:启动推理服务

./build/bin/llama-server \
    -m ./qwen2.5-7b-instruct-q4_k_m.gguf \
    --host 0.0.0.0 \
    --port 8000 \
    --ctx-size 8192 \
    --n-gpu-layers 99

启动时引擎会先加载权重(几秒到几十秒),之后终端会打印服务地址。8000 端口同时提供 HTTP API 和一个网页聊天界面(浏览器打开 http://localhost:8000 即可对话)。加 --metrics 参数还会在 /metrics 暴露 Prometheus 格式的监控指标(生产部署篇会用到)。

参数逐个解释:

参数

含义

-m

模型文件路径

--host

监听地址。0.0.0.0 表示接受局域网访问,只本机用可以写 127.0.0.1

--port

HTTP 端口

--ctx-size

上下文窗口(能处理的 token 总数)。越大能聊的历史越长,但 KV cache 吃显存越多

--n-gpu-layers

放到 GPU 上的层数。99 表示"尽量全部放 GPU"

-t

CPU 线程数(纯 CPU 推理时才有意义)

备注

--n-gpu-layers 背后的原理:模型由几十个结构相同的"层"堆叠而成(7B 模型 28 层)。显存足够时全部放 GPU;显存不够时可以把一部分层放回内存用 CPU 算——速度会断崖式下降,但至少跑得起来。这是消费级显卡上跑大模型的常用手段。

验证服务:curl 测试

# 查看服务上的模型
curl http://localhost:8000/v1/models

# 发起一次对话
curl http://localhost:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "qwen2.5-7b-instruct",
    "messages": [
      {"role": "user", "content": "用一句话解释什么是KV cache"}
    ],
    "temperature": 0.7
  }'

响应长这样(节选):

{
  "choices": [{
    "message": {
      "role": "assistant",
      "content": "KV cache 是推理时缓存已计算过的键值对,避免重复计算的机制。"
    }
  }],
  "usage": {
    "prompt_tokens": 14,
    "completion_tokens": 25,
    "total_tokens": 39
  }
}

值得注意的字段:

  • usage.prompt_tokens:你的输入被切成多少个 token;

  • usage.completion_tokens:回答生成了多少个 token;

  • 这是非流式响应:服务端把全部回答生成完才一次性返回。稍后会看到流式("stream": true)。

第四步:更省事的方案 Ollama

如果编译只是为了理解原理,日常使用可以换 Ollama——它内部同样基于 llama.cpp,但把下载、量化选型、模型管理、进程管理全部包掉了:

# 安装
curl -fsSL https://ollama.com/install.sh | sh

# 拉取模型(自动下载对应量化的 GGUF)
ollama pull qwen2.5:7b

# 启动服务(默认端口 11434)
ollama serve

Ollama 同样暴露 OpenAI 兼容接口,把上面 curl 的地址换成 http://localhost:11434/v1 即可。唯一的差别是请求里的 model 名要写成 Ollama 的标签(qwen2.5:7b)。

llama.cpp 手动编译

Ollama

适合

学习原理、深度定制、嵌入自己的程序

日常使用、快速体验

模型管理

手动下载管理 GGUF 文件

ollama pull/rm/list 自动管理

定制

完全自由(改源码都行)

Modelfile 定义系统提示词、参数

推理服务内部发生了什么

上面把服务跑起来了,这一节回答"请求进来之后,机器在干什么"。整条链路:

        flowchart TB
    A[HTTP 请求到达] --> B[tokenize 分词]
    B --> C[prefill 预填充<br/>一次性并行算完所有输入 token]
    C --> D[decode 解码<br/>逐个生成下一个 token]
    D --> E[采样器<br/>按概率从候选里挑一个]
    E --> F{生成完了?}
    F -- 否 --> D
    F -- 是 --> G[detokenize 还原成文本]
    G --> H[流式返回给客户端]
    

图示:一次"生成回答"其实是一个循环——每轮只多生成一个 token,把它拼回上下文,再来一轮,直到采样器选出结束符。

prefill 与 decode 是两个不同的阶段

  • prefill(预填充):把输入的所有 token 并行处理,一次性算出整个输入的表示。这个阶段 GPU 算力吃满,是计算密集;

  • decode(解码):一个一个地生成新 token。每生成一个,都要读取整个上下文的 KV cache,是显存带宽密集。

为什么区分这两个阶段?因为它们瓶颈完全不同,生产优化(比如连续批处理)正是利用了这个区别。

KV cache 为什么吃显存

注意力机制里,每个 token 都要"看"上下文里所有其他 token。为了不重复计算,已经算过的 K(键)和 V(值)会被缓存下来——这就是 KV cache。

每 token 的 KV cache = 2(K 和 V) × 层数 × KV头数 × 头维度 × 2字节(fp16)

以 Qwen2.5-7B 为例:2 × 28 × 4 × 128 × 2 = 57,344 字节 ≈ 56 KB/token。听着不大,但上下文一长:

8192 token 上下文 × 56 KB ≈ 450 MB/请求

所以"上下文窗口开多大"和"显存剩多少"是直接挂钩的:--ctx-size 8192 意味着每个请求最多预留 ~450MB 的 KV cache。显存不够时,第一个要砍的就是它。

为什么回答的第一个字要等那么久

  • 从请求到达,到第一个 token 出现的时间,叫 TTFT(Time To First Token)。TTFT 里主要是 prefill 的时间——输入越长,第一个字越慢;

  • 之后每个 token 的间隔叫 TPOT(Time Per Output Token),decode 阶段基本稳定。

这是所有大模型服务都有的现象,不是你的服务坏了。想确认速度是否正常,看 llama-server 的日志:每轮生成完成后终端会打印类似 eval time = 1.23s / 30 tokens (41 ms per token, 24 tokens per second) 的报告。

流式输出:SSE

服务端支持把答案"边生成边发回来",协议叫 SSE(Server-Sent Events):

curl http://localhost:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model": "qwen2.5-7b-instruct", "stream": true, "messages": [{"role": "user", "content": "你好"}]}'

响应是一条条 data: 行,每行一个增量 token,最后以 data: [DONE] 结束。你用的聊天网页"打字机效果"就是靠它实现的。生产环境里流式输出还有一层实际意义:用户不用干等完整回答,TTFT 之后内容就持续可见,体感延迟大幅下降。

常见问题排查

显存不足(OOM)

启动时或生成到一半报 CUDA out of memory。按损失从小到大的顺序处理:

  1. 减小 --ctx-size(砍 KV cache,见效最快);

  2. 换更激进的量化(如 Q4_K_S,注意和 Q4_K_M 是不同文件);

  3. 减少 --n-gpu-layers,把部分层放 CPU(最慢,但肯定能跑)。

生成内容乱码/明显截断

  • 上下文不够:输入+输出超过了 --ctx-size 设置,提高它(前提是显存够);

  • 温度参数不合理:temperature 越高输出越随机,写代码、事实问答时调低到 0.1~0.3。

速度比预期慢很多

  • 确认模型真的跑在 GPU 上:nvidia-smi 看显存占用和 GPU 利用率;

  • 纯 CPU 推理时 7B 模型每秒几个 token 是正常水平,想快就得用 GPU。

下一步

现在你有了一个自己的 AI 服务,但它只会"说话",不会"做事"。下一篇 从0开始搭建一个 Agent 会给它装上工具,让它能执行命令、查询信息、自主完成多步任务。