☰
FastAPI+WebSocket+React:构建带场景状态管理的英语情景教学Agent
2026/11/2 14:12:13 网站建设 项目流程

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

先说结论:这个项目本质上是一个“能陪你练英语对话的AI陪练”,但它和市面上那些套壳聊天机器人最大的区别在于——它有场景状态,有角色设定,有实时反馈,而且整个链路是我自己从后端到前端一手搭起来的。

我做这个项目的起因很朴素。去年帮一个朋友准备雅思口语,发现市面上大部分AI口语陪练要么是纯文本对话,要么是语音识别加一个通用大模型直接怼回去,体验非常割裂。你问它“模拟一下机场值机场景”,它确实能跟你聊,但聊着聊着就忘了自己是地勤人员,开始跟你讨论天气和哲学。更致命的是,它不会根据你的水平动态调整难度,也不会在对话结束后给你一个结构化的反馈。

所以我决定自己写一个。核心目标很明确:用FastAPI做后端,WebSocket做实时通信,React做前端,LLM做对话引擎,构建一个带场景状态管理的英语情景教学Agent。整个项目从零到一大概花了三周业余时间,踩了不少坑,也积累了一些在常规教程里看不到的经验。

这篇文章适合谁看?如果你是一个有一定Python和React基础,想了解Agent开发中“状态管理”和“实时通信”怎么落地的开发者,那这篇内容应该能给你不少参考。如果你只是想找个AI练英语,那可以直接跳过技术部分,看场景设计那几节。

提示:本文所有代码和架构决策都基于我个人的实际项目经验,不同业务场景下可能需要调整。涉及LLM的部分我会尽量给出可替换的方案,不绑定特定厂商。

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

2.1 为什么是FastAPI加WebSocket加React这套组合

先说说后端选型。Python生态里做Web服务,Django和Flask都是成熟选择,但我最终选了FastAPI,原因有三个。

第一,原生异步支持。英语情景教学Agent的核心交互是“用户说话→LLM生成回复→前端展示”,这个链路里LLM调用是典型的IO密集型操作,用异步框架可以轻松处理并发请求,不会因为一个用户的对话生成阻塞其他用户。FastAPI基于Starlette,异步是一等公民,写起来很自然。

第二,WebSocket支持开箱即用。FastAPI直接提供了@app.websocket装饰器,不需要像Django那样额外配置Channels层。对于我这个项目来说,WebSocket是刚需,因为我要实现的是“用户发送语音或文本→后端流式返回LLM生成内容→前端逐字显示”的效果,轮询虽然也能做,但延迟和服务器压力都不可接受。

第三,自动生成API文档。FastAPI的Swagger UI和ReDoc是自动生成的,调试接口非常方便。虽然WebSocket不在自动文档里,但RESTful部分的管理接口(比如获取场景列表、获取历史记录)用起来很顺手。

前端选React的理由更直接:我需要一个能高效管理复杂UI状态的框架。英语情景教学Agent的前端状态包括:当前场景、对话历史、连接状态、录音状态、LLM流式输出中的临时文本等。用React的useState和useReducer管理这些状态,配合useEffect处理WebSocket生命周期,逻辑清晰且可维护。

2.2 Agent的核心状态机设计

这个项目最核心的设计不是技术栈,而是场景状态机。一个英语情景教学Agent如果只是把用户输入转发给LLM,那就退化成聊天机器人了。我需要的Agent必须知道:

  • 当前是什么场景(比如“酒店入住”)
  • 用户扮演什么角色(比如“旅客”)
  • AI扮演什么角色(比如“前台接待员”)
  • 对话进行到哪个阶段(比如“询问预订信息”→“确认房型”→“办理入住”→“告知注意事项”)
  • 用户的英语水平大概在什么区间(A2/B1/B2)

这些信息构成了Agent的“状态”。每次用户输入进来,Agent不是直接把输入丢给LLM,而是先把状态和输入一起构造成一个结构化的Prompt,让LLM在特定角色和阶段约束下生成回复。

我用一个Python字典来维护每个会话的状态:

session_state = { "session_id": "uuid", "scene": "hotel_checkin", "user_role": "guest", "ai_role": "receptionist", "stage": "ask_reservation", "user_level": "B1", "history": [], "corrections": [] }

