LangChain实战:用Ollama本地大模型搭建智能客服系统(完整代码+配置流程)

最近和几个做企业服务的朋友聊天,他们都在头疼同一个问题:客服成本越来越高,但服务质量却很难标准化。招人难、培训周期长、夜间服务覆盖不足,这些痛点几乎成了行业通病。有人尝试过市面上的SaaS客服机器人,但要么效果不佳,要么担心数据安全问题,尤其是涉及内部知识库和客户隐私的时候。

这让我想起了去年自己折腾的一个项目——用本地大模型搭建智能客服。当时主要考虑两点:一是数据完全留在本地,安全可控;二是可以深度定制,贴合业务场景。经过几个月的迭代,这套基于LangChain和Ollama的方案已经能处理大部分常见咨询,甚至在特定领域的回答比人工客服更精准。

今天我就把这套系统的完整搭建过程拆解出来,从环境准备到代码实现,再到性能调优,手把手带你走一遍。无论你是想给现有业务加个智能助手,还是单纯想探索本地大模型的应用,这篇文章都能给你实实在在的参考。

1. 系统架构设计与核心组件选型

搭建一个能用的客服系统和搭建一个好用的客服系统,中间隔着好几个层级。我们先从顶层设计开始,理清楚各个模块的职责和交互方式。

1.1 整体架构分层

我设计的这套系统采用典型的分层架构,从上到下依次是交互层、应用层、服务层和数据层。这种设计的好处是模块清晰,后期扩展和维护都比较方便。

用户界面 (Web/API)
    │
    ▼
应用层 (LangChain Chain/Agent)
    │
    ▼
服务层 (向量检索/记忆管理)
    │
    ▼
数据层 (文档/向量库/配置)

交互层负责与用户对接,可以是Web界面、API接口,甚至集成到微信、钉钉等平台。考虑到大多数企业的实际情况,我建议先从API开始,这样前端可以灵活选择。

应用层是整个系统的智能核心,基于LangChain构建。这里需要设计合适的Chain(处理链)或Agent(智能体)来组织对话流程。我的经验是,对于客服场景,一个精心设计的Chain往往比复杂的Agent更稳定可靠。

服务层提供关键能力支持,主要包括:

  • 向量检索服务:用于从知识库中快速找到相关文档
  • 对话记忆管理:记住用户的历史对话,提供连贯的体验
  • 意图识别模块:判断用户问题的类型,路由到不同的处理流程

数据层存储所有需要的信息,包括:

  • 原始文档(产品手册、FAQ、政策文件等)
  • 向量化后的知识库
  • 对话历史记录
  • 系统配置信息

1.2 核心组件技术选型

选型是个技术活,既要考虑功能需求,也要权衡开发成本和维护难度。下面是我经过实际测试后的推荐方案:

组件推荐方案备选方案选择理由
大模型Ollama + Llama 3.1 8BDeepSeek-R1, Qwen2.5Llama 3.1在8B参数级别表现均衡,中英文支持好,推理速度适中
向量数据库ChromaDBFAISS, PineconeChroma轻量易用,支持持久化,Python集成友好
文本分割器RecursiveCharacterTextSplitterTokenTextSplitter按字符递归分割能更好保持语义完整性
嵌入模型BAAI/bge-small-zh-v1.5text-embedding-ada-002专门优化中文,本地部署,无需API调用
Web框架FastAPIFlask, Django异步支持好,性能出色,自动生成API文档

这里重点说说模型选择。很多人一上来就想用最大的模型,觉得参数越多效果越好。但实际部署时你会发现,模型大小直接影响到响应速度和硬件成本。对于客服场景,大部分问题都是相对标准的,不需要太强的推理能力。

我测试过几个主流模型在客服问答上的表现:

# 模型响应速度测试对比(本地RTX 4060显卡)
models = {
    "llama3.1:8b": "响应时间: 2-3秒,质量: 优秀",
    "qwen2.5:7b": "响应时间: 1.5-2.5秒,质量: 良好", 
    "deepseek-r1:7b": "响应时间: 3-4秒,质量: 优秀(推理强)",
    "mistral:7b": "响应时间: 1-2秒,质量: 良好"
}

从实际使用来看,Llama 3.1 8B在中文客服场景下表现最均衡。它的回答比较规范,不太会“胡言乱语”,这对于企业应用很重要。如果对推理能力要求高(比如需要多步计算的问题),可以考虑DeepSeek-R1,但要注意它的响应时间会稍长一些。

提示:选择模型时一定要在自己的数据集上做AB测试。不同模型在不同类型问题上的表现差异很大,别人的推荐只能作为参考。

1.3 知识库设计原则

知识库的质量直接决定客服系统的上限。很多人以为把文档扔进去就行,其实这里面有很多讲究。

首先,文档预处理是关键一步。我通常会把原始文档(PDF、Word、网页等)转换成纯文本,然后进行清洗:

  • 移除无关的页眉页脚、水印
  • 处理表格数据,保持结构信息
  • 统一格式和编码
  • 分段处理,确保每个段落语义完整

其次,分块策略直接影响检索效果。太小的块会丢失上下文,太大的块会引入噪声。我的经验是:

  • 对于FAQ类文档,按问题-答案对分块
  • 对于手册类文档,按章节或主题分块,每块500-800字
  • 重叠设置150-200字,确保边界问题也能被检索到
from langchain.text_splitter import RecursiveCharacterTextSplitter

# 推荐的分块配置
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=800,      # 每个块的最大字符数
    chunk_overlap=150,   # 块之间的重叠字符数
    separators=["\n\n", "\n", "。", ";", ",", " ", ""]  # 分割符优先级
)

最后,元数据管理经常被忽视,但很有用。给每个文档块添加元数据,比如来源文档、创建时间、主题标签等,可以在检索时进行过滤,提高精准度。

2. 环境搭建与依赖安装

环境配置是第一步,也是最容易踩坑的地方。我建议按照下面的顺序一步步来,确保每个环节都验证通过。

2.1 基础环境准备

硬件配置方面,如果你的客服系统预计并发量不大(比如同时在线用户<100),那么一台配置不错的台式机就够用了。我的开发机配置供你参考:

  • CPU: Intel i7-13700K(16核24线程)
  • 内存: 32GB DDR5
  • 显卡: NVIDIA RTX 4060 12GB(如果只用CPU推理,显卡可选)
  • 存储: 1TB NVMe SSD
  • 系统: Ubuntu 22.04 LTS / Windows 11 WSL2

软件环境需要提前准备好:

  1. Python 3.10+:3.10是个比较稳定的版本,新特性支持好,社区包兼容性也不错
  2. CUDA 12.1(如果使用GPU加速):NVIDIA显卡必备
  3. Docker(可选):方便部署和隔离环境

注意:Windows用户强烈建议使用WSL2(Windows Subsystem for Linux),很多AI相关的库在Linux环境下更稳定。WSL2的安装和配置微软官方有详细文档,这里不展开。

2.2 Ollama安装与模型部署

Ollama是目前最方便的本地大模型管理工具,支持一键安装、模型下载和API服务。

安装Ollama:

# Linux/macOS一键安装
curl -fsSL https://ollama.ai/install.sh | sh

# Windows用户直接下载安装包
# 访问 https://ollama.ai/download 下载exe文件

安装完成后,启动Ollama服务:

# 启动服务(Linux/macOS)
ollama serve

# Windows会自动创建服务,可以在任务管理器查看

下载模型:

