☰
LangChain与LangGraph核心概念对比:从链式编排到Agent状态机实战解析
2026/10/24 2:42:42 网站建设 项目流程

一开始我也被LangChain和LangGraph这两个名字搞得一头雾水。网上教程铺天盖地,但真正讲清楚“什么时候该用链、什么时候该用图”的少之又少。这篇学习记录不是官方文档的搬运,是我从零开始啃源码、跑示例、被坑绊倒再爬起来的过程沉淀。如果你也正在学LangChain和LangGraph,或者已经在项目里用但总觉得哪里不太对劲,这篇内容应该能帮你省下不少瞎摸索的时间。

我给自己定的学习目标很明确:不追求覆盖所有API,而是把核心抽象和设计动机吃透。所以这篇文章里你会看到大量“为什么这样设计”的分析,而不是单纯的API罗列。我会用实际案例来贯穿,从一条简单的链写到一张可运转的Agent图,把我踩过的坑和你提前剧透出来。

1. 入坑LangChain之前,先搞清楚它的定位

1.1 它到底解决了什么问题

要理解LangChain,得先回到那个最原始的烦恼:直接调用大模型API的时候,我们能做的事情其实非常有限。传一段文本进去,拿到一段文本出来,仅此而已。但要做一个真正能用的应用,你需要把模型调用、数据检索、Prompt拼装、输出解析、记忆管理等一堆环节串起来。

我第一次手动写的时候,代码长这样:先定义prompt模板,然后写一个函数去调模型API,解析返回结果,再把结果拼到下一轮对话里。逻辑不复杂,但一旦加路由、加条件分支、加多步工具调用,整个代码就变成了一团互相引用的面条。今天加一个回调参数,明天改一下返回格式,后天发现某个环节需要拆出来单独测,改一处崩三处。

LangChain真正解决的问题不是“调用模型”这一步,而是把应用拆成了可组合的积木。它提供了标准化的组件接口:模型封装、Prompt模板、输出解析器、记忆模块、检索器、工具调用。这种抽象一旦建立,你的业务代码就变成了积木的拼装方式,而不是每块积木内部的实现细节。你可以像搭积木一样替换模型、替换向量库、替换Prompt策略,而不用重写整个链路。

需要提醒的是,LangChain并不是模型本身,它只是一个编排层。它不会提升模型智商,也不会替你debug逻辑错误。它的价值在于让结构清晰、让复用方便、让生态里各种第三方能力(比如向量数据库、工具API、监控服务)能以统一方式接入。明确这一点很重要,否则你会对它产生不切实际的期望。

1.2 为什么要再学一个LangGraph

LangChain早期版本的核心抽象是Chain(链)。链的思想很好理解:一个流程是一串步骤,按顺序执行。但真实世界的业务很少是纯粹的线性流程。你做一个问答助手,用户可能问天气也可能是查数据库,不同意图要走的步骤完全不一样。你做一个内容生成工具,第一步生成完了要检查质量,质量不合格要回炉重造,这不是一条线能画完的。

我第一次尝试用Chain来实现“如果首轮回答置信度低于阈值就重新检索再回答”这个逻辑时,被迫发明了一些丑陋的状态标记参数,比如在链的输入里加一个retry_count字段,然后在外层写循环去调用整条链。链的第N次调用根本无法访问前N-1次中间产生的状态,所有信息都得从外部手动喂进去。这个设计在简单场景下凑合用,复杂一点就绷不住了。

LangGraph就是冲这个问题来的。它把工作流建模成一张有向图:节点是执行单元,边是流转逻辑,维护一个共享的全局状态对象。节点可以访问状态、修改状态,也可以根据状态决定下一步走哪条边。更关键的是,它支持循环、分支、并行、人工介入和断点续跑。

