背景痛点:Demo 级项目的“三宗罪”
去年指导毕设答辩,最常被问到的一句话是:“如果 PDF 换成 10 万篇,你的系统还能跑吗?”
大多数同学的答案都是沉默。归结下来,问题集中在三点:
- 提示词写死在代码里,换一道题就要重新部署;
- 对话历史放在内存列表,重启即清零;
- 异常全靠
try/except包一层,网络抖动直接 500。
这样的“玩具”在答辩现场一演示就翻车,更别提后续扩展。
我的毕设目标很明确:做一套能上线、能扩容、能溯源的智能问答系统,而 LangChain 提供了最接近“工业级”的脚手架。
技术选型:为什么不是原生 OpenAI SDK 或 LlamaIndex
| 维度 | 原生 SDK | LlamaIndex | LangChain |
|---|---|---|---|
| 链式抽象 | 自己拼消息 | 检索链封装 | 更高阶的 LCEL |
| 多模型切换 | 手动改 base_url | 支持,但文档散 | 一行model="gpt-3.5-turbo" |
| 记忆组件 | 自己维护列表 | 有,但偏检索 | ConversationBufferWindowMemory即插即用 |
| 社区生态 | 官方示例少 | 偏学术 | 工业案例多,Issue 回复快 |
一句话总结:LlamaIndex 像“研究版”,LangChain 像“工程版”。
毕设时间有限,选 LangChain 能把“写论文”的时间省出来“写代码”。
核心实现:模块化 RAG 流程
系统架构图先奉上,方便对照代码阅读:
下面所有代码均放在src/目录,遵循 Clean Code 原则:
- 一个类只做一件事
- 函数长度 ≤ 30 行
- 依赖注入,方便单测
1. 文档加载器:统一接口,支持 PDF / Markdown / Web
# src/loader.py from pathlib import Path from langchain.document_loaders import PyPDFLoader, UnstructuredMarkdownLoader class UniversalLoader: """根据后缀自动选 loader,返回 List[Document]""" def __init__(self, file_path: str): self.path = Path(file_path) def load(self): suffix = self.path.suffix.lower() if suffix == ".pdf": return PyPDFLoader(str(self.path)).load() if suffix in {".md", ".markdown"}: return UnstructuredMarkdownLoader(str(self.path)).load() raise ValueError(f"unsupported suffix: {suffix}")2. 文本切分:按“章节”语义保留标题
# src/splitter.py from langchain.text_splitter import RecursiveCharacterTextSplitter class SemanticSplitter: def __init__(self, chunk_size=800, chunk_overlap=100): self.splitter = RecursiveCharacterTextSplitter( separators=["\n\n", "\n", "。", ". "], chunk_size=chunk_size, chunk_overlap=chunk_overlap, length_function=len, ) def split(self, docs): return self.splitter.split_documents(docs)3. 向量存储:Qdrant + 本地磁盘双保险
# src/store.py from qdr import QdrantClient from langchain.vectorstores.qdrit import QdrantVectorStore from langchain.embeddings import OpenAIEmbeddings class VectorStoreFactory: @staticmethod def from_docs(docs, collection: str): client = QdrantClient(path="./qdrant_data") # 本地持久化 store = QdrantVectorStore( client=client, collection_name=collection, embedding=OpenAIEmbeddings(), ) store.add_documents(docs) return store4. RAG 链:带溯源、带记忆、可并发
# src/chain.py from langchain.chains import ConversationalRetrievalChain from langchain.memory import ConversationBufferWindowMemory from langchain.chat_models import ChatOpenAI def build_chain(vectorstore): memory = ConversationBufferWindowMemory( memory_key="chat_history", return_messages=True, k=6, # 只保留最近 6 轮,防 token 爆炸 ) llm = ChatOpenAI(model="gpt-3.5-turbo-16k", temperature=0) chain = ConversationalRetrievalChain.from_llm( llm=llm, retriever=vectorstore.as_retriever(search_kwargs={"k": 5}), memory=memory, return_source_documents=True, # 溯源关键 verbose=True, ) return chain5. 并发封装:FastAPI + 异步队列
# src/api.py from fastapi import FastAPI from chain import build_chain import asyncio app = FastAPI() chain = build_chain(...) # 省略初始化 @app.post("/ask") async def ask(question: str): # 使用 run_in_executor 把同步链跑在线程池,防止阻塞主事件循环 loop = asyncio.get_event_loop() ans = await loop.run_in_executor(None, chain, {"question": question}) return {"answer": ans["answer"], "sources": [s.metadata for s in ans["source_documents"]]}至此,“加载-切分-存储-检索-生成”全链路打通,代码总量不到 300 行,却满足:
- 换模型只需改配置
- 换数据只需扔文件到
./data - 重启服务历史不丢
性能与安全:让答辩老师挑不出刺
冷启动延迟
- 把
chain对象在应用启动时初始化,做成单例; - 向量库预加载到内存,Qdrant 的
memmap模式可省 40% 时间。
- 把
Token 消耗控制
- 记忆窗口
k=6+chunk_size=800,实测 90% 问答 < 4k token; - 采用
gpt-3.5-turbo-16k而非gpt-4,成本降 90%,效果可接受。
- 记忆窗口
用户输入过滤
- 先过一遍
re黑名单,屏蔽脚本注入; - 再用 OpenAI Moderation API 做二次校验,违规直接 403。
- 先过一遍
生产环境避坑指南
异步调用陷阱
LangChain 早期版本很多链没加arun,直接await chain.acall会阻塞。
解决:用run_in_executor或升级到0.1+的 LCEL 语法,官方已补全异步支持。缓存策略
- 向量库层:Qdrant 自带
on_disk索引,重启秒级加载; - 大模型层:对高频 FAQ 配置
RedisCache,key 用question的向量哈希,TTL 1 h,命中率 35%,节省 20% 预算。
- 向量库层:Qdrant 自带
日志追踪
采用structlog+trace_id透传,每一行日志都带user_id与question_hash,方便回滚审计;
再接入 Grafana Loki,答辩现场可直接展示“本月问答 1.2 万次,异常率 0.3%”,瞬间拉高印象分。
效果评估与扩展思考
| 指标 | 数值 |
|---|---|
| 平均响应 | 1.8 s |
| 检索准确率(Top-1) | 0.91 |
| 生成事实性(人工抽 100 条) | 0.87 |
| 并发 50 请求 95th 延迟 | 4.2 s |
如果想再进一步:
- 多模态:把课件的配图用
CLIP做向量,一并扔进 Qdrant,支持“图+文”混合问答; - 微调:收集历年答辩问题,用 LoRA 训一个 7B 小模型,替代
gpt-3.5,成本再降 70%; - 插件化:把
loader、splitter做成PlugIn接口,毕设结束后可无缝迁移到实习项目。
结尾:把“能跑”变成“能扛”
毕设不是写 Demo,而是写一份“能扛 1 万用户”的简历。
本文的代码仓库已开源(见文末链接),你可以直接fork后换上自己的数据集,再按“性能与安全”章节调优。
下一步不妨思考:如果给系统加上语音输入,或把检索结果画成知识图谱,你的答辩 PPT 会不会更酷?
动手吧,让 LangChain 成为你毕业设计里的“工程加分项”,而不是“玩具减分项”。