
这篇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( modeldemo-chat, base_urlhttps://your-endpoint.example.com/v1, api_keyyour-key, temperature0.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_size500, chunk_overlap50, separators[\n\n, \n, 。, , , , , ], ) chunks splitter.split_documents(docs)chunk_size500意味着每个块大约 500 个字符chunk_overlap50让相邻块有 50 字符重叠。overlap 的作用是避免关键句恰好被拦腰截断导致上下文丢失。切太细单块语义不完整切太粗块内噪声多检索命中率下降。500 到 800 是我在文档问答项目里常用的区间具体要看文档类型。这里特别想强调中文场景。默认的 separator 列表对英文友好但中文没有空格默认方案会把中文句子切得比较碎。我在 4.2 节会展开讲这个坑。2.5 检索器、向量库与输出解析把结果变得可预期Retriever的职责是给一个查询返回若干相关 Document。最常见的是把向量库包装成检索器。retriever db.as_retriever( search_typesimilarity, search_kwargs{k: 5}, )k表示返回多少条结果。为什么是 5 而不是 20一方面受模型上下文长度限制另一方面结果越多干扰信息也越多。但在后续章节我会推荐一个“先召回更多、再二次筛选”的进阶做法。输出解析器也是容易被忽略的组件。模型返回的是纯文本但业务往往需要结构化的数据。StrOutputParser直接取文本内容JsonOutputParser要求模型输出 JSONPydanticOutputParser可以指定数据结构和字段说明解析失败时还会报错提示。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_objectAnswer)输出解析器让模型输出从“靠运气”变成“可校验”。在需要对接后台系统的场景里这一步几乎是刚需。3. 实操记录从零搭一个本地知识库问答系统RAG现在进入正题。我用一个模拟项目X来说明完整流程项目里有一批产品说明文档目标是让用户用自然语言提问模型从文档里找答案而不是凭训练数据瞎编。3.1 数据准备从原始文档到干净文本这一步最不性感但收益最高。我遇到的第一个问题不是代码而是文档本身。有的文本从 PDF 抽取后带乱码有的表格被转成了断行文本有的文档开头带一堆没用的页眉页脚。我先把所有文档统一转成纯文本编码统一为 UTF-8然后做一轮清洗去掉多余的空白行、去掉页眉页脚、把表格描述成“字段名是 xxx值是 xxx”的句子。清洗后的质量会直接影响切分和检索效果。加载文本用TextLoaderfrom langchain_community.document_loaders import TextLoader loader TextLoader(./kb/产品说明.txt, encodingutf-8) docs loader.load()如果一个目录下有很多文件可以用DirectoryLoader批量加载。注意指定编码中文环境下默认编码经常踩坑与其猜测不如显式写清楚。3.2 切分参数chunk_size 与 overlap 的权衡切分是把长文档切成语义相对独立的小块。我在项目里用的是from langchain_text_splitters import RecursiveCharacterTextSplitter splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap50, 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_namebge-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_deserializationTrue, )加载时这个参数是新版本要求显式确认的主要防止反序列化风险。自己本机的索引可以放心打开但如果是外部传入的索引文件就要谨慎对待。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( modeldemo-chat, base_urlhttps://your-endpoint.example.com/v1, api_keyyour-key, temperature0, ) 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(怎么申请退款, k20) 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 链路之前先把最近几轮对话拼进 prompthistory [] 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 models 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_size500, chunk_overlap50, 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 学习笔记可以写得很长但真正有用的往往就是这些从报错和调试里逼出来的经验。