# 拉取Llama 3.1 8B模型(推荐)
ollama pull llama3.1:8b

# 如果想试试其他模型
ollama pull qwen2.5:7b
ollama pull deepseek-r1:7b

模型下载完成后,验证是否正常运行:

# 测试模型响应
ollama run llama3.1:8b "你好,请介绍一下你自己"

# 或者通过API测试
curl http://localhost:11434/api/generate -d '{
  "model": "llama3.1:8b",
  "prompt": "你好",
  "stream": false
}'

如果看到正常的回复,说明模型部署成功。第一次运行可能会慢一些,因为要加载模型到内存。

2.3 Python环境与依赖包

我习惯为每个项目创建独立的虚拟环境,避免包冲突。这里用conda和venv都可以,看个人习惯。

# 创建虚拟环境(conda方式)
conda create -n langchain-cs python=3.10
conda activate langchain-cs

# 或者使用venv
python -m venv langchain-cs
source langchain-cs/bin/activate  # Linux/macOS
langchain-cs\Scripts\activate     # Windows

安装核心依赖包:

# 基础框架
pip install langchain langchain-core langchain-community

# Ollama集成
pip install langchain-ollama

# 向量数据库和嵌入模型
pip install chromadb sentence-transformers

# Web框架和工具
pip install fastapi uvicorn python-multipart

# 文档处理(按需安装)
pip install pypdf python-docx beautifulsoup4 markdown

验证安装是否成功:

import langchain
print(f"LangChain版本: {langchain.__version__}")

from langchain_ollama import OllamaLLM
llm = OllamaLLM(model="llama3.1:8b")
response = llm.invoke("测试")
print(f"模型响应: {response[:50]}...")

如果一切正常,你会看到LangChain的版本号和模型的简短回复。到这里,基础环境就准备好了。

3. 核心功能模块实现

有了基础环境,我们开始实现系统的核心功能。我会按照数据准备、检索增强、对话管理的顺序来讲解。

3.1 知识库构建与向量化

知识库是客服系统的“大脑”,构建过程需要仔细处理。我通常把这个过程分为四个步骤:文档加载、文本分割、向量化、存储索引。

第一步:文档加载 LangChain提供了丰富的文档加载器,支持各种格式:

from langchain_community.document_loaders import (
    PyPDFLoader, 
    DocxLoader,
    TextLoader,
    WebBaseLoader,
    UnstructuredMarkdownLoader
)

# 根据文件类型选择加载器
def load_documents(file_path):
    if file_path.endswith('.pdf'):
        loader = PyPDFLoader(file_path)
    elif file_path.endswith('.docx'):
        loader = DocxLoader(file_path)
    elif file_path.endswith('.txt'):
        loader = TextLoader(file_path, encoding='utf-8')
    elif file_path.endswith('.md'):
        loader = UnstructuredMarkdownLoader(file_path)
    else:
        raise ValueError(f"不支持的格式: {file_path}")
    
    documents = loader.load()
    return documents

# 批量加载文档
documents = []
for file_path in document_files:
    docs = load_documents(file_path)
    documents.extend(docs)
print(f"共加载 {len(documents)} 个文档")

第二步:文本分割与处理 直接使用原始文档效果不好,需要合理分割:

from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.docstore.document import Document

def process_documents(documents, chunk_size=800, chunk_overlap=150):
    """处理文档,包括分割和添加元数据"""
    
    # 创建分割器
    text_splitter = RecursiveCharacterTextSplitter(
        chunk_size=chunk_size,
        chunk_overlap=chunk_overlap,
        separators=["\n\n", "\n", "。", ";", ",", " ", ""]
    )
    
    # 分割文档
    chunks = text_splitter.split_documents(documents)
    
    # 为每个块添加元数据
    processed_chunks = []
    for i, chunk in enumerate(chunks):
        # 提取或生成摘要
        if len(chunk.page_content) > 100:
            summary = chunk.page_content[:100] + "..."
        else:
            summary = chunk.page_content
        
        # 添加元数据
        metadata = chunk.metadata.copy()
        metadata.update({
            "chunk_id": i,
            "summary": summary,
            "word_count": len(chunk.page_content),
            "process_time": datetime.now().isoformat()
        })
        
        processed_chunks.append(Document(
            page_content=chunk.page_content,
            metadata=metadata
        ))
    
    return processed_chunks

第三步:向量化与存储 这里我选择ChromaDB作为向量数据库,BGE模型做嵌入:

from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_community.vectorstores import Chroma
import os

class KnowledgeBase:
    def __init__(self, persist_directory="./chroma_db"):
        self.persist_directory = persist_directory
        self.embeddings = None
        self.vectorstore = None
        
    def init_embeddings(self):
        """初始化嵌入模型"""
        # 使用BAAI的中文嵌入模型
        model_name = "BAAI/bge-small-zh-v1.5"
        self.embeddings = HuggingFaceEmbeddings(
            model_name=model_name,
            model_kwargs={'device': 'cpu'},  # 可用'cuda'加速
            encode_kwargs={'normalize_embeddings': True}
        )
        return self.embeddings
    
    def create_vectorstore(self, documents):
        """创建向量存储"""
        if not self.embeddings:
            self.init_embeddings()
        
        # 创建向量存储
        self.vectorstore = Chroma.from_documents(
            documents=documents,
            embedding=self.embeddings,
            persist_directory=self.persist_directory
        )
        
        # 持久化
        self.vectorstore.persist()
        print(f"向量库创建完成,共 {len(documents)} 个文档块")
        return self.vectorstore
    
    def load_vectorstore(self):
        """加载已有的向量存储"""
        if not self.embeddings:
            self.init_embeddings()
        
        if os.path.exists(self.persist_directory):
            self.vectorstore = Chroma(
                persist_directory=self.persist_directory,
                embedding_function=self.embeddings
            )
            print("向量库加载成功")
            return self.vectorstore
        else:
            print("向量库不存在,请先创建")
            return None
    
    def similarity_search(self, query, k=5, filter_dict=None):
        """相似度搜索"""
        if not self.vectorstore:
            self.load_vectorstore()
        
        results = self.vectorstore.similarity_search(
            query=query,
            k=k,
            filter=filter_dict
        )
        return results

实际使用时,你可以这样构建知识库:

# 初始化知识库
kb = KnowledgeBase()

# 加载和处理文档
raw_docs = load_documents("产品手册.pdf")
processed_docs = process_documents(raw_docs)

# 创建向量存储
vectorstore = kb.create_vectorstore(processed_docs)

# 测试检索
results = kb.similarity_search("如何退货?", k=3)
for i, doc in enumerate(results):
    print(f"结果 {i+1}: {doc.metadata.get('summary', '无摘要')}")

3.2 检索增强生成(RAG)实现

RAG是当前最实用的知识增强方案,它让模型能够基于特定知识回答问题,而不是仅凭训练数据“编造”。

基础RAG链实现:

from langchain.chains import RetrievalQA
from langchain.prompts import PromptTemplate
from langchain_ollama import OllamaLLM

