ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于LangChain、Ollama与Chroma的多轮对话客服系统构建指南

基于LangChain、Ollama与Chroma的多轮对话客服系统构建指南 我前几篇把Python环境、Ollama单机模型、Chroma入库这些基础流程都过了一遍后台收到不少留言问同一个问题怎么让客服系统记住用户上一句说了什么而不是每次对话都像失忆一样重新回答。这篇把Python Ollama Chroma LangChain搭一套多轮对话客服系统的完整思路和关键代码整理出来核心解决三件事上下文记忆、知识库检索、对话编排。这套方案的亮点是全部本地化运行没有外部API调用费用数据也完全留在自己机器上适合做产品原型、企业内训问答、私有知识库客服的场景。无论你是刚把LangChain跑通的新手还是看过一堆理论但没动手拼过完整系统的开发者这篇可以直接照着抄。做多轮对话系统最容易被忽略的一点是它不是一个“能连续聊天的聊天机器人”而是一个“在每轮回答时都知道自己刚才说了什么的问答系统”。这个区别决定了代码结构完全不一样。下面从架构设计开始讲再到完整实现和排坑尽量把我在实际开发中踩过的坑一并说清楚。1. 先把架构想清楚四件套到底各自负责什么1.1 用一个比喻理解四个组件的分工很多教程上来就贴代码结果读者只学会复制粘贴换一个场景就不知道怎么改。我习惯先把职责边界划分清楚。这套系统可以理解成一个快餐店Python和FastAPI是前台服务员负责接单LangChain是后厨调度员负责按流程把订单拆解、安排任务Chroma是仓库管理员负责在海量食材里快速找到要用的一份Ollama是掌勺大厨负责真正把菜做出来端上桌。对应到技术上Python负责整个应用的骨架、HTTP接口、会话数据保存LangChain负责把“用户问题 历史记录 检索结果”按一定顺序组装成提示词并在合适的时机调用模型Chroma负责存知识库向量并根据语义相似度找回相关资料Ollama负责本地跑大模型做真正的文本生成。各管一段出现问题也能快速定位。这个分工也解释了为什么需要LangChain单纯用Ollama的Python SDK也能直接调用模型但检索、历史消息整理、提示词拼接这些逻辑如果全手写代码会非常啰嗦且容易出错。LangChain的价值不是某个神级算法而是把“加载文档—切分—向量化—检索—组装上下文—调用模型”这条流水线标准化了。1.2 多轮对话和单轮问答的本质差异单轮问答系统好做把用户问题拿去检索拼到提示词里让模型回答就行。多轮对话真正复杂在“指代消解”和“省略补全”这两个问题上。举个客服场景的例子。用户先问“你们发货到杭州要几天”系统回答“通常3天”。用户接着说“那今天下单的话呢”这句话单独看是不完整的它隐含了上一轮的“发货到杭州”这个前提。如果系统只拿“那今天下单的话呢”去知识库检索向量相似度会非常低可能直接返回“抱歉我不理解您的问题”。多轮系统必须先把这句话结合历史改写成完整的问题比如“如果今天下单发货到杭州要几天”再去检索结果才靠谱。这就是多轮对话链中最关键的一个环节LangChain把它封装成create_history_aware_retriever做的事情就是“用大模型把当前问题结合历史改写成独立检索问题”。很多人忽略这一步以为把聊天记录塞进提示词就完事了结果检索质量总上不去问题就出在这。另外多轮对话还牵涉记忆窗口的管理。对话轮数多了以后提示词会越来越长超出模型上下文窗口后要么报错、要么生成质量急剧下降。所以还要设计一个会话历史管理策略只保留最近的几轮这个我后面在代码部分详细讲。2. 环境准备一次装对省掉半小时找坑时间2.1 Python环境与依赖版本选择如果你是从零开始Python版本建议直接用3.10或3.11。LangChain 0.3以上对Python 3.9的兼容性虽然还保留但有些新API的pyproject依赖解析会出现莫名问题没必要为了省那一点安装时间在版本上给自己挖坑。我习惯用conda建独立环境避免不同项目之间的包互相污染conda create -n kfbot python3.11 -y conda activate kfbot然后安装依赖。这里一定要留意版本2024年底之后LangChain的API改动比较大网上很多旧教程还在用RetrievalQA新版本虽然没删但官方已经不推荐。我当前环境的版本组合如下pip install langchain langchain-community langchain-chroma chromadb ollama fastapi uvicorn pypdflangchain-chroma这个包一定要单独装。网上很多教程只说装chromadb结果导入langchain_chroma的时候直接ModuleNotFoundError。langchain-chroma是LangChain对Chroma的适配层负责把文档和查询转换成Chroma能处理的格式不装的话代码跑不起来。最后验证一下安装结果python -c from langchain_chroma import Chroma; from langchain_ollama import OllamaLLM; print(ok)能打印出ok说明核心依赖都没问题。2.2 Ollama部署与模型选择顺手解决下载慢的痛点Ollama的安装本身不复杂但很多人在“下载安装包”这一步就卡住了。官方站点下载速度看网络环境确实不稳定我自己的体验是经常下载到一半断掉。这里分享几个我实测有效的方法。第一个方法是找国内可正常访问的镜像站点下载安装包。不少开源镜像站会同步Ollama的官方安装文件速度通常比直接访问官方快很多。你只需要对比一下安装包的校验值或者版本号确认是同一份官方的二进制文件就行。第二个方法是离线包方式。拿一台能正常访问外网的机器把Windows或macOS的安装包下载下来传到目标机器上安装。Ollama的安装包是自包含的安装后不依赖在线服务本地调用模型完全没问题。第三个方法是安装后模型下载慢。装好Ollama只是第一步关键是拉取模型。ollama run qwen2.5:3b如果一直卡在等待下载建议先在Ollama的models目录下确认是否有默认模型目录然后通过离线方式获取模型文件并导入。具体做法是把GGUF格式的模型文件放到Ollama配置的模型路径下再用ollama create命令创建本地模型标签。这个流程在社区里已经有很多人验证过是最稳妥的离线方案。模型选择上中文客服场景我推荐两个组合用途推荐模型说明对话生成qwen2.5:3b或qwen2.5:7b显存有限选3b效果优先选7b文本向量化bge-m3或nomic-embed-text中文检索质量bge-m3更稳qwen2.5:3b在客服问答这类相对固定格式的任务上表现已经不错而且显存占用小8G内存的普通笔记本也能跑。bge-m3是中英双语向量模型对中文场景更友好后面代码里Embedding都用它。2.3 Chroma用嵌入式模式还是服务端模式Chroma有两种运行方式嵌入式Embedded和服务端Server。嵌入式模式下Chroma作为Python库直接跑在你的应用进程里数据持久化到本地文件目录服务端模式则是独立启动一个Chroma服务应用通过网络连接。客服知识库这种场景数据量一般只有几千到几十万个文档片段嵌入式模式完全够用而且部署简单不需要额外守护进程。我会在这篇的代码里全部采用嵌入式模式。如果你的数据量到了百万级或者多个应用需要共享同一个向量库再考虑服务端模式普通项目别折腾。3. 知识库入库与检索客服的答案来源3.1 文档加载与切分参数怎么定客服知识库的原始素材通常是FAQ文档、产品说明、售后政策。这些文档格式繁杂有纯文本、Markdown、PDF甚至Excel。我在这套系统中用统一的方式把它们加载进来。以最常见的Markdown和PDF为例from langchain_community.document_loaders import TextLoader, PyPDFLoader docs [] loader TextLoader(faq.md, encodingutf-8) docs.extend(loader.load()) pdf_loader PyPDFLoader(product_manual.pdf) docs.extend(pdf_loader.load())加载完文档之后最关键的一步是切分。切分做不好检索出来的内容要么被截断、要么语义不完整后续再怎么调模型都弥补不了。RecursiveCharacterTextSplitter是LangChain里比较稳妥的切分器它会按优先级尝试用不同分隔符去切优先保持段落和句子的完整。经验参数如下from langchain.text_splitter import RecursiveCharacterTextSplitter text_splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap50, separators[\n\n, \n, 。, , , , , , ] ) split_docs text_splitter.split_documents(docs)chunk_size500表示每块文本长度大约500字左右chunk_overlap50表示相邻两块重叠50字目的是避免一个完整的知识点正好被切分线截断。对客服FAQ这种一句问答往往只有几十到一百字的场景500字偏大了可以下调到200或300。我的建议是先看你的文档粒度FAQ为主就200操作手册类就400。不要盲抄参数拿几段真实文档跑一下看看切出来的块是不是“一个完整问题带一个完整答案”是就对了。3.2 Embedding模型与向量库初始化检索质量的一半取决于向量模型。你问一句“退款多久到账”模型得能判断出和“退款时效”“退回原支付方式”这些片段语义相近才能检索对。纯英文的nomic-embed-text对中文支持一般我直接用Ollama里的bge-m3。初始化Chroma并写入向量的代码如下from langchain_ollama import OllamaEmbeddings from langchain_chroma import Chroma embedding OllamaEmbeddings(modelbge-m3) vectorstore Chroma.from_documents( documentssplit_docs, embeddingembedding, persist_directory./chroma_db )首次运行会先把文档向量化写入./chroma_db目录之后再启动只需要加载目录即可vectorstore Chroma( persist_directory./chroma_db, embeddingembedding )这里有个容易踩的坑加载已有目录时必须传入同样的embedding否则Chroma会不知道用什么办法把你新问题的文本转成向量轻则报错重则检索结果乱七八糟。我见过不少人在persist_directory传了路径但没传embedding导致每次检索都返回空列表排查了老半天。3.3 构建检索器并确定返回数量向量库建好之后把它包装成LangChain的标准检索器retriever vectorstore.as_retriever(search_kwargs{k: 3})k3表示每次检索返回最相似的3个文档片段。这个数字很有讲究k太小可能漏掉关键信息k太大噪音片段混进来反而干扰模型回答。客服场景一般3到5个比较合适。后续如果发现回答里出现了与问题无关的信息优先把k降下来试试。4. 多轮对话链路完整实现技术核心4.1 会话存储用滑动窗口管理上下文多轮对话系统必须有一个地方存“这个用户之前聊了什么”。最简单的方案是内存字典以session_id为键值是一个保存最近N轮对话的队列。我用collections.deque来做滑动窗口因为它在头部弹出旧消息的复杂度是O(1)。from collections import deque from typing import Dict, Deque session_history: Dict[str, Deque] {} MAX_TURNS 6 def get_session_history(session_id: str) - Deque: if session_id not in session_history: session_history[session_id] deque(maxlenMAX_TURNS * 2) return session_history[session_id]MAX_TURNS 6表示最多保留最近6轮对话每轮包含用户和助手两条消息所以deque的长度上限是12。超过6轮之后最旧的消息会自动被挤出窗口模型始终只需要处理最近的有效上下文token占用可控回答也更加专注在当前话题上。你可能会问为什么要限制历史长度直接把全部历史都传进去不是更完整吗实测下来模型面对超过10轮的历史时注意力会分散反而容易把更早的陈旧信息当成当前语境还白白消耗显存。滑动窗口是成本低、效果稳的方案。4.2 提示词模板客服角色和引用要求的定制模板是整个对话质量的灵魂。LLM本身是个泛化模型你不告诉它“你是客服”它就按通用助手的口吻讲话。我用的模板分两部分一部分是系统设定一部分是动态内容的组装。from langchain.prompts import ChatPromptTemplate, MessagesPlaceholder system_prompt 你是一个专业、友好的客服助手。 请基于以下资料回答用户问题。如果资料中没有相关信息请如实说明你不清楚不要编造。 回答时使用简体中文语气简洁、礼貌。 背景资料 {context} prompt ChatPromptTemplate.from_messages([ (system, system_prompt), MessagesPlaceholder(chat_history), (human, {input}) ])MessagesPlaceholder(chat_history)是LangChain里专门用来插入历史对话的位置。在0.3版本之后写多轮对话链基本离不开它。注意这里的历史消息必须是HumanMessage和AIMessage组成的列表不能是纯字符串我在后面代码里统一转换。4.3 多轮检索链历史改写 检索 生成这是整篇博文最核心的代码。新版LangChain用三个链组合完成多轮检索问答from langchain.chains import create_history_aware_retriever, create_retrieval_chain from langchain.chains.combine_documents import create_stuff_documents_chain from langchain_ollama import OllamaLLM llm OllamaLLM(modelqwen2.5:3b, temperature0.3) # 1. 历史改写链把用户当前问题和历史结合改写成适合检索的独立问题 contextualize_prompt ChatPromptTemplate.from_messages([ (system, 根据聊天记录和最新的用户问题生成一个不需要历史信息也能被理解的独立检索问题。不要回答问题只改写问题。), MessagesPlaceholder(chat_history), (human, {input}) ]) history_aware_retriever create_history_aware_retriever( llm, retriever, contextualize_prompt ) # 2. 文档组合链把检索到的资料和提示词组装后交给模型生成 combine_docs_chain create_stuff_documents_chain(llm, prompt) # 3. 总检索问答链 rag_chain create_retrieval_chain(history_aware_retriever, combine_docs_chain)这个三层结构里create_history_aware_retriever替代了老版本RetrievalQA加手动拼历史的笨办法。它的执行流程是先把当前问题和历史消息丢给LLM改写成一个独立的检索问题再拿改写后的问题去Chromatch检索。这样就解决了“那今天下单呢”这种指代问题。create_retrieval_chain的输出结构在新版里默认返回一个字典包含input、context、answer。其中input是原始问题context是检索到的资料列表answer是最终生成答案。注意它不是返回字符串直接拿result[answer]就是文本答案。4.4 在FastAPI接口中实现对话循环后端接口我用FastAPI封装它自带异步支持和数据校验写起来比Flask简洁。启动时把上面各个组件初始化好然后暴露一个/chat接口。from fastapi import FastAPI from pydantic import BaseModel from langchain_core.messages import HumanMessage, AIMessage import uvicorn app FastAPI() class ChatRequest(BaseModel): session_id: str message: str class ChatResponse(BaseModel): reply: str app.post(/chat, response_modelChatResponse) def chat(req: ChatRequest): # 取出该会话的历史 history get_session_history(req.session_id) # 调用多轮检索链 result rag_chain.invoke({ input: req.message, chat_history: list(history) }) answer result[answer] # 保存这轮对话记录 history.extend([ HumanMessage(contentreq.message), AIMessage(contentanswer) ]) return ChatResponse(replyanswer) if __name__ __main__: uvicorn.run(app, host0.0.0.0, port8000)history.extend这一步不要漏掉。虽然get_session_history已经返回了一个deque对象直接history.append也能往里面加但它必须同时把用户的问题和模型的回答都记录进去否则下一轮对话就只有用户消息没有助手消息模型的上下文会缺失。这里还要注意一个并发问题FastAPI默认是线程池处理同步接口多个请求可能同时操作同一个session_id的deque导致历史消息被并发修改。简单加一个线程锁import threading session_lock threading.Lock() def chat(req: ChatRequest): with session_lock: history get_session_history(req.session_id) result rag_chain.invoke({ input: req.message, chat_history: list(history) }) answer result[answer] history.extend([HumanMessage(contentreq.message), AIMessage(contentanswer)]) return ChatResponse(replyanswer)生产环境更好的方案是用Redis存历史但作为一套完整的本地客服原型内存字典加锁已经足够。5. 启动测试与调优指南5.1 完整启动流程把上面代码按顺序整理到一个main.py里然后执行python main.py服务起来后用浏览器或curl验证curl -X POST http://localhost:8000/chat \ -H Content-Type: application/json \ -d {session_id: user_001, message: 你们发货到杭州要几天}返回的reply就是系统答案。再发一句curl -X POST http://localhost:8000/chat \ -H Content-Type: application/json \ -d {session_id: user_001, message: 那今天下单的话呢}第二句能不能正确关联到“发货到杭州”这是检验多轮对话链路有没有生效的试金石。如果回答合理说明历史改写链在工作如果回答“我不理解”优先检查contextualize_prompt中的改写效果和检索结果。5.2 让回答更稳的调参心得先看Ollama模型生成参数。temperature对客服场景非常关键温度越高回答越发散可能带情感倾向温度越低回答越保守更贴近知识库事实。客服系统我设置在0.2到0.4之间不太容易输出模棱两可的话。参数推荐值说明temperature0.2 - 0.4太低显得机械太高容易编造k检索返回数3 - 5与知识库片段粒度有关按需调整chunk_size200 - 500FAQ用200手册用400MAX_TURNS6控制上下文长度太长反而效果差再就是提示词里的“诚实回答”约束。我见过不少客服系统因为约束没有写清楚模型在知识库检索不到相关信息时就开始自由发挥编出一套看起来很合理的退款政策这在真实业务里非常危险。提示词里一定要写明“如果没有资料支撑就老实说不知道”然后在后续测试环节故意问一个知识库之外的问题验证模型是否遵守这个约束。6. 高频问题排查与避坑实录6.1 Ollama调用时报500错误这是最近很多人在群里问的热门问题之一。用ollama run qwen3.5:2b这种模型名很容易踩坑因为Ollama官方仓库里基本没有这个标签。你输错模型名Ollama会尝试拉取一个不存在的模型然后拉取失败应用层就会报类似500 internal server error: llama-server process的错误。这类错误本质上不是LangChain或Chroma的问题而是Ollama侧模型加载失败。排查时先做这三步确认模型名存在ollama list查看本地已有模型ollama search qwen2.5查看可获取的标签。确认显存和内存足够模型加载不进内存就会启动失败可以先停掉其他占显存的服务再试。重启Ollama服务有些情况下llama-server进程死了但Ollama主进程没感知重启最有效。6.2 LangChain版本变化带来的API差异网上大量教程还在教RetrievalQA.from_chain_type的写法那是旧版的产物。新版本如果你的环境里装了langchain0.3再用旧API会出现告警虽然还能跑但不推荐。我在代码里用的create_retrieval_chain、create_stuff_documents_chain是官方推荐的新写法如果你看到某个老教程里传参结构不一样先确认一下它的版本再抄。还要区分一下LangChain和LangGraph的关系LangGraph是LangChain团队出的Agent编排框架适合构建复杂的多Agent流程和动态图任务流。如果你只是做一个固定姿势的多轮问答客服系统用我上面这套代码就足够了不需要引入LangGraph。等将来要做任务路由、多模型分工或者需要显式的状态机控制时再考虑迁移到LangGraph。6.3 检索结果为空或答非所问先确认Chroma的embedding参数没有变。比如入库时用的是bge-m3加载时却用了别的模型向量的维度或者空间分布对不上检索就会失效。这类问题通常没有报错但行为异常很隐蔽。其次检查切分后的文档总量print(f共切分 {len(split_docs)} 个文档片段)如果只有几十个片段说明知识库太小检索覆盖面不够。这种情况下不论怎么调参数回答质量都难上台阶。另外确认一下加载完文档后确实执行了写入逻辑有些人在调试时把from_documents的行注释掉了结果每次都在一个空集合里检索。6.4 并发场景下会话内容串线如果同一个用户的请求在不同线程里同时进来且没有加锁或者没有把session_id隔离到位历史记录可能A用户看到B用户的上一句。排查方法是在get_session_history入口打一条日志打印session_id和当前历史条数。我遇到过的案例基本都是没传对session_id或者用了全局变量存历史都没有走session维度。处理的优先级是先把session_id传对再加锁兜底。最后一个我自己实战中总结的经验跑通这套系统不算难真正花时间的是知识库质量和检索参数之间的“磨合”。代码层面结构清晰、参数留好调整入口剩下的就是拿真实客服对话去压测。你会发现80%的烂回答不是因为模型不够聪明而是检索出来的资料不对。先把切分、embedding、k值这三样调稳多轮链路基本就能稳定工作。把这个基础打牢后面再加流式输出、用户情绪识别、多模型路由都是顺理成章的事。
返回列表