资讯动态

LangGraph实战:从链式架构到代理模式的LLM应用编排

发布时间:2026/10/19 14:11:32 来源:尧图企业网站定制
做LLM应用开发这一年多我越来越有一个强烈的感受写Demo的快乐和做产品的痛苦往往只隔着一层架构选择。去年我们项目组还在用链式结构把prompt一段段串起来遇到分支、回溯、人工审核这类需求代码就开始失控到处是if-else、全局变量和临时补丁。直到我把LangGraph引入项目第一次用“图”的视角来编排LLM应用才真正理解了什么叫从链式思维转向代理思维。如果你也正在做LLM应用开发或者刚刚接触Agent相关概念这篇文章值得你认真读完。我会先讲清楚范式转换背后的原因再拆解LangGraph的核心设计然后给出一份可以直接复用的代理实现方案最后把我踩过的坑和排查思路全部整理出来。这篇文章适合两类人一类是用链式结构写了一段时间、正被复杂流程折磨的开发者另一类是听说过LangGraph但还没搞懂它到底解决什么问题的初学者。1. 从链式到代理为什么非得换一种架构1.1 链式架构的“天花板”在哪里先回忆一下链式架构Chain长什么样。其实就是把多个LLM调用按顺序排成一条直线先总结用户问题再检索资料再生成回答每个环节的输入输出都是固定死的。这种模式在处理单一、线性、可预测的任务时非常顺手简单直接我最早做的几个RAG Demo也都是这么搭的。但链式架构有个绕不开的天花板它假设业务路径是固定不变的。一旦任务需要分支、循环、回退、多工具动态选择你就只能在链外面套if-else再在if-else里面写逻辑。我试过在一条链里实现“先判断用户要不要查数据库如果查不到就换个方式再查一次”代码很快就变成了一层套一层的条件判断每个分支都要手工处理状态传递改一个环节牵一发动全身。更麻烦的是链式架构天然没有“记忆循环”的概念。LLM应用里最复杂的场景恰恰是模型需要根据上一轮工具调用的结果决定下一轮是继续调用工具还是直接给出答案。这在链式里几乎没法优雅落地最常见的做法就是写个while循环把整条链再跑一遍跑完之后你自己都搞不清当前状态在哪一步。1.2 代理模式的本质变化代理Agent模式的核心转变是把“谁来控制流程”这个问题重新定义了。链式架构里控制权在开发者手里每一步做什么是人写死的而代理模式里控制权被交还给LLM——由模型根据当前状态动态决定下一步调用哪个工具、什么时候停止、要不要追问用户。听起来很爽但代价也很明显代理是循环式的不是直线式的。LLM可能连续调用三次工具、中间还要结合中间结果修正下一步策略最后才输出答案。这就意味着你的应用必须支持循环、支持动态分支、支持在任意节点持久化状态。传统的线性代码结构根本承接不住这种控制流。所以代理模式真正考验的不是你怎么写prompt而是你怎么组织“状态”。你需要在每一轮循环里知道模型已经看了哪些消息、调用了哪些工具、拿到了哪些结果、还有哪些分支没走完。这些问题我在最开始做代理时全都用自定义全局变量硬扛写过的人都懂数据一乱起来根本没法调试。1.3 为什么偏偏是LangGraph我第一次看到LangGraph时第一反应是这不就是个状态机框架吗但真正上手之后才明白它的价值在于把“图计算”和“LLM应用”这两个领域缝合得很自然。LangGraph把一个LLM应用建模成一张有向图节点是具体动作调LLM、执行工具、人工审核边是流转规则节点之间靠一个全局State对象传送数据。它最戳中我痛点的几个设计第一天然支持循环节点可以跳回前序节点反复执行第二所有中间状态都可通过checkpoint持久化到存储里应用挂掉可以从任意节点恢复第三支持人工介入在关键节点上暂停执行让用户审核或修改后再继续。这三个能力恰恰是链式架构最薄弱、代理模式又最需要的东西。另外一个我不能不提的点是LangGraph不是把代码变成配置文件而是让你用普通Python函数定义节点和边。这意味着你可以复用已有的日志、断言、单测手段整个图本身就是一个可调试可测试的对象。工程上这一点极其重要我后面专门用一个章节说。2. LangGraph核心设计拆解一切皆状态的图编排2.1 StateGraph和State应用的全部状态LangGraph里第一个要理解的概念是StateGraph它就是你整个应用的拓扑骨架。你可以把StateGraph想象成一张地图每个节点是地图上的城市每条边是连接城市的公路。但这张地图有一个特殊规矩所有城市和公路之间只通过一份共享的行李单来传递信息这份行李单就是State。State通常定义成一个TypedDict或者数据类里面的字段就是整个应用运行期间需要保存的所有信息。我习惯把State分成三类字段消息列表messages、控制标记如当前工具名、重试次数、业务数据如检索结果、用户字段。实际项目中我用的最多的定义方式是这样from typing import Annotated, TypedDict from langgraph.graph.message import add_messages class AgentState(TypedDict): messages: Annotated[list, add_messages] current_tool: str retry_count: int注意messages字段外面套了一个Annotated[list, add_messages]这就是LangGraph里最有特色的Reducer机制。它的作用是解决一个所有状态机框架都会遇到的经典问题如果两个节点都往同一个字段里写数据到底以谁为准默认行为是覆盖也就是后执行的节点把之前的数据冲掉。但消息列表显然不能这么处理我们需要把新消息追加到旧消息后面而不是覆盖。add_messages就是一个预置的Reducer函数它规定messages字段的更新方式是“追加合并”而不是“覆盖”。理解了这一点你就能真正理解LangGraph里State的工作方式每个节点函数return出去的内容不是覆盖整个State而是“根据Reducer规则更新对应字段”。它跟普通函数返回值是完全两个概念。2.2 节点、边和条件边把LLM调用变成可控流程节点Node和边Edge是图里的基本元素。节点就是一个Python函数输入是整个State输出是一个字典字典里的每个键对应要更新的State字段。边表达的是节点之间的流转关系分两种普通边和条件边。普通边就是无条件的固定路径A节点执行完必定走向B节点。这种边适合表达“工具执行完回到模型决策”这种确定性流程。条件边则是一场“岔路口选择”你会写一个路由函数根据当前State里的内容判断下一步走向哪个节点。比如判断LLM最近一条消息里有没有工具调用请求有就去工具节点没有就直接结束。def route_after_llm(state: AgentState): last_message state[messages][-1] if last_message.tool_calls: return tools return end这个路由函数是代理模式下最关键的代码之一。它把“模型自主决策”和“代码强制控制”缝合在了一起模型负责表达意图输出tool_calls代码负责执行流转选择路径。很多初学者会犯一个错误试图在prompt里把决策逻辑写死或者用正则去解析模型输出其实LangGraph的条件边就是专门干这个事的干净利落。2.3 checkpoint机制让代理状态可持久化聊完了节点和边再讲一个LangGraph真正值钱的设计checkpoint机制。它会在图的每一次状态更新后把完整的State保存到一个持久化存储中。这个机制的牛逼之处在于代理跑了一半中间状态不是存在内存里而是落在存储里你可以随时中断、查看、回放甚至从任意节点继续跑。我之前做链式结构时最痛苦的就是排查问题只能从头到尾打印日志看一会儿就晕了。而有了checkpoint我可以在某个节点执行后停下来直接把当前State完整打印出来看到底是哪里的数据出了问题。结合graph.get_state(config)这个接口我几乎把调试方式从“靠猜”升级成了“靠看”。checkpoint的存储后端支持好多选择内存、SQLite、Postgres等。我个人的建议是本地开发用内存或SQLite一旦涉及多进程或分布式部署就用Postgres。这套机制也为人工审核流程提供了基础——当应用跑到需要人工确认的节点时可以把整个State冻结起来等人确认后再继续推进。3. 实操从0构建一个可调试的检索代理3.1 需求定义和状态设计代码是最好的解释方式。我带你搭建一个完整的“检索代理”用户提问后代理判断要不要调用检索工具如果检索结果不够完整就多轮补充检索直到信息足够再生成最终回答。这个场景能很好地展示循环、条件边、工具调用和状态管理。先明确State字段。我这里把上一节的定义扩展一下from typing import Annotated, TypedDict from langgraph.graph.message import add_messages class AgentState(TypedDict): messages: Annotated[list, add_messages] search_results: list[str] retry_count: intmessages带Reducer负责累积模型和工具的对话历史search_results保存本轮检索到的结果不被Reducer影响后写的节点会直接覆盖它retry_count用于控制最大检索轮数防止代理无限检索下去。为什么这么设计因为messages是需要累积的而search_results每轮检索应该替换掉上一轮的旧结果retry_count则是一个简单的自增计数器。如果三个字段都用同样的更新策略就会出现数据越积越乱或者该替换的不替换的问题。这里也体现了前面说的Reducer设计不同字段可以按各自的语义定义不同的更新方式。3.2 节点实现细节这个代理里我设计三个节点模型决策节点、工具执行节点、回答生成节点。先看模型决策节点def call_llm_node(state: AgentState): system_prompt ( 你是检索助手。如果需要补充信息请调用search工具 如果信息足够直接给出最终回答。 ) messages [{role: system, content: system_prompt}] state[messages] response llm.invoke(messages) return {messages: [response]}这里要点是模型输入必须包含到目前为止的所有历史消息否则代理将丧失记忆。很多初阶实现只把最后一条用户消息传给模型导致多轮决策前后矛盾。把state[messages]完整传进去模型才知道“我已经搜过什么”“刚才的工具结果是什么”。再写工具执行节点def execute_search_node(state: AgentState): last_message state[messages][-1] tool_results [] for tool_call in last_message.tool_calls: query tool_call[args].get(query, ) result search_knowledge_base(query) tool_results.append({ role: tool, tool_call_id: tool_call[id], content: f查询: {query}\n结果: {result}, }) new_retry state[retry_count] 1 return { messages: tool_results, search_results: [r[content] for r in tool_results], retry_count: new_retry, }注意我在return里同时更新了三个字段messages用追加方式累积工具返回search_results直接覆盖成最新结果retry_count自增。一次节点执行同时更新多种类型的字段这在链式架构里往往要拆成很多临时变量而LangGraph的状态更新机制让这个过程变得很自然。工具返回消息的格式必须跟上一次模型消息里的tool_calls对应特别是tool_call_id要严格匹配否则框架会无法关联工具结果。3.3 条件边与循环控制接下来是代理循环的引擎条件边。模型节点跑完后路由函数根据最新消息里有没有tool_calls决定下一步去执行工具还是去生成回答工具节点执行完后无条件回到模型节点让模型基于工具结果做下一轮决策。这就形成了一个“模型-工具-模型-工具”的循环。def route_after_llm(state: AgentState): last_message state[messages][-1] if not last_message.tool_calls: return answer if state[retry_count] 3: return answer return tools def route_after_tools(state: AgentState): return llm这里我在路由里加了retry_count的判断如果检索轮次已经达到3次即使模型还想继续查也强制走向回答节点。这个保护机制非常必要否则代理可能陷入无休止的工具调用。另一个值得注意的点是回答生成也是一个独立节点不是直接在路由里print出来这样才能保证最终回答也作为状态的一部分被记录和持久化。回答生成节点写法很简单def generate_answer_node(state: AgentState): final_answer llm.invoke([ {role: system, content: 基于检索结果用中文回答用户。}, *state[messages], ]) return {messages: [final_answer]}3.4 编译、运行与调试节点和边都定义好了最后把它们组装成图并编译from langgraph.graph import StateGraph, END builder StateGraph(AgentState) builder.add_node(llm, call_llm_node) builder.add_node(tools, execute_search_node) builder.add_node(answer, generate_answer_node) builder.add_conditional_edges( llm, route_after_llm, {tools: tools, answer: answer} ) builder.add_edge(tools, llm) builder.add_edge(answer, END) graph builder.compile()执行的时候可以通过thread_id开启会话级别的checkpointconfig {configurable: {thread_id: demo-001}} result graph.invoke( {messages: [{role: user, content: 公司上季度营收情况如何}], search_results: [], retry_count: 0}, configconfig, )运行完后你想看代理每一步到底发生了什么直接查历史history graph.get_state(config) for step in history.values: print(step[messages][-1].content)graph.get_state(config)配合thread_id能看到当前会话从开始到现在的所有状态快照。我在调试这个Demo时就是靠这个接口一步步看模型在每个节点产出了什么哪一轮开始瞎编工具参数结果一查一个准。这种可观测性是链式代码加日志都很难给的。4. 从Demo到生产的常见坑与排查实录4.1 状态字段互相覆盖第一个坑就是State字段的覆盖问题。很多初学者把State当成普通字典逻辑写到一半发现前面节点刚存进去的数据被后面节点return的字典整个冲掉了。比如我在早期实现里一个工具节点return了{messages: ..., retry_count: ...}另一个节点return了{messages: ..., search_results: ...}我当时以为每次都只更新自己关心的字段但实际发生的是只要return里有某个字段这个字段就会按Reducer规则被更新而没返回的字段保持原值。真正容易踩的雷是两个节点都返回了messages字段但一个用了add_messages兜底另一个直接return {messages: something}返回了一个字符串结果类型对不上运行时报错。解决办法就是搞清楚每个字段的Reducer类型列表类字段尽量都用add_messages或有同样语义的Reducer而不是裸返回。4.2 代理死循环与步数失控死循环是代理应用最魔幻的故障。症状是程序一直不停调用工具日志看着像正常运行但就是不结束最后消耗了大量token才发现不对劲。我排查过好几次原因集中在两类一是路由函数对tool_calls的判断太宽松模型已经连续输出了相同工具调用但格式略有变化始终命中“有工具调用”分支二是没有设置最大循环次数。建议双管齐下路由函数里加上基于状态的步数判断就像我前面示例里的retry_count同时配合前面讲的checkpoint每隔几轮记录一下当前工具调用列表确认模型是不是在重复调用同一个工具。如果发现同一个工具、同一组参数出现了两次以上基本就是模型陷入了循环需要在prompt里明确提示“不要重复执行已有查询”或者在路由里直接拦截。4.3 工具调用格式与异常处理工具调用是代理模式里最容易出问题的环节。我踩过的第一个坑是LangGraph的工具节点要求返回跟我们前面示例一致的消息结构tool_call_id必须正确。如果工具返回的消息跟模型请求里的tool_call_id对不上框架会当作孤儿消息处理模型后续循环会完全丢失上下文。第二个坑是工具本身也可能报错。很多初学者直接在工具函数里抛异常结果整个图直接崩溃。生产环境里我更推荐这个做法把异常捕获在工具节点内部以正常消息格式返回错误信息让模型自己判断怎么处理。这样既不会中断代理执行又能让模型基于错误信息决定要不要换一种问法重试。4.4 持久化配置与人工介入最后说说生产部署里经常被忽略的持久化配置。本地开发默认用内存checkpoint程序一停状态就没了很多人上线之后忘了换存储后端结果代理还在一个进程里跑重启之后就报找不到thread的数据。尽早把checkpoint存储从内存切到SQLite或Postgres这一步越早做越省钱。人工介入human-in-the-loop也是LangGraph特别适合干的事。我做过一个数据清洗代理在删除数据之前需要人工确认实现方式就是让代理执行到某个节点时暂停等待外部确认后再继续。这个在生产场景里非常实用尤其是那些涉及不可逆操作的业务光靠模型自觉远远不够。5. 范式切换背后的工程思维变化5.1 从“写流程”到“设计状态机”链式架构的时代我们的思维模式是“流程驱动”先做什么、再做什么、异常怎么跳转全部写死在代码里。代理模式反过来是“状态驱动”你把所有可能的状态和转移条件定义清楚至于从A到B再到C还是从A直接到D由模型根据当前状态决定。这个转变看着简单实际影响非常深。以前我接到一个需求第一反应是画出流程图然后在代码里一格格实现现在我第一反应是定义State字段、想清楚哪些字段需要Reducer、哪些节点可以复用、哪些条件边是硬性规则。代码结构从一长串顺序调用变成了一张清清爽爽的图新增一个能力就加一个节点和若干条边旧的节点完全不动。5.2 可观测性与可恢复性LangGraph让我最Respect的一点是它对工程化能力的认真。代理模式下你天然拥有了会话级的完整状态历史每个节点执行完都能看到一份可序列化的State。这意味着你可以设计出真正的大模型应用监控体系每个会话的每一步、每个工具调用、每个决策点都有据可查。可恢复性更加分。以前链式结构跑到一半崩了只能整个流程重跑现在有了checkpoint我可以从崩溃节点继续执行。有个线上应用是长流程内容生成中途经常因为第三方接口超时中断以前一天要重跑十几次现在挂掉之后直接把thread_id对应的State拉出来从失败节点续跑损失的只是那一次接口调用的时间。5.3 后续还能怎么玩LangGraph能做的事情远不止单代理循环。我最近在尝试的方向是团队成员模式的游戏化模拟多个代理各自持有独立的State私有记忆但共享同一份团队消息上下文能够协同完成任务。这在LangGraph里实现起来非常自然——每个代理是图中的一个子图团队级State里放着共享消息子图自己管理内部逻辑。另一个方向是把人工审核节点和自动化流程混排让代理在关键节点停下来问用户意见然后带着用户的反馈继续往后跑。如果让我给一个学习路线建议我会说先用一个小Demo把状态、节点、条件边这三个概念彻底搞熟再把checkpoint和人工介入加上最后再碰多代理协作。不要一上来就想做一个全自动复杂系统你会在调试循环上疯掉的。我个人实际操作中的体会是LangGraph带来最大的变化不是“代码变少了”而是“出问题了知道从哪查起”。以前代理应用出了问题我只能对着日志一遍遍看LLM的原始输出试图从中猜出模型当时在想什么。现在直接看State、看历史快照、看每个节点的实际输入输出整个调试过程变得确定多了。这种确定性才是LLM应用能走向生产环境的最重要底气。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