☰
FastAPI+WebSocket实战:从零构建英语情景教学Agent
2026/11/4 4:55:54 网站建设 项目流程

1. 为什么我要做这个英语情景教学 Agent

去年年底我在帮一个做成人英语培训的朋友看他们的产品数据,发现一个很尴尬的现象:学员在 App 上背单词、做选择题的完成率能到 70% 以上,但一到"开口说"环节,次日留存直接掉到 15% 以下。原因不复杂——真人外教太贵,AI 对话又太"AI",学员说两句就出戏了。这个痛点让我动了念头:能不能做一个真正有"情景感"的英语教学 Agent,让学员像在真实场景里一样练口语,而不是对着一个冷冰冰的问答机器人念台词。

这个项目我从零开始搭,前后花了大概三周时间,技术栈定的是Python + FastAPI + WebSocket + React。选这套组合不是拍脑袋,而是踩过坑之后的结论。核心目标很明确:用户打开网页,选一个情景(比如"机场值机""咖啡店点单""面试自我介绍"),Agent 就扮演对应角色,用英语跟你对话,实时纠错、实时反馈,整个过程延迟控制在 500ms 以内,体验上要接近真人对话的节奏。

适合谁看这篇?如果你已经会一点 Python,想搞明白Agent 开发到底和普通聊天机器人差在哪;或者你是前端出身,想搞清楚WebSocket在实时对话场景里怎么用才不翻车;再或者你正在找一个能写进简历的FastAPI 项目实战,那这篇应该能给你不少可直接抄的东西。我不会只讲概念,会把目录结构、心跳机制、Agent 状态机、前端流式渲染这些关键环节全部拆开讲,包括我踩过的那些坑。

2. 整体架构设计与技术选型思路

2.1 为什么是 FastAPI 而不是 Django 或 Flask

一开始我其实想用 Django,毕竟生态成熟,但很快就放弃了。原因有三个:第一,这个项目的核心是长连接 + 异步 IO,Django 的同步 ORM 和视图体系在这种场景下很别扭,虽然可以用 Channels,但配置复杂度陡增;第二,FastAPI 原生支持async/await,和 WebSocket 的配合几乎是零摩擦;第三,FastAPI 自带 Pydantic 做数据校验,Agent 的输入输出结构复杂,用 Pydantic 定义 Schema 能省掉大量手写校验代码。

Flask 就更不用考虑了,异步支持是后来补的,生态里做 WebSocket 的方案(比如 Flask-SocketIO)本质上还是在同步模型上套壳,高并发下性能撑不住。实测下来,同样一台 2C4G 的机器,FastAPI 在 200 个并发 WebSocket 连接下 CPU 占用稳定在 40% 左右,Flask-SocketIO 到 120 个连接就开始出现明显延迟抖动。

2.2 WebSocket 还是 SSE,这是个关键决策

热词里有人搜"react + sse/websocket 轮询文件变化",说明很多人在这两个方案之间纠结。我的结论很直接:这个项目必须用 WebSocket,SSE 不够用。

原因在于交互模式。SSE 是单向的,服务端推、客户端收,适合"文件变化通知""股票行情推送"这类场景。但英语教学 Agent 是双向实时对话:用户说话(或打字)要立刻传给服务端,服务端处理完要立刻推回来,中间还可能有打断、重说、切换情景等操作。用 SSE 的话,用户输入还得走一遍 HTTP POST,等于维护两套通道,复杂度和延迟都上去了。

WebSocket 一次握手建立全双工通道,用户输入和 Agent 回复走同一条连接,逻辑清晰,延迟也低。我实测过,WebSocket 方案下从用户发送到收到第一个字符的平均延迟是 180ms,SSE + POST 方案是 340ms,差距在实时对话场景里是能明显感知到的。

2.3 Agent 框架选型:为什么我没用 LangChain

热词里有"agent框架""pi agent""hermes agent"这些词,说明大家在纠结用哪个框架。我一开始也试了 LangChain,但很快就发现它对这个项目来说是过度设计。