class RAGChatbot:
    def __init__(self, model_name="llama3.1:8b"):
        self.llm = OllamaLLM(
            model=model_name,
            temperature=0.1,  # 低温度使回答更确定
            num_predict=512   # 最大生成长度
        )
        self.vectorstore = None
        self.qa_chain = None
        
    def init_chain(self, vectorstore):
        """初始化RAG链"""
        self.vectorstore = vectorstore
        
        # 自定义提示模板
        prompt_template = """你是一个专业的客服助手,请根据提供的上下文信息回答问题。
        如果上下文中有相关信息,请基于上下文回答。
        如果上下文中没有相关信息,请根据你的知识诚实回答"我不知道"。
        
        上下文:
        {context}
        
        问题:{question}
        
        请用中文回答,保持专业和友好:"""
        
        PROMPT = PromptTemplate(
            template=prompt_template,
            input_variables=["context", "question"]
        )
        
        # 创建检索器
        retriever = vectorstore.as_retriever(
            search_type="similarity",
            search_kwargs={"k": 4}  # 检索4个最相关的文档块
        )
        
        # 创建RAG链
        self.qa_chain = RetrievalQA.from_chain_type(
            llm=self.llm,
            chain_type="stuff",  # 简单合并上下文
            retriever=retriever,
            chain_type_kwargs={"prompt": PROMPT},
            return_source_documents=True
        )
        
        return self.qa_chain
    
    def ask(self, question):
        """提问并获取回答"""
        if not self.qa_chain:
            raise ValueError("请先初始化RAG链")
        
        result = self.qa_chain({"query": question})
        
        return {
            "answer": result["result"],
            "sources": [
                {
                    "content": doc.page_content[:200] + "...",
                    "metadata": doc.metadata
                }
                for doc in result["source_documents"]
            ]
        }

高级RAG优化技巧: 基础RAG有时候效果不够理想,特别是当问题复杂或者知识库庞大时。我总结了几个优化点:

  1. 重排序(Re-ranking):相似度搜索返回的结果不一定最相关,可以用更精细的模型重新排序。
from sentence_transformers import CrossEncoder

class Reranker:
    def __init__(self, model_name="BAAI/bge-reranker-base"):
        self.model = CrossEncoder(model_name)
    
    def rerank(self, query, documents, top_k=3):
        """对检索结果重新排序"""
        pairs = [[query, doc.page_content] for doc in documents]
        scores = self.model.predict(pairs)
        
        # 按分数排序
        ranked = sorted(zip(documents, scores), key=lambda x: x[1], reverse=True)
        return [doc for doc, score in ranked[:top_k]]

# 在RAG链中使用重排序
def enhanced_retrieval(query, vectorstore, reranker, k=10, top_k=3):
    # 先检索更多结果
    docs = vectorstore.similarity_search(query, k=k)
    # 重排序
    ranked_docs = reranker.rerank(query, docs, top_k=top_k)
    return ranked_docs
  1. 查询扩展:有时候用户的问题表述不完整,可以自动扩展查询词。
def expand_query(original_query, llm):
    """使用LLM扩展查询词"""
    expansion_prompt = f"""
    原始问题:{original_query}
    
    请生成3个相关的查询,用于在知识库中搜索相关信息。
    每个查询应该从不同角度表达相同或相关的意图。
    
    输出格式:
    1. [查询1]
    2. [查询2]
    3. [查询3]
    """
    
    response = llm.invoke(expansion_prompt)
    # 解析响应,提取查询词
    queries = [original_query]  # 包含原始查询
    lines = response.split('\n')
    for line in lines:
        if line.strip() and any(char.isdigit() for char in line[:3]):
            query = line.split('.', 1)[-1].strip()
            if query:
                queries.append(query)
    
    return queries[:4]  # 最多4个查询
  1. 混合检索:结合关键词搜索和向量搜索,取长补短。
from langchain.retrievers import BM25Retriever
from langchain.retrievers.ensemble import EnsembleRetriever

def create_hybrid_retriever(vectorstore, text_docs):
    """创建混合检索器"""
    # 向量检索器
    vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 5})
    
    # BM25关键词检索器
    bm25_retriever = BM25Retriever.from_documents(text_docs)
    bm25_retriever.k = 5
    
    # 集成检索器
    ensemble_retriever = EnsembleRetriever(
        retrievers=[vector_retriever, bm25_retriever],
        weights=[0.7, 0.3]  # 向量检索权重更高
    )
    
    return ensemble_retriever

3.3 对话管理与记忆机制

好的客服系统需要记住对话历史,否则每次都要用户重复信息,体验很差。LangChain提供了多种记忆机制,我根据客服场景做了定制。

对话记忆实现:

from langchain.memory import ConversationBufferWindowMemory
from langchain.schema import BaseMessage, HumanMessage, AIMessage
import json
from datetime import datetime

class CustomerServiceMemory:
    def __init__(self, window_size=10, persist_file="./memory.json"):
        self.memory = ConversationBufferWindowMemory(
            memory_key="chat_history",
            return_messages=True,
            k=window_size  # 记住最近10轮对话
        )
        self.persist_file = persist_file
        self.user_context = {}  # 用户上下文信息
        
    def add_context(self, user_id, key, value):
        """添加上下文信息"""
        if user_id not in self.user_context:
            self.user_context[user_id] = {}
        self.user_context[user_id][key] = value
        self._save_context()
    
    def get_context(self, user_id, key=None):
        """获取上下文信息"""
        if user_id not in self.user_context:
            return None if key else {}
        
        if key:
            return self.user_context[user_id].get(key)
        return self.user_context[user_id].copy()
    
    def add_conversation(self, user_id, human_input, ai_response):
        """添加对话记录"""
        # 转换为LangChain的消息格式
        human_msg = HumanMessage(content=human_input)
        ai_msg = AIMessage(content=ai_response)
        
        # 添加到记忆
        self.memory.chat_memory.add_message(human_msg)
        self.memory.chat_memory.add_message(ai_msg)
        
        # 提取可能的用户信息
        self._extract_user_info(user_id, human_input, ai_response)
        
        self._save_memory()
    
    def _extract_user_info(self, user_id, human_input, ai_response):
        """从对话中提取用户信息(简化版)"""
        # 这里可以集成NER模型来提取实体信息
        info_keywords = {
            "姓名": ["我叫", "我是", "名字是", "称呼我"],
            "订单号": ["订单号", "订单编号", "order number"],
            "手机号": ["手机", "电话", "联系方式"],
            "邮箱": ["邮箱", "email", "电子邮件"]
        }
        
        for info_type, keywords in info_keywords.items():
            for keyword in keywords:
                if keyword in human_input:
                    # 简单提取,实际应该用更复杂的方法
                    start_idx = human_input.find(keyword)
                    if start_idx != -1:
                        # 提取关键词后的内容
                        extracted = human_input[start_idx + len(keyword):].strip()
                        if extracted and len(extracted) < 50:  # 防止提取过多
                            self.add_context(user_id, info_type, extracted)
                            break
    
    def get_conversation_history(self, user_id=None):
        """获取对话历史"""
        # 这里简化处理,实际应该按用户ID隔离
        history = self.memory.load_memory_variables({})
        return history.get("chat_history", [])
    
    def _save_memory(self):
        """保存记忆到文件"""
        try:
            data = {
                "conversations": [
                    {
                        "role": msg.type,
                        "content": msg.content,
                        "timestamp": datetime.now().isoformat()
                    }
                    for msg in self.memory.chat_memory.messages[-20:]  # 保存最近20条
                ],
                "user_context": self.user_context,
                "last_updated": datetime.now().isoformat()
            }
            
            with open(self.persist_file, 'w', encoding='utf-8') as f:
                json.dump(data, f, ensure_ascii=False, indent=2)
        except Exception as e:
            print(f"保存记忆失败: {e}")
    
    def _save_context(self):
        """保存上下文到文件"""
        self._save_memory()  # 简化处理,一起保存
    
    def load_memory(self):
        """从文件加载记忆"""
        try:
            if os.path.exists(self.persist_file):
                with open(self.persist_file, 'r', encoding='utf-8') as f:
                    data = json.load(f)
                
                # 恢复对话历史
                self.memory.chat_memory.clear()
                for conv in data.get("conversations", []):
                    if conv["role"] == "human":
                        msg = HumanMessage(content=conv["content"])
                    else:
                        msg = AIMessage(content=conv["content"])
                    self.memory.chat_memory.add_message(msg)
                
                # 恢复用户上下文
                self.user_context = data.get("user_context", {})
                
                print(f"记忆加载成功,共 {len(data.get('conversations', []))} 条记录")
                return True
        except Exception as e:
            print(f"加载记忆失败: {e}")
        return False

