
英语情景教学这个方向我断断续续折腾了大半年。最早的想法特别朴素能不能做一个能跟人用英语聊天的Agent而且不是那种干巴巴的问答机器人而是能把你拉进一个具体场景里——比如机场值机、酒店入住、餐厅点单——让你在对话中把英语用起来。市面上不缺背单词的App也不缺通用聊天机器人但真正能把情景和教学这两件事捏在一起的工具用下来总觉得差点意思。要么场景太假要么反馈太泛要么就是延迟高得让人出戏。于是我决定自己从零搭一个前端用React后端用FastAPI实时通信走WebSocket核心是一个能理解场景、能纠错、能引导的英语教学Agent。这篇文章我会把整个开发过程拆开讲包括为什么这么选型、WebSocket在实时教学场景里到底解决了什么问题、Agent的对话逻辑怎么设计、并发怎么扛、以及我在调试过程中踩过的那些坑。如果你也在做类似的教育类Agent或者想找一个完整的FastAPI加React加WebSocket的实战项目练手这篇应该能给你省不少时间。1. 为什么英语情景教学需要一个Agent而不是普通聊天机器人1.1 普通对话机器人在教学场景里的三个硬伤我最早试过直接用通用大模型API套一个聊天界面让用户跟模型用英语对话。跑起来很快但用了几次就发现根本没法当教学工具用。第一个硬伤是没有场景约束。你问它我想练机场值机它确实能跟你聊但聊着聊着就飘了可能突然开始讨论天气或者推荐旅游景点。教学需要的是一个封闭的、有明确目标的对话空间用户在里面扮演一个角色Agent扮演另一个角色双方围绕一个具体任务推进。没有这个约束对话就变成了漫无目的的闲聊。第二个硬伤是纠错反馈太泛。通用模型倾向于鼓励式回复Great job!、Thats correct!说了一堆但用户真正说错的地方它不一定指出来。教学场景需要的是精准的、有针对性的反馈时态用错了、介词搭配不对、用词不地道这些都得点出来而且要用用户能理解的方式解释。第三个硬伤是没有教学节奏。一个好的语言老师知道什么时候该推进对话、什么时候该停下来解释、什么时候该重复练习。通用聊天机器人没有这个节奏感它只是被动地回应不会主动引导用户完成一个学习闭环。这三个问题归结起来就是通用聊天机器人是对话工具而英语情景教学需要的是教学Agent。Agent和普通机器人的区别在于Agent有目标、有状态、有策略它知道自己在这个场景里要完成什么教学任务并且能根据用户的水平动态调整。1.2 情景教学Agent的核心能力拆解我把这个Agent需要的能力拆成了四层。最底层是场景理解。Agent需要知道当前是什么场景比如餐厅点餐这个场景涉及哪些典型对话轮次问候、点餐、询问推荐、结账每个轮次有哪些关键表达。这些信息我把它结构化成一个场景配置而不是全部塞进prompt里让模型自己发挥。往上一层是对话管理。Agent要维护对话状态知道当前进行到哪个环节用户有没有完成当前环节的任务是否需要推进到下一环节。这其实是一个有限状态机的思路只不过状态转移是由模型判断加规则兜底共同决定的。再往上是语言评估。用户每说一句话Agent需要判断这句话在语法、用词、表达地道程度上有没有问题如果有问题是当场纠正还是先记下来等对话告一段落再统一反馈。这里我采用的是轻纠错加重总结的策略避免频繁打断用户的表达流畅度。最上层是教学策略。根据用户的水平初级、中级、高级和当前表现Agent要决定是给更多提示、还是提高难度、还是重复练习某个句型。这一层是最难做的也是最能体现教学价值的地方。1.3 为什么选WebSocket而不是HTTP轮询确定了要做Agent之后下一个问题就是前后端怎么通信。最直接的做法是HTTP请求-响应模式前端发一句话后端返回Agent的回复。但这个模式在实时教学场景里有明显问题。首先是延迟感。HTTP请求-响应是同步的用户发完消息要等后端处理完才能看到回复。如果Agent的回复需要调用大模型这个等待时间可能好几秒用户界面就卡在那里体验很差。WebSocket建立的是持久连接后端可以在处理过程中分阶段推送内容比如先推送正在思考的状态再推送部分回复最后推送完整结果。其次是双向主动推送。教学场景里Agent有时候需要主动发起对话比如用户沉默太久Agent要主动引导或者系统检测到用户某个错误反复出现要主动推送一个练习提示。HTTP模式下后端没法主动推只能靠前端轮询既浪费资源又有延迟。WebSocket天然支持服务端主动推送。第三是多轮对话的状态保持。WebSocket连接建立后服务端可以在这个连接上维护会话状态不需要每次请求都带上完整的对话历史。虽然HTTP也可以用session或者token来保持状态但WebSocket的连接本身就是状态载体更自然。我实测下来用WebSocket之后用户感知到的响应速度明显提升因为Agent的思考过程可以流式展示用户看到文字一个个蹦出来心理等待时间会短很多。2. FastAPI后端Agent的对话引擎与WebSocket连接管理2.1 FastAPI项目目录结构设计后端我用的是FastAPI选它的原因很简单原生支持异步、WebSocket支持完善、类型提示友好、文档自动生成。项目目录结构我参考了几个开源项目的做法最终定成这样app/ ├── main.py # 应用入口注册路由和WebSocket ├── config.py # 配置管理 ├── models/ # 数据模型 │ ├── scene.py # 场景定义模型 │ └── message.py # 消息模型 ├── services/ │ ├── agent_service.py # Agent核心逻辑 │ ├── scene_service.py # 场景管理 │ └── evaluation_service.py # 语言评估 ├── ws/ │ ├── manager.py # WebSocket连接管理器 │ └── handler.py # WebSocket消息处理 ├── api/ │ └── routes.py # REST接口 └── utils/ └── prompt_builder.py # Prompt构建工具这个结构的好处是职责清晰。ws/manager.py专门管连接services/agent_service.py专门管Agent逻辑两者通过消息队列或者直接调用解耦。我见过很多项目把WebSocket处理和业务逻辑混在一起后期加功能特别痛苦。提示FastAPI的WebSocket路由和普通HTTP路由可以共存在同一个应用里但要注意WebSocket的路径不要和HTTP路径冲突。我习惯把WebSocket统一放在/ws/前缀下。2.2 WebSocket连接管理器并发场景下的连接池设计WebSocket连接管理是这个项目里最容易被低估的部分。单用户测试的时候怎么写都行一旦上并发问题就全出来了。我的连接管理器核心是一个字典key是连接IDvalue是WebSocket对象和对应的会话状态。但直接用字典会有并发安全问题因为FastAPI的WebSocket处理是异步的多个协程可能同时读写这个字典。我用asyncio.Lock来保护关键操作import asyncio from fastapi import WebSocket class ConnectionManager: def __init__(self): self.active_connections: dict[str, WebSocket] {} self.sessions: dict[str, dict] {} self._lock asyncio.Lock() async def connect(self, conn_id: str, websocket: WebSocket): await websocket.accept() async with self._lock: self.active_connections[conn_id] websocket self.sessions[conn_id] {scene: None, history: []} async def disconnect(self, conn_id: str): async with self._lock: self.active_connections.pop(conn_id, None) self.sessions.pop(conn_id, None) async def send_personal(self, conn_id: str, message: dict): ws self.active_connections.get(conn_id) if ws: await ws.send_json(message)这里有个细节send_json本身是异步的如果多个协程同时往同一个连接发消息可能会出现消息交错。我在实际项目里给每个连接加了一个发送队列所有要发的消息先入队由一个单独的协程顺序发送。这样虽然增加了一点复杂度但彻底避免了消息乱序的问题。关于并发扛量我的经验是单个FastAPI实例用uvicorn跑配合异步IO撑几百个并发WebSocket连接问题不大。再往上就要考虑多进程或者多实例部署这时候连接管理器就不能用内存字典了得换成Redis pub/sub来做跨实例的消息分发。我目前还没做到那一步但架构上留了口子。2.3 Agent对话逻辑状态机加LLM的混合架构Agent的对话逻辑我没有完全交给大模型而是用了状态机加LLM的混合架构。原因很简单纯LLM驱动的对话不可控容易跑偏纯状态机又太死板没法处理用户的自由表达。我的做法是每个场景定义一个状态机状态机的节点是对话环节边是转移条件。LLM负责两件事一是判断用户当前这句话是否满足当前环节的完成条件二是生成Agent的回复内容。状态转移的决策权在状态机手里LLM只提供判断依据。举个例子餐厅点餐场景的状态机大概是这样的状态目标完成条件下一状态问候用户回应问候用户说了问候语点餐点餐用户点至少一个菜用户表达了点餐意图询问推荐询问推荐用户询问推荐或直接确认用户完成询问确认订单确认订单用户确认订单用户表示确认结账结账用户完成结账对话用户表达付款意图结束每个状态对应一组prompt模板告诉LLM当前场景、当前环节、用户水平、以及需要评估的要点。LLM返回结构化的JSON包含回复内容、评估结果、是否满足完成条件。状态机根据这个JSON决定是否转移。这种混合架构的好处是可控性和灵活性的平衡。状态机保证了教学流程的完整性LLM保证了对话的自然度。我试过纯LLM方案用户很容易把Agent带偏聊了十分钟还没进入正题。2.4 流式响应让Agent的思考过程可见Agent调用大模型需要时间如果等完整回复生成再一次性推给前端用户会盯着空白屏幕好几秒。我的做法是流式响应LLM生成一个token就推一个token前端逐字显示。FastAPI里实现流式响应如果是HTTP可以用StreamingResponse但WebSocket更直接因为WebSocket本身就是双向流。我在Agent服务里用异步生成器async def stream_agent_response(self, conn_id: str, user_input: str): async for chunk in self.llm.astream(prompt): await self.manager.send_personal(conn_id, { type: chunk, content: chunk }) await self.manager.send_personal(conn_id, { type: done, evaluation: evaluation_result })前端收到chunk类型的消息就追加显示收到done类型就结束当前轮次并展示评估结果。这个模式我实测下来用户体验很好因为用户能看到Agent在打字心理等待时间大幅缩短。注意流式响应要处理好中断逻辑。如果用户在Agent还在生成的时候又发了一条消息要能取消上一次生成。我在每个连接上维护了一个current_task新消息到来时先cancel旧task。3. React前端实时对话界面与状态管理3.1 WebSocket在React里的正确打开方式React里用WebSocket最常见的错误是把WebSocket实例放在组件的state里或者每次渲染都新建一个。WebSocket是长连接应该在整个应用生命周期内保持单例或者至少在每个会话内保持单例。我的做法是封装一个自定义HookuseWebSocket在Hook内部用useRef持有WebSocket实例用useEffect处理连接建立和清理function useWebSocket(url) { const wsRef useRef(null); const [messages, setMessages] useState([]); const [status, setStatus] useState(connecting); useEffect(() { const ws new WebSocket(url); wsRef.current ws; ws.onopen () setStatus(connected); ws.onclose () setStatus(disconnected); ws.onmessage (event) { const data JSON.parse(event.data); setMessages(prev [...prev, data]); }; return () ws.close(); }, [url]); const send useCallback((data) { if (wsRef.current?.readyState WebSocket.OPEN) { wsRef.current.send(JSON.stringify(data)); } }, []); return { messages, status, send }; }这里有几个关键点。第一useRef而不是useState来存WebSocket实例因为改ref不会触发重渲染。第二useEffect的依赖数组只放url避免重复连接。第三清理函数里要close()否则组件卸载后连接还挂着造成内存泄漏。我踩过的一个坑是React 18的StrictMode。开发模式下StrictMode会故意把effect执行两次导致WebSocket连了又断、断了又连。解决办法是在清理函数里正确关闭或者用ref标记是否已经初始化过。生产模式下没这个问题但开发时看到连接状态反复横跳会很困惑。3.2 对话状态管理useReducer比useState更适合对话界面涉及的状态比较多消息列表、当前场景、Agent状态思考中/等待输入、评估结果、用户水平。如果全用useState组件里会散落十几个状态变量更新逻辑也容易乱。我改用useReducer来管理对话状态把所有状态收敛到一个reducer里const initialState { messages: [], scene: null, agentStatus: idle, evaluation: null, userLevel: intermediate }; function chatReducer(state, action) { switch (action.type) { case ADD_USER_MESSAGE: return { ...state, messages: [...state.messages, action.payload] }; case AGENT_THINKING: return { ...state, agentStatus: thinking }; case AGENT_CHUNK: const last state.messages[state.messages.length - 1]; if (last?.role agent last.streaming) { const updated [...state.messages]; updated[updated.length - 1] { ...last, content: last.content action.payload }; return { ...state, messages: updated }; } return { ...state, messages: [...state.messages, { role: agent, content: action.payload, streaming: true }] }; case AGENT_DONE: return { ...state, agentStatus: idle, evaluation: action.payload.evaluation, messages: state.messages.map(m m.streaming ? { ...m, streaming: false } : m ) }; default: return state; } }用reducer之后状态更新逻辑集中在一处调试的时候也方便可以在reducer里打日志看每个action前后的状态变化。3.3 流式消息渲染的性能陷阱流式响应有个性能问题Agent每生成一个token就推一条消息如果每个token都触发一次React重渲染消息多了之后界面会卡。我的优化方案是批量更新。不在每次收到chunk时立即setState而是用一个缓冲区累积chunk每隔50毫秒或者累积到一定长度再统一更新一次。这样既保证了视觉上的流畅感又避免了过于频繁的重渲染。const bufferRef useRef(); const timerRef useRef(null); function handleChunk(content) { bufferRef.current content; if (!timerRef.current) { timerRef.current setTimeout(() { dispatch({ type: AGENT_CHUNK, payload: bufferRef.current }); bufferRef.current ; timerRef.current null; }, 50); } }另一个优化是给消息列表用React.memo包裹单条消息组件只有内容变化的那个消息会重渲染其他消息不受影响。我实测下来不加这两个优化长对话50轮以上滚动会明显掉帧加上之后基本流畅。3.4 场景选择与教学反馈的UI设计前端不只是聊天窗口还需要场景选择、教学反馈展示、用户水平设置这些模块。场景选择我做成卡片式布局每个场景一张卡片显示场景名称、难度、涉及的关键表达。用户点击卡片后前端通过WebSocket发送一个start_scene消息后端初始化对应的状态机。教学反馈的展示我纠结了很久。最初想做成实时纠错用户每说一句就在旁边标红错误。但实测发现这样太打断思路用户会变得不敢说。后来改成轻提示加重总结对话过程中如果Agent检测到明显错误会在回复里自然地重述正确表达比如用户说I go to airport yesterdayAgent回复Ah, you went to the airport yesterday? How was it?不直接说你错了。对话结束后再弹出一个总结面板列出本轮对话中的主要问题、正确表达、以及建议练习的句型。这个设计我找几个朋友试用过反馈比实时纠错好很多。语言学习最重要的是敢开口频繁纠错会打击积极性。4. 教学Agent的核心场景配置与评估逻辑4.1 场景配置的数据结构设计场景是这个项目的灵魂。我把每个场景定义成一个JSON配置包含元信息、对话环节、关键表达、评估要点。{ id: restaurant_ordering, name: 餐厅点餐, difficulty: beginner, roles: { user: 顾客, agent: 服务员 }, stages: [ { id: greeting, goal: 完成问候, keyPhrases: [Good evening, A table for two, I have a reservation], completionCriteria: 用户表达了问候或入座意图 }, { id: ordering, goal: 点至少一道菜, keyPhrases: [Id like, Can I have, Ill take], completionCriteria: 用户明确点了一道菜 } ], evaluationPoints: [ {type: grammar, focus: 情态动词的使用}, {type: vocabulary, focus: 食物相关词汇}, {type: pragmatics, focus: 礼貌表达} ] }这个配置驱动了整个Agent的行为。状态机从stages生成prompt从keyPhrases和evaluationPoints构建评估结果也对照evaluationPoints来组织。好处是加新场景只需要写配置不用改代码。我目前做了八个场景机场值机、酒店入住、餐厅点餐、购物砍价、问路、看医生、面试、租房。每个场景的配置大概一百多行JSON写起来不算太累但要想清楚每个环节的关键表达和完成条件还是需要点教学经验的。4.2 Prompt工程如何让LLM稳定输出结构化结果Agent的回复需要同时包含对话内容和评估结果所以我要求LLM输出JSON格式。但LLM输出JSON的稳定性是个老问题有时候会多输出一段解释有时候字段名写错。我的解决方案是三层保障。第一层是prompt里明确给出JSON schema和示例并且强调只输出JSON不要有任何其他文字。第二层是用response_format参数如果模型支持强制JSON输出。第三层是解析失败时的兜底逻辑用正则提取JSON部分如果还失败就降级为纯文本回复评估结果留空。prompt的结构我固定成四段角色设定、当前场景和环节、用户输入、输出要求。角色设定里会说明Agent是英语教学助手要根据用户水平调整语言难度。当前场景和环节从配置里动态填充。输出要求里明确JSON的字段和类型。提示prompt里给示例比给描述有效得多。我一开始只写输出包含reply和evaluation两个字段的JSON模型经常自由发挥。后来加了一个完整的输入输出示例稳定性大幅提升。4.3 语言评估规则加LLM的双通道语言评估我没有完全依赖LLM而是用了规则加LLM的双通道。规则通道处理一些确定性的问题比如拼写错误、明显的时态错误通过正则匹配常见模式、句子长度过短等。这些规则跑得快、成本低、结果稳定。LLM通道处理规则覆盖不到的问题比如用词是否地道、表达是否符合场景、语法结构是否复杂但正确。LLM通道的prompt里我会给出评估维度和评分标准让它输出结构化的评估结果。两个通道的结果合并后按严重程度排序取前三条作为本轮反馈。这样既保证了覆盖面又避免了反馈过多让用户不知所措。我实测下来规则通道能覆盖大概40%的常见错误而且零延迟。LLM通道覆盖剩下的60%但需要调用模型有延迟。两者结合整体评估的响应速度和准确性都还不错。4.4 用户水平自适应从初级到高级的动态调整用户水平自适应是我觉得最有教学价值的功能。Agent会根据用户的表现动态调整语言难度和教学策略。初级用户Agent用简单句、慢语速通过标点和换行模拟、多给提示。比如用户卡住了Agent会主动说你可以试着说I would like...。中级用户Agent用正常语速、偶尔用复杂句、少给提示。用户卡住时Agent会给一个关键词而不是完整句子。高级用户Agent用自然语速、地道表达、甚至故意设置一些陷阱比如服务员说了一个不常见的菜名看用户怎么应对。水平的判断我用了两个信号一是用户注册时自报的水平二是对话过程中的表现平均句长、错误率、回应速度。两个信号加权后动态调整。这个权重我调了几次目前是自报水平占60%实时表现占40%。纯靠实时表现判断的话前几轮波动太大容易误判。5. 并发、心跳与那些让我熬夜的坑5.1 WebSocket心跳机制为什么必须做以及怎么做WebSocket连接看起来是持久的但实际上中间的网络设备路由器、负载均衡、防火墙可能会在不活跃一段时间后悄悄断开连接。如果不做心跳前端可能以为连接还在发消息才发现发不出去。心跳机制的做法是客户端每隔一段时间我用的30秒发一个ping消息服务端收到后回一个pong。如果客户端连续几次没收到pong就认为连接断了主动重连。// 前端心跳 useEffect(() { const interval setInterval(() { if (wsRef.current?.readyState WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ type: ping })); } }, 30000); return () clearInterval(interval); }, []);服务端收到ping类型的消息直接回pong不进入Agent处理逻辑。这个要单独处理否则ping消息会被当成用户输入送给LLM浪费token还可能导致奇怪的回复。我踩过的坑是心跳间隔设得太短比如5秒移动端网络下频繁发消息反而增加了断连概率设得太长比如60秒断连检测不及时。30秒是我实测下来比较平衡的值。5.2 连接断开重连状态恢复的完整链路WebSocket断开后重连最大的问题是状态丢失。用户可能正在对话中间重连后如果对话历史没了体验就断了。我的方案是前端在localStorage里缓存对话历史和当前场景ID重连成功后发送一个resume消息带上缓存的会话ID。后端根据会话ID从Redis或者内存单实例情况下恢复会话状态。如果恢复失败比如会话过期就提示用户重新开始。这里有个细节重连后不要立即恢复对话而是先让用户确认。因为断连期间用户可能已经离开了直接恢复对话会让人困惑。我的做法是重连后显示一个提示条连接已恢复是否继续上次的对话用户点击继续才恢复。5.3 并发下的会话隔离一个连接串了另一个连接的对话这是我调试时遇到的最诡异的问题测试的时候开了两个浏览器标签结果A标签的Agent回复跑到了B标签的界面上。排查了半天发现是连接管理器的key生成有问题。我最初用用户ID作为key但同一个用户开两个标签用户ID是一样的后连接的把先连接的覆盖了。改成用连接ID前端生成一个UUID作为key之后问题解决。这个坑的教训是WebSocket连接的标识要用连接级别的唯一ID不要用用户级别的ID。一个用户可能有多个连接多标签、多设备每个连接是独立的会话。5.4 大模型调用超时与降级策略Agent调用大模型有时候会超时尤其是网络波动或者模型服务繁忙的时候。如果不处理用户就会一直等最后连接可能被中间设备断开。我的降级策略是设置一个超时时间我用的15秒超时后立即给用户返回一个预设的回复比如抱歉我这边有点卡顿你能再说一遍吗同时记录这次超时用于监控。如果连续超时就提示用户稍后再试。流式响应的情况下超时判断要更细如果第一个token在5秒内没到就认为这次调用有问题触发降级如果已经开始流式输出但中途卡住超过10秒也触发降级把已输出的内容保留补一个结束标记。6. 部署与实测从本地跑通到真实使用6.1 本地开发环境搭建的完整步骤后端python -m venv venv source venv/bin/activate # Windows用 venv\Scripts\activate pip install fastapi uvicorn websockets python-dotenv uvicorn app.main:app --reload --port 8000前端npx create-react-app english-scene-agent cd english-scene-agent npm install npm start前端开发服务器默认跑在3000端口后端在8000。开发时前端连ws://localhost:8000/ws/chat。要注意跨域问题FastAPI的CORS中间件要配置允许前端源。提示本地开发时如果前端用HTTPS比如某些脚手架默认开启WebSocket要用wss://而不是ws://否则浏览器会拒绝连接。我在这上面浪费过半小时。6.2 实测数据延迟、并发与用户反馈我在本地环境做了一轮测试数据如下指标数值说明首token延迟1.2-2.5秒取决于模型服务响应速度完整回复延迟3-8秒取决于回复长度单实例并发连接200uvicorn单进程异步IO心跳间隔30秒平衡断连检测和网络开销评估准确率约85%规则加LLM双通道人工抽检用户反馈方面我找了五个朋友试用主要反馈集中在三点一是流式响应体验好感觉Agent在思考二是场景化比通用聊天有目标感三是总结面板的反馈有用但希望能更详细地解释为什么错。6.3 后续可以扩展的方向这个项目目前还是个原型但架构上留了不少扩展空间。我接下来想做的几个方向一是加语音输入输出让对话更自然二是加多用户角色扮演比如一个场景里用户和Agent之外还有第三个角色三是加学习进度追踪记录用户在每个场景的表现生成学习报告。技术上如果要上生产环境连接管理器要换成Redis pub/sub支持多实例Agent调用要加缓存和限流前端要做代码分割和懒加载。这些我还在慢慢补。最后分享一个我在调试WebSocket时常用的小技巧在浏览器开发者工具的Network面板里WebSocket连接的Frames标签可以看到所有收发的消息。调试消息格式问题的时候比在代码里打日志直观多了。另外如果连接建立成功但收不到消息先检查一下服务端是不是把消息发到了错误的连接ID上这个坑我踩过不止一次。