websocket_handler.py 46 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109
  1. """
  2. WebSocket处理器 - 新版本(STT+LLM+TTS流水线,支持流式LLM)
  3. """
  4. import json
  5. import base64
  6. import asyncio
  7. import time
  8. import re
  9. import logging
  10. import traceback
  11. from typing import Dict, Optional
  12. from fastapi import WebSocket
  13. logger = logging.getLogger(__name__)
  14. # 预编译正则表达式(性能优化)
  15. EMOTION_TAG_PATTERN = re.compile(r'\[EMOTION:\w+\]')
  16. from models import (
  17. AudioChunkMessage,
  18. AudioEndMessage,
  19. CancelMessage,
  20. TextResultMessage,
  21. EmotionMessage,
  22. AudioResponseMessage,
  23. ErrorMessage,
  24. PingMessage,
  25. PongMessage,
  26. )
  27. from ai_service import ai_service
  28. from request_logger import request_logger, PerformanceMetrics
  29. from config import settings
  30. from stt_service import stt_service, STTSession
  31. from services.user_service import user_service
  32. class WebSocketHandler:
  33. """WebSocket连接处理器"""
  34. def __init__(self, websocket: WebSocket, user_id: str = None, redis_cache = None):
  35. self.websocket = websocket
  36. self.user_id = user_id or "guest_user" # 用户ID(默认游客)
  37. self.cancelled_requests: set = set() # 已取消的请求ID
  38. self.stt_sessions: Dict[str, STTSession] = {} # requestId -> STT会话对象
  39. self.event_loop: Optional[asyncio.AbstractEventLoop] = None # 保存事件循环引用
  40. self.request_start_times: Dict[str, float] = {} # 请求开始时间
  41. self.audio_end_times: Dict[str, float] = {} # requestId -> 录音结束时间
  42. self.ai_responses: Dict[str, list] = {} # requestId -> AI响应句子列表(用于日志记录)
  43. self.performance_metrics: Dict[str, PerformanceMetrics] = {} # requestId -> 性能指标
  44. # 初始化服务(延迟加载,避免循环导入)
  45. self.message_service = None
  46. self.context_service = None
  47. self.redis_cache = redis_cache # 🔥 Redis缓存服务
  48. # 🔥 预加载任务
  49. self.preload_tasks: Dict[str, asyncio.Task] = {}
  50. # 🔥 异步持久化队列
  51. self.pending_persistence = []
  52. self.persistence_task: Optional[asyncio.Task] = None
  53. self.persistence_interval = 5.0 # 每5秒批量持久化
  54. # 🔥 异步总结队列
  55. self.pending_summaries = []
  56. self.summary_task: Optional[asyncio.Task] = None
  57. async def _init_services(self):
  58. """初始化服务(延迟加载)"""
  59. try:
  60. from database import db_manager
  61. from services import MessageService, ContextService
  62. # 使用延迟初始化的全局实例
  63. from services.message_service import message_service
  64. from services.context_service import context_service
  65. await message_service.set_db_manager(db_manager)
  66. await context_service.set_db_manager(db_manager)
  67. self.message_service = message_service
  68. self.context_service = context_service
  69. except Exception as e:
  70. pass
  71. async def handle_connection(self):
  72. """处理WebSocket连接"""
  73. await self.websocket.accept()
  74. # 初始化服务
  75. await self._init_services()
  76. # 保存事件循环引用,供回调线程使用
  77. self.event_loop = asyncio.get_running_loop()
  78. try:
  79. while True:
  80. # 接收消息
  81. data = await self.websocket.receive_text()
  82. message_data = json.loads(data)
  83. # 处理消息
  84. await self.handle_message(message_data)
  85. except Exception as e:
  86. pass
  87. finally:
  88. pass
  89. async def handle_message(self, message_data: dict):
  90. """处理接收到的消息"""
  91. message_type = message_data.get("type")
  92. request_id = message_data.get("requestId")
  93. # 检查是否为已取消的请求
  94. if request_id in self.cancelled_requests:
  95. return
  96. if message_type == "audio_chunk":
  97. await self.handle_audio_chunk(AudioChunkMessage(**message_data))
  98. elif message_type == "audio_end":
  99. await self.handle_audio_end(AudioEndMessage(**message_data))
  100. elif message_type == "cancel":
  101. await self.handle_cancel(CancelMessage(**message_data))
  102. elif message_type == "ping":
  103. await self.handle_ping(PingMessage(**message_data))
  104. async def handle_audio_chunk(self, message: AudioChunkMessage):
  105. """处理音频数据块 - 使用新的STT服务"""
  106. request_id = message.requestId
  107. # 检查是否取消
  108. if request_id in self.cancelled_requests:
  109. return
  110. # Base64解码音频数据
  111. audio_data = base64.b64decode(message.data)
  112. chunk_size = len(audio_data)
  113. # 第一个chunk:初始化请求并启动STT会话
  114. if request_id not in self.request_start_times:
  115. current_time = time.time()
  116. self.request_start_times[request_id] = current_time
  117. # 初始化性能指标
  118. self.performance_metrics[request_id] = PerformanceMetrics()
  119. self.performance_metrics[request_id].user_speech_start = current_time
  120. self.performance_metrics[request_id].stt_first_audio_received = current_time
  121. # 创建STT会话
  122. session = await self._create_stt_session(request_id)
  123. if not session or not session.is_connected:
  124. await self.send_error(request_id, "STT_ERROR", "语音识别服务启动失败")
  125. return
  126. self.stt_sessions[request_id] = session
  127. # 🔥 启动Redis预加载任务(与录音并行)
  128. if self.redis_cache:
  129. self.preload_tasks[request_id] = asyncio.create_task(
  130. self._ensure_redis_context(request_id)
  131. )
  132. # 发送音频帧到STT服务
  133. session = self.stt_sessions.get(request_id)
  134. if session:
  135. success = await session.send_audio(audio_data)
  136. if not success:
  137. await self.send_error(request_id, "STT_ERROR", "发送音频数据失败")
  138. # 📝 更新STT音频统计(用于日志)
  139. if request_id in self.performance_metrics:
  140. self.performance_metrics[request_id].stt_audio_chunks += 1
  141. self.performance_metrics[request_id].stt_total_bytes += chunk_size
  142. async def handle_audio_end(self, message: AudioEndMessage):
  143. """处理音频结束 - 使用新的STT服务"""
  144. request_id = message.requestId
  145. # 记录录音结束时间
  146. audio_end_time = time.time()
  147. self.audio_end_times[request_id] = audio_end_time
  148. # 更新性能指标
  149. if request_id in self.performance_metrics:
  150. self.performance_metrics[request_id].user_speech_end = audio_end_time
  151. self.performance_metrics[request_id].stt_last_audio_received = audio_end_time
  152. # 🔥 更新用户累积说话时长(如果有提供)
  153. if message.speechDuration and message.speechDuration > 0:
  154. try:
  155. await user_service.update_speech_duration(self.user_id, message.speechDuration)
  156. except Exception as e:
  157. logger.warning(f"⚠️ 更新用户说话时长失败: user_id={self.user_id}, duration_ms={message.speechDuration}, error={e}")
  158. # 初始化AI响应收集器
  159. self.ai_responses[request_id] = []
  160. # 检查是否取消
  161. if request_id in self.cancelled_requests:
  162. self.cancelled_requests.remove(request_id)
  163. await self.cleanup_request(request_id)
  164. return
  165. try:
  166. # 1. STT: 等待完整识别结果
  167. stt_start = time.time()
  168. session = self.stt_sessions.get(request_id)
  169. if not session:
  170. await self.send_error(request_id, "STT_ERROR", "语音识别会话不存在")
  171. request_logger.log_error(
  172. user_id=self.user_id,
  173. request_id=request_id,
  174. error_code="STT_ERROR",
  175. error_message="语音识别会话不存在",
  176. stage="stt"
  177. )
  178. return
  179. # 🔥 手动提交音频,触发识别(禁用VAD时必需)
  180. try:
  181. commit_success = await session.commit_audio()
  182. if not commit_success:
  183. logger.error(
  184. f"[STT异常] request_id={request_id}, "
  185. f"错误类型=提交识别请求失败, "
  186. f"用户ID={self.user_id}, "
  187. f"音频块数={message.totalChunks}, "
  188. f"会话状态=is_connected={session.is_connected}"
  189. )
  190. await self.send_error(request_id, "STT_ERROR", "提交识别请求失败")
  191. request_logger.log_error(
  192. user_id=self.user_id,
  193. request_id=request_id,
  194. error_code="STT_ERROR",
  195. error_message="提交识别请求失败",
  196. stage="stt"
  197. )
  198. await self.cleanup_request(request_id)
  199. return
  200. except Exception as commit_error:
  201. logger.error(
  202. f"[STT异常] request_id={request_id}, "
  203. f"错误类型=提交音频异常, "
  204. f"异常类型={type(commit_error).__name__}, "
  205. f"错误消息={str(commit_error)}, "
  206. f"用户ID={self.user_id}, "
  207. f"音频块数={message.totalChunks}, "
  208. f"堆栈跟踪:\n{traceback.format_exc()}"
  209. )
  210. await self.send_error(request_id, "STT_ERROR", f"提交音频异常: {str(commit_error)}")
  211. request_logger.log_error(
  212. user_id=self.user_id,
  213. request_id=request_id,
  214. error_code="STT_ERROR",
  215. error_message=f"提交音频异常: {str(commit_error)}",
  216. stage="stt"
  217. )
  218. await self.cleanup_request(request_id)
  219. return
  220. # 等待最终结果(根据音频长度动态调整超时)
  221. total_chunks = message.totalChunks
  222. if total_chunks > 80:
  223. timeout = 5.0
  224. elif total_chunks > 50:
  225. timeout = 4.0
  226. else:
  227. timeout = 3.0
  228. text = await session.wait_for_final_result(timeout=timeout)
  229. # 获取识别结果
  230. if not text:
  231. await self.send_error(request_id, "STT_ERROR", "未检测到有效语音,请重新录音")
  232. request_logger.log_error(
  233. user_id=self.user_id,
  234. request_id=request_id,
  235. error_code="STT_ERROR",
  236. error_message="未检测到有效语音,请重新录音",
  237. stage="stt"
  238. )
  239. await self.cleanup_request(request_id)
  240. return
  241. stt_duration = time.time() - stt_start
  242. stt_complete_time = time.time()
  243. # 更新性能指标
  244. if request_id in self.performance_metrics:
  245. self.performance_metrics[request_id].stt_completed = stt_complete_time
  246. self.performance_metrics[request_id].user_text = text
  247. # 获取STT首个文本生成时间
  248. first_text_time = session.get_first_text_time()
  249. if first_text_time:
  250. self.performance_metrics[request_id].stt_first_text_generated = first_text_time
  251. # 发送最终识别结果
  252. await self.send_text_result(request_id, text, True)
  253. if request_id in self.cancelled_requests:
  254. return
  255. # 🔥 LLM提示词准备开始
  256. llm_prompt_start = time.time()
  257. if request_id in self.performance_metrics:
  258. self.performance_metrics[request_id].llm_prompt_prepare_start = llm_prompt_start
  259. # 🔥 从Redis获取上下文(零延迟)
  260. context = None
  261. current_round = 0
  262. next_round = 0
  263. if self.redis_cache:
  264. # 尝试从Redis获取上下文
  265. context = await self._get_context_from_redis(request_id)
  266. if context:
  267. current_round = context.get('current_round', 0)
  268. next_round = current_round + 1
  269. # 🔥 立即写入Redis(用户消息)
  270. await self.redis_cache.append_message(
  271. user_id=self.user_id,
  272. role='user',
  273. content=text,
  274. round_number=next_round
  275. )
  276. await self.redis_cache.increment_round(self.user_id)
  277. # 🔥 异步保存user消息到MySQL(不阻塞主流程)
  278. await self._init_services()
  279. if self.message_service:
  280. asyncio.create_task(
  281. self.message_service.save_message(
  282. user_id=self.user_id,
  283. role='user',
  284. content=text
  285. )
  286. )
  287. else:
  288. # Redis未命中,降级到MySQL
  289. await self._init_services()
  290. if self.message_service and self.context_service:
  291. try:
  292. # 保存用户消息
  293. logger.info("🔥 [WebSocket] 准备保存用户消息")
  294. user_message = await asyncio.wait_for(
  295. self.message_service.save_message(
  296. user_id=self.user_id,
  297. role='user',
  298. content=text
  299. ),
  300. timeout=5.0
  301. )
  302. next_round = user_message['round_number']
  303. current_round = next_round - 1
  304. # 📝 记录用户消息ID
  305. if request_id in self.performance_metrics:
  306. self.performance_metrics[request_id].user_message_id = user_message.get('message_id')
  307. # 获取上下文
  308. context = await asyncio.wait_for(
  309. self.context_service.get_context_for_round(
  310. user_id=self.user_id,
  311. current_round=current_round
  312. ),
  313. timeout=3.0
  314. )
  315. except asyncio.TimeoutError:
  316. pass
  317. except Exception as e:
  318. pass
  319. else:
  320. # 没有Redis,直接使用MySQL
  321. await self._init_services()
  322. if self.message_service and self.context_service:
  323. try:
  324. user_message = await asyncio.wait_for(
  325. self.message_service.save_message(
  326. user_id=self.user_id,
  327. role='user',
  328. content=text
  329. ),
  330. timeout=5.0
  331. )
  332. next_round = user_message['round_number']
  333. current_round = next_round - 1
  334. # 📝 记录用户消息ID
  335. if request_id in self.performance_metrics:
  336. self.performance_metrics[request_id].user_message_id = user_message.get('message_id')
  337. context = await asyncio.wait_for(
  338. self.context_service.get_context_for_round(
  339. user_id=self.user_id,
  340. current_round=current_round
  341. ),
  342. timeout=3.0
  343. )
  344. except asyncio.TimeoutError:
  345. pass
  346. except Exception as e:
  347. pass
  348. # 🔥 LLM提示词准备结束
  349. llm_prompt_end = time.time()
  350. if request_id in self.performance_metrics:
  351. self.performance_metrics[request_id].llm_prompt_prepare_end = llm_prompt_end
  352. llm_start = time.time()
  353. # 🔥 LLM调用开始
  354. if request_id in self.performance_metrics:
  355. self.performance_metrics[request_id].llm_call_start = llm_start
  356. chunk_index = 0
  357. first_audio_time = None
  358. final_emotion = "neutral"
  359. sentence_count = 0
  360. llm_first_sentence_time = None
  361. llm_first_text_time = None # 🔥 LLM首个文本输出时间
  362. emotion_sent = False # 标记是否已发送情感
  363. # 构建Prompt(如果有上下文,使用上下文;否则使用原始文本)
  364. llm_input = self._build_llm_input(text, context) if context else text
  365. # 获取system_prompt用于日志记录
  366. system_prompt = ai_service._build_system_prompt()
  367. full_prompt = f"System: {system_prompt}\n\nUser: {llm_input}"
  368. # 📝 更新LLM输入统计(用于日志)
  369. if request_id in self.performance_metrics:
  370. self.performance_metrics[request_id].llm_prompt_length = len(llm_input)
  371. self.performance_metrics[request_id].llm_context_round = next_round
  372. self.performance_metrics[request_id].llm_full_prompt = full_prompt
  373. # 🔥 流式处理:LLM生成一句 → 立即TTS
  374. try:
  375. async for sentence, emotion in ai_service.generate_response_stream(llm_input):
  376. if request_id in self.cancelled_requests:
  377. return
  378. # 🎯 优先处理情感:第一时间发送给前端
  379. if not emotion_sent and emotion:
  380. await self.send_emotion(request_id, emotion)
  381. emotion_sent = True
  382. final_emotion = emotion
  383. # ⚡ 如果是空句子(仅传递情感),跳过 TTS 处理
  384. if not sentence:
  385. continue
  386. sentence_count += 1
  387. # 🧹 清理文本:移除情感标记和特殊字符(保留文字内容)
  388. clean_sentence = EMOTION_TAG_PATTERN.sub('', sentence)
  389. clean_sentence = clean_sentence.translate(str.maketrans('', '', '()[]')).strip()
  390. # 如果清理后为空,跳过
  391. if not clean_sentence:
  392. continue
  393. # 收集AI响应(用于日志记录)
  394. self.ai_responses[request_id].append(clean_sentence)
  395. # 记录第一个句子生成时间
  396. if llm_first_sentence_time is None:
  397. llm_first_sentence_time = time.time()
  398. # 🔥 记录LLM首个文本输出时间
  399. if llm_first_text_time is None:
  400. llm_first_text_time = time.time()
  401. if request_id in self.performance_metrics:
  402. self.performance_metrics[request_id].llm_first_text_output = llm_first_text_time
  403. tts_sentence_start = time.time()
  404. # 🔥 记录TTS调用开始时间(第一个句子)
  405. if request_id in self.performance_metrics and self.performance_metrics[request_id].tts_call_start is None:
  406. self.performance_metrics[request_id].tts_call_start = tts_sentence_start
  407. # 立即TTS合成这个句子(使用清理后的文本 + 情感参数)
  408. try:
  409. async for audio_chunk in ai_service.text_to_speech_stream(clean_sentence, emotion):
  410. if request_id in self.cancelled_requests:
  411. return
  412. # Base64编码
  413. audio_base64 = base64.b64encode(audio_chunk).decode()
  414. # 发送音频数据
  415. await self.send_audio_response(
  416. request_id,
  417. audio_base64,
  418. chunk_index,
  419. False,
  420. )
  421. # 记录第一个音频块发送时间
  422. if chunk_index == 0:
  423. first_audio_time = time.time()
  424. time_to_first_audio = first_audio_time - audio_end_time
  425. # 🔥 记录TTS首个音频输出时间
  426. if request_id in self.performance_metrics:
  427. self.performance_metrics[request_id].tts_first_audio_output = first_audio_time
  428. # 📝 更新TTS音频统计(用于日志)
  429. if request_id in self.performance_metrics:
  430. self.performance_metrics[request_id].tts_audio_chunks += 1
  431. self.performance_metrics[request_id].tts_total_bytes += len(audio_chunk)
  432. chunk_index += 1
  433. except Exception as tts_error:
  434. # TTS异常已在ai_service中详细记录,这里只记录上下文信息
  435. logger.error(
  436. f"[WebSocket异常] request_id={request_id}, "
  437. f"错误类型=TTS合成异常, "
  438. f"异常类型={type(tts_error).__name__}, "
  439. f"错误消息={str(tts_error)}, "
  440. f"句子内容={clean_sentence[:100]}... (前100字符), "
  441. f"情感={emotion}, "
  442. f"句子序号={sentence_count}, "
  443. f"已发送chunk数={chunk_index}, "
  444. f"堆栈跟踪:\n{traceback.format_exc()}"
  445. )
  446. # 重新抛出异常,让外层处理
  447. raise
  448. # 更新当前情感(但不再发送,因为已在开头发送)
  449. if emotion:
  450. final_emotion = emotion
  451. except Exception as llm_error:
  452. # LLM异常已在ai_service中详细记录,这里只记录上下文信息
  453. logger.error(
  454. f"[WebSocket异常] request_id={request_id}, "
  455. f"错误类型=LLM生成异常, "
  456. f"异常类型={type(llm_error).__name__}, "
  457. f"错误消息={str(llm_error)}, "
  458. f"用户输入={text[:100]}... (前100字符), "
  459. f"LLM输入={llm_input[:100]}... (前100字符), "
  460. f"上下文轮次={next_round}, "
  461. f"已生成句子数={sentence_count}, "
  462. f"堆栈跟踪:\n{traceback.format_exc()}"
  463. )
  464. # 重新抛出异常,让外层处理
  465. raise
  466. # 发送TTS结束标记
  467. await self.send_audio_response(request_id, "", chunk_index, True)
  468. # 记录最后一个音频块发送时间
  469. last_audio_time = time.time()
  470. time_to_last_audio = last_audio_time - audio_end_time
  471. # 🔥 更新性能指标:LLM和TTS完成时间
  472. if request_id in self.performance_metrics:
  473. self.performance_metrics[request_id].llm_all_text_completed = last_audio_time
  474. self.performance_metrics[request_id].llm_call_end = last_audio_time
  475. self.performance_metrics[request_id].tts_all_audio_completed = last_audio_time
  476. self.performance_metrics[request_id].tts_call_end = last_audio_time
  477. # 📝 更新LLM输出统计
  478. self.performance_metrics[request_id].llm_emotion = final_emotion
  479. self.performance_metrics[request_id].llm_sentences = sentence_count
  480. # 📝 更新TTS输入统计
  481. self.performance_metrics[request_id].tts_voice = settings.tts_voice
  482. # 🔥 保存AI响应
  483. ai_response_text = ' '.join(self.ai_responses.get(request_id, []))
  484. if ai_response_text:
  485. # 🔥 立即写入Redis(不阻塞)
  486. if self.redis_cache:
  487. await self.redis_cache.append_message(
  488. user_id=self.user_id,
  489. role='assistant',
  490. content=ai_response_text,
  491. round_number=next_round
  492. )
  493. # 🔥 立即保存AI消息到MySQL(获取message_id)
  494. if self.message_service:
  495. try:
  496. assistant_message = await asyncio.wait_for(
  497. self.message_service.save_message(
  498. user_id=self.user_id,
  499. role='assistant',
  500. content=ai_response_text,
  501. emotion=final_emotion
  502. ),
  503. timeout=5.0
  504. )
  505. # 📝 记录AI消息ID
  506. if request_id in self.performance_metrics:
  507. self.performance_metrics[request_id].assistant_message_id = assistant_message.get('message_id')
  508. except Exception as e:
  509. # 保存失败不影响主流程,继续异步持久化
  510. pass
  511. # 🔥 加入异步持久化队列(批量写入MySQL,用于重试或降级场景)
  512. asyncio.create_task(
  513. self._persist_conversation_async(
  514. request_id=request_id,
  515. user_text=text,
  516. ai_text=ai_response_text,
  517. round_number=next_round,
  518. emotion=final_emotion
  519. )
  520. )
  521. # 注意:总结触发逻辑已移至 message_service.py
  522. # 在保存 assistant 消息时,如果 current_round % 15 == 0,会自动触发总结
  523. # 计算总耗时
  524. total_end = time.time()
  525. total_duration = total_end - self.request_start_times.get(request_id, stt_start)
  526. llm_tts_total = total_end - llm_start
  527. # 📝 记录详细性能日志到文件
  528. try:
  529. ai_response_log = ' '.join(self.ai_responses.get(request_id, []))
  530. # 更新最终的AI响应文本
  531. if request_id in self.performance_metrics:
  532. self.performance_metrics[request_id].ai_response_text = ai_response_log
  533. # 记录详细性能数据
  534. request_logger.log_request(
  535. user_id=self.user_id,
  536. request_id=request_id,
  537. metrics=self.performance_metrics[request_id]
  538. )
  539. except Exception as log_error:
  540. pass
  541. except Exception as e:
  542. # 详细记录异常信息
  543. session = self.stt_sessions.get(request_id)
  544. user_text_log = session.get_current_text() if session else ""
  545. # 判断异常来源(STT/LLM/TTS)
  546. error_type = "UNKNOWN_ERROR"
  547. error_stage = "unknown"
  548. error_str = str(e).lower()
  549. if "stt" in error_str or "语音识别" in error_str or "transcription" in error_str:
  550. error_type = "STT_ERROR"
  551. error_stage = "stt"
  552. elif "llm" in error_str or "chat" in error_str or "completion" in error_str or "generate" in error_str:
  553. error_type = "LLM_ERROR"
  554. error_stage = "llm"
  555. elif "tts" in error_str or "speech" in error_str or "audio" in error_str:
  556. error_type = "TTS_ERROR"
  557. error_stage = "tts"
  558. else:
  559. error_type = "NETWORK_ERROR"
  560. error_stage = "unknown"
  561. logger.error(
  562. f"[WebSocket异常] request_id={request_id}, "
  563. f"user_id={self.user_id}, "
  564. f"错误类型={error_type}, "
  565. f"异常类型={type(e).__name__}, "
  566. f"错误消息={str(e)}, "
  567. f"阶段={error_stage}, "
  568. f"用户输入={user_text_log[:100]}... (前100字符), "
  569. f"堆栈跟踪:\n{traceback.format_exc()}"
  570. )
  571. # 记录错误日志
  572. try:
  573. request_logger.log_error(
  574. user_id=self.user_id,
  575. request_id=request_id,
  576. error_code=error_type,
  577. error_message=str(e),
  578. stage=error_stage,
  579. user_text=user_text_log
  580. )
  581. except Exception as log_error:
  582. logger.error(
  583. f"[日志记录异常] request_id={request_id}, "
  584. f"错误类型=记录错误日志失败, "
  585. f"异常类型={type(log_error).__name__}, "
  586. f"错误消息={str(log_error)}"
  587. )
  588. await self.send_error(request_id, error_type, str(e))
  589. finally:
  590. # 清理资源
  591. await self.cleanup_request(request_id)
  592. async def _create_stt_session(self, request_id: str) -> Optional[STTSession]:
  593. """创建STT会话并设置回调"""
  594. async def on_text_update(text: str, is_final: bool):
  595. """STT文本更新回调"""
  596. await self.send_text_result(request_id, text, is_final)
  597. async def on_error(error_msg: str):
  598. """STT错误回调"""
  599. await self.send_error(request_id, "STT_ERROR", error_msg)
  600. # 创建会话
  601. session = await stt_service.create_session(
  602. request_id=request_id,
  603. on_text_update=on_text_update,
  604. on_error=on_error,
  605. event_loop=self.event_loop
  606. )
  607. return session if session.is_connected else None
  608. async def cleanup_request(self, request_id: str):
  609. """清理请求相关资源"""
  610. # 清理STT会话
  611. if request_id in self.stt_sessions:
  612. session = self.stt_sessions[request_id]
  613. await session.close()
  614. del self.stt_sessions[request_id]
  615. # 清理取消标记
  616. if request_id in self.cancelled_requests:
  617. self.cancelled_requests.remove(request_id)
  618. # 清理时间记录
  619. if request_id in self.request_start_times:
  620. del self.request_start_times[request_id]
  621. if request_id in self.audio_end_times:
  622. del self.audio_end_times[request_id]
  623. # 清理AI响应收集器
  624. if request_id in self.ai_responses:
  625. del self.ai_responses[request_id]
  626. # 清理性能指标
  627. if request_id in self.performance_metrics:
  628. del self.performance_metrics[request_id]
  629. async def handle_cancel(self, message: CancelMessage):
  630. """处理取消请求"""
  631. request_id = message.requestId
  632. self.cancelled_requests.add(request_id)
  633. # 清理资源(包括关闭STT会话)
  634. await self.cleanup_request(request_id)
  635. async def handle_ping(self, message: PingMessage):
  636. """处理Ping"""
  637. await self.send_pong(message.requestId)
  638. async def send_text_result(self, request_id: str, text: str, is_final: bool):
  639. """发送文本结果"""
  640. message = TextResultMessage(
  641. type="text_result",
  642. requestId=request_id,
  643. timestamp=self._get_timestamp(),
  644. text=text,
  645. isFinal=is_final,
  646. )
  647. await self.websocket.send_text(message.model_dump_json())
  648. async def send_emotion(self, request_id: str, emotion: str):
  649. """发送情感标签"""
  650. message = EmotionMessage(
  651. type="emotion",
  652. requestId=request_id,
  653. timestamp=self._get_timestamp(),
  654. emotion=emotion,
  655. )
  656. await self.websocket.send_text(message.model_dump_json())
  657. async def send_audio_response(
  658. self, request_id: str, data: str, chunk_index: int, is_last: bool,
  659. format: str = "pcm", sample_rate: int = 24000
  660. ):
  661. """发送音频响应"""
  662. message = AudioResponseMessage(
  663. type="audio_response",
  664. requestId=request_id,
  665. timestamp=self._get_timestamp(),
  666. data=data,
  667. sampleRate=sample_rate,
  668. format=format,
  669. chunkIndex=chunk_index,
  670. isLast=is_last,
  671. )
  672. await self.websocket.send_text(message.model_dump_json())
  673. async def send_error(self, request_id: str, code: str, error_message: str):
  674. """发送错误消息"""
  675. message = ErrorMessage(
  676. type="error",
  677. requestId=request_id,
  678. timestamp=self._get_timestamp(),
  679. code=code,
  680. message=error_message,
  681. )
  682. await self.websocket.send_text(message.model_dump_json())
  683. async def send_pong(self, request_id: str):
  684. """发送Pong"""
  685. message = PongMessage(
  686. type="pong",
  687. requestId=request_id,
  688. timestamp=self._get_timestamp(),
  689. )
  690. await self.websocket.send_text(message.model_dump_json())
  691. def _get_timestamp(self) -> int:
  692. """获取当前时间戳(毫秒)"""
  693. return int(time.time() * 1000)
  694. def _build_llm_input(self, user_input: str, context: dict) -> str:
  695. """
  696. 构建包含上下文的LLM输入
  697. Args:
  698. user_input: 用户当前输入
  699. context: 上下文信息(包含summary和recent_messages)
  700. Returns:
  701. 完整的LLM输入文本
  702. """
  703. parts = []
  704. # 1. 最新总结(如果有,已包含所有历史信息)
  705. summary = context.get('summary')
  706. if summary:
  707. parts.append("【历史对话总结】")
  708. parts.append(summary)
  709. parts.append("")
  710. # 2. 最近对话(如果有)
  711. recent_messages = context.get('recent_messages', [])
  712. if recent_messages:
  713. parts.append("【最近对话】")
  714. for msg in recent_messages:
  715. role_label = "User" if msg['role'] == 'user' else "Assistant"
  716. parts.append(f"{role_label}: {msg['content']}")
  717. parts.append("")
  718. # 3. 当前用户输入
  719. parts.append(f"User: {user_input}")
  720. return "\n".join(parts)
  721. async def _ensure_redis_context(self, request_id: str):
  722. """
  723. 确保Redis中有最新上下文(预加载)
  724. 策略:
  725. 1. 检查Redis是否有缓存
  726. 2. 如果没有,从MySQL加载并更新Redis
  727. 3. 如果有,直接使用(零等待)
  728. """
  729. try:
  730. start = time.time()
  731. # 检查Redis缓存
  732. current_round = await self.redis_cache.get_current_round(self.user_id)
  733. cached_context = await self.redis_cache.get_context(self.user_id)
  734. if cached_context:
  735. # ✅ Redis已有缓存,无需操作
  736. return
  737. # ❌ Redis缓存未命中,从MySQL加载
  738. # 确保服务已初始化
  739. await self._init_services()
  740. if not self.context_service:
  741. return
  742. # 从MySQL获取上下文
  743. context = await self.context_service.get_context_for_round(
  744. user_id=self.user_id,
  745. current_round=current_round
  746. )
  747. # 更新Redis缓存
  748. await self.redis_cache.set_context(self.user_id, context)
  749. # 同时缓存消息列表
  750. for msg in context.get('recent_messages', []):
  751. await self.redis_cache.append_message(
  752. user_id=self.user_id,
  753. role=msg['role'],
  754. content=msg['content'],
  755. round_number=msg.get('round_number', 0)
  756. )
  757. # 同步轮次
  758. if current_round > 0:
  759. await self.redis_cache.set_current_round(self.user_id, current_round)
  760. except Exception as e:
  761. import traceback
  762. traceback.print_exc()
  763. async def _get_context_from_redis(self, request_id: str) -> Optional[dict]:
  764. """
  765. 从Redis获取上下文(零延迟)
  766. Returns:
  767. 上下文字典 或 None(需降级到MySQL)
  768. """
  769. try:
  770. if not self.redis_cache:
  771. return None
  772. start = time.time()
  773. # 获取当前轮次
  774. current_round = await self.redis_cache.get_current_round(self.user_id)
  775. # 从Redis构建上下文(需要传入db_manager以获取所有总结)
  776. db_manager = None
  777. if self.context_service:
  778. db_manager = self.context_service.db
  779. context = await self.redis_cache.build_context_from_cache(
  780. user_id=self.user_id,
  781. current_round=current_round,
  782. db_manager=db_manager
  783. )
  784. return context
  785. except Exception as e:
  786. return None
  787. async def _persist_conversation_async(
  788. self,
  789. request_id: str,
  790. user_text: str,
  791. ai_text: str,
  792. round_number: int,
  793. emotion: str
  794. ):
  795. """
  796. 异步持久化对话到MySQL(不阻塞主流程)
  797. 策略:加入队列,批量写入
  798. """
  799. self.pending_persistence.append({
  800. 'request_id': request_id,
  801. 'user_id': self.user_id,
  802. 'user_text': user_text,
  803. 'ai_text': ai_text,
  804. 'round_number': round_number,
  805. 'emotion': emotion,
  806. 'timestamp': time.time()
  807. })
  808. # 启动持久化任务
  809. if not self.persistence_task or self.persistence_task.done():
  810. self.persistence_task = asyncio.create_task(
  811. self._batch_persistence_loop()
  812. )
  813. async def _batch_persistence_loop(self):
  814. """批量持久化循环(后台任务)"""
  815. while True:
  816. try:
  817. await asyncio.sleep(self.persistence_interval)
  818. if not self.pending_persistence:
  819. continue
  820. # 批量提取
  821. batch = self.pending_persistence.copy()
  822. self.pending_persistence.clear()
  823. start = time.time()
  824. # 确保服务已初始化
  825. await self._init_services()
  826. if not self.message_service:
  827. # 重新加入队列
  828. self.pending_persistence.extend(batch)
  829. continue
  830. # 批量写入MySQL
  831. # ⚠️ 注意:用户消息和AI消息都已在主流程中立即保存
  832. # 这里不再重复保存,避免数据库中出现重复记录
  833. # 如果立即保存失败,应该通过错误日志和监控告警处理,而不是在这里重试
  834. for item in batch:
  835. # 记录日志,但不保存(避免重复)
  836. logger.debug(
  837. f"跳过批量持久化(已立即保存): "
  838. f"user={item['user_id']}, round={item['round_number']}"
  839. )
  840. except Exception as e:
  841. import traceback
  842. traceback.print_exc()
  843. async def _generate_summary_async(
  844. self,
  845. request_id: str,
  846. user_id: str,
  847. start_round: int,
  848. end_round: int
  849. ):
  850. """
  851. 异步生成对话总结(完全不阻塞)
  852. 触发时机:
  853. - 每15轮触发一次(15, 30, 45...)
  854. """
  855. self.pending_summaries.append({
  856. 'request_id': request_id,
  857. 'user_id': user_id,
  858. 'start_round': start_round,
  859. 'end_round': end_round,
  860. 'timestamp': time.time()
  861. })
  862. # 启动总结生成任务
  863. if not self.summary_task or self.summary_task.done():
  864. self.summary_task = asyncio.create_task(
  865. self._summary_generation_loop()
  866. )
  867. async def _summary_generation_loop(self):
  868. """总结生成循环(后台任务)"""
  869. while True:
  870. try:
  871. # 等待任务或间隔
  872. await asyncio.sleep(1.0)
  873. if not self.pending_summaries:
  874. continue
  875. # 逐个处理总结任务
  876. task = self.pending_summaries.pop(0)
  877. start = time.time()
  878. try:
  879. # 确保服务已初始化
  880. await self._init_services()
  881. if not self.context_service or not self.redis_cache:
  882. continue
  883. # 调用总结服务(耗时2-3秒)
  884. # 注意:这里假设有summary_service,如果没有需要创建
  885. from services.summary_service import SummaryService
  886. summary_service = SummaryService(self.context_service.db)
  887. start_round = task['start_round']
  888. end_round = task['end_round']
  889. # generate_summary 会自动从数据库获取消息并保存总结
  890. # 该方法会自动获取之前的总结并进行累积总结
  891. await summary_service.generate_summary(
  892. user_id=task['user_id'],
  893. start_round=start_round,
  894. end_round=end_round
  895. )
  896. # 从数据库获取生成的总结文本
  897. summary_result = await self.context_service.db.execute_one(
  898. """
  899. SELECT summary_text
  900. FROM conversation_summaries
  901. WHERE user_id = %(user_id)s
  902. AND start_round = %(start_round)s
  903. AND end_round = %(end_round)s
  904. ORDER BY created_at DESC
  905. LIMIT 1
  906. """,
  907. {
  908. "user_id": task['user_id'],
  909. "start_round": start_round,
  910. "end_round": end_round
  911. }
  912. )
  913. summary = summary_result['summary_text'] if summary_result else ""
  914. # 🔥 保存到Redis(立即生效)
  915. await self.redis_cache.set_summary(
  916. user_id=task['user_id'],
  917. end_round=end_round,
  918. summary=summary
  919. )
  920. # 🔥 更新Redis上下文缓存(包含新总结)
  921. await self.redis_cache.invalidate_context(task['user_id'])
  922. except Exception as e:
  923. import traceback
  924. traceback.print_exc()
  925. except Exception as e:
  926. import traceback
  927. traceback.print_exc()