简介:面向希望快速入门LangChain的Python开发者,这份项目代码包提供了一套可运行的入门实践示例。通过模块化组合LLM接口、提示词模板、链式操作、记忆功能与工具调用,资源直观演示了如何将外部数据与语言模型连接,以应对信息过时、无法执行外部操作、记忆有限等典型痛点。压缩包共3个文件,其中inscode文件承载主要示例代码,html说明文档梳理了框架核心概念与运行逻辑,gitignore用于工程配置,整体仅5KB,轻量且便于下载后直接查阅。目前已有107人学习,适合需要在智能运维、问答系统或对话机器人等场景中快速验证LangChain能力的开发者。掌握这些基础模块后,可结合官方学习资源与面试题库进一步搭建更完整的LLM应用,具有清晰的进阶参考价值。
1. LangChain不是“会调API就够了”:它到底帮你省了什么
一个很常见的入门场景:你已经能用一段几十行的代码把大模型API调通,生成一段文案,甚至做了个能跑的小工具。可一旦要做正经项目,比如让模型基于你手头的一堆文档回答问题,还要把回答链路接进已有系统,事情立刻就变了:文档怎么切分、历史对话怎么塞进上下文、模型偶尔返回JSON格式乱了怎么办、换一个模型供应商要不要重写业务代码?这些问题每一个单拎出来都能解决,但它们凑在一起,你会在胶水代码里消耗掉大量时间,而LangChain就是从这个痛点切入的:它把“调大模型”这件事从单次请求升级成了一条可编排、可替换、可测试的流水线。
对刚接触LangChain的人来说,最容易踩的认知坑是把它当成一个新框架重新学一遍。实际上你只需要把它理解为一套围绕大模型应用的标准零件:模型封装(Model)、提示词模板(Prompt)、记忆(Memory)、检索(Retriever)、链与Agent(Chain / Agent),外加一套把这些零件拼起来的组装语法。你原有的大模型API经验不会作废,反而能让你更快分辨出哪些是LangChain的便利设施,哪些是它自己的设计逻辑。
这篇文章不做泛泛的框架介绍,我直接顺着一条可运行的主线带你走一遍:先搭好最小环境、跑通第一个调用,再拆开核心抽象,然后动手写一个完整的“本地文档问答”项目代码——这是LangChain最主流、也是搜索最多的一类落地场景。每一步都有能直接抄的代码、参数说明和失败时该看哪里。如果你是希望用LangChain做点真实事情的开发者,而不是只看概念图的旁观者,这篇就是按你的需求写的。
2. 在本地跑通第一个LangChain程序:环境搭建与最小链路
先把一个最让人困惑的问题说透:langchain、langchain-openai、langchain-community、langchain-core这些包到底有什么区别,为什么要拆成这么多?我的理解是,LangChain团队为了应对不同模型供应商和社区贡献者提交的代码,把核心抽象、各家模型适配、社区组件拆成了独立包,避免每次升级都拖着一大堆依赖。作为入门者,你不用全装,只装三个就够起步。
2.1 先明确版本策略:为什么我建议你锁定主版本
LangChain的API迭代比大多数开源库都快,0.1到0.2是一次较大的接口迁移,0.3又在可观测性和部分回调上做了调整。如果你直接pip install langchain拉最新版,打开教程时很可能因为API名称对不上而寸步难行。我一般会指定一个大版本区间来安装,保证入口一致。
# 创建虚拟环境,避免污染系统Python python -m venv .langchain-demo source .langchain-demo/bin/activate # 安装核心包,注意指定主版本,教程以0.2.x的API为准 pip install "langchain>=0.2,<0.3" pip install "langchain-openai>=0.1,<0.2" pip install "langchain-community>=0.2,<0.3" pip install python-dotenv逻辑说明:第一行创建隔离环境,是所有入门项目里最容易跳过的步骤,但LangChain依赖较多,直接装进系统环境后续很容易和机器学习库产生冲突。source命令激活环境,之后的pip都装在这个环境里。版本限制使用的是>=与<的组合,意思是安装0.2.x最新版但不会自动跳到0.3。
参数说明:python-dotenv是用来读取.env文件的工具,很多初学者把API Key直接写死在代码里,提交代码时一不留神就泄露了。用环境变量管理密钥是业界默认习惯,后面会看到具体用法。
2.2 最小链路:模型、提示词、输出解析器三者串起来
安装完成后,用最经典的“模型调用 + 模板 + 解析输出”来完成一次有效对话。这里不使用传统的LLMChain写法,因为LangChain在0.2里已经明确推荐LCEL(LangChain Expression Language,链式表达语言)作为首选组装方式。
import os from dotenv import load_dotenv from langchain_core.prompts import ChatPromptTemplate from langchain_openai import ChatOpenAI from langchain_core.output_parsers import StrOutputParser load_dotenv() # 初始化模型,读取环境变量中的API Key model = ChatOpenAI( model="gpt-4o-mini", temperature=0.3, api_key=os.getenv("OPENAI_API_KEY"), ) # 声明提示词模板:{topic} 是待填充的占位符 prompt = ChatPromptTemplate.from_template( "用三句话向产品经理介绍{topic}的核心价值,避免专业术语。" ) # 输出解析器:把模型返回的完整消息对象转成纯字符串 parser = StrOutputParser() # 使用LCEL用管道符组装链路 chain = prompt | model | parser # 调用链路传入参数 result = chain.invoke({"topic": "检索增强生成"}) print(result)逻辑说明:prompt | model | parser是LCEL的核心表达方式,在这里就是数据依次流经模板渲染、模型调用、输出解析三个环节。invoke负责真正执行,参数格式是字典,键名必须与模板中的占位符对应。字符串输出听起来简单,但它保证了你拿到的是一个str,而不是带着各种元数据的AIMessage对象,这对接下来的Web接口封装非常重要。
参数说明:temperature=0.3是生成随机性的控制参数,0.3偏保守,适合解释类任务。如果做创意文案可以调到0.8以上。模型名选择gpt-4o-mini是性价比考量,入门阶段没必要用更贵的模型。如果你的环境里配置的是其他兼容接口(比如通过代理网关访问开源模型),只需要替换为对应的模型类,链路写法完全不变,这正是LangChain封装第一层价值。
2.3 跑不起来时先查这四处
新环境第一次跑,最常见的问题不是代码逻辑,而是环境配置。如果上面的脚本报错,按顺序排查:第一,.env文件是否在项目根目录且键名与代码中读取的名字一致;第二,API Key是否有效,很多报错信息写着连接超时,实际是Key过期;第三,是否在虚拟环境内执行,激活环境后which python应该指向项目的.langchain-demo/bin/python;最后,检查模型名是否在当前网关下可用,有些兼容服务并不支持全部模型名。
解决了上面这四步,你的LangChain第一个程序就算正式跑通了。这时候你已经有了一条最小可用的“提示词 → 模型 → 输出”组装链,但这还不够,因为真实项目的难点从来不在调用模型,而在如何组织长流程。下一章我们把这几个核心零件的边界拆开,分别是模型如何选、提示词模板和普通字符串到底差在哪里、链在什么时候必须引入。
3. 理解LangChain三大核心抽象:模型封装、提示词模板与链
很多入门教程把这几个概念描述得很玄,其实它们完全可以用工程视角理解。你早就会写requests调模型接口,也早就会在代码里拼字符串提示词,那为什么还需要LangChain的抽象层?理由只有一个:让业务代码与模型供应商解耦、让流程可以被组合与复用。这一章把三个抽象逐一拆开,重点说明它们各自解决什么问题、在什么场景下可以不用。
3.1 模型封装:为什么不是“封装了个寂寞”
一句话解释模型封装:它把不同供应商的“对话消息格式”统一成一套Python调用入口。OpenAI接口的messages格式、Anthropic的system/user/assistant结构、国内一些兼容OpenAI协议的网关,细节各不相同。如果你直接在自己代码里逐条构造这些消息体,换供应商等于重写整个请求逻辑。但通过ChatOpenAI这样的封装类,你只需要替换类名和模型名,其余代码全部保留。
from langchain_openai import ChatOpenAI # 兼容OpenAI协议的服务也可以用同样的类,改base_url即可 model = ChatOpenAI( base_url="http://你的网关地址/v1", api_key="your-key", model="your-model-name", temperature=0.7, max_tokens=2048, timeout=60, )参数说明:base_url是一个经常被忽略但实用的参数。很多团队不是直连官方API,而是通过内部网关做审计和转发,这时你不需要另一个SDK,只改这个字段就行。timeout=60用来处理长文本生成任务,默认值在一些网络环境下偏短,容易误报超时。
需要提醒的是,封装并不意味着“永远不用看文档”。不同模型在Tools/Function Call上的返回格式差异较大,这类高级功能LangChain的抽象还没有完全抹平差异。所以入门阶段,建议把模型封装当作“统一的调用入口”,而不是“万能转换器”。
3.2 提示词模板:从f-string到PromptTemplate的边界在哪
很多初学者会问:我直接用f-string拼接提示词,为什么还要ChatPromptTemplate?答案在于消息结构。你平时用f-string拼接的是单个字符串,但对话模型接收的是“系统消息 + 多轮历史 + 当前提问”的消息列表。ChatPromptTemplate的优势在于,它可以把这几类消息组合起来,并统一做变量校验。
from langchain_core.prompts import ChatPromptTemplate prompt = ChatPromptTemplate.from_messages([ ("system", "你是一个严谨的技术文档助手,回答必须基于给定的资料,不得编造。"), ("human", "文档内容如下:{context}"), ("human", "请基于以上内容回答:{question}"), ]) # 传入参数时,缺失键会直接报错,而不是等到调用模型时才暴露 formatted = prompt.invoke({ "context": "LangChain是一个用于构建大模型应用的开发框架。", "question": "LangChain主要解决什么问题?" })逻辑说明:from_messages接收的是元组列表,每个元组第一位是消息角色,第二位是内容模板。invoke方法会把模板渲染成ChatPromptValue对象,内部已经包含了一条系统消息和两条用户消息。这样做的好处有两点:一是模板本身是声明式的,方便统一管理;二是运行时如果有占位符没有传参,会立刻抛出KeyError,避免把未渲染的{变量}直接发给模型。
边界在哪里?如果你的提示词只是在一个函数内部拼一次,且不涉及多角色消息,用f-string完全没问题,不要为了“用框架”而用框架。一旦你要维护多套场景提示词、需要做变量校验或版本管理,PromptTemplate的价值才开始体现。
3.3 链(Chain):从“调用模型”到“编排流程”的关键一步
链解决的问题是:一个真实功能往往不是一次模型调用就能完成的。比如你做文档问答,需要先根据问题召回相关片段,再把片段拼进提示词,最后让模型作答。这个流程如果全部写在业务函数里,代码会充满临时变量和if-else。LCEL用管道符把这些步骤连成数据流,每一步的输入输出类型是已知的,调试时能清晰地看到断点在哪一段。
from langchain_core.runnables import RunnablePassthrough def format_docs(docs): return "\n\n".join(doc.page_content for doc in docs) chain = ( {"context": retriever | format_docs, "question": RunnablePassthrough()} | prompt | model | parser )逻辑说明:这里展示的是RAG链的骨架,retriever先根据问题检索文档,format_docs把检索结果拼成纯文本,RunnablePassthrough()把用户的原始提问透传下去,最终形成context和question两个变量填充进提示词模板。LCEL的管道在这里表现出了很强的表达力:每一步的输入都是上一步的输出,你可以随时在任意两段之间插入日志或缓存节点,而不需要改动其他代码。
需要澄清的是:并不是所有项目都必须用链。单个模型调用、业务逻辑本身已经很简单的工具,直接用模型类就行。链是给“多步骤、涉及其它组件如检索器、需要复用和测试”的流程准备的。这个判断标准,比“因为大家都在用所以要学”要实用得多。
4. 项目代码实战:用LCEL实现一个本地RAG问答Demo
前面两章解决了“零件怎么用”的问题,这一章把这些零件组装成一个完整项目:一个可以对本地文档提问的RAG问答Demo。我做这类项目时习惯遵循五条主线:文档加载、文本切分、向量化、检索、生成。每一步都不复杂,但连起来很容易在衔接处出问题。我会把这个项目命名为“某本地知识库问答Demo”,然后逐步实现,全程使用LCEL写法。
4.1 文档加载:PDF、Markdown、网页三种来源怎么统一到一个接口
RAG流程的第一步是把原始文档转换成LangChain的统一文档对象:一个带page_content和metadata的Document对象。不同格式的加载器类名不同,但返回的对象类型一致,所以业务代码可以完全屏蔽来源差异。
from langchain_community.document_loaders import PyPDFLoader, TextLoader from langchain_community.document_loaders import WebBaseLoader # 加载PDF:路径指向一个本地文件 pdf_loader = PyPDFLoader("./data/manual.pdf") pdf_docs = pdf_loader.load() # 加载Markdown/纯文本:需要注意编码问题,显式指定utf-8 text_loader = TextLoader("./data/notes.md", encoding="utf-8") text_docs = text_loader.load() # 加载网页:自动抓取页面正文 web_loader = WebBaseLoader("https://example.com/docs/start") web_docs = web_loader.load() # 合并所有文档,后续切分和向量化只需要处理这一个list all_docs = pdf_docs + text_docs + web_docs print(f"加载完成,共 {len(all_docs)} 个文档片段")逻辑说明:load()返回的是List[Document],PDF按页拆、网页按结构拆,Markdown则整个文件作为一个文档对象。合并列表这一步是把异构数据源变成同构输入的关键,后续所有组件不管来源,只认这一种对象。
参数说明:WebBaseLoader对目标网站有隐性要求,有些站点开启了反爬,有的页面是JavaScript动态渲染,抓下来的内容会是空的。如果你的文档主要在本地,网页加载器可以先不引入,等以后确实需要再叠加,避免一上来就踩网络抓取的坑。
4.2 文本切分:为什么“直接按字数切”会让回答质量大打折扣
切分是RAG里最容易糊弄、但最影响效果的环节。如果切得太大,超过模型的上下文窗口,检索精度也下降;如果切得太小,一句话的语义被拦腰截断,检索时召回的内容不完整。LangChain提供了多种切分器,最常见的是递归字符切分器,它按段落、句子、词组的优先级逐级切分,尽量保持语义完整。
from langchain.text_splitter import RecursiveCharacterTextSplitter text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=50, separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""], length_function=len, ) split_docs = text_splitter.split_documents(all_docs) print(f"切分完成,共 {len(split_docs)} 个文本块")逻辑说明:chunk_size=500表示每个文本块的目标长度,这里用len函数计算的是字符串长度。chunk_overlap=50让相邻两个块之间重叠50个字符,目的是避免一个完整意思恰好落在两个块的边界上而丢失上下文。separators列表是切分优先级顺序,先尝试按双换行切,再按单换行,最后按标点切,都不满足时才硬切。
参数说明:chunk_size的选择需要结合你的模型上下文窗口和Embedding模型的能力来定。常见做法是先设500到800之间进行测试,如果回答总说“资料不足”,可能是因为切得太碎导致上下文不全;如果回答偏离主题,可能是因为单块过长噪声太多。可以明确地说,这两个参数在项目初期值得花半天时间调,性价比远高于换模型。
4.3 向量化与检索:Embedding模型和向量库选型的一次性判断
文本块切好后,要转成向量才能支持相似度检索。这一步有两个选型决策:Embedding模型决定文本语义表达的粒度,向量库决定检索性能和存储方式。对入门Demo来说,我最常用的组合是text-embedding-3-small配合本地内存向量库InMemoryVectorStore。简单、零部署、数据量不大时性能充足。
from langchain_openai import OpenAIEmbeddings from langchain_community.vectorstores import InMemoryVectorStore embeddings = OpenAIEmbeddings( model="text-embedding-3-small", dimensions=1536, ) vector_store = InMemoryVectorStore.from_documents( documents=split_docs, embedding=embeddings, ) retriever = vector_store.as_retriever( search_type="similarity", search_kwargs={"k": 4} )逻辑说明:from_documents内部会遍历所有切分后的文本块,逐个调用Embedding接口生成向量,然后存入内存向量库。as_retriever把向量库包装成LangChain的检索器接口,search_kwargs里的k=4表示每次检索返回最相似的4个文本块,这个数量决定后续提示词里能塞多少参考内容。
参数说明:dimensions这个参数要特别留意。OpenAI的嵌入模型支持降维输出,如果你在创建向量库时用了1536维,后续检索时用了不同维度,相似度计算会直接报错。更隐蔽的是,如果将来换用了其他Embedding模型,比如开源的bge-m3输出维度是1024,旧的向量库必须重建,这个问题在第五章会专门展开。
4.4 组装RAG链:用LCEL把检索和生成串成一条线
前面几步产出都是静态数据,现在把它们变成动态链路:用户提出问题 → 检索器召回文档片段 → 拼接上下文 → 模型生成答案。这一章第一节的RunnablePassthrough在这里派上用场。
from langchain_core.runnables import RunnablePassthrough from langchain_core.prompts import ChatPromptTemplate from langchain_openai import ChatOpenAI from langchain_core.output_parsers import StrOutputParser def format_docs(docs): return "\n\n".join( f"【片段{i+1}】\n{doc.page_content}" for i, doc in enumerate(docs) ) rag_prompt = ChatPromptTemplate.from_messages([ ("system", "你是一个严格基于资料回答的助手。如果资料中没有答案,直接说'资料中未找到相关信息',禁止自行编造。"), ("human", "参考资料:\n{context}\n\n用户问题:{question}"), ]) model = ChatOpenAI(model="gpt-4o-mini", temperature=0.2) parser = StrOutputParser() rag_chain = ( {"context": retriever | format_docs, "question": RunnablePassthrough()} | rag_prompt | model | parser ) # 模拟用户提问 answer = rag_chain.invoke("LangChain支持哪些文档格式?") print(answer)逻辑说明:检索器的输入是字符串问题,输出是Document列表,经过format_docs转成带编号的文本。注意这一段使用了函数组合retriever | format_docs,意思是先执行检索再执行格式化,这在LCEL里是完全合法的。{"context": ..., "question": ...}这个字典结构同时接收两个数据流,最终拼成提示词所需的所有变量。
参数说明:为了调试方便,chain调用时只传入invoke("问题")因为question是透传参数,context由检索器自动生成。如果你希望看到每一段中间结果,可以在管道任意位置插入RunnableLambda(lambda x: print(x) or x)这样的调试节点,LCEL的管道结构让这种“偷看中间结果”的操作变得非常简单。
4.5 给Demo加一层自测逻辑:别等部署后才发现链路是断的
项目代码写到一个阶段,最高频的翻车点是“单测没跑过但Demo是好的”——因为交互式调用时偷偷拿到了缓存或大模型随机生成的内容掩盖了问题。我给这个Demo补一个最简单的自测:预设一个已知答案的问题,断言模型回答中包含特定关键词。
def test_rag_chain(): question = "这个Demo支持的文档格式有哪些?" expected_keywords = ["PDF", "Markdown"] answer = rag_chain.invoke(question) missing = [kw for kw in expected_keywords if kw not in answer] if missing: raise AssertionError(f"回答中缺少关键词: {missing}") print("断言通过:回答覆盖了预期关键词") test_rag_chain()逻辑说明:test_rag_chain做的事情非常简单,但有真实价值:它验证的是从问题输入到最终回答的整条链路是通的,而不是单测某个组件。expected_keywords需要根据你实际放入的文档内容来设定,不要写一个文档里根本没有的词,那样相当于让系统故意答错。
这一步做完,你的“某本地知识库问答Demo”就已经是一个可以拿出去演示的完整项目了。但在投入更多数据之前,我建议先把第五章的几个高频坑全部排掉,因为这些问题在我自己带过的入门项目里出现的频率极高,而且每一个都能让你浪费至少半天时间。
5. LangChain避坑指南:输出解析、上下文截断与版本依赖问题
这章写的是我最想在你动手前就告诉你的内容。所有问题都来自真实的调试经历,按“现象 → 原因 → 解决”的方式记录。每一条背后对应一个具体的LangChain机制,理解了机制,就不会只在出问题时搜报错。
5.1 模型返回了“多余前缀”,JSON输出解析失败
现象:使用JsonOutputParser或PydanticOutputParser解析模型输出时,报“JSON解码失败”。打印出原始内容发现,模型在JSON前面加了一句“好的,以下是您需要的JSON:”,导致解析器无法正常解析。
原因:LLM在生成结构化内容时,经常会带一些自然语言前缀,这是模型本身的习惯而非Bug。StrOutputParser不关心内容形态所以没事,但JSON解析器对格式要求严格,任何前后缀都会导致解析中断。
解决:最可靠的办法不是靠提示词纠正,而是在解析前做一次清洗。常见做法是使用PydanticOutputParser附带的格式指令,同时配合一个简单的文本清理函数,用正则截取第一个{到最后一个}之间的内容。
import json import re def extract_json_string(text: str) -> dict: # 定位JSON部分:从第一个左花括号到最后一个右花括号 match = re.search(r"\{.*\}", text, re.DOTALL) if not match: raise ValueError(f"未找到JSON内容,原始输出:{text}") return json.loads(match.group(0))逻辑说明:re.DOTALL让点号能匹配换行,保证多行JSON也能被正确捕获。这种方法本质上是在“尽力解析”,而不是严格依赖模型输出格式。提示:如果你用了LangChain的with_structured_output这类新接口,它会自动处理格式约束,这个问题出现概率会低很多。
5.2 上下文塞太多:长对话场景下模型开始“忘记”最早的内容
现象:在对话历史较长的场景中(比如超过10轮),模型开始重复回答、引用错误信息,甚至忽略系统提示词。排查代码没有发现bug,偶尔一次报告“上下文长度超限”。
原因:RAG项目的上下文由三部分组成:系统提示词 + 检索到的文档片段 + 累积的对话历史。很多人只关注检索片段裁剪,忽略了历史对话也在持续消耗token窗口。当整体超过模型的上下文长度限制时,线上请求直接报错;没超限时,模型注意力被碎片化的历史占满,指令遵循能力显著下降。
解决:引入消息历史裁剪策略。最直接的做法是只保留最近N轮对话,并把系统提示词保持在简短状态。
from langchain_core.messages import HumanMessage, AIMessage MAX_HISTORY_ROUNDS = 6 def trim_history(messages): # messages格式为 [HumanMessage/AIMessage] 列表,按轮次裁剪 if len(messages) <= MAX_HISTORY_ROUNDS * 2: return messages return messages[-(MAX_HISTORY_ROUNDS * 2):]逻辑说明:MAX_HISTORY_ROUNDS=6意味着保留最近6轮问答,也就是12条消息(每轮一条用户消息加一条AI消息)。trim_history返回裁剪后的列表,在实际调用时可以放在chain.invoke之前对历史变量做预处理。这个参数建议后续根据模型窗口大小和实际效果调整,通常定位在5到10之间。
5.3 依赖升级把项目“搞挂了”出现AttributeError
现象:过了一个假期回来,项目启动直接报AttributeError: module 'langchain_core' has no attribute 'xxx'或ImportError: cannot import name 'xxx' from 'langchain'。
原因:LangChain的API在次版本更新中也会调整,尤其是0.2向0.3过渡时,部分旧类被移动或改名。许多入门项目完全没有锁版本,别人跑通后复制到自己的环境就全挂了。
解决:把依赖固定到具体版本并生成锁文件,而不是只写包名。
# 导出当前环境的精确版本 pip freeze > requirements.txt # 安装时使用精确版本号,杜绝意外升级 pip install "langchain==0.2.17" "langchain-openai==0.1.22"逻辑说明:pip freeze生成的requirements.txt会把所有包的精确版本记录下载。注意不要用pip install -r requirements.txt在新机器上恢复一套不一致的环境,而是先检查其中是否有不需要的间接依赖去重后再安装。更推荐用pip-compile这类工具生成约束文件,但入门阶段用requirements.txt已经足够。
5.4 Embedding维度不一致导致的向量检索报错
现象:代码昨天还能跑,今天把Embedding模型从text-embedding-3-small换成了text-embedding-3-large,再查询时抛出向量维度不匹配的错误。
原因:向量库中已有内容的向量维度不会因你换了模型而自动改变。每个Embedding模型输出的维度不同,比如小模型默认1536维,大模型是3072维。新旧向量混在同一库里,相似度计算必然失败。
解决:更换Embedding模型时,必须重建整个向量库,而不是只替换Embedding类。融资级的做法是在代码里显式把向量库版本和Embedding模型名绑定,重建时一并更新。
# 用模型名作为向量库目录名的一部分,避免版本混乱 from pathlib import Path embedding_key = "text-embedding-3-small-1536" store_dir = Path(f"./vector_store/{embedding_key}") store_dir.mkdir(parents=True, exist_ok=True) # 实际使用时,向量库会和这个key一起持久化逻辑说明:embedding_key把模型名+维度作为标识,这样即使你换了Embedding模型,创建的也是另一个目录,不会和旧数据混在一起。加上这一层隔离之后,线上切换模型的风险就降到了“重新灌库”这个可控范围。
这四条避坑经验,覆盖了我见过的至少八成入门卡点。但解决了“跑通”的问题还不够,一个项目要从Demo变成本地服务,还要考虑接口封装、异常兜底这些工程化问题。这也是最后一章要处理的内容。
6. 从Demo到可用:把RAG问答链封装成Web服务并加兜底
能不能用和好不好用是两件事。把rag_chain封装成一个带输入校验、超时控制、降级回复的服务接口,本地工具就拥有了变成一个可靠后端的基础。
6.1 FastAPI封装:同步链路也可以安全暴露给外部
虽然是Demo,我还是建议一开始就按服务的标准来写,后续接前端、企业微信机器人或命令行工具都能复用。封装时注意:LangChain的invoke是阻塞调用,如果在FastAPI的异步接口里直接使用会阻塞事件循环。入门阶段最简单的方案是用普通def定义路由,让FastAPI自动放到线程池中执行。
from fastapi import FastAPI, HTTPException from pydantic import BaseModel app = FastAPI(title="本地知识库问答服务") class QueryRequest(BaseModel): question: str temperature: float = 0.2 @app.post("/ask") def ask_question(req: QueryRequest): if not req.question.strip(): raise HTTPException(status_code=400, detail="question不能为空") try: answer = rag_chain.invoke(req.question) return {"answer": answer} except Exception as e: # 兜底:任何异常都返回可读消息,而不是堆栈 return {"answer": f"服务暂时不可用,请稍后重试。错误类型:{type(e).__name__}"}逻辑说明:QueryRequest用Pydantic做请求体校验,question为空时直接返回400。try/except包住invoke是为了防止上游API波动导致整个服务返回500。请求方拿到的是带error_type的提示,而不是让内部错误直接暴露。参数说明:给接口暴露temperature是实用设计,调用方可以在不重启服务的情况下切换回答风格。
6.2 两层兜底:检索结果为空与大模型超时重试
服务化之后,两个高频生产问题会立刻浮出来:检索器有时返回空结果(文档库里没有相关内容),模型接口偶发超时。这两件事的处理策略完全不同。检索为空是业务侧可预期的正常分支,我直接做降级;模型超时是外部依赖故障,做一次重试。
from tenacity import retry, stop_after_attempt, wait_random_exponential @retry(stop=stop_after_attempt(2), wait=wait_random_exponential(multiplier=1, max=5)) def safe_invoke(chain, question: str) -> str: return chain.invoke(question) @app.post("/ask_with_fallback") def ask_with_fallback(req: QueryRequest): docs = retriever.invoke(req.question) if not docs: return {"answer": "抱歉,资料库中未找到相关内容,请换一种问法或补充文档。"} try: answer = safe_invoke(rag_chain, req.question) return {"answer": answer} except Exception as e: return {"answer": "模型服务暂时不可用,请稍后再试。"}逻辑说明:先用检索器单独检查是否有可用资料,这一步在调用大模型之前就可以完成,能省下一次昂贵的模型调用。tenacity库做重试时,wait_random_exponential意思是退避时间在1到5秒之间随机增长,避免多个请求同时重试造成二次雪崩。两次尝试仍失败,就直接走降级文案。
到这里,这个项目已经不只是一个能跑的脚本,而是一个有输入校验、有超时重试、有业务降级的完整问答服务。我把这套封装方式用在每一个需要交付给非技术同事使用的Demo上,效率提升明显——对方只需要知道“有一个接口,传问题进去,就有回答出来”,而不需要关心LangChain链路内部发生了什么。
回顾整个流程:环境搭建、最小链路、核心抽象、RAG实战、避坑、封装服务,一条线从零走到了可用。最终我想留下的一个习惯是:每写一条LangChain链子,先问自己它会在哪一环失败——是检索召回空,还是模型返回格式乱,再决定要不要加兜底。别等第一个外部调用者把问题抛回来才去补。希望这篇能帮你少踩几个我踩过的坑,顺利把LangChain用起来。
本文还有配套的精品资源,点击获取