这篇LangChain学习笔记,我拖了比较久才写。不是不想写,是这东西变化太快。今天写好的代码,过两周再看可能就已经被标记为弃用。网上关于 LangChain 的教程,绝大多数是把官网文档翻了个面,标题写着“入门到实战”,正文却停在 Hello World。我觉得真正值得沉淀下来的,是自己从零调通一个本地知识库问答项目的完整过程——包括为什么要这样组链、参数为什么这样设、报错到底是什么原因,以及哪些是文档永远不告诉你的细节。这篇笔记适合两类人:一类是刚接触 LangChain,想知道它到底能干什么、跟直接调大模型接口有什么区别;另一类是已经在用,但卡在检索不准、记忆混乱、版本报错这些实际问题上的人。我会尽量少说空话,直接把设计思路、核心组件和可落地的代码方案交代清楚。1. 先搞懂 LangChain 的核心设计:它解决的是组装问题,不是模型问题1.1 它到底做了什么很多人一开始以为 LangChain 是某种大模型,或者是一套模型服务平台。其实不是。它本质上是编排层,负责把模型、提示词、记忆、检索、工具这些零部件按不同方式接起来。用生活化一点的类比:大模型好比一台发动机,LangChain 是给这台发动机接线、装变速箱、接仪表盘的那套系统。发动机很重要,但一辆车能不能好开,取决于整车怎么装配。我学习时的第一个误区是急于写代码调 Agent,结果连“链”和“代理”的关系都没理清。LangChain 的核心抽象是 Chain,即一条确定性的处理流水线:输入进来,依次经过提示词格式化、模型调用、输出解析,最后返回结果。Agent 则是在这条流水线上加了循环和工具选择,本质上更复杂。后来官方用 LangGraph 实现图式流程编排,底层思路一致:把任务拆成节点,把节点连成图,逐段执行。所以我的建议是,如果决定学 LangChain,先种下这个概念:学的是“组装方式”,不是“模型本身”。模型能力决定输出天花板,框架决定工程质量和可控性。很多人搭出来的 RAG 效果差,不一定是模型不行,反而是组装链路里某些环节太粗糙。1.2 版本演进:先看版本,再看教程还有一个必须提醒的点:LangChain 的版本分裂非常严重。0.1 时代很多代码写的是from langchain.llms import OpenAI,到了 0.2、0.3 时代这行已经不够用了,包被拆成langchain-core、langchain-openai、langchain-community、langchain-text-splitters等。刚开始我用旧教程练手,第一行 import 就报错,后来才意识到问题不在我,而是版本差异。我现在的做法是:学习前先确认自己装的版本,代码里优先从langchain_core导入基础抽象,模型类从langchain_openai或langchain_community导入。遇到报错先查版本迁移文档里那个被改名的类,一般都能解决。比如同样是加载文本,旧版写成from langchain.document_loaders import TextLoader,新版本更规范的路径是from langchain_community.document_loaders import TextLoader。如果看到网上教程的 import 路径扎堆混乱,不要怀疑自己,先看看教程发布日期和版本号。1.3 什么场景真正有必要用它也不是所有项目都必须上 LangChain。如果只是调一次模型、写一个翻译小脚本,直接用请求库打接口反而干净。LangChain 的价值在场景变复杂时体现:多个模型切换、多轮对话需要记忆、要给模型接公司内部知识库、要让模型调用工具或执行多步任务。把它当胶水用,它的优势很大;把它当万能框架用,反而给自己加戏。我自己判断是否引入 LangChain 的标准很简单:如果任务里出现了“提示词需要动态组装”“对话超过三句”“回答需要参考外部资料”三个特征中的任意两个,就值得引入。如果只是单轮问答,裸调接口更省事。2. 核心组件逐个拆解:模型、提示词、记忆、检索、输出解析2.1 模型封装:同一个接口切换不同模型LangChain 对模型的封装是最基础的一层。它会帮你处理消息格式、流式输出、缓存、失败重试等细节。在 LangChain 里,模型接口被抽象成两种:纯文本补全模型和对话模型(Chat Models)。现在新项目尽量用对话模型,因为返回消息带角色,方便处理多轮对话。from langchain_openai import ChatOpenAI llm = ChatOpenAI( model="demo-chat", base_url="https://your-endpoint.example.com/v1", api_key="your-key", temperature=0.3, )ChatOpenAI这个名字容易让人误以为只能连特定厂商的接口。实际上只要服务走的是 OpenAI 兼容协议,基本都能通过base_url接入。这样做的直接收益是:业务代码不感知模型厂商,想从 A 模型切到 B 模型,只改配置,不动链路。在真实项目里,这个灵活性非常值钱,因为模型迭代太快,今天觉得好用的模型,下个月可能被另一个模型在效果和价格上双杀。2.2 提示词模板:别再用 f-string 拼提示词很多人写提示词喜欢直接f"你是{role},请回答{question}"。在简单脚本里没问题,一旦工程化,这类字符串散落在代码各处,改起来非常痛苦。LangChain 提供了PromptTemplate和ChatPromptTemplate。ChatPromptTemplate 更适合对话模型,因为它能区分 system、human、ai 消息。from langchain_core.prompts import ChatPromptTemplate prompt = ChatPromptTemplate.from_messages([ ("system", "你是一名{role},回答必须简洁,不要编造事实。"), ("human", "{question}"), ]) messages = prompt.invoke({"role": "客服助手", "question": "怎么申请退款?"}) print(messages.to_messages())提示词模板不只是把变量套进字符串,它还把消息角色结构固化下来。比如 system 消息负责定人设,human 消息负责承载用户输入。更重要的是,模板可以独立维护和版本管理,换提示词不需要改代码逻辑。实际项目中,我习惯把 prompt 单独放进一个文件或配置项,关键内容做到可视化修改。尤其当你发现自己频繁调整提示词的时候,模板化的收益会迅速超过手工拼接。2.3 记忆模块:别让它无限制累积大模型本身没有记忆,每次调用都是无状态的。多轮对话时如果不把历史信息传给模型,模型不会记得上一句说了什么。LangChain 提供了几种记忆方案,名字容易混淆:记忆类型工作方式适合场景成本ConversationBufferMemory全部历史拼接短对话、调试token 消耗高ConversationBufferWindowMemory只留最近 N 轮常规客服场景可控ConversationSummaryMemory模型压缩历史,存摘要长对话有压缩开销且会丢细节一开始我无脑使用 BufferMemory,结果对话到第 20 轮时 prompt 长度直接爆炸,报错提示超出上下文上限。后来切到窗口记忆,只保留最近 6 轮,问题立刻解决。一个更可控的做法是手动维护消息列表。在 LCEL 管道里,记忆不一定要绑定在 Chain 内部,直接把历史拼进 prompt 反而透明:history = [] # 每个元素是 {"human": "用户问题", "ai": "模型回答"} def build_messages(question): messages = [] for item in history[-6:]: messages.append(("human", item["human"])) messages.append(("ai", item["ai"])) messages.append(("human", question)) return messages message_dicts = build_messages("那请问它支持企业微信吗?") prompt = ChatPromptTemplate.from_messages(message_dicts)招数朴素,但好处很明确:每条消息都清晰可见,调试时一眼看清模型到底收到了什么。生产环境里,可观测性永远是第一位。2.4 文档加载与切割:RAG 效果一半由这里决定RAG 项目里最容易被低估的环节是文档处理。LangChain 对文档的抽象是Document,它包含两个字段:page_content存放文本正文,metadata存来源、页码、标题等附加信息。文档加载完成后,通常不能直接把整个文档丢给向量库。因为一篇文章可能几千字,直接向量化会稀释语义,检索精度很差。正确做法是先切分成小块,每个块作为一个独立检索单元。from langchain_text_splitters import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=50, separators=["\n\n", "\n", "。", "!", "?", ";", " ", ""], ) chunks = splitter.split_documents(docs)chunk_size=500意味着每个块大约 500 个字符,chunk_overlap=50让相邻块有 50 字符重叠。overlap 的作用是避免关键句恰好被拦腰截断,导致上下文丢失。切太细,单块语义不完整;切太粗,块内噪声多,检索命中率下降。500 到 800 是我在文档问答项目里常用的区间,具体要看文档类型。这里特别想强调中文场景。默认的 separator 列表对英文友好,但中文没有空格,默认方案会把中文句子切得比较碎。我在 4.2 节会展开讲这个坑。2.5 检索器、向量库与输出解析:把结果变得可预期Retriever的职责是:给一个查询,返回若干相关 Document。最常见的是把向量库包装成检索器。retriever = db.as_retriever( search_type="similarity", search_kwargs={"k": 5}, )k表示返回多少条结果。为什么是 5 而不是 20?一方面受模型上下文长度限制,另一方面结果越多,干扰信息也越多。但在后续章节我会推荐一个“先召回更多、再二次筛选”的进阶做法。输出解析器也是容易被忽略的组件。模型返回的是纯文本,但业务往往需要结构化的数据。StrOutputParser直接取文本内容;JsonOutputParser要求模型输出 JSON;PydanticOutputParser可以指定数据结构和字段说明,解析失败时还会报错提示。from langchain_core.output_parsers import PydanticOutputParser from pydantic import BaseModel, Field class Answer(BaseModel): answer: str = Field(description="最终回答内容") confidence: float = Field(description="置信度,0到1之间") sources: list[str] = Field(description="参考来源编号列表") parser = PydanticOutputParser(pydantic_object=Answer)输出解析器让模型输出从“靠运气”变成“可校验”。在需要对接后台系统的场景里,这一步几乎是刚需。3. 实操记录:从零搭一个本地知识库问答系统(RAG)现在进入正题。我用一个模拟项目X来说明完整流程:项目里有一批产品说明文档,目标是让用户用自然语言提问,模型从文档里找答案,而不是凭训练数据瞎编。3.1 数据准备:从原始文档到干净文本这一步最不性感,但收益最高。我遇到的第一个问题不是代码,而是文档本身。有的文本从 PDF 抽取后带乱码,有的表格被转成了断行文本,有的文档开头带一堆没用的页眉页脚。我先把所有文档统一转成纯文本,编码统一为 UTF-8,然后做一轮清洗:去掉多余的空白行、去掉页眉页脚、把表格描述成“字段名是 xxx,值是 xxx”的句子。清洗后的质量会直接影响切分和检索效果。加载文本用TextLoader:from langchain_community.document_loaders import TextLoader loader = TextLoader("./kb/产品说明.txt", encoding="utf-8") docs = loader.load()如果一个目录下有很多文件,可以用DirectoryLoader批量加载。注意指定编码,中文环境下默认编码经常踩坑,与其猜测,不如显式写清楚。3.2 切分参数:chunk_size 与 overlap 的权衡切分是把长文档切成语义相对独立的小块。我在项目里用的是:from langchain_text_splitters import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=50, separators=["\n\n", "\n", "。", "!", "?", ";", " ", ""], ) chunks = splitter.split_documents(docs) print(f"共切分为 {len(chunks)} 个片段")为什么选 500?因为太短的 chunk 往往只有一两句话,语义不完整,模型回答时缺少上下文;太长的 chunk 又会让向量检索命中时夹带大量无关内容。选 50 的 overlap,是给边界信息一个缓冲,防止一句完整的话被分到两个块里导致两边都不完整。实际操作时,我会随机抽查几个 chunk,看切分边界是否落在完整语义上。如果发现大量分割点在句子中间,就调整分隔符优先级或加大 overlap。3.3 向量化与向量库选型:为什么小项目选 FAISS把文本变成向量是 RAG 的核心环节。Embedding 模型的选择很重要,可以用云端接口,也可以用本地小模型。本地小模型的好处是数据不出内网、调用零延迟,适合内部知识库。我用的本地模型是一个开源中文 Embedding 模型,体积小,效果够用。加载方式:from langchain_community.embeddings import HuggingFaceEmbeddings embeddings = HuggingFaceEmbeddings( model_name="bge-small-zh-v1.5", encode_kwargs={"normalize_embeddings": True}, )向量库方面,我一开始直接在几个方案里纠结了半天。最后选择很简单:单机、小规模、快速验证:选 FAISS,纯本地文件,零运维需要可视化翻看数据、多人协作:选 Chroma大规模分布式、高并发:再考虑云端向量库FAISS 是一个向量检索库,不是数据库,所以不能当存储系统长期依赖。项目原型阶段用它足够,正式上线前再迁移到更适合的存储。构建索引:from langchain_community.vectorstores import FAISS db = FAISS.from_documents(chunks, embeddings) db.save_local("./faiss_index")下次启动代码时加载:db = FAISS.load_local( "./faiss_index", embeddings, allow_dangerous_deserialization=True, )加载时这个参数是新版本要求显式确认的,主要防止反序列化风险。自己本机的索引可以放心打开,但如果是外部传入的索引文件,就要谨慎对待。3.4 组装 RAG 链路:LCEL 管道写法LangChain 0.2 以后主推 LCEL,用|符号把组件像管道一样连起来。下面是一个完整的 RAG 问答链路。from langchain_core.runnables import RunnablePassthrough from langchain_core.output_parsers import StrOutputParser from langchain_core.prompts import ChatPromptTemplate from langchain_openai import ChatOpenAI def format_docs(docs): return "\n\n".join(doc.page_content for doc in docs) prompt = ChatPromptTemplate.from_messages([ ("system", "你是一个严谨的文档问答助手。请仅根据给定上下文回答问题。" "如果上下文中没有相关信息,请直接说不知道,不要编造。\n\n" "上下文:\n{context}"), ("human", "{question}"), ]) llm = ChatOpenAI( model="demo-chat", base_url="https://your-endpoint.example.com/v1", api_key="your-key", temperature=0, ) rag_chain = ( {"context": retriever | format_docs, "question": RunnablePassthrough()} | prompt | llm | StrOutputParser() ) result = rag_chain.invoke("我们的产品支持哪些支付方式?") print(result)这段代码里,retriever先根据问题检索出若干文档,format_docs把它们拼成一段上下文;RunnablePassthrough()把用户原样传入作为 question;随后交给 prompt 模板格式化,再送入模型,最后取出纯文本结果。3.5 检索调参:召回 20,二次筛选取 5基础链路跑通后,我发现一个典型问题:有时候正确答案在第 6 条、第 8 条的位置,只取前 5 条就把正确答案漏掉了。解决办法是改成两段式检索。第一段召回 20 条,保留足够多的候选;第二段用相似度分数过滤,或者用重排序模型压缩到 5 条。docs_with_scores = db.similarity_search_with_score("怎么申请退款", k=20) filtered = [] for doc, score in docs_with_scores: # FAISS 返回的距离,分数越小代表越相似 # 具体阈值需要先打印看分布,不能直接套网上的值 if score < 50: filtered.append(doc) context = format_docs(filtered[:5])这里特别想提醒:不同向量库、不同 Embedding 模型计算出来的分数范围完全不一样。有的库返回的是距离,有的返回余弦相似度,连“越大越好还是越小越好”都不一样。我第一次调试时拿网上的阈值直接套,结果把所有结果都过滤掉了,打印出来一看,分数分布和我以为的差了十万八千里。正确的习惯是:先打印 20 条分数,观察相关结果和不相关结果的分布,再选择分割点。3.6 加上历史记忆:让追问不再断片用户提问往往是一连串的。用户问“你们有什么套餐?”,接着会问“第一个多少钱?”如果没有历史,第二个问题就是一串无意义的字符,检索效果很差。做法是在进入 RAG 链路之前,先把最近几轮对话拼进 prompt:history = [] def ask(question): messages = [] for item in history[-6:]: messages.append(("human", item["human"])) messages.append(("ai", item["ai"])) messages.append(("human", question)) prompt_cur = ChatPromptTemplate.from_messages(messages) chain = prompt_cur | llm | StrOutputParser() answer = chain.invoke({}) history.append({"human": question, "ai": answer}) return answer注意这里没有直接在 prompt 里用变量,而是把历史消息作为多轮对话传入,模型能区分哪句是谁说的。检索部分也可以额外加一层:把“第一个”这类代词改写为原始问题里的实体,这是进阶优化,可以先不做。4. 常见报错与排查技巧实录4.1 高频报错速查表我把实际项目里遇到的高频问题整理成了表格,方便直接对照。报错信息常见原因处理方式ModuleNotFoundError: No module named 'langchain_openai'包版本拆分,旧写法失效安装对应分包:pip install langchain-openaiImportError: cannot import name 'LLMChain' from 'langchain'代码基于 0.1 老版本改用 LCEL 管道写法,或升级迁移OutputParserException: Failed to parse模型返回内容不是合法 JSON换结构化输出,解析失败后重试This model's maximum context length is ...输入超出上下文限制减小 chunk、缩短历史、用摘要压缩IndexError: Faiss with no index loaded索引文件不存在或路径不对检查是否执行过save_local,路径是否正确InvalidPromptError: Missing input variablesprompt 模板变量没有传全打印模板的输入变量列表,逐一核对版本类报错在 LangChain 里占比最高。如果看到某段旧代码一堆 import 报错,不要逐个硬改,直接去官方升级指南搜索改名后的类,效率最高。4.2 中文切分乱码与语义割裂我在项目里第一次使用默认的RecursiveCharacterTextSplitter,切分结果惨不忍睹。默认分隔符列表是["\n\n", "\n", " ", ""],对英文友好,但中文没有空格,最终会退化到按单个字符切割,chunk 边界经常落在一个完整句子的中间,导致检索时语义被拦腰截断。修正方案是显式加入中文断句标点:splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=50, separators=["\n\n", "\n", "。", "!", "?", ";", " ", ""], )RecursiveCharacterTextSplitter会按顺序尝试分隔符,先按段落分,再按句子分,这样切出来的块语义通常更完整。切完以后我强烈建议打印几个 chunk 人工看一眼,这一步花不了两分钟,但能省掉后面大量检索调试时间。4.3 空检索、弱检索:把召回当成一个漏斗如果检索结果为空,或者检索到的内容和问题完全无关,我会按下面顺序排查:文档是否真的加载进来了。打印len(docs),很多空检索其实是读取文件失败,内容为空。切分后的片段是否太碎或太脏。看几个 sample 确认。Embedding 是否正常工作。可以用一小段文字先向量化,再计算它和自身的相似度,如果分数异常,说明模型加载有问题。查询本身是否太模糊。比如用户只输入“你好”,向量检索自然找不到稳定结果。阈值是否设得太高。把分数全部打印出来看分布,再决定分割点。一个很实用的技巧是查询改写。对模糊问题做一次扩展,再分别检索。例如“怎么退款”可以扩展成“退款流程”“退款申请”“退款规则”“取消订单后怎么退款”四个查询,分别检索后合并结果,再让模型综合回答。实现不复杂,但对弱检索的提升非常明显。4.4 输出格式不稳定:用结构化输出兜底模型输出经常不够规矩,让它输出 JSON,它可能在前面加一句解释,导致解析失败。第一次遇到时,我的第一反应是修改提示词,后来发现治标不治本,因为模型状态不可控。更可靠的方案是解析失败时让模型自己修正。伪代码如下:from langchain_core.output_parsers import JsonOutputParser from langchain_core.runnables import RunnableLambda parser = JsonOutputParser() def safe_parse(text): try: return parser.parse(text) except Exception as e: # 把错误信息拼回去,让模型重新生成合法 JSON fix_prompt = f"你上次的输出不是合法 JSON。错误信息:{e}\n请重新输出,只输出 JSON,不要加任何解释。原始内容:{text}" fixed = llm.invoke(fix_prompt) return parser.parse(fixed.content) chain = prompt | llm | RunnableLambda(safe_parse)这类重试机制价值很高,能在代码层面兜住模型的不确定性。代价是多一次模型调用,但换来的是链路整体可靠性,完全值得。5. 我的学习路线与几个反共识的看法5.1 先别急着学 Agent很多初学者一上来就想做自主 Agent,结果陷入循环调用、token 烧完、行为不可控的泥潭。我的建议是先把 Chain 用熟,再考虑 Agent。Agent 的本质是让模型决定下一步调用什么工具,这依赖模型对工具的推理能力。模型不够强时,所谓 Agent 就是灾难:它会在工具之间来回横跳,迟迟不结束任务,还会调用无关工具浪费预算。而 Chain 是确定性的,输入什么就走什么流程,至少可以预期。RAG 是比 Chain 再高一级的复杂度,但它是确定可调优的。把 RAG 做好之后,再学 Agent 会轻松很多,因为你已经理解模型输出的随机性、工具调用的开销、提示词对决策的影响,面对 Agent 失控时也更容易定位问题。5.2 推荐的五阶段学习路径第一阶段:用接口直接调用大模型,理解 temperature、max_tokens、流式输出这些基础概念。不要跳过,因为框架可能会掩盖这些细节,但工程里一定会遇到。第二阶段:用ChatPromptTemplate加输出解析器做一个简单小工具,比如关键词提取器或文本分类器。目标是理解模板、模型、解析三段式结构。第三阶段:做一个完整的 RAG 项目,包括文档清洗、切分、向量化、检索、生成、评测。这是性价比最高的一步,所有核心技能都会用到。第四阶段:优化检索质量,引入记忆、查询改写、重排序、评分过滤。这些优化对效果的提升往往比换模型更明显。第五阶段:根据需求决定是否进入 LangGraph 或多 Agent 方向。到这一步再看官方文档,你不再是一头雾水,而是能看懂它解决的是什么问题了。5.3 几个反共识的看法第一,LangChain 本身不慢,也不笨重。慢的是我们不加选择地调用模型和向量库,同样的检索写了一圈重循环。链路的每一步最好都记录耗时,再决定优化哪里。第二,不必为了框架而框架。场景足够简单时,手工拼请求反而更清楚。我不会强行把一个小脚本套进 LangChain,只有出现多组件协作时才用它。第三,永远把可观测性放在第一位。我在调试 RAG 时最常用的工具不是断点,而是埋头打印每一步的输入输出:prompt 最后长什么样、retriever 召回了哪些文档、分数是多少。很多问题一打印就水落石出。第四,不要迷信“一键 RAG”。真实项目中,文档清洗、切分、召回评测占掉七成工作量,LangChain 只把剩下三成的胶水活变得干净。如果你发现效果不好,先检查数据和检索,别第一时间怀疑框架。我现在的习惯是,把每个环节都做成可以单独测试的小模块:文档加载单独测,切分单独看,检索单独打印分数,最后才拼成完整链路。这套方式,是跑了两个完整项目之后沉淀下来的。LangChain 学习笔记可以写得很长,但真正有用的,往往就是这些从报错和调试里逼出来的经验。
这篇LangChain学习笔记,我拖了比较久才写。不是不想写,是这东西变化太快。今天写好的代码,过两周再看可能就已经被标记为弃用。网上关于 LangChain 的教程,绝大多数是把官网文档翻了个面,标题写着“入门到实战”,正文却停在 Hello World。我觉得真正值得沉淀下来的,是自己从零调通一个本地知识库问答项目的完整过程——包括为什么要这样组链、参数为什么这样设、报错到底是什么原因,以及哪些是文档永远不告诉你的细节。这篇笔记适合两类人:一类是刚接触 LangChain,想知道它到底能干什么、跟直接调大模型接口有什么区别;另一类是已经在用,但卡在检索不准、记忆混乱、版本报错这些实际问题上的人。我会尽量少说空话,直接把设计思路、核心组件和可落地的代码方案交代清楚。1. 先搞懂 LangChain 的核心设计:它解决的是组装问题,不是模型问题1.1 它到底做了什么很多人一开始以为 LangChain 是某种大模型,或者是一套模型服务平台。其实不是。它本质上是编排层,负责把模型、提示词、记忆、检索、工具这些零部件按不同方式接起来。用生活化一点的类比:大模型好比一台发动机,LangChain 是给这台发动机接线、装变速箱、接仪表盘的那套系统。发动机很重要,但一辆车能不能好开,取决于整车怎么装配。我学习时的第一个误区是急于写代码调 Agent,结果连“链”和“代理”的关系都没理清。LangChain 的核心抽象是 Chain,即一条确定性的处理流水线:输入进来,依次经过提示词格式化、模型调用、输出解析,最后返回结果。Agent 则是在这条流水线上加了循环和工具选择,本质上更复杂。后来官方用 LangGraph 实现图式流程编排,底层思路一致:把任务拆成节点,把节点连成图,逐段执行。所以我的建议是,如果决定学 LangChain,先种下这个概念:学的是“组装方式”,不是“模型本身”。模型能力决定输出天花板,框架决定工程质量和可控性。很多人搭出来的 RAG 效果差,不一定是模型不行,反而是组装链路里某些环节太粗糙。1.2 版本演进:先看版本,再看教程还有一个必须提醒的点:LangChain 的版本分裂非常严重。0.1 时代很多代码写的是from langchain.llms import OpenAI,到了 0.2、0.3 时代这行已经不够用了,包被拆成langchain-core、langchain-openai、langchain-community、langchain-text-splitters等。刚开始我用旧教程练手,第一行 import 就报错,后来才意识到问题不在我,而是版本差异。我现在的做法是:学习前先确认自己装的版本,代码里优先从langchain_core导入基础抽象,模型类从langchain_openai或langchain_community导入。遇到报错先查版本迁移文档里那个被改名的类,一般都能解决。比如同样是加载文本,旧版写成from langchain.document_loaders import TextLoader,新版本更规范的路径是from langchain_community.document_loaders import TextLoader。如果看到网上教程的 import 路径扎堆混乱,不要怀疑自己,先看看教程发布日期和版本号。1.3 什么场景真正有必要用它也不是所有项目都必须上 LangChain。如果只是调一次模型、写一个翻译小脚本,直接用请求库打接口反而干净。LangChain 的价值在场景变复杂时体现:多个模型切换、多轮对话需要记忆、要给模型接公司内部知识库、要让模型调用工具或执行多步任务。把它当胶水用,它的优势很大;把它当万能框架用,反而给自己加戏。我自己判断是否引入 LangChain 的标准很简单:如果任务里出现了“提示词需要动态组装”“对话超过三句”“回答需要参考外部资料”三个特征中的任意两个,就值得引入。如果只是单轮问答,裸调接口更省事。2. 核心组件逐个拆解:模型、提示词、记忆、检索、输出解析2.1 模型封装:同一个接口切换不同模型LangChain 对模型的封装是最基础的一层。它会帮你处理消息格式、流式输出、缓存、失败重试等细节。在 LangChain 里,模型接口被抽象成两种:纯文本补全模型和对话模型(Chat Models)。现在新项目尽量用对话模型,因为返回消息带角色,方便处理多轮对话。from langchain_openai import ChatOpenAI llm = ChatOpenAI( model="demo-chat", base_url="https://your-endpoint.example.com/v1", api_key="your-key", temperature=0.3, )ChatOpenAI这个名字容易让人误以为只能连特定厂商的接口。实际上只要服务走的是 OpenAI 兼容协议,基本都能通过base_url接入。这样做的直接收益是:业务代码不感知模型厂商,想从 A 模型切到 B 模型,只改配置,不动链路。在真实项目里,这个灵活性非常值钱,因为模型迭代太快,今天觉得好用的模型,下个月可能被另一个模型在效果和价格上双杀。2.2 提示词模板:别再用 f-string 拼提示词很多人写提示词喜欢直接f"你是{role},请回答{question}"。在简单脚本里没问题,一旦工程化,这类字符串散落在代码各处,改起来非常痛苦。LangChain 提供了PromptTemplate和ChatPromptTemplate。ChatPromptTemplate 更适合对话模型,因为它能区分 system、human、ai 消息。from langchain_core.prompts import ChatPromptTemplate prompt = ChatPromptTemplate.from_messages([ ("system", "你是一名{role},回答必须简洁,不要编造事实。"), ("human", "{question}"), ]) messages = prompt.invoke({"role": "客服助手", "question": "怎么申请退款?"}) print(messages.to_messages())提示词模板不只是把变量套进字符串,它还把消息角色结构固化下来。比如 system 消息负责定人设,human 消息负责承载用户输入。更重要的是,模板可以独立维护和版本管理,换提示词不需要改代码逻辑。实际项目中,我习惯把 prompt 单独放进一个文件或配置项,关键内容做到可视化修改。尤其当你发现自己频繁调整提示词的时候,模板化的收益会迅速超过手工拼接。2.3 记忆模块:别让它无限制累积大模型本身没有记忆,每次调用都是无状态的。多轮对话时如果不把历史信息传给模型,模型不会记得上一句说了什么。LangChain 提供了几种记忆方案,名字容易混淆:记忆类型工作方式适合场景成本ConversationBufferMemory全部历史拼接短对话、调试token 消耗高ConversationBufferWindowMemory只留最近 N 轮常规客服场景可控ConversationSummaryMemory模型压缩历史,存摘要长对话有压缩开销且会丢细节一开始我无脑使用 BufferMemory,结果对话到第 20 轮时 prompt 长度直接爆炸,报错提示超出上下文上限。后来切到窗口记忆,只保留最近 6 轮,问题立刻解决。一个更可控的做法是手动维护消息列表。在 LCEL 管道里,记忆不一定要绑定在 Chain 内部,直接把历史拼进 prompt 反而透明:history = [] # 每个元素是 {"human": "用户问题", "ai": "模型回答"} def build_messages(question): messages = [] for item in history[-6:]: messages.append(("human", item["human"])) messages.append(("ai", item["ai"])) messages.append(("human", question)) return messages message_dicts = build_messages("那请问它支持企业微信吗?") prompt = ChatPromptTemplate.from_messages(message_dicts)招数朴素,但好处很明确:每条消息都清晰可见,调试时一眼看清模型到底收到了什么。生产环境里,可观测性永远是第一位。2.4 文档加载与切割:RAG 效果一半由这里决定RAG 项目里最容易被低估的环节是文档处理。LangChain 对文档的抽象是Document,它包含两个字段:page_content存放文本正文,metadata存来源、页码、标题等附加信息。文档加载完成后,通常不能直接把整个文档丢给向量库。因为一篇文章可能几千字,直接向量化会稀释语义,检索精度很差。正确做法是先切分成小块,每个块作为一个独立检索单元。from langchain_text_splitters import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=50, separators=["\n\n", "\n", "。", "!", "?", ";", " ", ""], ) chunks = splitter.split_documents(docs)chunk_size=500意味着每个块大约 500 个字符,chunk_overlap=50让相邻块有 50 字符重叠。overlap 的作用是避免关键句恰好被拦腰截断,导致上下文丢失。切太细,单块语义不完整;切太粗,块内噪声多,检索命中率下降。500 到 800 是我在文档问答项目里常用的区间,具体要看文档类型。这里特别想强调中文场景。默认的 separator 列表对英文友好,但中文没有空格,默认方案会把中文句子切得比较碎。我在 4.2 节会展开讲这个坑。2.5 检索器、向量库与输出解析:把结果变得可预期Retriever的职责是:给一个查询,返回若干相关 Document。最常见的是把向量库包装成检索器。retriever = db.as_retriever( search_type="similarity", search_kwargs={"k": 5}, )k表示返回多少条结果。为什么是 5 而不是 20?一方面受模型上下文长度限制,另一方面结果越多,干扰信息也越多。但在后续章节我会推荐一个“先召回更多、再二次筛选”的进阶做法。输出解析器也是容易被忽略的组件。模型返回的是纯文本,但业务往往需要结构化的数据。StrOutputParser直接取文本内容;JsonOutputParser要求模型输出 JSON;PydanticOutputParser可以指定数据结构和字段说明,解析失败时还会报错提示。from langchain_core.output_parsers import PydanticOutputParser from pydantic import BaseModel, Field class Answer(BaseModel): answer: str = Field(description="最终回答内容") confidence: float = Field(description="置信度,0到1之间") sources: list[str] = Field(description="参考来源编号列表") parser = PydanticOutputParser(pydantic_object=Answer)输出解析器让模型输出从“靠运气”变成“可校验”。在需要对接后台系统的场景里,这一步几乎是刚需。3. 实操记录:从零搭一个本地知识库问答系统(RAG)现在进入正题。我用一个模拟项目X来说明完整流程:项目里有一批产品说明文档,目标是让用户用自然语言提问,模型从文档里找答案,而不是凭训练数据瞎编。3.1 数据准备:从原始文档到干净文本这一步最不性感,但收益最高。我遇到的第一个问题不是代码,而是文档本身。有的文本从 PDF 抽取后带乱码,有的表格被转成了断行文本,有的文档开头带一堆没用的页眉页脚。我先把所有文档统一转成纯文本,编码统一为 UTF-8,然后做一轮清洗:去掉多余的空白行、去掉页眉页脚、把表格描述成“字段名是 xxx,值是 xxx”的句子。清洗后的质量会直接影响切分和检索效果。加载文本用TextLoader:from langchain_community.document_loaders import TextLoader loader = TextLoader("./kb/产品说明.txt", encoding="utf-8") docs = loader.load()如果一个目录下有很多文件,可以用DirectoryLoader批量加载。注意指定编码,中文环境下默认编码经常踩坑,与其猜测,不如显式写清楚。3.2 切分参数:chunk_size 与 overlap 的权衡切分是把长文档切成语义相对独立的小块。我在项目里用的是:from langchain_text_splitters import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=50, separators=["\n\n", "\n", "。", "!", "?", ";", " ", ""], ) chunks = splitter.split_documents(docs) print(f"共切分为 {len(chunks)} 个片段")为什么选 500?因为太短的 chunk 往往只有一两句话,语义不完整,模型回答时缺少上下文;太长的 chunk 又会让向量检索命中时夹带大量无关内容。选 50 的 overlap,是给边界信息一个缓冲,防止一句完整的话被分到两个块里导致两边都不完整。实际操作时,我会随机抽查几个 chunk,看切分边界是否落在完整语义上。如果发现大量分割点在句子中间,就调整分隔符优先级或加大 overlap。3.3 向量化与向量库选型:为什么小项目选 FAISS把文本变成向量是 RAG 的核心环节。Embedding 模型的选择很重要,可以用云端接口,也可以用本地小模型。本地小模型的好处是数据不出内网、调用零延迟,适合内部知识库。我用的本地模型是一个开源中文 Embedding 模型,体积小,效果够用。加载方式:from langchain_community.embeddings import HuggingFaceEmbeddings embeddings = HuggingFaceEmbeddings( model_name="bge-small-zh-v1.5", encode_kwargs={"normalize_embeddings": True}, )向量库方面,我一开始直接在几个方案里纠结了半天。最后选择很简单:单机、小规模、快速验证:选 FAISS,纯本地文件,零运维需要可视化翻看数据、多人协作:选 Chroma大规模分布式、高并发:再考虑云端向量库FAISS 是一个向量检索库,不是数据库,所以不能当存储系统长期依赖。项目原型阶段用它足够,正式上线前再迁移到更适合的存储。构建索引:from langchain_community.vectorstores import FAISS db = FAISS.from_documents(chunks, embeddings) db.save_local("./faiss_index")下次启动代码时加载:db = FAISS.load_local( "./faiss_index", embeddings, allow_dangerous_deserialization=True, )加载时这个参数是新版本要求显式确认的,主要防止反序列化风险。自己本机的索引可以放心打开,但如果是外部传入的索引文件,就要谨慎对待。3.4 组装 RAG 链路:LCEL 管道写法LangChain 0.2 以后主推 LCEL,用|符号把组件像管道一样连起来。下面是一个完整的 RAG 问答链路。from langchain_core.runnables import RunnablePassthrough from langchain_core.output_parsers import StrOutputParser from langchain_core.prompts import ChatPromptTemplate from langchain_openai import ChatOpenAI def format_docs(docs): return "\n\n".join(doc.page_content for doc in docs) prompt = ChatPromptTemplate.from_messages([ ("system", "你是一个严谨的文档问答助手。请仅根据给定上下文回答问题。" "如果上下文中没有相关信息,请直接说不知道,不要编造。\n\n" "上下文:\n{context}"), ("human", "{question}"), ]) llm = ChatOpenAI( model="demo-chat", base_url="https://your-endpoint.example.com/v1", api_key="your-key", temperature=0, ) rag_chain = ( {"context": retriever | format_docs, "question": RunnablePassthrough()} | prompt | llm | StrOutputParser() ) result = rag_chain.invoke("我们的产品支持哪些支付方式?") print(result)这段代码里,retriever先根据问题检索出若干文档,format_docs把它们拼成一段上下文;RunnablePassthrough()把用户原样传入作为 question;随后交给 prompt 模板格式化,再送入模型,最后取出纯文本结果。3.5 检索调参:召回 20,二次筛选取 5基础链路跑通后,我发现一个典型问题:有时候正确答案在第 6 条、第 8 条的位置,只取前 5 条就把正确答案漏掉了。解决办法是改成两段式检索。第一段召回 20 条,保留足够多的候选;第二段用相似度分数过滤,或者用重排序模型压缩到 5 条。docs_with_scores = db.similarity_search_with_score("怎么申请退款", k=20) filtered = [] for doc, score in docs_with_scores: # FAISS 返回的距离,分数越小代表越相似 # 具体阈值需要先打印看分布,不能直接套网上的值 if score < 50: filtered.append(doc) context = format_docs(filtered[:5])这里特别想提醒:不同向量库、不同 Embedding 模型计算出来的分数范围完全不一样。有的库返回的是距离,有的返回余弦相似度,连“越大越好还是越小越好”都不一样。我第一次调试时拿网上的阈值直接套,结果把所有结果都过滤掉了,打印出来一看,分数分布和我以为的差了十万八千里。正确的习惯是:先打印 20 条分数,观察相关结果和不相关结果的分布,再选择分割点。3.6 加上历史记忆:让追问不再断片用户提问往往是一连串的。用户问“你们有什么套餐?”,接着会问“第一个多少钱?”如果没有历史,第二个问题就是一串无意义的字符,检索效果很差。做法是在进入 RAG 链路之前,先把最近几轮对话拼进 prompt:history = [] def ask(question): messages = [] for item in history[-6:]: messages.append(("human", item["human"])) messages.append(("ai", item["ai"])) messages.append(("human", question)) prompt_cur = ChatPromptTemplate.from_messages(messages) chain = prompt_cur | llm | StrOutputParser() answer = chain.invoke({}) history.append({"human": question, "ai": answer}) return answer注意这里没有直接在 prompt 里用变量,而是把历史消息作为多轮对话传入,模型能区分哪句是谁说的。检索部分也可以额外加一层:把“第一个”这类代词改写为原始问题里的实体,这是进阶优化,可以先不做。4. 常见报错与排查技巧实录4.1 高频报错速查表我把实际项目里遇到的高频问题整理成了表格,方便直接对照。报错信息常见原因处理方式ModuleNotFoundError: No module named 'langchain_openai'包版本拆分,旧写法失效安装对应分包:pip install langchain-openaiImportError: cannot import name 'LLMChain' from 'langchain'代码基于 0.1 老版本改用 LCEL 管道写法,或升级迁移OutputParserException: Failed to parse模型返回内容不是合法 JSON换结构化输出,解析失败后重试This model's maximum context length is ...输入超出上下文限制减小 chunk、缩短历史、用摘要压缩IndexError: Faiss with no index loaded索引文件不存在或路径不对检查是否执行过save_local,路径是否正确InvalidPromptError: Missing input variablesprompt 模板变量没有传全打印模板的输入变量列表,逐一核对版本类报错在 LangChain 里占比最高。如果看到某段旧代码一堆 import 报错,不要逐个硬改,直接去官方升级指南搜索改名后的类,效率最高。4.2 中文切分乱码与语义割裂我在项目里第一次使用默认的RecursiveCharacterTextSplitter,切分结果惨不忍睹。默认分隔符列表是["\n\n", "\n", " ", ""],对英文友好,但中文没有空格,最终会退化到按单个字符切割,chunk 边界经常落在一个完整句子的中间,导致检索时语义被拦腰截断。修正方案是显式加入中文断句标点:splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=50, separators=["\n\n", "\n", "。", "!", "?", ";", " ", ""], )RecursiveCharacterTextSplitter会按顺序尝试分隔符,先按段落分,再按句子分,这样切出来的块语义通常更完整。切完以后我强烈建议打印几个 chunk 人工看一眼,这一步花不了两分钟,但能省掉后面大量检索调试时间。4.3 空检索、弱检索:把召回当成一个漏斗如果检索结果为空,或者检索到的内容和问题完全无关,我会按下面顺序排查:文档是否真的加载进来了。打印len(docs),很多空检索其实是读取文件失败,内容为空。切分后的片段是否太碎或太脏。看几个 sample 确认。Embedding 是否正常工作。可以用一小段文字先向量化,再计算它和自身的相似度,如果分数异常,说明模型加载有问题。查询本身是否太模糊。比如用户只输入“你好”,向量检索自然找不到稳定结果。阈值是否设得太高。把分数全部打印出来看分布,再决定分割点。一个很实用的技巧是查询改写。对模糊问题做一次扩展,再分别检索。例如“怎么退款”可以扩展成“退款流程”“退款申请”“退款规则”“取消订单后怎么退款”四个查询,分别检索后合并结果,再让模型综合回答。实现不复杂,但对弱检索的提升非常明显。4.4 输出格式不稳定:用结构化输出兜底模型输出经常不够规矩,让它输出 JSON,它可能在前面加一句解释,导致解析失败。第一次遇到时,我的第一反应是修改提示词,后来发现治标不治本,因为模型状态不可控。更可靠的方案是解析失败时让模型自己修正。伪代码如下:from langchain_core.output_parsers import JsonOutputParser from langchain_core.runnables import RunnableLambda parser = JsonOutputParser() def safe_parse(text): try: return parser.parse(text) except Exception as e: # 把错误信息拼回去,让模型重新生成合法 JSON fix_prompt = f"你上次的输出不是合法 JSON。错误信息:{e}\n请重新输出,只输出 JSON,不要加任何解释。原始内容:{text}" fixed = llm.invoke(fix_prompt) return parser.parse(fixed.content) chain = prompt | llm | RunnableLambda(safe_parse)这类重试机制价值很高,能在代码层面兜住模型的不确定性。代价是多一次模型调用,但换来的是链路整体可靠性,完全值得。5. 我的学习路线与几个反共识的看法5.1 先别急着学 Agent很多初学者一上来就想做自主 Agent,结果陷入循环调用、token 烧完、行为不可控的泥潭。我的建议是先把 Chain 用熟,再考虑 Agent。Agent 的本质是让模型决定下一步调用什么工具,这依赖模型对工具的推理能力。模型不够强时,所谓 Agent 就是灾难:它会在工具之间来回横跳,迟迟不结束任务,还会调用无关工具浪费预算。而 Chain 是确定性的,输入什么就走什么流程,至少可以预期。RAG 是比 Chain 再高一级的复杂度,但它是确定可调优的。把 RAG 做好之后,再学 Agent 会轻松很多,因为你已经理解模型输出的随机性、工具调用的开销、提示词对决策的影响,面对 Agent 失控时也更容易定位问题。5.2 推荐的五阶段学习路径第一阶段:用接口直接调用大模型,理解 temperature、max_tokens、流式输出这些基础概念。不要跳过,因为框架可能会掩盖这些细节,但工程里一定会遇到。第二阶段:用ChatPromptTemplate加输出解析器做一个简单小工具,比如关键词提取器或文本分类器。目标是理解模板、模型、解析三段式结构。第三阶段:做一个完整的 RAG 项目,包括文档清洗、切分、向量化、检索、生成、评测。这是性价比最高的一步,所有核心技能都会用到。第四阶段:优化检索质量,引入记忆、查询改写、重排序、评分过滤。这些优化对效果的提升往往比换模型更明显。第五阶段:根据需求决定是否进入 LangGraph 或多 Agent 方向。到这一步再看官方文档,你不再是一头雾水,而是能看懂它解决的是什么问题了。5.3 几个反共识的看法第一,LangChain 本身不慢,也不笨重。慢的是我们不加选择地调用模型和向量库,同样的检索写了一圈重循环。链路的每一步最好都记录耗时,再决定优化哪里。第二,不必为了框架而框架。场景足够简单时,手工拼请求反而更清楚。我不会强行把一个小脚本套进 LangChain,只有出现多组件协作时才用它。第三,永远把可观测性放在第一位。我在调试 RAG 时最常用的工具不是断点,而是埋头打印每一步的输入输出:prompt 最后长什么样、retriever 召回了哪些文档、分数是多少。很多问题一打印就水落石出。第四,不要迷信“一键 RAG”。真实项目中,文档清洗、切分、召回评测占掉七成工作量,LangChain 只把剩下三成的胶水活变得干净。如果你发现效果不好,先检查数据和检索,别第一时间怀疑框架。我现在的习惯是,把每个环节都做成可以单独测试的小模块:文档加载单独测,切分单独看,检索单独打印分数,最后才拼成完整链路。这套方式,是跑了两个完整项目之后沉淀下来的。LangChain 学习笔记可以写得很长,但真正有用的,往往就是这些从报错和调试里逼出来的经验。