我自己学下来的感受:LangGraph不是LangChain的升级替代品,而是LangChain生态里负责“复杂编排”的那一层。如果需求是一个固定顺序的问答流程,用LangGraph反而是杀鸡用牛刀。只有当你需要带着状态在步骤之间来回走、需要反复调用工具、需要人能中途干预的时候,LangGraph的优势才真正体现出来。

2. 我在LangChain阶段建立的核心抽象

2.1 模型、提示词、输出解析的三角组合

LangChain最基础的用法就是把“给模型的输入”拆成PromptTemplate、Model和OutputParser三个部分。我一开始觉得这种拆分纯粹是多此一举,直到我发现不同模型对同样指令的遵循程度差异极大。

我用一个模拟项目来演示这段学习记录里的完整思考过程。假设要做一个小助手,把用户输入的一句非结构化的会议记录整理成结构化数据。纯手工写是这样的:

prompt = f""" 请从下面的文本中提取会议信息,输出JSON格式,包含字段:date, participants, action_items。 文本:{raw_text} """ response = openai_client.chat.completions.create( model="some-model", messages=[{"role": "user", "content": prompt}] ) # 接下来手动解析response...

这段代码看起来能用,但问题很快出现:每次都要手写prompt模板,返回结果可能是纯文本夹杂JSON,可能是带markdown代码块包裹,各种格式不统一,解析代码越写越长。用LangChain的三角组合是这样:

from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import StrOutputParser from langchain_openai import ChatOpenAI prompt = ChatPromptTemplate.from_template( "请从下面的文本中提取{{date, participants, action_items}}三字段JSON。\n文本:{raw_text}" ) model = ChatOpenAI(model="some-model", temperature=0) parser = JsonOutputParser(pydantic_object=MeetingInfo) chain = prompt | model | parser result = chain.invoke({"raw_text": raw_text})

这套组合的精髓在于,每一段都可以单独替换。今天想换一个更听话的模型,只动model那行;明天想把输出结构从JSON改成Pydantic对象,只动parser。这套面向接口而非面向实现的思维,是LangChain使用方式里最核心的一课。

我实际跑下来发现,temperature参数的设置对结构化输出影响极大。做提取任务时,temperature如果不设成0或接近0,模型会在字段名上自由发挥,例如把字段action_items写成actions,解析器直接报错。这个坑我在早期反复踩,后来养成了习惯:凡是需要被程序消费的输出,一律temperature=0。

2.2 真正拉开差距的LCEL链式语法

LangChain后面引入了LCEL(LangChain Expression Language),也就是用管道符|来组合组件。我第一次看到prompt | model | parser这种写法时心想这不就是函数式编程的管道嘛,有什么稀奇的?

真正深入之后我发现LCEL不只是语法糖。它定义的Runnable协议让每个组件都支持同样的调用方式:invoke(单次调用)、batch(批量调用)、stream(流式返回)、ainvoke(异步调用)。这意味着你组装的任何一条链,天生就拥有流式输出和异步能力。

这一点在实际开发中太重要了。用户看到大模型逐字蹦出来的体验,和转圈等待十秒出全文的体验完全是两回事。用裸API实现流式不难,但你自己组装一个多步骤链时,每个环节又要重新解析又要拼prompt又要处理流式中断。LCEL把这些都抹平了,你对一个复杂链执行.stream(),它会自动从最后一个组件往前传播流式需求,中间组件只要实现了流式方法,整条链就是流式的。

LCEL还有一个特性叫RunnableLambda,可以把任意自定义函数包装成链上的一个节点。这让我可以对链的中间结果做监控、加日志、做缓存,甚至插入一个条件判断。这种灵活度对生产环境的价值远远大于“写法好看”本身。

2.3 RAG链路里被低估的三个部件

LangChain生态里最出圈的应用方向就是RAG(检索增强生成)。大多数人上手RAG就搞两步:切文档、塞向量库,然后拿用户问题去检索,把结果拼进prompt。真正把链路做扎实以后你会发现,LangChain里一些不起眼的部件才是提高回答质量的关键。