带记忆的RAG链: 有了记忆模块,我们需要修改RAG链来利用这些信息:

from langchain.chains import ConversationalRetrievalChain

class MemoryEnhancedChatbot(RAGChatbot):
    def __init__(self, model_name="llama3.1:8b"):
        super().__init__(model_name)
        self.memory_manager = CustomerServiceMemory()
        
    def init_conversational_chain(self, vectorstore):
        """初始化带记忆的对话链"""
        # 加载记忆
        self.memory_manager.load_memory()
        
        # 创建对话检索链
        self.qa_chain = ConversationalRetrievalChain.from_llm(
            llm=self.llm,
            retriever=vectorstore.as_retriever(search_kwargs={"k": 4}),
            memory=self.memory_manager.memory,
            chain_type="stuff",
            verbose=False,  # 调试时可设为True
            max_tokens_limit=4000,  # 上下文token限制
            get_chat_history=lambda h: h  # 获取历史函数
        )
        
        return self.qa_chain
    
    def ask_with_context(self, user_id, question):
        """带上下文的提问"""
        # 获取用户上下文
        user_context = self.memory_manager.get_context(user_id)
        
        # 如果有用户信息,可以增强问题
        enhanced_question = question
        if user_context:
            # 简单示例:在问题前加上用户信息
            context_str = " ".join([f"{k}:{v}" for k, v in user_context.items()])
            enhanced_question = f"[用户信息: {context_str}] {question}"
        
        # 执行查询
        result = self.qa_chain({"question": enhanced_question})
        
        # 保存对话
        self.memory_manager.add_conversation(user_id, question, result["answer"])
        
        return {
            "answer": result["answer"],
            "user_id": user_id,
            "context_used": bool(user_context)
        }

4. 系统集成与API服务

单个功能模块测试通过后,我们需要把它们集成起来,并通过API提供服务。这里我用FastAPI搭建Web服务,因为它性能好,异步支持完善。

4.1 FastAPI服务搭建

主应用结构:

from fastapi import FastAPI, HTTPException, Depends
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import List, Optional, Dict, Any
import uvicorn
import logging
from datetime import datetime

# 配置日志
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)

# 定义数据模型
class ChatRequest(BaseModel):
    question: str
    user_id: str = "anonymous"
    session_id: Optional[str] = None
    context: Optional[Dict[str, Any]] = None

class ChatResponse(BaseModel):
    answer: str
    session_id: str
    timestamp: str
    sources: Optional[List[Dict]] = None
    processing_time: float

class KnowledgeUpdateRequest(BaseModel):
    documents: List[str]  # 文档路径列表
    clear_existing: bool = False

class SystemStatus(BaseModel):
    model: str
    vectorstore_size: int
    memory_entries: int
    uptime: float
    last_request: Optional[str] = None

# 创建FastAPI应用
app = FastAPI(
    title="智能客服系统API",
    description="基于LangChain和Ollama的本地大模型客服系统",
    version="1.0.0"
)

# 添加CORS中间件
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],  # 生产环境应该限制域名
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# 全局组件
chatbot = None
knowledge_base = None

@app.on_event("startup")
async def startup_event():
    """应用启动时初始化组件"""
    global chatbot, knowledge_base
    
    logger.info("正在初始化智能客服系统...")
    
    try:
        # 初始化知识库
        from knowledge_base import KnowledgeBase
        knowledge_base = KnowledgeBase(persist_directory="./data/chroma_db")
        kb_status = knowledge_base.load_vectorstore()
        
        if not kb_status:
            logger.warning("知识库不存在,请先创建知识库")
        
        # 初始化聊天机器人
        from chatbot import MemoryEnhancedChatbot
        chatbot = MemoryEnhancedChatbot(model_name="llama3.1:8b")
        
        if kb_status:
            chatbot.init_conversational_chain(knowledge_base.vectorstore)
            logger.info("对话链初始化成功")
        else:
            logger.info("聊天机器人初始化完成(无知识库)")
        
        logger.info("系统启动完成")
        
    except Exception as e:
        logger.error(f"系统初始化失败: {e}")
        raise

@app.get("/")
async def root():
    """根路径,返回服务信息"""
    return {
        "service": "智能客服系统",
        "version": "1.0.0",
        "status": "running",
        "endpoints": {
            "chat": "/api/chat",
            "knowledge": "/api/knowledge/update",
            "status": "/api/status"
        }
    }

@app.post("/api/chat", response_model=ChatResponse)
async def chat_endpoint(request: ChatRequest):
    """处理用户聊天请求"""
    start_time = datetime.now()
    
    if not chatbot:
        raise HTTPException(status_code=503, detail="系统未初始化完成")
    
    try:
        # 处理用户问题
        if request.context:
            # 如果有额外上下文,添加到用户信息中
            for key, value in request.context.items():
                chatbot.memory_manager.add_context(
                    request.user_id, key, value
                )
        
        # 获取回答
        result = chatbot.ask_with_context(
            user_id=request.user_id,
            question=request.question
        )
        
        # 计算处理时间
        processing_time = (datetime.now() - start_time).total_seconds()
        
        # 生成会话ID(如果未提供)
        session_id = request.session_id or f"session_{datetime.now().timestamp()}"
        
        response = ChatResponse(
            answer=result["answer"],
            session_id=session_id,
            timestamp=datetime.now().isoformat(),
            processing_time=processing_time
        )
        
        logger.info(f"用户 {request.user_id} 提问: {request.question[:50]}...")
        logger.info(f"处理时间: {processing_time:.2f}秒")
        
        return response
        
    except Exception as e:
        logger.error(f"处理聊天请求失败: {e}")
        raise HTTPException(status_code=500, detail=f"处理失败: {str(e)}")

@app.post("/api/knowledge/update")
async def update_knowledge(request: KnowledgeUpdateRequest):
    """更新知识库"""
    if not knowledge_base:
        raise HTTPException(status_code=503, detail="知识库未初始化")
    
    try:
        from document_processor import load_and_process_documents
        
        # 加载和处理文档
        documents = load_and_process_documents(request.documents)
        
        if request.clear_existing:
            # 清空现有知识库(实际应该更安全地处理)
            import shutil
            if os.path.exists(knowledge_base.persist_directory):
                shutil.rmtree(knowledge_base.persist_directory)
            knowledge_base.vectorstore = None
        
        # 创建或更新向量存储
        vectorstore = knowledge_base.create_vectorstore(documents)
        
        # 更新聊天机器人的检索器
        if chatbot:
            chatbot.init_conversational_chain(vectorstore)
        
        return {
            "status": "success",
            "message": f"知识库更新成功,新增 {len(documents)} 个文档块",
            "total_chunks": len(documents)
        }
        
    except Exception as e:
        logger.error(f"更新知识库失败: {e}")
        raise HTTPException(status_code=500, detail=f"更新失败: {str(e)}")

