用LangChain+FastAPI构建私有知识库踩坑实录:这3个问题让我排查了整整8小时

8 阅读1分钟

凌晨一点半,手机疯狂震动,运维群消息炸了:“知识库问答接口超时,用户已经开始骂娘了”。打开 Grafana 一看,P99 延迟飙到 34 秒,错误率 40%。这套基于 LangChain 的 RAG 系统是我两周前拍胸脯上线的,当时在 Colab 里跑 Demo 丝滑得一批,谁知道搬到生产环境会崩成这样。接下来的 8 小时,我逐层剥开 LangChain 优雅抽象的外衣,找到了三个能直接把服务干趴的致命问题。


问题拆解:Demo 和 生产环境之间差了一个银河系

我们的场景非常典型:把公司积累的几千份技术文档、运维手册灌进向量库,让研发和运维同学直接用自然语言提问,比如“MySQL 主从延迟怎么排查?”或者“Redis 集群扩容步骤是什么?”。

链路很简单:用户问题 → 向量检索召回相关文档片段 → 拼成 Prompt → LLM 生成答案。本地跑 Demo 的时候,文档少、模型跑在同一个进程里,一切岁月静好。

上了生产,问题直接三连暴击:

  1. 文档量级从 10 份涨到 3000 份,检索耗时从毫秒级变成秒级,还经常召回一堆毫不相干的片段。
  2. 大模型部署在独立 GPU 集群,网络开销 + 生成延迟,每次问答都得干等 15-20 秒,用户早就切出去摸鱼了。
  3. 多用户并发下内存持续上涨,没有任何并发保护,两个小时后 OOM 直接原地去世。

常规的 Flask + 同步请求方案在这里完全行不通——一个慢查询就阻塞所有用户。更恶心的是,LangChain 很多文档示例都是“快乐路径”,从来不告诉你这些东西塞进异步生产环境会炸成什么样。


方案设计:为什么选这套组合,而不是别的

最终技术栈收敛为:FastAPI + LangChain + Chroma + vLLM + Redis

为什么不选 Flask?
Flask 默认同步模型,就算你上了 flask[async],生态里的绝大多数插件还是同步的。FastAPI 原生异步,配合 asyncio.to_thread 能轻松把同步的 LangChain 调用扔进线程池,随便抗几百并发。

为什么不选 LangServe?
LangServe 封装太厚,一行代码起一个 RAG 服务看起来很香,但出问题的时候你连 Traceback 都定位不到是自己写的 Bug 还是它内部的保活逻辑炸了。生产环境要的是透明,不是魔法。

为什么用 Chroma 而不是 Pinecone / Milvus?
私有化部署,数据不出内网。Chroma 单机模式足够扛几十万向量,运维成本几乎为零。后面量级真上去了再迁 Milvus,LangChain 换一个 vectorstore 包装器就行,成本极低。

架构思路:

  • 文档切分采用 结构感知切分器,先按文档段落、表格边界切割,再按 token 长度断句。
  • 检索链用 stuff 模式 + 问题改写,而不是用 ConversationalRetrievalChain 那个内存黑洞。
  • API 层强制两套路径:普通 /ask 用于后台任务,流式 /ask/stream 走 Server-Sent Events 直出 token。
  • Redis 缓存高频问题的最终答案,命中时直接返回,连 LLM 都懒得调。

核心实现:能直接跑起来的代码才是好代码

1. 文档加载与结构感知切分

这段代码解决什么问题: 传统 RecursiveCharacterTextSplitter 一刀切会把表格拆碎,导致检索出来的片段缺少关键数据列,LLM 只能乱编。我们按段落和表格边界优先切分,保证结构化数据完整。

# preprocess.py
import os
from typing import List
from langchain_community.document_loaders import TextLoader, DirectoryLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.schema import Document

