1. 先想清楚再动手:这套组合到底解决了什么问题
在 Ubuntu 22.04 上用 Docker 加 FastChat 部署大模型 LLM,本质上是在解决三个互相牵连的问题:环境能不能一次装对、服务能不能拆开单独扩、接口能不能被现有业务直接吃下去。我见过太多人卡在第一步——驱动版本和 CUDA 对不上,或者 pip 装到一半依赖冲突,最后把整台机器搞成“半可用”状态,重装又舍不得。容器化就是为了把这堆不确定性关进盒子里,让宿主机只负责驱动和显卡,其余全部由镜像描述。
FastChat 在这个链条里的角色,很多人一开始会误解。它不是推理引擎,更像一个“调度中枢加协议适配层”。它把模型加载、对话模板、会话管理、OpenAI 兼容接口这几件事拆成独立进程:controller 管全局状态,model_worker 真正吃显存,api_server 对外提供 HTTP 接口,web server 提供可视化界面。这种拆分带来的好处是,你可以四个进程塞一台机器,也可以 controller 放一台、worker 放另一台。代价是多了一层网络通信,配置文件写错一个地址就全线不通,这也是后面排查问题的重灾区。
适合谁看这篇内容?手里有一张或几张消费级、专业级显卡,想在内部环境跑一个可控的对话服务,需要 OpenAI 风格接口对接现有系统,同时又不希望每次升级都重装系统的同学。如果你只是想在自己笔记本上玩两句,其实没必要上这套,直接跑单进程更省事。这套方案的甜点区是“有明确并发需求、需要长期挂着、需要多模型切换”的场景。
先说结论性的判断:Ubuntu 22.04 是当前比较稳的底座,内核 5.15 对主流显卡驱动支持成熟,容器运行时生态也完整;Docker 负责隔离和编排;FastChat 负责协议与调度;真正的算力交给底层推理后端。这四层各司其职,任何一层出问题,症状都长得不一样,所以排查时一定要先定位层级,别一上来就怀疑模型坏了。
2. 方案选型:为什么是这三件套,而不是别的
2.1 三个组件各管一段,职责不能混
把部署拆成“宿主机层、容器层、调度层、推理层”四段来看,思路会清楚很多。宿主机层只装显卡驱动和容器运行时,不装 Python、不装 CUDA 工具链的完整版,保持干净;容器层用镜像锁定依赖版本,一次构建到处运行;调度层由 FastChat 的四个进程组成;推理层根据显卡和模型规模在 transformers、vLLM、SGLang 之间选。
很多人把 FastChat 和推理引擎对立起来,其实它是可以“带后端”的。启动 model_worker 时加--worker参数,就能把实际计算交给 vLLM 之类的引擎,FastChat 只做协议转换和请求分发。这个设计非常关键:小规模试跑用默认后端足够,追求吞吐时换 vLLM 后端,上层接口一行不用改。我在实际项目里就是先用默认后端把链路跑通,确认接口、模板、鉴权都对了,再换后端做压测,避免一次改两个变量。
为什么不直接用裸机 pip 装?原因很现实:FastChat 的依赖树不浅,transformers、torch、accelerate、fastapi、uvicorn 加上各种 tokenizer 相关包,版本窗口很窄。裸机装完,过两个月想升级某个包,很可能把另一个包顶掉。容器把这份脆弱性封起来,镜像 tag 就是一份可回滚的快照。我习惯给镜像打两个 tag,一个语义版本号,一个日期,出问题直接切回上一个日期 tag。
2.2 显存这笔账,必须在拉模型之前算完
部署失败最常见的原因不是配置写错,是显存不够还不自知。模型权重的显存占用有个粗略公式:参数量 × 每参数字节数。fp16 是 2 字节,int8 是 1 字节,int4 约 0.5 字节。一个 7B 模型 fp16 大约 13.5GB,13B 大约 26GB,再往上单卡就很难受了。
真正容易被忽略的是 KV Cache。它跟上下文长度线性相关,公式是:2(K 和 V)× 层数 × KV 头数 × 头维度 × 字节数 × token 数。以一个 32 层、32 个 KV 头、头维度 128 的 7B 模型为例,单 token 的 KV 占用约 0.5MB,8192 token 就是 4GB。也就是说,光“能装下权重”不等于“能跑满上下文”。
| 模型规模 | fp16 权重 | 8K 上下文 KV 估算 | 框架与激活开销 | 建议单卡显存 |
|---|---|---|---|---|
| 7B | 约 13.5GB | 约 4GB | 2 到 3GB | 24GB 起 |
| 13B | 约 26GB | 约 5GB | 3 到 4GB | 40GB 或双卡 |
| 34B int4 | 约 18GB | 约 4GB | 3GB | 24GB 紧张,建议 48GB |
这张表不是为了精确,是为了让你在下载几十 GB 权重之前先做个判断。我踩过的坑是:一张 24GB 卡跑 7B fp16,权重加载完还剩 10GB,看着很宽裕,结果并发三个请求、每个 8K 上下文,直接把显存打爆,进程被系统杀掉,日志里只留下一行看不出原因的退出码。后来把max-model-len降到 4096,并发限制到 2,才稳定下来。
2.3 和其它路线的取舍
单进程直接跑推理引擎,最省事,但没有会话管理和多模型路由;用现成的模型服务平台,功能全但定制空间小;自己写一层 FastAPI 包推理引擎,灵活但会话、模板、流式这些细节要自己填,工作量不小。FastChat 的位置在中间:它把“会话、模板、OpenAI 协议、多 worker 调度”这些共性问题做完了,同时保留换后端的口子。
选它的另一个理由是生态契合。市面上大量客户端、SDK、编排工具都认 OpenAI 那套/v1/chat/completions协议,FastChat 的 api_server 正好提供这个。你的前端、Agent 框架、评测脚本几乎零改动就能接上。这一点在实际交付时价值极高,改协议比改部署难多了。
3. 宿主机准备:驱动、容器运行时和目录规划
3.1 驱动与 CUDA 兼容性先核对,别急着装 Docker
第一步永远是确认显卡被识别。跑lspci | grep -i vga看设备在不在,再跑nvidia-smi看驱动版本和 CUDA 版本。注意nvidia-smi右上角显示的 CUDA 版本是“驱动支持的最高运行时版本”,不是已安装的 CUDA 版本,这个误解我见过太多次。容器里用的 CUDA 是镜像自带的,宿主机只需要驱动足够新。
驱动版本和镜像 CUDA 版本的对应关系要查官方兼容表,原则是驱动支持的上限要大于等于镜像里的 CUDA 版本。比如镜像基于 CUDA 12.1,驱动就得支持到 12.1 以上。版本不够时,症状是容器启动后nvidia-smi报 “Failed to initialize NVML: Driver/library version mismatch”,或者推理时直接抛 CUDA 初始化错误。
# 核对显卡与驱动 lspci | grep -i vga nvidia-smi # 查看内核版本,22.04 一般是 5.15 系列 uname -r # 确认没有残留的 nouveau 干扰 lsmod | grep nouveau需要提醒的是,装驱动尽量走系统包管理器或者官方提供的.run安装包,二选一,不要混用。混用的后果是升级内核后驱动模块没重新编译,机器重启直接黑屏进不了图形界面。我一般建议在纯命令行环境部署,省掉图形界面对驱动模块的额外依赖,也省显存。
3.2 Docker 与 GPU 容器运行时
Docker 本身不认识显卡,需要额外装一层容器运行时,把宿主机的显卡设备透传进容器。这一步现在比几年前简单很多,装好运行时之后跑一个测试容器,能打印出显卡信息就算通了。
# 安装基础依赖 sudo apt-get update sudo apt-get install -y ca-certificates curl gnupg # 添加 Docker 官方仓库并安装 sudo install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \ sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | \ sudo tee /etc/apt/sources.list.d/docker.list sudo apt-get update sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin # 把当前用户加入 docker 组,避免每条命令都要 sudo sudo usermod -aG docker $USER仓库地址如果拉取慢,可以在/etc/docker/daemon.json里配置可用的镜像仓库地址,然后重启服务。这一步属于网络环境差异,按自己实际情况处理,配完用docker info确认生效。
GPU 运行时这边,配好之后会多出一个nvidia运行时,docker run --gpus all才能生效。验证方式很直接:
docker run --rm --gpus all nvidia/cuda:12.1.0-base-ubuntu22.04 nvidia-smi输出里能看到显卡型号和显存,就说明宿主机到容器这条路通了。这一步不通,后面所有工作都是白费,务必先过。
注意:不要在容器里再装一遍驱动。容器只需要运行时透传的设备节点和库,装驱动会污染镜像并且几乎必然失败。
3.3 目录与权限:把模型和配置从镜像里摘出来
模型权重动辄十几 GB,绝对不能打进镜像。我的习惯是在宿主机建一个统一的工作目录,把模型、配置、日志、缓存分开,用卷挂载进容器。这样镜像可以随便重建,模型不用重下。
| 目录 | 用途 | 挂载点 |
|---|---|---|
/data/llm/models | 模型权重,只读挂载 | /models |
/data/llm/config | 服务配置与 compose 文件 | /config |
/data/llm/logs | 各进程日志输出 | /logs |
/data/llm/cache | HuggingFace 缓存 | /root/.cache/huggingface |
权限问题很隐蔽。容器内默认以 root 跑,挂载出来的目录属主会变成 root,宿主机上的普通用户就改不动了。解决办法有两个:要么启动时加user: "1000:1000",要么在宿主机上把目录属组设好,容器里加对应组。我倾向后者,因为推理进程偶尔要写缓存,用固定 UID 更可控。
还有一个小细节:模型目录建议只读挂载(ro),防止进程意外往模型目录里写东西,把权重文件搞坏。这个坑我在早期项目里遇到过,某个脚本往模型目录写了临时文件,磁盘满了之后模型加载直接失败,排查了半天才定位到。
4. 镜像构建:把 FastChat 关进盒子
4.1 为什么不直接拉现成镜像
现成镜像确实方便,但有两个隐患:一是体积往往很大,包含了一堆用不上的工具链;二是版本不透明,你不知道它内部锁的是哪个版本的 torch,出问题时无从下手。自己构建的镜像,每一行都看得见,改起来也顺手。
构建思路是分层:基础层用 CUDA 运行时镜像,装 Python 和系统依赖;中间层装 Python 包,这一层最耗时,尽量少变动;最上层拷贝 FastChat 源码或从包安装。这样调整配置时只重建最上层,几秒钟就好。
4.2 Dockerfile 逐层说明
FROM nvidia/cuda:12.1.0-cudnn8-runtime-ubuntu22.04 ENV DEBIAN_FRONTEND=noninteractive \ PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 RUN apt-get update && apt-get install -y --no-install-recommends \ python3.10 python3-pip python3.10-venv git curl \ build-essential libgl1 libglib2.0-0 \ && rm -rf /var/lib/apt/lists/* RUN ln -sf /usr/bin/python3.10 /usr/bin/python && \ python -m pip install --upgrade pip setuptools wheel # 依赖层单独放,改代码不用重装依赖 COPY requirements.txt /tmp/requirements.txt RUN pip install -r /tmp/requirements.txt # 应用层 WORKDIR /app COPY . /app EXPOSE 21001 21002 8000 7860 CMD ["python", "-m", "fastchat.serve.controller"]requirements.txt里最关键的是 torch 的版本要和 CUDA 对得上。装完一定要在构建阶段验证一次:
RUN python -c "import torch; print(torch.__version__, torch.cuda.is_available())"如果打印出True就不用担心,打印False说明镜像里的 CUDA 版本和 torch 的 wheel 不匹配,或者构建时没带显卡(构建阶段本来就没有显卡,这一步主要验证 torch 能否正常导入,真正验证cuda.is_available()要在运行时做)。
这里有个经验:--no-install-recommends能砍掉大量无关包,镜像体积能小三分之一以上。另外PIP_NO_CACHE_DIR=1不要在开发镜像里开,构建速度会明显变慢,只在最终镜像里开。
4.3 构建参数与体积优化
构建时给依赖装个国内可用的索引地址能省不少时间,配置方式是在 pip 的配置文件里加索引地址,或者构建时传--build-arg。这类配置属于环境差异,不写死进 Dockerfile 更好,用构建参数注入,换环境不用改文件。
docker build \ --build-arg PIP_INDEX_URL=<你的索引地址> \ -t fastchat-serve:20240601 \ -t fastchat-serve:latest \ -f Dockerfile .打完两个 tag 之后,生产环境永远引用日期 tag,不要用 latest。latest 看着方便,实际上是个定时炸弹,某天重建之后行为变了,你连对比的基准都没有。
5. 用 Compose 拉起整套服务
5.1 四个角色的职责与启动顺序
controller 必须先起,它是注册中心,worker 启动后要向它报到,api_server 也要连它拿 worker 列表。顺序错了,worker 会一直重试,日志刷屏但服务起不来。web server 可有可无,主要给内部人员试用。
启动顺序是:controller → model_worker → api_server → web server。用 compose 的depends_on只能保证启动顺序,不能保证“controller 已就绪”,所以更稳的做法是给 controller 加健康检查,其余服务等它健康后再起。
5.2 compose 文件与关键参数
services: controller: image: fastchat-serve:20240601 command: python -m fastchat.serve.controller --host 0.0.0.0 --port 21001 ports: - "21001:21001" healthcheck: test: ["CMD", "curl", "-f", "http://localhost:21001/"] interval: 10s timeout: 3s retries: 6 worker: image: fastchat-serve:20240601 depends_on: controller: condition: service_healthy command: > python -m fastchat.serve.model_worker --model-path /models/Qwen2-7B-Instruct --controller http://controller:21001 --worker http://0.0.0.0:21002 --host 0.0.0.0 --port 21002 --num-gpus 1 --max-gpu-memory 20GiB volumes: - /data/llm/models:/models:ro - /data/llm/cache:/root/.cache/huggingface deploy: resources: reservations: devices: - driver: nvidia count: 1 capabilities: [gpu] shm_size: "8gb" api: image: fastchat-serve:20240601 depends_on: - controller command: > python -m fastchat.serve.openai_api_server --host 0.0.0.0 --port 8000 --controller-address http://controller:21001 ports: - "8000:8000"几个参数值得单独说。shm_size是共享内存,默认只有 64MB,跑模型加载时多进程读写权重很容易把它撑爆,症状是进程被 killed 但没有任何报错。设成 8GB 是个保险值。--max-gpu-memory用来限制 worker 的显存上限,留出余量给系统和其他进程,尤其在多卡共享的机器上必须设,否则一个进程能把整卡吃干净。
--num-gpus和模型规模的匹配也有讲究。7B 单卡够,13B 建议双卡做张量并行,超过 30B 就得考虑 int4 量化或者更多卡。张量并行不是卡越多越快,卡间通信有开销,2 卡到 4 卡通常收益明显,再往上就要看场景了。
5.3 参数计算的实际例子
假设目标是 7B 模型、8K 上下文、单张 24GB 卡。权重 13.5GB,KV Cache 峰值 4GB,框架开销 2.5GB,合计 20GB。--max-gpu-memory设成 20GiB,系统余 4GB,比较安全。如果业务只需要 4K 上下文,KV 降到 2GB,可以把上限提到 22GiB,给更多并发留空间。
并发数的估算是:可用显存减去权重,再除以单请求 KV 峰值。上例中 20 - 13.5 - 2.5 = 4GB,单请求 4K 上下文约 2GB,那理论并发就是 2。这个数字很残酷,但它是真实的。想提高并发,要么降上下文,要么用量化,要么换带 PagedAttention 的后端,这也是为什么后续往往要换推理引擎。
6. 联调验证:从健康检查到第一次对话
6.1 分步验证,别一次性全起
我习惯一层一层验。先单独起 controller,看端口通不通;再起 worker,看日志里有没有 “Register to controller success”;最后起 api,用 curl 打/v1/models,能看到模型列表就说明链路通了。一次性全起的话,报错信息混在一起,很难定位。
# 1. controller 是否活着 curl -s http://localhost:21001/ | head # 2. worker 是否注册成功 curl -s http://localhost:21001/list_models | python -m json.tool # 3. 对外接口是否可用 curl -s http://localhost:8000/v1/models | python -m json.tool如果第二步返回空列表,说明 worker 没注册上,先看 worker 日志里的重试记录,八成是 controller 地址写成了localhost——容器里的 localhost 是容器自己,不是宿主机,必须用服务名。
6.2 第一次对话调用
curl http://localhost:8000/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "Qwen2-7B-Instruct", "messages": [{"role": "user", "content": "用三句话说明容器化部署的好处"}], "temperature": 0.7, "max_tokens": 256, "stream": false }'Python 侧用 OpenAI SDK 最省事,只要把 base_url 指过来:
from openai import OpenAI client = OpenAI(base_url="http://localhost:8000/v1", api_key="not-needed") resp = client.chat.completions.create( model="Qwen2-7B-Instruct", messages=[{"role": "user", "content": "写一段一百字的产品介绍"}], temperature=0.7, ) print(resp.choices[0].message.content)这里有个容易忽略的点:model字段必须和 worker 注册时的名字完全一致。FastChat 默认用模型路径的目录名当名字,路径里有大小写或下划线的差异就对不上,报 “model not found”。稳妥做法是启动 worker 时显式加--model-name,定一个短名字。
6.3 压测观察与吞吐判断
跑通之后别急着上线,先做一轮简单压测,看首 token 延迟和整体吞吐。用十个并发、每个生成 200 token 的请求打过去,观察显存曲线和响应时间分布。我的经验是:单张 24GB 卡跑 7B fp16,首 token 延迟在几百毫秒到一两秒之间,取决于上下文长度;吞吐大概在每秒十几到几十 token。如果发现延迟随并发急剧上升,说明显存已经接近上限,在频繁换入换出。
压测时一定要盯nvidia-smi的显存和利用率,看到显存接近 100% 就该停手,别等进程被杀。另外建议先在低并发下测出单请求延迟基线,再逐步加并发,画一条延迟随并发变化的曲线,能很直观地看出拐点在哪,这个拐点就是你实际能承载的上限。
7. 常见故障排查实录
7.1 启动阶段的典型问题
容器起了但看不见显卡。九成是没加--gpus all或者 compose 里没写 device reservations。先确认宿主机nvidia-smi正常,再确认运行时装好了,最后查挂载配置。
worker 一直重试注册。优先怀疑地址写成localhost,其次是网络不通。compose 里服务之间用服务名通信,前提是它们在同一个自定义网络里,默认 compose 会建一个,手动docker run就没有了。
加载模型时报 OOM。分两种情况:权重加载阶段 OOM,说明单卡装不下,得换量化或者多卡;运行阶段 OOM,说明上下文或并发给太高,调小max-model-len或者限制并发。
进程无报错直接退出。大概率是共享内存不够,shm_size调大试试。这个症状特别迷惑人,因为容器状态显示 exited(137),但日志里什么都没有。
| 症状 | 可能原因 | 处理方式 |
|---|---|---|
| 容器内 nvidia-smi 报错 | 运行时未配置或驱动版本低 | 检查运行时与驱动兼容性 |
| 注册失败,日志刷重试 | controller 地址写成 localhost | 改用服务名或宿主机 IP |
| 加载权重 OOM | 单卡显存不足 | 量化、多卡或换小模型 |
| 运行中 OOM | 上下文或并发过高 | 降 max-model-len、限并发 |
| 进程被杀无日志 | 共享内存不足 | 调大 shm_size |
| 接口返回 model not found | 模型名不一致 | 启动时显式 --model-name |
7.2 运行阶段的问题
回答内容是乱码或者一直重复。八成是对话模板没匹配。不同模型的 chat template 差别很大,FastChat 靠模型配置里的模板名识别,识别错了就会把特殊 token 当普通文本处理。解决办法是指定--conv-template,或者确认模型目录里的配置文件完整。
流式输出卡住不结束。检查反向代理是否开了缓冲,很多网关默认会攒够一批再转发,流式就变成了“一次性返回”。另外确认客户端设了超时,模型生成慢的时候连接可能先断。
多轮对话上下文丢失。FastChat 的 api_server 是无状态的,上下文完全靠客户端每次把历史消息带上。如果客户端只发最后一轮,模型当然不记得前面说过什么。这不是 bug,是设计如此,别在这上面浪费时间。
心得:日志一定要挂出来,而且每个进程分开写文件。四个进程的日志混在一个流里,排查时等于大海捞针。我在 compose 里给每个服务配了不同的日志文件路径,出问题直接看对应文件,效率高很多。
7.3 几个不写在文档里的细节
模型下载尽量用huggingface-cli download带断点续传,直接 git clone 大仓库经常断在半路,还占双份空间。下载完检查一下文件完整性,尤其是分片文件数量对不对得上索引文件。
挂载模型目录时路径别带空格和中文,某些底层库处理这类路径会出问题,报错信息还特别隐晦。这个坑我踩过一次,查了两小时才发现是目录名的问题。
容器内时区默认是 UTC,日志时间戳和本地差八小时,排查时序问题时会误导判断。启动时加个TZ环境变量,或者在 compose 里统一设,能省不少事。
8. 上线前必须处理的三件事
8.1 别把接口裸奔在公网
openai_api_server本身没有鉴权,谁都能调。内部环境也建议加一层,最简单的做法是在前面放一个反向代理,校验一个固定的 API Key,业务侧调用时带上。FastChat 支持传入--api-keys参数做简单校验,但功能有限,生产环境还是建议在网关层做,顺便把限流、日志、审计一起解决。
端口映射也要收敛,worker 的 21002 端口完全没必要暴露到宿主机,只有 api 的 8000 需要对外。controller 的 21001 也只在 compose 网络内部用。少暴露一个端口,就少一个被扫到的面。
8.2 日志、监控和资源上限
容器一定要设资源上限,尤其是内存。推理进程内存泄漏不常见,但 tokenizer 处理超长文本时内存会冲高,不设上限可能把宿主机拖垮。compose 里用mem_limit限制,配合restart: unless-stopped做自动恢复。
监控至少盯三个指标:显存占用、请求延迟分位数、错误率。显存持续爬升不回落,说明有请求没被正确释放;延迟 P99 突然拉高,通常是并发打满了;错误率上升先看是不是 OOM 导致的进程重启。这三个指标用最简单的脚本定时采集就够,不必上重型方案。
8.3 升级路径要留退路
镜像打日期 tag、模型目录做版本子目录、compose 文件进版本管理,这三件事做好,升级就是改一行 tag 的事,回滚也是改回来。我习惯在升级前先用新镜像起一套临时服务,把同样的压测跑一遍,指标没退化再切正式环境。灰度期间两套并存,流量按比例切,比直接替换稳得多。
模型升级要更谨慎。换模型意味着输出风格、上下文长度、显存占用全都变了,客户端的 prompt 可能也要跟着调。我的做法是保留旧模型 worker 一段时间,通过 api_server 的模型名路由把新请求打到新模型,观察一段时间再下线旧的。这套流程跑顺之后,换模型就跟换配置一样平常。
最后分享一个我在实际部署里体会最深的点:这套方案的稳定性,八成取决于你有没有把“层级”想清楚。驱动问题看宿主机,依赖问题看镜像,注册问题看网络,显存问题看参数,输出问题看模板。每次出故障先问自己“这属于哪一层”,比盲目重启容器有效得多。真正让人抓狂的从来不是技术本身难,而是不知道问题出在哪一层,把一个网络地址写错当成模型加载失败,来回折腾一整天。把验证步骤拆细、把日志分开、把参数算清楚,剩下的就是耐心。