FastAPI+WebSocket+LangChain实现AI Agent流式对话

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,因为它的invokestream方法签名完全一致,切换成本为零。

提示:别被“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续传)
WebSocket85ms±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": "你好"}),它实际触发的是:

  1. LLM组件启动推理,每生成一个token(如中文词元、英文subword),就触发一次on_llm_new_token回调;
  2. 这个回调被StreamingStdOutCallbackHandler捕获,但不直接发给前端,而是推入一个asyncio.Queue
  3. 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-pyaioredis库,为每个连接生成唯一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_REFUSEDcode 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:3000ws://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=csv

5.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的入口,必须和登录页一样严格守门