开头先扔一个真实场景某天我要做一个能查订单、查库存、还能帮客户算优惠价的小助手传统做法是把大模型接到聊天框里然后自己写一堆状态机代码处理“模型到底要不要调工具、调完工具结果往哪塞、对话历史怎么存”。这套东西第一版能跑第二版就崩因为prompt里忘了带工具结果或者多轮对话串了session问题层出不穷。后来把方案换成了LangGraph做后端编排useAgent做前端接入层整个链路的复杂度一下降了很多。LangGraph把Agent的运行逻辑变成一张有向图状态流动、分支跳转、记忆持久化全都框架化了useAgent作为前端侧的React Hook库把流式事件、会话生命周期、状态管理全部封装好我不需要再手动拼SSE报文或者维护WebSocket心跳。这篇文章就是围绕这个组合做一次完整拆解讲清楚全栈AI Agent应用怎么分层、LangGraph的状态图怎么设计、useAgent前端怎么接、踩过哪些坑。适合正在做Agent应用落地、被多轮对话和工具调用搞到头大的后端或前端开发者。1. 全栈AI Agent应用的整体架构与选型思路1.1 一条完整的Agent链路分几个层先把全栈AI Agent拆开看。一次用户提问从浏览器到最终渲染至少要经过四个层前端交互层、接入网关层、Agent编排层、外部工具层。前端交互层负责收集用户输入、渲染流式输出、展示工具调用的中间状态。接入网关层解决通信协议、鉴权、Session管理一般是一个轻量的BFF服务。Agent编排层是整个应用的大脑它决定模型调哪个工具、工具结果怎么处理、什么时候结束回答。外部工具层则是业务系统的API、数据库查询、第三方服务等。过去最容易出问题的不是模型本身而是第二层和第三层之间、第三层和第四层之间的胶水代码。自研状态机方案通常长这样一个全局变量存messages一个循环判断tool_calls再用一堆if-else分支处理异常。这套逻辑在一两个工具时勉强能对付一旦工具数量超过五个分支组合爆炸维护成本直线上升。LangGraph解决的是第三层的编排问题useAgent解决的是第一层和第二层的通信问题。两者一个管后端大脑一个管前端入口正好覆盖了Agent应用最核心的两段链路中间的协议用LLM推理时代的标准事件流来连接。1.2 为什么编排层选LangGraph而不是自研状态机LangGraph把Agent定义成一张状态图核心模型是StateGraph。状态图有三个要素State、Node、Edge。State是全局数据对象Node是对State执行的某个操作Edge定义Node之间的流转条件。对比一下自研方案。自研循环通常是一个while循环加上if-else判断本质上是把Agent的运行逻辑硬编码在业务代码里。增加一个工具要改循环、改判断、改异常处理三个地方同步修改。LangGraph里增加一个工具只做两件事注册一个工具节点加一条条件边其余的控制流完全复用。另一个关键点是LangGraph原生支持Checkpoint机制可以把Agent的运行状态持久化到数据库或内存存储。多轮对话需要记忆Agent执行到一半需要断点续跑这些需求在自研方案里都要自己设计存储结构在LangGraph里配置一个checkpointer参数就能解决。有人可能会担心“再包装一层会不会增加Black Box”实际上LangGraph的源码很薄节点函数都是自己写的Python函数图的执行逻辑清晰可见。它没有把智能藏在框架里只是把确定性流程的控制权从手工编码变成了声明式配置。1.3 为什么前端选useAgent而不是裸写WebSocket如果不用useAgent最原始的做法是在前端初始化一个WebSocket连接然后手动解析后端推过来的各类事件token增量、工具开始、工具结束、错误信息、最终回答。这些事件有的要更新气泡组件有的要渲染在侧边栏有的要弹错误提示。每次新增一种事件类型switch-case就要加一个分支。useAgent做的事情是把这一整套封装成Hooks调用。初始化实例、发送消息、接收事件、管理生命周期、维护messages数组这些操作都是声明式的。它内部处理了断线重连、事件去重、流式解析等繁琐问题前端开发者只需要关心UI的渲染逻辑。这也是我认为这个组合比较舒服的地方后端你写图前端你写Hook中间的事件协议两边一约定整个应用的骨架就出来了。2. LangGraph中Agent编排的核心实现2.1 状态图里State类型怎么设计先看一段实际定义State的代码这是Agent运行时的数据结构基础。from typing import Annotated, TypedDict from langgraph.graph import StateGraph, END from langgraph.graph.message import add_messages class AgentState(TypedDict): messages: Annotated[list, add_messages] user_id: str current_tool: str这里最值得注意的就是Annotated[list, add_messages]。它告诉LangGraph当多个节点同时更新messages字段时不是直接覆盖而是把新消息追加进列表。这是Agent多轮对话能够正常工作的基石。另外一个经验是State里不要塞太多临时字段。有些开发者喜欢把工具返回的原始JSON直接塞进State导致State体积越来越大每次传递都要序列化一次大数据影响性能。我通常在State里只保留必要字段大块的工具返回结果放到持久化存储里再通过引用ID去关联。State类型一旦确定尽量保持稳定。中途频繁改字段类型会导致checkpointer无法正常序列化旧数据多轮对话的历史恢复会报错。如果确实要扩展建议做好数据迁移。2.2 Agent节点和工具节点的职责边界LangGraph的Agent图至少需要两类节点Agent节点和工具节点。Agent节点负责调用大模型做推理决策工具节点负责执行具体的外部调用。def agent_node(state: AgentState): response llm_with_tools.invoke(state[messages]) return {messages: [response]} def tool_node(state: AgentState): last_message state[messages][-1] tool_calls last_message.tool_calls results [] for call in tool_calls: tool_result execute_tool(call[name], call[args]) results.append(ToolMessage(contentjson.dumps(tool_result), tool_call_idcall[id])) return {messages: results}Agent节点的职责是“决定做什么”工具节点的职责是“执行并返回结果”两者不要混写。现实中常见的错误是在Agent节点里强行夹带工具调用逻辑比如在prompt里拼接工具结果导致LLM无法以结构化方式接收工具反馈。保持职责分离还有一个好处就是可以方便地给工具节点加缓存、加限流、加审计日志这些横切逻辑不会污染模型的推理上下文。2.3 条件边实现ReAct循环LangGraph里控制循环的核心是条件边。Agent节点调用完模型后需要判断返回的消息有没有tool_calls有就流向工具节点没有就直接结束回答。def should_continue(state: AgentState): last_message state[messages][-1] if last_message.tool_calls: return tools return END graph StateGraph(AgentState) graph.add_node(agent, agent_node) graph.add_node(tools, tool_node) graph.set_entry_point(agent) graph.add_conditional_edges(agent, should_continue, {tools: tools, END: END}) graph.add_edge(tools, agent)这段代码的本质就是ReAct循环的声明式表达推理、执行、再推理。工具节点跑完之后回到Agent节点Agent节点看到工具结果后决定是否继续调用工具直到某次推理不再产生tool_calls。条件边的分支映射不要写得过于复杂。我见过有人把tool_calls按工具名拆成十几条分支边完全没必要。工具执行逻辑统一走一个工具节点真正需要分流的场景是“是否需要人工审批”、“是否超过最大迭代步数”这类控制条件。2.4 记忆与多轮会话的持久化配置Agent的持久化在LangGraph里通过Checkpointer机制实现。配置方式非常简洁但背后的概念要理清楚Thread是会话维度的隔离单位同一个Thread内的所有节点执行记录会被checkpointer保存下来。from langgraph.checkpoint.sqlite import SqliteSaver memory SqliteSaver.from_conn_string(checkpoints.db) graph graph.compile(checkpointermemory)调用时传入thread_idLangGraph会自动恢复该线程的历史状态。config {configurable: {thread_id: user-123-session-456}} result graph.invoke({messages: [user_input]}, configconfig)我在项目里把thread_id拼成“用户ID-会话ID”的格式方便按维度检索。实战中要注意Chekpointer保存的是节点执行后的状态如果节点的返回数据里有不可序列化的对象保存会报错。工具返回的响应对象要先转成JSON字符串再放入State。3. useAgent前端的接入方式与状态管理3.1 useAgent实例的初始化与消息发送useAgent的接入方式非常类似数据请求库。先初始化一个Agent运行时实例指定后端服务的地址和事件处理回调然后通过Hook拿到消息发送函数和状态字段。import { useAgent } from useagent; function ChatPanel() { const { messages, sendMessage, status, events } useAgent({ endpoint: /api/agent/stream, sessionId: user-123-session-456, }); const onSubmit (text) { sendMessage(text); }; }这里sessionId对应后端的thread_id两端的会话标识必须对齐。很多联调问题的根源就是前端生成的sessionId和后端checkpointer使用的thread_id对不上导致多轮会话记忆一直无法命中。useAgent内部会维护一个事件队列按顺序处理后端推送来的事件流。相比手动管理WebSocket的状态它把连接状态收敛成了connecting、streaming、ready几个枚举值前端渲染loading态和流式效果都方便很多。3.2 流式事件订阅与交互渲染Agent运行时和普通对话接口最大的差异是事件类型丰富。我实际用到的核心事件包括消息增量事件、工具调用开始事件、工具调用完成事件、最终回答完成事件。const { subscribe } useAgent({ endpoint: /api/agent/stream, sessionId: user-123-session-456, }); subscribe((event) { switch (event.type) { case token: // 追加流式文字 break; case tool_start: // 展示“正在调用订单查询工具…” break; case tool_end: // 收起工具调用状态展示结果摘要 break; case message_complete: // 整条消息渲染完成 break; } });这里有个实践细节不要在前端做事件逻辑的深度处理比如自己拼装工具调用链、判断下一步动作。前端的职责是呈现Agent运行逻辑统一收敛在事件流里。你只需要定义每种事件长什么样然后做对应的UI渲染。3.3 Agent会话的生命周期与销毁Agent应用里经常被忽略的是会话销毁。测试阶段我遇到过后端连接数暴涨查了一圈发现是前端页面切走时没有销毁Agent实例。useAgent在组件卸载时务必调用清理函数useEffect(() { const agent createAgent({ endpoint: /api/agent/stream, sessionId }); return () { agent.destroy(); }; }, [sessionId]);不销毁实例的后果是WebSocket连接无法正常关闭后端checkpointer会持续保留该线程的状态锁。高并发场景下这种泄漏直接拖垮整体性能。另外如果业务上需要用户手动清空对话应该同时做两件事前端清空messages列表、后端新建thread_id。旧的thread数据可以保留用于审计但不能复用给新会话。4. 完整实操案例构建一个订单查询AI助手4.1 后端工具注册与图编译下面以一个订单查询助手为例完整过一遍代码链路。先模拟一个工具函数再放入工具节点。def query_order(order_id: str) - dict: return { order_id: order_id, status: shipped, amount: 399.00, items: [USB-C Hub, Portable SSD], eta_days: 2, } tools [query_order] llm_with_tools llm.bind_tools(tools) def agent_node(state: AgentState): response llm_with_tools.invoke(state[messages]) return {messages: [response]} def tool_node(state: AgentState): last_message state[messages][-1] tool_calls last_message.tool_calls results [] for call in tool_calls: if call[name] query_order: result query_order(**call[args]) else: result {error: unknown tool} results.append(ToolMessage(contentjson.dumps(result), tool_call_idcall[id])) return {messages: results}图的编译和启动方式和前面一样重点看API层怎么把这个图暴露成流式接口。我用的方案是基于SSE的流式网关每一类事件以event:字段标识前端按事件类型解析。def stream_agent(request): user_input request.json[message] thread_id request.json[session_id] config {configurable: {thread_id: thread_id}} events graph.stream( {messages: [HumanMessage(contentuser_input)]}, configconfig, stream_modemessages, ) for event, metadata in events: if hasattr(event, content) and event.content: yield fevent: token\ndata: {json.dumps({content: event.content})}\n\n这一层要注意不要把graph.stream的输出原封不动地转发给前端。后端要按事件类型做一层清洗和封装剔除模型内部元数据只暴露前端需要的内容。4.2 前端页面接入useAgent前端的ChatPanel组件可以做成一个可控组件。输入框、消息列表、工具调用状态栏三块区域分别由useAgent的sendMessage、messages、events驱动。function OrderAssistant() { const { messages, sendMessage, status, clearThread } useAgent({ endpoint: /api/agent/stream, sessionId: user-${currentUser.id}, }); return ( div classNamechat-panel MessageList messages{messages} / ToolStatusBar events{events} / InputArea onSubmit{sendMessage} disabled{status streaming} onClear{clearThread} / /div ); }这里有一个常见参数细节endpoint路径必须和后端路由完全匹配。如果中间经过网关转发建议在网关卡点保留原始路径防止SSE连接在代理层被缓冲导致事件无法实时到达。4.3 一次完整提问的运行时序分析假设用户在界面输入“帮我查一下订单20240801的状态”。完整请求链路如下第一步前端调用sendMessageuseAgent建立SSE连接附带sessionId。第二步后端LangGraph按图执行Agent节点把用户消息加进State调用LLMLLM返回tool_calls请求。第三步条件边选中“tools”分支工具节点执行query_order生成ToolMessage放回State。第四步Agent节点再次调用LLM看到工具结果后生成最终回答文本。第五步SSE逐token推送回答内容useAgent把增量拼进messages数组。从用户视角看输入问题后页面先出现“正在查询订单状态…”的提示接着文字逐字流出。整个过程不需要刷新页面多轮追问时后端会自动带上历史上下文因为thread_id对应的checkpoint里保存了完整对话状态。这种体验的关键在于前后端的事件类型完全对齐。建议在开发阶段把事件类型定义在共享的类型文件里前端和后端统一引用避免字段名不一致导致的解析失败。5. 常见问题与排查技巧实录5.1 工具调用循环不退出症状是SSE一直有事件推给前端但永远没有最终回答。典型的循环场景是模型调用工具后工具返回的内容依然让模型认为“还需要再查一次”于是无限次调用工具。排查思路很直接在Agent节点打印每次的tool_calls内容看模型到底为什么继续调。通常原因有两个一是工具返回结果格式含糊模型无法从中提取到状态信息只能反复查询二是工具节点给ToolMessage的tool_call_id不匹配模型无法关联工具结果和之前的调用请求。我的处理方案是给图的执行加一个最大迭代步数限制。在条件边函数里维护一个计数器超过5轮强制走END分支。再配合清晰的工具返回模板让结果中包含“查询成功/查询失败/需补充参数”之类的状态字段模型很容易判断下一步。5.2 SSE事件丢失或乱序前端有时会漏掉中间的工具调用事件或者收到乱序的事件。这个问题要分两层排查第一层是浏览器端怀疑事件源被代理缓冲确认网关没有对SSE做缓冲第二层是后端stream输出顺序和节点执行顺序不一致。LangGraph的stream_mode参数很关键。默认的values模式只返回节点执行结束后的整体状态会丢失细粒度事件。要用messages模式才能拿到每一步的消息增量。不同模式的输出结构差异很大前端解析逻辑要跟着模式走。另外一个容易踩的坑是多个Node并发输出。LangGraph默认情况下节点是串行执行的但如果一些节点没有依赖关系而并行执行事件输出的先后顺序就不是严格按输入的。在Agent场景里我一般把全部节点保持串行连接到主链路上保证事件顺序的可预期性。5.3 多轮对话记忆失效用户第二次提问时模型完全不记得第一轮聊了什么这是checkpointer配置最常见的坑。出现这个问题先确认两件事graph编译时是否传了checkpointer参数每次invoke时是否传了完全一致的thread_id。有些情况是前端每次创建useAgent实例时都生成随机sessionId导致后端每次都是新thread。另一些情况是开发人员在路由层把sessionId参数默认成空值后端拿不到有效线程标识还默默地用随机线程跑了一遍。解决办法是在前端把sessionId持久化到localStorage或cookie同一个用户在同一浏览器内的会话标识保持一致。用户显式点击“新会话”时才生成新的sessionId同时后端关闭旧线程的持锁状态。5.4 工具返回超大结果导致请求超时排查大规模工具调用时发现工具返回了几百条记录塞进消息列表不仅Token消耗飙升LLM的上下文窗口也接近上限。此时SSE推流可能因为后端等待模型响应而长时间无输出前端就误判成了卡死。解决的方式是“结果摘要化”工具节点返回给模型的内容不是原始结果而是结果的结构化摘要比如记录数、总额、前三条样本。真正需要完整数据渲染时前端根据摘要里的引用ID再单独请求详情。这也是全栈链路的一个设计原则模型上下文里只放决策需要的信息展示需要的信息交给前端直接获取不让大模型当数据库用。5.5 线程安全与生产环境部署本地开发用的是SqliteSaver生产环境我换成了基于数据库的CheckpointSaver实现。这里要注意并发问题同一个thread_id如果被两个请求同时invoke可能产生状态覆盖。解决方式是业务层做好线程锁或采用队列保证同一线程的请求串行处理。图编译完成后建议提前做一次“空跑验证”就是用一个测试消息把整个图完整执行一遍确认checkpoint能正常保存、恢复。否则一旦上线后才发现持久化逻辑有问题线上用户的多轮对话历史全丢排查成本会非常高。结尾这套useAgent加LangGraph的组合我实际跑下来最大的感受是“终于不用在应用层硬写Agent状态机了”。把编排逻辑交给LangGraph图结构把通信细节交给useAgent自己在中间只写业务工具和UI组件整个开发节奏快了很多。最后分享一个小技巧Agent应用的调试不能只看最终输出一定要把事件日志完整记录下来。我在工具节点和Agent节点上都加了结构化日志等一个会话跑完直接按thread_id检索日志模型每次想了什么、调用了什么工具、拿到了什么返回一目了然。配合LangGraph的checkpoint恢复功能可以把调试会话恢复到任意节点反复回放问题。如果你也在做Agent应用建议先拿一个中等复杂度的业务场景试水这个组合比如带三五个工具的客服助手。跑通之后你会对状态图、工具节点、流式事件这些概念建立直觉再往复杂的多Agent协作场景延伸会顺手很多。