首页 / 资讯中心 / 文章详情

ChatGLM私有化问答助理:RAG链路、显存调优与内网落地

ChatGLM私有化问答助理:RAG链路、显存调优与内网落地 ★ FEATURED ARTICLE
简介面向希望本地化部署智能问答系统的开发者与学习者这份示例项目以 Gradio 构建文件上传与对话界面结合 ChatGLM 大模型、FAISS 向量检索和 Sentence-Transformers 文本嵌入实现从知识库文档中检索相关信息并生成回答适合用来理解私有化问答助理的完整链路、定制化调整与数据隐私保护需求。压缩包共18个文件约336KB包含5个py源码、5个txt配置与说明、2个yaml及whl、sh等依赖和运行脚本并附docx、xlsx、pdf等示例文档便于对照项目结构与配置进行调试。已有140人浏览学习。读者可获得可运行的项目骨架、依赖安装与启动脚本、知识库样例和私有化部署思路其中源码覆盖界面、检索、模型调用等模块配置与说明文件可用于环境准备和参数调整适合作为智能问答与检索增强生成方向的入门实践参考。1. 为什么把智能问答助理搬回本地从一次内网问答翻车说起某公司内网知识库接的是公有云问答接口回答流畅但数据出域审批一拖就是两个月另一组为了赶进度直接把一个 ChatGLM 系对话模型拷到 16GB 显存卡上跑结果模型加载成功第三轮追问就 OOMPDF 里的表格串成乱码回答还引用了不存在的页码。本地部署私有化智能问答助理真正的难点不在“把模型跑起来”而在模型、检索、提示词、审计四段链路能不能闭环。机器学习负责切分和召回人工智能负责组织语言ChatGLM 架构适合做中文对话底座但它不负责替你保证知识库干净。适合需要数据不出域、有 1 到 2 张显卡、能维护 Python 服务的团队先小范围落地。2. 选型与运行环境ChatGLM 架构的私有化问答助理需要哪些底座2.1 模型规格与量化档位怎么选私有化问答助理的第一道分岔是模型规格。6B 参数级别的对话模型在中文问答、指令跟随和显存占用之间比较平衡适合作为示例项目的默认档位13B 级别回答更稳但对显存和首 token 延迟的要求明显上来。量化不是免费午餐8bit 量化能把权重占用压到大约一半4bit 压得更狠但低比特量化对长文档问答、数字和专有名词的保真度更敏感尤其是内部制度、型号、金额这类字段量化后容易“看着像对的”。我一般会让团队先用 6B 非量化跑通链路再用 8bit 复测同一批问题最后才决定生产档位。显存预算别只算权重。一个粗略公式是权重占用约等于参数量乘以每参数字节数再叠加 KV cache、框架开销和并发余量。6B 模型 FP16 权重约 12GB实际加载后还要给上下文和批处理留空间8bit 约 7GB 到 9GB4bit 约 5GB 到 6GB但长上下文和并发会把 KV cache 迅速抬高。16GB 显存卡建议从 6B 8bit、单路并发开始24GB 卡可以尝试 6B FP16 或 13B 4bit纯 CPU 只适合做离线兜底和功能验证不适合多人同时提问。档位权重占用大致范围适合场景主要代价6B FP1612GB 左右单卡 24GB、质量优先并发低KV cache 吃紧6B 8bit7GB 到 9GB16GB 卡、内网小团队数字与专名可能漂移6B 4bit5GB 到 6GB显存紧张、先验证链路长文档问答稳定性下降13B 4bit8GB 到 10GB24GB 卡、回答质量优先首 token 慢调参成本高选型时还要看 tokenizer 和模型目录是否完整。示例项目把模型目录当成只读资产不让应用往模型目录里写缓存否则容器重启后权限错乱加载时报的错往往指向“文件不存在”实际是挂载路径或用户权限问题。模型目录里常见的是配置文件、词表、权重分片和生成配置缺一个都可能让加载在半路失败。不要用“能加载”判断模型完整要用一次固定问题的生成结果做冒烟测试。2.2 推理后端与显存预算推理后端的选择取决于并发和交付形态。原生 Transformers 推理接入快、调试直观适合示例项目和单路验证量化推理后端对显存更友好但和特定模型结构的兼容性要提前验证服务化推理后端吞吐高却会引入额外的部署组件和版本对齐成本。我的习惯是先用原生推理把问答链路跑通确认检索、提示词、引用格式都没问题再评估是否换吞吐更高的后端。否则一上来就上服务化后端出了问题分不清是模型、检索还是网关。显存预算要按“加载后空闲显存”而不是“总显存”做判断。加载模型后先跑一轮 1024 token 的生成再看空闲显存如果只剩不到 2GB并发一上来就会翻车。KV cache 会随着上下文长度和并发数增长max_length 设得越大单路占用越高。示例项目默认把 max_length 控制在 1024 到 2048问答场景够用如果业务要求整篇文档问答宁可先做检索切分也不要把整篇文档塞进上下文。环境检查不要只看显卡型号。下面这段脚本能确认 CUDA 是否可用、当前显存余量以及 PyTorch 是否能正常分配张量import torch print(CUDA 可用:, torch.cuda.is_available()) if torch.cuda.is_available(): free, total torch.cuda.mem_get_info() print(f显存空闲 {free / 1024**3:.1f} GB / 总计 {total / 1024**3:.1f} GB) # 分配一个小张量确认驱动和运行时没有错配 x torch.randn(1024, 1024, devicecuda) print(张量设备:, x.device)这段代码不依赖具体显卡品牌只看运行时状态。torch.cuda.is_available()为 False 时先查驱动、容器运行时和 Python 环境是否在同一层mem_get_info()返回的是空闲和总显存能避免“总显存够、空闲不够”的误判最后的小张量分配是冒烟测试能提前暴露驱动与运行时错配。参数上不要把1024改得太大否则检查脚本自己就把显存吃掉了。2.3 最小可用目录结构示例项目的目录结构按“模型只读、数据可替换、索引可重建、日志可审计”来分。模型目录独立挂载应用目录只放代码和配置知识库原始文件、切分后的中间文件、向量索引、日志分别落盘。这样做的直接好处是换模型不碰业务代码换文档不碰模型目录出问题能按层定位。app/ server.py # 问答服务入口 rag.py # 检索与提示词拼装 parser.py # 文档解析与清洗 configs/ app.yaml # 模型路径、端口、并发、超时 prompts/ qa_system.txt # 系统提示词与拒答边界 models/ chatglm-local/ # 只读模型目录 data/ raw/ # 原始文档按部门/来源分目录 processed/ # 清洗后的文本与元数据 vector_store/ index/ # 可重建的向量索引 logs/ app/ # 请求日志 audit/ # 问答审计留痕 scripts/ build_index.py # 重建索引 smoke_test.py # 固定问题冒烟测试目录结构不是摆设。models/chatglm-local/挂载为只读能防止应用把缓存写进模型目录data/raw/保留原始文件索引坏了可以重建vector_store/index/明确标记为可重建资产不要把它当唯一数据源logs/audit/记录问题、召回文档编号和引用编号出问题时能回放。配置里把模型路径、并发数、超时、最大上下文长度都外置别写死在代码里否则换环境就要改代码。3. 把示例项目跑起来模型加载、知识库接入与流式问答3.1 拉取模型与校验文件离线环境里最常见的问题不是模型不会加载而是模型文件在拷贝过程中缺分片、权限不对、校验没做。示例项目要求模型目录先做一次文件清单到目标机器后复核。下面这段命令在模型目录内生成校验清单再在目标机器上校验# 在模型目录内生成校验清单只处理当前层文件 find . -maxdepth 1 -type f -print0 | sort -z | xargs -0 sha256sum SHA256SUMS # 到目标机器后复核任何 FAILED 都先别启动服务 sha256sum -c SHA256SUMSfind的-maxdepth 1只处理当前层避免把缓存目录也扫进去-print0和xargs -0处理带空格的文件名sort -z保证清单顺序稳定。sha256sum -c输出OK才继续出现FAILED时不要用“重新拷一遍试试”掩盖问题先看是分片缺失还是拷贝截断。模型目录权限建议设为只读运行用户只保留读权限避免应用写缓存后把目录搞脏。3.2 启动推理服务参数与端口模型加载代码要显式控制精度、设备映射和生成参数。下面这段是示例项目的最小加载与单轮问答from transformers import AutoTokenizer, AutoModel import torch MODEL_PATH /data/models/chatglm-local # 词表和模型都从本地目录加载避免运行时访问外部地址 tokenizer AutoTokenizer.from_pretrained( MODEL_PATH, trust_remote_codeTrue, ) model AutoModel.from_pretrained( MODEL_PATH, trust_remote_codeTrue, torch_dtypetorch.float16, device_mapauto, load_in_8bitFalse, ) model.eval() prompt 用三句话说明内网知识库问答的检索流程 response, history model.chat( tokenizer, prompt, history[], temperature0.2, top_p0.8, max_length1024, ) print(response)trust_remote_codeTrue表示加载模型目录里的自定义代码离线环境要先确认这份代码来源可信最好锁定版本后随模型一起归档torch_dtypetorch.float16控制权重精度显存不足时再考虑 8bit 或 4bitdevice_mapauto让框架自动分配设备多卡时能减少手工指定load_in_8bitFalse是显式关闭量化方便先跑质量基线。生成参数里temperature0.2让回答更收敛top_p0.8控制候选范围max_length1024限制总上下文防止长回答把显存顶满。history[]表示单轮问答多轮会话要由服务层做历史裁剪。服务启动时把模型路径、端口、并发和最大长度外置# 单进程启动示例服务先压单路确认稳定后再加并发 python -m app.server \ --model-path /data/models/chatglm-local \ --host 0.0.0.0 \ --port 18080 \ --max-concurrency 2 \ --max-length 1024 \ --request-timeout 60--max-concurrency 2表示同时最多两路生成超过后排队--max-length 1024要和模型加载时的生成参数一致避免服务层和模型层各说各话--request-timeout 60是请求级超时流式接口要单独看首 token 时间和总时长。端口不要直接暴露到公网内网也建议放在统一网关后面做鉴权和限流。启动日志里要打印模型路径、量化档位、最大并发和可用显存排错时这几项比“服务已启动”有用得多。3.3 接入本地知识库的检索链路知识库接入按“解析、清洗、切分、向量化、索引、召回、重排、拼提示词”走。示例项目先用轻量向量检索把链路跑通生产再换服务化向量库。下面是一个可复现的切分与余弦召回片段向量部分用抽象函数表示替换成实际 embedding 服务即可import numpy as np def split_text(text, chunk_size500, overlap80): 按字符切分overlap 防止答案被切断中文按字符估算更稳。 chunks [] start 0 while start len(text): end min(start chunk_size, len(text)) chunk text[start:end].strip() if chunk: chunks.append(chunk) if end len(text): break start end - overlap return chunks def cosine_search(query_vec, doc_vectors, top_k5): query_vec 和 doc_vectors 需先归一化分数越大越相关。 scores doc_vectors query_vec order np.argsort(scores)[::-1][:top_k] return [(int(i), float(scores[i])) for i in order] def build_prompt(question, docs): 召回为空时直接返回 None由服务层走拒答。 if not docs: return None refs \n.join( f[{i 1}] 来源{d[source]} 页码{d.get(page, -)}\n{d[text]} for i, d in enumerate(docs) ) return f参考资料\n{refs}\n\n问题{question}\n回答要求只依据参考资料逐条标注引用编号。chunk_size500是字符级估算中文场景下大约对应几百个 token具体要按 embedding 模型的上下文长度调整overlap80让相邻块有重叠减少答案跨块被切断cosine_search前要先做向量归一化否则点积不是余弦相似度top_k5是召回条数后面还可以加重排。build_prompt里把来源和页码带进上下文生成后要求模型标注引用编号服务层再用编号回查文档避免回答里出现“知识库没有的页码”。召回为空时不要硬答直接返回“当前知识库未收录”这是私有化问答助理的底线。3.4 前端问答与会话隔离前端接入建议用会话 ID 隔离历史服务端按 session_id 维护最近几轮对话超出窗口就裁剪。流式接口用 SSE 或分块响应都行关键是首 token 时间、结束标记和错误标记要明确。下面是一个请求示例curl -N http://127.0.0.1:18080/chat \ -H Content-Type: application/json \ -d {session_id:s-001,question:报销流程是什么,top_k:5}-N关闭 curl 缓冲方便看流式输出session_id用于隔离不同用户的历史不要把全局历史混在一起top_k5是本次请求的召回条数服务端可以设上限防止拖垮检索。返回内容里建议包含answer、references、latency_ms和trace_id其中references至少带来源文件名和页码trace_id用于把前端问题和后端日志对上。多轮会话不要把完整历史无限拼接保留最近两到三轮即可否则上下文会挤掉召回文档。4. 私有化问答的效果调优提示词、切分与召回参数4.1 系统提示词与拒答边界提示词要解决三件事只依据参考资料、不知道就拒答、回答带引用。示例项目的系统提示词如下你是内网知识库问答助理只依据【参考资料】回答。 如果参考资料没有答案直接说“当前知识库未收录”不要编造。 回答时给出引用编号格式为 [1][2]。 不要输出与问题无关的背景知识。这段提示词不是越长越好。第一句限定来源第二句给拒答出口第三句规定引用格式第四句防止模型自由发挥。生成参数建议temperature0.1到0.2、top_p0.7到0.9、repetition_penalty1.05左右温度太低会让回答僵硬太高则容易在数字和条款上漂移。拒答边界要在服务层也做一次召回为空直接拒答召回分数低于阈值也拒答模型输出里没有引用编号时回退到“未找到可靠依据”。提示词管模型服务层管兜底两层都要有。4.2 文档切分与元数据设计切分策略要按文档类型分。制度类文档按章节切每块带标题和条款号表格类文档先把表格转成结构化文本再按行或按表切扫描件先做 OCR再做文本清洗FAQ 按问答对切不要把问题和答案拆散。元数据至少保留来源文件、页码或章节、更新时间、文档版本。没有元数据的切分回答看起来对引用却对不上审计时就是黑匣子。def attach_metadata(chunks, source, pageNone, sectionNone, versionv1): 每个块都带来源信息引用回查时不用再猜。 docs [] for idx, text in enumerate(chunks): docs.append({ text: text, source: source, page: page, section: section, version: version, chunk_id: f{source}#{idx}, }) return docssource用相对路径或文档编号不要用用户本地绝对路径page对 PDF 有意义对纯文本可以留空section存章节标题方便回答里引用“第几章”version用于知识库更新后区分新旧块chunk_id是检索和日志的对账键。切分长度不要一刀切制度文档可以 400 到 600 字FAQ 可以按问答对完整保留表格行可以更短。切得太碎会丢上下文切得太长会稀释召回分数。4.3 召回数量与重排阈值召回参数用“先宽后窄”更稳向量召回 8 到 20 条重排后保留 3 到 5 条低于相似度阈值的直接丢掉。阈值没有万能值和 embedding 模型、文档质量、查询写法都有关。示例项目先设一个保守值再用固定问题集调参数建议起点调整依据常见误用召回 top_k8 到 12问题复杂、文档长就上调一上来设 50噪声淹没答案重排后保留3 到 5引用可读性和上下文长度平衡保留太多模型抓不住重点相似度阈值0.3 到 0.5用固定问题集看误拒和误召只看单条分数不看整体命中最大上下文1024 到 2048显存和回答长度共同决定把整篇文档塞进去调参时准备 30 到 50 条固定问题覆盖制度、流程、数字、表格和“知识库没有”的问题。每次只改一个参数记录召回命中率、引用正确率和拒答率。召回多不等于回答好重排后保留太多会把无关段落塞进上下文模型反而更容易跑偏。低于阈值的召回不要硬拼进提示词宁可拒答。知识库更新后要重建索引并复跑同一批问题否则新旧块混在一起回答会前后矛盾。5. 避坑与排查本地问答助理最容易翻车的五件事5.1 显存看似够却 OOMKV cache 与并发叠加现象模型加载成功第一轮问答正常第三轮追问或第二路并发进来就 OOM日志里能看到显存分配失败。原因通常不是权重太大而是 KV cache 随上下文长度和并发数增长服务层又开了多进程每个进程各加载一份模型。解决方法是先压单路并发限制最大上下文确认加载后空闲显存再把并发逐级加上去。启动参数里把max_concurrency设为 1 先跑冒烟测试稳定后再改 2max_length不要超过业务实际需要多进程部署改成单进程共享模型加请求队列。torch.cuda.empty_cache()只能回收缓存救不了持续增长的 KV cache。5.2 中文乱码与 PDF 解析断层现象回答里出现乱码、页码错位、表格内容串行或者同一段话重复出现。原因多是 PDF 没有文本层、扫描件没做 OCR、按固定字符切分把表格切断页眉页脚也被当成正文。解决方法是先检测每页文本层文本太少就走 OCR解析时保留页码表格单独转成结构化文本清洗页眉页脚和重复水印。下面这段检查能快速看出哪些页没有文本层import fitz doc fitz.open(sample.pdf) for i, page in enumerate(doc): text page.get_text(text).strip() # 文本长度过短通常意味着扫描页或图片页 print(i 1, len(text))fitz.open打开文档get_text(text)抽文本层len(text)过短就标记为待 OCR。页码用i 1保留后面写进元数据。不要用“解析失败就整篇丢掉”的做法扫描页可以单独走 OCR表格页可以单独保留。清洗规则要可回放别在解析脚本里写一堆正则把正文也删了。5.3 流式输出卡死与超时现象前端一直转圈后端日志显示生成已经结束但连接没有关闭或者首 token 正常中途突然断流。原因可能是反向代理缓冲了流式响应、生成器异常没有捕获、超时设置只覆盖了总时长没覆盖首 token。解决方法是关闭代理缓冲、给流式响应加结束标记和错误标记、在生成器里捕获异常并返回可识别的错误帧。下面是一个流式生成器的骨架async def stream_chat(generator): try: for token in generator: # SSE 帧格式前端按双换行切分 yield fdata: {token}\n\n yield data: [DONE]\n\n except Exception as exc: # 不要把异常吞掉返回错误帧让前端可感知 yield fdata: [ERROR] {type(exc).__name__}\n\n[DONE]是正常结束标记[ERROR]是异常标记前端要分别处理异常帧只返回类型不返回堆栈避免泄露内部路径生成器里不要做长时间阻塞操作。反向代理层要确认流式响应没有被整体缓冲超时参数要同时看连接超时、首 token 超时和总时长。流式接口排错时先直连服务再经过代理能快速定位是哪一层卡住。5.4 知识库更新不生效现象新文档已经上传问答还是引用旧内容或者同一问题有时答新、有时答旧。原因通常是索引没有重建、文件指纹没变导致跳过、缓存层还拿着旧结果。解决方法是给每个文件算指纹索引构建按指纹增量更新问答缓存键带上索引版本。下面这段指纹代码可以直接用import hashlib def file_fingerprint(path): h hashlib.sha256() with open(path, rb) as f: for block in iter(lambda: f.read(1024 * 1024), b): h.update(block) return h.hexdigest()sha256对文件内容做指纹文件改名但内容不变时不会重复建索引分块读取避免大文件一次吃满内存指纹存进索引元数据构建时对比新旧指纹。索引版本要写进问答缓存键重建索引后旧缓存自然失效。删除文档时也要处理别只追加不删除否则旧块会一直留在索引里。5.5 回答胡编与引用缺失现象模型给出看似合理但知识库里没有的答案或者回答里没有引用编号审计时无法回查。原因通常是召回为空仍让模型回答、提示词没有拒答约束、引用编号由模型自由生成而没有服务层校验。解决方法是召回为空直接拒答提示词要求只依据参考资料生成后校验引用编号是否落在召回文档范围内。下面这段提示词拼装把空召回挡在模型之前def build_prompt(question, docs): if not docs: return None refs \n.join( f[{i 1}] {d[source]} 第{d.get(page, -)}页{d[text]} for i, d in enumerate(docs) ) return ( f参考资料\n{refs}\n\n f问题{question}\n 回答要求只依据参考资料逐条标注引用编号没有依据就说未收录。 )docs为空返回None服务层直接走拒答引用编号按召回顺序生成生成后解析回答里的[n]超出范围就标记为引用异常来源和页码写进参考资料模型不需要自己编。温度调低、拒答提示词、服务层校验三件事要一起做只靠提示词挡不住所有胡编。知识库问答的验收标准不是“回答像人”而是“每句关键结论都能回到文档”。6. 把问答助理做成可交付的内网服务验收脚本与压测技巧最后一公里是验收。我一般准备三组材料固定问题集、单路冒烟脚本、并发压测脚本。固定问题集覆盖制度、流程、数字、表格和“知识库没有”的问题每条写must_contain和must_not_contain不要只靠人眼看。下面是一个最小验收脚本CASES [ {q: 报销流程是什么, must_contain: [报销, 审批], must_not_contain: [未收录]}, {q: 年假怎么计算, must_contain: [年假], must_not_contain: []}, {q: 知识库里没有的型号 X 的参数, must_contain: [未收录], must_not_contain: [参数为]}, ] def check(answer, case): if any(word not in answer for word in case[must_contain]): return False if any(word in answer for word in case[must_not_contain]): return False return Truemust_contain用来验证关键结论没有被漏掉must_not_contain用来抓胡编和错误拒答第三条专门测拒答避免模型对不存在的内容硬答。脚本跑完输出通过率和失败样例失败样例要带 trace_id方便回查召回文档。验收不要只看一次知识库更新、模型换档、提示词改动后都要复跑同一批问题。并发压测先压单路再压 5 路、10 路观察首 token 时间、总耗时和错误率。下面这段循环能快速看服务在并发下的响应码和耗时for i in $(seq 1 20); do curl -s -o /dev/null -w %{http_code} %{time_total}\n \ -H Content-Type: application/json \ -d {session_id:load-$i,question:报销流程是什么,top_k:4} \ http://127.0.0.1:18080/chat done waitseq 1 20是并发请求数按显卡余量往下调-w输出状态码和总耗时状态码 200 不代表回答正确还要结合验收脚本session_id每个请求不同避免历史串线。压测时看三件事错误率、P95 耗时、显存峰值。显存峰值持续贴近上限就降并发或降 max_length不要等 OOM 再补救。上线前我习惯把模型路径、索引版本、提示词版本、验收通过率写进发布记录出问题时能对上是哪一版。从那以后每次内网交付我都先跑 30 条固定问题、单路冒烟、10 路并发再谈回答质量这套习惯比事后找后悔药有用。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站