1. 多轮对话客服系统的整体架构设计思路
1.1 为什么选择这套技术栈组合
做多轮对话客服系统,最核心的诉求就三个:能记住上下文、能查私有知识、能本地跑起来不烧钱。市面上方案很多,但真正能同时满足这三点的组合并不多。我试过纯调云端API的做法,效果确实好,但成本随对话量线性上涨,而且数据要出本地,很多做企业内部客服的场景根本过不了合规这一关。后来转向本地化方案,最终锁定Python + Ollama + Chroma + LangChain这套组合,用下来最顺手。
拆开说各自的定位。Ollama负责把大模型跑在本地,它把模型下载、量化、推理服务封装成一条命令,不用自己去折腾CUDA版本和显存分配,对个人开发者极其友好。Chroma是轻量级向量数据库,专门存知识库的向量表示,支持持久化到本地磁盘,重启不丢数据,而且它的Python客户端API设计得很直觉,几行代码就能建集合、加文档、做相似度检索。LangChain是胶水层,把模型调用、提示词模板、检索器、对话历史管理这些零件串成一条链,尤其是它的ConversationBufferMemory和RetrievalQA这类封装,省掉大量重复代码。Python就不用多说了,整个生态的底座。
这套组合最大的优势是全本地、可离线、零调用成本。模型权重下载一次之后,断网也能跑。对于做内部知识库客服、售后问答机器人这类场景,数据不出内网,安全性和成本都控制得住。当然代价是本地模型的能力上限不如云端旗舰模型,但对于客服这种领域相对收敛的任务,7B到14B级别的模型配合好的知识库检索,效果完全够用。
1.2 多轮对话与单轮问答的本质区别
很多人第一次做客服系统,容易把它当成单轮问答来做——用户问一句,系统查一次知识库,返回一个答案。这样做出来的东西,用户问第二句“那它多少钱”的时候就懵了,因为它不知道“它”指的是上一句里的哪个产品。多轮对话的核心难点在于指代消解和上下文继承。
具体来说,多轮对话系统需要维护一个对话状态,这个状态里至少包含:历史问答对、当前话题、用户提到的实体(产品名、订单号等)。当用户说“它的保修期多久”时,系统要能从历史里找到“它”指代的对象,把这个问题改写成“XX产品的保修期多久”,再去检索知识库。这个改写动作,行话叫Query Rewriting(查询重写),是多轮客服系统里最关键的一环,也是新手最容易忽略的地方。
我在实际项目里踩过的坑是:一开始直接把用户当前这句话丢给向量库检索,结果多轮场景下检索命中率惨不忍睹。后来加了查询重写这一步,命中率直接翻倍。所以这套系统的架构里,查询重写模块是必须有的,不能省。
1.3 整体数据流与模块划分
把整个系统拆开,数据流大致是这样的:用户输入进来,先经过查询重写模块,结合历史对话把当前问题补全成独立完整的问题;然后这个完整问题进入检索模块,Chroma 做向量相似度搜索,召回最相关的若干知识片段;接着提示词组装模块把检索结果、历史对话、系统指令拼成一个完整的prompt;最后Ollama推理模块调用本地模型生成回答,回答再写回对话历史,等待下一轮。
模块划分上我建议分成四层:接入层(处理用户输入输出,可以是命令行、Web API、或者接微信/网页的接口)、对话管理层(维护会话状态、历史记录、查询重写)、检索层(Chroma向量库的增删改查)、模型层(Ollama的调用封装)。分层的好处是每一层可以独立替换,比如以后想把Chroma换成别的向量库,只动检索层就行,上层无感知。
提示:不要一上来就追求微服务架构,个人项目或者小团队用单进程Python脚本完全够用,模块之间用函数调用而不是网络请求,调试起来简单十倍。等真的扛不住并发量了再拆。
2. 环境搭建与核心组件安装实操
2.1 Python环境准备与虚拟环境隔离
Python版本我建议用3.10 或 3.11,这两个版本对LangChain和Chroma的兼容性最好。3.12虽然新,但有些依赖包的wheel还没跟上,装的时候容易编译报错。装Python本身去官网下载安装包就行,Windows记得勾选“Add Python to PATH”,Linux用系统包管理器或者源码编译都可以。
装完第一件事是建虚拟环境,千万别在全局环境里装这些库,依赖冲突能把你搞崩溃。用venv或者conda都行:
python -m venv venv # Windows venv\Scripts\activate # Linux/Mac source venv/bin/activate用conda的话:
conda create -n chatbot python=3.11 conda activate chatbot虚拟环境激活后,命令行前面会出现(venv)或(chatbot)标识,看到这个就说明隔离成功了。这一步看着简单,但我见过太多人跳过这步,最后pip装了几百个包互相打架,重装系统的心都有。
2.2 Ollama安装与模型拉取避坑
Ollama的安装,Windows和Mac直接去官网下安装包,双击一路下一步。Linux用一条curl脚本就能装。装完之后验证一下:
ollama --version能打印版本号就说明装好了。接下来是拉模型,这是新手最容易卡住的地方——下载慢。默认从官方源拉,国内网络环境下经常几KB每秒,一个7B模型要下好几个小时。解决办法是配置国内镜像源,设置环境变量OLLAMA_HOST指向镜像地址,或者用一些社区维护的镜像加速服务。具体镜像地址会变,建议去Ollama的社区文档查最新的。
模型选择上,客服场景我推荐qwen2.5:7b或者qwen2.5:14b,中文能力强,指令遵循好。如果机器显存有限,用qwen2.5:3b也能跑,但复杂问题的推理能力会弱一些。拉模型命令:
ollama pull qwen2.5:7b拉完之后测试一下能不能正常推理:
ollama run qwen2.5:7b "你好,请介绍一下你自己"如果报500 internal server error: llama-server process这类错误,通常是显存不够或者模型文件损坏。先检查显存占用,关掉其他吃显存的程序;还不行就删掉模型重新拉:
ollama rm qwen2.5:7b ollama pull qwen2.5:7b注意:Ollama默认把模型存在系统盘,7B模型大概4-5GB,14B要9GB左右。如果系统盘空间紧张,提前设置
OLLAMA_MODELS环境变量把模型目录挪到大盘上,不然装到一半磁盘满了很尴尬。
2.3 LangChain与Chroma依赖安装
这两个库用pip装就行,但要注意版本匹配。LangChain生态更新极快,不同版本API差异很大,建议锁定版本:
pip install langchain==0.2.16 pip install langchain-community==0.2.16 pip install langchain-ollama==0.1.3 pip install chromadb==0.5.5 pip install sentence-transformers==3.0.1这里解释一下为什么装langchain-ollama这个独立包。LangChain从0.2版本开始把各家模型的集成拆成了独立包,langchain-ollama就是专门对接Ollama的,比老版本用Ollama类的方式更规范。sentence-transformers是用来做文本向量化的,Chroma本身不带embedding能力,需要外挂一个embedding模型。
embedding模型我推荐BAAI/bge-small-zh-v1.5或者bge-m3,中文语义表示效果好,模型也不大。第一次用会自动从HuggingFace下载,如果下载慢,可以设置HF_ENDPOINT环境变量指向国内镜像。
装完之后跑个import测试:
import langchain import chromadb from langchain_ollama import OllamaLLM print("all ok")没报错就说明环境齐了。
2.4 目录结构与配置文件规划
项目目录我习惯这样组织,清晰且好扩展:
chatbot/ ├── config.py # 配置项集中管理 ├── llm_client.py # Ollama调用封装 ├── vector_store.py # Chroma操作封装 ├── query_rewriter.py # 查询重写模块 ├── dialog_manager.py # 对话状态管理 ├── main.py # 入口 ├── data/ # 知识库原始文档 └── chroma_db/ # Chroma持久化目录config.py里放模型名、向量库路径、检索top_k、温度等参数,改配置不用翻代码。这个习惯在项目变大之后能救命,尤其是要同时维护测试环境和生产环境的时候。
3. 知识库构建与向量检索核心实现
3.1 文档加载与文本切分策略
知识库的质量直接决定客服系统的上限。原始文档可能是PDF、Word、Markdown、Excel各种格式,LangChain提供了对应的Loader:PyPDFLoader、Docx2txtLoader、UnstructuredMarkdownLoader等等。加载进来是一堆Document对象,每个对象有page_content和metadata。
接下来是文本切分,这一步极其关键。切得太碎,语义不完整,检索出来的片段答非所问;切得太大,一个片段里混了好几个主题,模型容易被无关信息干扰。我的经验值是chunk_size 设 500-800 字符,chunk_overlap 设 50-100 字符。overlap的作用是防止一句话正好被切在中间,导致两边都读不通。
from langchain.text_splitter import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter( chunk_size=600, chunk_overlap=80, separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""] )注意separators的顺序,它优先按段落切,段落太长再按句子切,最后才按字符硬切。中文场景一定要把中文标点加进去,默认的分隔符是英文的,切中文效果很差。
实操心得:如果你的知识库是FAQ形式,一条问答就是一个完整语义单元,那就别切了,一条一个chunk,metadata里标记好问题类型。强行切分反而破坏语义完整性。
3.2 向量化与Chroma持久化存储
切分完的chunk要转成向量存进Chroma。向量化用HuggingFaceEmbeddings包装bge模型:
from langchain_community.embeddings import HuggingFaceEmbeddings embedding = HuggingFaceEmbeddings( model_name="BAAI/bge-small-zh-v1.5", model_kwargs={'device': 'cpu'}, encode_kwargs={'normalize_embeddings': True} )normalize_embeddings=True很重要,它把向量归一化到单位长度,这样余弦相似度计算就等价于点积,检索更快更准。
存进Chroma:
from langchain_community.vectorstores import Chroma vectorstore = Chroma.from_documents( documents=chunks, embedding=embedding, persist_directory="./chroma_db", collection_name="customer_service" ) vectorstore.persist()persist_directory指定持久化目录,下次启动直接Chroma(persist_directory=..., embedding_function=...)就能加载,不用重新向量化。collection_name相当于数据库里的表名,不同知识库用不同collection隔离。
这里有个性能细节:批量向量化比逐条快得多。from_documents内部会批量处理,但如果你自己写循环逐条add,速度会慢好几倍。几千条文档的话,用默认的批量方式几分钟就搞定。
3.3 检索器配置与相似度阈值调优
检索器决定了召回哪些片段给模型。基础配置:
retriever = vectorstore.as_retriever( search_type="similarity", search_kwargs={"k": 4} )k=4表示召回最相似的4个片段。k太小可能漏掉关键信息,k太大则塞给模型的上下文过长,既慢又容易引入噪声。我的经验是k取3到5之间,具体看知识库的粒度。
search_type除了similarity,还有mmr(最大边际相关性)。MMR会在相似度和多样性之间做平衡,避免召回一堆内容重复的片段。如果你的知识库里有大量近似表述,用MMR效果更好:
retriever = vectorstore.as_retriever( search_type="mmr", search_kwargs={"k": 4, "fetch_k": 10, "lambda_mult": 0.5} )fetch_k=10表示先取10个候选,再从中挑4个多样性最好的。lambda_mult控制多样性权重,0偏向多样性,1偏向相似度,0.5是折中。
相似度阈值是另一个关键参数。低于某个阈值的片段说明和问题根本不相关,应该丢弃而不是硬塞给模型。Chroma支持score_threshold:
retriever = vectorstore.as_retriever( search_type="similarity_score_threshold", search_kwargs={"score_threshold": 0.5, "k": 4} )阈值设多少要看embedding模型,bge系列一般0.4-0.6之间比较合适。设太高会漏召回,设太低会引入噪声,建议拿一批测试问题跑一下,看召回片段的分数分布再定。
3.4 检索效果评估与迭代方法
知识库建完不能就这么上线,得评估检索效果。我的做法是准备一批测试问答对,每个问题标注好应该召回哪个文档片段。然后跑检索,看top_k里有没有命中正确片段,算命中率。
如果命中率低,排查顺序是:先看切分是否合理(片段语义是否完整),再看embedding模型是否适合领域(通用模型对专业术语可能表示不好),最后看检索参数(k值、阈值)。有时候问题出在原始文档本身表述和用户提问方式差异太大,这时候可以考虑给文档片段加上同义问法,或者用查询扩展技术。
我做过一个售后客服的知识库,用户问“怎么退货”,文档里写的是“商品退回流程”,字面差异大但语义相近,bge模型能正确召回。但如果用户问“我不想要了怎么办”,这种口语化表达就需要embedding模型有足够强的语义泛化能力,bge-m3比bge-small在这方面表现更好,代价是模型更大、推理更慢。
4. 多轮对话管理与查询重写实现
4.1 对话历史的存储与截断策略
多轮对话要记住历史,但历史不能无限增长。模型有上下文窗口限制,qwen2.5:7b是32K token,看着很大,但历史对话加上检索片段加上系统提示,很容易就撑满了。而且历史越长,推理越慢,成本越高。
我的策略是滑动窗口 + 摘要压缩结合。最近N轮对话保留原文,更早的对话压缩成一段摘要。N一般取5到10轮。实现上维护一个列表:
class DialogManager: def __init__(self, max_turns=8): self.history = [] self.max_turns = max_turns def add_turn(self, user_input, ai_output): self.history.append({"user": user_input, "ai": ai_output}) if len(self.history) > self.max_turns: self._compress_old_history() def _compress_old_history(self): old = self.history[:-self.max_turns//2] # 调用模型把old压缩成摘要 summary = self._summarize(old) self.history = [{"summary": summary}] + self.history[-self.max_turns//2:]摘要压缩会多一次模型调用,有额外延迟。如果对延迟敏感,可以只做滑动窗口,直接丢弃最老的对话。但丢弃有个风险:用户可能在第10轮提到一个关键信息,第15轮又引用它,这时候历史里已经没了,系统就答不上来。所以关键实体信息建议单独抽出来存成结构化状态,不随历史丢弃。
4.2 查询重写的原理与提示词设计
查询重写是多轮客服的灵魂。用户说“它多少钱”,系统要结合历史改写成“XX产品多少钱”。这个改写用LLM来做最自然,给模型一个提示词:
REWRITE_PROMPT = """你是一个查询重写助手。根据下面的对话历史,把用户的最新问题改写成不依赖上下文、可以独立理解的完整问题。只输出改写后的问题,不要解释。 对话历史: {history} 用户最新问题:{question} 改写后的问题:"""把历史格式化成文本填进去,调用模型生成改写结果。这里有个技巧:改写结果要保留原问题的意图,不要添加历史里没有的信息。有时候模型会过度发挥,把历史里提到的所有实体都塞进去,导致改写后的问题偏离原意。可以在提示词里加一句“如果最新问题本身已经完整,直接原样返回”。
改写模块的延迟大概几百毫秒,对客服场景可以接受。如果追求极致速度,可以用小模型专门做改写,比如qwen2.5:3b,改写任务比生成回答简单,小模型够用。
4.3 指代消解与上下文继承的工程实现
指代消解是查询重写要解决的核心问题。中文里的指代有几种:代词指代(它、他、这个、那个)、省略指代(“多少钱”省略了主语)、零指代(“还有呢”)。LLM做指代消解的能力比传统规则方法强很多,但也不是百分百准。
工程上我加了一层实体追踪。每轮对话结束后,用模型或规则从对话里抽取实体(产品名、订单号、日期等),存到一个实体表里。查询重写时,把实体表也作为上下文提供给模型,帮助它判断指代对象。这样即使历史被截断了,实体信息还在。
def extract_entities(text): # 简化示例,实际可以用NER模型或让LLM抽取 entities = {} # 匹配订单号格式 order_match = re.search(r'订单[号]?[::]?\s*(\w+)', text) if order_match: entities['order_id'] = order_match.group(1) return entities实体追踪还有个好处:可以在回答里主动引用,比如“您刚才提到的订单12345,物流状态是...”,用户体验会好很多。
4.4 对话状态机的设计与边界处理
客服系统不是所有问题都需要查知识库。用户说“你好”,不需要检索;用户说“转人工”,应该直接走转接流程;用户骂人,应该走安抚话术。这些靠一个对话状态机来路由。
我设计的状态有:greeting(问候)、qa(知识问答)、clarify(澄清追问)、handoff(转人工)、fallback(兜底)。每轮输入先做意图分类,判断当前该走哪个状态。意图分类可以用LLM做few-shot,也可以训一个小分类模型。
边界处理上,几个必须考虑的情况:检索结果为空怎么办(走fallback,回复“这个问题我暂时答不上来,建议您...”)、模型生成超时怎么办(设超时时间,超时返回兜底话术)、用户连续追问同一问题怎么办(检测重复,主动询问是否没解决)。这些细节决定了系统是“能用”还是“好用”。
5. 系统集成与完整对话流程串联
5.1 LangChain链的组装方式
把各个模块串起来,LangChain提供了Runnable接口,可以用管道符|组合:
from langchain_core.runnables import RunnablePassthrough from langchain_core.output_parsers import StrOutputParser chain = ( {"context": retriever, "question": RunnablePassthrough()} | prompt_template | llm | StrOutputParser() )但多轮对话场景下,这个简单链不够用,因为要注入历史、要做查询重写。我实际用的是自定义函数串联,比硬套LangChain的链更灵活:
def chat(user_input, session_id): history = dialog_manager.get_history(session_id) rewritten = query_rewriter.rewrite(user_input, history) docs = retriever.invoke(rewritten) context = "\n\n".join([d.page_content for d in docs]) prompt = build_prompt(context, history, user_input) answer = llm.invoke(prompt) dialog_manager.add_turn(session_id, user_input, answer) return answer这样每一步都清晰可控,出问题好定位。LangChain的价值在于它提供了retriever、embedding、llm这些标准组件,而不是非要你用它的链式语法。
5.2 提示词模板的工程化设计
提示词模板决定了模型输出的质量。客服场景的模板我一般包含这几块:角色设定、知识库上下文、对话历史、当前问题、输出要求。
PROMPT_TEMPLATE = """你是一个专业的客服助手,请根据下面的知识库内容回答用户问题。 要求: 1. 只根据知识库内容回答,不要编造信息 2. 如果知识库中没有相关信息,如实告知用户 3. 回答要简洁、友好、专业 4. 涉及数字、日期、金额时务必准确 知识库内容: {context} 对话历史: {history} 用户问题:{question} 回答:"""几个细节:“只根据知识库回答”这句能显著降低幻觉率,但也不能保证百分百,模型有时候还是会自由发挥。“如实告知”给了模型一个退路,避免它硬编。输出要求里明确“简洁”,能防止模型啰嗦。
实操心得:提示词里的顺序有讲究。把知识库放在前面、问题放在后面,模型对最近的内容注意力更强,回答更聚焦。如果反过来,模型容易被历史对话带偏。
5.3 流式输出与响应速度优化
客服系统用户对延迟很敏感,等5秒才出字体验很差。Ollama支持流式输出,LangChain也封装了stream方法:
for chunk in llm.stream(prompt): print(chunk, end="", flush=True)流式输出让用户看到字一个个蹦出来,感知延迟大幅降低。虽然总时间没变,但体验好很多。
响应速度优化还有几个方向:检索加速(Chroma建索引,数据量大时用HNSW)、模型量化(用q4量化版模型,速度快一倍,质量损失很小)、缓存(相同问题直接返回缓存答案)。缓存对客服场景特别有效,因为用户问的问题重复率很高,热门问题缓存命中率能到30%以上。
5.4 会话隔离与并发处理
多个用户同时用,会话必须隔离。每个用户分配一个session_id,对话历史按session_id分开存。简单实现用字典:
sessions = {} # {session_id: DialogManager}但字典在内存里,进程重启就丢了。生产环境要持久化,可以存Redis或者SQLite。并发方面,Python的GIL限制了多线程CPU并行,但Ollama推理是IO密集(等模型返回),用异步或者多线程能提升吞吐。FastAPI配async def接口是常见做法。
如果并发量真的上来了,单机Ollama扛不住,可以考虑多实例部署加负载均衡,或者用vLLM这类高吞吐推理框架替换Ollama。但那是另一个量级的工程了,个人项目和小团队用Ollama单实例足够。
6. 常见问题排查与实战避坑指南
6.1 模型加载与推理报错速查
实际跑起来会遇到各种报错,我整理了一张速查表:
| 报错信息 | 可能原因 | 解决办法 |
|---|---|---|
500 internal server error: llama-server process | 显存不足或模型损坏 | 关其他程序释放显存,或删模型重拉 |
model not found | 模型名写错或没拉取 | ollama list确认模型名,ollama pull拉取 |
connection refused | Ollama服务没启动 | ollama serve启动服务 |
context length exceeded | 输入超过模型上下文窗口 | 减少历史轮数或检索片段数 |
CUDA out of memory | 显存不够 | 换小模型或用量化版 |
context length exceeded这个特别常见,因为很多人不控制历史长度。qwen2.5:7b虽然标称32K,但实际用的时候输入越长推理越慢,建议控制在8K以内。
6.2 检索不准的排查思路
检索不准的表现是:明明知识库里有答案,但模型说不知道,或者答非所问。排查步骤:
第一步,单独测检索。把用户问题直接丢给retriever,看召回的片段是什么。如果召回的就是无关内容,问题在检索层;如果召回正确但模型答错,问题在生成层。
第二步,检查切分。把召回的片段打印出来看,是不是被切得七零八落,语义不完整。如果是,调整chunk_size和overlap。
第三步,检查embedding。用几个语义相近但表述不同的句子测embedding相似度,看模型能不能正确判断。如果相似度区分度低,换更强的embedding模型。
第四步,检查查询重写。多轮场景下,如果重写后的query偏离原意,检索肯定不准。把重写结果打印出来人工检查。
6.3 回答质量差的调优手段
回答质量差有几种表现:答非所问、编造信息、啰嗦、格式乱。对应调优手段:
答非所问:通常是检索没召回正确内容,或者提示词没约束好。先解决检索,再在提示词里强调“根据以下内容回答”。
编造信息:降低temperature(设0.1-0.3),提示词里加“不知道就说不知道”,检索时提高相似度阈值过滤噪声。
啰嗦:提示词里明确“回答控制在100字以内”,或者用few-shot给几个简洁回答的示例。
格式乱:提示词里规定输出格式,比如“用分点列表回答”,或者用输出解析器后处理。
temperature这个参数值得单独说。客服场景我建议设0.1到0.3,要的是稳定准确,不是创意。设太高模型会自由发挥,设0又可能过于死板。0.2是个不错的平衡点。
6.4 性能瓶颈定位与优化清单
系统跑起来慢,定位瓶颈的方法是在每个环节打时间戳:
import time t0 = time.time() rewritten = query_rewriter.rewrite(user_input, history) t1 = time.time() docs = retriever.invoke(rewritten) t2 = time.time() answer = llm.invoke(prompt) t3 = time.time() print(f"重写:{t1-t0:.2f}s 检索:{t2-t1:.2f}s 生成:{t3-t2:.2f}s")一般生成占大头,7B模型生成100字大概2-5秒(看硬件)。检索通常几十毫秒,重写几百毫秒。如果检索特别慢,检查Chroma的索引和集合大小;如果生成特别慢,考虑换量化模型或升级硬件。
优化清单按性价比排序:流式输出(体验提升最大,改动最小)、缓存热门问答(命中率高时效果显著)、模型量化(速度翻倍,质量损失小)、减少检索片段数(k从5降到3,上下文短了生成快)、升级硬件(GPU比CPU快一个数量级,但成本高)。
6.5 上线前的检查清单
系统开发完准备上线,过一遍这个清单:
- 知识库覆盖度:测试问题集里多少能正确回答,目标80%以上
- 兜底话术:检索为空、模型超时、异常输入都有兜底回复
- 会话隔离:多用户并发测试,确认历史不串
- 持久化:重启后知识库和会话状态能恢复
- 日志:每轮对话的输入、检索结果、输出都记日志,方便排查
- 限流:防止单用户高频请求打爆服务
- 敏感词过滤:用户输入和模型输出都过一遍过滤
我个人在实际操作中的体会是,知识库的质量比模型的选择重要得多。同样的模型,知识库整理得好,回答准确率能到90%;知识库乱七八糟,再强的模型也救不回来。所以前期在文档整理、切分策略、测试集构建上多花时间,后期调优会轻松很多。另外,多轮对话系统一定要用真实的多轮场景去测,单轮测试通过不代表多轮没问题,指代消解和上下文继承的坑只有多轮测试才能暴露出来。