简介:面向希望本地私有化搭建智能问答助理的开发者与机器学习学习者,这份示例项目以 Gradio 构建文件上传与对话界面,结合 FAISS 向量检索和 Sentence-Transformers 文本嵌入,演示从文档入库到问题匹配回答的完整链路,适合用来理解知识库问答系统的核心模块与私有部署思路。压缩包共 18 个文件、约 336KB,包含 Python 源码、依赖与配置文件、Shell 更新脚本、离线 whl 安装包,以及 txt 配置说明、docx/pdf/xlsx 示例文档和 pem 证书等,兼顾代码运行、环境准备与知识库素材。已有 140 人学习,读者可据此梳理界面层、检索层与模型调用的组织方式,参考示例文档构造自己的知识库,并借鉴证书与依赖文件处理本地部署中的环境问题,快速搭起可对话的原型。
1. 本地部署私有化问答助理:一套 ChatGLM 推理链路到底要装多少东西
前阵子帮一个做内部工单系统的团队梳理过需求,他们想把问答机器人搬进自己机房,数据一条都不能出内网。调用外部接口的方案法务过不了,拿开源模型硬跑又卡在显存上——一张 24G 卡加载 6B 全精度模型,还没开始服务就快满了。最后落到同一套组合:ChatGLM 系列的中文对话模型做底座,本地推理服务出接口,再叠一层检索把内部文档喂进去,形成本地部署私有化的智能问答助理。这套架构本身不新鲜,真正耗时间的是显存怎么算、参数怎么设、文档怎么切、检索怎么兜底。这篇把整条链路拆开讲,手上有卡、有文档、又不想让问答出内网的,可以照着走一遍。
2. 环境与模型选型:6B 全精度还是 INT4 量化,先算显存再动手
选型这一步最容易拍脑袋。看到"6B 参数量不大"就直接拉全精度权重,跑起来发现模型加载完只剩几百兆余量,请求一并发立刻 OOM。参数量的单位是十亿,占用要乘上每个参数的字节数,再叠上推理时的 KV cache 和框架自身的开销,账面数字和实际能吃下的并发差得很远。
2.1 显存账本:权重、KV cache 与框架开销三层
先把权重算清楚。FP16 每个参数 2 字节,6B 模型光权重就在 12GB 上下;INT8 压到 6GB 出头;INT4 理论 3GB,算上分组量化的 scale 和 zero-point,实际落在 4GB 左右。这是静态占用,加载完就定型。
真正会随着对话增长的是 KV cache。以 28 层、hidden size 4096 的 6B 模型为例,每个 token 在每层要缓存一对 K/V 向量,单 token 占用约2 × 4096 × 2 字节 × 28 层 ≈ 448KB。2048 token 的上下文就是 900MB 左右,4096 token 接近 1.8GB。这个数字还只是 batch=1 的情况,并发数一乘,显存曲线会陡得吓人。
| 精度 | 权重占用 | 2K 上下文 KV | 24G 单卡余量 | 适合场景 |
|---|---|---|---|---|
| FP16 | 约 12.5GB | 约 0.9GB | 宽裕但并发受限 | 质量优先、单人使用 |
| INT8 | 约 7GB | 约 0.9GB | 较宽裕 | 质量与成本折中 |
| INT4 | 约 4GB | 约 0.9GB | 可起多实例 | 显卡紧张、内网小流量 |
框架自身还要吃掉 1~2GB,CUDA context、cuBLAS workspace、PyTorch 的缓存分配器都算在里面。所以 24G 卡跑 FP16 的 6B,安全并发基本就是个位数;换成 INT4 才有余量做多实例或者开更大的 batch。
提示:显存规划按"峰值"算,不按"加载完那一刻"算。压测时把最长的那种问题、最长的检索上下文、最高并发一起打上去,看
nvidia-smi的峰值才是真实门槛。
2.2 推理运行时:transformers 原生、官方 API 脚本、vLLM 怎么选
同一份权重,换不同的运行时,吞吐能差好几倍。我一般按流量规模分三档:
| 运行时 | 部署复杂度 | 流式输出 | 并发能力 | 适用判断 |
|---|---|---|---|---|
| transformers 直接加载 | 最低 | 需自己包 | 弱,单进程串行 | 验证模型、写脚本 |
| 仓库自带 openai_api 脚本 | 低 | 内置 | 中等,靠 uvicorn | 内网十几人小团队 |
| vLLM / 连续批处理框架 | 中 | 内置 | 强,PagedAttention | 几十人以上并发 |
小团队落地,最省事的是用仓库自带的 OpenAI 兼容脚本,接口格式统一,前端不用改代码。人数上到几十、又要求首字延迟稳定,就得上带连续批处理的框架。这里有个血泪教训:vLLM 对 ChatGLM 架构的支持是近几个版本才补齐的,不同版本对模型目录结构的要求不一样,上线前一定拿自己的权重目录在测试机跑通一轮,别直接信文档里的示例。
2.3 环境搭建:版本对齐比装包本身更重要
深度学习环境翻车,八成是 CUDA、驱动、PyTorch 三者版本错位。我的习惯是先看驱动支持的最高 CUDA 版本,再倒推着装 PyTorch,最后才装业务依赖。
# 1. 建独立环境,Python 3.10 是目前兼容性最好的选择 conda create -n qa python=3.10 -y conda activate qa # 2. 先装 PyTorch,index-url 里的 cu118 按显卡驱动支持的版本替换 pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 # 3. 模型加载与推理依赖 pip install transformers accelerate sentencepiece cpm_kernels # 4. 服务层依赖 pip install fastapi uvicorn sse-starlette # 5. 检索层依赖,先用 CPU 版 FAISS 验证链路 pip install faiss-cpu sentence-transformers langchain-text-splitters装完先验证 GPU 是否真的可见,别等模型加载到一半才发现跑在 CPU 上:
import torch print("cuda available:", torch.cuda.is_available()) print("device count:", torch.cuda.device_count()) print("device name:", torch.cuda.get_device_name(0)) print("torch cuda ver:", torch.version.cuda) # 预期输出应显示 True、卡数与具体型号,版本号与安装时选择的 cu 版本一致这段检查看着多余,但能省掉后面大量误判。torch.cuda.is_available()返回 False 的常见原因是装了 CPU 版 wheel,或者驱动版本低于 CUDA runtime 要求;device name打出来是集显,说明容器没挂到独显。cpm_kernels是 ChatGLM 量化推理要用的算子包,装不上通常是没有编译工具链,补build-essential即可。faiss-cpu先跑通链路,等索引规模上万条再换faiss-gpu,避免一开始就把环境搞复杂。
3. 把模型跑起来:从加载脚本到 OpenAI 兼容接口
环境过了,下一步是让模型开口说话。这一步的目标不是"能生成文字",而是"稳定地、按预期参数、以接口形式吐出答案"。中间要跨过加载、多轮、流式、接口封装四道坎。
3.1 最小加载脚本:trust_remote_code 与 dtype 两个必填项
import torch from transformers import AutoTokenizer, AutoModel MODEL_PATH = "/data/models/chatglm3-6b" # trust_remote_code 必须开,ChatGLM 的建模代码不在 transformers 主干里 tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH, trust_remote_code=True) model = AutoModel.from_pretrained( MODEL_PATH, trust_remote_code=True, torch_dtype=torch.float16, # 半精度,显存减半;显卡不支持 bf16 时别改成 bfloat16 device_map="auto", # 多卡时自动切分,单卡时等价于全部放到 0 号卡 ).eval() # 推理模式,关掉 dropout,必须调用 question = "用一句话解释什么是向量检索" response, history = model.chat(tokenizer, question, history=[]) print(response)trust_remote_code=True是硬性要求,ChatGLM 的网络结构和 tokenizer 逻辑随权重一起分发,不开这个参数会直接报模型类找不到。torch_dtype决定显存占用和数值稳定性,30 系及更早的卡用 float16,40 系和 A 系列可以试 bfloat16,数值范围更大、溢出风险更低。device_map="auto"在 accelerate 的调度下把层分到各卡,单卡场景下等价于全量放到 0 号卡,但显式写出来更利于以后扩卡。.eval()容易被漏掉,漏了之后推理结果会带随机性,同一个问题两次回答不一样,排查起来很折磨。
model.chat返回的是元组,第二个元素是累积的对话历史,格式是[(用户话, 模型话), ...]。这个历史必须由调用方自己维护并传回去,服务端不保存状态。
3.2 多轮与流式:history 的增长和截断
多轮对话直接复用返回值里的 history:
history = [] for q in ["这份工单属于哪个模块", "那它的优先级怎么定", "给我一句处理建议"]: response, history = model.chat(tokenizer, q, history=history) print("Q:", q) print("A:", response)问题在于 history 只增不减。聊到第十轮,上下文可能已经三四千 token,KV cache 跟着涨,响应越来越慢,最后 OOM。生产环境一定要做截断,常见做法是只保留最近 N 轮,或者按 token 数从旧到新删。
MAX_HISTORY_TOKENS = 1500 def trim_history(tokenizer, history, max_tokens=MAX_HISTORY_TOKENS): """从最新的对话往旧删,直到历史 token 数低于阈值""" kept, total = [], 0 for user_msg, bot_msg in reversed(history): cost = len(tokenizer.encode(user_msg)) + len(tokenizer.encode(bot_msg)) if total + cost > max_tokens: break kept.insert(0, (user_msg, bot_msg)) total += cost return keptmax_tokens设多大取决于模型窗口和业务容忍度。问答场景里,用户很少真的需要记住十轮以前的内容,1500 token 左右通常够用;如果业务是长文档追问,把这个值抬高,同时把单次请求的并发压下来,用吞吐换上下文。
流式输出用stream_chat,它返回生成器,每 yield 一次就是当前累计的完整文本:
for partial, history in model.stream_chat(tokenizer, "总结一下这段报错", history=history): print(partial, end="", flush=True) # partial 是累计文本,不是增量,前端要按"覆盖"而不是"追加"处理这里有个容易踩的点:stream_chat每次吐的是从开头到当前的完整字符串,不是增量片段。前端如果按追加方式拼接,会看到重复内容不断堆叠。
3.3 生成参数:问答场景的一组可用基线
参数没有万能值,但问答类应用有一条相对稳的基线:
| 参数 | 推荐值 | 作用与调法 |
|---|---|---|
| temperature | 0.1~0.3 | 越低越确定,问答场景别超过 0.5 |
| top_p | 0.7~0.9 | 与 temperature 二选一重点调,别同时拉到极端 |
| max_length | 2048~4096 | 输入加输出的总窗口,受显存约束 |
| max_new_tokens | 256~512 | 单次回答长度上限,超了会被硬截断 |
| repetition_penalty | 1.05~1.1 | 高于 1.2 容易让句子变别扭 |
temperature 在问答场景要压低,是因为这类任务要的是"照着资料复述",不是"自由发挥"。我见过把 temperature 设成 0.9 的配置,模型对同一个问题每次给的答案细节都不一样,客服团队直接不敢用。max_new_tokens要和前端展示逻辑对齐,设 256 却期望输出八百字,用户看到的就是半句话戛然而止。
3.4 封装成 OpenAI 兼容接口
仓库自带的openai_api.py会把模型包成/v1/chat/completions,参数用脚本内的 argparse 定义,启动前先看一眼它支持哪些开关:
# 模型路径、监听地址、端口按脚本实际参数名填写 python openai_api.py \ --model-path /data/models/chatglm3-6b \ --device cuda \ --port 8000起来之后用 curl 验证,注意model字段要和脚本里注册的名字一致:
curl -s http://127.0.0.1:8000/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "chatglm3-6b", "messages": [{"role": "user", "content": "你好,做个自我介绍"}], "temperature": 0.2, "stream": false }'model写错会返回模型不存在的错误,api_key在这种本地脚本里通常不校验,但前端 SDK 一般要求字段存在,随便填一个非空字符串即可。stream: true时返回的是 SSE 流,每行以data:开头,最后以data: [DONE]结束,反向代理要关掉缓冲,否则前端会等到全部生成完才一次性收到。
4. 知识库接入:RAG 链路里最容易翻车的三个环节
模型能对话不等于能答业务问题。内部文档里的制度、流程、参数,模型训练时根本没见过,硬问就是编。检索增强这条链路就是把这部分外部知识在提问时喂进去,难点不在"接上",在于"接准"。
4.1 文档切分:chunk_size 与 overlap 的经验值
切分是整条链路的第一道闸。切太大,检索命中后噪声多,模型容易被无关内容带偏;切太小,一句完整的话被腰斩,语义残缺。
from langchain_text_splitters import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter( chunk_size=400, # 按中文字符数,约对应 500 token 以内 chunk_overlap=80, # 相邻块重叠,防止关键句被切断 separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""], ) chunks = splitter.split_text(raw_text) print(len(chunks), chunks[0][:120])separators的顺序决定优先在哪切。中文文档必须先按段落、再按句号问号感叹号切,最后才退化到按字符硬切。默认的英文分隔符里没有中文标点,直接沿用会把整段话切在莫名其妙的位置。chunk_size=400是个折中值,制度类文档一段通常两三百字,400 能保住上下文又不至于太散;技术手册这种一句一参数的,可以降到 200~250。chunk_overlap=80保证跨块的句子至少在一侧是完整的,重叠太小起不到作用,太大会让索引膨胀、检索结果重复。
切完最好人工抽十条看看边界,这一步花十分钟,能省掉后面大量"明明文档里有却答不出来"的排查。
4.2 向量化与检索:归一化加内积等价余弦
import faiss import numpy as np from sentence_transformers import SentenceTransformer encoder = SentenceTransformer("/data/models/bge-small-zh") # normalize_embeddings 必须开,归一化后内积等价于余弦相似度 emb = encoder.encode(chunks, normalize_embeddings=True, batch_size=32) emb = np.asarray(emb, dtype="float32") index = faiss.IndexFlatIP(emb.shape[1]) # IP = 内积索引 index.add(emb) def search(query, top_k=5, score_threshold=0.35): qv = encoder.encode([query], normalize_embeddings=True) scores, ids = index.search(np.asarray(qv, dtype="float32"), top_k) hits = [] for score, idx in zip(scores[0], ids[0]): if idx == -1 or score < score_threshold: continue hits.append({"text": chunks[idx], "score": float(score)}) return hits归一化这一步不做,内积索引算出来的分数跟向量长度挂钩,长文本天然占优,排序会失真。IndexFlatIP是暴力检索,几千到几万条 chunk 完全够用,延迟在毫秒级;上到十万条再考虑 IVF 或 HNSW,别一上来就上近似索引,召回率掉了很难查。score_threshold是兜底闸门,低于阈值直接丢弃,宁可让模型回答"资料中未提及",也别把不相关内容硬塞进去。阈值怎么定:拿二十个典型问题跑一遍,看正确命中块的分数分布,取下沿再往下放一点。
4.3 Prompt 拼装:把"不知道就说不知道"写死
检索回来的内容怎么放进 prompt,直接决定模型是老实回答还是自由发挥。
PROMPT_TEMPLATE = """你是一个内部知识库问答助理。请严格依据下面的资料回答问题。 资料: {context} 问题:{question} 规则: 1. 只能使用资料中出现的信息,资料没有的内容直接回答"资料中未提及",不要推测。 2. 回答末尾用 [编号] 标注引用的资料,例如 [1][2]。 3. 如果资料之间互相矛盾,指出矛盾点并分别列出。 """ def build_prompt(question, hits): context = "\n\n".join( f"[{i + 1}] {h['text']}" for i, h in enumerate(hits) ) return PROMPT_TEMPLATE.format(context=context, question=question)三条规则里,第一条是止血带。"不要推测"这句话看着像废话,但实测能显著压低编造率,尤其是资料只覆盖了一半问题的时候。第二条让回答可追溯,用户点开编号能看到原文,信任度完全不一样。第三条针对内部文档的常见情况——制度改版后新旧两版并存,模型如果只挑一个说,等于制造错误信息。context拼装时给每块加编号,模型引用时才有锚点。
上下文长度要留预算。模型总窗口 4096,max_new_tokens留 512,历史留 1000,剩下给资料的大概 2500 token,按每块 500 token 算就是五块。超了就按分数从低到高丢,别硬塞,塞进去反而稀释了关键信息。
4.4 混合检索:BM25 补上向量的短板
纯向量检索对语义相近但字面不同的问法很友好,可遇到型号、编号、专有名词这类精确匹配就发虚。内部工单里全是ERR-4032、模块 B-7这种串,向量模型往往召回不准。常见做法是加一路 BM25 做关键词召回,两路结果用 RRF 融合:
def rrf_fuse(vec_ids, bm25_ids, k=60): """Reciprocal Rank Fusion:按排名倒数加权,无需对齐两路分数量纲""" score = {} for rank, doc_id in enumerate(vec_ids): score[doc_id] = score.get(doc_id, 0) + 1 / (k + rank + 1) for rank, doc_id in enumerate(bm25_ids): score[doc_id] = score.get(doc_id, 0) + 1 / (k + rank + 1) return sorted(score.items(), key=lambda x: -x[1])RRF 的好处是不用管两路分数一个是余弦相似度、一个是 BM25 分值,量纲完全不同也能直接融合。k=60是论文里的常用值,作用是压平头部差距,让两路都有贡献。融合后取前五块进 prompt,实测对含编号类问题的召回提升很明显。
5. 避坑与排查:显存、输出、并发三类问题的定位顺序
上线之后的问题基本集中在三个方向,按"先看显存、再看输出、最后看服务"的顺序排查,能少绕很多弯。
5.1 显存与加载类:能起来不等于能服务
现象一:模型加载到 90% 报 CUDA out of memory,但nvidia-smi显示显存没满。原因通常是碎片化或缓存分配器预留。PyTorch 的 caching allocator 会预占显存,nvidia-smi看到的是进程占用,不是实际张量占用。解决办法是先export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True减少碎片,再不行就把max_length从 4096 降到 2048,KV cache 的余量立刻回来一截。
现象二:单人测试正常,第二天早上服务挂了。十有八九是 history 没截断,一晚上的多轮对话把 KV cache 撑爆。排查时在服务端打日志,记录每次请求的 history 长度和当前显存占用,看是不是单调上涨。解决就是 3.2 节那个截断函数,外加一个兜底:单会话 history 超过阈值直接重置并提示用户开启新会话。
现象三:加载 INT4 权重时报算子缺失或精度异常。ChatGLM 的量化路径依赖cpm_kernels,这个包在部分 Python 版本上没有预编译 wheel,需要本地编译;容器里缺 gcc 就会装成空壳。另一条路是直接用仓库提供的现成量化权重,跳过运行时量化。两条路的显存曲线不一样,运行时量化是先加载 FP16 再压缩,峰值显存按 FP16 算,这一点经常被忽略。
5.2 输出质量类:乱码、截断与复读
现象一:回答里出现大量重复句子,或者同一段话循环。先看repetition_penalty,低于 1.0 等于鼓励重复,正常应在 1.05~1.1。如果参数没问题,检查 prompt 里是不是把同一块资料拼了两遍——混合检索融合后没去重,同一个 chunk 从两路都召回,进 prompt 就重复了。融合后按文本内容做一次去重即可。
现象二:回答总是半句话结束。max_new_tokens设太小,或者前端按 token 数截断展示。前者调大,后者改前端。还有一种隐蔽情况:流式接口的最后一个 chunk 没被处理,前端拿到的就是残缺文本,检查 SSE 解析有没有处理[DONE]之前的收尾。
现象三:问 A 答 B,或者答得头头是道但文档里根本没写。先看检索结果,把 hits 的文本和分数打出来。分数普遍低于 0.35 说明根本没召回对,问题出在切分或 embedding 模型;分数高但内容不相关,说明 chunk 太大,一块里混了多个主题。确认检索没问题还编,就把 prompt 里的约束再收紧,并在后处理加一道校验:回答里出现的编号必须来自本次 hits,编号越界直接降级为"资料中未提及"。
5.3 服务与并发类:一并发就超时
现象:单请求 2 秒返回,压到 5 并发变成 30 秒,10 并发直接 504。transformers 原生推理是串行的,多个请求进来排队,第 10 个请求要等前 9 个跑完。这是架构问题不是参数问题。短期办法是限制并发数并在网关层做排队和超时提示;中期办法是上连续批处理框架,把多个请求拼成一个 batch 前向,吞吐能翻几倍。这里别踩的坑是:给 uvicorn 开多个 worker 并不能提升 GPU 吞吐,每个 worker 会各自加载一份模型,显存直接翻倍,最后 OOM 得更快。
现象:流式输出在本地正常,挂到反向代理后面就变成一次性返回。代理默认开缓冲,把 SSE 攒成一整块。关掉对应 location 的proxy_buffering,并把超时时间调到大于最长生成时间。
现象:前端调接口报跨域。本地脚本默认不一定带 CORS 头。要么在服务端加允许来源,要么前端走同源代理转发,后者更省事,也不用在生产环境放开宽泛的来源限制。
6. 进阶用法与验证:把回答质量做成可回归的评测
链路跑通只是起点,真正决定这套东西能不能长期用的是"改动之后知不知道自己变好了还是变差了"。我一般会攒一个小评测集,五十条左右,覆盖四类问题,每次改切分、改 prompt、换 embedding 模型都跑一遍:
| 问题类型 | 数量 | 期望行为 | 判定方式 |
|---|---|---|---|
| 文档原文可查 | 20 | 答出原文关键句 | 关键词命中 |
| 需要跨块综合 | 10 | 合并两块信息 | 人工看是否遗漏 |
| 文档中不存在 | 10 | 回答"资料中未提及" | 严格匹配,不许编 |
| 含编号/型号 | 10 | 精确召回对应条目 | 编号是否出现在回答 |
第三类最关键,很多人只测"能不能答对",不测"该拒答时有没有拒答",结果上线后模型对着空白文档一顿输出,用户信了反而更危险。判定不用上模型打分,关键词匹配加人工抽查就够,成本低且稳定。
观测指标上,除了准确率,我还会盯首字延迟和完整响应时间。首字延迟决定用户感知的"快慢",超过 3 秒体感就很差;完整响应时间影响并发容量。这两个数在流式接口里很好采:记录请求进入时间和第一个 chunk 的时间戳做差即可。
import time def timed_stream(question, history): t0 = time.time() first_token_at = None for partial, history in model.stream_chat(tokenizer, question, history=history): if first_token_at is None: first_token_at = time.time() yield partial, history print(f"首字延迟 {first_token_at - t0:.2f}s, 总耗时 {time.time() - t0:.2f}s")这段计时代码简单,但能暴露很多问题:首字延迟突然从 0.8 秒涨到 4 秒,通常意味着 history 或 context 变长了;总耗时涨而首字延迟没变,多半是max_new_tokens被调大。把它挂到每次发版的冒烟测试里,比事后翻日志高效得多。
血泪经验是:任何一次"只改一点点"的调整——换个 embedding 模型、把 chunk_size 从 400 改到 600、prompt 里多加一句约束——都必须跑一遍这五十条。我吃过亏,某次为了提升召回把 top_k 从 5 调到 8,结果拒答率掉了十几个点,因为塞进去的边角料让模型有了"编造素材"。从那以后我每次动检索参数,都强制走一遍评测集再合并代码。希望这套流程能帮到你,少走几段弯路。
本文还有配套的精品资源,点击获取