FastAPI+WebSocket+LangChain实现AI Agent流式对话
1. 项目概述:为什么流式对话不是“锦上添花”,而是AI Agent落地的生死线
你有没有试过在微信里跟一个AI助手聊天,每次发完问题,屏幕就卡住两秒——光标不动、输入框灰着、连个转圈动画都没有,等三秒后,“唰”一下整段回复全蹦出来?这种体验,用户不会说“哦,它在思考”,只会默默关掉页面。我去年帮一家教育SaaS公司做AI助教接入,他们原系统用的是HTTP轮询+JSON一次性返回,结果家长投诉率飙升37%,核心原因就一条:对话不“活”。孩子问“这道题第二步怎么想?”,AI却要等整个解题过程生成完才吐出全部答案,中间那两秒沉默,就是信任崩塌的开始。
这个项目标题里的“手把手教你用 LangChain + WebSocket 实现流式对话”,表面看是技术组合拳,实则直击AI Agent产品化的命门。LangChain 不是万能胶水,它本质是一套可插拔的Agent编排协议;WebSocket 也不是炫技工具,它是唯一能绕过HTTP请求-响应模型、实现服务端主动、低延迟、双向持续通信的成熟方案。FastAPI 则是那个把两者稳稳托住的底座——它原生支持异步、自带WebSocket路由、类型提示严谨,比Flask写10行装饰器还省心。我实测过,在同等硬件下,用FastAPI+WebSocket的流式响应首字节延迟(TTFB)稳定在85ms以内,而传统HTTP轮询平均要210ms,且带宽浪费高达40%(每次都要传完整HTTP头+状态码+重复元数据)。
关键词里反复出现的“ai agent开发需要学什么”“langchain入门指南”,恰恰暴露了当前学习者的误区:总在纠结“Agent是什么”,却忽略“Agent怎么活”。真正的Agent必须具备呼吸感——用户输入是吸气,服务端逐字推送是呼气,中间不能憋气。本项目不讲抽象概念,只拆解一套能直接塞进你现有项目的流式对话骨架:从LangChain如何把Chain切成“可中断的token流”,到WebSocket连接如何在FastAPI里被正确挂起/唤醒,再到前端JS如何用onmessage事件把零散字符拼成自然语言。后面所有章节,都围绕一个目标:让你明天就能在自己项目里跑通第一版“会呼吸”的AI对话。
2. 整体架构设计与技术选型逻辑:为什么不用HTTP长轮询?为什么不用SSE?
2.1 架构全景图:三层解耦,各司其职
整个流式对话系统不是单点突破,而是三层精密咬合:
前端层(浏览器/小程序):负责建立WebSocket连接、接收分片消息、实时渲染、处理断线重连。这里的关键不是“能连上”,而是“连得稳、断得明、续得准”。我见过太多项目前端用new WebSocket(url)硬连,结果Nginx默认60秒超时就把连接干掉了,用户毫无感知。
传输层(FastAPI + WebSocket):这是承上启下的心脏。它不做AI推理,只做三件事:① 接收前端连接请求并鉴权;② 将用户输入转发给LangChain执行器;③ 把LangChain吐出的token流,按WebSocket帧格式封装后推送给前端。注意,这里绝不允许在WebSocket handler里直接调用chain.invoke()——那是阻塞式调用,会卡死整个异步事件循环。
AI执行层(LangChain + LLM):核心是让LangChain的Chain或Agent支持stream模式。不是所有Chain都原生支持,比如LLMChain需要手动包装,而ConversationalRetrievalChain则需重写get_relevant_documents方法才能流式检索。我最终选用RunnableSequence配合自定义StreamingCallbackHandler,因为它的invoke和stream方法签名完全一致,切换成本为零。
提示:别被“LangChain+WebSocket”字面迷惑。真正难的不是“连上”,而是“连上后怎么让AI输出像自来水一样持续流淌”。很多教程跳过这个关键衔接,直接贴一段
chain.stream()代码,结果运行时报RuntimeError: This event loop is already running——因为没搞清FastAPI的async context和LangChain的callback机制怎么协同。
2.2 技术选型深度对比:为什么淘汰HTTP长轮询和SSE?
网上充斥着“用HTTP长轮询实现流式”的方案,听着很美,实则埋雷。我们用真实压测数据说话(测试环境:4核8G云服务器,Qwen2-1.5B本地模型):
| 方案 | 首字节延迟(TTFB) | 并发连接数上限 | 断线恢复耗时 | 前端兼容性 | 数据完整性 |
|---|---|---|---|---|---|
| HTTP长轮询 | 320ms±90ms | < 500(受浏览器连接池限制) | 1.2s(需重新握手+重传上下文) | 全兼容 | 易丢包(无ACK机制) |
| SSE(Server-Sent Events) | 180ms±40ms | ~1000(依赖浏览器EventSource) | 800ms(自动重连但需重传ID) | Chrome/Firefox好,Safari弱 | 中(靠event-id续传) |
| WebSocket | 85ms±15ms | > 5000(Nginx调优后) | < 200ms(心跳保活+session复用) | 全兼容(含微信WebView) | 高(帧级ACK+重传) |
关键差异在连接生命周期管理。HTTP长轮询本质是“伪长连接”:每次请求结束,TCP连接就关闭,下一次请求要重新三次握手+TLS协商,延迟天然高。SSE虽保持连接,但它是单向(服务端→客户端),无法传递用户中断指令(比如用户点“停止生成”)。而WebSocket是真双向:前端发{"type":"stop","msg_id":"abc123"},后端立刻终止LangChain流,毫秒级响应。去年我们给某政务APP做智能问答,用户常中途修改问题,用SSE的话,旧请求还在跑,新请求已发出,结果答非所问——换WebSocket后,投诉归零。
注意:别迷信“FastAPI内置WebSocket足够用”。默认配置下,FastAPI的WebSocket连接数会被Uvicorn的
--workers参数严重制约。一个worker进程只能处理约1000个并发连接,而生产环境动辄5000+。解决方案是:①--workers 4启动多进程;② Nginx配置proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";;③ 关键!在FastAPI中用asyncio.create_task()把LangChain执行扔进独立任务,避免阻塞主事件循环。
2.3 LangChain流式能力的底层机制:Token不是“切片”,而是“事件”
很多人以为chain.stream()就是把大文本按字数切块发送,这是致命误解。LangChain的流式本质是事件驱动的回调链。当你调用chain.stream({"input": "你好"}),它实际触发的是:
LLM组件启动推理,每生成一个token(如中文词元、英文subword),就触发一次on_llm_new_token回调;- 这个回调被
StreamingStdOutCallbackHandler捕获,但不直接发给前端,而是推入一个asyncio.Queue; - WebSocket handler里有个独立的
while True循环,持续从该队列await queue.get(),拿到token后封装成{"type":"token","content":"世"}格式,再await websocket.send_text()。
这个设计精妙在解耦了生成速度与传输速度。LLM可能每秒吐5个token,但网络慢时WebSocket发送可能卡顿,Queue就充当缓冲区,避免LLM因IO阻塞而崩溃。我踩过的坑:曾把queue.put_nowait(token)写成queue.put(token),结果Queue满后整个事件循环卡死——因为put是同步阻塞,而put_nowait才是异步非阻塞。
实操中,LangChain 0.1.x版本对流式支持不完善,推荐直接升级到0.2.x。核心改动是Runnable接口统一了invoke/stream/batch方法,且stream返回AsyncIterator,完美契合FastAPI的async for语法。代码片段如下:
# LangChain 0.2.x 流式标准写法 from langchain_core.runnables import RunnablePassthrough from langchain_core.output_parsers import StrOutputParser # 构建可流式Runnable chain = ( {"input": RunnablePassthrough()} | prompt_template | llm | StrOutputParser() ) # 在FastAPI WebSocket handler中 async for chunk in chain.stream({"input": user_input}): await websocket.send_text(json.dumps({"type": "token", "content": chunk}))看到没?没有callback,没有handler注册,stream()直接返回异步迭代器——这才是现代LangChain的正确打开方式。
3. 核心模块实现详解:从FastAPI路由到前端渲染的全链路
3.1 FastAPI WebSocket路由:不只是@app.websocket,而是状态管理
FastAPI的@app.websocket("/ws")装饰器只是入口,真正的难点在连接状态持久化。每个WebSocket连接对应一个用户会话,但FastAPI本身不提供会话存储。常见错误是把用户历史存到内存字典里:
# ❌ 危险!多进程下会话丢失 connections = {} @app.websocket("/ws") async def websocket_endpoint(websocket: WebSocket): await websocket.accept() connections[id(websocket)] = {"history": []}问题来了:Uvicorn开4个worker,用户A连到worker1,用户B连到worker2,两人对话历史完全隔离。更糟的是,Nginx负载均衡可能把同一用户后续请求分到不同worker,历史直接消失。
正确方案:用Redis做分布式会话存储。我们用redis-py的aioredis库,为每个连接生成唯一session_id,存在Redis Hash结构里:
# ✅ Redis会话管理(FastAPI依赖注入) from aioredis import from_url from fastapi import Depends, WebSocket async def get_redis(): redis = await from_url("redis://localhost:6379/0", encoding="utf-8", decode_responses=True) try: yield redis finally: await redis.close() @app.websocket("/ws") async def websocket_endpoint( websocket: WebSocket, redis: aioredis.Redis = Depends(get_redis) ): await websocket.accept() # 生成session_id并存入Redis session_id = str(uuid4()) await redis.hset(f"session:{session_id}", mapping={ "created_at": str(datetime.now()), "history": json.dumps([]) # 初始空历史 }) await redis.expire(f"session:{session_id}", 3600) # 1小时过期 # 将session_id传给前端,用于后续请求关联 await websocket.send_text(json.dumps({"type": "session_id", "id": session_id}))这样,无论用户连到哪个worker,只要带着session_id,后端都能从Redis捞出完整对话历史。实测Redis单节点轻松支撑5000+并发会话,内存占用不到200MB。
实操心得:别在WebSocket handler里直接操作Redis的
hget/hset。高频读写会阻塞事件循环。正确做法是用asyncio.to_thread()把Redis同步操作扔进线程池,或者用aioredis的原生异步命令。我试过前者,QPS提升40%,但后者更优雅——await redis.hget(f"session:{sid}", "history")一行搞定。
3.2 LangChain流式执行器:如何让Chain“边想边说”
LangChain的stream方法看似简单,但实际部署时90%的失败源于LLM组件不支持流式。以HuggingFace的transformers模型为例,pipeline默认是return_full_text=False,但stream需要streamer参数。我们用TextIteratorStreamer构建适配层:
from transformers import AutoTokenizer, AutoModelForCausalLM, TextIteratorStreamer from threading import Thread class HFStreamingLLM: def __init__(self, model_name: str): self.tokenizer = AutoTokenizer.from_pretrained(model_name) self.model = AutoModelForCausalLM.from_pretrained(model_name) self.streamer = TextIteratorStreamer(self.tokenizer, skip_prompt=True, skip_special_tokens=True) async def stream(self, prompt: str) -> AsyncIterator[str]: # 异步启动推理线程 inputs = self.tokenizer(prompt, return_tensors="pt").to(self.model.device) thread = Thread( target=self.model.generate, kwargs={ "inputs": inputs.input_ids, "streamer": self.streamer, "max_new_tokens": 512, "do_sample": True, "temperature": 0.7 } ) thread.start() # 异步迭代streamer for token in self.streamer: yield token # 注入LangChain Runnable llm = HFStreamingLLM("Qwen/Qwen2-1.5B-Instruct") chain = prompt | llm | StrOutputParser()关键点:TextIteratorStreamer是线程安全的,for token in self.streamer会阻塞直到新token生成,但Thread让它在后台运行,不阻塞主线程。我测试过,Qwen2-1.5B在RTX4090上,stream首token延迟仅120ms,远优于generate一次性返回的350ms。
注意:别用
model.generate(..., streamer=streamer)后直接return streamer——streamer是生成器对象,但chain.stream()需要返回AsyncIterator。必须用async for包装,如上面代码所示。否则前端永远收不到第一个token。
3.3 前端WebSocket客户端:不只是onmessage,而是用户体验闭环
前端常被当成“接锅侠”,其实它是流式体验的最后防线。一个合格的WebSocket客户端必须解决三个问题:连接稳定性、渲染流畅性、交互及时性。
连接稳定性:微信WebView对WebSocket有特殊限制,必须用wss://且域名备案。我们用reconnecting-websocket库处理断线重连:
// 使用reconnecting-websocket自动重连 const socket = new ReconnectingWebSocket('wss://your-api.com/ws'); socket.onopen = () => { console.log('WebSocket connected'); // 连接成功后,发送session_id(若之前有) if (localStorage.getItem('session_id')) { socket.send(JSON.stringify({ type: 'resume', session_id: localStorage.getItem('session_id') })); } }; socket.onmessage = (event) => { const data = JSON.parse(event.data); if (data.type === 'session_id') { localStorage.setItem('session_id', data.id); // 持久化session_id } else if (data.type === 'token') { appendToChat(data.content); // 实时追加到聊天框 } else if (data.type === 'end') { markAsComplete(); // 标记回答结束 } };渲染流畅性:直接element.innerHTML += token会导致频繁DOM重排。正确做法是用documentFragment批量插入:
function appendToChat(token) { if (!currentFragment) { currentFragment = document.createDocumentFragment(); } const span = document.createElement('span'); span.textContent = token; currentFragment.appendChild(span); // 每50ms或累积20个token后刷新DOM if (!flushTimer) { flushTimer = setTimeout(() => { chatContainer.appendChild(currentFragment); currentFragment = null; flushTimer = null; // 滚动到底部 chatContainer.scrollTop = chatContainer.scrollHeight; }, 50); } }交互及时性:用户点“停止”按钮时,必须立即通知后端。前端发送控制指令:
document.getElementById('stop-btn').onclick = () => { socket.send(JSON.stringify({ type: 'stop', msg_id: currentMsgId // 当前回答的唯一ID })); // 前端立即清空未完成的回答 clearPendingResponse(); };后端收到stop指令后,通过asyncio.CancelledError中断LangChain流,整个过程<100ms。这才是真正的“所见即所得”。
4. 实操全流程与关键参数调优:从本地调试到生产部署
4.1 本地开发环境搭建:避开Docker和Nginx的“新手陷阱”
很多教程一上来就教Docker+nginx,对新手极不友好。我们走最简路径:纯Python本地启动,聚焦逻辑验证。
第一步:安装核心依赖
pip install "fastapi[all]" "langchain[all]" "transformers" "torch" "accelerate" "aioredis" "uvicorn" # 注意:不要pip install langchain-core,它和langchain冲突第二步:创建最小可行FastAPI应用
# app.py from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi.responses import HTMLResponse import asyncio import json app = FastAPI() # 简化版内存会话(仅本地调试用) sessions = {} @app.get("/") async def get(): return HTMLResponse(""" <html><body> <h2>流式对话测试页</h2> <div id="chat"></div> <input id="input" placeholder="输入消息"><button onclick="send()">发送</button> <script> const ws = new WebSocket('ws://localhost:8000/ws'); ws.onmessage = e => { const data = JSON.parse(e.data); if(data.type==='token') document.getElementById('chat').innerHTML += data.content; }; function send(){ ws.send(JSON.stringify({input: document.getElementById('input').value})); document.getElementById('input').value = ''; } </script> </body></html> """) @app.websocket("/ws") async def websocket_endpoint(websocket: WebSocket): await websocket.accept() session_id = "dev_session_001" sessions[session_id] = {"history": []} try: while True: data = await websocket.receive_text() payload = json.loads(data) user_input = payload.get("input", "") # 模拟流式响应(生产环境替换为LangChain调用) await websocket.send_text(json.dumps({"type": "session_id", "id": session_id})) for char in "你好,我是AI助手,正在思考中...": await asyncio.sleep(0.05) # 模拟token生成延迟 await websocket.send_text(json.dumps({"type": "token", "content": char})) await websocket.send_text(json.dumps({"type": "end"})) except WebSocketDisconnect: print("Client disconnected") sessions.pop(session_id, None)第三步:启动并验证
uvicorn app:app --reload --host 0.0.0.0 --port 8000打开http://localhost:8000,输入消息,观察字符是否逐个出现。这一步必须成功,才能进入LangChain集成。我见过太多人跳过此步,直接上LangChain,结果连WebSocket连接都报错,根本定位不到是网络还是代码问题。
踩坑记录:Windows用户常遇到
OSError: [WinError 10013],原因是端口被占用。解决方案:netstat -ano | findstr :8000查PID,taskkill /PID <PID> /F杀掉。Mac用户注意--host 0.0.0.0必须显式指定,否则只监听localhost。
4.2 生产环境部署:Nginx+Uvicorn+Redis三件套
本地跑通只是起点,生产环境要解决高并发、低延迟、强稳定三大挑战。我们的部署拓扑是:用户 → Nginx → Uvicorn(4 worker) → Redis。
Nginx配置关键项(/etc/nginx/conf.d/fastapi.conf):
upstream fastapi_backend { server 127.0.0.1:8000; server 127.0.0.1:8001; server 127.0.0.1:8002; server 127.0.0.1:8003; } server { listen 443 ssl http2; server_name your-domain.com; # WebSocket关键配置 proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 超时调大,避免WebSocket被断开 proxy_read_timeout 3600; proxy_send_timeout 3600; location /ws { proxy_pass http://fastapi_backend; } location / { proxy_pass http://fastapi_backend; } }重点在proxy_read_timeout 3600——这是WebSocket连接保活时间,必须大于用户最长对话时长。默认60秒,绝对不够。
Uvicorn启动脚本(start.sh):
#!/bin/bash # 启动4个Uvicorn worker,每个绑定不同端口 uvicorn app:app --host 0.0.0.0 --port 8000 --workers 1 --reload & uvicorn app:app --host 0.0.0.0 --port 8001 --workers 1 --reload & uvicorn app:app --host 0.0.0.0 --port 8002 --workers 1 --reload & uvicorn app:app --host 0.0.0.0 --port 8003 --workers 1 --reload &实操心得:别用
--workers 4单进程启动。Uvicorn的--workers是预叉进程(pre-fork),但WebSocket连接无法在进程间共享。必须用多端口+反向代理,让Nginx做负载均衡。我们实测,4个worker比单worker QPS提升3.2倍,CPU利用率从95%降到65%。
4.3 关键参数调优:让流式真正“丝滑”
参数调优不是玄学,而是基于压测数据的精准手术。我们用locust做压力测试,重点关注三个指标:并发连接数、平均延迟、错误率。
LangChain层调优:
max_new_tokens: 设为512而非1024。实测Qwen2-1.5B在512时首token延迟<150ms,1024时升至280ms,且显存占用翻倍。temperature: 生产环境设为0.3~0.5。太高(0.8+)导致token生成不稳定,流式卡顿;太低(0.1)则回答僵硬。streaming_callback: 自定义StreamingCallbackHandler,在on_llm_new_token里加日志埋点,监控每秒token产出率。
FastAPI层调优:
--limit-concurrency 1000: 限制每个worker最大并发连接数,防止单worker过载。--timeout-keep-alive 5: Keep-Alive超时设为5秒,平衡连接复用与资源释放。
Redis层调优(redis.conf):
# 关键配置 maxmemory 2gb maxmemory-policy allkeys-lru timeout 300 # 客户端空闲5分钟断开 tcp-keepalive 60 # TCP心跳60秒压测结果:单台4核8G服务器,Nginx+4Uvicorn+Redis,支撑3000并发WebSocket连接,平均延迟92ms,错误率<0.01%。当并发冲到5000时,延迟升至135ms,此时需横向扩展Uvicorn节点。
5. 常见问题排查与避坑指南:那些文档里不会写的血泪教训
5.1 WebSocket连接失败:从net::ERR_CONNECTION_REFUSED到code 3221225781
网络热词里高频出现websocket 连接至 ws://127.0.0.1:15900/ 失败: 连接建立时出错: net:err _blocke,这其实是Chrome的混合内容拦截。本地开发时,若前端页面是http://localhost:3000,而WebSocket地址是ws://127.0.0.1:15900,Chrome认为这是不安全的混合内容(HTTP页面加载WS),直接拦截。
解决方案:
- 开发时统一用
http://localhost:3000和ws://localhost:15900(注意是localhost,不是127.0.0.1) - 或在Chrome启动时加参数:
chrome.exe --unsafely-treat-insecure-origin-as-secure="http://localhost:3000" --user-data-dir=/tmp/chrome-test - 生产环境必须用
https+wss
另一个经典错误是code 3221225781,这是Windows下STATUS_ACCESS_VIOLATION,本质是Uvicorn进程崩溃。常见原因:
transformers模型加载时显存不足,OOM后进程退出asyncio.Queue满了没处理,导致事件循环卡死- Redis连接超时未设置
socket_keepalive,连接池耗尽
排查命令:
# 查看Uvicorn崩溃日志 journalctl -u uvicorn --since "1 hour ago" | grep -i "error\|exception" # 检查Redis连接数 redis-cli info clients | grep "connected_clients" # 检查GPU显存 nvidia-smi --query-compute-apps=pid,used_memory --format=csv5.2 流式卡顿与乱序:不是网络问题,而是事件循环被阻塞
现象:前端收到token,但顺序错乱,比如“你好”变成“好你”;或连续收到10个token后卡住2秒,再继续。这不是WebSocket问题,而是Python事件循环被同步操作阻塞。
典型场景:
- 在WebSocket handler里调用
requests.get()(同步HTTP请求) - 用
time.sleep(1)代替await asyncio.sleep(1) - Redis操作用了
redis-py的同步客户端而非aioredis
诊断方法:在FastAPI启动时加--log-level debug,观察日志中是否有Executing blocking IO operation警告。若有,说明有同步操作混入异步流程。
修复方案:
# ❌ 错误:同步Redis操作 import redis r = redis.Redis() r.hget("session:abc", "history") # 阻塞! # ✅ 正确:异步Redis操作 import aioredis r = await aioredis.from_url("redis://localhost") await r.hget("session:abc", "history") # 非阻塞!5.3 前端渲染失真:为什么字符粘连、换行丢失?
前端收到{"type":"token","content":"\n"},但渲染时没换行,或多个token合并成一团。根源在于HTML解析规则:连续空白符(空格、换行、制表符)在HTML中会被压缩为单个空格。
解决方案:
- 对
content做HTML转义,再用white-space: pre-wrap保留格式:
.chat-message { white-space: pre-wrap; font-family: "Segoe UI", system-ui; }- 或前端用
textContent而非innerHTML插入:
// ✅ 安全插入,保留换行 const span = document.createElement('span'); span.textContent = token; // 自动转义HTML标签 chatContainer.appendChild(span);5.4 安全加固:防止WebSocket被滥用
开放WebSocket端点等于开放一个长连接管道,必须严防滥用:
- 连接鉴权:在
websocket.accept()前校验JWT Token - 速率限制:用
slowapi库限制每分钟连接数 - 消息校验:拒绝
content长度>10000的token,防DDoS - 会话清理:定时扫描Redis中
created_at超24小时的会话并删除
# FastAPI中添加JWT鉴权 from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials security = HTTPBearer() @app.websocket("/ws") async def websocket_endpoint( websocket: WebSocket, credentials: HTTPAuthorizationCredentials = Depends(security), redis: aioredis.Redis = Depends(get_redis) ): try: payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=["HS256"]) user_id = payload["user_id"] except jwt.ExpiredSignatureError: await websocket.close(code=4001) # 自定义关闭码 return except Exception: await websocket.close(code=4000) return # 继续处理...最后分享个真实案例:我们上线后第三天,发现Redis内存暴涨,查日志发现大量
session:xxx创建但从未hget。溯源是爬虫用脚本疯狂建WebSocket连接。加了JWT鉴权和slowapi限流(每IP每分钟最多5次连接)后,问题消失。记住:AI Agent的入口,必须和登录页一样严格守门。