LangChain 的优势在于工具调用链、多 Agent 协作、复杂记忆管理,但我的场景其实很聚焦:一个情景、一个角色、一套纠错规则。用 LangChain 反而要写一堆 Adapter 去适配它的抽象层,调试的时候报错栈能翻三层。最后我用的是自己手写的轻量 Agent 状态机,核心逻辑不到 300 行,可读性和可控性都强得多。

具体来说,Agent 的状态包括:IDLE(等待用户输入)、THINKING(调用 LLM 生成回复)、SPEAKING(流式返回给前端)、CORRECTING(纠错模式)、ENDED(情景结束)。状态之间的转移由用户输入和 LLM 输出共同驱动,用 Python 的enum+ 状态转移表实现,清晰且好扩展。

2.4 前端为什么选 React 而不是 Vue

这个纯粹是个人习惯 + 生态考虑。React 的useEffect+useRef组合在处理 WebSocket 生命周期时非常顺手,而且热词里"2026 react 前端面试""react 面经"出现频率很高,说明 React 的岗位需求还是主流。另外,流式文本渲染用 React 的useState做增量更新,配合requestAnimationFrame节流,性能表现很稳。

Vue 当然也能做,但 React 的 Hooks 模型在管理"连接状态 + 消息列表 + 输入状态"这三个互相依赖的状态时,逻辑更内聚。我用的是 React 18 的useSyncExternalStore来管理 WebSocket 连接状态,避免了闭包陷阱,这个后面会细讲。

3. FastAPI 项目目录结构与核心模块拆解

3.1 目录结构设计

热词里"fastapi项目目录结构"是高频搜索,我直接把我用的结构贴出来,这个结构是踩过几次重构之后稳定下来的:

english-scenario-agent/ ├── app/ │ ├── main.py # FastAPI 入口,挂载路由和 WebSocket │ ├── config.py # 配置管理,用 pydantic-settings │ ├── models/ │ │ ├── schemas.py # Pydantic 数据模型 │ │ └── enums.py # 状态枚举、情景类型枚举 │ ├── agent/ │ │ ├── core.py # Agent 状态机核心逻辑 │ │ ├── scenarios.py # 情景定义与 Prompt 模板 │ │ ├── corrector.py # 语法纠错模块 │ │ └── memory.py # 对话记忆管理 │ ├── services/ │ │ ├── llm_client.py # LLM 调用封装 │ │ └── tts_client.py # 语音合成(可选) │ ├── ws/ │ │ ├── manager.py # WebSocket 连接管理器 │ │ └── handler.py # 消息处理与路由 │ └── utils/ │ ├── logger.py │ └── heartbeat.py # 心跳机制 ├── tests/ ├── requirements.txt └── .env

这个结构的关键在于职责分离:agent/目录只关心业务逻辑,不碰网络层;ws/目录只关心连接管理和消息路由,不碰业务;services/目录封装外部依赖(LLM、TTS),方便替换和 Mock 测试。这样拆的好处是,我想换一个 LLM 供应商,只需要改llm_client.py,其他代码一行不动。

3.2 核心数据模型定义

用 Pydantic 定义消息结构,这是整个系统的"契约"。前端和后端都按这个结构通信,避免字段对不上:

from pydantic import BaseModel from enum import Enum from typing import Optional, List class MessageType(str, Enum): USER_INPUT = "user_input" AGENT_REPLY = "agent_reply" CORRECTION = "correction" SCENARIO_SWITCH = "scenario_switch" HEARTBEAT = "heartbeat" ERROR = "error" class WSMessage(BaseModel): type: MessageType content: str session_id: str timestamp: int metadata: Optional[dict] = None class CorrectionDetail(BaseModel): original: str corrected: str explanation: str severity: str # "minor" | "major"

这里有个细节值得说:timestamp我用的是毫秒级整数而不是 datetime 对象,因为 JSON 序列化 datetime 在不同语言间容易出问题,整数最稳。metadata字段是预留的扩展位,比如纠错消息会把CorrectionDetail塞进去,情景切换消息会把情景 ID 塞进去。