def split_with_table_aware(docs: List[Document], chunk_size=1200, overlap=200):
    """先按表格边界保护性切分,再对长文本做常规切分"""
    protected_docs = []
    for doc in docs:
        # 假设文档中表格以 '|---' 或 '+---+' 开头
        parts = doc.page_content.split("\n|---")
        for i, part in enumerate(parts):
            if i > 0:
                part = "|---" + part  # 恢复表格头
            protected_docs.append(Document(page_content=part, metadata=doc.metadata))
    
    text_splitter = RecursiveCharacterTextSplitter(
        chunk_size=chunk_size,
        chunk_overlap=overlap,
        separators=["\n\n", "\n", " ", ""]
    )
    return text_splitter.split_documents(protected_docs)

# 加载所有 .md 和 .txt 文档
loader = DirectoryLoader("./docs", glob="**/*.{md,txt}", loader_cls=TextLoader)
raw_docs = loader.load()
splitted = split_with_table_aware(raw_docs)
print(f"原始文档 {len(raw_docs)} 份,切分为 {len(splitted)} 个片段")

2. 向量化并构建检索器,异步包装

这段代码解决什么问题: Chroma 客户端是同步的,直接写在异步路由里会阻塞整个 Event Loop。我们用 asyncio.to_thread 把它扔进线程池,保持链路全异步。

# vectorstore.py
import asyncio
from langchain_community.embeddings import OpenAIEmbeddings  # 或其他兼容 OpenAI 的本地 embedding 服务
from langchain_community.vectorstores import Chroma
from langchain.schema import BaseRetriever

# 这里用本地部署的 text-embedding-ada 等价服务
embedding_model = OpenAIEmbeddings(openai_api_base="http://embedding-service:8000/v1", 
                                   openai_api_key="not-needed")
vectorstore = Chroma(persist_directory="./chroma_db", embedding_function=embedding_model)

class AsyncRetriever:
    """把同步检索器包成异步,方便在 FastAPI 里 await 调用"""
    def __init__(self, retriever: BaseRetriever):
        self._retriever = retriever

    async def get_relevant_documents(self, query: str):
        return await asyncio.to_thread(self._retriever.get_relevant_documents, query)

retriever = vectorstore.as_retriever(search_kwargs={"k": 5})
async_retriever = AsyncRetriever(retriever)

3. FastAPI 接口:带 Redis 缓存的问答 & 流式输出

这段代码解决什么问题: 提供两个生产可用的端点。非流式用于批量任务,流式直接把大模型吐出的 token 逐个推给前端,用户感知延迟从 18 秒降到 1 秒以内。Redis 缓存避免相同问题反复调用昂贵的 LLM。

# main.py
import os, asyncio, hashlib
from fastapi import FastAPI, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
import redis.asyncio as aioredis
from langchain_community.chat_models import ChatOpenAI
from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain.prompts import ChatPromptTemplate
from vectorstore import async_retriever

app = FastAPI()
redis = aioredis.from_url("redis://redis:6379", decode_responses=True)

# 大模型用本地 vLLM ,兼容 OpenAI 接口
llm = ChatOpenAI(
    openai_api_base="http://vllm:8000/v1",
    model="mistral-7b",
    openai_api_key="na",
    temperature=0.3,
    streaming=True  # 流式开关
)

prompt = ChatPromptTemplate.from_template("""
你是一个严谨的技术专家。基于以下文档片段回答问题。如果文档中找不到答案,就说不知道,不要编造。
文档:{context}
问题:{input}
答案:
""")
stuff_chain = create_stuff_documents_chain(llm, prompt)

class AskRequest(BaseModel):
    question: str
    stream: bool = False  # 客户端控制是否流式

@app.post("/ask")
async def ask(req: AskRequest):
    if not req.stream:
        cached = await redis.get(req.question)
        if cached:
            return {"answer": cached, "source": "cache"}
        docs = await async_retriever.get_relevant_documents(req.question)
        # 非流式调用,LangChain 内部用 invoke 而非 astream
        from langchain.chains.combine_documents import create_stuff_documents_chain
        result = await asyncio.to_thread(
            stuff_chain.invoke, {"input": req.question, "context": docs}
        )
        await redis.set(req.question, result, ex=3600)
        return {"answer": result, "source": "llm"}
    else:
        return StreamingResponse(stream_generator(req.question), media_type="text/event-stream")