@app.get("/api/status", response_model=SystemStatus)
async def get_status():
    """获取系统状态"""
    if not chatbot or not knowledge_base:
        raise HTTPException(status_code=503, detail="系统未初始化")
    
    # 获取向量库大小
    vs_size = 0
    if knowledge_base.vectorstore:
        try:
            # 这里需要根据实际向量库实现获取大小
            vs_size = knowledge_base.vectorstore._collection.count()
        except:
            vs_size = -1  # 无法获取
    
    # 获取记忆条目数
    memory_count = len(chatbot.memory_manager.get_conversation_history())
    
    # 计算运行时间(简化)
    uptime = 3600  # 示例值,实际应该从启动时间计算
    
    return SystemStatus(
        model="llama3.1:8b",
        vectorstore_size=vs_size,
        memory_entries=memory_count,
        uptime=uptime,
        last_request=datetime.now().isoformat()
    )

if __name__ == "__main__":
    # 启动服务
    uvicorn.run(
        app,
        host="0.0.0.0",
        port=8000,
        log_level="info"
    )

API测试客户端: 为了方便测试,我写了一个简单的客户端:

import requests
import json
from typing import Dict, Any

class ChatClient:
    def __init__(self, base_url="http://localhost:8000"):
        self.base_url = base_url
        self.session_id = None
        
    def chat(self, question: str, user_id: str = "test_user") -> Dict[str, Any]:
        """发送聊天请求"""
        url = f"{self.base_url}/api/chat"
        payload = {
            "question": question,
            "user_id": user_id,
            "session_id": self.session_id
        }
        
        try:
            response = requests.post(url, json=payload, timeout=30)
            response.raise_for_status()
            result = response.json()
            
            # 保存会话ID
            if not self.session_id:
                self.session_id = result.get("session_id")
            
            return result
            
        except requests.exceptions.RequestException as e:
            print(f"请求失败: {e}")
            return {"error": str(e)}
    
    def update_knowledge(self, document_paths: list, clear_existing: bool = False):
        """更新知识库"""
        url = f"{self.base_url}/api/knowledge/update"
        payload = {
            "documents": document_paths,
            "clear_existing": clear_existing
        }
        
        try:
            response = requests.post(url, json=payload, timeout=60)
            response.raise_for_status()
            return response.json()
        except requests.exceptions.RequestException as e:
            print(f"更新知识库失败: {e}")
            return {"error": str(e)}
    
    def get_status(self):
        """获取系统状态"""
        url = f"{self.base_url}/api/status"
        try:
            response = requests.get(url, timeout=10)
            response.raise_for_status()
            return response.json()
        except requests.exceptions.RequestException as e:
            print(f"获取状态失败: {e}")
            return {"error": str(e)}

# 使用示例
if __name__ == "__main__":
    client = ChatClient()
    
    # 测试系统状态
    status = client.get_status()
    print(f"系统状态: {status}")
    
    # 测试聊天
    response = client.chat("你们公司的退货政策是什么?")
    print(f"回答: {response['answer']}")
    print(f"处理时间: {response['processing_time']}秒")
    
    # 测试上下文记忆
    response2 = client.chat("那我需要准备什么材料?")
    print(f"后续回答: {response2['answer']}")

4.2 前端界面示例

虽然API服务已经足够,但有个简单的前端界面会更方便测试和演示。这里用HTML+JavaScript实现一个极简的聊天界面:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>智能客服系统</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            min-height: 100vh;
            display: flex;
            justify-content: center;
            align-items: center;
            padding: 20px;
        }
        
        .chat-container {
            width: 100%;
            max-width: 800px;
            background: white;
            border-radius: 20px;
            box-shadow: 0 20px 60px rgba(0,0,0,0.3);
            overflow: hidden;
            display: flex;
            flex-direction: column;
            height: 80vh;
        }
        
        .chat-header {
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            padding: 20px;
            text-align: center;
        }
        
        .chat-header h1 {
            font-size: 24px;
            margin-bottom: 5px;
        }
        
        .chat-header .status {
            font-size: 14px;
            opacity: 0.9;
        }
        
        .chat-messages {
            flex: 1;
            padding: 20px;
            overflow-y: auto;
            background: #f8f9fa;
        }
        
        .message {
            margin-bottom: 15px;
            display: flex;
        }
        
        .message.user {
            justify-content: flex-end;
        }
        
        .message.bot {
            justify-content: flex-start;
        }
        
        .message-content {
            max-width: 70%;
            padding: 12px 16px;
            border-radius: 18px;
            position: relative;
        }
        
        .user .message-content {
            background: #667eea;
            color: white;
            border-bottom-right-radius: 4px;
        }
        
        .bot .message-content {
            background: white;
            color: #333;
            border: 1px solid #e0e0e0;
            border-bottom-left-radius: 4px;
        }
        
        .message-time {
            font-size: 12px;
            color: #999;
            margin-top: 4px;
            text-align: right;
        }
        
        .chat-input {
            padding: 20px;
            border-top: 1px solid #e0e0e0;
            background: white;
        }
        
        .input-group {
            display: flex;
            gap: 10px;
        }
        
        #messageInput {
            flex: 1;
            padding: 12px 16px;
            border: 2px solid #e0e0e0;
            border-radius: 24px;
            font-size: 16px;
            outline: none;
            transition: border-color 0.3s;
        }
        
        #messageInput:focus {
            border-color: #667eea;
        }
        
        #sendButton {
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            border: none;
            padding: 12px 24px;
            border-radius: 24px;
            font-size: 16px;
            cursor: pointer;
            transition: transform 0.2s;
        }
        
        #sendButton:hover {
            transform: translateY(-2px);
        }
        
        #sendButton:disabled {
            opacity: 0.5;
            cursor: not-allowed;
        }
        
        .typing-indicator {
            display: none;
            padding: 12px 16px;
            background: white;
            border-radius: 18px;
            border: 1px solid #e0e0e0;
            margin-bottom: 15px;
            width: fit-content;
        }
        
        .typing-dots {
            display: flex;
            gap: 4px;
        }
        
        .typing-dots span {
            width: 8px;
            height: 8px;
            background: #999;
            border-radius: 50%;
            animation: typing 1.4s infinite;
        }
        
        .typing-dots span:nth-child(2) {
            animation-delay: 0.2s;
        }
        
        .typing-dots span:nth-child(3) {
            animation-delay: 0.4s;
        }
        
        @keyframes typing {
            0%, 60%, 100% {
                transform: translateY(0);
            }
            30% {
                transform: translateY(-8px);
            }
        }
        
        .system-info {
            font-size: 12px;
            color: #666;
            text-align: center;
            padding: 10px;
            background: #f8f9fa;
            border-top: 1px solid #e0e0e0;
        }
    </style>