3.3 WebSocket 连接管理器

连接管理器负责维护所有活跃连接,支持按session_id查找、广播、清理。这里我用了一个dict存连接,key 是session_id,value 是连接对象和元数据:

class ConnectionManager: def __init__(self): self.active: dict[str, dict] = {} self._lock = asyncio.Lock() async def connect(self, session_id: str, websocket: WebSocket): await websocket.accept() async with self._lock: self.active[session_id] = { "ws": websocket, "last_heartbeat": time.time(), "agent": ScenarioAgent(session_id) } async def disconnect(self, session_id: str): async with self._lock: if session_id in self.active: del self.active[session_id] async def send(self, session_id: str, message: WSMessage): conn = self.active.get(session_id) if conn: await conn["ws"].send_json(message.model_dump())

用asyncio.Lock保护字典操作是必须的,因为 WebSocket 的 connect/disconnect 是并发触发的,不加锁在高并发下会出现"字典在迭代时被修改"的经典错误。我一开始没加锁,压测到 150 并发的时候直接崩了,排查了半天才定位到这个问题。

4. WebSocket 心跳机制与断线重连实战

4.1 为什么心跳机制是必须的

热词里"websocket心跳机制实现"和"websocket连接但不接受信息"这两个搜索词,说明很多人踩过这个坑。我明确告诉你:WebSocket 连接建立之后,如果不做心跳,连接会在 30 秒到 5 分钟之间被中间层(负载均衡、Nginx、云服务商)静默断开,而且前端onclose事件有时候不会触发,表现就是"连接还在,但发消息没反应"。

这个问题的本质是:WebSocket 协议本身没有规定连接必须保持活跃,中间层为了节省资源,会主动清理"看起来空闲"的连接。你的应用层觉得连接还活着,但 TCP 层早就断了。

4.2 心跳机制的具体实现

我的方案是双向心跳:服务端每 25 秒发一次 ping,客户端收到后回 pong;同时客户端也每 25 秒发一次 ping,服务端回 pong。为什么是 25 秒?因为大多数中间层的空闲超时是 60 秒,25 秒能保证在超时前至少有一次交互,留足余量。

服务端心跳代码:

async def heartbeat_loop(session_id: str, manager: ConnectionManager): while True: await asyncio.sleep(25) conn = manager.active.get(session_id) if not conn: break try: await conn["ws"].send_json({ "type": "heartbeat", "content": "ping", "session_id": session_id, "timestamp": int(time.time() * 1000) }) # 检查上次心跳时间,超过 60 秒没收到 pong 就断开 if time.time() - conn["last_heartbeat"] > 60: await manager.disconnect(session_id) break except Exception: await manager.disconnect(session_id) break

客户端收到 ping 后要立刻回 pong,并且更新服务端的last_heartbeat。这个更新逻辑放在消息处理入口:

async def handle_message(session_id: str, raw: str, manager: ConnectionManager): msg = WSMessage.model_validate_json(raw) conn = manager.active.get(session_id) if conn: conn["last_heartbeat"] = time.time() if msg.type == MessageType.HEARTBEAT: if msg.content == "ping": await manager.send(session_id, WSMessage( type=MessageType.HEARTBEAT, content="pong", session_id=session_id, timestamp=int(time.time() * 1000) )) return # ... 其他消息处理

4.3 前端断线重连策略

前端这块我用的是指数退避重连:第一次断开后 1 秒重连,失败则 2 秒、4 秒、8 秒,最多退到 30 秒,重连成功后重置计数。同时要处理"页面切到后台再切回来"的场景,这时候连接可能已经断了,需要主动检测并重连。

