# 从0开始搭建本地 AI 服务(学习用)
```{contents}
```
这篇教程的目标不是"装个软件点两下",而是**理解一个 AI 推理服务由哪些部分组成、每一部分在做什么**。学完之后你会拥有:
1. 一个完全离线、跑在自己电脑上的大模型服务,接口和 OpenAI 官方兼容;
2. 能解释清楚推理服务的完整链路:请求进来后发生了什么、token 是什么、为什么显存会不够用。
```{toctree}
:maxdepth: 2
```
## 一个"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 | 明显 | 只有显存实在不够才用 |
```{note}
量化损失的是"细节"而不是"知识":数字精度变低,但模型的组织结构、语言能力还在。Q4_K_M 是社区公认的性价比甜点——质量几乎无感下降,体积砍到 1/3。
```
## 第一步:下载模型
### 安装 huggingface-cli
HuggingFace 是模型文件的事实标准仓库,官方提供了命令行工具:
```bash
pip install -U "huggingface_hub[cli]"
```
### 下载 GGUF 格式的模型
```bash
# 国内网络建议加这一行,走镜像站
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](https://github.com/ggml-org/llama.cpp) 是 Georgi Gerganov 开源的 C/C++ 推理框架,目标是"让大模型跑在消费级硬件上"。整个项目没有 Python 依赖,编译产物里有一个 `llama-server` 程序,就是我们要的推理服务。
```bash
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)。
```{tip}
编译完成后 `build/bin/` 下有几个工具,和我们的主题相关的是:
- `llama-server`:推理服务(HTTP API);
- `llama-cli`:命令行交互式推理;
- `llama-quantize`:把 fp16 模型量化成 GGUF。
```
### 可选:亲手把模型量化成 GGUF
如果你不只满足于"下载现成的量化文件",可以自己走一遍量化流程:
```bash
# 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 的离散刻度上。信息有损,所以叫"量化损失"。
## 第三步:启动推理服务
```bash
./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 推理时才有意义) |
```{note}
`--n-gpu-layers` 背后的原理:模型由几十个结构相同的"层"堆叠而成(7B 模型 28 层)。显存足够时全部放 GPU;显存不够时可以把一部分层放回内存用 CPU 算——速度会断崖式下降,但至少跑得起来。这是消费级显卡上跑大模型的常用手段。
```
### 验证服务:curl 测试
```bash
# 查看服务上的模型
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
}'
```
响应长这样(节选):
```json
{
"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](https://ollama.com/)——它内部同样基于 llama.cpp,但把下载、量化选型、模型管理、进程管理全部包掉了:
```bash
# 安装
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 定义系统提示词、参数 |
## 推理服务内部发生了什么
上面把服务跑起来了,这一节回答"请求进来之后,机器在干什么"。整条链路:
```{mermaid}
flowchart TB
A[HTTP 请求到达] --> B[tokenize 分词]
B --> C[prefill 预填充
一次性并行算完所有输入 token]
C --> D[decode 解码
逐个生成下一个 token]
D --> E[采样器
按概率从候选里挑一个]
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):
```bash
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](./agent.md) 会给它装上工具,让它能执行命令、查询信息、自主完成多步任务。