LangGraph、FastAPI、Streamlit三件套搭建生产级AI助手
发布时间:2026/10/1 5:16:04来源:尧图网络
这年头谁还没用ChatGPT写过一版AI助手demo但真到了生产环境问题就全冒出来了多轮对话的上下文怎么接管、工具调用的边界怎么控制、接口扛不扛得住并发、页面卡了后端到底在干什么、日志里为什么什么都查不到。如果你正好撞上这些问题那这篇文章就是给你准备的。这篇攻略以LangGraph为核心编排层、FastAPI做服务化出口、Streamlit搭交互界面三件套组合起来从零完整搭一套生产级AI助手。我会把每一步的为什么这么设计和实际踩过的坑都交代清楚适合已经写过简单Agent、想往工程化方向走的开发者参考。1. 为什么是这三件套生产级AI助手的架构分层1.1 三件套的职责边界先说结论LangGraph、FastAPI、Streamlit这三者不是三个组件硬凑在一起而是各管一个核心层次恰好把AI助手切成了三块——工作流编排、服务化暴露、交互交付。LangGraph负责的是大脑里的决策路径。过去用LangChain的AgentExecutor也能写带工具的Agent但问题是逻辑是黑盒模型要调哪个工具、循环几次、什么时候结束全靠框架内部判断出问题很难排查。LangGraph把这条链路显式变成一张图节点是模型决策和工具执行边是是否继续调用工具的条件判断。生产环境里这种可控性比省几行代码重要得多。FastAPI负责的是接口契约。AI助手一旦要上生产就必须被别的服务调用、被前端页面调用、被监控系统探测。FastAPI基于Pydantic做请求/响应校验原生支持异步和流式响应这两点对LLM服务来说简直是刚需——请求校验挡住脏数据异步不阻塞事件循环流式响应就是打字机效果的底层保障。Streamlit负责的是最后一公里。它不追求前端的极致美观但能用几十行Python就出一个可交互的聊天界面并且自带会话状态管理思路。对于内部工具、私有化部署、快速验证产品形态的场景Streamlit是性价比很高的选择。三者的关系可以类比成一家餐厅LangGraph是后厨的配菜流水线决定每道菜按什么顺序做FastAPI是传菜窗口规定所有菜品必须从这里出去Streamlit就是顾客面前的餐桌。配菜怎么做、怎么传、怎么摆盘各有各的规则但合起来才是一家能正常营业的餐厅。另外提一个对比项很多人会用Gradio替代Streamlit。Gradio做算法演示确实快但Streamlit的会话模型、布局能力和State管理更接近一个真正的Web应用所以我在生产项目里优先选Streamlit。1.2 生产级到底指什么我见过不少项目Demo跑得飞起一上生产就趴窝基本都栽在下面这几件事上第一可恢复。用户聊到一半进程重启了对话上下文能不能完整恢复LangGraph的Checkpoint机制就是干这个的。第二可控。工具调用不能无边界模型说调就调超时怎么办、失败怎么办、敏感操作要不要人工确认第三可观测。一个Agent从收到请求到吐出回复中间可能经历了模型调用、工具调用、再次模型调用每一步的耗时、消耗、结果都要能追踪。第四可扩展。用户多了是单进程扛还是多Worker扛状态存内存还是存数据库这直接决定架构上限。这篇文章后面所有设计都会围绕这四个维度展开。你不会看到那种能跑但别问为什么的玩具代码而是每一步都解释清楚它为什么是这么设计的。2. 核心细节解析5个让AI助手真正可用的关键设计2.1 会话状态与持久化add_messages和Checkpoint先说LangGraph里最核心的状态管理机制。LangGraph的State是一个TypedDict每个节点返回部分状态更新框架负责合并。多轮对话的messages字段一般用Annotated类型加上add_messages reducer这样每轮新消息会被追加到列表尾部而不是覆盖旧消息。from typing import Annotated, TypedDict from langchain_core.messages import AnyMessage from langgraph.graph.message import add_messages class AgentState(TypedDict): messages: Annotated[list[AnyMessage], add_messages]这个设计的意义在于你不需要手动维护历史消息新消息的拼接逻辑模型返回的AIMessage、工具返回的ToolMessage都会自动追加进State。在多节点图里任何一个节点都能拿到完整对话历史。但光有State还不够生产环境需要持久化。LangGraph的Checkpoint机制会把每一步的状态快照存起来绑定一个thread_id。下次请求带上同一个thread_idAgent就能从上次中断的位置继续跑。这个能力有三个实际用途多轮对话续接用户关掉页面再打开历史还在。断点恢复Agent在等待人工确认时被中断确认后能继续执行。审计追溯每一步的输入输出都有据可查。开发时用SqliteSaver最省事它是本地文件数据库零配置。但请注意SqliteSaver有并发写锁问题生产多Worker部署时建议切换到PostgresSaver这个我在第4部分会专门讲。from langgraph.checkpoint.sqlite import SqliteSaver checkpointer SqliteSaver.from_conn_string(./checkpoints.db) graph workflow.compile(checkpointercheckpointer)2.2 工具调用的安全与超时控制工具调用是Agent能力的放大器也是生产事故的高发区。你可能已经发现了模型返回的tool_calls只是一段JSON里面有工具名和参数真正执行是在你注册的工具函数里。这中间每一环都要做防守。首先是工具白名单。不要把你所有的内部函数都直接注册成工具。暴露给模型的工具必须经过筛选这个工具是否安全、参数是否会被恶意利用、返回值是否包含敏感信息。我见过有人把数据库删除函数注册给Agent模型一旦被Prompt注入诱导后果不堪设想。其次是超时控制。LLM调用工具是异步的等待一个工具如果卡住不返回整个Agent的循环就卡死了。所以我给每个工具函数都加上超时保护用concurrent.futures或asyncio.wait_for都可以原则是宁可让模型说工具调用失败也不要让整个请求挂半小时。import asyncio from langchain_core.tools import tool tool async def get_order_status(order_id: str) - str: 查询订单状态输入订单编号返回最新物流与状态。 async def _query(): # 实际的业务查询逻辑 return {order_id: order_id, status: shipped} try: return await asyncio.wait_for(_query(), timeout10) except asyncio.TimeoutError: return 查询超时请稍后重试再有就是执行结果的错误兜底。工具抛异常不能直接让整张图崩掉要在工具函数内部try/except把错误信息构造成ToolMessage返回给模型让模型基于错误信息做出下一步决策。这才是Agent自主纠错的正确姿势。2.3 流式输出从转圈等结果到边算边写用户能忍受转圈等待的时间上限大概在3秒但一个多工具调用的Agent完整跑完可能超过10秒。怎么办答案就俩字流式。LangGraph支持多种流式模式但生产里我主要用两种消息流step by step的Agent运行状态比如正在调用工具、工具执行完毕。Token流模型生成过程中的逐token增量聊天框里的打字机效果靠它实现。Token流在SSEServer-Sent Events协议下传回前端最方便。SSE其实就是一条HTTP长连接服务端往里面一段一段写data: 内容\n\n前端onmessage就能实时收到。相比WebSocketSSE实现更简单且天然支持HTTP的断线重连、代理、鉴权。流式输出具体怎么在FastAPI和Streamlit里落地我放在第3部分展开。这里只提醒一个关键原则前端永远不要等着接收一个完整JSON响应后端尽早开始往流里写数据哪怕第一条消息是正在思考。2.4 可观测性与错误追踪日志、指标、Trace生产级系统和Demo最大的区别就是出了问题你能不能快速定位。LLM应用的可观测性有自己的特殊性传统日志只能告诉你模型调用报错了但你想知道的往往是模型在那一轮为什么决定调用那个工具是Prompt的问题还是上下文的问题。我的做法是三层追踪第一层是应用日志。FastAPI中间件统一记录每个请求的thread_id、耗时、Token用量。标准做法是用结构化的JSON日志每个字段都可检索。第二层是Agent运行追踪。LangGraph本身支持LangSmith、Langfuse这类Trace平台可以把每一步的LLM输入输出、工具参数、耗时全部串成一条Trace。如果没有外部平台也可以自己给每个节点加日志装饰器把节点的输入输出落库。注意一定要打上thread_id和时间戳。第三层是业务指标。接口的QPS、平均首Token延迟、平均总延迟、工具调用失败率、Token消耗总量这些指标NaN出来之后配合告警规则才能在你被用户骂之前发现问题。这里再多说一句安全日志里不要记完整对话内容尤其涉及用户隐私或内部业务数据时。可以把消息截断、脱敏之后再做记录否则日志系统一泄露比对话泄露还麻烦。2.5 多用户会话隔离与并发控制生产环境一定不止一个用户。LangGraph的thread_id天然隔离会话上下文但你要做的是保证thread_id是按用户维度生成的且不可被用户伪造相互访问。实际操作里我会在FastAPI层做一个简单的鉴权依赖从Header里取用户身份把thread_id强制绑定为user_id_会话编号而不是信任客户端传什么就是什么。另外并发控制也要提前设计。同一thread_id同时进来两个请求Checkpoint写入会互相覆盖状态就乱了。我的做法是对同一个thread_id加Redis分布式锁或者在服务端做单线程队列。这个问题不解决生产环境一定会在高流量下爆雷。3. 实操落地从零跑通一条完整的AI助手链路3.1 项目结构与环境准备先说结论项目结构从一开始就要按核心分离来拆否则写到后面就是一团乱麻。我推荐的最小可维护结构是这样的app/ main.py # FastAPI入口启动服务 schemas.py # 请求/响应Pydantic模型 streamlit_app.py # Streamlit交互页面 agent/ __init__.py graph.py # LangGraph图定义、编译 state.py # AgentState状态schema tools.py # 业务工具注册与实现 core/ config.py # 模型配置、环境变量 logging.py # 结构化日志配置 requirements.txt把agent单独拆一个包是因为它是整条链路的核心FastAPI和Streamlit都依赖它但不应该互相依赖。Streamlit将来如果换成别的前端FastAPI的接口不变graph也不用动。依赖版本这里直接给一份我实测过能跑的组合langgraph0.2.60 langchain0.3.0 langchain-openai0.2.0 fastapi0.115.0 uvicorn[standard]0.30.0 streamlit1.40.0 openai1.40.0有个经验教训LangGraph和LangChain的版本更新很快API变动也很频繁。不要随便pip install -U全部升级锁定版本号才是生产环境的基本礼貌。我见过太多人早上升级完依赖下午项目就报TypeError了。3.2 LangGraph核心图状态、工具节点、条件边现在写核心的graph.py。为了让逻辑清晰我手写条件判断来替代黑盒的AgentExecutor前置封装这样你能看到Agent是如何决定调用工具的。先在state.py里定义状态from typing import Annotated, TypedDict from langgraph.graph.message import add_messages class AgentState(TypedDict): messages: Annotated[list, add_messages] # 如果需要额外状态比如当前订单号、用户身份可以继续加字段再在tools.py里定义业务工具。这里用一个查询订单状态的示例工具import asyncio from langchain_core.tools import tool tool async def get_order_status(order_id: str) - str: 查询订单状态输入订单编号返回最新物流与状态信息。 # 实际业务中这里会查询后端订单系统 await asyncio.sleep(0.2) return {order_id: order_id, status: 已发货, eta: 2天后送达}然后在graph.py里构建流程图。这里我不绕弯子直接用LangGraph推荐的ToolNode方式同时显式写出条件边from langchain_openai import ChatOpenAI from langgraph.graph import StateGraph, START, END from langgraph.prebuilt import ToolNode def build_agent(): tools [get_order_status] llm ChatOpenAI(modelgpt-4o-mini, temperature0.3) llm_with_tools llm.bind_tools(tools) async def call_model(state): response await llm_with_tools.ainvoke(state[messages]) return {messages: [response]} def should_continue(state): last_message state[messages][-1] if last_message.tool_calls: return tools return end workflow StateGraph(AgentState) workflow.add_node(agent, call_model) workflow.add_node(tools, ToolNode(tools)) workflow.add_edge(START, agent) workflow.add_conditional_edges(agent, should_continue, {tools: tools, end: END}) workflow.add_edge(tools, agent) return workflow.compile()这个图跑起来的逻辑是这样的用户消息进入agent节点模型判断是否需要调用工具。如果需要should_continue返回tools进入工具节点。工具执行结果作为ToolMessage追加到状态里。然后回到agent节点模型看到工具返回结果继续生成最终回答或者再次决定调下一个工具。直到模型生成的消息不再包含tool_calls图走到END。你可能会问为什么要工具结果再喂给模型绕一圈因为工具返回的是结构化数据模型需要把这些数据组织成自然语言回复。这个工具-CoT-回复的循环就是Agent比普通ChatBot聪明的地方。3.3 FastAPI服务层SSE流式接口与请求校验FastAPI要干的活有三件接收请求、调用LangGraph、以SSE流式返回。请求和响应都用Pydantic模型约束这是FastAPI的看家本领。schemas.pyfrom pydantic import BaseModel, Field class ChatRequest(BaseModel): thread_id: str Field(..., description会话ID由客户端生成或从历史会话获取) message: str Field(..., min_length1, max_length4000) class ChatResponse(BaseModel): thread_id: str content: strmain.py里最关键的是SSE生成器。这里我直接使用LangGraph的astream_events来捕获模型token流。注意事件过滤on_chat_model_stream事件表示模型正在逐token生成import json from fastapi import FastAPI from fastapi.responses import StreamingResponse from langchain_core.messages import HumanMessage from app.agent.graph import build_agent app FastAPI() graph build_agent() app.post(/v1/chat/stream) async def chat_stream(req: ChatRequest): config {configurable: {thread_id: req.thread_id}} async def event_generator(): try: async for event in graph.astream_events( {messages: [HumanMessage(contentreq.message)]}, configconfig, versionv2 ): # 只捕获模型输出的token增量 if event[event] on_chat_model_stream: chunk event[data][chunk] token chunk.content if hasattr(chunk, content) else str(chunk) if token: payload {type: token, content: token} yield fdata: {json.dumps(payload, ensure_asciiFalse)}\n\n # 如果捕获到工具调用事件也可以推送给前端展示 elif event[event] on_tool_start: tool_event {type: tool, name: event[name]} yield fdata: {json.dumps(tool_event, ensure_asciiFalse)}\n\n except Exception as e: error_payload {type: error, content: str(e)} yield fdata: {json.dumps(error_payload, ensure_asciiFalse)}\n\n yield data: [DONE]\n\n return StreamingResponse( event_generator(), media_typetext/event-stream, headers{Cache-Control: no-cache, X-Accel-Buffering: no} )这里有两个细节值得单独拿出来说headers里的X-Accel-Buffering必须设为no。如果你在Nginx后面部署Nginx默认会缓冲响应导致SSE流被攒在一起前端体验跟普通请求没区别打字机效果全无。我在两个事件里都push了数据token事件让前端看到字一个一个蹦出来tool事件让前端知道正在调用什么工具。别忘了健康检查接口生产环境一定要有app.get(/health) async def health_check(): return {status: ok}3.4 Streamlit交互层聊天UI与流式渲染Streamlit端有两种对接方式一种是在Streamlit进程里直接import刚才的graph对象不经过HTTP层另一种是Streamlit作为纯前端用httpx调FastAPI的SSE接口。两种都行的但我建议你按团队协作方式来选。如果是单人小项目Streamlit直接import graph省掉一层HTTP延迟更低写起来也简单。但如果是团队开发前端和后端分开部署Streamlit上云、FastAPI上独立服务那就必须走HTTP接口。下面这段代码以HTTP方式为例这样架构上是解耦的import json import streamlit as st import httpx API_BASE http://127.0.0.1:8000 def init_session(): if messages not in st.session_state: st.session_state.messages [] if thread_id not in st.session_state: st.session_state.thread_id sess_ str(int(time.time() * 1000)) def stream_chat(thread_id, message): with httpx.stream( POST, f{API_BASE}/v1/chat/stream, json{thread_id: thread_id, message: message}, timeout300 ) as resp: for line in resp.iter_lines(): if not line.startswith(data: ): continue data line[6:] if data [DONE]: break event json.loads(data) if event[type] token: yield event[content] elif event[type] tool: yield f\n\n 正在调用工具{event[name]}\n\n init_session() for msg in st.session_state.messages: with st.chat_message(msg[role]): st.markdown(msg[content]) prompt st.chat_input(请输入你的问题) if prompt: st.session_state.messages.append({role: user, content: prompt}) with st.chat_message(user): st.markdown(prompt) with st.chat_message(assistant): response_area st.empty() collected [] for chunk in stream_chat(st.session_state.thread_id, prompt): collected.append(chunk) response_area.markdown(.join(collected)) full_response .join(collected) st.session_state.messages.append({role: assistant, content: full_response})Streamlit的rest思想很符合AI助手交互场景st.chat_message管气泡展示st.chat_input管输入框st.session_state管消息历史。上面代码里我用手动渲染的方式逐chunk刷新内容比直接st.write_stream多一点控制权如果你想在流式过程中插入工具调用信息手动渲染更灵活。有个小坑提醒你Streamlit每次交互都会从头执行整个脚本所以页面上的变量如果要跨交互保留必须放进st.session_state。thread_id也是否则每次刷新都生成新会话多轮对话就断掉了。这段代码里在init_session里把thread_id存下来就是干这个的。3.5 部署与性能调优uvicorn、多Worker与状态存储本地跑通了部署时还有几个关键决策。首先是进程模型。FastAPI用uvicorn跑开发时uvicorn app.main:app --reload就够了。生产时我会加--workers 4再多Worker意义不大毕竟瓶颈通常在LLM API的IO等待上4个Worker配合异步基本能打满单机吞吐。但多Worker有一个坑如果你用的是SqliteSaver每个Worker进程的本地内存状态是不共享的。Worker 1处理的会话如果被负载均衡分到Worker 2Checkpoint对不上对话就串了。解决方案有两个要么--workers 1牺牲部分并发能力要么换PostgresSaver把状态存到共享数据库。生产级方案一定是后者。然后是限流。LLM API有配额有成本不能任由一个用户狂发请求把你的预算打爆。FastAPI层面可以加个简单的令牌桶限流中间件按用户维度限制每分钟请求数。不要等模型API报429了才后悔那是既浪费钱又影响其他用户的体验。最后是配置管理。模型名、API Key、API Base这些不能写死在代码里用环境变量或配置中心管理。API Key尤其注意千万别提交到Git仓库否则整个项目代码一旦泄露key就全完了。4. 常见问题与排查技巧实录4.1 LangGraph版本API变化与依赖锁定LangGraph从0.1到0.2再到0.3API变化很大。最典型的是Checkpoint的导入路径早期版本在langgraph.checkpoint后来拆出了langgraph.checkpoint.sqlite、langgraph.checkpoint.postgres。你再网上搜教程很多文章用的还是老写法直接复制结果就是ImportError。这类问题排查起来很简单看报错信息里的ModuleNotFoundError去对应包目录里找正确的路径就行。但更聪明的做法是进入项目第一天就pip freeze requirements.txt把版本锁死升级依赖时单独开一个分支全量测试。别问为什么问就是曾经在生产环境被langchain升级整出过事故。4.2 流式响应断连与资源泄漏SSE连接是长连接但用户可能中途关掉页面、刷新、切走。前端断开了后端如果还傻傻地继续跑Agent、继续调LLM那就是浪费一次模型调用最终也传不回去。资源越积越多服务迟早拖垮。FastAPI里可以用request.is_disconnected()检测客户端是否断开。在事件生成器里每轮循环检查一次断开就break掉。实现上并不复杂但很多教程根本没提这茬。真实生产环境里我见过一次线上事故就是用户刷新页面导致Agent任务堆积最终把数据库连接池打满这问题必须正视。from fastapi import Request app.post(/v1/chat/stream) async def chat_stream(req: ChatRequest, request: Request): async def event_generator(): async for event in graph.astream_events(...): if await request.is_disconnected(): break yield ... return StreamingResponse(event_generator(), media_typetext/event-stream)4.3 SQLite并发写锁database is lockedSqliteSaver在单进程里没问题但多线程并发访问时经常报database is locked。LangGraph内部开启了多线程异步执行和回调线程如果本地开多个Worker进程写同一个checkpoints.db文件锁冲突几乎是必然的。我用SqliteSaver时都会显式设置checkpoint_same_threadFalse但请记住这只是把报错从It is not safe to use this connection转移到更隐蔽的写锁上。真心建议用户量一大直接上PostgresSaver。不要在高并发场景里跟SQLite较劲它就不是干这活儿的工具。4.4 Streamlit状态丢失与rerun机制Streamlit的脚本是每次交互从头跑一遍全部代码这个机制对新手极其容易造成迷惑。最常见的错误是直接给模块级变量赋值比如thread_id xxx然后下一个交互发现它又变回初始值了。解决方案只有一个一切跨交互的变量都放st.session_state。我在项目中还遇到过另一个坑把LangGraph的graph对象放进session_state。graph是不可序列化的Streamlit在rerun之间对session_state做序列化操作graph对象直接炸。正解是让graph保持模块级单例session_state里只放普通的字符串、列表。4.5 FastAPI中同步阻塞与事件循环FastAPI虽然是异步框架但如果你在async def接口里写了同步阻塞代码比如time.sleep()、同步的requests.get()、同步的数据库查询会导致整个事件循环卡住其他所有请求一起遭殃。这不是影响性能是拖垮全站。排查这类问题看两个信号CPU没满但所有请求都变慢、日志里出现大段gap。解决办法是把同步阻塞调用放到线程池用run_in_threadpool或anyio.to_thread或者干脆把接口定义成普通defFastAPI会自动丢到线程池执行。对于LangGraph的同步版本最简单的方式是在async函数里用await asyncio.to_thread(graph.invoke, ...)。4.6 部分修复补充async接口里调用同步LangGraph顺带说一下如果你的LangGraph用的是同步CompiledGraph比如没有启用异步节点在FastAPI的async函数里直接graph.invoke()会阻塞事件循环。此时你有两条路一是改造成异步节点把节点函数写成async调用时用ainvoke和astream二是用asyncio.to_thread包一层。两条我都走过从代码整洁度来看改造异步节点更彻底但工作量大从快速上线来看to_thread是性价比最高的逃生舱。5. 最后分享一点我的实际体会这套组合我已经在几个不同业务的项目里落地过最大的体会是技术栈从来不是难点难点在于一开始就把生产两个字放在心上。LangGraph的学习曲线不在API而在你想清楚什么时候用工具、什么时候用interrupt做人工确认、什么时候拆子图。流程越清晰后面出问题的概率越低。如果再让我追加一个小技巧第一次搭建时不要急着塞一堆工具进去先把模型-单工具-流式返回这条最短链路跑通再逐步加工具、加人工确认、加多会话隔离。每加一个环节就回归测试一轮流式体验——流式一旦断了用户马上就会感知到这是所有AI助手应用里体验损伤最大的问题。这套东西能扩展的方向还有很多比如接入多模态、加Agent间协作、把工具调用升级成事件驱动的异步流程。希望你在自己的项目里也能把AI助手从能跑推到能用、能扛、能查、能救的生产级水准。
网站建设高端定制企业官网