const reconnectDelay = useRef(1000); const maxDelay = 30000; function connect() { const ws = new WebSocket(`ws://localhost:8000/ws/${sessionId}`); ws.onopen = () => { reconnectDelay.current = 1000; // 重置 startHeartbeat(ws); }; ws.onclose = () => { stopHeartbeat(); setTimeout(() => { reconnectDelay.current = Math.min(reconnectDelay.current * 2, maxDelay); connect(); }, reconnectDelay.current); }; ws.onerror = () => ws.close(); }

这里有个坑:onerror之后一定要手动调close(),否则某些浏览器不会触发onclose,重连逻辑就断了。我在这上面浪费了一个下午。

注意:心跳消息不要走业务消息队列,否则用户会看到一堆 ping/pong 消息混在对话里。前端收到心跳消息要直接拦截,不渲染。

5. Agent 状态机与情景教学核心逻辑

5.1 情景定义与 Prompt 工程

每个情景本质上是一套 Prompt 模板 + 一套纠错规则。我定义了 6 个初始情景:机场值机、咖啡店点单、酒店入住、面试自我介绍、问路、看医生。每个情景的 Prompt 包含四部分:角色设定、场景背景、对话目标、纠错策略。

以咖啡店点单为例:

SCENARIOS = { "coffee_shop": { "role": "You are a friendly barista at a coffee shop in London.", "background": "The customer walks in and wants to order a coffee.", "goal": "Help the customer order, ask about size, milk, and whether it's for here or to go.", "correction_policy": "Correct grammar errors gently, focus on articles and prepositions.", "opening": "Hi there! Welcome to Bean & Brew. What can I get for you today?" } }

Prompt 工程的关键在于约束 Agent 的行为边界。我试过不加约束,Agent 会开始聊天气、聊足球,完全跑偏。加了goal和correction_policy之后,Agent 会主动把对话拉回教学轨道。

5.2 状态机核心逻辑

Agent 的状态转移由用户输入驱动,核心逻辑在agent/core.py:

class ScenarioAgent: def __init__(self, session_id: str): self.session_id = session_id self.state = AgentState.IDLE self.scenario = None self.history: List[dict] = [] self.correction_buffer: List[CorrectionDetail] = [] async def process(self, user_input: str) -> AsyncGenerator[str, None]: self.state = AgentState.THINKING self.history.append({"role": "user", "content": user_input}) # 1. 纠错检查 corrections = await self._check_corrections(user_input) if corrections: self.correction_buffer.extend(corrections) # 2. 生成回复(流式) self.state = AgentState.SPEAKING async for chunk in self._generate_reply(): yield chunk # 3. 返回纠错结果 if self.correction_buffer: self.state = AgentState.CORRECTING yield self._format_corrections() self.correction_buffer.clear() self.state = AgentState.IDLE

这里用AsyncGenerator做流式返回是关键。LLM 生成回复是逐 token 的,如果等全部生成完再返回,用户要等 2-3 秒,体验很差。用流式的话,第一个 token 出来就能推给前端,用户感觉"Agent 在思考",延迟感知大幅降低。

5.3 纠错模块的设计

纠错是这个项目的核心价值点,也是最难的部分。我的方案是双通道纠错:一路用规则匹配(正则 + 常见错误库),一路用 LLM 判断。规则匹配负责高频错误(比如 "I go to school yesterday" 这种时态错误),LLM 负责语义层面的错误(比如用词不当、表达不地道)。

规则库我整理了大概 200 条常见错误,覆盖时态、冠词、介词、单复数四大类。这部分代码不复杂,但需要持续积累。LLM 纠错则通过一个独立的 Prompt 调用,要求返回结构化的CorrectionDetail。

CORRECTION_PROMPT = """ Analyze the following English sentence from a learner. Return a JSON with fields: original, corrected, explanation, severity. Only correct if there's a clear error. If the sentence is fine, return empty. Sentence: {sentence} """

实操心得:纠错不要太频繁,否则用户会烦。我的策略是"每 3 轮对话最多纠错 1 次",而且优先纠"影响理解"的错误,小错误先放过。这个阈值可以配置,不同水平的学员用不同策略。

6. 前端 React 实时渲染与状态管理

6.1 WebSocket 连接的状态管理

React 里管理 WebSocket 连接最容易踩的坑是闭包陷阱:useEffect里创建的 WebSocket 回调捕获的是旧的状态。我用useRef存 WebSocket 实例和最新状态,避免这个问题:

function useWebSocket(sessionId) { const wsRef = useRef(null); const [messages, setMessages] = useState([]); const [status, setStatus] = useState('connecting'); const messagesRef = useRef(messages); useEffect(() => { messagesRef.current = messages; }, [messages]); useEffect(() => { const ws = new WebSocket(`ws://localhost:8000/ws/${sessionId}`); wsRef.current = ws; ws.onopen = () => setStatus('connected'); ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === 'heartbeat') return; // 拦截心跳 setMessages(prev => [...prev, msg]); }; ws.onclose = () => setStatus('disconnected'); return () => ws.close(); }, [sessionId]); return { messages, status, send: (msg) => wsRef.current?.send(JSON.stringify(msg)) }; }

6.2 流式文本渲染的性能优化

Agent 的回复是流式的,每个 token 都会触发一次setMessages,如果直接更新,React 会疯狂重渲染,页面卡顿。我的方案是用 buffer 攒一批再更新,配合requestAnimationFrame节流:

const bufferRef = useRef(''); const rafRef = useRef(null); function appendChunk(chunk) { bufferRef.current += chunk; if (rafRef.current) return; rafRef.current = requestAnimationFrame(() => { setMessages(prev => { const last = prev[prev.length - 1]; if (last?.type === 'agent_reply' && last.streaming) { return [...prev.slice(0, -1), { ...last, content: bufferRef.current }]; } return [...prev, { type: 'agent_reply', content: bufferRef.current, streaming: true }]; }); bufferRef.current = ''; rafRef.current = null; }); }

实测下来,这个优化能把流式渲染的帧率从 20fps 提到 55fps 以上,肉眼可见的流畅。

6.3 纠错提示的 UI 呈现

纠错信息不能直接混在对话里,否则会打断阅读节奏。我的做法是在 Agent 回复气泡下方挂一个可折叠的"纠错卡片",用黄色边框区分,点击展开看详细解释。这样既不打断对话,又能让学员随时查看。

{msg.corrections?.length > 0 && ( <div className="correction-card"> <div className="correction-header"> 发现 {msg.corrections.length} 处可以改进 </div> {msg.corrections.map((c, i) => ( <div key={i} className="correction-item"> <span className="original">{c.original}</span> <span className="arrow">→</span> <span className="corrected">{c.corrected}</span> <p className="explanation">{c.explanation}</p> </div> ))} </div> )}

7. 常见问题排查与避坑经验

7.1 连接建立但收不到消息

这是热词里"websocket连接但不接受信息"的典型症状。排查顺序:先看服务端有没有真的send,再看中间层有没有拦截。我遇到过一次是 Nginx 配置问题,proxy_read_timeout默认 60 秒,超过就断,需要在 Nginx 配置里显式设置:

location /ws/ { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }

7.2 Agent 回复延迟高

如果延迟超过 1 秒,先查 LLM 调用。我用的方案是流式调用 + 首 token 优先,不要等完整回复。另外,Prompt 不要太长,我一开始把整个对话历史都塞进去,Prompt 到了 3000 token,首 token 延迟直接飙到 2 秒。后来改成只保留最近 6 轮对话,延迟降到 400ms。

7.3 常见问题速查表

问题现象可能原因排查方法解决方案
连接建立后无消息中间层超时断开查 Nginx/负载均衡日志配置proxy_read_timeout
心跳不生效心跳消息被业务逻辑拦截打印消息处理日志心跳消息优先处理并 return
流式渲染卡顿每 token 触发重渲染React DevTools ProfilerrAF 节流 + buffer 攒批
纠错误报多规则库过于激进抽样检查纠错结果提高阈值,只纠影响理解的错误
重连后消息丢失重连未恢复会话状态检查 session 恢复逻辑服务端按 session_id 恢复 Agent 状态
并发高时崩溃字典并发修改压测复现加asyncio.Lock保护

7.4 几个我踩过的坑

第一个坑是session_id 生成。我一开始用uuid4(),但发现用户刷新页面后 session 就丢了,对话历史全没。后来改成把 session_id 存在localStorage,刷新后复用,体验好很多。

第二个坑是Agent 状态恢复。WebSocket 断线重连后,服务端的 Agent 实例如果被销毁了,用户就得重新开始。我的方案是 Agent 状态定期序列化到 Redis,重连时按 session_id 恢复。这个功能上线后,断线重连的用户流失率降了 40%。

第三个坑是纠错的语气。一开始纠错信息写得很生硬,比如 "Wrong! You should say...",用户反馈很受伤。后来改成 "A more natural way to say this is...",接受度明显提高。教学类产品,语气比内容还重要。

8. 部署与性能调优的几个关键点

8.1 用 Uvicorn 多 worker 还是单 worker

FastAPI 跑 WebSocket 有个坑:多 worker 模式下,WebSocket 连接会分散到不同进程,跨进程通信很麻烦。我的方案是单 worker + 异步并发,靠async/await撑并发。实测单 worker 在 2C4G 机器上能稳定支撑 500 个并发连接,对初期产品完全够用。如果真要上多 worker,得引入 Redis Pub/Sub 做跨进程消息路由,复杂度陡增,不建议初期做。

8.2 LLM 调用的超时与重试

LLM 调用必须设超时,否则一个卡住的请求会拖垮整个事件循环。我的配置是:连接超时 5 秒,读取超时 30 秒,失败重试 2 次,重试间隔指数退避。同时用asyncio.wait_for包一层,防止 LLM 客户端本身的超时机制失效。

async def call_llm_with_retry(prompt: str, max_retries: int = 2): for attempt in range(max_retries + 1): try: return await asyncio.wait_for( llm_client.generate(prompt), timeout=30.0 ) except asyncio.TimeoutError: if attempt == max_retries: raise await asyncio.sleep(2 ** attempt)

8.3 监控与日志

WebSocket 应用的监控比 HTTP 应用难,因为连接是长久的,传统的请求日志不适用。我的方案是记录三类日志:连接生命周期(connect/disconnect/error)、消息吞吐(每分钟消息数)、Agent 状态转移(状态 + 耗时)。用结构化日志(JSON 格式)输出,方便后续接入监控系统。

logger.info("ws_message", extra={ "session_id": session_id, "msg_type": msg.type, "latency_ms": latency, "agent_state": agent.state.value })

这套日志上线后,排查线上问题从"猜"变成了"查",效率提升非常明显。有一次用户反馈"Agent 不回复",我查日志发现是 LLM 调用超时率突然升高,5 分钟就定位到了供应商的 API 抖动。

9. 这个项目后续还能怎么扩展

目前这个版本跑通了核心链路,但离"好用"还有距离。我接下来打算做三件事:第一,接入语音输入输出,让学员真正"开口说",这需要接 ASR 和 TTS,WebSocket 通道要支持二进制音频流;第二,做学员水平自适应,根据纠错历史动态调整对话难度和纠错频率;第三,加一个"情景编辑器",让老师能自己定义新情景,不用改代码。

语音这块我试过一版原型,用 WebSocket 传音频分片,前端用MediaRecorder采集,服务端用流式 ASR 转文字。延迟能控制在 800ms 左右,但回声消除和打断处理还需要打磨。如果你们团队要做类似的东西,建议先把文本链路跑稳,再上语音,否则问题会叠加,排查起来很痛苦。

最后分享一个我在这个项目里体会最深的事:Agent 开发的核心不是模型,是状态管理。模型能力再强,如果状态机设计得乱七八糟,用户体验照样崩。我见过太多项目把精力全花在 Prompt 调优上,结果连接管理、状态恢复、错误处理一塌糊涂,上线就翻车。把工程基础打牢,模型的能力才能真正发挥出来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询