本地把一套大语言模型服务跑起来,这件事从“想试试”到“真能用”中间隔着的不是一行命令,而是一堆显存账、端口账和模板账。FastChat 是我这几年用得比较顺手的方案:它把模型加载、请求调度、网页界面和标准接口拆成几个独立进程,哪一层出问题都能单独拉出来看日志。我用它在不同配置的机器上搭过内部问答、模型对比评测、业务系统联调三类环境,机器从单卡 8GB 显存的小主机到四卡工作站都试过。这篇就把整套配置过程摊开讲:每个进程在干什么、参数为什么这么传、显存怎么算、报错怎么查。刚接触本地大语言模型部署的朋友可以照着抄,已经跑通过一遍的也能在参数调优和排查那几节里找到点东西。
1. 先想清楚:本地跑 FastChat 到底图什么
动手敲命令之前,有三件事必须先在心里过一遍:这套东西的架构长什么样、你的场景是不是真的适合自建、以及显存这本账能不能算平。我见过太多人卡在第一步——环境装完了,模型权重下了一百多 G,结果启动时显存不够,白折腾一个下午。把这三件事想清楚,后面的操作会顺很多。
1.1 它把大模型服务拆成了哪几块
FastChat 的设计思路很像餐厅:controller 是前台接待,不碰菜,只负责记录“现在有几个厨师、各自会做什么菜”;model worker 是厨师,真正占着灶台(显存)干活;WebUI 和 API server 是两种点餐方式,一种是顾客自己看菜单点,一种是外卖平台下单。这四个角色通过 HTTP 互相说话,谁挂了都不至于让整个系统瞬间瘫痪(controller 除外,它是单点)。
具体到进程层面,controller 默认监听 21001 端口,对外提供注册、寻址、模型列表三类接口。model worker 启动后先加载模型,然后主动向 controller 报到,告诉它“我叫什么名字、我监听哪个地址”,之后每隔几秒发一次心跳。WebUI 是一个网页服务,默认 7860 端口,它向 controller 问“现在有哪些模型可用”,把列表渲染成下拉框。API server 默认 8000 端口,收到标准格式的对话请求后,它自己不做推理,而是转手问 controller 要一个 worker 地址,再把请求转发过去。
这种拆法最大的好处是解耦。你想换模型,只需要停掉 worker 换一个权重重启,WebUI 和 API 那一层完全不用动;你想同时挂三个模型做效果对比,就起三个 worker 注册不同名字,前端下拉框里自然就出现三个选项;你想给业务系统提供接口,直接对 API server 编程就行,不用管底层是 transformers 还是别的推理后端。我后来给几个内部工具接模型能力,都是只对 API 那一层写代码,底层换过两次模型,业务侧一行没改。
1.2 三种典型场景,对号入座再动手
第一种是个人或小团队的内部问答。五六个人共用一台带显卡的机器,问点文档相关的问题,或者当个写作助手。这种场景对并发要求极低,一个人说话的时候别人等一下完全能接受,单 worker 足够。我建议这种场景直接上 WebUI,界面现成,还能保存对话历史,省去自己写前端的时间。
第二种是模型对比评测。同一批问题,分别喂给几个不同参数量、不同量化精度的模型,看输出质量、速度、显存占用。这种场景 FastChat 几乎是现成答案,起多个 worker,WebUI 支持并排对比两个模型的回答,评测记录直接就能截图存档。要注意的是多模型并存时显存是叠加的,一张 24GB 卡最多塞一个 fp16 的 7B 模型加一个 int4 量化的小模型,再往上就得排队加载。
第三种是给业务系统做接口 PoC。比如客服工单摘要、合同条款抽取这类需求,先用本地模型跑通链路,验证效果再决定要不要走别的方案。这种场景重点在 API 层:请求格式、超时设置、返回结构、错误码,都要和业务侧对齐。FastChat 提供的是兼容主流对话协议的标准接口,业务代码里改个 base_url 和模型名就能接,迁移成本很低。
反过来说,有些场景我不建议硬上。比如你要对外提供高并发服务,单 worker 的处理能力撑不住几十个并发请求,得堆机器或者换专门的推理引擎;再比如你手上只有 CPU 没有独显,7B 模型 int4 量化后在 CPU 上跑,出字速度大概是每秒一两个 token,一句话等半分钟,体验没法用,这种情况不如直接调用现成的云端接口。
1.3 动手前的显存账本
显存估算这件事,很多人凭感觉,结果就是反复 OOM。我给一个能落地的算法,分三块加:
权重占用= 参数量 × 每个参数的字节数。fp16 是 2 字节,int8 是 1 字节,int4 大约 0.5 字节。7B 参数用 fp16 加载,光权重就是 14GB 左右;13B 是 26GB;70B 是 140GB,这个量级普通机器就别想了。
KV Cache 占用= 2 × 层数 × 隐藏维度 × 序列长度 × 并发数 × 字节数。以 7B 模型常见的 32 层、4096 隐藏维度为例,2048 上下文、单条请求、fp16 精度,算下来是 2 × 32 × 4096 × 2048 × 2 字节,约 1.07GB。上下文翻倍,这块就翻倍;并发两条,再翻一倍。这就是为什么长上下文加多并发最容易炸显存。
激活值和框架开销,一般按权重占用的 15% 到 25% 预留。
三项加起来,7B fp16 的实际占用大概 17 到 19GB,24GB 卡能跑但余量不多;8GB 卡只能上 int4 量化的 7B;12GB 卡跑 int4 的 7B 比较舒服,跑 fp16 的 7B 必然 OOM。我一般会在这个估算基础上再留 2GB 缓冲给系统和其他进程,宁可保守一点,也别跑到一半崩掉。
注意:显存估算只算 GPU,别忘了解析模型权重时内存也会飙。加载 14GB 的权重文件,系统内存峰值可能到 20GB 以上,内存小的机器会在加载阶段就被系统杀掉进程,报错信息还看不出来是内存问题。
2. 环境搭建:从裸系统到能拉起第一个进程
环境这一层是最枯燥也最容易埋雷的地方。CUDA 版本、PyTorch 版本、Python 版本、驱动版本,这四个东西互相牵制,错一个就是一连串看不懂的报错。我的习惯是先固定驱动和 CUDA,再选 PyTorch,最后装 FastChat,顺序反了就要返工。
2.1 系统、驱动与 CUDA 的对齐
先用nvidia-smi看驱动版本和支持的最高 CUDA 版本。右上角那行 CUDA Version 是驱动能支持的上限,不是你装了哪个版本。CUDA 12.x 一般要求驱动 525 以上,CUDA 11.8 要求 450 以上。如果驱动太老,先升级驱动,这一步在 Linux 上就是加个软件源装包,重启后生效,别在这上面省事。
然后是 PyTorch。我的经验是先去 PyTorch 官方给的安装命令页面,按你的 CUDA 版本选对应的命令,装完立刻验证:
python -c "import torch; print(torch.__version__, torch.cuda.is_available(), torch.cuda.device_count())"输出里True和卡数对得上,才算这一步过了。如果is_available()返回 False,八成是装成了 CPU 版本,或者驱动和 CUDA 运行时不匹配。这种情况不要急着重装整个环境,先pip list | grep torch看一眼装的是不是+cu118这类带 CUDA 标记的版本。
还有一个容易忽略的点:如果你在容器里跑,宿主机驱动要能被容器识别,需要装对应的容器运行时工具,nvidia-smi在容器内能正常输出才算通。我遇到过一次容器里torch.cuda.is_available()一直是 False,查了半天发现是启动参数里没加显卡透传。
2.2 Python 环境与依赖安装
Python 版本我推荐 3.10,兼容性最好,很多依赖包对 3.11、3.12 的支持还在陆续补齐,3.9 又偏老,某些新版本的库开始不兼容。用 conda 或者 venv 建一个独立环境,不要往系统 Python 里装,这是基本纪律。
conda create -n fastchat python=3.10 -y conda activate fastchat安装 FastChat 有两种方式。图省事就用发布版:
pip install "fschat[model_worker,webui]"但我更推荐从源码装,因为本地部署几乎必然要改东西——加一个自定义的对话模板、调整一下生成参数、给某个模型加适配,发布版改起来很别扭。源码方式:
git clone <源码仓库地址> cd FastChat pip install -e ".[model_worker,webui]"-e是开发模式,改完代码不用重装。方括号里那两个是可选依赖组,model_worker负责推理相关的库,webui负责网页界面相关的库,不装的话启动时会报模块找不到。
这里有个顺序陷阱:如果先装了 FastChat,pip 可能会顺手把 PyTorch 换成一个 CPU 版本或者别的版本,把你前面配好的 CUDA 环境搞乱。所以一定是先装好 PyTorch,再装 FastChat,装完再用前面那条命令验证一次 CUDA 还在。国内网络环境下装依赖慢,可以配置镜像源,但镜像源的同步延迟有时会导致某个包版本对不上,遇到奇怪的依赖冲突就先换回官方源试一次。
2.3 模型权重的落地与目录规划
权重文件是本地部署里最占空间的部分。7B 模型 fp16 大约 14GB,13B 大约 26GB,下载前先df -h看一眼磁盘,留出至少两倍空间,因为下载过程中会有临时文件,解压或者转换格式还要额外空间。
我习惯把模型统一放在一个目录下,按模型名分子目录:
/data/models/ ├── chat-model-7b/ │ ├── config.json │ ├── tokenizer.json │ ├── tokenizer_config.json │ ├── special_tokens_map.json │ └── model-00001-of-00004.safetensors ... └── chat-model-13b-int4/ └── ...每个目录里必须齐全的是:模型配置文件、分词器相关文件(分词器配置、词表、特殊 token 映射)、权重分片文件。少一个都会在加载时报错,而且报错信息往往只提一个文件名,不会告诉你整体缺了什么。我的习惯是下载完ls -la看一眼文件数量和大小,和发布页给的清单对一遍。
下载方式看你的网络条件,能用命令行工具断点续传最好,大文件下到一半断了重来很痛苦。下完之后建议先做个校验,至少确认权重文件大小和发布页标注的一致,损坏的权重文件加载时可能不报错,但推理输出全是乱码,这种问题排查起来极其费时。
注意:启动时尽量传绝对路径,不要依赖默认的联网下载逻辑。有些实现会在本地找不到时尝试去远端拉取,网络不通就卡在那里,日志还不明显。传了绝对路径,加载行为就是确定的。
3. 四个核心进程逐个拆解
进程之间的关系是这套系统最需要理解的部分。很多人启动失败,不是命令写错,而是没搞明白“谁必须先起、谁向谁报到”。这一节把四个进程的职责、依赖顺序和关键行为讲清楚,后面实操的时候你就能预判每一步会发生什么。
3.1 controller:不干活的调度中心
controller 是整个系统里唯一不做推理的组件,但它是必须第一个启动的。它的工作就三件:接收 worker 注册、维护 worker 心跳、按模型名返回可用地址。
启动命令很短:
python -m fastchat.serve.controller --host 0.0.0.0 --port 21001--host 0.0.0.0表示监听所有网卡,如果你只在同一台机器上跑所有进程,用127.0.0.1更安全;如果 worker 分布在别的机器上,就必须用0.0.0.0并且保证端口互通。端口我一般不动,21001 是个不太容易冲突的号,真要改的话记得后面所有进程的--controller参数都要跟着改。
controller 内部维护一张表,键是模型名,值是 worker 地址列表。同一个模型名注册多个 worker 时,它做轮询分发;不同模型名可以并存,互不影响。心跳这块要留意:worker 会周期性重新注册,如果某个 worker 进程挂了但没清理,controller 要等心跳超时才会把它从列表里摘掉,这段时间内分发到它的请求会失败。所以我一般在停 worker 时用正常退出,别直接kill -9。
controller 本身几乎不吃资源,内存占用几十 MB,CPU 基本闲置。但它是单点,挂了整个系统就没法路由。生产环境里我会用一个简单的守护进程看着它,崩了自动拉起,这件事在后面第七节会讲。
3.2 model worker:真正吃显存的那个
model worker 是唯一加载模型权重的进程,显存主要被它占着。启动时它做四件事:读配置、加载分词器、加载权重到显存、向 controller 注册自己。整个过程的耗时取决于模型大小和磁盘速度,7B fp16 从本地固态盘加载大概 30 到 60 秒,机械盘或者网络存储可能要好几分钟。
关键参数有这么几个:
python -m fastchat.serve.model_worker \ --model-path /data/models/chat-model-7b \ --model-name chat-7b \ --controller http://127.0.0.1:21001 \ --worker http://127.0.0.1:31000 \ --host 0.0.0.0 \ --port 31000 \ --num-gpus 1 \ --limit-worker-concurrency 1--model-name是注册到 controller 的名字,WebUI 和 API 里都用这个名字找模型,不传的话默认取路径最后一段。我建议显式指定一个简短好记的名字,路径名往往很长,调用时容易写错。
--worker和--port要一致,前者是它告诉 controller 的地址,后者是它实际监听的端口。这两个不一致的话,controller 会把请求发到一个没人监听的地址上,表现为“模型列表里有,但一问就超时”。
--limit-worker-concurrency控制单个 worker 同时处理几个请求,默认值比较保守。这个参数和显存直接相关:并发数加一,KV cache 就多占一份,长上下文场景下很容易 OOM。我通常先设成 1,压测完再往上调。
3.3 WebUI 与 API server:两种入口
WebUI 是给人用的,启动后浏览器打开就能聊,支持多模型切换、对话历史、并排对比:
python -m fastchat.serve.gradio_web_server \ --controller http://127.0.0.1:21001 \ --model-list-mode reload--model-list-mode reload表示每次刷新页面都重新问 controller 要模型列表,这样你新起一个 worker,刷新一下页面就能看到,不用重启 WebUI。调试阶段这个参数很省事,正式用的时候可以改成once,减少对 controller 的请求。
API server 是给程序用的:
python -m fastchat.serve.openai_api_server \ --controller http://127.0.0.1:21001 \ --host 0.0.0.0 \ --port 8000它对外暴露的是兼容主流对话协议的接口,包括模型列表、对话补全、文本补全这几类。业务代码里把请求地址指到http://你的机器:8000/v1,密钥随便填一个占位值,模型名填你注册的那个名字,就能直接调通。这个兼容层是 FastChat 最值钱的部分之一,省掉了自己封装协议的工作量。
3.4 多 worker 与负载分担
单 worker 的处理能力有限,因为它内部对请求做了排队。想提高并发能力,最直接的办法是起多个 worker,注册同一个模型名:
# 终端一 python -m fastchat.serve.model_worker --model-path /data/models/chat-model-7b \ --model-name chat-7b --controller http://127.0.0.1:21001 \ --worker http://127.0.0.1:31000 --port 31000 # 终端二(如果有多张卡) python -m fastchat.serve.model_worker --model-path /data/models/chat-model-7b \ --model-name chat-7b --controller http://127.0.0.1:21001 \ --worker http://127.0.0.1:31001 --port 31001两个 worker 端口必须不同,模型名必须相同,controller 会在它们之间轮询。这种做法的前提是你有两张卡,或者模型小到能在一张卡上放两份。如果只有一张卡又想要更高并发,那就得回到参数调优那一节,从量化和上下文长度上想办法。
另一个思路是起不同模型名的 worker,比如一个通用对话模型加一个小参数的专用模型,前端下拉切换,各取所需。这种配置在对比评测场景里特别实用,同一个问题分别问两个模型,答案并列显示,差异一眼就能看出来。
4. 完整实操:从零启动一套可用服务
前面讲的是零件,这一节把整套流程串起来。我按真实操作的顺序写,包括每步之后应该看到什么输出,这样你对照着就能判断当前这步到底成没成。所有进程我都建议开独立终端,或者用tmux分屏,日志混在一起的时候排查会非常痛苦。
4.1 第一步:拉起 controller
conda activate fastchat python -m fastchat.serve.controller --host 0.0.0.0 --port 21001正常启动后终端会打出类似Uvicorn running on http://0.0.0.0:21001的日志,没有报错就是成了。这时候可以另开一个终端验证一下:
curl http://127.0.0.1:21001/list_models返回一个空的模型列表是正常的,因为还没有 worker 注册。如果这条命令连不上,先查端口有没有被占用(ss -lntp | grep 21001),再查防火墙。这一步不通,后面全白搭,所以别急着往下走。
4.2 第二步:注册 model worker
python -m fastchat.serve.model_worker \ --model-path /data/models/chat-model-7b \ --model-name chat-7b \ --controller http://127.0.0.1:21001 \ --worker http://127.0.0.1:31000 \ --host 0.0.0.0 \ --port 31000 \ --num-gpus 1 \ --limit-worker-concurrency 1启动过程中会依次打印加载分词器、加载权重的日志,最后出现注册成功的提示。这期间用nvidia-smi能看到显存逐步上涨,涨到估算值附近停住,说明加载完成。如果显存涨到一半突然掉回零,进程也退了,基本就是 OOM,回到 1.3 节重新算账或者换量化版本。
加载完成后回到 controller 的终端,应该能看到一条 worker 注册的日志;再执行一次curl http://127.0.0.1:21001/list_models,列表里就有chat-7b了。这一步是整个流程的分水岭:模型列表里出现名字,说明 controller 和 worker 之间的通信是通的,后面的入口层才有意义。
4.3 第三步:WebUI 与 API 双入口
先起 WebUI:
python -m fastchat.serve.gradio_web_server \ --controller http://127.0.0.1:21001 \ --model-list-mode reload \ --host 0.0.0.0 \ --port 7860浏览器打开http://你的机器IP:7860,左上角模型下拉框里应该能看到chat-7b。选中它,随便问一句,能正常出字就说明链路全通了。第一次请求会慢一些,因为要做一些初始化,后面就快了。
再起 API server:
python -m fastchat.serve.openai_api_server \ --controller http://127.0.0.1:21001 \ --host 0.0.0.0 \ --port 8000这两个入口可以同时跑,互不干扰,因为它们都只是向 controller 问地址,真正的推理还是在 worker 里做。我平时调试用 WebUI,接业务用 API,两个都开着,方便对照。
4.4 第四步:验证服务是否真的通了
不要只看网页能聊就以为万事大吉,接口层面要单独验。先看模型列表:
curl http://127.0.0.1:8000/v1/models再发一条对话请求:
curl http://127.0.0.1:8000/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "chat-7b", "messages": [{"role": "user", "content": "用一句话解释什么是显存"}], "temperature": 0.7, "max_tokens": 128 }'返回结构里应该有choices数组和里面的message.content。如果这里报模型不存在,说明model字段和注册名不一致;如果报超时,多半是 worker 那边卡住了,去看 worker 的终端日志。
Python 侧验证更贴近真实业务:
from openai import OpenAI client = OpenAI(base_url="http://127.0.0.1:8000/v1", api_key="not-needed") resp = client.chat.completions.create( model="chat-7b", messages=[{"role": "user", "content": "写三条显存优化的建议"}], temperature=0.3, max_tokens=256, ) print(resp.choices[0].message.content)密钥字段填什么都行,本地服务一般不校验,但有些客户端库要求非空,随便给个占位字符串就好。
4.5 多卡并行的配置方式
一张卡放不下的时候,最直接的做法是让模型分片到多张卡上:
python -m fastchat.serve.model_worker \ --model-path /data/models/chat-model-13b \ --model-name chat-13b \ --controller http://127.0.0.1:21001 \ --worker http://127.0.0.1:31000 \ --port 31000 \ --num-gpus 2--num-gpus 2会让加载器把权重按层切分到两张卡上,显存占用大致均分。要注意的是,多卡并行不等于速度翻倍,卡间通信有开销,小 batch 场景下甚至可能比单卡还慢一点。它的主要价值是“装得下”,不是“跑得快”。如果你的目标是提高吞吐,更划算的做法是每张卡各起一个 worker,注册同一个模型名,让 controller 轮询分发,这样两张卡是真正并行处理不同请求的。
5. 参数调优:显存、延迟、吞吐的三角平衡
服务能跑通只是起点,能不能用得舒服是另一回事。同样一个 7B 模型,参数配得好和配得差,首字延迟能差好几倍,并发能力能差一个数量级。这一节把关键参数逐个拆开,说清楚调大调小分别影响什么,以及我实际用下来的取值习惯。
5.1 启动参数逐项拆解
| 参数 | 作用 | 调大的影响 | 我的常用值 |
|---|---|---|---|
--model-path | 权重目录 | 无 | 绝对路径 |
--model-name | 注册名 | 无 | 简短英文名 |
--num-gpus | 分片卡数 | 装得下更大模型,但通信开销上升 | 能单卡就单卡 |
--max-gpu-memory | 每卡显存上限 | 留更多给系统,但可能放不下 | 比卡容量少 1 到 2GB |
--load-8bit | 8 位量化加载 | 省显存,精度略降 | 显存紧张时开 |
--dtype | 计算精度 | fp16 快,fp32 准但翻倍吃显存 | 默认 fp16 |
--limit-worker-concurrency | 单 worker 并发 | 吞吐上升,显存和延迟上升 | 先 1,压测后调到 2 到 4 |
--conv-template | 对话模板 | 不设或设错会导致答非所问 | 按模型文档指定 |
--max-gpu-memory这个参数值得单独说。它限制的是单个 worker 在每张卡上最多用多少显存,单位可以是绝对大小也可以是比例。设得太满,系统和其他进程没地方待,会出现莫名其妙的失败;设得太保守,模型加载不进去直接报错。我一般是卡容量减 1.5GB 左右,比如 24GB 卡设 22GB 出头。
--limit-worker-concurrency是最容易被忽略也最影响体验的参数。默认值意味着请求排队,一个人问的时候感觉不到,两个人同时问就开始等。往上调能明显改善并发体验,但每加一个并发,KV cache 就多一份,长上下文下很容易顶到显存上限。我的做法是先用默认值跑通,然后用两三个并发压一下,看显存峰值,再决定加到几。
5.2 量化方案怎么选
显存不够的时候,量化是最有效的手段,但不同量化方式差别很大:
| 方案 | 显存节省 | 精度损失 | 加载速度 | 适用场景 |
|---|---|---|---|---|
| fp16 原始 | 无 | 无 | 快 | 显存充足,追求质量 |
| 8 位在线量化 | 约 50% | 轻微 | 加载慢一些 | 显存中等,快速验证 |
| 4 位在线量化 | 约 70% | 可感知 | 加载慢 | 显存紧张,跑通链路 |
| 离线 4 位量化 | 约 70% | 比在线 4 位略好 | 快 | 长期部署,显存有限 |
在线量化指的是启动时用--load-8bit这类参数,让框架在加载过程中做转换,好处是不用提前准备文件,坏处是每次启动都要花时间转换。离线量化是提前把权重转成低精度格式存好,启动时直接加载,速度快,精度通常也略好一点。我的建议是:验证阶段用在线量化图省事,确定要长期跑就直接做离线量化,省下的启动时间很可观。
量化不是免费的午餐。4 位量化的模型在长文本推理、数字计算、代码生成这几类任务上退化比较明显,我做过几组对比,同样的提示词,fp16 版本能给出结构完整的答案,4 位版本偶尔会漏条件或者算错数。如果任务对准确性要求高,宁可换小一号的模型跑 fp16,也别硬上大模型的 4 位版本。
5.3 上下文长度与并发的连锁反应
上下文长度是显存的黑洞。前面算过,7B 模型 2048 上下文单条请求的 KV cache 约 1GB,开到 8192 就是 4GB 多,再乘并发数,很快就吃掉了权重之外的所有余量。很多人配置完发现聊几句就崩,就是历史对话越攒越长,KV cache 一路涨到顶。
控制手段有三个。一是限制单次请求的最大生成长度,别让模型无节制地输出;二是限制历史轮数,只保留最近几轮对话,老的截断掉;三是在服务层做输入长度检查,超长的请求直接拒绝,而不是让它把显存撑爆。第三条最容易被忽略,但对服务可靠性影响最大——一条超长请求能把整个 worker 拖垮,所有人都用不了。
并发和上下文是乘法关系,不是加法。并发 4、上下文 8192 的组合,显存压力是并发 1、上下文 2048 的十几倍。调参的时候一定要按最坏情况算,别拿单条短请求的占用去推多并发的表现。
6. 常见问题排查实录
这一节是我这些年攒下来的报错记录,按“起不来”“答不对”“跑不久”三类分。每个问题我都写了现象、原因和验证方法,遇到类似的先对照现象定位,别急着重装环境——我见过太多人一遇到问题就重装,结果同一个坑踩三次。
6.1 进程起不来
端口被占用是最常见的。现象是启动命令执行后立刻退出,日志里有 address already in use。用ss -lntp | grep 端口号找到占用的进程,要么杀掉,要么换个端口,换端口记得同步改其他进程的引用参数。
controller 连不上,现象是 worker 日志里反复重试注册。先确认 controller 进程还活着,再确认地址写对了——同一台机器用127.0.0.1,跨机器要用实际内网 IP,写localhost在某些容器网络里会解析到别的地方。跨机器场景还要确认防火墙放行了 21001 端口。
CUDA out of memory,现象是加载到一半进程退出。先在加载时用watch -n 1 nvidia-smi盯着显存曲线,看是在哪个阶段爆的。如果是权重加载阶段就爆,说明模型太大,换量化版本或者加卡;如果是加载完刚开始推理就爆,说明 KV cache 预留不够,降低并发或者缩短上下文。
分词器相关报错,现象是提示找不到某个 token 文件或者配置字段。八成是权重目录不完整,或者模型需要额外的信任参数才能加载自定义代码。后者在启动命令里加--trust-remote-code通常能解决,但要注意这个参数意味着会执行模型目录里的自定义代码,只对你信得过的权重来源使用。
量化库报错,现象是提示找不到某个底层库或者版本不匹配。这类问题基本是量化库和 CUDA 版本没对齐,处理办法是先确认量化库版本,再确认它支持的 CUDA 范围,必要时降级 CUDA 或者换一个量化方案。这种底层依赖问题我不建议硬调,换方案往往比修环境快。
6.2 起来了但答非所问
对话模板不对是头号嫌疑。现象是模型能出字,但回答驴唇不对马嘴,或者把用户的话重复一遍。原因是不同模型训练时用的对话格式不一样,特殊标记、角色前缀、结束符都有讲究,模板错了模型就理解不了输入结构。解决方式是查模型文档,确认它对应的模板名,启动时用--conv-template指定;如果框架里没有现成的,就得在源码的模板定义文件里加一个,照着已有条目的格式写,把角色前缀和结束符配对。
停止符缺失,现象是模型一直说个不停,停不下来。这是生成参数里没有正确设置结束标记,模型不知道该在哪停。检查模板定义里的停止符字段是否和模型文档一致,不一致就补上。
重复输出,现象是同一个句子反复出现。这通常是生成长度设太大加上温度太低导致的,把温度调到 0.7 左右、加上重复惩罚,一般能缓解。也有可能是模板里的结束符没生效,模型一直在同一个上下文里打转。
中英混杂,现象是问中文答英文。先确认模型本身的中文能力,有些模型中文训练数据少,这个是能力问题,调参解决不了。如果模型本身中文不错,那可能是系统提示词被模板吃掉了,检查模板里系统角色的位置是否被正确保留。
6.3 跑一会儿就崩
长跑显存缓慢上涨,现象是刚启动正常,聊了半小时后 OOM。这是典型的历史累积问题,每轮对话都往上下文里追加,KV cache 只增不减。解决办法是在服务层限制历史轮数,或者限制总 token 数,超了就从头开始。
worker 心跳丢失,现象是 WebUI 里模型突然消失,或者请求报找不到可用 worker。先看 worker 进程还在不在,再看它的日志有没有异常退出。如果进程还在但心跳断了,可能是它卡在某个长请求里没法响应心跳,这种情况把并发调低、加上请求超时能缓解。
WebUI 页面卡住,现象是浏览器一直转圈。先直接用 curl 打 API,如果 API 正常,那就是前端的问题,刷新页面或者重启 WebUI 进程;如果 API 也卡,问题在 worker,去看那边的日志。分层定位能省很多时间。
系统内存被打满,现象是进程被系统杀掉,日志里什么都没有。这是加载大权重时内存峰值过高,配合交换分区使用会非常慢。处理办法是加载前确认空闲内存足够,或者用内存映射方式加载,减少一次性读入的量。
6.4 常见问题速查表
| 现象 | 最可能原因 | 快速验证 | 处理方式 |
|---|---|---|---|
| 启动即退出,提示端口占用 | 端口冲突 | ss -lntp查占用 | 换端口或杀进程 |
| 模型列表为空 | worker 未注册成功 | 看 worker 日志 | 检查 controller 地址 |
| 加载中进程消失 | 显存或内存不足 | nvidia-smi盯峰值 | 量化或加卡 |
| 有输出但答非所问 | 对话模板错 | 对照模型文档 | 指定正确模板 |
| 输出停不下来 | 停止符缺失 | 看模板定义 | 补齐停止符 |
| 聊一会儿就崩 | 上下文累积 | 看显存曲线 | 限制历史轮数 |
| 接口报模型不存在 | 名字不一致 | /v1/models查列表 | 对齐注册名 |
| 请求超时 | worker 排队或卡死 | 看 worker 日志 | 降并发或加 worker |
| 中文答成英文 | 模型能力或提示词丢失 | 换提示词测试 | 换模型或修模板 |
7. 从能跑到能用的几件事
本地服务跑通之后,还有一段路要走:进程怎么守护、日志怎么看、访问怎么控制。这几件事不做,服务就只能在你盯着的时候活着,人一走就出问题。这一节讲我实际部署时的几个固定动作,都是些不复杂但很省心的事。
7.1 守护进程与开机自启
手动开四个终端的方式只适合调试。正式用的时候我会给每个进程写一个守护配置,以系统服务的方式管理,好处是崩溃自动重启、开机自动拉起、日志统一收集。核心配置大概是这个结构:
[Unit] Description=FastChat Controller After=network.target [Service] Type=simple User=youruser WorkingDirectory=/home/youruser Environment="PATH=/home/youruser/miniconda3/envs/fastchat/bin" ExecStart=/home/youruser/miniconda3/envs/fastchat/bin/python -m fastchat.serve.controller --host 0.0.0.0 --port 21001 Restart=always RestartSec=5 StandardOutput=append:/var/log/fastchat/controller.log StandardError=append:/var/log/fastchat/controller.log [Install] WantedBy=multi-user.targetworker 的配置同理,只是启动命令不同。这里有两个细节值得注意:一是环境变量里的 PATH 要指向你那个 conda 环境,否则服务启动时找不到对应的 Python 和依赖;二是重启间隔别设太短,模型加载本身就要几十秒,间隔太短会在加载失败时反复重启,把日志刷满。
用容器部署的话思路类似,把四个进程拆成四个服务,controller 先起,worker 依赖它,入口层依赖 worker。容器里要确认显卡能被识别,权重目录挂载进去,日志目录也挂出来,否则容器一删日志就没了。
7.2 日志与监控
日志分两类看:进程日志和请求日志。进程日志记录启动、注册、异常,请求日志记录每次调用的输入长度、输出长度、耗时。前者用来排查崩溃,后者用来做容量规划。
我关注的指标有三个。首字延迟,从发请求到收到第一个字的时间,这个直接影响体感,超过三秒用户就会觉得卡。每秒输出字数,反映推理速度,7B 模型 fp16 在单卡上一般能到每秒二三十字,量化版本会快一些。显存峰值,决定你还能不能再加并发或者加模型。
这三个指标不用上复杂的监控系统,写个定时脚本打几条固定请求,把耗时和显存记到文件里,画个曲线就能看出趋势。我遇到过一次服务越跑越慢,查下来是某个客户端一直不主动结束会话,上下文越积越长,加了个最大长度限制就好了。这种问题没有指标很难发现。
日志轮转别忘配。请求日志增长很快,一天几个 G 很正常,不轮转的话磁盘满了会连带其他服务一起出问题。
7.3 访问控制与限流
本地服务最容易犯的错是直接监听0.0.0.0又没有任何鉴权,内网里谁都能调。更麻烦的是如果这台机器有公网入口,等于把显卡资源开放给了所有人。我的做法分三层。
第一层是网络层,只监听内网地址,或者用防火墙限制来源网段。只有确实需要跨网访问时才开放,开放时也限定端口和来源。
第二层是入口层加鉴权。API server 前面套一个轻量的转发服务,校验一个固定的请求头,不对就拒绝。这个转发服务不需要多复杂,几十行代码就够,重点是让未授权的请求在到达模型之前就被挡住。
第三层是限流。按来源或者按密钥做每分钟请求数限制,同时限制单次请求的最大输入长度和最大生成长度。限流的意义不只是防滥用,也是保护服务本身——一条超长请求能把 worker 卡住几十秒,期间所有人都用不了。
注意:如果这套服务只是自己用,最省事的做法是所有进程都监听
127.0.0.1,需要远程访问时通过 SSH 端口转发进来,别图方便直接开0.0.0.0。我见过不止一次因为图省事把内部服务暴露出去,最后被人拿去跑批量任务,显卡满载好几天没人发现。
8. 一些踩坑之后才明白的事
配置这套东西,命令层面的东西查文档基本都能解决,真正花时间的是那些文档里不写的判断。比如量化到底该选哪一档,参数表里只会告诉你显存省多少,不会告诉你哪些任务会明显退化;比如多 worker 到底该起几个,要看你的请求是长对话还是短问答,长对话场景下多 worker 反而不如单 worker 加长上下文划算。
我现在给新机器做配置,顺序固定是:先算出显存能装多大的模型,再决定量化方案,然后按最坏情况(最长上下文、最大并发)验证一次会不会 OOM,最后才去调速度和并发。反过来先追求速度再补显存,往往要返工。还有一条经验是,模型效果不满意的时候,先怀疑模板和提示词,再怀疑量化,最后才怀疑模型本身——我遇到过好几次“模型变笨了”,查下来都是模板里系统提示被截断,跟模型能力没关系。
另外提一句,本地部署这套东西的价值不完全在于省钱。数据不出内网、可以随便改提示词和参数、能把模型当成一个普通服务接进自己的工具链,这些是调用现成接口换不来的。但也要认清它的边界:单机单卡的吞吐就那么多,别指望它扛住几十个并发,真到了那个量级,该做的是换推理引擎、加机器、做队列,而不是在这套架构上硬挤。