这个状态机是整个Agent的骨架。没有它,LLM就会自由发挥;有了它,LLM的每次回复都被约束在场景和阶段里。

2.3 LLM在Agent中的角色定位

很多人做Agent容易陷入一个误区:把所有逻辑都交给LLM。我的做法是LLM只负责它最擅长的事——生成自然语言,而状态管理、阶段推进、难度调整这些逻辑由代码控制。

具体来说,LLM在我的项目里承担三个职责:

  1. 角色扮演对话生成:根据当前场景、角色、阶段,生成符合情境的英语回复。
  2. 语法和用词纠正:在用户发送消息后,额外调用一次LLM分析用户输入中的语法错误和用词不当,但不打断对话流,而是把纠正结果暂存,在对话结束后统一展示。
  3. 难度适配:根据用户的历史表现,动态调整LLM生成回复的词汇复杂度和句子长度。

这里有个关键决策:纠正和对话生成是两次独立的LLM调用。为什么?因为如果让LLM在生成回复的同时顺便纠正,它往往会为了纠正而牺牲对话的流畅性,或者为了流畅而忽略纠正。分开调用虽然增加了token消耗,但体验好很多。

注意:如果你的预算有限,可以把纠正功能改成异步后台任务,不阻塞主对话流程。我实测下来,用户对“对话结束后统一看纠正”的接受度很高,反而实时纠正会打断表达欲。

3. 后端核心实现细节与实操要点

3.1 FastAPI项目目录结构怎么组织才不乱

FastAPI项目最容易出现的问题就是所有代码堆在main.py里。我踩过这个坑,后来调整成了下面这个结构:

english-scene-agent/ ├── app/ │ ├── main.py # 应用入口,注册路由和WebSocket │ ├── config.py # 配置管理,读取环境变量 │ ├── models/ │ │ ├── session.py # 会话状态数据模型 │ │ └── scene.py # 场景定义数据模型 │ ├── services/ │ │ ├── llm_service.py # LLM调用封装 │ │ ├── scene_service.py # 场景状态机逻辑 │ │ └── correction.py # 语法纠正服务 │ ├── websocket/ │ │ ├── manager.py # WebSocket连接管理器 │ │ └── handler.py # 消息处理逻辑 │ └── api/ │ └── scenes.py # RESTful接口:场景列表等 ├── requirements.txt └── .env

这个结构的好处是职责分离。services层不依赖FastAPI,可以单独测试;websocket层只负责通信,不包含业务逻辑;models层定义数据结构,前后端可以共享字段命名。

config.py里我用pydantic的BaseSettings管理配置,LLM的API Key、模型名称、超时时间都从环境变量读取。这样本地开发和部署时只需要改.env文件,不用动代码。

3.2 WebSocket连接管理与心跳机制

WebSocket最大的坑是连接假死。用户可能因为网络切换、浏览器休眠等原因,TCP连接实际上已经断了,但服务端不知道,还在往一个死连接发消息。我一开始没做心跳,结果用户反馈“有时候AI不回复了,刷新页面才好”。

解决方案是双向心跳。服务端每30秒发一个ping帧,客户端收到后回pong。如果服务端连续两次没收到pong,就主动关闭连接并清理会话状态。

FastAPI里实现心跳的代码大概是这样:

import asyncio from fastapi import WebSocket async def heartbeat(websocket: WebSocket, interval: int = 30): while True: await asyncio.sleep(interval) try: await websocket.send_json({"type": "ping"}) except Exception: break

客户端React侧用onmessage监听ping,收到后立即send一个pong回去。同时客户端也要做超时检测:如果超过60秒没收到任何服务端消息,就主动重连。

实操心得:心跳间隔不要设太短,30秒是实测比较平衡的值。设10秒会导致移动端耗电明显增加,设60秒又容易出现“用户以为断了但其实没断”的困惑。

3.3 LLM调用的流式输出与超时处理

英语对话场景下,用户最在意的是响应速度。如果用户说完一句话,等5秒才看到AI回复,体验会非常差。所以LLM调用必须用流式输出。

我的做法是:LLM返回的每个token通过WebSocket实时推送到前端,前端逐字显示。这样用户感觉AI“正在思考并说话”,心理等待时间大大缩短。