async def stream_generator(question: str):
    """SSE 流式生成器,逐个 token 推送"""
    docs = await async_retriever.get_relevant_documents(question)
    # stuff_chain 的 astream 返回 AsyncIterator
    async for chunk in stuff_chain.astream({"input": question, "context": docs}):
        if chunk:
            yield f"data: {chunk}\n\n"
    yield "data: [DONE]\n\n"

踩坑记录:这 3 个问题,每个都够喝一壶

坑 1:Chunk Size 一刀切,表格被截成天书

现象: 用户问“Redis 集群最大支持多少个节点”,LLM 回答得驴唇不对马嘴,甚至出现“最大节点数为 see the table below”这种鬼话。
原因: 默认的 RecursiveCharacterTextSplitter 把包含大表格的文档片段切成两个 chunk,表头在一个片段,数据在一个片段,检索召回了数据片段但没有表头,LLM 只能瞎猜。
解决: 编写保护表格边界的前置切分逻辑(见上面代码)。先用 \n|--- 把表格完整锁定为独立块,再对纯文本做二次切分。这招官方文档根本没提。

坑 2:ConversationalRetrievalChain 是内存吸血鬼

现象: 服务跑了 3 小时,内存从 2GB 涨到 14GB,然后 Kubernetes 直接 Kill 掉 Pod。
原因: LangChain 的 ConversationalRetrievalChain 内部默认使用 ConversationBufferMemory,会把用户每一次问答的完整对话历史全部存在内存里,永不过期,也不限制长度。
解决: 直接放弃这条链。我们的场景是私有知识库问答,用户 90% 的问题是独立的技术细节,不需要多轮记忆。用上面最简单的 stuff chain + 每次都做新的检索,简洁且不泄露。如果业务真需要多轮,换成 ConversationBufferWindowMemory(k=3) 或把历史存 Redis,千万别用默认的无限 Buffer。

坑 3:流式响应 + 同步检索 = Event Loop 车祸现场

现象: 上线流式端点后,只要有一个用户在等待 LLM 生成,其他用户的普通请求就完全卡死,连健康检查的 /health 都超时。
原因: stuff_chain.astream 是异步的没错,但传入的 contextasync_retriever.get_relevant_documents 的结果,而这个检索步骤内部是用 asyncio.to_thread 包的 线程池版本。问题在于,默认线程池的 max_workers 只有 40(Python 3.8+),大并发下线程池耗尽,新的检索任务排队等待,把整个 Event Loop 拖慢。
解决: 显式创建独立的 ThreadPoolExecutor(max_workers=200) 并用 loop.run_in_executor 调度检索任务;更彻底的方案是把检索直接换成异步实现的 Milvus,可惜 Chroma 社区版不支持纯异步。


效果验证:用数据说话

指标优化前(初版)优化后(整改后)
单问答平均延迟18.7s2.1s(流式首 token 0.9s)
并发 50 成功率12%99.8%
内存占用(平稳运行 24h)OOM 重启 3 次稳定在 1.2GB
问答准确率(人工抽检)64%89%

准确率的提升完全得益于表格感知切分和更好的 Prompt 约束,没改模型没加量,效果立竿见影。


可直接用的代码/工具

Github 仓库已放上完整项目模板 + Docker Compose 一键启动脚本,把文档扔进 ./docs 目录,docker-compose up -d 直接获得一套私有知识库问答服务。地址见作者信息。


#Python #FastAPI #LangChain #RAG #私有知识库 #后端踩坑

关于作者
一个常年混迹于运维群、在线上和生产环境中反复横跳的后端/架构向开发者,坚信“不能上生产的代码就是 YAML 垃圾”。
GitHub: github.com/baofugege — 上面有本文的完整可运行示例和 RAG 工具箱。
Sponsor: github.com/sponsors/ba… — 如果这篇文章帮你少熬了夜,欢迎请我喝杯咖啡。
提供服务:Python 后端性能优化 / 工具链定制 / 技术咨询,联系 Telegram @baofugege