背景痛点:为什么本地跑得起,上线就崩?
第一次把 Chatbot UI 从localhost搬到公网,90% 的人会踩这三坑:
- 跨域:前端 3000,后端 8000,浏览器一堵,WebSocket 直接 403。
- 会话:刷新页面后 ThreadID 丢失,AI“失忆”,用户以为掉线。
- 延迟:串流式回答没做队列,并发一高,后端被 OpenAI 的慢响应拖垮,前端 UI 卡成 PPT。
下面按“选型→骨架→通信→优化→避坑”五段式,带你一次性把 Chatbot UI 从 0 推到生产环境。
技术选型:别在“全家桶”里迷路
| 端 | 候选 | 适用场景 | 不推荐理由 |
|---|---|---|---|
| 前端 | React | 生态大、虚拟滚动库多、WebSocket 库稳 | 包体积略大 |
| Vue | 模板友好,中小项目快 | 大型对话组件生态不如 React | |
| Angular | 企业级自带 DI、路由、RxJS | 学习曲线陡,重 | |
| 后端 | Flask | 脚本式,5 分钟原型 | 异步支持弱,并发高时阻塞 |
| FastAPI | 原生异步、类型提示、自动生成文档 | 需要理解 Python async | |
| Django | ORM+Admin 开箱即用 | 重,配置多,WebSocket 需 Channels |
结论:
“React + FastAPI” 在实时性与可维护性之间最平衡,下文全部基于此组合。
核心实现:30 分钟搭出可扩展骨架
1. 前端:create-react-app 一脚踢
npx create-react-app chatbot-ui --template typescript cd chatbot-ui npm i socket.io-client @tanstack/react-virtual # 通信+虚拟滚动目录约定(便于 ESLint 统一):
plaintext src/ ├─ api/ # 封装 axios、WebSocket ├─ components/ # 纯 UI ├─ hooks/ # 业务逻辑 └─ utils/ # 工具函数2. WebSocket 双向通信(带重连)
src/api/socket.ts
import { io, Socket } from 'socket.io-client'; const URL = process.env.REACT_APP_WS_URL || 'ws://localhost:8000'; class WsClient { private socket: Socket | null = null; private reconnectTimer: NodeJS.Timeout | null = null; connect(token: string) { this.socket = io(URL, { auth: { token }, transports: ['websocket'], }); this.socket.on('connect', () => { console.log('[WS] connected'); if (this.reconnectTimer) clearTimeout(this.reconnectTimer); }); this.socket.on('disconnect', () => { console.warn('[WS] lost, schedule reconnect'); this.reconnectTimer = setTimeout(() => this.connect(token), 3000); }); this.socket.on('exception', (err) => console.error('[WS] error:', err)); } sendMessage(payload: any) { this.socket?.emit('human_message', payload); } onBotMessage(cb: (data: any) => void) { this.socket?.on('bot_message', cb); } disconnect() { this.socket?.disconnect(); } } export default new WsClient();使用 hook 封装,组件层无感知:
src/hooks/useChat.ts
import { useEffect, useState } from 'react'; import ws from '../api/socket'; export default function useChat() { const [msgs, setMsgs] = useState<any[]>([]); useEffect(() => { const token = localStorage.getItem('token') || ''; ws.connect(token); ws.onBotMessage((chunk) => { setMsgs((prev) => { const copy = [...prev]; const last = copy[copy.length - 1]; if (last?.role === 'bot') { last.content += chunk; // 流式拼接 } else { copy.push({ role: 'bot', content: chunk }); } return copy; }); }); return () => { ws.disconnect(); }; }, []); const send = (text: string) => { setMsgs((x) => [...x, { role: 'human', content: text }]); ws.sendMessage({ text, thread_id: localStorage.getItem('thread') }); }; return { msgs, send }; }3. 后端:FastAPI + JWT 中间件
main.py
from fastapi import FastAPI, WebSocket, Depends, HTTPException from fastapi.middleware.cors import CORSMiddleware from jose import JWTError, jwt # jose 轻量 import redis.asyncio as redis import asyncio, json, os, uuid app = FastAPI(title='Chatbot API') # ---------- 配置 ---------- SECRET = os.getenv('JWT_SECRET', 'dev-secret-change-me') ORIGINS = ['http://localhost:3000', 'https://yourdomain.com'] r = redis.from_url(os.getenv('REDIS_URL', 'redis://localhost:6379/0')) app.add_middleware( CORSMiddleware, allow_origins=ORIGINS, allow_credentials=True, allow_headers=['*'], ) # ---------- JWT 中间件 ---------- async def get_current_user(token: str = Depends(lambda x: x.headers.get('authorization', '').replace('Bearer ', ''))): try: payload = jwt.decode(token, SECRET, algorithms=['HS256']) return payload['sub'] except JWTError: raise HTTPException(status_code=401, detail='Invalid token') # ---------- WebSocket ---------- class ConnectionManager: def __init__(self): self.active: dict[str, WebSocket] = {} async def connect(self, uid: str, ws: WebSocket): await ws.accept() self.active[uid] = ws def disconnect(self, uid: str): self.active.pop(uid, None) async def send(self, uid: str, msg: str): ws = self.active.get(uid) if ws: await ws.send_text(msg) manager = ConnectionManager() @app.websocket('/ws') async def websocket_endpoint(ws: WebSocket, token: str): try: user = jwt.decode(token, SECRET, algorithms=['HS256'])['sub'] except: await ws.close(code=1008, reason='Unauthorized') return await manager.connect(user, ws) try: while True: data = await ws.receive_json() # 投递到队列,立即返回 await r.lpUSH('chat_queue', json.dumps({'user': user, 'msg': data})) except: manager.disconnect(user) # ---------- 队列消费者 ---------- async def consumer(): while True: _, job = await r.brpop('chat_queue', timeout=1) if not job: continue job = json.loads(job) uid, text = job['user'], job['msg']['text'] # 模拟 LLM 流式回答 for ch in f'echo: {text}\n': await manager.send(uid, ch) await asyncio.sleep(0.02) @app.on_event('startup') async def start_consumer(): asyncio.create_task(consumer())代码均带类型提示,符合 PEP8(black一键格式化)。
性能优化:高并发也不掉线
- 消息队列:上面已用 Redis
brpop做最简队列;生产环境可换 RabbitMQ / Kafka,并按 UID 做分片,保证同一用户顺序。 - 虚拟滚动:对话过千条时 DOM 节点爆炸,用
@tanstack/react-virtual只渲染可视区,滚到哪儿插到哪儿,CPU 占用从 70% 降到 10%。 - 流式背压:LLM 吐 token 过快,前端用
requestAnimationFrame限流,防止 setState 疯狂重渲染。
避坑指南:上线前 checklist
- Chrome 跨域:本地
localhost到127.0.0.1都算跨域!allow_origins一定写全;另外给 WebSocket 也加auth字段,避免withCredentials被屏蔽。 - 会话持久化:ThreadID 放
localStorage最简;多端同步可改存 Redis,键user:{sub}:thread。 - XSS 过滤:前端
DOMPurify.sanitize()后再插入 DOM;后端对 Markdown 先转义再返回,禁止直接回显 HTML。 - 日志脱敏:生产环境把用户消息打码(掩码手机号、身份证),防止 GDPR / 国内 PI 合规踩雷。
代码规范:让同事不骂你
- Python:统一
black+isort,行宽 88,提交前pre-commit自动钩。 - TypeScript:
.eslintrc继承react-app+@typescript-eslint,any必须写注释说明理由。 - 提交信息:
<type>(scope): <subject>,例fix(ws): handle abnormal close code 1006,CHANGELOG 自动生成。
延伸思考:多租户隔离怎么做?
单实例部署时,所有用户共享同一套 Redis 队列与 LLM token 池。若面向 B 端,需要:
- 路由层按子域名或 Header 中的
X-Tenant-ID切分队列; - 数据库/Redis 加
tenant_前缀; - LLM 调用限流按租户维度计数,防止 A 客户把 B 客户带宽吃光。
你可以基于上文骨架,把uid换成tenant:uid复合键,试试看!
结尾体验:把“能说话”的 Demo 跑起来
如果你不想自己踩一遍环境坑,可以直接上手从0打造个人豆包实时通话AI动手实验。我跟着教程 20 分钟就把语音通话调通了,ASR→LLM→TTS 全链路打通,比自己拼接省掉 70% 配置时间。小白也能顺利体验,建议本地跑通后再回读本文,把 WebSocket 文本模式升级成语音模式,数字人就能“开口说话”了。祝编码愉快!