第一个是文档切分器。很多人直接把几万字的PDF整个塞进向量库,检索时召回一堆无关片段。文本切分器把文档切成块,并且允许块之间有重叠区间,避免把一个完整语义硬生生切断。LangChain里好几种切分策略,按字符切、按递归字符切、按语义边界切。实测下来递归字符切分器最稳定,它对代码、Markdown、普通文本都友好。

第二个是向量检索的相似度阈值。向量库返回的结果天然没有好坏标签,你得设定一个最小相似度,低于阈值的召回内容干脆不送进prompt。没有这个阈值,用户随便问一句话,系统都可能从知识库捞出一堆无关信息拼进去,模型被带偏了还挺自信。

第三个是重排序。第一次检索召回20段内容没问题,但这20段塞进prompt既不经济也可能超长。先在向量库里粗召回20段,再用重排序模型精排选前5段,效果会比单纯靠向量相似度取前5段好一个档次。这一步我最初嫌麻烦一直没做,后来在一次知识库问答测试里发现Top-5质量大幅下滑,才老老实实补上重排序。

3. LangGraph的状态机式设计让我重新理解了Agent

3.1 从“链”到“图”的心智转变

学LangGraph之前,我对Agent的理解很朴素;agent就是一个循环,模型决定调什么工具、拿结果、再决定下一步、直到输出最终回答。LangChain早年也有Agent框架,但对循环行为的支持非常别扭,每次循环的状态都留在agent内部,外部插不进去手。

LangGraph让我重新理解了Agent的本质:它根本不是魔法的产物,就是一个显式的、可观察的状态机。你定义好状态的数据结构,定义好节点函数,定义好节点之间的转移条件,它就像地图一样严格执行整个流程。这种“把魔法变成显式状态机”的思路,正是LangGraph最打动我的地方。

在LangGraph里,没有任何隐藏的循环、隐藏的分支。每个节点做了什么改动、状态从什么变成什么、哪条边被选中了,全部可以看得见。这意味着我可以单步调试、可以中途暂停、可以在某一个节点后面接人工审批。链式时代做不到的事,图式时代都能做到。

3.2 状态、节点、边:三个概念的落地理解

LangGraph的三核心概念——State(状态)、Node(节点)、Edge(边)——理解起来不难,但用好的关键在于状态的设计。状态就是一个全局的字典或者Pydantic对象,所有节点都能读取和修改它。节点就是普通函数,接收当前状态,返回状态的更新部分。边就是连接关系,可以是有条件的,根据状态内容决定走哪条路。

我写过一个模拟项目来实践,目标是构建一个能检索知识库、能查天气、能算数学题的助手。状态设计长这样:

from typing import TypedDict, Annotated, List class AgentState(TypedDict): user_query: str retrieved_docs: List[str] tool_results: Annotated[List[str], operator.add] messages: Annotated[List[str], operator.add] route: str

这里Annotated[List[str], operator.add]是LangGraph里很关键的一个设计:状态约减器。默认情况下,节点返回的新值会直接覆盖旧值。但有些字段你希望是增量追加的,比如工具调用结果的列表,每次新的结果应该追加进去,而不是覆盖掉之前的。用operator.add做约减器就实现了这种合并行为。

节点函数就是一个普通函数:

def retrieve_node(state: AgentState) -> dict: docs = retrieve_from_vector_db(state["user_query"]) return {"retrieved_docs": docs} def tool_node(state: AgentState) -> dict: results = run_tools(state["user_query"]) return {"tool_results": results, "messages": [f"工具结果: {results}"]} def route_node(state: AgentState) -> dict: if needs_calculation(state["user_query"]): return {"route": "calculator"} return {"route": "retriever"}

边则负责把它们串起来。有条件的边更像一个路由器,拿到当前状态,返回下一步应该去哪个节点。这个设计模式非常直观,比如判断用户问题里有没有数学表达式、有没有天气关键词、需不需要检索知识库。

