收到,说干就干。这期我们用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(model="qwen2.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\activate,macOS/Linux用source venv/bin/activate。激活成功后命令行前缀会变成(venv)。
编辑器我用VS Code,装两个插件:Python和Pylance。然后按Ctrl+Shift+P打开命令面板,输入Python: Select Interpreter,选刚才创建的venv里的解释器。这一步如果不做,后面写代码的时候会出现“import成功但编辑器报红”的尴尬情况,代码能跑但体验很差。
2.2 安装Ollama并完成模型下载
Ollama的安装也简单,官网直接下载对应系统的安装包,Windows是exe,macOS是dmg,Linux是安装脚本。装完之后打开一个终端验证:
ollama --version第一次跑ollama run qwen2.5:7b会自动拉取模型,7B参数量的模型大概4.7GB,取决于网速。如果下载比较慢,可以换国内镜像源加速,方法是在环境变量里设置OLLAMA_MODEL相关镜像地址。如果实在下载不下来,还有一个思路:从ModelScope魔搭社区下载GGUF格式的模型文件,再通过Ollama的模型导入功能加载到本地。具体命令大概是ollama create my-model -f Modelfile,Modelfile里指定本地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,方便以后换机器复现:
langchain==0.3.7 langchain-community==0.3.5 langchain-chroma==0.1.3 langchain-ollama==0.1.2 chromadb==0.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( k=4, return_messages=True, )这里的k=4表示保留最近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( model="qwen2.5:7b", temperature=0.7, ) prompt = ChatPromptTemplate.from_messages([ ("system", "你是一个耐心的客服助手。请用简洁、友善的语气回答用户问题。"), MessagesPlaceholder(variable_name="history"), ("human", "{input}"), ]) chain = ConversationChain( llm=llm, prompt=prompt, memory=memory, )注意这个MessagesPlaceholder(variable_name="history"),它是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", encoding="utf-8") docs = loader.load() print(f"加载了 {len(docs)} 个文档") # 2. 切块 splitter = RecursiveCharacterTextSplitter( chunk_size=300, chunk_overlap=50, 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里混入多个主题,向量表达不聚焦,检索准确率也会下降。overlap=50是为了避免恰好把关键信息切在边界上丢掉。
Embedding模型我用的是OllamaEmbeddings。注意Ollama要单独拉取一个Embedding模型:
ollama pull nomic-embed-text对于纯中文场景,也可以使用bge-m3,这是一个中文效果非常好的Embedding模型,用法一样先ollama pull bge-m3,代码里指定模型名即可。
继续构建向量库:
embedding = OllamaEmbeddings(model="nomic-embed-text") vectorstore = Chroma.from_documents( documents=chunks, embedding=embedding, persist_directory="data/chroma_db", ) print("向量库构建完成")运行一次:
python scripts/init_db.py运行完之后去data/chroma_db目录下看看,可以看到生成了SQLite数据库文件和向量索引目录,说明数据已经持久化到磁盘。以后再启动程序,不需要重新构建,直接加载即可:
vectorstore = Chroma( persist_directory="data/chroma_db", embedding_function=embedding, )检索测试:
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_name="history"), ("human", "{input}"), ]) chat_prompt = ChatPromptTemplate.from_messages([ ("system", "你是一个友善的客服助手,请简洁地回应用户。"), MessagesPlaceholder(variable_name="history"), ("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.txt |
ChatOllama调用超时或连接失败 | Ollama服务没启动,或端口被占用 | 终端执行ollama serve(或打开Ollama桌面应用),检查http://localhost:11434是否能访问 |
| 拉取模型一直停在某个进度不动 | 网络下载不稳定 | 配置国内镜像源,或从ModelScope下载GGUF模型后通过Modelfile导入本地Ollama |
| Chroma 构建向量库时内存暴涨 | 文档太大、没切块就直接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”值和客服场景要匹配。我做过一次对比测试:k=2时,用户上一轮提到的产品名,下一轮再用指代词(“那个XX”)模型大概率会断片;k=6时上下文信息足够但回答会变啰嗦,而且稍微一点历史噪音就能影响判断;k=4在客服场景里比较平衡。如果你的客服系统有多轮资料填写类需求(比如开户、报修),可能需要结合槽位填充做结构化状态管理,而不是单纯堆窗口。
第四,向量数据库和传统数据库的定位要分开。Chroma负责的是“相似度召回”,它不做精确过滤更不擅长聚合计算。比如“上个月有多少退款工单”这种问题,向量库答不了,那是SQL的事。客服系统成熟之后,通常要做“混合检索”:向量召回候选文档,再用规则或评分模型精排答案。
5. 最后再聊两句实战体会
第一期内容做到这里,我们已经把本地多轮客服系统的最小可行版本跑通了:用户能和机器人连续对话,机器人能记住上下文,并且当用户提到退款、发货这类业务问题时,系统会自动从FAQ知识库里检索资料再回答。这套东西完全可以作为一个小型客服机器人的原型,扔到微信公众号后端或飞书机器人里当接单入口。
我个人在实操中的一点体会是:这套技术栈里,真正决定系统上限的不是大模型能力,而是知识库的质量和检索链路。同一个模型,你喂他一份结构混乱的文档,他给你的答案就会颠三倒四;你把FAQ整理清爽、切块合理,他的回答准确率会肉眼可见地提升。所以如果时间紧张,宁可多花点时间整理数据,也别一头扎进调模型参数的细节里。
下一期我会沿着这个项目继续往下做,包括多用户会话隔离与Redis持久化、客服系统的流式输出优化、用LangGraph替换简单Chain来管理复杂多轮状态、以及如何做检索增强中的rerank精排。你要是把自己机器上的坑都踩平了,我们下一期见。