</head>
<body>
    <div class="chat-container">
        <div class="chat-header">
            <h1>🤖 智能客服助手</h1>
            <div class="status" id="status">系统已连接</div>
        </div>
        
        <div class="chat-messages" id="chatMessages">
            <div class="message bot">
                <div class="message-content">
                    您好!我是智能客服助手,有什么可以帮您的吗?
                </div>
            </div>
        </div>
        
        <div class="typing-indicator" id="typingIndicator">
            <div class="typing-dots">
                <span></span>
                <span></span>
                <span></span>
            </div>
        </div>
        
        <div class="chat-input">
            <div class="input-group">
                <input type="text" id="messageInput" placeholder="请输入您的问题..." autocomplete="off">
                <button id="sendButton" onclick="sendMessage()">发送</button>
            </div>
        </div>
        
        <div class="system-info">
            基于 LangChain + Ollama 本地大模型 | 数据完全本地处理,安全可靠
        </div>
    </div>

    <script>
        const API_BASE_URL = 'http://localhost:8000';
        let sessionId = null;
        let userId = 'user_' + Math.random().toString(36).substr(2, 9);
        
        // 检查系统状态
        async function checkStatus() {
            try {
                const response = await fetch(`${API_BASE_URL}/api/status`);
                if (response.ok) {
                    const data = await response.json();
                    document.getElementById('status').textContent = 
                        `模型: ${data.model} | 知识库: ${data.vectorstore_size}条`;
                }
            } catch (error) {
                console.error('状态检查失败:', error);
                document.getElementById('status').textContent = '系统连接异常';
            }
        }
        
        // 发送消息
        async function sendMessage() {
            const input = document.getElementById('messageInput');
            const message = input.value.trim();
            const button = document.getElementById('sendButton');
            
            if (!message) return;
            
            // 禁用输入和按钮
            input.disabled = true;
            button.disabled = true;
            
            // 添加用户消息到界面
            addMessage(message, 'user');
            input.value = '';
            
            // 显示输入中状态
            showTypingIndicator();
            
            try {
                // 发送请求到API
                const response = await fetch(`${API_BASE_URL}/api/chat`, {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                    },
                    body: JSON.stringify({
                        question: message,
                        user_id: userId,
                        session_id: sessionId
                    })
                });
                
                if (!response.ok) {
                    throw new Error(`HTTP error! status: ${response.status}`);
                }
                
                const data = await response.json();
                
                // 保存会话ID
                if (!sessionId) {
                    sessionId = data.session_id;
                }
                
                // 隐藏输入中状态
                hideTypingIndicator();
                
                // 添加机器人回复到界面
                addMessage(data.answer, 'bot');
                
                // 滚动到底部
                scrollToBottom();
                
            } catch (error) {
                console.error('发送消息失败:', error);
                hideTypingIndicator();
                addMessage('抱歉,服务暂时不可用,请稍后重试。', 'bot');
            } finally {
                // 恢复输入和按钮
                input.disabled = false;
                button.disabled = false;
                input.focus();
            }
        }
        
        // 添加消息到界面
        function addMessage(content, sender) {
            const messagesDiv = document.getElementById('chatMessages');
            const messageDiv = document.createElement('div');
            messageDiv.className = `message ${sender}`;
            
            const time = new Date().toLocaleTimeString([], { 
                hour: '2-digit', 
                minute: '2-digit' 
            });
            
            messageDiv.innerHTML = `
                <div class="message-content">
                    ${content}
                    <div class="message-time">${time}</div>
                </div>
            `;
            
            messagesDiv.appendChild(messageDiv);
            scrollToBottom();
        }
        
        // 显示输入中状态
        function showTypingIndicator() {
            document.getElementById('typingIndicator').style.display = 'block';
            scrollToBottom();
        }
        
        // 隐藏输入中状态
        function hideTypingIndicator() {
            document.getElementById('typingIndicator').style.display = 'none';
        }
        
        // 滚动到底部
        function scrollToBottom() {
            const messagesDiv = document.getElementById('chatMessages');
            messagesDiv.scrollTop = messagesDiv.scrollHeight;
        }
        
        // 回车发送消息
        document.getElementById('messageInput').addEventListener('keypress', function(e) {
            if (e.key === 'Enter') {
                sendMessage();
            }
        });
        
        // 初始化
        document.addEventListener('DOMContentLoaded', function() {
            checkStatus();
            // 每30秒检查一次状态
            setInterval(checkStatus, 30000);
        });
    </script>
</body>
</html>

这个前端界面虽然简单,但包含了核心功能:实时聊天、状态显示、响应时间展示等。你可以把它保存为index.html,用浏览器打开就能直接使用。

4.3 部署与配置管理

实际部署时,我们需要考虑更多生产环境的问题。这里提供一个Docker部署方案和配置管理。

Docker部署文件:

# Dockerfile
FROM python:3.10-slim

WORKDIR /app