3.3 持久化与人工介入:生产环境最需要的两个能力

LangGraph和LangChain最大的差异点,除了图本身,就是原生支持持久化和断点恢复。它的checkpointer会把每一步的状态快照保存下来,这意味着一个Agent流程跑到一半停了,可以从最近的快照恢复,不需要从头开始。

这个能力对真实业务极其重要。假设一个Agent要做多轮工具调用,每轮都要花几秒,如果中间某一步崩了整条链就得重跑,光是费用就够你心疼的。有了checkpointer,崩溃点和恢复点之间的代价大大缩小。

人工介入是另一个杀手级能力。在链式实现里,你想在“生成最终报告”之前插一个真人审核,需要自己写复杂的暂停逻辑。在LangGraph里,通过interrupt机制就可以在任意节点之间暂停执行,等人工在外部确认或修改状态之后,再继续往下跑。这不光是让流程可控,还解决了一个合规问题:某些关键动作必须由人确认,不能让Agent全自动执行。

4. 记录一次从Chain改写成Graph的完整过程

4.1 原始需求与链式实现版本

为了真正理解Chain和Graph的区别,我做了一个对照实验:同一个需求,先写成LangChain的链,再改写成LangGraph的图,然后对比两者的代码量、可扩展性和调试体验。

需求是这样的:做一个日程协调助手,用户说出一个模糊的日程安排需求,系统要完成三个子任务。第一,解析用户需求中的日期和时间;第二,查询当天日程来检查冲突;第三,如果冲突则提供调换建议,如果没有冲突则直接生成确认消息。

按照这个原始需求设计,回答会因数据库CahtLogs添加无用信息而变得碎片化,因此关键逻辑是输出格式标准化,并由解析模块统一渲染。

用LangChain写这个需求,最容易想到的是一个顺序执行的链:

