很多人学LangChain有个共同的困惑:文档翻了很多遍,Runnable、Chain、Agent、Memory这些名词都能背出来,但真要独立做一个项目,还是不知道从哪下手。我一开始也是这样——把核心模块的官方教程过了一遍,自认为“精通”了,结果在第一个真实项目里被Model I/O、Retriever、Memory之间的协作关系狠狠教育了一顿。这篇文章不打算复述官方文档,而是想按“这些模块到底在解决什么问题、互相之间怎么配合、真实项目里怎么选型”这个角度,把LangChain核心模块重新拆一遍。适合已经知道LangChain是什么、但想在项目里真正用好的读者。
1. 模块全景:先理解模块之间的协作,再谈精通
1.1 LangChain不是工具集合,而是一条主链路的六个环节
官方文档把LangChain分成Models、Prompts、Chains、Memory、Indexes/Retrievers、Agents这几个模块。很多初学者把它们当成一张平级清单,逐个去学,学完就散架了。实际上,这些模块全都是围绕同一条主链路设计的:
用户输入 → 组装Prompt → 调用模型 → 解析输出 → 写入/读取Memory → 决定下一步(继续走Chain还是交给Agent决策)
我建议把每个模块放到这条链路上理解,而不是孤立地看。下面这张表是我自己整理的一个速记框架,推荐收藏:
| 模块 | 解决的核心问题 | 关键抽象 | 在链路中的位置 |
|---|---|---|---|
| Models | 如何统一调用不同模型 | ChatModel / EmbeddingModel | 推理中心 |
| Prompts | 如何把输入变成模型友好格式 | PromptTemplate / Messages | 输入侧 |
| OutputParser | 如何把模型文本变回结构化数据 | BaseOutputParser | 输出侧 |
| Chains | 如何编排确定性步骤 | RunnableSequence / RunnableBranch | 流程骨架 |
| Memory | 如何让对话有状态 | BufferMemory / SummaryMemory | 状态层 |
| Indexes/Retrievers | 如何给模型补充外部知识 | Loader / Splitter / VectorStore | 知识层 |
| Agents | 如何让模型自主决策调用工具 | AgentExecutor / Tool | 决策层 |
这里最需要先建立的一个认知是:Chain和Agent的区别是结构性的,不是用法上的区别。 Chain是一条预先确定的有向图,每一步是固定的;Agent是一条带循环的决策链路,每一步由模型根据当前上下文现决定。也就是说,Chain负责“确定的流程”,Agent负责任“不确定时的决策”。项目里最常见的错误,就是把能用Chain解决的问题硬上Agent,结果自己给自己引入了不确定性。
1.2 Runnable统一协议:理解新版LangChain的钥匙
如果你看过LangChain的新版本代码,会发现几乎所有对象都实现了Runnable接口:PromptTemplate可以invoke,Model可以invoke,OutputParser可以invoke,Retriever可以invoke,甚至Agent也可以invoke。这意味着它们可以像管道一样被组合:
python复制chain = prompt | model | parser
result = chain.invoke({"question": "..."})
这个管道写法不是语法糖,背后是统一的Runnable协议:invoke(单次执行)、batch(批量执行)、stream(流式执行)。理解这三点,比记多少个具体类的名字都重要。
我在迁移旧项目时吃过亏。旧版本里大量使用LLMChain、load_qa_chain这类高层封装,新版本里这些封装依然存在,但很多时候直接用RunnableSequence反而更灵活。高层封装的问题是中间结果被隐藏,一旦链路出错,你只能看到输入和输出,中间变量全在黑盒里。所以我现在的习惯是:能拆成管道写法就尽量用管道,只有在需要复用一段复杂流程时才封装成自定义Chain。
1.3 我的模块学习顺序:按确定程度递增
官方文档的学习顺序是概念顺序,但实际项目里的学习顺序应该是“从确定到不确定”:
- 先学Models和Prompts,因为这两个模块行为最确定,prompt写好、模型调好,输出基本可预期。
- 再学Chains,因为Chain是确定性的编排,调试起来逻辑清晰。
- 然后学Memory和Retriever,因为引入了状态和外部数据,复杂度开始上升。
- 最后学Agents,因为Agents是所有模块中不确定性最高的,需要你已经有足够的调试经验再上。
这个顺序能让你每一步的复杂度都是可控的,不会一上来就被Agent那种“时好时坏”的表现劝退。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Models与Prompts:模型层和提示词层的真实工程细节
2.1 三类模型的选型,错了后面全白搭
LangChain里的Models大体分三类:Chat Models、LLMs、Embedding Models。很多人觉得这只是接口不同,实际上选型错误会直接传导到下游。
Chat Models接收的是Message列表,返回的也是Message,天然支持system、user、assistant三种角色,适合多轮对话和复杂任务。LLMs接收纯字符串,返回纯字符串,结构简单但缺少角色概念。Embedding Models则完全不同——它不负责生成文本,只负责把文本变成向量,用于后续检索。
工程上我的第一建议是:能用Chat Models就不用LLMs。 哪怕你的场景只是单轮问答,Chat Models的角色消息结构也会让PromptTemplate的可控性高一个档次。多轮对话场景里,system消息放规则、user消息放用户输入、assistant消息放历史回复,这个结构是LLMs那种纯字符串接口很难优雅表达的。
Embedding Models的选型则要单独看三个指标:向量维度、最大输入长度、中文/领域文本效果。同一个文档集合,换一个embedding模型,检索召回效果可能差10个百分点以上。所以不要把embedding选型当作一个“后面再优化”的问题,最好在项目一开始就跑一批测试文档对比效果。
2.2 PromptTemplate的细节:变量命中与历史消息占位
PromptTemplate最基础的功能是模板渲染,但实际项目里有两个细节很值得注意。
第一个是变量缺失的快速报错。PromptTemplate在组装时会检查变量,如果你传入的字典缺了模板里需要的字段,invoke的时候会立刻报错,而不是让模型收到一句残缺的prompt。这个“fail fast”的机制比手动拼字符串强太多,强烈建议所有prompt都走Template,不要图省事直接用f-string。
第二个是MessagePlaceholder。多轮对话场景里,你可能需要在固定的system规则和最新的用户输入之间插入一整段历史消息。手动拼字符串会把历史消息的格式搞乱,而MessagePlaceholder专门解决这个问题:
python复制from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
prompt = ChatPromptTemplate.from_messages([
("system", "你是一个数据助手,回答要简洁,不要编造数据。"),
MessagesPlaceholder("history"),
("human", "{question}")
])
这样history可以是任意长度的消息列表,模板会自动展开成多条消息。这个能力在Memory模块搭配使用时非常关键,我后面讲Memory会再提到。
2.3 输出解析器:模型与应用之间的“翻译层”
模型返回的是自然语言文本,但你的应用可能需要JSON、需要Python对象、需要枚举值。输出解析器就是干这个翻译工作的。
新手最容易踩的坑是:用PydanticOutputParser定义了极其严格的JSON schema,然后模型偶尔返回一段“好的,以下是您需要的JSON:”这种带解释的文本,解析直接失败。这不是模型笨,而是你给模型的约束不够。
我现在的经验是三层防护:
- 在prompt里给出一个具体的、可参考的JSON示例,而不是只给schema定义。示例和schema配合,成功率会高很多。
- 优先使用模型原生的结构化输出能力,也就是
with_structured_output这类接口。它直接让模型侧输出结构化内容,比事后解析稳定得多。 - 解析失败后加一个修复重试:把解析错误信息拼到下一轮prompt里,让模型自己修正格式。事实上,这个“让模型改错”的流程,比反复调prompt的性价比更高。
输出解析器看起来是个小工具,但它决定了你的下游逻辑能不能稳定拿到结构化的数据。这一步不稳,后面所有链都会跟着崩。
3. Chains组装:从线性管道到路由分支,设计决策的分水岭
3.1 Runnable管道语法:多数场景已经够用
在新版本LangChain里,prompt | model | parser三段式管道已经能覆盖很大一部分场景。它本质上是把三个Runnable对象按顺序组合成一个新的Runnable。调试方式也很直接:你可以单独跑prompt看渲染结果,单独跑model看原始输出,再单独跑parser看解析结果。
那什么时候还需要链式封装呢?我的判断标准是:当一段管道组合被复用了三次以上,或者这段组合有比较独立的业务含义时,才值得封装成一个函数或自定义Chain。否则,过度封装反而让代码变得像洋葱,排查问题时需要层层剥开。
3.2 SequentialChain与并行流的token消耗差异
在需要多步骤串联的场景(比如先总结文档,再根据总结写邮件),有人会倾向于把所有步骤塞进一个长prompt里让模型一次完成,但这会让单次调用的token急剧膨胀,且模型对超长复杂指令的遵从度会下降。更合理的做法是拆成多步链。
但拆多步链时,有一个非常隐蔽的坑:每一步模型的输出都会被保留在上下文里传递下去,如果你用的是简单拼接多轮结果的写法,中间产物可能被重复计入下一次调用的token。用RunnableParallel并行跑多个独立任务时尤其明显——并行任务各自输出大段文本,然后全部拼进最后一步的prompt。
我的做法是:每个步骤只显式传递下一步需要的字段。多出来的中间结果不往prompt里塞,除非后续步骤确实需要引用。这样做的收益是token费用直接下降,prompt也更干净,模型不会被无关信息干扰。
3.3 路由分支:何时需要RouterChain
业务场景里常出现一条输入需要走不同处理逻辑的情况:用户问“订单状态”走订单查询链路,问“退换货政策”走知识库检索链路,问“转人工”走特殊处理。这种场景最适合路由分支。
旧版本的RouterChain是根据输入做规则匹配再分流。新版本更推荐RunnableBranch,或者干脆把路由决策交给模型。我的实际体验是:如果路由规则非常明确(比如关键词命中),用可解释的规则判断更可靠,成本也低;如果路由规则模糊,才考虑让模型来做“意图路由”。
说个反向建议:不要动不动就上路由。如果你的业务只有一种处理逻辑,加路由就是纯加复杂度。路由的价值只在于“分支数量足够多、每个分支的处理逻辑足够不同”时才体现出来。
3.4 数据传递中最高频的字段命名坑
链式调用里最常见的报错是“field X not found in input”。根本原因通常是:上一个节点的输出字段名,和下一个节点的变量名不一致。比如模型输出是{ "answer": "..." },你下一步的prompt模板里却写了{result}。
解决思路很简单:给每个Runnable的输出字段显式重命名,或者在关键节点用RunnablePassthrough做字段透传。不要依赖“碰巧名字一样”的隐式匹配。这种隐式匹配在单个链里看起来没问题,一但链长了,某个中间节点改了输出字段名,后面所有依赖它的节点都会静默出错。
4. Memory模块:对话历史的保存、裁剪与污染防治
4.1 四种Memory的适用边界
Memory模块解决的是“让模型记住之前说过什么”。但“记住”是有代价的——历史消息要占token,而token是钱。
LangChain提供了多种Memory,我按适用场景列个表:
| Memory类型 | 机制 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| ConversationBufferMemory | 全量保存历史 | 信息无损 | token消耗大 | 短会话 |
| BufferWindowMemory | 只保留最近N轮 | 成本可控 | 早期信息丢失 | 中长会话 |
| SummaryMemory | 定期总结历史 | 省token | 总结会丢细节 | 长会话 |
| VectorStoreRetrieverMemory | 检索历史片段 | 可处理超长历史 | 有检索失败风险 | 超长会话 |
4.2 长期记忆与短期记忆必须分离
我在一个客服项目里吃过一次大亏:把所有用户画像、业务规则、历史对话全部塞进Memory,结果prompt被撑到接近模型输入上限,而且模型开始“遗忘”最近的指令——因为早期系统规则被大量历史消息推到注意力边缘了。
现在的做法是分三层:
- 不变规则放system prompt,比如回复风格、安全边界、业务红线。
- 近期对话放Memory,控制窗口大小。
- 用户画像和业务数据放外部存储(如向量库或普通数据库),只在需要时检索出来插入当前prompt。
这套分层本质上是在和“上下文窗口有限”这个现实做妥协。你不可能把用户的所有信息都塞进一次调用,所以必须确定优先级。
4.3 多轮对话中的一致性问题:一个真实的漏水案例
有一个场景让我印象很深:用户先问“我的订单12345什么时候发货”,模型回答“预计3天后”。用户接着问“那运费多少钱”,模型回答时居然忘了订单号,开始泛泛而谈运费规则。
排查下来发现,问题不在模型本身,而在Memory里存的历史消息格式不统一。当时的实现把部分assistant回复存成了“你回答:xxx”,把另一部分存成了“助手说:xxx”,模型在后续轮次里对这些历史消息的角色感知出现错乱,自然无法正确引用之前提到的关键信息。
修复方式有两个:一是统一历史消息的角色前缀,构建Memory上下文时严格区分user和assistant;二是用ChatPromptTemplate配合MessagesPlaceholder,让历史消息以结构化角色消息的形式传入模型,而不是靠文本拼凑。前者治标,后者才能真正保持角色语义。
5. RAG链路中的Indexes与Retriever:难的不是向量库,是切分与召回
5.1 文档加载之后,切分才是真正的分水岭
LangChain的Document Loader非常丰富,支持各种格式,但加载只是最基础的一步。真正决定RAG效果上限的,是文本切分策略。
很多人直接用固定长度切分,比如每500个字符一块。这样做的一个典型问题是:一句话或一个表格在中间被截断了,语义完整性被破坏。检索时如果召回了一个“半句话”的块,模型只能瞎猜。
我的经验是分层切分:
- 优先按文档结构切——Markdown标题、HTML标题、段落边界,这些都是天然的语义边界。
- 结构切完还有长段落,再用递归切分(RecursiveCharacterTextSplitter),按分隔符优先级从高到低逐级尝试,保证每个块尽量在语义完整处断开。
- 设置适当的overlap(重叠),把上一块的最后几十个字和下一块的开头重叠起来,避免关键信息恰好落在切分边界上。
块大小也不是越大越好。我之前做过一组对比测试,把块从512字符调到1024字符后,检索命中率小幅提升,但生成阶段输入的token明显变多,响应时间变长,最终用户体感反而变差。块大小和检索质量之间是权衡,不是单调增长的关系。
5.2 VectorStore选型与相似度检索的几个细节
向量存储的可选方案很多,但选型不用只看“性能排行”。实际项目里更该关注三个工程指标:
- 元数据过滤:能不能按文档来源、业务ID、时间范围过滤后再检索。
- 增量更新:新增一批文档时,是全量重建索引还是可以增量写入。
- 部署运维成本:自建还是托管,直接影响后续维护工作量。
检索相关的参数也值得单独说。top_k设多少?这个没有标准答案,但经验值是:小知识库场景3到5个块就够,大知识库场景建议先top_k=10再重排。直接top_k=20把一堆低相关块塞进prompt,反而会把模型答案带偏。
5.3 检索后处理:被很多人忽略的“最后一公里”
很多RAG项目跑到“能检索能生成”就停了,但检索之后的处理才是让答案质量上一个台阶的关键:
- 给检索结果加元数据。把来源文档标题、页码或章节号一起拼进prompt,让模型能引用出处,显著降低“一本正经胡说八道”的概率。
- 设置相关性阈值。低于阈值的块直接丢弃,不要让模型基于不相关内容硬凑答案。
- 重排(Rerank)。当检索出的候选集合较大时,用一个重排模型对候选集合做精细化排序,效果往往比直接依赖向量相似度好很多。
这三个技巧不需要引入复杂架构,只需要在Retriever输出和prompt组装之间加一层处理,投入产出比非常高。
6. Agents与Tools:把控制权交给模型之前要做的四件事
6.1 Agent的决策循环机制
Agent的核心是一个循环:模型观察当前状态 → 决定调用某个工具 → 拿到工具返回结果 → 再次观察 → 继续决策,直到认为任务完成或达到迭代上限。
这个过程看起来自由,实际调试成本很高。模型可能选错工具、可能用错误的参数调用工具、可能陷入死循环反复做同一件事。所以工程上必须给Agent加上“安全带”:
- 设置
max_iterations,防止循环失控。 - 每个Tool的描述要写清楚“这个工具做什么、什么场景才应该用、参数应该填什么”。描述写得太含糊,模型就会在多个相似工具之间随机选择。
- 工具调用涉及敏感操作(删除、修改、发消息)时,必须有外部人工确认环节,不把最终控制权完全交给模型。
6.2 Tool设计与注册的工程细节
工具设计是Agent项目里最容易被低估的部分。下面几个坑都是我踩过的:
- 工具参数名和描述不一致。模型在生成工具调用时会参考参数名做语义推断,参数名起得模糊(比如
data、info),模型大概率会传错含义。 - 工具返回值结构不稳定。Agent会解析工具的返回内容来决定下一步,如果返回值时而是JSON、时而是纯文本,模型很容易在下一步决策时产生混乱。
- 工具数量过多但缺少区分度。Agent一次要面临太多候选工具时,选择准确率明显下降。宁可先给少量核心工具,跑通后再逐步增加。
6.3 能用Chain解决的,就不要上Agent
这是我做项目最想说的一句话。Agent的“自主决策”看起来很强大,但它的副产品是不确定性。同一个问题,这次走工具A,下次可能走工具B,输出结果难以稳定复现。
所以我的判断标准是:如果业务流程是确定的,优先用Chain;只有需要动态选择路径、处理开放性问题时,才考虑Agent。混合架构往往是最佳方案——用Chain把确定性流程做好,把Agent限制在一个很小的“决策点”上,而不是让Agent接管整个流程。
7. 三个真实项目中的排错实录
7.1 案例一:检索召回为空,模型开始乱编
最早做的一个RAG项目,用户问“某产品的退换货期限是多久”,模型答了一长串通用退换货规则,但细节全是错的。
排查链路被我记录如下:
- 先看文本切分结果——发现文档加载时把表格直接转成了纯文本,表格的行列结构完全丢失,切分出来的块里根本不存在“退换货期限”的完整语义。
- 再看向量化——检查embedding调用是否有失败或重试,确认向量写入正常。
- 再看检索参数——把top_k从3调大后,依然召不回正确内容。
- 最后回到文档加载——定位到问题根源是表格被拍平成文本,关键信息被打散。
修复方式是改用支持表格结构的文档解析方式,把表格按行列结构保留,并给表格块单独设置更高的检索权重。从那以后,这个项目的RAG效果才算稳定下来。
7.2 案例二:多轮对话中,模型突然“失忆”
另一个客服项目,模型在前几轮还能准确引用用户提供的订单号和金额,到第五轮之后开始出现数字错误。
排查链路:
- 先查Memory的窗口设置——发现用的是无限缓冲Memory,历史消息确实都在。
- 再查模板组装——发现Memory里的历史消息没有带角色前缀,模型分不清哪些是用户说的、哪些是它自己说的。
- 最后查切分逻辑——Memory内容是多条消息拼接的,拼接格式不统一导致模型对消息来源产生误判。
修复方式是统一历史消息结构,改用MessagesPlaceholder把History作为结构化消息列表传入,并限制窗口为最近10轮。修复后模型在多轮对话中的信息引用准确率明显上升。
7.3 案例三:结构化输出反复解析失败
第三个项目需要模型从合同文本中抽取关键字段,输出JSON。上线前测试阶段,每十几条文本就出现一次解析失败。
排查链路:
- 先看失败样本——发现部分模型的输出里带了“以下是解析结果”这类前缀文本。
- 再看prompt约束——PydanticOutputParser只给了JSON Schema,没给示例,模型对字段含义理解不足。
- 最后看重试机制——当时没有重试逻辑,失败就直接报错。
修复方式是在prompt中加入一个解析好的示例,同时增加了一层校验重试:解析失败时,把错误信息回传给模型让它修正。这个改动让解析失败率降到了几乎可以忽略的程度。
写在最后:我对“精通”的重新定义
这些坑踩下来,我对“LangChain精通”有了和刚开始完全不同的理解。精通不是背得出几个类的名字,而是清楚每条数据是怎么流经各个模块的、每个模块在什么情况下会引入什么风险、出了问题该从哪一层开始排查。
如果让我给一个做LangChain项目的朋友提建议,我会说:先跑通一个最简单的链,然后一个模块一个模块地叠加,每加一个模块就做一次回归测试,确认新模块没有破坏前面模块的行为。这个习惯帮我省下的调试时间,比任何调优技巧都多。另外,保持对“数据流向”的敏感,比追逐新出的类和方法更重要——版本会更新,API会变更,但模块协作的底层逻辑不会变。