# 安装系统依赖
RUN apt-get update && apt-get install -y \
    gcc \
    g++ \
    curl \
    && rm -rf /var/lib/apt/lists/*

# 安装Ollama
RUN curl -fsSL https://ollama.ai/install.sh | sh

# 复制Python依赖文件
COPY requirements.txt .

# 安装Python依赖
RUN pip install --no-cache-dir -r requirements.txt

# 复制应用代码
COPY . .

# 下载模型(可以在运行时下载,这里预下载加快启动)
RUN ollama pull llama3.1:8b

# 创建数据目录
RUN mkdir -p /app/data

# 暴露端口
EXPOSE 8000

# 启动脚本
COPY start.sh .
RUN chmod +x start.sh

CMD ["./start.sh"]

启动脚本:

#!/bin/bash
# start.sh

# 启动Ollama服务(后台运行)
ollama serve &

# 等待Ollama启动
sleep 10

# 启动FastAPI服务
uvicorn main:app \
    --host 0.0.0.0 \
    --port 8000 \
    --workers 4 \
    --log-level info

配置文件:

# config.yaml
system:
  name: "智能客服系统"
  version: "1.0.0"
  debug: false

model:
  name: "llama3.1:8b"
  temperature: 0.1
  max_tokens: 1024
  timeout: 30

vectorstore:
  type: "chroma"
  persist_directory: "./data/chroma_db"
  embedding_model: "BAAI/bge-small-zh-v1.5"
  chunk_size: 800
  chunk_overlap: 150

api:
  host: "0.0.0.0"
  port: 8000
  workers: 4
  cors_origins: ["*"]
  rate_limit: 100  # 每分钟请求数限制

memory:
  window_size: 10
  persist_file: "./data/memory.json"
  context_ttl: 86400  # 上下文保存时间(秒)

logging:
  level: "INFO"
  file: "./logs/app.log"
  max_size: 10485760  # 10MB
  backup_count: 5

monitoring:
  enabled: true
  prometheus_port: 9090
  health_check_interval: 30

配置管理类:

import yaml
import os
from typing import Any, Dict

class Config:
    def __init__(self, config_path="./config.yaml"):
        self.config_path = config_path
        self.config = self._load_config()
        
    def _load_config(self) -> Dict[str, Any]:
        """加载配置文件"""
        if not os.path.exists(self.config_path):
            # 使用默认配置
            return self._get_default_config()
        
        with open(self.config_path, 'r', encoding='utf-8') as f:
            return yaml.safe_load(f)
    
    def _get_default_config(self) -> Dict[str, Any]:
        """获取默认配置"""
        return {
            "system": {
                "name": "智能客服系统",
                "version": "1.0.0",
                "debug": False
            },
            "model": {
                "name": "llama3.1:8b",
                "temperature": 0.1,
                "max_tokens": 1024,
                "timeout": 30
            },
            "vectorstore": {
                "type": "chroma",
                "persist_directory": "./data/chroma_db",
                "embedding_model": "BAAI/bge-small-zh-v1.5",
                "chunk_size": 800,
                "chunk_overlap": 150
            },
            "api": {
                "host": "0.0.0.0",
                "port": 8000,
                "workers": 4,
                "cors_origins": ["*"],
                "rate_limit": 100
            },
            "memory": {
                "window_size": 10,
                "persist_file": "./data/memory.json",
                "context_ttl": 86400
            },
            "logging": {
                "level": "INFO",
                "file": "./logs/app.log",
                "max_size": 10485760,
                "backup_count": 5
            },
            "monitoring": {
                "enabled": True,
                "prometheus_port": 9090,
                "health_check_interval": 30
            }
        }
    
    def get(self, key: str, default: Any = None) -> Any:
        """获取配置值"""
        keys = key.split('.')
        value = self.config
        
        for k in keys:
            if isinstance(value, dict) and k in value:
                value = value[k]
            else:
                return default
        
        return value
    
    def update(self, key: str, value: Any):
        """更新配置值"""
        keys = key.split('.')
        config = self.config
        
        for k in keys[:-1]:
            if k not in config:
                config[k] = {}
            config = config[k]
        
        config[keys[-1]] = value
        
        # 保存到文件
        self._save_config()
    
    def _save_config(self):
        """保存配置到文件"""
        os.makedirs(os.path.dirname(self.config_path), exist_ok=True)
        with open(self.config_path, 'w', encoding='utf-8') as f:
            yaml.dump(self.config, f, allow_unicode=True, default_flow_style=False)
    
    def reload(self):
        """重新加载配置"""
        self.config = self._load_config()

5. 性能优化与监控

系统能跑起来只是第一步,要真正好用还需要优化和监控。这里分享几个我在实际项目中积累的经验。

5.1 性能优化策略

响应时间优化: 本地大模型最大的挑战就是响应时间。经过测试,我发现以下几个优化点效果最明显:

  1. 模型量化:使用GGUF格式的量化模型,可以在几乎不损失精度的情况下大幅减少内存占用和提升推理速度。
# 使用量化模型(以Qwen2.5为例)
ollama pull qwen2.5:7b-q4_K_M  # 4位量化,平衡精度和速度
# 或者
ollama pull llama3.1:8b-q4_K_S  # 更小的量化,速度更快
  1. 批处理请求:如果有多个相似问题,可以批量处理。
from typing import List
import asyncio

class BatchProcessor:
    def __init__(self, chatbot, batch_size=4):
        self.chatbot = chatbot
        self.batch_size = batch_size
    
    async def process_batch(self, questions: List[str]) -> List[str]:
        """批量处理问题"""
        results = []
        
        # 分批处理
        for i in range(0, len(questions), self.batch_size):
            batch = questions[i:i + self.batch_size]
            batch_results = await self._process_single_batch(batch)
            results.extend(batch_results)
            
            # 避免过热
            if i + self.batch_size < len(questions):
                await asyncio.sleep(0.1)
        
        return results
    
    async def _process_single_batch(self, questions: List[str]) -> List[str]:
        """处理单个批次"""
        # 这里可以并行处理,但要注意Ollama的并发限制
        tasks = []
        for question in questions:
            task = asyncio.create_task(
                self._ask_question(question)
            )
            tasks.append(task)
        
        return await asyncio.gather(*tasks)
    
    async def _ask_question(self, question: str) -> str:
        """异步提问"""
        # 注意:OllamaLLM可能不支持原生异步
        # 实际使用时可能需要线程池
        import threading
        result = []
        
        def sync_ask():
            response = self.chatbot.ask_with_context(
                user_id="batch_user",
                question=question
            )
            result.append(response["answer"])
        
        thread = threading.Thread(target=sync_ask)
        thread.start()
        thread.join()
        
        return result[0] if result else "处理失败"
  1. 缓存机制:常见问题可以缓存答案,避免重复计算。
import hashlib
import pickle
from datetime import datetime, timedelta

class ResponseCache:
    def __init__(self, cache_dir="./cache", ttl=3600):
        self.cache_dir = cache_dir
        self.ttl = ttl  # 缓存有效期(秒)
        os.makedirs(cache_dir, exist_ok=True)
    
    def _get_cache_key(self, question: str, context_hash: str = "") -> str:
        """生成缓存键"""
        content = question + context_hash
        return hashlib.md5(content.encode()).hexdigest()
    
    def _get_cache_path(self, key: str) -> str:
        """获取缓存文件路径"""
        return os.path.join(self.cache_dir, f"{key}.pkl")
    
    def get(self, question: str, context_hash: str = ""):
        """获取缓存响应"""
        key = self._get_cache_key(question, context_hash)
        cache_path = self._get_cache_path(key)
        
        if os.path.exists(cache_path):
            try:
                with open(cache_path, 'rb') as f:
                    data = pickle.load(f)
                
                # 检查是否过期
                if datetime.now() - data["timestamp"] < timedelta(seconds=self.ttl):
                    return data["response"]
                else:
                    # 过期删除
                    os.remove(cache_path)
            except:
                pass
        
        return None
    
    def set(self, question: str, response: dict, context_hash: str = ""):
        """设置缓存"""
        key = self._get_cache_key(question, context_hash)
        cache_path = self._get_cache_path(key)
        
        data = {
            "response": response,
            "timestamp": datetime.now(),
            "question": question,
            "context_hash": context_hash
        }
        
        try:
            with open(cache_path, 'wb') as f:
                pickle.dump(data, f)
        except:
            pass
    
    def clear_expired(self):
        """清理过期缓存"""
        now = datetime.now()
        for filename in os.listdir(self.cache_dir):
            if filename.endswith('.pkl'):
                path = os.path.join(self.cache_dir, filename)
                try:
                    with open(path, 'rb') as f:
                        data = pickle.load(f)
                    
                    if now - data["timestamp"] > timedelta(seconds=self.ttl):
                        os.remove(path)
                except:
                    os.remove(path)  # 损坏的文件也删除

内存优化: 大模型很吃内存,特别是同时服务多个用户时。我的优化策略:

  1. 动态加载模型:根据请求量动态调整模型加载。
class ModelManager:
    def __init__(self, model_names):
        self.models = {}
        self.active_model = None
        self.load_threshold = 5  # 每分钟请求数阈值
        self.request_count = 0
        self.last_reset = datetime.now()
    
    def get_model(self, request_rate):
        """根据请求率返回合适的模型"""
        # 重置计数器
        if (datetime.now() - self.last_reset).seconds >= 60:
            self.request_count = 0
            self.last_reset = datetime.now()
        
        self.request_count += 1
        
        # 根据请求率选择模型
        if request_rate < 2:  # 低负载
            model_name = "llama3.1:8b"
        elif request_rate < 10:  # 中等负载
            model_name = "qwen2.5:7b"  # 更快的模型
        else:  # 高负载
            model_name = "mistral:7b"  # 最快但能力稍弱
        
        # 加载模型(如果未加载)
        if model_name not in self.models:
            self._load_model(model_name)
        
        self.active_model = model_name
        return self.models[model_name]
    
    def _load_model(self, model_name):
        """加载模型"""
        print(f"正在加载模型: {model_name}")
        # 实际应该调用Ollama加载模型
        # 这里简化处理
        self.models[model_name] = {"name": model_name, "loaded": True}
    
    def unload_unused_models(self):
        """卸载不用的模型释放内存"""
        if len(self.models) > 1 and self.active_model:
            for name in list(self.models.keys()):
                if name != self.active_model:
                    print(f"卸载模型: {name}")
                    # 实际应该调用Ollama卸载模型
                    del self.models[name]
  1. 响应流式输出:对于长回答,可以边生成边返回。
from fastapi.responses import StreamingResponse
import json

@app.post("/api/chat/stream")
async def chat_stream(request: ChatRequest):
    """流式聊天接口"""
    async def generate():
        # 模拟流式输出
        question = request.question
        words = question.split()
        
        for i in range(len(words) + 10):  # 模拟生成过程
            if i < len(words):
                chunk = words[i] + " "
            else:
                chunk = f"[思考{i-len(words)}] "
            
            yield f"data: {json.dumps({'chunk': chunk})}\n\n"
            await asyncio.sleep(0.1)  # 模拟生成延迟
        
        yield f"data: {json.dumps({'done': True})}\n\n"
    
    return StreamingResponse(
        generate(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "Connection": "keep-alive",
            "X-Accel-Buffering": "no"  # 禁用Nginx缓冲
        }
    )

5.2 监控与日志

生产环境必须要有完善的监控。我通常从几个维度来监控系统:

性能监控:

import time
from collections import deque
from dataclasses import dataclass
from typing import Dict, List
import psutil
import GPUtil

@dataclass
class SystemMetrics:
    timestamp: float
    cpu_percent: float
    memory_percent: float
    gpu_memory_percent: float
    request_count: int
    avg_response_time: float
    error_rate: float

class Monitor:
    def __init__(self, window_size=100):
        self.metrics_history = deque(maxlen=window_size)
        self.request_times = deque(maxlen=1000)
        self.error_count = 0
        self.total_requests = 0
    
    def record_request(self, processing_time: float, success: bool = True):
        """记录请求指标"""
        self.request_times.append(processing_time)
        self.total_requests += 1
        
        if not success:
            self.error_count += 1
    
    def collect_metrics(self) -> SystemMetrics:
        """收集系统指标"""
        # CPU使用率
        cpu_percent = psutil.cpu_percent(interval=0.1)
        
        # 内存使用率
        memory = psutil.virtual_memory()
        memory_percent = memory.percent
        
        # GPU使用率(如果有)
        gpu_memory_percent = 0
        try:
            gpus = GPUtil.getGPUs()
            if gpus:
                gpu_memory_percent = gpus[0].memoryUtil * 100
        except:
            pass
        
        # 计算平均响应时间
        avg_response_time = 0
        if self.request_times:
            avg_response_time = sum(self.request_times) / len(self.request_times)
        
        # 计算错误率
        error_rate = 0
        if self.total_requests > 0:
            error_rate = self.error_count / self.total_requests * 100
        
        metrics = SystemMetrics(
            timestamp=time.time(),
            cpu_percent=cpu_percent,
            memory_percent=memory_percent,
            gpu_memory_percent=gpu_memory_percent,
            request_count=self.total_requests,
            avg_response_time=avg_response_time,
            error_rate=error_rate
        )
        
        self.metrics_history.append(metrics)
        return metrics
    
    def get_alerts(self) -> List[Dict]:
        """检查是否需要告警"""
        alerts = []
        
        if not self.metrics_history:
            return alerts
        
        latest = self.metrics_history[-1]
        
        # CPU告警
        if latest.cpu_percent > 80:
            alerts.append({
                "level": "warning",
                "metric": "cpu",
                "value": latest.cpu_percent,
                "message": f"CPU使用率过高: {latest.cpu_percent:.1f}%"
            })
        
        # 内存告警
        if latest.memory_percent > 85:
            alerts.append({
                "level": "critical",
                "metric": "memory",
                "value": latest.memory_percent,
                "message": f"内存使用率过高: {latest.memory_percent:.1f}%"
            })
        
        # 响应时间告警
        if latest.avg_response_time > 5.0:  # 超过5秒
            alerts.append({
                "level": "warning",
                "metric": "response_time",
                "value": latest.avg_response_time,
                "message": f"平均响应时间过长: {latest.avg_response_time:.2f}秒"
            })
        
        # 错误率告警
        if latest.error_rate > 5.0:  # 超过5%
            alerts.append({
                "level": "critical",
                "metric": "error_rate",
                "value": latest.error_rate,
                "message": f"错误率过高: {latest.error_rate:.1f}%"
            })
        
        return alerts
    
    def get_performance_report(self) -> Dict:
        """生成性能报告"""
        if not self.metrics_history:
            return {}
        
        metrics_list = list(self.metrics_history)
        
        return {
            "current": {
                "cpu_percent": metrics_list[-1].cpu_percent,
                "memory_percent": metrics_list[-1].memory_percent,
                "gpu_memory_percent": metrics_list[-1].gpu_memory_percent,
                "avg_response_time": metrics_list[-1].avg_response_time,
                "error_rate": metrics_list[-1].error_rate
            },
            "stats": {
                "total_requests": self.total_requests,
                "error_count": self.error_count,
                "p95_response_time": self._calculate_percentile(95),
                "p99_response_time": self._calculate_percentile(99),
                "max_response_time": max(self.request_times) if self.request_times else 0
            },
            "alerts": self.get_alerts()
        }
    
    def _calculate_percentile(self, percentile: float) -> float:
        """计算百分位数"""
        if not self.request_times:
            return 0
        
        sorted_times = sorted(self.request_times)
        index = (percentile / 100) * (len(sorted_times) - 1)
        
        if index.is_integer():
            return sorted_times[int(index)]
        else:
            lower = sorted_times[int(index)]
            upper = sorted_times[int(index) + 1]
            return lower + (upper - lower) * (index - int(index))

日志配置:

import logging
import logging.handlers
from pathlib import Path

def setup_logging(config):
    """配置日志系统"""
    log_config = config.get("logging", {})
    log_level = getattr(logging, log_config.get("level", "INFO"))
    log_file = log_config.get("file", "./logs/app.log")
    
    # 创建日志目录
    log_path = Path(log_file).parent
    log_path.mkdir(parents=True, exist_ok=True)
    
    # 配置根日志
    logger = logging.getLogger()
    logger.setLevel(log_level)
    
    # 清除现有处理器
    logger.handlers.clear()
    
    # 控制台处理器
    console_handler = logging.StreamHandler()
    console_format = logging.Formatter(
        '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
    )
    console_handler.setFormatter(console_format)
    logger.addHandler(console_handler)
    
    # 文件处理器(按大小轮转)
    file_handler = logging.handlers.RotatingFileHandler(
        filename=log_file,
        maxBytes=log_config.get("max_size", 10485760),  # 10MB
        backupCount=log_config.get("backup_count", 5),
        encoding='utf-8'
    )
    file_format = logging.Formatter(
        '%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s'
    )
    file_handler.setFormatter(file_format)
    logger.addHandler(file_handler)
    
    # 特定模块的日志级别
    logging.getLogger("uvicorn").setLevel(logging.WARNING)
    logging.getLogger("httpx").setLevel(logging.WARNING)
    
    return logger

# 使用示例
logger = setup_logging(config)

# 记录不同级别的日志
logger.debug("调试信息")
logger.info("系统启动完成")
logger.warning("内存使用率超过80%")
logger.error("数据库连接失败")

5.3 测试与验证

系统上线前一定要充分测试。我通常从几个维度进行测试:

功能测试:

import unittest
from unittest.mock import Mock, patch
import sys
import os

sys.path.append(os.path.dirname(os

更多推荐