chain = ( prompt_1 # 解析时间 | model_1 | parser_1 | prompt_2 # 根据解析结果查日程 | model_2 | parser_2 | prompt_3 # 生成最终回复 | model_3 | parser_3 )

这条链能用,但马上就有三个问题。第一,第二步查日程需要外部数据库,我不能只靠prompt_2,得在链中间插入一个自定义函数,用RunnableLambda包一层。第二,第三步需要根据冲突结果走两条不同的提示词分支,链式里只能先用一个大prompt让模型自己判断,效果不稳定。第三,用户如果中途说“换个时间”,整个链的状态全丢,没法增量调整。

4.2 改写成LangGraph后的节点划分

改成LangGraph后,我把流程拆成五个节点和两条分支。五个节点分别是:解析时间节点、查询日程节点、检测冲突节点、生成建议节点、生成确认节点。两条分支这样走:检测冲突节点根据当前状态里的冲突标记,决定去生成建议节点还是生成确认节点。

核心代码如下:

from langgraph.graph import StateGraph, START, END def parse_time_node(state): # 用模型解析出结构化时间,写入 state["parsed_time"] ... return {"parsed_time": parsed} def query_calendar_node(state): # 查日历,返回日程列表和冲突标记 conflicts = check_conflicts(state["parsed_time"]) return {"conflicts": conflicts, "schedule_items": items} def detect_conflict_node(state): if state["conflicts"]: return {"next_step": "suggest"} return {"next_step": "confirm"} def suggest_node(state): # 生成调换建议 ... return {"messages": [suggestion]} def confirm_node(state): # 生成确认消息 ... return {"messages": [confirmation]} graph = StateGraph(StateSchema) graph.add_node("parse_time", parse_time_node) graph.add_node("query_calendar", query_calendar_node) graph.add_node("detect_conflict", detect_conflict_node) graph.add_node("suggest", suggest_node) graph.add_node("confirm", confirm_node) graph.add_edge(START, "parse_time") graph.add_edge("parse_time", "query_calendar") graph.add_edge("query_calendar", "detect_conflict") graph.add_conditional_edges( "detect_conflict", lambda state: state["next_step"], {"suggest": "suggest", "confirm": "confirm"} ) graph.add_edge("suggest", END) graph.add_edge("confirm", END) app = graph.compile()

这段代码一眼就能看清整个流程。对比链条那一版,Graph版虽然代码量略多,但结构上的收益非常明显。你可以把任何一个节点单独抽出来测试,可以复制一个agent在不同时间点重跑,也可以随时在任意节点之间插入新的步骤,比如在查询完日程之后加一个“询问用户是否确认”的人工介入节点。

4.3 改造前后的对比总结

做了这个对照实验之后,我有了一个非常直观的感受:LangChain的Chain适合“流程固定、中途不需要改道”的场景,代码短,心智负担小。LangGraph适合“流程会分叉、需要循环、需要人工介入、需要状态积累”的场景,代码啰嗦一些,但扩展性和可观测性都上了台阶。

有一个容易被忽略的点是调试体验。链式跑挂了,你只能看到哪一步抛异常,中间状态无从查起。Graph版因为有checkpointer,挂掉之后可以打开状态快照,看看那个节点写入了什么脏数据,一目了然。这个优势在日常开发和线上排障中价值巨大。

我还留了一个额外的建议:不要急着把所有Chain都改成Graph。在做这个对照实验之前,我一度觉得Graph是更先进的东西,应该全面替换Chain。实际试完之后我意识到,每种工具都有它的适用边界,强行给简单流程套图结构,只会增加代码量和不必要的配置。成熟的做法是两种组件并存,按场景选型。

5. 运行中踩过的坑与排查记录

5.1 “更新状态没生效”其实是我对字段语义理解错了

我第一次跑LangGraph时遇到一个很诡异的现象:一个节点明明在返回字典里写了user_query字段,但下一个节点读到的还是旧值。这让我一度怀疑框架有bug。后来翻源码才发现,状态更新不是简单合并,而是遵循每个字段的约减器规则。

以Annotated[List[str], operator.add]为例,这种字段的语义是“追加”,不是“覆盖”。我当时的错误是试图用同一个字段既传递最新输入又累积历史记录,结果因为自定义了约减器,旧值没有被清掉,新值反而叠加出一个越来越长的列表。

这个教训让我理解了状态设计的第一原则:一个字段只承担一种语义。历史消息用追加字段,当前输入用覆盖字段,两者划分清楚,不要混用。我现在设计状态结构时,会先明确每个字段是“快照型”还是“累积型”,再决定要不要加约减器,以免后患。

5.2 并行分支共享变量导致的结果串味

LangGraph支持从一个节点发出多个并行分支,这些分支跑完之后会把结果合并回状态。但并行分支如果同时修改同一个字段,就会产生竞争冲突,结果取决于哪个分支最后返回,完全不可控。

我在实验Agent里用过并行检索:一路查知识库,一路查搜索引擎,两路结果都拼进retrieved_docs字段。最初我把两条分支的返回写进同一个字段,结果时好时坏,有时知识库结果排在前面,有时搜索引擎结果霸屏。后来我把字段拆成kb_docs和search_docs,再在合并节点里统一处理。问题立刻解决。

这背后其实隐藏着LangGraph的一个设计哲学:字段粒度越细,并行越安全。如果不方便拆字段,就需要在合并节点里做显式的状态归并,不能依赖返回字典的覆盖顺序。这属于那种不踩一下完全想不到的坑。

5.3 调试框架从看日志到看状态快照

调试LangChain和LangGraph的方式差异很大。Chain的调试基本靠打印日志,也就是在每个RunnableLambda里放print,看输入输出。这种方式在小规模下勉强够用,一旦链路长、分支多,print出来的数据互相交错,根本看不出顺序。

LangGraph的调试天然适合“状态驱动”。运行时打开LangGraph的调试面板或者自己写一段脚本,遍历每个节点执行前后的状态快照,就能看到每个节点到底改了什么。Node 1执行后状态多了什么字段,Node 2读取时这个字段的值是什么,这些信息比日志直观得多。

我自己用一个简单的技巧:写一个包装函数,在每个节点执行前后打印当前节点名和状态的关键字段。这样调试的时候就在关键路径上放“探针”,跑一遍就能定位状态异常发生在哪个节点。探针用完就撤,不污染代码。

5.4 模型调用成本与重复执行的规避

Graph结构容易出现一个隐蔽问题:同一个模型在一个流程里被调用多次,但有些中间结果明明可以缓存复用。我早期在并行分支里,把同样的用户问题分别用两个不同prompt问模型,结果发现一个问题被问了三次,费用蹭蹭往上涨。

后来我养成了两个习惯。第一,凡是模型调用的中间结果,只要不依赖上下文变化,就写进状态字段复用,避免重复计算。第二,引入一个简单的检查,一个节点已经算过的字段,后续节点直接读取,不再重新调用模型。

成本控制这种事,平时不太起眼,一旦流量上来就是一笔不小的开支。还有一点,如果调用了外部工具API,重复执行还可能触发限流或者重复扣费,这可能是多少钱都换不回来的教训。

6. 不同场景的选型判断与学习建议

6.1 现阶段我在什么场景下选Chain、什么场景选Graph

运行了一段时间之后,我给自己画出了一条比较清晰的选型分界线。

需求特征推荐方案理由
固定顺序、中间分支很少LangChain Chain代码简洁,心智负担小
需要条件路由/循环/动态流程LangGraph图结构天然支持分支和循环
需要人工审核、断点恢复LangGraphcheckpointer + interrupt 原生支持
简单问答、单轮RAG裸LCEL链不需要状态管理,加Graph反而复杂
多轮Agent、工具调用频繁LangGraph状态积累和多工具编排是核心优势

这条分界线不是一成不变的。我在做模拟项目时发现,即使需求是固定流程,只要涉及多人协作或外部动作的审批,最终都免不了加人工节点的需求。提前用Graph把那一路预留好,后续扩展会很省事。

6.2 我的学习路径建议

如果让我重新学一遍LangChain和LangGraph,我大致会分四个阶段走完,不跳步。

第一阶段,只用LCEL组装两三步的链,包括prompt、模型、输出解析,把Runnable接口的invoke、stream、batch这些入口用熟。不要急着碰Agent。

第二阶段,挑一个RAG项目,完整走一遍文档切分、嵌入、检索、拼prompt的流程,重点体会文本切分和召回质量的关系。

第三阶段,一定要亲自动手把同一个需求分别用Chain和Graph实现一遍,就像我上面做的日程协调项目那样。对比两种写法的扩展性和调试体验,会比看十篇对比文章的理解都深。

第四阶段,再开始尝试复杂Graph,把你最常用的Agent场景改成多个并行分支加条件路由,加上持久化跑一遍,好好体会断点恢复和人工介入是怎么一回事。

6.3 最后分享一点个人体会

我在学习LangChain和LangGraph的过程中最大的收获,不是会用了某个框架,而是学会了一种思考方式:应用编排的核心不是把代码写得多巧,而是把状态和流程边界设计得清清楚楚。把每一步做什么、状态里存什么、下一步根据什么流转,这三个问题想清楚,用什么工具其实都差不多。LangGraph把状态和流程显式化了,逼着我以更严谨的方式思考Agent结构,这个思维方式比框架本身值钱得多。

如果你也正在学习,建议不要一口气刷完所有官方文档,找一个真实的小项目,哪怕像我的日程协调助手一样小,亲手把它从Chain改写成Graph,再跑上几轮真实对话。那些官网上读起来平平无奇的概念,在你动手踩坑之后,自然会长成你自己的经验。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询