
收到说干就干。这期我们用Python Ollama Chroma LangChain老老实实从零搭一个本地多轮对话客服系统跑通全流程。讲真客服系统是LLM落地最典型的场景之一但市面上的教程要么只讲单轮对话要么直接用付费API要么把大模型包装得过于神秘。这次我选了一条完全免费、完全本地、可断网运行的路线Ollama负责跑大模型Chroma负责存知识库向量LangChain负责把对话记忆、知识检索、模型调用串成一条完整的处理链路。这个组合的好处是每一层都换得掉、改得动适合想搞清楚原理、不想被云厂商绑定的开发者。这篇是第一期我把项目从环境配置讲到你能够把多轮记忆和知识库问答跑起来。按我的习惯先讲清楚每个部件为什么要这么选、背后是什么逻辑再给完整可运行的代码最后是实操中必然会踩的坑。不管你是刚学Python的新手还是用过LangChain但没接本地模型的老手都能在里面找到对自己有用的东西。1. 整体方案设计与技术选型思路1.1 这套技术栈到底在解决什么问题先明确一下我们要做的东西长什么样。一个客服系统用户进来之后至少要做这几件事理解用户说的话维持上下文记住用户刚才问过什么。能回答两类问题一类是闲聊和常识靠模型自身能力另一类是产品、业务流程类问题要靠企业知识库检索。答案要稳定、可追溯不能每次回答都不一样更不能在知识库里有明确答案的时候自己瞎编。市面上很多Demo只做到了其中一点。拿单轮QA做Demo很简单调用一次模型接口返回结果就行但这离“系统”还差得远。真正能落地必须解决三件事记忆、检索、路由。记忆每轮对话的上下文要传给模型但历史记录无限增长会超长上下文窗口所以要做窗口管理。检索用向量数据库把企业FAQ或文档切块存入用户提问时先去检索相关知识把命中的段落拼进Prompt再让模型回答。路由判断用户当前问题适合走“普通对话”还是“知识库问答”避免所有问题都走检索流程造成性能浪费或回答混乱。这四个组件正好对应Python负责逻辑编排LangChain负责抽象链路的串联Ollama负责模型推理Chroma负责向量检索。1.2 为什么是Ollama而不是直接跑源码或调API我最开始试用大模型也走过弯路从HuggingFace下载模型权重再用Transformers库加载推理结果一个7B模型把16G内存吃满加载一次要两分钟。后来换Ollama发现体验完全变了。它的核心优势是一把模型管理简化成几个命令。安装之后直接ollama run qwen2.5:7b就能拉模型并开始对话不需要手动处理tokenizer、推理脚本和显存分配。二自带OpenAI兼容接口。只需要写一个Ollama(modelqwen2.5:7b)的客户端LangChain就能直接调用。如果你以后想换GPT-4或者通义千问的API只要把客户端类换掉业务逻辑一行不用动——这也是LangChain这类框架存在的意义。三支持离线运行。所有模型都保存在本机断网也能跑这对企业内部数据敏感的场景太重要了。另外Ollama原生就支持/bye、/clear这类命令来管理交互会话后台还有一个常驻的服务进程默认监听localhost:11434。后面LangChain调用的本质就是向这个端口发HTTP请求。1.3 为什么是Chroma而不是Milvus或Qdrant选向量数据库的时候我在Chroma、Milvus、Qdrant之间权衡了很久。Milvus是分布式架构支持十亿级向量检索功能齐全但部署重要单独起一堆服务对一个本地客服Demo来说属于拿大炮打蚊子。Qdrant性能好Rust写的但对新手来说配置门槛稍微高一点。Chroma最核心的优势是“嵌入式”和“开箱即用”。它有两种运行方式一种是Embedded模式直接在Python进程内运行数据持久化到本地文件夹另一种是Server模式通过Docker启动服务。我们这种单机项目直接选Embedded代码里指定一个persist_directory路径数据自动落到磁盘下次启动自动加载。这不代表Chroma只适合Demo。真实的客服知识库字段量级一般在几万到几十万条记录之间单个节点跑Chroma完全扛得住。真正到千万级向量再考虑换Qdrant或Milvus也来得及因为LangChain把所有向量库都抽象成了同一个接口切换成本没那么可怕。1.4 项目目录与模块划分写代码之前我习惯先把目录结构定下来。这个项目的规划是customer-service-system/ ├── config/ │ └── settings.py # 配置文件 ├── data/ │ ├── faq/ │ │ └── faq.md # 企业知识库原始文档 │ └── chroma_db/ # 向量数据库持久化目录 ├── modules/ │ ├── llm.py # 模型初始化 │ ├── memory.py # 多轮对话记忆管理 │ ├── knowledge.py # 向量库初始化与检索 │ ├── router.py # 意图路由 │ └── chatbot.py # 主流程入口 ├── scripts/ │ ├── init_db.py # 构建向量库 │ └── run_chat.py # 启动对话 └── requirements.txt这样划分的好处是每个模块只干一件事后面加功能比如加企微接入、加语音转文字只需要加模块不需要重写主流程。2. 环境准备与基础部署动手前必须做对的事2.1 Python环境与VS Code配置首先确保装了Python 3.10以上版本。客服系统依赖的LangChain生态从某个版本开始对Python 3.9的兼容就变差了新项目直接用3.10或3.11最省心。检查命令python --version如果没有Python去官网下载安装包注意勾选“Add Python to PATH”选项否者在命令行启动不了python命令。装完在命令行里执行一下python --version确认版本号。接下来创建一个虚拟环境。这一步强烈建议不要省Python项目最怕的就是全局环境里装了一堆包互相打架mkdir customer-service-system cd customer-service-system python -m venv venvWindows激活虚拟环境用venv\Scripts\activatemacOS/Linux用source venv/bin/activate。激活成功后命令行前缀会变成(venv)。编辑器我用VS Code装两个插件Python和Pylance。然后按CtrlShiftP打开命令面板输入Python: Select Interpreter选刚才创建的venv里的解释器。这一步如果不做后面写代码的时候会出现“import成功但编辑器报红”的尴尬情况代码能跑但体验很差。2.2 安装Ollama并完成模型下载Ollama的安装也简单官网直接下载对应系统的安装包Windows是exemacOS是dmgLinux是安装脚本。装完之后打开一个终端验证ollama --version第一次跑ollama run qwen2.5:7b会自动拉取模型7B参数量的模型大概4.7GB取决于网速。如果下载比较慢可以换国内镜像源加速方法是在环境变量里设置OLLAMA_MODEL相关镜像地址。如果实在下载不下来还有一个思路从ModelScope魔搭社区下载GGUF格式的模型文件再通过Ollama的模型导入功能加载到本地。具体命令大概是ollama create my-model -f ModelfileModelfile里指定本地GGUF路径。这个方案我实操过可以完全绕开下载瓶颈。模型下载成功后先在命令行里试一下ollama run qwen2.5:7b输入“你好”确认对话正常再输入/bye退出。这里我要多说一句模型选择。客服系统这种场景7B到8B这个档位的模型性价比很高qwen2.5:7b综合能力强中文语料充足如果机器配置比较差可以选qwen2.5:3b速度快但要牺牲一点理解能力。14B以上的模型对显存要求就高了CPU跑起来响应时间会明显变长。我自己的实践结论是先上7B跑通流程后再根据自己的显存和响应速度要求调模型。Ollama装完之后还有一个细节它会在后台常驻一个服务Windows的任务栏和macOS的菜单栏里都能看到图标。如果你改了模型目录或者要排查问题可以用ollama list查看已拉取的模型列表用ollama ps查看当前正在加载的模型。2.3 安装Chroma和LangChain这一步用pip统一安装所有依赖。我建议把依赖写进requirements.txt方便以后换机器复现langchain0.3.7 langchain-community0.3.5 langchain-chroma0.1.3 langchain-ollama0.1.2 chromadb0.5.15这里有一个版本概念LangChain从0.1时代就已经把很多第三方集成拆成了独立的子包。langchain-community里包含各种社区维护的组件langchain-chroma专门负责Chroma向量库的对接封装langchain-ollama则是Ollama的适配器。如果你装的是老教程的pip install langchain chromadb大概率会遇到API对不上的问题版本规划很清楚。所以在开发前一定要规划和锁定版本不能即装即忘。重要提示:Python 3.9及以下版本和langchain-chroma的兼容性不好如果你的环境装不上包先检查Python版本。安装命令pip install -r requirements.txt装完之后验证一下是否成功python -c from langchain_ollama import ChatOllama; from langchain_chroma import Chroma; print(Environment OK)没有报错说明基础环境就绪了。3. 系统核心流程逐段实现3.1 多轮记忆让聊天机器人记住上下文先攻最核心的部分——多轮对话。很多初学者直接把所有历史记录一股脑传给模型这样有几个问题第一长度无上限增长迟早超出模型的上下文窗口直接报错第二成本高、响应慢第三模型容易丢失重点。LangChain里提供了多种记忆组件客服场景我用的是ConversationBufferWindowMemory——它只保留最近几轮对话像窗口一样滑动。配置如下from langchain.memory import ConversationBufferWindowMemory memory ConversationBufferWindowMemory( k4, return_messagesTrue, )这里的k4表示保留最近4轮一轮一问一答实际是8条消息。这个数字怎么定我建议先从4开始因为客服场景的上下文跨度一般不会太长用户问完问题、你回答完基本就翻篇了。设太大会让模型变“啰嗦”还容易把早先的错误信息重新翻出来干扰判断。LangChain还要求记忆组件必须绑定一个ChatMessageHistory才能真正读写但在ConversationBufferWindowMemory的内部封装里已经自动处理了这个问题可以直接在Chain中使用。为了演示更通用这里直接用它自带的默认历史存储跑通之后你完全可以把它替换成Redis或数据库实现实现多用户会话隔离这对客服系统很重要后面我会单独写一篇多会话管理的文章。光有记忆还不够记忆和Prompt要拼在一起用。构造一个带记忆的对话链条from langchain_ollama import ChatOllama from langchain.chains import ConversationChain from langchain.prompts import ChatPromptTemplate, MessagesPlaceholder llm ChatOllama( modelqwen2.5:7b, temperature0.7, ) prompt ChatPromptTemplate.from_messages([ (system, 你是一个耐心的客服助手。请用简洁、友善的语气回答用户问题。), MessagesPlaceholder(variable_namehistory), (human, {input}), ]) chain ConversationChain( llmllm, promptprompt, memorymemory, )注意这个MessagesPlaceholder(variable_namehistory)它是LangChain里专门用来插入历史消息的占位符。系统提示词、历史消息、当前用户输入三部分被拼接在一起传给模型。整个Chain调用起来就一行answer chain.predict(input你好你们有什么产品) print(answer)再调用一次answer chain.predict(input那一款多少钱) print(answer)第二次的那一款模型能理解是指上一个问题里提到的产品因为历史记录已经被自动注入到Prompt里了。3.2 知识库接入把企业FAQ变成向量多轮记忆解决的是“对话连续性”接下来要解决的是“回答准确性”。客服系统不能只靠模型自由发挥要把企业知识库的内容接进来让模型基于资料回答。核心思路是把文档切成长度合适的文本块用Embedding模型转成向量存进Chroma。用户提问时把问题也转成向量到Chroma里找最相似的几个文本块拼进Prompt作为参考资料。先准备一份简单的FAQ文档data/faq/faq.md# 产品常见问题 ## 退款政策 用户可以在购买后7天内申请无理由退款。 退款将在审核通过后的3-5个工作日内原路返回。 ## 发货时间 工作日16:00前下单当天发货。 16:00后下单次日发货。 法定节假日顺延到节后第一个工作日。 ## 售后服务 产品支持一年质保非人为损坏可免费维修。 售后热线400-800-1234服务时间9:00-18:00。这份文档的知识可以分成三块每块都是独立的问答对。但如果实际文档更长你需要用RecursiveCharacterTextSplitter按段落或字数切块。下面是构建向量库的完整代码我把它写在scripts/init_db.py里from langchain_community.document_loaders import TextLoader from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain_ollama import OllamaEmbeddings from langchain_chroma import Chroma # 1. 加载文档 loader TextLoader(data/faq/faq.md, encodingutf-8) docs loader.load() print(f加载了 {len(docs)} 个文档) # 2. 切块 splitter RecursiveCharacterTextSplitter( chunk_size300, chunk_overlap50, separators[\n\n, \n, 。, , , , ], ) chunks splitter.split_documents(docs) print(f切分成 {len(chunks)} 个文本块) for i, chunk in enumerate(chunks): print(f--- chunk {i} ---) print(chunk.page_content)切块时的chunk_size和chunk_overlap需要特别说明。300这个值比较适合FAQ场景因为一个完整的问答对大约就是100到300字。如果切太小比如50一个完整语义会被拆断检索时匹配不完整如果切太大比如1000一个chunk里混入多个主题向量表达不聚焦检索准确率也会下降。overlap50是为了避免恰好把关键信息切在边界上丢掉。Embedding模型我用的是OllamaEmbeddings。注意Ollama要单独拉取一个Embedding模型ollama pull nomic-embed-text对于纯中文场景也可以使用bge-m3这是一个中文效果非常好的Embedding模型用法一样先ollama pull bge-m3代码里指定模型名即可。继续构建向量库embedding OllamaEmbeddings(modelnomic-embed-text) vectorstore Chroma.from_documents( documentschunks, embeddingembedding, persist_directorydata/chroma_db, ) print(向量库构建完成)运行一次python scripts/init_db.py运行完之后去data/chroma_db目录下看看可以看到生成了SQLite数据库文件和向量索引目录说明数据已经持久化到磁盘。以后再启动程序不需要重新构建直接加载即可vectorstore Chroma( persist_directorydata/chroma_db, embedding_functionembedding, )检索测试retriever vectorstore.as_retriever(search_kwargs{k: 3}) results retriever.invoke(退款几天到账) for i, doc in enumerate(results): print(f--- hit {i} ---) print(doc.page_content)这里用了余弦相似度做检索返回的是与问题向量最相近的前3个文本块。如果FAQ文档和问题所涉及的内容高度相关你会在输出里看到对应的退款政策段落。3.3 意图识别与路由判断该走哪条应答链路系统不可能每个问题都走知识库检索。用户问“今天天气怎么样”这种闲聊你让他检索企业FAQ反而降低效率。所以要在主流程里加一个“路由器”先判断意图再决定链路。我用两种方案做了对比一种是用传统正则规则做兜底另一种是让模型自己选。正则方案的代码是这样的import re def routes_by_rule(question: str) - str: if re.search(r退款|退货|发票|售后|发货|质保, question): return knowledge if re.search(r你好|在吗|谢谢|再见|你是谁, question): return chat return knowledge这个方案优势是零耗时、零成本、可控性强。缺点是规则覆盖面有限用户说法稍微变一下规则就命中不了。比如“钱什么时候退回来”也属于退款场景但不含“退款”两个字。所以更好的方案是让模型做意图分类。在Prompt里让它只输出一个标签然后做硬路由from langchain.prompts import PromptTemplate router_prompt PromptTemplate.from_template( 你是客服系统的意图分类器。以下有三种意图 - knowledge: 与公司产品、业务、售后等企业相关资料相关的问题 - chat: 闲聊、问候、个人情感等与企业资料无关的问题 用户问题{question} 请只输出一个词knowledge 或 chat。 ) router_chain router_prompt | llm result router_chain.invoke({question: question}) intent result.content.strip() print(识别意图:, intent)这里用到了LangChain的LCEL语法|管道符前面的router_prompt输出的Prompt对象被直接传给llm返回的result里.content就是模型输出。但是模型可能会有极少数情况输出“我选择knowledge”这种不规范的格式。我在生产代码里一般会在后面加一段解析逻辑用包含匹配来兜底def parse_intent(raw_output: str) - str: if chat in raw_output.lower(): return chat return knowledge就这一小步防御能避免很多莫名其妙的报错。3.4 主流程串联把记忆、检索、路由拼成完整链路到这里各个部件都已经准备好了现在把它们拼成一个完整的客服回复函数。看一下完整逻辑用户输入问题。意图路由器判断是走“chat”链路还是“knowledge”链路。如果走“knowledge”链路用向量库检索相关资料拼到Prompt里。如果走“chat”链路直接用大模型回答。在两种链路里都把历史对话记录拼进Prompt保证多轮连续性。返回答案。完整主流程代码from langchain.prompts import ChatPromptTemplate, MessagesPlaceholder knowledge_prompt ChatPromptTemplate.from_messages([ (system, 你是一个客服助手。请基于下面的企业资料回答问题。 如果资料中没有相关信息就明确回答抱歉相关资料中暂未覆盖这个问题不要编造。), (system, 参考资料\n{context}), MessagesPlaceholder(variable_namehistory), (human, {input}), ]) chat_prompt ChatPromptTemplate.from_messages([ (system, 你是一个友善的客服助手请简洁地回应用户。), MessagesPlaceholder(variable_namehistory), (human, {input}), ]) def get_answer(question: str) - str: intent parse_intent(router_chain.invoke({question: question}).content) if intent knowledge: docs retriever.invoke(question) context \n\n.join([d.page_content for d in docs]) chain knowledge_prompt | llm response chain.invoke({ context: context, history: memory.load_memory_variables({})[history], input: question, }) else: chain chat_prompt | llm response chain.invoke({ history: memory.load_memory_variables({})[history], input: question, }) answer response.content memory.save_context({input: question}, {output: answer}) return answer这一段代码里要注意memory.save_context的调用时机。之前我们用ConversationChain时内部会帮我们保存记忆但在这种自定义链路里每一步都要手动保存否则下一轮就记不住。我在第一次写的时候漏了这一步导致多轮对话完全失效排查了很久才找到原因。从LangChain 0.3开始官方更推荐用RunnableWithMessageHistory来做这种带记忆和外部检索的链它会把历史消息管理的职责从Prompt组装中抽离出来多会话场景下尤其好用。今天这篇先不过度扩展自定义链路的方式对于理解底层逻辑更直观先跑通再优化。4. 常见问题与排查技巧实录新手跑这个项目遇到问题是非常正常的。下面把我在实操中反复遇到的、以及学员反馈最多的问题整理成一个速查表问题现象常见原因解决方案langchain安装报错Python版本过低或包版本冲突确认Python≥3.10创建干净的虚拟环境后重新安装requirements.txtChatOllama调用超时或连接失败Ollama服务没启动或端口被占用终端执行ollama serve或打开Ollama桌面应用检查http://localhost:11434是否能访问拉取模型一直停在某个进度不动网络下载不稳定配置国内镜像源或从ModelScope下载GGUF模型后通过Modelfile导入本地OllamaChroma 构建向量库时内存暴涨文档太大、没切块就直接embedding先切块再入库如果Embedding模型较大控制并发数检索结果和问题无关Embedding模型不适合中文chunk_size过大中文场景换bge-m3调小chunk_size增加overlap多轮对话第一轮正常第二轮完全忘了上文自定义链路里忘记保存记忆每次回答保存上下文memory.save_context({input: q}, {output: a})模型回答知识库问题时出现幻觉Prompt里允许模型自由发挥在系统提示词里明确资料没有就承认不知道禁止编造模型输出包含额外说明文字导致意图解析失败LLM回答不规范输出了非目标标签用正则包含匹配做兜底而不是精确匹配除了上面的表格我再补充几个独家心得。第一Ollama的内存管理机制要心里有数。ollama llama.cpp运行时会把模型按权重加载到内存或显存切换不同大小的模型时会自动卸载旧的。如果你同时跑了对话模型和Embedding模型比如qwen2.5:7b和nomic-embed-text内存不够的话会导致性能下降甚至OOM。排查时会发现程序响应慢ollama ps查看模型加载状态。第二Embedding模型的作用范围比很多人想象的大。换Embedding模型的时候必须重新构建一遍整个向量库。因为新旧Embedding模型向量空间不一致老向量和新向量没法对比。我建议项目从第一天就固定好Embedding模型后续升级要评估一遍全量重建的成本。第三窗口记忆的“k”值和客服场景要匹配。我做过一次对比测试k2时用户上一轮提到的产品名下一轮再用指代词“那个XX”模型大概率会断片k6时上下文信息足够但回答会变啰嗦而且稍微一点历史噪音就能影响判断k4在客服场景里比较平衡。如果你的客服系统有多轮资料填写类需求比如开户、报修可能需要结合槽位填充做结构化状态管理而不是单纯堆窗口。第四向量数据库和传统数据库的定位要分开。Chroma负责的是“相似度召回”它不做精确过滤更不擅长聚合计算。比如“上个月有多少退款工单”这种问题向量库答不了那是SQL的事。客服系统成熟之后通常要做“混合检索”向量召回候选文档再用规则或评分模型精排答案。5. 最后再聊两句实战体会第一期内容做到这里我们已经把本地多轮客服系统的最小可行版本跑通了用户能和机器人连续对话机器人能记住上下文并且当用户提到退款、发货这类业务问题时系统会自动从FAQ知识库里检索资料再回答。这套东西完全可以作为一个小型客服机器人的原型扔到微信公众号后端或飞书机器人里当接单入口。我个人在实操中的一点体会是这套技术栈里真正决定系统上限的不是大模型能力而是知识库的质量和检索链路。同一个模型你喂他一份结构混乱的文档他给你的答案就会颠三倒四你把FAQ整理清爽、切块合理他的回答准确率会肉眼可见地提升。所以如果时间紧张宁可多花点时间整理数据也别一头扎进调模型参数的细节里。下一期我会沿着这个项目继续往下做包括多用户会话隔离与Redis持久化、客服系统的流式输出优化、用LangGraph替换简单Chain来管理复杂多轮状态、以及如何做检索增强中的rerank精排。你要是把自己机器上的坑都踩平了我们下一期见。