
1. 项目到底在做什么一个全栈 Agent 的长什么样做 AI 应用这两年我最深的体会是单点接一个大模型 API 已经不算什么了真正的难点在于把模型能力嵌进一个完整的、可落地的产品里。这个项目标题看起来简单实际上是一个前后端贯通的活儿——后端用 LangGraph 管住 Agent 的状态流转和工具调用前端用 useAgent 把 Agent 的执行过程变成页面上的实时交互。两者拼起来才是真正的全栈 AI Agent。先说清楚一个问题为什么不能只写后端或者只写前端因为 Agent 应用的本质不是调用一次模型而是模型驱动一段业务流程。后端需要编排模型的多次调用、工具的多次执行、甚至人工介入前端需要把这段流程实时呈现给用户并允许用户在关键节点打断、补充、确认。任何一边缺失产品都是瘸腿的。这个项目的核心价值就在这里它不追求把 Agent 做到多聪明而是把 Agent 从实验代码变成可运维、可交互、可上线的应用。适合谁参考已经会写基础的大模型调用、想进一步做有状态的复杂 Agent 应用或者正愁前后端怎么衔接的开发者。无论你偏前端还是偏后端这篇文章都能帮你补上另一侧的知识空白。1.1 从需求说起为什么需要一个全栈的 Agent先还原一个真实场景。我做一个内部知识库问答系统最初版本就是后端一个接口把用户问题扔给大模型返回一段文字。用起来确实能回答问题但问题很快暴露第一用户问帮我查一下上个月的销售数据顺便总结一下异常原因这种多步任务模型一次回答根本搞不定。它需要先调订单查询工具拿到数据再调数据分析工具计算异常最后总结。这一串动作必须有状态管理模型才能记住上一步做了什么。第二用户希望看到中间过程。比如工具调用到哪一步了、查到了什么数据、下一步准备干什么。纯后端处理用户只能傻等一个最终结果一旦超时或者出错体验非常糟糕。第三前端不是只展示文字的。它可能需要渲染表格、展示图表、允许用户在其中一步手动选择筛选条件。这就意味着 Agent 的执行过程要能流式地推到前端而不是攒一个大 JSON 一次性返回。以上三点恰好对应了后端要解决的编排问题、前后端要解决的通信问题、前端要解决的交互渲染问题。这就是我理解的全栈 Agent执行链路完整、交互过程透明、状态前后端一致。1.2 方案选型为什么是 LangGraph useAgent后端选 LangGraph我当时的考量其实很直接。核心诉求是Agent 不是一个单向的调用链而是有循环、有条件分支、有状态的流程。用普通的 Chain 或者装饰器模式硬写代码会越来越难维护。LangGraph 把 Agent 抽象成一张图节点Node是具体执行单元边Edge决定流转方向条件边Conditional Edge根据执行结果决定下一步走哪里全局状态State在每一步之间传递。这和你画流程图、排业务节点的思路完全一致。它还天然支持持久化断点Agent 中途中断下次还能从断点恢复——这在人工审核流程里特别有用。前端选 useAgent则是看上了它对流式渲染和工具调用的封装。useAgent 允许在前端声明语言模型、声明工具然后通过一个 React Hook 驱动 Agent 运行。它有一个很关键的能力Agent 的执行结果可以是React 节点流工具调用结果可以直接渲染成表格、卡片、表单不用再手动解析 JSON 然后拼 JSX。两端配对之后的逻辑是LangGraph 在后端掌控核心业务逻辑和私有数据访问useAgent 在前端做交互编排和结果渲染中间通过流式协议对齐。后端管正确前端管体验。2. 后端先行用 LangGraph 把 Agent 编排成一张清晰的状态图LangGraph 上手核心就三样东西State、Node、Edge。我建议别急着写复杂业务先用最小例子跑通这三者的配合再往里面填业务。2.1 LangGraph 的最小套路State、Node、Edge 三件套State 就是一张工作台所有节点共享这张工作台。比如客服 Agent 的 State 可以这样定义from typing import TypedDict, Annotated, List from langgraph.graph import StateGraph, END class AgentState(TypedDict): messages: Annotated[List[dict], operator.add] # 消息历史节点间累积 order_id: str order_info: dict pending_action: str这里最需要注意的是Annotated[List[dict], operator.add]。如果你不写 reducer后一个节点给messages赋值就会把前一个节点的值直接覆盖掉。用operator.add会把每次赋值的列表追加进来这是 LangGraph 里最常用的累积方式也是最容易踩坑的地方——我在第一个项目里就因为没有写 reducer导致消息历史在第二个节点被清空了。Node 就是普通的 Python 函数输入 State输出一个 dictdict 里的键会被合并回 State。Edge 定义节点之间的跳转。比如def query_order(state: AgentState): order_id state.get(order_id) return {order_info: find_order(order_id)} def decide_next(state: AgentState): if state.get(pending_action) refund: return refund_node return answer_node graph StateGraph(AgentState) graph.add_node(query_order, query_order) graph.add_node(answer_node, answer_node) graph.add_edge(query_order, decide_next) graph.add_conditional_edges(decide_next, lambda state: state[next_step]) graph.add_edge(answer_node, END) app graph.compile()流程图到最后其实就是一张由节点和边连起来的有向图。LangGraph 的价值在于把模型决定下一步做什么变成了一条条件边模型返回要调用的工具名条件边就把执行流送到对应的工具节点去。2.2 一个真实 Agent 的节点设计订单客服的完整案例我拿一个做过的模拟项目某电商订单客服 Agent举例需求是用户问订单、查物流、发起退款。后端不能把整个流程塞进一个模型调用里而是要拆成节点每个节点管一件事。我的节点拆分是这样的入口节点parse_intent用户消息进来先过一遍模型判断意图提取订单号、问题类型。工具节点query_order_info根据订单号查数据库。工具节点check_logistics外部物流接口。工具节点check_refund_eligibility查订单状态判断是否满足退款条件。决策节点decide_next根据意图和工具查询结果决定回答用户还是继续走退款流程。出口节点generate_answer汇总已有信息生成给用户的最终回复。每个工具节点都要设计查询失败的路径。比如查单号查不到节点返回状态为error条件边就应该走请用户核对订单号节点而不是硬着头皮往下走。实际开发中这一步是让 Agent 显得靠谱的关键。工具节点里我还会还加上人工确认的断点当退款条件满足时Agent 不直接执行退款而是停在waiting_approval状态通知前端展示确认按钮用户点击确认后调用app.resume()继续执行。LangGraph 的interrupt和checkpointer在这是单独的必备方案这类功能用普通函数循环写很容易搞成一团乱麻。from langgraph.checkpoint.memory import MemorySaver checkpointer MemorySaver() app graph.compile(checkpointercheckpointer) # 前端调起 Agent得到 thread_id中途允许暂停和恢复 config {configurable: {thread_id: user-123}} # 用户确认后从断点继续 result app.invoke(None, config)2.3 工具调用循环与中断恢复控制 Agent 迭代次数Agent 和模型的核心区别就是循环模型决定调用工具工具返回结果结果又送回模型模型再判断下一步。这个循环如果失控你的服务器账单会很难看。我处理失控的方式是加两个硬指标。第一个是最大迭代次数在图里维护一个step_count每次进入决策节点加一超过 5 次就强制进入兜底节点直接告诉用户这个问题我暂时处理不了帮你转人工。第二个是工具调用白名单模型只能调用在 ToolRegistry 里注册过的工具没注册的一律返回 error。这两条看起来简单但能拦住大多数死循环。中断恢复这块特别提一下LangGraph 的断点机制通常配一个MemorySaver但它只适合本地测试生产环境要么换成数据库实现的 checkpointer要么自己实现持久化。我踩过一次坑本地跑得好好的部署到容器里一重启所有会话 state 全丢了用户当时差点炸。务必记住默认的 MemorySaver 就是内存进程没了就没了。3. 前端实战useAgent 如何把 Agent 变成页面上的实时交互前端引入 useAgent 之前我有一段非常痛苦的经历后端 LangGraph 每走一步前端就要靠轮询 解析大 JSON来更新 UI。工具调用的中间结果先存在后端等最终结果出来以后一次性发给前端前端再根据类型渲染。结果就是用户界面要么长时间空白要么突然蹦出所有内容体验非常差。useAgent 把这事改成了流式渲染。它不是一个简单的 API 封装而是一套前端 Agent 运转模型语言模型、工具、状态、渲染流都是声明式的。3.1 useAgent 到底解决了什么问题最直观的答案是让 Agent 的思考过程也能变成 UI 的一部分。传统的模型流式输出只是 token 逐渐出现但 Agent 场景下你希望用户看到的是正在查询订单 - 已查到正在分析 - 生成回答这样的演进甚至在某个步骤里直接嵌入一个可交互的图表组件。useAgent 的粒度比输出文本更细。它在 Agent 每一步执行时都会产出一个对应的 UI 更新流。你可以把它理解为一个会自己画界面的 Agent——不是让模型输出 HTML 字符串而是让模型调用工具工具返回结构化的数据再由前端 React 组件把数据渲染成真实交互界面。另外很重要的一点useAgent 是纯前端的库它不锁定你的后端。你可以把LanguageModel指向你自己的服务端接口。这意味着它和 LangGraph 并不冲突——LangGraph 存在的意义依然明确后端管控数据和业务逻辑前端 useAgent 只负责呈现和交互。3.2 核心概念速览LanguageModel、Tool、useAgent、StreamuseAgent 的基础看这四个概念就够了LanguageModel一个通用接口定义了模型如何接收消息和工具定义、如何返回流式响应。你可以对接任意厂商的模型 API也可以自定义实现让它去请求你的 LangGraph Server。Tool前端工具的定义和模型解释有哪些工具可用用的是同一份定义。可以是浏览器本地执行的工具比如读取文件、获取定位也可以是通过 API 调用工具来间接触发后端能力的代理工具。useAgent核心 React Hook接收模型和工具列表返回执行函数和实时的 React 节点流。Stream用来渲染流式输出的组件Agent 跑出的内容像小溪一样流进页面每个节点可能是一个文本、一个表格、一张卡片。一个典型的前端工具定义大概长这样const queryOrderTool: Tool { name: query_order, description: 查询订单信息返回订单状态、金额、物流单号, parameters: { type: object, properties: { order_id: { type: string, description: 用户订单号 } }, required: [order_id] }, async run({ order_id }) { const res await fetch(/api/orders/${order_id}); return res.json(); } };注意run返回的是结构化数据不是字符串。useAgent 在拿到这个数据之后会把它交给前端渲染层渲染层按类型匹配对应的组件。这一下就把数据展示和模型对话解耦了。3.3 浏览器端工具与远端工具混排一种强大的组合这个能力让我很惊喜。之前的架构里客户端工具和后端工具是割裂的前端只能发消息、拿结果。useAgent 把两端工具放到了同一个 Agent 决策空间里模型可以根据情况自己决定调用浏览器本地工具还是远端工具。举一个实际例子某次我做了一个商品对比分析功能模型先调用后端工具拿到两个商品的价格数据再调用一个前端本地工具读取用户当前浏览器里选中的商品两者数据合并后再生成对比图表。这些工具都注册在同一个工具列表里模型自己排序决策调用顺序。这个设计其实对产品体验影响非常大。你可以让 Agent 直接操作当前页面比如自动填充表单、读取剪贴板、操作文件。这类浏览器能力如果都走后端playwright模拟既浪费资源又延迟高。用前端工具整个过程零网络来回响应快一个量级。提示前端工具一定不要放敏感操作比如支付确认、删除数据这类必须由后端鉴权的行为。前端工具只用于读取、展示、轻交互所有写操作一律走后端工具。4. 全栈联调LangGraph 后端与 useAgent 前端如何对接到这里前后端各自的 Agent 底盘已经搭好了真正有挑战的是让它们互相听懂对方在说什么。4.1 事件协议设计前后端必须说同一种语言后端 LangGraph 的执行过程是分步的前端 useAgent 的渲染流也是分步的。两者对接不能简单地把最终 JSON 返回给前端就算了而是要把中间过程也暴露出来。我和团队当时的做法是定义一套全栈事件协议所有 Agent 的执行过程都拆成事件流输出。事件格式大致如下事件类型含义关键字段agent_message模型生成的文本片段contenttool_call模型决定调用工具tool_name,argumentstool_result工具执行完成tool_name,resultstate_updateAgent 状态字段发生变化statefinal_answer最终答案生成完毕content前端不需要理解 LangGraph 内部逻辑只需要按事件类型更新页面。后端每次节点执行完就推送一个事件前端把事件分发给对应的 HandlerStream 就会渲染出相应的 UI。实现上我选择用 SSEServer-Sent Events而不是 WebSocket原因很简单Agent 执行是单向流模型产出事件从后端流到前端即可用户输入在上一个请求里已经发完了。SSE 基于 HTTP天然兼容代理和负载均衡实现成本比 WebSocket 低太多。当然如果你的 Agent 场景需要用户持续对话、双向推送那就用 WebSocket。后端用 FastAPI 配合 SSE 的话核心思路是生成器函数不断 yield 事件把 LangGraph 的stream方法每次产出的状态变化透传到前端from fastapi.responses import StreamingResponse import json app.post(/api/agent/run) async def run_agent(request: AgentRunRequest): config {configurable: {thread_id: request.thread_id}} def event_generator(): # 每个 super-step 都会产出一个状态快照 for step in app.stream( {messages: request.messages}, config, stream_modeupdates ): event_payload { event: state_update, data: step, } yield fdata: {json.dumps(event_payload)}\n\n return StreamingResponse( event_generator(), media_typetext/event-stream, headers{ Cache-Control: no-cache, X-Accel-Buffering: no } )前端 useAgent 这边就是在LanguageModel接口里发请求、解析 SSE 流。每解析到一个事件就根据类型触发对应的钩子生成 React 节点流。难点在于 SSE 的 buffering——很多反向代理会默认缓存响应导致前端等不到事件流直到请求结束才一次拿到全部数据。上面代码里的X-Accel-Buffering: no就是告诉 Nginx 这类代理不要缓存这个 header 一定不要省。4.2 鉴权与安全不能让 Agent 裸奔Agent 应用的攻击面比普通 API 大得多因为你暴露的不仅是数据还有操作工具的能力。我的安全设计分三层第一层是会话层鉴权。每次用户发起 Agent 请求都带一个短时效的 token后端校验 token 后从 token 里解析出用户身份再把身份注入 Agent 的 State。这样工具节点执行时能知道当前操作者是谁避免横向越权。第二层是工具层权限。State 里维护一个allowed_tools集合每个工具节点执行前先判断该用户是否有权限调用。比如普通用户不能调用修改订单金额工具客服人员才能调用。第三层是内容层安全。模型生成的回答前端渲染前要做 XSS 过滤工具返回的数据如果涉及用户隐私避免整个塞给前端而是只下发需要展示的字段。这一点在实际项目里特别容易忽视因为 Agent 的中间状态往往包含大量敏感信息如果全量透传给前端调试方便了安全却也漏了。注意useAgent 的本地工具运行在浏览器里天然就是可被用户篡改的。任何涉及资金、权限、数据变更的操作绝不能放在前端工具里必须封装成后端工具走鉴权链路。4.3 部署要点流式接口、超时和可观测性全栈 Agent 应用部署时有几个细节和普通 Web 应用不太一样。第一个是超时。LLM 调用可能超过 30 秒普通 HTTP 网关默认超时往往不够。我在实际项目中把网关的超时调到 120 秒并让前端把请求分成两段先发一个开始任务请求拿到 thread_id 之后再用 SSE 持续监听事件流。这样即使单个连接断开重连后也能从断点恢复。第二个是可观测性。Agent 应用出问题很难排查因为一个用户问题会触发多个模型调用、多次工具执行。我建议从第一天就埋好三类日志模型调用日志prompt、响应、token 数、耗时、工具执行日志输入、输出、错误栈、状态流转日志每个节点的 State 快照。日志统一打到结构化收集系统里按 thread_id 串起来排查问题就像看流水线录像一样清楚。第三个是流式限流。前端不要一股脑把流式数据渲染成大量 DOM 节点否则页面会卡。我在前端做了虚拟滚动只渲染当前视口范围内的流内容历史内容折叠到已执行 N 步的摘要里既保证用户体验又不透支浏览器性能。5. 实操复盘从空白目录到一个可跑的 Agent 应用光讲概念容易我把一个最小可运行的完整流程走一遍方便你直接复现。整个项目我称它为某简单客服 Agent Demo技术栈是 Python 后端 React 前端。我按步骤记录附带能直接用的代码骨架。5.1 完整实现步骤与目录结构建议目录长这样. ├── backend │ ├── agent.py # LangGraph 图定义 │ ├── tools.py # 工具注册与实现 │ ├── server.py # FastAPI 入口 SSE 流式接口 │ └── schemas.py # 状态定义与请求模型 ├── frontend │ ├── src │ │ ├── tools.ts # useAgent 工具定义 │ │ ├── model.ts # 自定义 LanguageModel对接后端 │ │ ├── App.tsx # useAgent Hook 入口 │ │ └── components │ │ ├── OrderCard.tsx │ │ └── ChatView.tsx │ └── package.json └── README.md第二步是定义后端工具。比如查订单# tools.py def query_order(order_id: str) - dict: 从数据库/第三方接口查询订单 order order_service.get_by_id(order_id) if not order: return {error: ORDER_NOT_FOUND, message: 没有找到该订单} return { order_id: order.id, status: order.status, amount: order.amount, items: [ {name: item.name, quantity: item.quantity} for item in order.items ] }第三步是组装 LangGraph 图。这块比两三个节点复杂一些但只要注意条件边的写法基本顺序是意图识别节点 - 工具分发节点 - 条件边对应工具名。工具执行完再回到模型节点模型判断是否继续调用工具还是直接回答。可以参考 2.2 节的节点设计。第四步是写 FastAPI Server关键是把app.stream接到 SSE 上。注意上面 4.1 节的代码块stream_modeupdates表示只输出更新的字段而不是完整 State 快照数据量小很多。第五步是前端。在 useAgent 上绑定自定义 LanguageModel它其实就是一个发起请求并解析流的方法// model.ts const customModel: LanguageModel { async *run(messages, tools) { const res await fetch(/api/agent/run, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ messages, tools }), }); if (!res.ok || !res.body) { throw new Error(Agent request failed); } const reader res.body.getReader(); const decoder new TextDecoder(); let buffer ; while (true) { const { value, done } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); const lines buffer.split(\n\n); buffer lines.pop() || ; for (const line of lines) { const event JSON.parse(line.replace(/^data: /, )); yield event; // 把事件交给 useAgent 的渲染流 } } }, };第六步是配套 UI 组件。针对tool_result事件当工具名是query_order时直接渲染OrderCard data{result} /而不需要自己拼 HTML 或字符串模板。这就是 useAgent 最省心的地方。整套跑通以后效果是用户在聊天框问查一下订单 X01 的状态页面先出现即将调用工具 query_order然后订单卡片流畅地插入聊天流紧接着模型生成一句你的订单已完成金额 199 元。全部过程不需要手动管理状态机和页面刷新。5.2 我自己踩过的几个坑最后再整理几个实操坑每一个都是我真实遇到过、并且修复过程费了不少精力的坑一LangGraph 的 State 字段被覆盖。最初的 Agent 每一轮对话只保留最后一轮的消息因为我在messages字段上没用 reducer。排查了半天最后发现文档里醒目的Annotated才是正解。建议只要是有累加语义的字段第一时间加上operator.add或者自定义 reducer。坑二SSE 被网关缓存。上线第二天用户反馈页面一直转圈最后一次性蹦出所有内容典型表现就是 Nginx 缓存了 SSE 响应。除了代码里加X-Accel-Buffering: no还要留意生产环境的代理配置有些云厂商的负载均衡还需要单独开启流式支持。坑三模型在一个死循环里反复调用同一个工具。一次对话里模型连续五次查询同一个订单号直接刷爆了后端 API 配额。我加了两个措施才彻底解决一是在状态里记录工具调用历史模型下一次调用前先判断是否重复二是设了最大步数上限超出就强制进入人工客服节点。这两个措施建议从第一天就写进图定义里不要等问题出现以后再补。坑四useAgent 的前端工具和后端工具重名。同名工具在模型眼里是两个不同的选择但它可能混淆。我的经验是前端工具命名加上ui_前缀后端工具保持业务名并且在工具描述里写清楚这类工具运行在浏览器仅用于展示类操作。描述写清楚了模型混淆的概率会大幅下降。写在最后说实话全栈 Agent 应用最难的从来不是哪一项单独的技术而是让后端的状态图和前端的交互流真正对齐。LangGraph 把复杂的业务流程变得可控useAgent 把执行过程变成了动人的界面但把它们缝合在一起的还是你对整个链路的理解和踩坑积累。这个组合我已经在实际项目中验证过稳定性和扩展性都让我满意特别是面对多轮对话、工具调用频繁、需要人工介入的场景它比任何单体框架都顺手。希望你也能在自己的项目里跑通这套链路。