首页/新闻资讯/正文详情

FastAPI+WebSocket+React:构建带场景状态管理的英语情景教学Agent

发布时间:2026/11/2 14:12:15 来源:云帆数科 栏目:资讯中心
FastAPI+WebSocket+React:构建带场景状态管理的英语情景教学Agent
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在我的项目里承担三个职责角色扮演对话生成根据当前场景、角色、阶段生成符合情境的英语回复。语法和用词纠正在用户发送消息后额外调用一次LLM分析用户输入中的语法错误和用词不当但不打断对话流而是把纠正结果暂存在对话结束后统一展示。难度适配根据用户的历史表现动态调整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(timeouttimeout) 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毫秒批量更新一次stateconst 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里加三条硬性规则你只能以{ai_role}的身份说话绝对不能承认自己是AI。如果用户说了与场景无关的内容你要用{ai_role}的身份把话题拉回来。你的回复长度控制在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_bufferingLLM出戏Prompt约束弱检查System Prompt加强角色约束规则会话状态串号全局状态未隔离检查状态存储key用session_id隔离流式输出卡顿频繁setStateReact DevTools Profiler批量更新100ms一次录音无数据权限或mimeType浏览器控制台检查麦克风权限和格式支持最后分享一个小技巧在开发阶段我建议加一个“调试模式”把每次发给LLM的完整Prompt和LLM的原始返回都打印到日志里。这样排查问题时能快速定位是Prompt的问题还是LLM本身的问题。上线后关掉这个模式即可。这个项目后续还可以扩展的方向包括多场景切换时的状态迁移、基于用户历史表现的长期学习档案、以及多人角色扮演场景。不过那是另一个故事了先把单场景的体验打磨好比堆功能重要得多。

相关推荐

Electron 封装 DeepSeek Harness:桌面端 profile 管理与 runProfile 执行实战
Electron 封装 DeepSeek Harness:桌面端 profile 管理与 runProfile 执行实战

1. 为什么我要把 DeepSeek Harness 塞进 Electron 壳里第一次接触 DeepSeek Harness 是在一个做本地推理工作流的朋友那里,他当时用命令行跑得挺欢,但每次要切换 profile、改 runProfile 参数、加载不同插件,都得开终端敲一长串命令&#xff… · 2026/11/2 14:12:15

微信小程序常见问题排查:白屏、登录态、支付回调与包体积
微信小程序常见问题排查:白屏、登录态、支付回调与包体积

小程序的入门门槛低到什么程度?一个下午就能把页面跑起来,一周就能提交审核。但真正让人栽跟头的,从来不是语法,而是那些文档里一句话带过、出问题时却要花半天定位的东西——真机白屏、登录态诡异失效、支付回调收不到、主包超限… · 2026/11/2 14:12:15

机器学习建模与评估:核心概念与实战技巧
机器学习建模与评估:核心概念与实战技巧

1. 机器学习建模与评估的核心概念 作为一名从业多年的数据科学家,我经常被问到:"机器学习建模到底在做什么?为什么评估如此重要?"简单来说,机器学习建模就是教会计算机从数据中学习规律的过程,而… · 2026/11/2 14:12:15

MPU-60X0六轴芯片九轴输出:量程、DMP、FIFO与总线踩坑
MPU-60X0六轴芯片九轴输出:量程、DMP、FIFO与总线踩坑

简介:《MPU-6000 / MPU-6050 产品说明书》中文翻译版是一份面向嵌入式硬件工程师、飞控与机器人开发者的传感器手册,针对英文原版阅读门槛高、寄存器与引脚细节难懂的问题,只翻译了实际开发中用得上的部分。内容覆盖芯片整体架构、DMP 数字运… · 2026/11/3 3:54:38

逆向拆解能力:从结果反推决策链,把别人经验变成自己起点
逆向拆解能力:从结果反推决策链,把别人经验变成自己起点

不知道从什么时候开始,我判断一个人有没有“手感”,只用一把尺子:给他一个好结果,看他能不能倒推出它怎么来的。这说的就是 reverse-skill,逆向拆解的能力。看到一篇爆款文章,普通人会想“写得好”&#xf… · 2026/11/3 3:54:38

hidetoolz 3.0:绿色工具集整理与打包分发实践
hidetoolz 3.0:绿色工具集整理与打包分发实践

1. 先想清楚:一个自用工具箱为什么要做到 3.0我这几年有个习惯,电脑里始终放着一个自建的工具目录,名字换过几次,最早叫tools,后来改成toolbox,现在固定叫hidetoolz,版本号一路推到 3.0。说白了… · 2026/11/3 3:54:38

消费品牌如何构建系统性烙印体系提升认知度
消费品牌如何构建系统性烙印体系提升认知度

1. 为什么消费品牌需要系统性烙印体系?在消费品市场高度饱和的今天,消费者每天接触的品牌信息超过5000条。我服务过的一个新锐茶饮品牌,首月营销投放费用高达300万,但消费者调研显示品牌记忆度仅为12%。这个残酷的现实让我意识到&… · 2026/11/3 3:54:38

以太网供电POE实战指南:从选型到部署的避坑心得
以太网供电POE实战指南:从选型到部署的避坑心得

以太网供电这个话题,我最早接触是在一个安防监控项目里。当时甲方要求所有摄像头“只走一根线”,我第一反应是这怎么可能——又要供电又要传数据,难不成还得在弱电箱里塞一堆插座?后来被现场逼着把POE这套东西从头啃了一遍&#x… · 2026/11/3 3:54:38

Level-2全推行情Python SDK部署避坑:多市场订阅与版本对齐
Level-2全推行情Python SDK部署避坑:多市场订阅与版本对齐

简介:《同花顺level-2全推行情Python API操作手册》面向高频交易与量化开发者,尤其适合做涨停板、盘口异动类策略的研究与工程人员,用来解决Level-2行情如何借助Python稳定接入、按字段准确解析的落地问题。资料是单个PDF文档,压缩… · 2026/11/3 3:53:38

双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/2 13:10:28

STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/2 4:42:45

DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/2 5:17:18

STM32F411RE与MAX14431实现SPI隔离:工业现场抗共模干扰实战
STM32F411RE与MAX14431实现SPI隔离:工业现场抗共模干扰实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59

ESP32+大模型AI硬件开发:从Demo到产品的8个工程问题
ESP32+大模型AI硬件开发:从Demo到产品的8个工程问题

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59

基于MAX14431BAEE+与PIC18F85J10的SPI隔离方案设计与实践
基于MAX14431BAEE+与PIC18F85J10的SPI隔离方案设计与实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59

双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/2 13:10:28

STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/2 4:42:45

DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/2 5:17:18

了解更多?预约专属演示

我们的顾问将为您一对一讲解产品与方案

企业微信二维码