
简介面向希望本地私有化搭建智能问答助理的开发者与机器学习学习者这份示例项目以 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。这个数字还只是 batch1 的情况并发数一乘显存曲线会陡得吓人。精度权重占用2K 上下文 KV24G 单卡余量适合场景FP16约 12.5GB约 0.9GB宽裕但并发受限质量优先、单人使用INT8约 7GB约 0.9GB较宽裕质量与成本折中INT4约 4GB约 0.9GB可起多实例显卡紧张、内网小流量框架自身还要吃掉 12GBCUDA 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 python3.10 -y conda activate qa # 2. 先装 PyTorchindex-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_codeTrue) model AutoModel.from_pretrained( MODEL_PATH, trust_remote_codeTrue, torch_dtypetorch.float16, # 半精度显存减半显卡不支持 bf16 时别改成 bfloat16 device_mapauto, # 多卡时自动切分单卡时等价于全部放到 0 号卡 ).eval() # 推理模式关掉 dropout必须调用 question 用一句话解释什么是向量检索 response, history model.chat(tokenizer, question, history[]) print(response)trust_remote_codeTrue是硬性要求ChatGLM 的网络结构和 tokenizer 逻辑随权重一起分发不开这个参数会直接报模型类找不到。torch_dtype决定显存占用和数值稳定性30 系及更早的卡用 float1640 系和 A 系列可以试 bfloat16数值范围更大、溢出风险更低。device_mapauto在 accelerate 的调度下把层分到各卡单卡场景下等价于全量放到 0 号卡但显式写出来更利于以后扩卡。.eval()容易被漏掉漏了之后推理结果会带随机性同一个问题两次回答不一样排查起来很折磨。model.chat返回的是元组第二个元素是累积的对话历史格式是[(用户话, 模型话), ...]。这个历史必须由调用方自己维护并传回去服务端不保存状态。3.2 多轮与流式history 的增长和截断多轮对话直接复用返回值里的 historyhistory [] for q in [这份工单属于哪个模块, 那它的优先级怎么定, 给我一句处理建议]: response, history model.chat(tokenizer, q, historyhistory) print(Q:, q) print(A:, response)问题在于 history 只增不减。聊到第十轮上下文可能已经三四千 tokenKV cache 跟着涨响应越来越慢最后 OOM。生产环境一定要做截断常见做法是只保留最近 N 轮或者按 token 数从旧到新删。MAX_HISTORY_TOKENS 1500 def trim_history(tokenizer, history, max_tokensMAX_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, 总结一下这段报错, historyhistory): print(partial, end, flushTrue) # partial 是累计文本不是增量前端要按覆盖而不是追加处理这里有个容易踩的点stream_chat每次吐的是从开头到当前的完整字符串不是增量片段。前端如果按追加方式拼接会看到重复内容不断堆叠。3.3 生成参数问答场景的一组可用基线参数没有万能值但问答类应用有一条相对稳的基线参数推荐值作用与调法temperature0.10.3越低越确定问答场景别超过 0.5top_p0.70.9与 temperature 二选一重点调别同时拉到极端max_length20484096输入加输出的总窗口受显存约束max_new_tokens256512单次回答长度上限超了会被硬截断repetition_penalty1.051.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_size400, # 按中文字符数约对应 500 token 以内 chunk_overlap80, # 相邻块重叠防止关键句被切断 separators[\n\n, \n, 。, , , , , , ], ) chunks splitter.split_text(raw_text) print(len(chunks), chunks[0][:120])separators的顺序决定优先在哪切。中文文档必须先按段落、再按句号问号感叹号切最后才退化到按字符硬切。默认的英文分隔符里没有中文标点直接沿用会把整段话切在莫名其妙的位置。chunk_size400是个折中值制度类文档一段通常两三百字400 能保住上下文又不至于太散技术手册这种一句一参数的可以降到 200250。chunk_overlap80保证跨块的句子至少在一侧是完整的重叠太小起不到作用太大会让索引膨胀、检索结果重复。切完最好人工抽十条看看边界这一步花十分钟能省掉后面大量明明文档里有却答不出来的排查。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_embeddingsTrue, batch_size32) emb np.asarray(emb, dtypefloat32) index faiss.IndexFlatIP(emb.shape[1]) # IP 内积索引 index.add(emb) def search(query, top_k5, score_threshold0.35): qv encoder.encode([query], normalize_embeddingsTrue) scores, ids index.search(np.asarray(qv, dtypefloat32), 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(contextcontext, questionquestion)三条规则里第一条是止血带。不要推测这句话看着像废话但实测能显著压低编造率尤其是资料只覆盖了一半问题的时候。第二条让回答可追溯用户点开编号能看到原文信任度完全不一样。第三条针对内部文档的常见情况——制度改版后新旧两版并存模型如果只挑一个说等于制造错误信息。context拼装时给每块加编号模型引用时才有锚点。上下文长度要留预算。模型总窗口 4096max_new_tokens留 512历史留 1000剩下给资料的大概 2500 token按每块 500 token 算就是五块。超了就按分数从低到高丢别硬塞塞进去反而稀释了关键信息。4.4 混合检索BM25 补上向量的短板纯向量检索对语义相近但字面不同的问法很友好可遇到型号、编号、专有名词这类精确匹配就发虚。内部工单里全是ERR-4032、模块 B-7这种串向量模型往往召回不准。常见做法是加一路 BM25 做关键词召回两路结果用 RRF 融合def rrf_fuse(vec_ids, bm25_ids, k60): 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(), keylambda x: -x[1])RRF 的好处是不用管两路分数一个是余弦相似度、一个是 BM25 分值量纲完全不同也能直接融合。k60是论文里的常用值作用是压平头部差距让两路都有贡献。融合后取前五块进 prompt实测对含编号类问题的召回提升很明显。5. 避坑与排查显存、输出、并发三类问题的定位顺序上线之后的问题基本集中在三个方向按先看显存、再看输出、最后看服务的顺序排查能少绕很多弯。5.1 显存与加载类能起来不等于能服务现象一模型加载到 90% 报 CUDA out of memory但nvidia-smi显示显存没满。原因通常是碎片化或缓存分配器预留。PyTorch 的 caching allocator 会预占显存nvidia-smi看到的是进程占用不是实际张量占用。解决办法是先export PYTORCH_CUDA_ALLOC_CONFexpandable_segments:True减少碎片再不行就把max_length从 4096 降到 2048KV cache 的余量立刻回来一截。现象二单人测试正常第二天早上服务挂了。十有八九是 history 没截断一晚上的多轮对话把 KV cache 撑爆。排查时在服务端打日志记录每次请求的 history 长度和当前显存占用看是不是单调上涨。解决就是 3.2 节那个截断函数外加一个兜底单会话 history 超过阈值直接重置并提示用户开启新会话。现象三加载 INT4 权重时报算子缺失或精度异常。ChatGLM 的量化路径依赖cpm_kernels这个包在部分 Python 版本上没有预编译 wheel需要本地编译容器里缺 gcc 就会装成空壳。另一条路是直接用仓库提供的现成量化权重跳过运行时量化。两条路的显存曲线不一样运行时量化是先加载 FP16 再压缩峰值显存按 FP16 算这一点经常被忽略。5.2 输出质量类乱码、截断与复读现象一回答里出现大量重复句子或者同一段话循环。先看repetition_penalty低于 1.0 等于鼓励重复正常应在 1.051.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, historyhistory): 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结果拒答率掉了十几个点因为塞进去的边角料让模型有了编造素材。从那以后我每次动检索参数都强制走一遍评测集再合并代码。希望这套流程能帮到你少走几段弯路。本文还有配套的精品资源点击获取