但流式输出带来一个新问题:超时处理。如果LLM服务不稳定,流式输出到一半卡住了怎么办?我的策略是设置两级超时:

  • 首token超时:从发起请求到收到第一个token,超过8秒就中断,返回兜底回复。
  • 总超时:整个流式输出过程超过30秒就强制结束,把已生成的部分返回给用户。

代码层面用asyncio.wait_for包裹LLM调用,配合httpx的流式响应:

async def stream_llm_response(prompt: str, timeout: int = 30): try: async with httpx.AsyncClient(timeout=timeout) as client: async with client.stream("POST", LLM_API_URL, json={...}) as response: async for chunk in response.aiter_text(): yield chunk except asyncio.TimeoutError: yield "[系统提示] 回复生成超时,请重试。"

注意:流式输出时,WebSocket的消息格式要设计好。我用的格式是{"type": "stream", "content": "部分文本", "done": false},最后一个消息done为true。前端根据done字段决定是否结束“正在输入”状态。

3.4 场景状态机的代码实现

场景状态机是这个项目的灵魂。我用一个SceneService类来管理所有场景的推进逻辑。每个场景定义为一个JSON文件,包含阶段列表、每个阶段的AI目标、推荐问句、以及阶段推进条件。

举个例子,“酒店入住”场景的定义大概是这样:

{ "scene_id": "hotel_checkin", "name": "Hotel Check-in", "ai_role": "receptionist", "user_role": "guest", "stages": [ { "stage_id": "greeting", "ai_goal": "Greet the guest and ask for reservation name", "sample_questions": ["Good evening, welcome to our hotel. May I have your name, please?"], "next_stage": "confirm_booking" }, { "stage_id": "confirm_booking", "ai_goal": "Confirm the booking details and ask for ID", "sample_questions": ["I found your reservation. Could I see your ID, please?"], "next_stage": "room_info" } ] }

SceneService的核心方法advance_stage(session_state, user_input)会根据用户输入判断是否满足推进条件。判断逻辑我用的是关键词匹配加LLM辅助:先用关键词快速判断(比如用户说了“yes”“here you go”就认为提供了ID),如果关键词不明确,再调用一次轻量LLM做意图分类。

这样做的好处是大部分情况下不需要额外LLM调用,只有模糊情况才走LLM,节省了成本和延迟。

4. 前端React实现与实时通信对接

4.1 React中WebSocket的生命周期管理

React里管理WebSocket最容易犯的错误是在组件每次渲染时都创建新连接。我一开始把new WebSocket()写在组件函数体里,结果每次state变化都重连一次,服务器日志里全是连接建立和断开。

正确做法是用useRef保存WebSocket实例,用useEffect在组件挂载时创建连接,卸载时关闭:

const wsRef = useRef(null); useEffect(() => { const ws = new WebSocket(`ws://localhost:8000/ws/chat/${sessionId}`); wsRef.current = ws; ws.onopen = () => setConnected(true); ws.onmessage = (event) => { const data = JSON.parse(event.data); if (data.type === 'ping') { ws.send(JSON.stringify({ type: 'pong' })); } else if (data.type === 'stream') { // 处理流式文本 } }; ws.onclose = () => setConnected(false); return () => ws.close(); }, [sessionId]);

这里sessionId作为依赖项是合理的,因为切换会话本来就应该重建连接。但其他状态(比如对话历史)不应该触发重连。

4.2 流式文本的逐字显示与状态同步

流式文本显示看起来简单,做起来有几个细节要注意。

第一,不要每个token都setState。如果LLM每秒生成20个token,每个token都触发一次React渲染,页面会卡。我的做法是用一个缓冲区,每100毫秒批量更新一次state:

const bufferRef = useRef(''); const timerRef = useRef(null); function appendStreamText(text) { bufferRef.current += text; if (!timerRef.current) { timerRef.current = setTimeout(() => { setDisplayText(prev => prev + bufferRef.current); bufferRef.current = ''; timerRef.current = null; }, 100); } }

第二,流式输出结束后要同步到对话历史。流式过程中显示的是临时文本,done消息到达后,要把完整文本追加到messages数组里,并清空临时文本。否则用户滚动查看历史时会发现内容重复或丢失。

第三,自动滚动到底部。用useEffect监听messages变化,调用scrollIntoView。但要注意:如果用户主动往上滚看历史,就不要强制滚动。我加了一个isUserScrolling标志来判断。

4.3 录音与语音识别的前端处理

英语口语练习离不开语音输入。我用的是浏览器原生的MediaRecorderAPI录制音频,然后通过WebSocket把音频数据发给后端,后端调用语音识别服务转成文本。

这里有个关键决策:音频是在前端转文本还是后端转文本。我选了后端,原因是前端语音识别API在不同浏览器上表现差异大,而且识别准确率不如服务端模型。虽然后端转文本会增加一次网络往返,但准确率优先。

录音的代码逻辑:

const mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' }); const chunks = []; mediaRecorder.ondataavailable = (e) => chunks.push(e.data); mediaRecorder.onstop = () => { const blob = new Blob(chunks, { type: 'audio/webm' }); // 通过WebSocket发送二进制数据 wsRef.current.send(blob); };

后端收到二进制数据后,先保存为临时文件,调用语音识别API,拿到文本后再走正常的对话流程。

实操心得:录音时一定要给用户明确的视觉反馈,比如按钮变红、显示波形。我一开始只改了个文字状态,用户根本不知道有没有在录。后来加了一个简单的CSS动画,体验好很多。

5. 常见问题与排查技巧实录

5.1 WebSocket连接成功但不接收消息

这是我最常遇到的问题,表现是前端onopen触发了,但onmessage一直不执行。排查下来通常有三个原因:

原因一:后端没有正确接受连接。FastAPI的WebSocket端点必须先调用await websocket.accept(),否则连接会一直挂起。我一开始忘了这行,前端显示连接成功但收不到任何消息。

原因二:消息发送到了错误的连接对象。如果你用了连接管理器,要确保发送消息时用的是当前会话对应的WebSocket实例,而不是其他会话的。我建议在连接管理器里用session_id作为key存储连接。

原因三:代理层缓冲了WebSocket消息。如果你在Nginx后面部署,需要配置proxy_buffering off和proxy_read_timeout。我本地开发没问题,一部署到服务器就出现消息延迟,排查了半天才发现是Nginx缓冲。

5.2 LLM回复内容不符合场景角色

有时候LLM会“出戏”,比如在酒店场景里突然说“作为一个AI语言模型”。这是因为Prompt约束不够强。我的解决方案是在System Prompt里加三条硬性规则:

  1. 你只能以{ai_role}的身份说话,绝对不能承认自己是AI。
  2. 如果用户说了与场景无关的内容,你要用{ai_role}的身份把话题拉回来。
  3. 你的回复长度控制在2到3句话,符合真实对话节奏。

另外,在对话历史里保留最近5轮,让LLM有上下文记忆。太长的历史会稀释System Prompt的约束力,太短又会导致LLM忘记之前聊了什么。

5.3 并发场景下的会话状态混乱

这个问题在测试阶段没发现,上线后用户一多就暴露了。表现是A用户的对话历史出现在了B用户的界面上。原因是我的会话状态存在了一个全局字典里,但没有做好隔离。

修复方案很简单:所有状态操作都必须带session_id。我把全局字典改成了dict[session_id, SessionState],每个WebSocket连接对应一个session_id,所有读写都通过session_id索引。另外,连接关闭时要记得清理对应的状态,否则内存会一直增长。

5.4 常见问题速查表

问题现象可能原因排查方法解决方案
前端连接成功但无消息后端未accept检查WebSocket端点第一行添加await websocket.accept()
消息延迟严重代理缓冲查看Nginx配置关闭proxy_buffering
LLM出戏Prompt约束弱检查System Prompt加强角色约束规则
会话状态串号全局状态未隔离检查状态存储key用session_id隔离
流式输出卡顿频繁setStateReact DevTools Profiler批量更新,100ms一次
录音无数据权限或mimeType浏览器控制台检查麦克风权限和格式支持

最后分享一个小技巧:在开发阶段,我建议加一个“调试模式”,把每次发给LLM的完整Prompt和LLM的原始返回都打印到日志里。这样排查问题时能快速定位是Prompt的问题还是LLM本身的问题。上线后关掉这个模式即可。

这个项目后续还可以扩展的方向包括:多场景切换时的状态迁移、基于用户历史表现的长期学习档案、以及多人角色扮演场景。不过那是另一个故事了,先把单场景的体验打磨好,比堆功能重要得多。

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

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

立即咨询