最近把团队内部的 RAG 知识库从 LangChain 链式调用整体迁到了 LangGraph整个过程比预想的麻烦不少但跑通之后收益非常明显。这篇文章不打算做两个框架的全面评测也不准备重复官方文档就围绕“RAG 知识库改造”这条主线讲讲我为什么迁、新的流程怎么设计、检索层动了哪些手术、以及踩过的几个值得记下来的坑。如果你正在纠结要不要把 LangChain 项目往 LangGraph 上搬或者想了解 LangGraph 在真实 RAG 场景里怎么用这篇应该能给你一些参考。1. 迁移的动机基础版 RAG 在真实业务里暴露的三个问题1.1 一切从“答非所问”和“无法介入”开始先说背景。我这边维护的是一个面向内部业务团队的知识库问答系统早期版本非常标准文档切块、向量化、存向量库用户提问后走 LangChain 的 RetrievalQA 链召回到相关片段后交给大模型生成回答。这套东西在小规模试用时表现还行demo 也很顺但一旦到了真实业务场景问题就藏不住了。最典型的一个场景是业务同事拿合同条款来问模型回答得头头是道看起来完整实际引用的是另一份相似文档里的片段。用户反馈“回答不对”之后我们没法在链式调用里做任何干预——检索错了就是错了生成错了就是错了整条链路像一个黑盒中间没有可以伸手进去调整的地方。第二个让我头疼的问题是多轮对话。原来的实现方式非常朴素每轮把历史对话直接拼到 prompt 里再进链路。表面上能用但两个实际问题甩不掉一是检索不准确用户说“那它的有效期到什么时候”检索器拿到的是原始文本而不是改写后的完整问题经常召回到一堆无关内容二是聊到第三四轮历史一长prompt 被撑得很大响应延迟和 token 成本都上去了。1.2 LangChain 链式结构的本质局限说到底LangChain 的 RetrievalQA 类本质上是一条单向的、无状态的链路加载检索器 → 把问题格式化 → 拼 prompt → 调模型 → 输出。代码大概长这样from langchain.chains import RetrievalQA qa RetrievalQA.from_chain_type( llmllm, retrievervectorstore.as_retriever(search_kwargs{k: 4}), ) result qa.invoke({query: question})这种设计在简单问答场景下足够直接但它默认了“问题一进来答案就应该一路顺滑地生成出来”。它没有中间状态的概念没有分支逻辑也设计不了循环。可真实业务里的问答根本不是一条直线经常是要不要改写问题召回来的片段够不够答案需不需要人工确认这些判断和动作都要求流程具备可暂停、可分支、可复用的能力。用链式调用硬做也不是完全不行办法是写一堆 if-else 在外面套壳把流程控制逻辑和业务逻辑缠在一起。我试过前期还好等需求增加到“检索结果相关性低时要重新检索”“回答前需要人工审核”“审核不通过要退回修改问题”的时候套壳代码就失控了改一个分支要牵连好几个地方测试成本直线上升。1.3 LangGraph 解决的是“流程可控”问题LangGraph 和 LangChain 最大的区别在于它把流程从“链”变成了“图”。核心概念不复杂就是三个东西状态、节点、边。状态是一个跨节点共享的数据对象节点是实际执行逻辑的函数边决定节点之间的流转方向还支持条件边也就是根据当前状态动态决定下一步走哪里。这个模型天然适合做 RAG 改造。检索、改写、生成、人工审核都可以拆成独立节点步骤之间通过共享状态传递数据。更重要的是图结构允许暂停执行——这也是 LangGraph 官方文档里强调的 human-in-the-loop 能力——流程走到某个节点时可以挂起等外部确认后再继续往下走。对 RAG 场景来说这意味着我可以把原来那条“一条路走到黑”的链改造成一个真正可控的流程问题先经过改写节点再走检索节点检索结果经过一个判断节点决定是直接生成还是重新检索生成前还可以插入一个人工审核节点。这些东西在 LangChain 里要写一坨自定义逻辑才能凑合在 LangGraph 里则是把流程画出来、把节点函数填上就完事了。2. 改造前的基线旧系统是怎么搭的迁移目标又是什么2.1 旧架构盘点动手之前我先把老系统的组成部分列了个清单免得迁移过程中漏掉功能点。旧系统大约分四块文档处理Markdown 和 PDF 两种格式按固定大小切块每块 500 字符重叠 50。向量检索Embedding 模型生成向量向量库用的 FAISS召回 TopK 设为 4。答案生成LangChain 的 RetrievalQA 链Prompt 模板里塞入检索片段和原始问题。会话管理用内存 List 存历史消息靠外部代码拼接到下一个问题里。这套东西的性能瓶颈和体验问题我在前面已经说了。除此之外还有一个隐性问题检索层和生成层耦合太紧。RetrievalQA 把检索结果直接交给 Prompt 模板我如果想对召回片段做重排、过滤、融合就只能在链外处理再用自定义逻辑把结果塞回去非常别扭。2.2 我给自己定的四条改造目标迁移不能为了换而换动手前我想清楚这次改造到底要解决什么问题。最后落到四条目标上检索结果可干预。至少在生成前系统能对召回内容做二次判断我可以插入节点做过滤或重排。多轮对话要有真正的状态管理。历史消息由框架统一维护并且检索前要做 query 改写让“它”“那个”这类指代词变成可检索的完整问题。流程可扩展。以后要加“回答前转人工审核”“审核不通过改问题重试”“回答后让用户反馈”之类的功能不应该伤筋动骨。检索准确率不下降最好有提升。这是底线如果改造后连原来的效果都不如那这次迁移就失去意义了。这四条目标后来成了我判断改造是否成功的验收标准。现在回头看它们确实帮我避免了很多不必要的争论——每次犹豫“这个功能要不要做”时对照目标一看就清楚了。3. LangGraph 重写 RAG 核心流程状态机思维取代链式调用3.1 状态对象一切共享信息的载体LangGraph 里最核心的设计就是状态。状态通俗说就是一个全局字典图里的每个节点都能读它、改它。我把改造后的 RAG 流程状态定义成下面这样from typing import TypedDict, List class RAGState(TypedDict): question: str # 用户原始问题 rewritten_question: str # 改写后的检索问题 chat_history: List[dict] # 会话历史 retrieved_docs: List[dict] # 召回文档 review_status: str # 人工审核状态none / pending / approved / rejected answer: str # 最终回答状态字段的取舍是有讲究的。一开始我恨不得把所有中间结果都塞进去比如“检索用的 embedding 向量”“完整文档全文”“prompt 模板内容”后来发现完全没必要。状态里只应该放节点之间需要传递的关键信息体积越大checkpoint 持久化的开销越高调试时看状态也越累。像完整文档全文这种只会在单个节点内用到的东西就该留在节点内部变量里不要扔进全局状态。3.2 节点设计把原来的一条大链拆成几个独立函数LangGraph 的节点就是普通 Python 函数输入是当前状态返回一个字典表示要更新的字段。我把原来的 RetrievalQA 一条大链拆成了三个节点from langgraph.graph import StateGraph, START, END def rewrite_query(state: RAGState) - dict: if not need_rewrite(state[question], state[chat_history]): return {rewritten_question: state[question]} rewritten llm_rewrite(state[question], state[chat_history]) return {rewritten_question: rewritten} def retrieve_docs(state: RAGState) - dict: docs retriever.invoke(state[rewritten_question]) return {retrieved_docs: docs} def generate_answer(state: RAGState) - dict: answer llm_generate(state[rewritten_question], state[retrieved_docs]) return {answer: answer} g StateGraph(RAGState) g.add_node(rewrite_query, rewrite_query) g.add_node(retrieve_docs, retrieve_docs) g.add_node(generate_answer, generate_answer) g.add_edge(START, rewrite_query) g.add_edge(rewrite_query, retrieve_docs) g.add_edge(retrieve_docs, generate_answer) g.add_edge(generate_answer, END) app g.compile()这段代码看着简单但它背后是一种完全不同的设计思路。链式调用里逻辑藏在框架内部用户能控制的就是 Prompt 和检索参数图结构里逻辑是你自己写进节点里的每个步骤的输入输出都清清楚楚。好处很明显哪里有问题就改哪个节点不影响其他部分。3.3 条件边与循环让流程“长脑子”LangGraph 真正让我觉得值回迁移成本的是条件边。条件边允许一个节点执行完后根据状态判断下一步走哪里而不是机械地走向固定节点。我在检索节点后面加了一个相关性判断def has_valid_docs(state: RAGState) - str: if not state[retrieved_docs]: return rewrite_query # 没召回到任何东西回去改写问题 if relevance_score(state[retrieved_docs]) 0.6: return rewrite_query # 召回质量太低重新来 return generate_answer g.add_conditional_edges( retrieve_docs, has_valid_docs, {rewrite_query: rewrite_query, generate_answer: generate_answer} )这个设计解决了一个在 LangChain 里很烦的问题当检索结果为空或者完全不相关时旧系统照样会把无意义的内容拼进 Prompt让模型硬着头皮编一个答案。现在有了条件边流程能自动回到改写节点换一种方式重新检索实在不行才进入生成阶段。这就是从“流程固定”到“流程可控”的本质变化——系统开始具备判断和纠错能力了。4. 检索层的手术切块、向量库、混合召回与 RRF 融合4.1 切块策略的调整固定大小切块该退休了老系统的切块方式非常粗暴按字符数硬切一块 500 字符重叠 50。这种方式在 RetriivalQA 时代还能跑但实际用下来问题很大——最典型的就是语义割裂一个完整表格被拦腰截断一个段落因为超长被拆成两半召回时经常只命中半截内容生成出的回答自然不完整。改造检索层时第一件事就是把切块策略换掉。我换成了按文档结构切块优先按 Markdown 标题、段落、句子这些自然边界去切实在没有自然边界才按字符数兜底。切块参数也从 500/50 调成了 400/80。块变小是因为 400 字符左右的片段语义更聚焦召回时命中率更高重叠从 50 提到 80是为了减少自然边界附近的上下文丢失同时避免把同一内容重复切进多个块导致召回重复。这里分享一个实测心得切块参数没有银弹跟你的文档类型强相关。如果你的知识库全是法律合同、操作手册这种具有强结构性的文档按结构切块收益很大如果是零散的公告、邮件、聊天记录那还是规规矩矩按字符切更稳。我踩过坑后总结出的方法是先拿 20 条真实问题做测试集分别用几组切块参数跑召回看 Top5 命中率用数据说话别拍脑袋。4.2 混合检索向量召回 关键词召回的融合策略纯向量检索的毛病也很典型对专有名词、编号、型号这类精确信息特别不友好。业务同事问“请解释合同第 3.2 条”Embedding 模型不一定能精确匹配到含“3.2”字样的片段但 BM25 这种关键词检索可以。所以我在检索层加了混合检索一路走向量召回一路走 BM25 关键词召回最后做融合。融合算法我选了 RRFReciprocal Rank Fusion公式是每个文档得分等于在所有检索结果中排名倒数的累加。RRF 的好处是不需要调权重而且对不同检索器的分数尺度不敏感。实际调用时我直接在 LangGraph 的retrieve_docs节点里同时调两路检索器然后做融合取 TopN。这个逻辑放在节点里非常自然因为检索不再是框架替你完成的神秘步骤而是你可以自由控制的普通函数。这次改造让我意识到LangGraph 对 RAG 的真正价值其实不在检索这一层而是为了后续的 agentic RAG 铺路——检索策略可以随时替换、扩展不用动流程主干。5. 两个关键流程的落地多轮对话改写与 human-in-the-loop 人工审核5.1 多轮对话问题改写节点让检索不再“断章取义”多轮对话在 LangGraph 里的实现方式比原来“拼历史进 Prompt”清晰得多。我的做法是在检索之前加一个改写节点专门处理指代问题。流程是这样的用户发来“它的有效期到什么时候”改写节点拿到原始问题结合最近几轮历史判断这句话里“它”指代的是什么然后生成一个独立、完整的检索问题比如“公司员工手册中关于年假有效期的规定是什么”。这个改写后的内容只用于检索不直接作为生成答案的输入避免回答被改写过程带偏。实现上要特别注意一点不是每一轮都需要改写。第一轮对话没有历史直接跳过。用户在连续追问同一个主题时前面问题已经写得很清楚了也没有必要改写。我总结了一个简单的规则只有当当前问题中存在指示代词或明显省略主语的情况时才触发改写。这样可以省一次 LLM 调用关键是不引入额外的“改写噪音”——我曾经见过改写节点把好好的问题改得面目全非反而导致检索效果变差。5.2 human-in-the-loop人工审核节点该插在哪里业务方最强烈的一个诉求是希望 AI 生成的关键回答在对外发出前必须经过人工确认。比如合同条款问答、财务政策问答不能模型生成什么就直接给用户看什么。在 LangChain 里做这件事非常痛苦因为链式调用没有“暂停”的概念。在 LangGraph 里这是官方明说的能力实现方式是通过interrupt()挂起图执行。我在generate_answer节点前插入了一个review_answer节点流程走到这里时先把检索到的文档片段和拟生成的答案草稿交给人审人点头后流程继续人摇头则流程回到检索节点重新处理。有一点务必注意interrupt()必须配合 checkpointer 使用否则没法定点恢复。checkpointer 你可以理解为图执行到哪一步的“存档机制”它把中间状态持久化下来才能做到停在哪、从哪续。本地调试我用的是MemorySaver一个简单的内存存储如果要多实例部署建议直接用基于 SQLite 或 Postgres 的持久化方案不然进程一重启所有挂起的流程全丢了。6. 迁移实测与踩坑记录数据对比、资源开销、三个值得记下的坑6.1 改造前后效果对比先说结论迁移到 LangGraph 后核心指标没有变差部分指标有明显提升。我整理了一组小范围测试数据样本是 50 条真实业务问题人工评估回答是否准确、是否引用正确知识来源指标改造前LangChain 链式改造后LangGraph 图式回答准确率人工抽检78%87%首次回答直接命中率62%74%多轮对话指代理解弱依赖暴力拼接好改写节点兜底检索结果可干预性无支持过滤、重排、人工审核平均响应耗时约 2.3 秒约 2.8 秒流程可扩展性差改动成本高好新增节点低成本接入响应耗时比原来增加了约 0.5 秒原因不难理解多了一个改写节点的判断条件边也要做相关性计算检索层从一路变成了两路加融合。这个延迟在可接受范围内换来的是检索准确率明显提升。如果你的场景对延迟极其敏感可以把改写节点做成“命中规则才触发”的模式大部分对话轮次可以直接跳过能省掉一次模型调用。6.2 踩坑记录三个值得写下来的问题坑一状态字段塞入过大的数据checkpoint 膨胀。第一次用 LangGraph 时我把检索回来的完整文档全文也放进了全局状态发现每次中断保存 checkpoint 都要序列化大量文本调试器打开状态慢得要命还容易触发内存问题。解决办法是只往状态里放文档 ID、标题、关键摘要、相关性分数这些轻量信息完整内容留在节点内部自行获取。坑二RRF 融合前必须按文档 ID 去重。我这里要特别提醒的是热词里也提到了 langchain 和 langchain4j 的默认 RRF 实现存在去重逻辑缺陷。实测确实如此如果不先去重同一文档的不同切块会反复出现在两路召回结果里RRF 融合时同一文档会累加出异常高分最后 TopN 里三四条都是同一篇文档的内容其他真正相关的文档反而被挤掉了。正确做法是在融合前先对文档 ID 做一次去重再算排名分。坑三interrupt()报错总以为是代码问题其实是没有配 checkpointer。这个坑我花了一晚上才排查明白报错提示语又不够明显不看官方文档根本想不到是持久化的问题。所以如果你用 LangGraph 做 human-in-the-loop第一件事就是先把 checkpointer 配好再写节点逻辑。建议从一开始就用 SQLite 这类文件型持久化别图省事用 MemorySaver免得后续又要改。还有一个不算坑、但容易忽略的点条件边返回的映射表键名必须和节点名完全一致否则图编译直接报错。这听起来简单但项目一大节点名改名或者映射表复制粘贴时很容易漏改。改造完成后我最大的感受是LangGraph 不是替代 LangChain而是把 RAG 从“一条链”提升到了“一张图”的层次。如果你只是做一个一次性问答 demoLangChain 完全够用没必要折腾但一旦你的知识库问答涉及多轮对话、需要人工确认、或者未来要考虑 agentic 方向LangGraph 带来的状态管理和流程控制能力确实值得花成本迁过来。最后给三个实操建议先从一个小流程试水别一上来就把所有节点铺开状态字段设计一定要克制只保留节点间必须传递的信息检索层改造和流程改造分开做一个一个验证出了问题也容易定位。