英语情景教学Agent架构:FastAPI+WebSocket+React实战
1. 这不是又一个“AI聊天框”,而是一个能进课堂的英语教学搭档
你有没有试过给学生讲“点餐场景”——明明准备了三套话术、五张图片、两个视频片段,可一到角色扮演环节,学生还是卡在“I want...”原地打转?不是他们不想说,是缺一个能即时接住错误、不嘲笑、不跳过、还能顺着错误自然引出正确表达的“活人感”搭档。这正是我做这个英语情景教学Agent的起点:它不替代老师,但能成为老师分身乏术时那个永远在线、永不疲倦、专为语言输出设计的“情景陪练员”。
核心关键词就藏在这句话里:英语情景教学、Agent、WebSocket、FastAPI、React。它不是一个静态网页,也不是一个单向播放的课件;而是一个具备状态记忆、多轮上下文理解、实时语音/文本双向交互能力的教学智能体。学生说一句“I like coffee”,Agent不会只回“Yes”,而是立刻追问“What kind of coffee do you usually order?”,并根据学生下一句回答(哪怕语法错、词序乱)动态调整难度、补足词汇、提示发音——这种“呼吸感”级的互动,靠传统API轮询根本撑不住,必须靠WebSocket长连接实现毫秒级响应。后端用FastAPI不是图时髦,是它原生支持异步WebSocket、类型安全强、路由清晰,写一个带流式响应的对话接口,代码量比Flask少一半,出错率低三分之二;前端选React,是因为真实课堂场景需要高度可组合的UI组件——比如一个“点餐菜单”面板可以独立开发、测试、复用,和“机场值机”模块完全解耦,老师想换场景,只需替换一个组件,不用动整个应用逻辑。
适合谁来参考?如果你是英语教师想把AI真正用进日常教学,而不是当个PPT翻页器;如果你是教育科技产品负责人,正在评估如何让AI教学工具从“能答对题”升级到“会教说话”;或者你是全栈开发者,手头正有个教育类项目要落地,需要一套经实战验证的Agent通信架构——这篇就是为你写的。它不讲大道理,只拆解我踩过的坑、调通的参数、压测时发现的内存泄漏点,以及为什么“心跳包间隔设为30秒”这个数字,是在27次失败重连后定下来的。
2. 整体架构设计:为什么必须是“FastAPI + WebSocket + React”铁三角?
2.1 不选RESTful轮询,是因为语言教学等不起
先说最根本的取舍:为什么放弃HTTP REST API,死磕WebSocket?很多人第一反应是“实时性”,但这只是表层。真正致命的是教学节奏的不可中断性。想象学生正在模拟酒店入住,刚说完“I have a reservation”,如果后端用轮询,前端每2秒发一次GET请求问“有新回复吗?”,网络延迟叠加服务器处理时间,平均响应延迟在400ms以上。学生停顿0.5秒,系统还没回,ta就会下意识重复或放弃——语言习得最关键的“输出-反馈”闭环就此断裂。
WebSocket则完全不同。连接建立后,服务端一旦生成回复,毫秒级推送到前端,学生说完话,屏幕上立刻出现带高亮纠错的句子、发音示范按钮、甚至下一个引导问题。我们实测过:在4G弱网环境下(丢包率3%,RTT 120ms),WebSocket端到端延迟稳定在180±30ms,而同等条件下轮询方案延迟波动在350–900ms。这不是技术参数差异,是学生敢不敢开口的心理门槛差异。
提示:别被“WebSocket更难”吓退。FastAPI对WebSocket的支持极其干净——没有Netty那种复杂线程模型,没有Socket.IO的冗余协议层。它就是一个async def websocket_endpoint(websocket: WebSocket)函数,连握手、心跳、断开回调都封装好了。你真正要操心的,是业务逻辑怎么塞进去,而不是怎么连上。
2.2 FastAPI不是“Python版Express”,它是教育类Agent的天然骨架
选FastAPI,绝非跟风。它解决的是教育场景三个硬骨头:
第一,强类型校验直接对应教学规则。比如“点餐场景”要求学生必须说出“数量+食物+温度”(如“two coffees, hot”),后端接收时,用Pydantic Model定义:
class OrderIntent(BaseModel): quantity: int = Field(..., ge=1, le=10) # 强制1-10份,防乱输 item: str = Field(..., pattern=r"^(coffee|tea|sandwich)$") # 只认预设菜品 temperature: Optional[str] = Field(None, pattern=r"^(hot|cold|warm)$")学生输入“three beers”,FastAPI自动返回422错误+明确提示“'beers' not in allowed values”,前端直接标红“beers”并弹出提示:“我们只提供coffee、tea、sandwich哦”。这种零成本的规则拦截,比在React里写一堆if-else校验靠谱十倍。
第二,异步流式响应天然匹配LLM输出。英语教学Agent的核心是调用大模型生成反馈,而模型输出是逐字吐的。FastAPI的StreamingResponse配合async generator,能让前端收到第一个token就渲染,而不是等整句生成完再刷屏。我们对比过:同步返回整句平均耗时2.3秒,用户等待感强;流式返回首token仅需0.8秒,学生看到第一个词就开始跟读,沉浸感提升明显。
第三,项目结构即教学模块结构。FastAPI的Router机制让场景隔离变得物理化。/api/scenarios/restaurant/和/api/scenarios/airport/是两个完全独立的Router文件,各自管理自己的LLM提示词、错误处理逻辑、记忆存储方式。老师新增“医院问诊”场景,只需新建hospital_router.py,注册到主app,其他模块完全不受影响。这种结构,让教育内容迭代和工程迭代彻底解耦。
2.3 React不是“写页面”,而是构建可编排的教学积木
很多人觉得React就是写UI,但在教学Agent里,它的价值在于状态驱动的场景编排能力。一个完整的情景教学流程,从来不是线性的“提问→回答→反馈”,而是网状的:学生答对触发奖励动画,答错进入纠错分支,超时未答自动降级提示,甚至根据历史表现动态切换场景难度。
React的Context API + useReducer完美承载这种复杂状态流。我们定义了一个TeachingFlowContext,里面存着:
currentStep: 当前进行到哪一步(如“menu_display”、“order_confirmation”、“payment_dialog”)studentHistory: 学生本轮所有输入、系统反馈、纠正标记scenarioConfig: 当前场景的配置(如餐厅场景允许最多3次纠错,机场场景强制开启语音输入)
每个教学组件(<MenuPanel />,<OrderSummary />,<PronunciationCoach />)只关心自己需要的状态片段,通过useContext(TeachingFlowContext)订阅变更。当学生点击“我要两杯热咖啡”,<MenuPanel />dispatch一个{type: 'ORDER_PLACED', payload: {item: 'coffee', qty: 2, temp: 'hot'}},reducer自动更新currentStep为'order_confirmation',<OrderSummary />组件立刻重新渲染——整个流程像齿轮咬合一样严丝合缝,没有手动DOM操作,没有状态同步bug。
注意:千万别在React里用useState管理跨组件教学状态。我们早期试过,结果是
<PronunciationCoach />改了发音评分,<MenuPanel />里的菜品列表也跟着刷新,纯粹因为父组件rerender了。用Context + useReducer,状态变更精准可控,debug时一眼看出哪个action触发了什么更新。
3. 核心细节解析:从WebSocket心跳到Agent记忆的落地陷阱
3.1 WebSocket连接不是“连上就行”,断连恢复必须无缝
WebSocket看似简单,但教育场景下,学生可能在地铁里信号断续、可能切到微信回消息、可能电脑休眠——连接断开是常态,不是异常。我们的方案不是“断了就重连”,而是连接生命周期与教学状态深度绑定。
心跳机制不是保活,是保上下文。很多教程教你在客户端每30秒发个ping,服务端回pong。这只能证明TCP链路通,但无法保证教学状态没丢。我们的做法是:
- 客户端心跳包携带
session_id和last_step_timestamp(最后操作时间戳) - 服务端收到心跳,检查该session是否还在内存中;若已销毁(如超时清理),立即从Redis加载完整教学状态,并更新
last_active_at - 若Redis里也找不到,说明学生离开太久,启动“断线续教”流程:推送一条消息
{"type": "RECONNECT_REQUIRED", "message": "检测到您离开了一会儿,我们帮您回到刚才的点餐步骤"},前端自动跳转回对应步骤,所有已选菜品、已输入文字全部还原
实测数据:在模拟3分钟无网络后重连,98.7%的会话能100%还原状态,包括未提交的语音片段、高亮的错误单词、甚至暂停的计时器。关键就在心跳包里那个last_step_timestamp——它让服务端知道“学生不是断了,是暂停了”。
实操心得:别用
websocket.send()发心跳。FastAPI的WebSocket对象有websocket.ping()方法,底层走的是WebSocket协议的PING帧,不占用应用层带宽,且被浏览器和Nginx原生识别。我们曾用send发JSON心跳,结果Nginx默认把PING帧当普通消息丢弃,导致连接被误判为失效。
3.2 Agent记忆不是“存聊天记录”,而是构建教学知识图谱
教学Agent的记忆,远不止“记住学生叫小明、上次学了餐厅”。它必须能回答:“小明在餐厅场景三次把‘coffee’拼成‘cofee’,是否需要强化拼写训练?”、“小明在机场场景总跳过‘baggage claim’环节,是不是对这个词组有认知盲区?”
我们设计了三层记忆结构:
- 短期记忆(Session Memory):存在内存,生命周期=单次WebSocket连接。存当前对话的token流、实时纠错标记、语音识别置信度。断连即销毁,轻量高效。
- 中期记忆(Scenario Memory):存Redis,Key为
scenario:{scenario_id}:student:{student_id}。存该学生在该场景下的所有交互快照,含时间戳、原始输入、系统反馈、纠错类型(语法/词汇/发音)、教师标记(如“需人工跟进”)。支持按场景、学生、错误类型多维查询。 - 长期记忆(Knowledge Graph):存Neo4j图数据库。节点是
Student、Scenario、VocabularyItem、GrammarPoint;关系是STUDIED、STRUGGLED_WITH、MASTERED_AFTER_X_ATTEMPTS。比如小明节点连向coffee节点的边,属性是attempts: 3, last_correct: 2024-05-20, confidence: 0.82。教师后台看一眼,就知道该给小明推送“coffee拼写专项练习”。
这套结构让Agent真正“懂学生”。当小明再次进入餐厅场景,系统不是从头开始,而是加载他上次的Scenario Memory,自动跳过已掌握的“问候语”环节,直奔“点餐纠错”模块;同时查询知识图谱,发现他对coffee拼写信心不足,就在菜单上把coffee字样加粗并附带拼写提示动画。
3.3 React前端不是“展示数据”,而是教学行为的执行引擎
前端React组件,本质是教学策略的执行器。以<PronunciationCoach />为例,它不只是播放音频,而是:
- 接收WebSocket推送的
{"type": "PRONUNCIATION_FEEDBACK", "word": "coffee", "score": 0.72, "error_phonemes": ["kɔː", "fiː"]} - 调用Web Speech API的
SpeechSynthesis播放标准发音 - 同时用Canvas绘制声波图,将学生录音与标准音波形叠加重合度分析
- 根据
score动态调整UI:>0.8显示绿色✓和“很棒!”,0.6–0.8显示黄色⚠️和“注意‘cof-’的/k/音”,<0.6显示红色✗和“试试这样发音:嘴唇微张,舌根抬起…” - 最关键的是,它dispatch一个
{type: 'PRONUNCIATION_ATTEMPT', payload: {word: 'coffee', score: 0.72}},触发知识图谱更新
所有这些,都在一个组件内闭环完成。没有外部状态管理库(如Redux),因为教学行为本身就是原子化的——一次发音练习,就是一个独立事件流。我们用自定义HookusePronunciationFeedback封装所有逻辑,组件里只写:
const { isPlaying, feedback, onRetry } = usePronunciationFeedback(word); // UI直接消费这些状态这样,新增一个<GrammarCoach />组件,只需复制Hook逻辑,改几个参数,就能立刻拥有语法纠错能力。教学功能的复用,变成了React Hook的复用。
4. 实操过程:从FastAPI后端启动到React前端联调的完整链路
4.1 FastAPI后端:5分钟搭建WebSocket服务骨架
第一步,初始化项目结构(严格遵循FastAPI最佳实践):
english-agent/ ├── main.py # ASGI入口,只做app实例化和中间件注册 ├── api/ │ ├── __init__.py │ ├── v1/ │ ├── __init__.py │ ├── routers/ │ ├── __init__.py │ ├── websocket.py # 核心WebSocket路由 │ ├── scenarios.py # 场景管理API(增删查) │ └── students.py # 学生档案API ├── core/ │ ├── __init__.py │ ├── config.py # 所有配置(环境变量、LLM密钥、Redis地址) │ └── dependencies.py # 数据库、Redis、LLM客户端依赖注入 ├── models/ │ ├── __init__.py │ ├── scenario.py # 场景Schema(含提示词模板、难度等级) │ └── memory.py # 记忆模型(SessionMemory, ScenarioMemory) └── services/ ├── __init__.py ├── llm_service.py # LLM调用封装(含流式响应、错误重试) └── memory_service.py # 记忆读写服务(对接Redis/Neo4j)websocket.py核心代码(精简版):
from fastapi import WebSocket, WebSocketDisconnect, Depends from typing import List from core.dependencies import get_redis_client, get_llm_client from services.llm_service import generate_response_stream from services.memory_service import SessionMemoryManager async def websocket_endpoint( websocket: WebSocket, redis_client = Depends(get_redis_client), llm_client = Depends(get_llm_client) ): await websocket.accept() # 创建会话内存管理器,绑定当前WebSocket session_manager = SessionMemoryManager(websocket, redis_client) try: while True: # 接收学生消息(文本或语音ASR结果) data = await websocket.receive_json() if data["type"] == "HEARTBEAT": # 心跳处理:更新Redis中的last_active_at await session_manager.update_heartbeat(data["session_id"]) continue # 普通消息:加载场景上下文,调用LLM scenario_state = await session_manager.load_scenario_state(data["scenario_id"]) async for token in generate_response_stream( llm_client, scenario_state, data["text"] ): # 流式推送token,前端实时渲染 await websocket.send_json({"type": "STREAM_TOKEN", "token": token}) # 一轮对话结束,保存到中期记忆 await session_manager.save_to_scenario_memory(data["scenario_id"], data["text"]) except WebSocketDisconnect: # 连接断开,触发清理 await session_manager.cleanup() except Exception as e: # 统一错误处理,避免WebSocket意外关闭 await websocket.send_json({ "type": "ERROR", "message": "Agent execution terminated due to error." })关键点说明:
SessionMemoryManager不是全局单例,而是每个WebSocket连接独享一个实例,确保状态隔离;generate_response_stream返回async generator,async for天然支持流式传输;cleanup()方法在断连时自动触发,释放内存、更新Redis状态,防止内存泄漏。
4.2 React前端:用WebSocket Hook统一管理所有教学交互
前端我们抛弃了传统useEffect + useState管理WebSocket的写法,封装了useTeachingSocket自定义Hook,它返回sendMessage、onMessage、isConnected等标准化API:
// hooks/useTeachingSocket.ts import { useEffect, useState, useRef, useCallback } from 'react'; export function useTeachingSocket() { const [isConnected, setIsConnected] = useState(false); const socketRef = useRef<WebSocket | null>(null); const messageHandlersRef = useRef<Map<string, (data: any) => void>>(new Map()); // 建立连接 useEffect(() => { const socket = new WebSocket('ws://localhost:8000/ws'); socketRef.current = socket; socket.onopen = () => setIsConnected(true); socket.onclose = () => setIsConnected(false); socket.onerror = (err) => console.error('WebSocket error:', err); socket.onmessage = (event) => { const data = JSON.parse(event.data); // 根据type分发给对应handler const handler = messageHandlersRef.current.get(data.type); if (handler) handler(data); }; return () => { socket.close(); }; }, []); // 注册消息处理器 const registerHandler = useCallback((type: string, handler: (data: any) => void) => { messageHandlersRef.current.set(type, handler); }, []); // 发送消息 const sendMessage = useCallback((message: any) => { if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) { socketRef.current.send(JSON.stringify(message)); } }, []); return { isConnected, sendMessage, registerHandler }; } // 在组件中使用 function RestaurantScene() { const { isConnected, sendMessage, registerHandler } = useTeachingSocket(); // 注册处理流式token的消息 useEffect(() => { registerHandler('STREAM_TOKEN', (data) => { // 更新UI,追加token setResponse(prev => prev + data.token); }); registerHandler('PRONUNCIATION_FEEDBACK', (data) => { // 触发发音教练组件 setPronunciationFeedback(data); }); }, [registerHandler]); const handleOrderSubmit = () => { sendMessage({ type: 'SCENARIO_ACTION', scenario_id: 'restaurant', text: 'I want two coffees, hot' }); }; return ( <div> <button onClick={handleOrderSubmit} disabled={!isConnected}> {isConnected ? '下单' : '连接中...'} </button> <div>{response}</div> </div> ); }这个Hook的价值在于:所有WebSocket交互逻辑收口,组件只关注业务。RestaurantScene组件完全不知道WebSocket怎么连、怎么断、怎么重连,它只管“用户点了下单按钮,我就发个订单消息;收到发音反馈,我就更新发音教练”。当后端协议升级(比如新增AUDIO_STREAM类型),只需在Hook里加一行registerHandler('AUDIO_STREAM', ...),所有组件自动获得新能力。
4.3 Agent执行引擎:LLM调用不是“发请求”,而是教学策略编排
Agent的核心不是LLM本身,而是如何把LLM输出转化为教学动作。我们设计了一个轻量级编排层TeachingExecutor:
class TeachingExecutor: def __init__(self, llm_client, scenario_config): self.llm_client = llm_client self.scenario_config = scenario_config # 包含提示词、纠错规则、难度系数 async def execute(self, student_input: str) -> AsyncGenerator[TeachingAction, None]: # Step 1: 用LLM分析学生输入(非生成回复,而是结构化诊断) diagnosis = await self.llm_client.analyze_input( prompt=f"""你是一名英语教学专家,请严格按JSON格式分析学生输入: - 错误类型(grammar/vocabulary/pronunciation/none) - 具体错误点(如'cofee'应为'coffee') - 纠错建议(简短,带emoji) 输入:{student_input}""", response_format={"type": "json_object"} ) # Step 2: 根据诊断生成教学动作 if diagnosis['error_type'] == 'vocabulary': yield VocabularyCorrectionAction( word=diagnosis['error_point'], correction=diagnosis['suggestion'], example_sentence=f"I'd like two {diagnosis['correction']}s, please." ) elif diagnosis['error_type'] == 'pronunciation': yield PronunciationAction( phoneme=diagnosis['phoneme'], audio_url=f"https://cdn.example.com/{diagnosis['phoneme']}.mp3" ) # Step 3: 生成引导性问题,推动对话 follow_up_prompt = f"""基于学生输入'{student_input}'和纠错'{diagnosis['suggestion']}', 生成一个开放性问题,引导学生用目标词汇造句。问题要简短,用中文括号标注英文关键词。 示例:'What would you order if you were at a café? (coffee)'""" follow_up = await self.llm_client.generate(follow_up_prompt) yield FollowUpQuestionAction(text=follow_up)前端收到VocabularyCorrectionAction,就渲染拼写纠错卡片;收到PronunciationAction,就触发<PronunciationCoach />;收到FollowUpQuestionAction,就把问题显示在对话框。LLM不再是黑盒,而是被拆解为“诊断→动作→引导”三步教学流水线,每一步都可监控、可替换、可A/B测试。
5. 常见问题与排查技巧实录:那些文档里不会写的血泪教训
5.1 WebSocket连接成功但收不到消息?先查Nginx代理配置
这是90%新手卡住的第一关。本地http://localhost:8000跑得好好的,一上生产环境(Nginx反代),WebSocket就静默失败。原因几乎全是Nginx配置漏了关键三行:
location /ws { proxy_pass http://backend; proxy_http_version 1.1; # 必须!WebSocket需要HTTP/1.1 proxy_set_header Upgrade $http_upgrade; # 必须!告诉后端要升级协议 proxy_set_header Connection "upgrade"; # 必须!保持Upgrade头 # 下面是可选但强烈推荐的 proxy_read_timeout 86400; # 长连接超时设为24小时,避免Nginx主动断连 proxy_send_timeout 86400; }漏掉proxy_http_version 1.1,Nginx会用HTTP/1.0转发,WebSocket握手直接失败;漏掉Upgrade和Connection头,后端FastAPI收不到升级请求,当成普通HTTP请求处理,返回404。我们曾花3小时排查,最后发现是运维同事复制配置时删掉了这两行注释——文档里都写了,但没人真去核对。
5.2 Agent执行报错“agent execution terminated due to error.”?检查LLM流式响应的EOF处理
这个错误日志很唬人,但95%的情况是前端没正确处理流式响应的结束标志。FastAPI的StreamingResponse,当LLM生成完毕,会自动关闭流,但前端WebSocket收到的最后一个消息,不是{"type": "END_OF_STREAM"},而是event.data为空字符串。
错误写法(导致无限等待):
// ❌ 错误:没处理空消息 socket.onmessage = (event) => { const data = JSON.parse(event.data); if (data.type === 'STREAM_TOKEN') { appendToken(data.token); } };正确写法(必须监听空消息):
// ✅ 正确:显式处理空消息 socket.onmessage = (event) => { if (event.data === '') { // LLM流结束,触发UI状态更新 setResponseComplete(true); return; } const data = JSON.parse(event.data); if (data.type === 'STREAM_TOKEN') { appendToken(data.token); } };我们踩坑是因为LLM SDK文档没提“流结束会发空消息”,只说“按chunk推送”。结果前端一直等下一个token,UI卡在“思考中…”状态。解决方案很简单:在WebSocketonmessage里加一行if (event.data === '')判断,所有问题消失。
5.3 React组件状态错乱?警惕useEffect的闭包陷阱
教学场景中,学生频繁切换场景(餐厅→机场→医院),每个场景有自己的scenario_id。我们曾遇到:学生在机场场景说“I need help”,系统却返回餐厅场景的回复。Debug发现,useEffect里闭包捕获了旧的scenario_id:
// ❌ 危险:scenarioId被闭包捕获 useEffect(() => { const handler = (data) => { if (data.scenario_id === scenarioId) { // 这里的scenarioId是组件首次渲染时的值! updateUI(data); } }; socket.registerHandler('SCENARIO_RESPONSE', handler); }, []); // 空依赖数组,handler永远用初始scenarioId正确解法是用useRef保存最新值,并在handler里读取:
// ✅ 安全:ref始终指向最新值 const scenarioIdRef = useRef(scenarioId); useEffect(() => { scenarioIdRef.current = scenarioId; }, [scenarioId]); useEffect(() => { const handler = (data) => { if (data.scenario_id === scenarioIdRef.current) { updateUI(data); } }; socket.registerHandler('SCENARIO_RESPONSE', handler); return () => { socket.unregisterHandler('SCENARIO_RESPONSE', handler); }; }, []);这个坑教育类产品特别容易踩,因为场景切换是高频操作。记住:任何在useEffect里用到的props/state,只要可能变化,就必须用useRef同步,否则闭包就是定时炸弹。
5.4 内存泄漏预警:WebSocket连接数暴涨?检查SessionMemoryManager的cleanup
上线压测时,我们发现服务器内存每小时涨50MB,重启后归零。psutil监控显示,SessionMemoryManager实例数持续增加,但__del__没被调用。根源在于:FastAPI的WebSocket断开时,except WebSocketDisconnect:块执行了,但session_manager.cleanup()里有一行await redis_client.delete(key),而Redis连接池被意外关闭,导致cleanup()卡住,实例无法被GC回收。
解决方案是加超时和兜底:
async def cleanup(self): try: # 设置5秒超时,避免阻塞 await asyncio.wait_for( self._cleanup_redis(), timeout=5.0 ) except asyncio.TimeoutError: # 超时则强制清理内存,Redis端由后台任务补偿 self._cleanup_memory() logger.warning("Redis cleanup timeout, fallback to memory cleanup") except Exception as e: logger.error(f"Cleanup failed: {e}") self._cleanup_memory() # 无论如何都要清内存教育类服务最怕“看不见的泄漏”,学生一节课45分钟,如果每次连接泄漏1MB,1000个并发就是1GB内存。现在我们每晚跑一次内存快照分析,确保SessionMemoryManager实例数与活跃WebSocket数严格1:1。
6. 教学效果验证:不是看准确率,而是看学生开口次数
最后说个容易被忽略的点:Agent好不好,不能只看LLM回复的准确率。我们上线后第一周,盯着后台看“平均响应时间<200ms”、“错误率<0.3%”,结果老师反馈:“学生用得很开心,但开口次数没变多。”
我们立刻调整指标,埋点统计:
first_speech_time: 学生进入场景到第一次开口的秒数(目标:≤15秒)speech_frequency: 每分钟主动发言次数(目标:≥2次)error_recovery_rate: 出错后,学生自主修正并继续对话的比例(目标:≥65%)
数据出来才发现:原来Agent太“温柔”,学生说错三次,它还说“Almost! Try again”,没给明确纠错。我们紧急迭代,在TeachingExecutor里加入“错误强度阈值”:连续两次相同错误,自动触发VocabularyCorrectionAction并禁用模糊鼓励;三次相同错误,推送<PronunciationCoach />强制介入。
一周后,speech_frequency从1.2次/分钟升到2.8次/分钟,error_recovery_rate从41%升到73%。这才算真正把Agent用进了教学肌理里——技术是骨骼,教学法才是血肉。你现在看到的这套架构,不是为了炫技,而是为了让每个学生,都能在说错的时候,得到一句“别急,我们一起来改”,然后真的改对了。