websocket_handler.py 63 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402
  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 Any, Dict, Optional
  12. from fastapi import WebSocket
  13. from starlette.websockets import WebSocketDisconnect
  14. logger = logging.getLogger(__name__)
  15. # 预编译正则表达式(性能优化)
  16. EMOTION_TAG_PATTERN = re.compile(r'\[EMOTION:\w+\]')
  17. from models import (
  18. AudioChunkMessage,
  19. AudioEndMessage,
  20. CancelMessage,
  21. TextResultMessage,
  22. EmotionMessage,
  23. AudioResponseMessage,
  24. ErrorMessage,
  25. PingMessage,
  26. PongMessage,
  27. EnergyStoneEventMessage,
  28. EnergyStoneSpawnMessage,
  29. UserLevelMessage,
  30. )
  31. from ai_service import ai_service
  32. from request_logger import request_logger, PerformanceMetrics
  33. from config import settings
  34. from stt_service import stt_service, STTSession
  35. from services.user_service import user_service
  36. from services.energy_stone_service import energy_stone_service
  37. class WebSocketHandler:
  38. """WebSocket连接处理器"""
  39. def __init__(self, websocket: WebSocket, user_id: str = None, redis_cache = None):
  40. self.websocket = websocket
  41. self.user_id = user_id or "guest_user" # 用户ID(默认游客)
  42. self.cancelled_requests: set = set() # 已取消的请求ID
  43. self.stt_sessions: Dict[str, STTSession] = {} # requestId -> STT会话对象
  44. self.event_loop: Optional[asyncio.AbstractEventLoop] = None # 保存事件循环引用
  45. self.request_start_times: Dict[str, float] = {} # 请求开始时间
  46. self.audio_end_times: Dict[str, float] = {} # requestId -> 录音结束时间
  47. self.ai_responses: Dict[str, list] = {} # requestId -> AI响应句子列表(用于日志记录)
  48. self.performance_metrics: Dict[str, PerformanceMetrics] = {} # requestId -> 性能指标
  49. self.energy_stone_service = None
  50. # 初始化服务(延迟加载,避免循环导入)
  51. self.message_service = None
  52. self.context_service = None
  53. self.redis_cache = redis_cache # 🔥 Redis缓存服务
  54. # 🔥 预加载任务
  55. self.preload_tasks: Dict[str, asyncio.Task] = {}
  56. # 🔥 异步持久化队列
  57. self.pending_persistence = []
  58. self.persistence_task: Optional[asyncio.Task] = None
  59. self.persistence_interval = 5.0 # 每5秒批量持久化
  60. # 🔥 异步总结队列
  61. self.pending_summaries = []
  62. self.summary_task: Optional[asyncio.Task] = None
  63. async def _init_services(self):
  64. """初始化服务(延迟加载,避免重复初始化)"""
  65. start_time = time.time()
  66. # 🔥 如果服务已初始化,直接返回(避免重复初始化导致的阻塞)
  67. if self.message_service and self.context_service:
  68. logger.debug(f"[{self.user_id}] 🔍 服务已初始化,跳过重复初始化")
  69. return
  70. try:
  71. logger.info(f"[{self.user_id}] 🔧 开始初始化服务...")
  72. from database import db_manager
  73. from services import MessageService, ContextService
  74. # 使用延迟初始化的全局实例
  75. from services.message_service import message_service
  76. from services.context_service import context_service
  77. # 🔥 只在服务未初始化时才设置(避免重复调用set_db_manager)
  78. if not message_service.db or not context_service.db:
  79. logger.debug(f"[{self.user_id}] 🔧 设置数据库管理器...")
  80. await message_service.set_db_manager(db_manager)
  81. await context_service.set_db_manager(db_manager)
  82. if not energy_stone_service.db:
  83. logger.debug(f"[{self.user_id}] 🔧 设置能量石服务数据库管理器...")
  84. await energy_stone_service.set_db_manager(db_manager)
  85. self.message_service = message_service
  86. self.context_service = context_service
  87. self.energy_stone_service = energy_stone_service
  88. elapsed = (time.time() - start_time) * 1000
  89. logger.info(f"[{self.user_id}] ✅ 服务初始化完成,耗时: {elapsed:.1f}ms")
  90. except Exception as e:
  91. elapsed = (time.time() - start_time) * 1000
  92. logger.error(f"[{self.user_id}] ❌ 服务初始化失败,耗时: {elapsed:.1f}ms, 错误: {e}")
  93. logger.error(traceback.format_exc())
  94. async def handle_connection(self):
  95. """处理WebSocket连接"""
  96. await self.websocket.accept()
  97. # 初始化服务
  98. await self._init_services()
  99. # 保存事件循环引用,供回调线程使用
  100. self.event_loop = asyncio.get_running_loop()
  101. try:
  102. while True:
  103. # 接收消息
  104. data = await self.websocket.receive_text()
  105. message_data = json.loads(data)
  106. # 处理消息
  107. await self.handle_message(message_data)
  108. except WebSocketDisconnect as e:
  109. close_code = getattr(e, "code", None)
  110. reason = getattr(e, "reason", None)
  111. logger.warning(f"[{self.user_id}] ⚠️ WebSocket断开: code={close_code}, reason={reason}")
  112. except Exception as e:
  113. logger.error(f"[{self.user_id}] ❌ WebSocket异常: {e.__class__.__name__}: {e}")
  114. logger.error(traceback.format_exc())
  115. finally:
  116. close_code = getattr(self.websocket, "close_code", None)
  117. logger.info(f"[{self.user_id}] ℹ️ WebSocket连接结束,close_code={close_code}")
  118. async def handle_message(self, message_data: dict):
  119. """处理接收到的消息"""
  120. message_type = message_data.get("type")
  121. request_id = message_data.get("requestId")
  122. # 检查是否为已取消的请求
  123. if request_id in self.cancelled_requests:
  124. return
  125. if message_type == "audio_chunk":
  126. await self.handle_audio_chunk(AudioChunkMessage(**message_data))
  127. elif message_type == "audio_end":
  128. await self.handle_audio_end(AudioEndMessage(**message_data))
  129. elif message_type == "cancel":
  130. await self.handle_cancel(CancelMessage(**message_data))
  131. elif message_type == "ping":
  132. await self.handle_ping(PingMessage(**message_data))
  133. elif message_type == "energy_stone_event":
  134. await self.handle_energy_stone_event(EnergyStoneEventMessage(**message_data))
  135. async def handle_audio_chunk(self, message: AudioChunkMessage):
  136. """处理音频数据块 - 使用新的STT服务"""
  137. request_id = message.requestId
  138. # 检查是否取消
  139. if request_id in self.cancelled_requests:
  140. return
  141. # Base64解码音频数据
  142. audio_data = base64.b64decode(message.data)
  143. chunk_size = len(audio_data)
  144. # 第一个chunk:初始化请求并启动STT会话
  145. if request_id not in self.request_start_times:
  146. current_time = time.time()
  147. self.request_start_times[request_id] = current_time
  148. # 初始化性能指标
  149. self.performance_metrics[request_id] = PerformanceMetrics()
  150. self.performance_metrics[request_id].user_speech_start = current_time
  151. self.performance_metrics[request_id].stt_first_audio_received = current_time
  152. # 创建STT会话
  153. session = await self._create_stt_session(request_id)
  154. if not session or not session.is_connected:
  155. await self.send_error(request_id, "STT_ERROR", "语音识别服务启动失败")
  156. return
  157. self.stt_sessions[request_id] = session
  158. # 🔥 启动Redis预加载任务(与录音并行)
  159. if self.redis_cache:
  160. self.preload_tasks[request_id] = asyncio.create_task(
  161. self._ensure_redis_context(request_id)
  162. )
  163. # 发送音频帧到STT服务
  164. session = self.stt_sessions.get(request_id)
  165. if session:
  166. success = await session.send_audio(audio_data)
  167. if not success:
  168. await self.send_error(request_id, "STT_ERROR", "发送音频数据失败")
  169. # 📝 更新STT音频统计(用于日志)
  170. if request_id in self.performance_metrics:
  171. self.performance_metrics[request_id].stt_audio_chunks += 1
  172. self.performance_metrics[request_id].stt_total_bytes += chunk_size
  173. async def handle_audio_end(self, message: AudioEndMessage):
  174. """处理音频结束 - 使用新的STT服务"""
  175. request_id = message.requestId
  176. # 记录录音结束时间
  177. audio_end_time = time.time()
  178. self.audio_end_times[request_id] = audio_end_time
  179. # 更新性能指标
  180. if request_id in self.performance_metrics:
  181. self.performance_metrics[request_id].user_speech_end = audio_end_time
  182. self.performance_metrics[request_id].stt_last_audio_received = audio_end_time
  183. speech_duration_ms = message.speechDuration or 0
  184. total_duration_ms = None
  185. level_update = None
  186. # 🔥 更新用户累积说话时长(如果有提供,使用超时避免阻塞)
  187. if speech_duration_ms > 0:
  188. duration_start = time.time()
  189. try:
  190. logger.info(f"[{request_id}] ⏱️ 开始更新用户说话时长: {speech_duration_ms}ms(超时: 1.0s)...")
  191. # 使用超时,避免长时间阻塞
  192. level_update = await asyncio.wait_for(
  193. user_service.update_speech_duration(self.user_id, speech_duration_ms),
  194. timeout=1.0 # 1秒超时
  195. )
  196. duration_elapsed = (time.time() - duration_start) * 1000
  197. logger.info(f"[{request_id}] ✅ 更新用户说话时长成功,耗时: {duration_elapsed:.1f}ms")
  198. except asyncio.TimeoutError:
  199. duration_elapsed = (time.time() - duration_start) * 1000
  200. logger.warning(f"[{request_id}] ⚠️ 更新用户说话时长超时(耗时: {duration_elapsed:.1f}ms): user_id={self.user_id}, duration_ms={speech_duration_ms}")
  201. except Exception as e:
  202. duration_elapsed = (time.time() - duration_start) * 1000
  203. logger.warning(f"[{request_id}] ⚠️ 更新用户说话时长失败(耗时: {duration_elapsed:.1f}ms): user_id={self.user_id}, duration_ms={speech_duration_ms}, error={e}")
  204. # 初始化AI响应收集器
  205. self.ai_responses[request_id] = []
  206. if level_update and isinstance(level_update, dict):
  207. total_duration_ms = level_update.get("total_duration_ms")
  208. user_level = level_update.get("user_level")
  209. if total_duration_ms is not None:
  210. await self._maybe_spawn_conversation_stones(total_duration_ms, speech_duration_ms, request_id)
  211. # 🔥 即使等级没有变化,也发送 user_level 消息,确保前端能同步 total_duration_ms
  212. # 获取当前用户等级(如果更新时等级没变,user_level 可能为 None,需要查询)
  213. if user_level is not None:
  214. await self.send_user_level_update(
  215. request_id=request_id,
  216. level=user_level,
  217. total_duration_ms=total_duration_ms or 0,
  218. )
  219. elif total_duration_ms is not None:
  220. # 等级没变,但总时长更新了,需要查询当前等级并发送消息
  221. try:
  222. user_info = await user_service.get_user(self.user_id)
  223. if user_info and user_info.get("user_level") is not None:
  224. await self.send_user_level_update(
  225. request_id=request_id,
  226. level=user_info.get("user_level"),
  227. total_duration_ms=total_duration_ms,
  228. )
  229. except Exception as e:
  230. logger.warning(f"[{request_id}] ⚠️ 查询用户等级失败,无法发送 user_level 消息: {e}")
  231. # 检查是否取消
  232. if request_id in self.cancelled_requests:
  233. self.cancelled_requests.remove(request_id)
  234. await self.cleanup_request(request_id)
  235. return
  236. try:
  237. # 1. STT: 等待完整识别结果
  238. stt_start = time.time()
  239. session = self.stt_sessions.get(request_id)
  240. if not session:
  241. await self.send_error(request_id, "STT_ERROR", "语音识别会话不存在")
  242. request_logger.log_error(
  243. user_id=self.user_id,
  244. request_id=request_id,
  245. error_code="STT_ERROR",
  246. error_message="语音识别会话不存在",
  247. stage="stt"
  248. )
  249. return
  250. # 🔥 手动提交音频,触发识别(禁用VAD时必需)
  251. try:
  252. commit_success = await session.commit_audio()
  253. if not commit_success:
  254. logger.error(
  255. f"[STT异常] request_id={request_id}, "
  256. f"错误类型=提交识别请求失败, "
  257. f"用户ID={self.user_id}, "
  258. f"音频块数={message.totalChunks}, "
  259. f"会话状态=is_connected={session.is_connected}"
  260. )
  261. await self.send_error(request_id, "STT_ERROR", "提交识别请求失败")
  262. request_logger.log_error(
  263. user_id=self.user_id,
  264. request_id=request_id,
  265. error_code="STT_ERROR",
  266. error_message="提交识别请求失败",
  267. stage="stt"
  268. )
  269. await self.cleanup_request(request_id)
  270. return
  271. except Exception as commit_error:
  272. logger.error(
  273. f"[STT异常] request_id={request_id}, "
  274. f"错误类型=提交音频异常, "
  275. f"异常类型={type(commit_error).__name__}, "
  276. f"错误消息={str(commit_error)}, "
  277. f"用户ID={self.user_id}, "
  278. f"音频块数={message.totalChunks}, "
  279. f"堆栈跟踪:\n{traceback.format_exc()}"
  280. )
  281. await self.send_error(request_id, "STT_ERROR", f"提交音频异常: {str(commit_error)}")
  282. request_logger.log_error(
  283. user_id=self.user_id,
  284. request_id=request_id,
  285. error_code="STT_ERROR",
  286. error_message=f"提交音频异常: {str(commit_error)}",
  287. stage="stt"
  288. )
  289. await self.cleanup_request(request_id)
  290. return
  291. # 等待最终结果(根据音频长度动态调整超时)
  292. total_chunks = message.totalChunks
  293. if total_chunks > 80:
  294. timeout = 5.0
  295. elif total_chunks > 50:
  296. timeout = 4.0
  297. else:
  298. timeout = 3.0
  299. logger.info(f"[{request_id}] ⏳ 等待STT最终识别结果(超时: {timeout}s)...")
  300. text = await session.wait_for_final_result(timeout=timeout)
  301. # 获取识别结果
  302. if not text:
  303. # 检查语音检测状态,提供更详细的错误信息
  304. callback = session.callback
  305. speech_detected = callback.speech_detected if callback else False
  306. speech_start_count = callback.speech_start_count if callback else 0
  307. if not speech_detected and speech_start_count == 0:
  308. error_msg = "未检测到有效语音,请检查麦克风权限或重新录音"
  309. else:
  310. error_msg = f"语音识别超时,已检测到语音但未返回识别结果(speech_detected={speech_detected}, speech_start_count={speech_start_count})"
  311. logger.error(
  312. f"[{request_id}] ❌ STT识别失败: {error_msg}, "
  313. f"音频块数={message.totalChunks}, "
  314. f"录音时长={message.speechDuration}ms"
  315. )
  316. await self.send_error(request_id, "STT_ERROR", error_msg)
  317. request_logger.log_error(
  318. user_id=self.user_id,
  319. request_id=request_id,
  320. error_code="STT_ERROR",
  321. error_message=error_msg,
  322. stage="stt"
  323. )
  324. await self.cleanup_request(request_id)
  325. return
  326. stt_duration = time.time() - stt_start
  327. stt_complete_time = time.time()
  328. # 记录STT完成时间(在获取到text之后)
  329. stt_complete_elapsed = (stt_complete_time - self.request_start_times.get(request_id, stt_complete_time)) * 1000
  330. logger.info(f"[{request_id}] ✅ STT识别完成,识别文本: '{text[:50]}{'...' if len(text) > 50 else ''}',耗时: {stt_complete_elapsed:.1f}ms")
  331. # 更新性能指标
  332. if request_id in self.performance_metrics:
  333. self.performance_metrics[request_id].stt_completed = stt_complete_time
  334. self.performance_metrics[request_id].user_text = text
  335. # 获取STT首个文本生成时间
  336. first_text_time = session.get_first_text_time()
  337. if first_text_time:
  338. self.performance_metrics[request_id].stt_first_text_generated = first_text_time
  339. # 发送最终识别结果
  340. await self.send_text_result(request_id, text, True)
  341. if request_id in self.cancelled_requests:
  342. return
  343. # 🔥 LLM提示词准备开始
  344. llm_prompt_start = time.time()
  345. if request_id in self.performance_metrics:
  346. self.performance_metrics[request_id].llm_prompt_prepare_start = llm_prompt_start
  347. # 🔥 从Redis获取上下文(零延迟)
  348. context = None
  349. current_round = 0
  350. next_round = 0
  351. if self.redis_cache:
  352. # 尝试从Redis获取上下文(使用超时,避免长时间阻塞)
  353. redis_start = time.time()
  354. try:
  355. logger.info(f"[{request_id}] 🔍 开始从Redis获取上下文(超时: 2.0s)...")
  356. context = await asyncio.wait_for(
  357. self._get_context_from_redis(request_id),
  358. timeout=2.0 # 2秒超时
  359. )
  360. redis_elapsed = (time.time() - redis_start) * 1000
  361. logger.info(f"[{request_id}] ✅ Redis上下文获取完成,耗时: {redis_elapsed:.1f}ms")
  362. except asyncio.TimeoutError:
  363. redis_elapsed = (time.time() - redis_start) * 1000
  364. logger.warning(f"[{request_id}] ⚠️ 从Redis获取上下文超时(耗时: {redis_elapsed:.1f}ms),降级到MySQL")
  365. context = None
  366. if context:
  367. current_round = context.get('current_round', 0)
  368. next_round = current_round + 1
  369. logger.debug(f"[{request_id}] 📊 当前轮次: {current_round},下一轮次: {next_round}")
  370. # 🔥 立即写入Redis(用户消息,使用超时)
  371. write_start = time.time()
  372. try:
  373. logger.info(f"[{request_id}] 💾 开始写入Redis(用户消息,超时: 1.0s)...")
  374. await asyncio.wait_for(
  375. self.redis_cache.append_message(
  376. user_id=self.user_id,
  377. role='user',
  378. content=text,
  379. round_number=next_round
  380. ),
  381. timeout=1.0 # 1秒超时
  382. )
  383. append_elapsed = (time.time() - write_start) * 1000
  384. logger.debug(f"[{request_id}] ✅ Redis写入用户消息完成,耗时: {append_elapsed:.1f}ms")
  385. increment_start = time.time()
  386. await asyncio.wait_for(
  387. self.redis_cache.increment_round(self.user_id),
  388. timeout=1.0 # 1秒超时
  389. )
  390. increment_elapsed = (time.time() - increment_start) * 1000
  391. write_total = (time.time() - write_start) * 1000
  392. logger.info(f"[{request_id}] ✅ Redis写入完成(增量轮次耗时: {increment_elapsed:.1f}ms,总耗时: {write_total:.1f}ms)")
  393. except asyncio.TimeoutError:
  394. write_elapsed = (time.time() - write_start) * 1000
  395. logger.warning(f"[{request_id}] ⚠️ Redis写入超时(耗时: {write_elapsed:.1f}ms),但继续处理")
  396. except Exception as e:
  397. write_elapsed = (time.time() - write_start) * 1000
  398. logger.warning(f"[{request_id}] ⚠️ Redis写入失败(耗时: {write_elapsed:.1f}ms): {e}")
  399. # 🔥 异步保存user消息到MySQL(不阻塞主流程)
  400. await self._init_services()
  401. if self.message_service:
  402. asyncio.create_task(
  403. self.message_service.save_message(
  404. user_id=self.user_id,
  405. role='user',
  406. content=text
  407. )
  408. )
  409. else:
  410. # Redis未命中,降级到MySQL
  411. mysql_start = time.time()
  412. logger.info(f"[{request_id}] ⚠️ Redis未命中,降级到MySQL获取上下文...")
  413. await self._init_services()
  414. if self.message_service and self.context_service:
  415. try:
  416. # 保存用户消息
  417. save_start = time.time()
  418. logger.info(f"[{request_id}] 💾 开始保存用户消息到MySQL(超时: 5.0s)...")
  419. user_message = await asyncio.wait_for(
  420. self.message_service.save_message(
  421. user_id=self.user_id,
  422. role='user',
  423. content=text
  424. ),
  425. timeout=5.0
  426. )
  427. save_elapsed = (time.time() - save_start) * 1000
  428. next_round = user_message['round_number']
  429. current_round = next_round - 1
  430. logger.info(f"[{request_id}] ✅ 用户消息保存成功,轮次: {next_round},耗时: {save_elapsed:.1f}ms")
  431. # 📝 记录用户消息ID
  432. if request_id in self.performance_metrics:
  433. self.performance_metrics[request_id].user_message_id = user_message.get('message_id')
  434. # 获取上下文
  435. context_start = time.time()
  436. logger.info(f"[{request_id}] 🔍 开始从MySQL获取上下文(超时: 3.0s)...")
  437. context = await asyncio.wait_for(
  438. self.context_service.get_context_for_round(
  439. user_id=self.user_id,
  440. current_round=current_round
  441. ),
  442. timeout=3.0
  443. )
  444. context_elapsed = (time.time() - context_start) * 1000
  445. mysql_total_elapsed = (time.time() - mysql_start) * 1000
  446. if context:
  447. messages_count = len(context.get('messages', []))
  448. logger.info(f"[{request_id}] ✅ 从MySQL获取上下文成功,消息数: {messages_count},获取耗时: {context_elapsed:.1f}ms,总耗时: {mysql_total_elapsed:.1f}ms")
  449. else:
  450. logger.warning(f"[{request_id}] ⚠️ 从MySQL获取上下文为空,获取耗时: {context_elapsed:.1f}ms,总耗时: {mysql_total_elapsed:.1f}ms")
  451. except asyncio.TimeoutError as e:
  452. elapsed = (time.time() - mysql_start) * 1000
  453. logger.error(f"[{request_id}] ❌ MySQL操作超时,耗时: {elapsed:.1f}ms, 错误: {e}")
  454. except Exception as e:
  455. elapsed = (time.time() - mysql_start) * 1000
  456. logger.error(f"[{request_id}] ❌ MySQL操作失败,耗时: {elapsed:.1f}ms, 错误: {e}")
  457. logger.error(traceback.format_exc())
  458. else:
  459. # 没有Redis,直接使用MySQL
  460. await self._init_services()
  461. if self.message_service and self.context_service:
  462. try:
  463. user_message = await asyncio.wait_for(
  464. self.message_service.save_message(
  465. user_id=self.user_id,
  466. role='user',
  467. content=text
  468. ),
  469. timeout=5.0
  470. )
  471. next_round = user_message['round_number']
  472. current_round = next_round - 1
  473. # 📝 记录用户消息ID
  474. if request_id in self.performance_metrics:
  475. self.performance_metrics[request_id].user_message_id = user_message.get('message_id')
  476. context = await asyncio.wait_for(
  477. self.context_service.get_context_for_round(
  478. user_id=self.user_id,
  479. current_round=current_round
  480. ),
  481. timeout=3.0
  482. )
  483. except asyncio.TimeoutError:
  484. pass
  485. except Exception as e:
  486. pass
  487. # 🔥 LLM提示词准备结束
  488. llm_prompt_end = time.time()
  489. if request_id in self.performance_metrics:
  490. self.performance_metrics[request_id].llm_prompt_prepare_end = llm_prompt_end
  491. llm_start = time.time()
  492. # 🔥 LLM调用开始
  493. if request_id in self.performance_metrics:
  494. self.performance_metrics[request_id].llm_call_start = llm_start
  495. chunk_index = 0
  496. first_audio_time = None
  497. final_emotion = "neutral"
  498. sentence_count = 0
  499. llm_first_sentence_time = None
  500. llm_first_text_time = None # 🔥 LLM首个文本输出时间
  501. emotion_sent = False # 标记是否已发送情感
  502. # 构建Prompt(如果有上下文,使用上下文;否则使用原始文本)
  503. prompt_build_start = time.time()
  504. llm_input = self._build_llm_input(text, context) if context else text
  505. prompt_build_elapsed = (time.time() - prompt_build_start) * 1000
  506. # 获取system_prompt用于日志记录
  507. system_prompt = ai_service._build_system_prompt()
  508. full_prompt = f"System: {system_prompt}\n\nUser: {llm_input}"
  509. # 📝 更新LLM输入统计(用于日志)
  510. if request_id in self.performance_metrics:
  511. self.performance_metrics[request_id].llm_prompt_length = len(llm_input)
  512. self.performance_metrics[request_id].llm_context_round = next_round
  513. self.performance_metrics[request_id].llm_full_prompt = full_prompt
  514. logger.info(f"[{request_id}] 🤖 开始调用LLM,Prompt长度: {len(llm_input)},构建耗时: {prompt_build_elapsed:.1f}ms,轮次: {next_round}")
  515. # 🔥 流式处理:LLM生成一句 → 立即TTS
  516. try:
  517. llm_first_chunk_time = None
  518. async for sentence, emotion in ai_service.generate_response_stream(llm_input):
  519. if llm_first_chunk_time is None:
  520. llm_first_chunk_time = time.time()
  521. llm_first_chunk_elapsed = (llm_first_chunk_time - llm_start) * 1000
  522. logger.info(f"[{request_id}] ✅ LLM首个响应到达,耗时: {llm_first_chunk_elapsed:.1f}ms")
  523. if request_id in self.cancelled_requests:
  524. return
  525. # 🎯 优先处理情感:第一时间发送给前端
  526. if not emotion_sent and emotion:
  527. await self.send_emotion(request_id, emotion)
  528. emotion_sent = True
  529. final_emotion = emotion
  530. # ⚡ 如果是空句子(仅传递情感),跳过 TTS 处理
  531. if not sentence:
  532. continue
  533. sentence_count += 1
  534. # 🧹 清理文本:移除情感标记和特殊字符(保留文字内容)
  535. clean_sentence = EMOTION_TAG_PATTERN.sub('', sentence)
  536. clean_sentence = clean_sentence.translate(str.maketrans('', '', '()[]')).strip()
  537. # 如果清理后为空,跳过
  538. if not clean_sentence:
  539. continue
  540. # 收集AI响应(用于日志记录)
  541. self.ai_responses[request_id].append(clean_sentence)
  542. # 记录第一个句子生成时间
  543. if llm_first_sentence_time is None:
  544. llm_first_sentence_time = time.time()
  545. # 🔥 记录LLM首个文本输出时间
  546. if llm_first_text_time is None:
  547. llm_first_text_time = time.time()
  548. if request_id in self.performance_metrics:
  549. self.performance_metrics[request_id].llm_first_text_output = llm_first_text_time
  550. tts_sentence_start = time.time()
  551. # 🔥 记录TTS调用开始时间(第一个句子)
  552. if request_id in self.performance_metrics and self.performance_metrics[request_id].tts_call_start is None:
  553. self.performance_metrics[request_id].tts_call_start = tts_sentence_start
  554. # 立即TTS合成这个句子(使用清理后的文本 + 情感参数)
  555. try:
  556. async for audio_chunk in ai_service.text_to_speech_stream(clean_sentence, emotion):
  557. if request_id in self.cancelled_requests:
  558. return
  559. # Base64编码
  560. audio_base64 = base64.b64encode(audio_chunk).decode()
  561. # 发送音频数据
  562. await self.send_audio_response(
  563. request_id,
  564. audio_base64,
  565. chunk_index,
  566. False,
  567. )
  568. # 记录第一个音频块发送时间
  569. if chunk_index == 0:
  570. first_audio_time = time.time()
  571. time_to_first_audio = first_audio_time - audio_end_time
  572. # 🔥 记录TTS首个音频输出时间
  573. if request_id in self.performance_metrics:
  574. self.performance_metrics[request_id].tts_first_audio_output = first_audio_time
  575. # 📝 更新TTS音频统计(用于日志)
  576. if request_id in self.performance_metrics:
  577. self.performance_metrics[request_id].tts_audio_chunks += 1
  578. self.performance_metrics[request_id].tts_total_bytes += len(audio_chunk)
  579. chunk_index += 1
  580. except Exception as tts_error:
  581. # TTS异常已在ai_service中详细记录,这里只记录上下文信息
  582. logger.error(
  583. f"[WebSocket异常] request_id={request_id}, "
  584. f"错误类型=TTS合成异常, "
  585. f"异常类型={type(tts_error).__name__}, "
  586. f"错误消息={str(tts_error)}, "
  587. f"句子内容={clean_sentence[:100]}... (前100字符), "
  588. f"情感={emotion}, "
  589. f"句子序号={sentence_count}, "
  590. f"已发送chunk数={chunk_index}, "
  591. f"堆栈跟踪:\n{traceback.format_exc()}"
  592. )
  593. # 重新抛出异常,让外层处理
  594. raise
  595. # 更新当前情感(但不再发送,因为已在开头发送)
  596. if emotion:
  597. final_emotion = emotion
  598. except Exception as llm_error:
  599. # LLM异常已在ai_service中详细记录,这里只记录上下文信息
  600. logger.error(
  601. f"[WebSocket异常] request_id={request_id}, "
  602. f"错误类型=LLM生成异常, "
  603. f"异常类型={type(llm_error).__name__}, "
  604. f"错误消息={str(llm_error)}, "
  605. f"用户输入={text[:100]}... (前100字符), "
  606. f"LLM输入={llm_input[:100]}... (前100字符), "
  607. f"上下文轮次={next_round}, "
  608. f"已生成句子数={sentence_count}, "
  609. f"堆栈跟踪:\n{traceback.format_exc()}"
  610. )
  611. # 重新抛出异常,让外层处理
  612. raise
  613. # 发送TTS结束标记
  614. await self.send_audio_response(request_id, "", chunk_index, True)
  615. # 记录最后一个音频块发送时间
  616. last_audio_time = time.time()
  617. time_to_last_audio = last_audio_time - audio_end_time
  618. # 🔥 更新性能指标:LLM和TTS完成时间
  619. if request_id in self.performance_metrics:
  620. self.performance_metrics[request_id].llm_all_text_completed = last_audio_time
  621. self.performance_metrics[request_id].llm_call_end = last_audio_time
  622. self.performance_metrics[request_id].tts_all_audio_completed = last_audio_time
  623. self.performance_metrics[request_id].tts_call_end = last_audio_time
  624. # 📝 更新LLM输出统计
  625. self.performance_metrics[request_id].llm_emotion = final_emotion
  626. self.performance_metrics[request_id].llm_sentences = sentence_count
  627. # 📝 更新TTS输入统计
  628. self.performance_metrics[request_id].tts_voice = settings.tts_voice
  629. # 🔥 保存AI响应
  630. ai_response_text = ' '.join(self.ai_responses.get(request_id, []))
  631. llm_total_elapsed = (time.time() - llm_start) * 1000
  632. logger.info(f"[{request_id}] ✅ LLM调用完成,总耗时: {llm_total_elapsed:.1f}ms,句子数: {sentence_count},情感: {final_emotion}")
  633. if ai_response_text:
  634. # 🔥 立即写入Redis(不阻塞)
  635. if self.redis_cache:
  636. redis_save_start = time.time()
  637. try:
  638. logger.debug(f"[{request_id}] 💾 开始保存AI响应到Redis...")
  639. await self.redis_cache.append_message(
  640. user_id=self.user_id,
  641. role='assistant',
  642. content=ai_response_text,
  643. round_number=next_round
  644. )
  645. redis_save_elapsed = (time.time() - redis_save_start) * 1000
  646. logger.debug(f"[{request_id}] ✅ AI响应保存到Redis完成,耗时: {redis_save_elapsed:.1f}ms")
  647. except Exception as e:
  648. redis_save_elapsed = (time.time() - redis_save_start) * 1000
  649. logger.warning(f"[{request_id}] ⚠️ AI响应保存到Redis失败,耗时: {redis_save_elapsed:.1f}ms, 错误: {e}")
  650. # 🔥 立即保存AI消息到MySQL(获取message_id)
  651. if self.message_service:
  652. mysql_save_start = time.time()
  653. try:
  654. logger.info(f"[{request_id}] 💾 开始保存AI消息到MySQL(超时: 5.0s)...")
  655. assistant_message = await asyncio.wait_for(
  656. self.message_service.save_message(
  657. user_id=self.user_id,
  658. role='assistant',
  659. content=ai_response_text,
  660. emotion=final_emotion
  661. ),
  662. timeout=5.0
  663. )
  664. mysql_save_elapsed = (time.time() - mysql_save_start) * 1000
  665. logger.info(f"[{request_id}] ✅ AI消息保存到MySQL成功,耗时: {mysql_save_elapsed:.1f}ms")
  666. # 📝 记录AI消息ID
  667. if request_id in self.performance_metrics:
  668. self.performance_metrics[request_id].assistant_message_id = assistant_message.get('message_id')
  669. except asyncio.TimeoutError:
  670. mysql_save_elapsed = (time.time() - mysql_save_start) * 1000
  671. logger.warning(f"[{request_id}] ⚠️ AI消息保存到MySQL超时,耗时: {mysql_save_elapsed:.1f}ms")
  672. except Exception as e:
  673. mysql_save_elapsed = (time.time() - mysql_save_start) * 1000
  674. logger.warning(f"[{request_id}] ⚠️ AI消息保存到MySQL失败,耗时: {mysql_save_elapsed:.1f}ms, 错误: {e}")
  675. # 保存失败不影响主流程,继续异步持久化
  676. # 🔥 加入异步持久化队列(批量写入MySQL,用于重试或降级场景)
  677. asyncio.create_task(
  678. self._persist_conversation_async(
  679. request_id=request_id,
  680. user_text=text,
  681. ai_text=ai_response_text,
  682. round_number=next_round,
  683. emotion=final_emotion
  684. )
  685. )
  686. if next_round == 1:
  687. await self._maybe_spawn_mission_stone(request_id)
  688. # 注意:总结触发逻辑已移至 message_service.py
  689. # 在保存 assistant 消息时,如果 current_round % 15 == 0,会自动触发总结
  690. # 计算总耗时
  691. total_end = time.time()
  692. total_duration = total_end - self.request_start_times.get(request_id, stt_start)
  693. llm_tts_total = total_end - llm_start
  694. # 📝 记录详细性能日志到文件
  695. try:
  696. ai_response_log = ' '.join(self.ai_responses.get(request_id, []))
  697. # 更新最终的AI响应文本
  698. if request_id in self.performance_metrics:
  699. self.performance_metrics[request_id].ai_response_text = ai_response_log
  700. # 记录详细性能数据
  701. request_logger.log_request(
  702. user_id=self.user_id,
  703. request_id=request_id,
  704. metrics=self.performance_metrics[request_id]
  705. )
  706. except Exception as log_error:
  707. pass
  708. except Exception as e:
  709. # 详细记录异常信息
  710. session = self.stt_sessions.get(request_id)
  711. user_text_log = session.get_current_text() if session else ""
  712. # 判断异常来源(STT/LLM/TTS)
  713. error_type = "UNKNOWN_ERROR"
  714. error_stage = "unknown"
  715. error_str = str(e).lower()
  716. if "stt" in error_str or "语音识别" in error_str or "transcription" in error_str:
  717. error_type = "STT_ERROR"
  718. error_stage = "stt"
  719. elif "llm" in error_str or "chat" in error_str or "completion" in error_str or "generate" in error_str:
  720. error_type = "LLM_ERROR"
  721. error_stage = "llm"
  722. elif "tts" in error_str or "speech" in error_str or "audio" in error_str:
  723. error_type = "TTS_ERROR"
  724. error_stage = "tts"
  725. else:
  726. error_type = "NETWORK_ERROR"
  727. error_stage = "unknown"
  728. logger.error(
  729. f"[WebSocket异常] request_id={request_id}, "
  730. f"user_id={self.user_id}, "
  731. f"错误类型={error_type}, "
  732. f"异常类型={type(e).__name__}, "
  733. f"错误消息={str(e)}, "
  734. f"阶段={error_stage}, "
  735. f"用户输入={user_text_log[:100]}... (前100字符), "
  736. f"堆栈跟踪:\n{traceback.format_exc()}"
  737. )
  738. # 记录错误日志
  739. try:
  740. request_logger.log_error(
  741. user_id=self.user_id,
  742. request_id=request_id,
  743. error_code=error_type,
  744. error_message=str(e),
  745. stage=error_stage,
  746. user_text=user_text_log
  747. )
  748. except Exception as log_error:
  749. logger.error(
  750. f"[日志记录异常] request_id={request_id}, "
  751. f"错误类型=记录错误日志失败, "
  752. f"异常类型={type(log_error).__name__}, "
  753. f"错误消息={str(log_error)}"
  754. )
  755. await self.send_error(request_id, error_type, str(e))
  756. finally:
  757. # 清理资源
  758. await self.cleanup_request(request_id)
  759. async def _create_stt_session(self, request_id: str) -> Optional[STTSession]:
  760. """创建STT会话并设置回调"""
  761. async def on_text_update(text: str, is_final: bool):
  762. """STT文本更新回调"""
  763. await self.send_text_result(request_id, text, is_final)
  764. async def on_error(error_msg: str):
  765. """STT错误回调"""
  766. await self.send_error(request_id, "STT_ERROR", error_msg)
  767. # 创建会话
  768. session = await stt_service.create_session(
  769. request_id=request_id,
  770. on_text_update=on_text_update,
  771. on_error=on_error,
  772. event_loop=self.event_loop
  773. )
  774. return session if session.is_connected else None
  775. async def cleanup_request(self, request_id: str):
  776. """清理请求相关资源"""
  777. # 清理STT会话
  778. if request_id in self.stt_sessions:
  779. session = self.stt_sessions[request_id]
  780. await session.close()
  781. del self.stt_sessions[request_id]
  782. # 清理取消标记
  783. if request_id in self.cancelled_requests:
  784. self.cancelled_requests.remove(request_id)
  785. # 清理时间记录
  786. if request_id in self.request_start_times:
  787. del self.request_start_times[request_id]
  788. if request_id in self.audio_end_times:
  789. del self.audio_end_times[request_id]
  790. # 清理AI响应收集器
  791. if request_id in self.ai_responses:
  792. del self.ai_responses[request_id]
  793. # 清理性能指标
  794. if request_id in self.performance_metrics:
  795. del self.performance_metrics[request_id]
  796. async def handle_cancel(self, message: CancelMessage):
  797. """处理取消请求"""
  798. request_id = message.requestId
  799. self.cancelled_requests.add(request_id)
  800. # 清理资源(包括关闭STT会话)
  801. await self.cleanup_request(request_id)
  802. async def handle_ping(self, message: PingMessage):
  803. """处理Ping"""
  804. await self.send_pong(message.requestId)
  805. async def handle_energy_stone_event(self, message: EnergyStoneEventMessage):
  806. """处理前端能量石事件"""
  807. if not self.energy_stone_service:
  808. return
  809. metadata = {
  810. "client_ts": message.clientTs,
  811. "position": message.position,
  812. "request_id": message.requestId,
  813. }
  814. try:
  815. await self.energy_stone_service.mark_event(
  816. stone_id=message.stoneId,
  817. event=message.event,
  818. metadata={k: v for k, v in metadata.items() if v is not None},
  819. )
  820. except Exception as exc:
  821. logger.warning(
  822. "[EnergyStone] 记录事件失败: stone_id=%s event=%s error=%s",
  823. message.stoneId,
  824. message.event,
  825. exc,
  826. )
  827. async def _maybe_spawn_mission_stone(self, request_id: str):
  828. """首次对话奖励"""
  829. if not self.energy_stone_service or not self.user_id:
  830. return
  831. try:
  832. payload = await self.energy_stone_service.create_mission_stone_if_needed(
  833. user_id=self.user_id,
  834. request_id=request_id,
  835. )
  836. if payload:
  837. await self.send_energy_stone_spawn(payload)
  838. except Exception as exc:
  839. logger.warning(
  840. "[EnergyStone] 任务能量石生成失败: user_id=%s error=%s",
  841. self.user_id,
  842. exc,
  843. )
  844. async def _maybe_spawn_conversation_stones(
  845. self,
  846. total_duration_ms: int,
  847. increment_ms: int,
  848. request_id: str,
  849. ):
  850. """按累计说话时长发放"""
  851. if (
  852. not self.energy_stone_service
  853. or not self.user_id
  854. or total_duration_ms <= 0
  855. or increment_ms <= 0
  856. ):
  857. return
  858. try:
  859. stones = await self.energy_stone_service.create_conversation_stones_if_needed(
  860. user_id=self.user_id,
  861. request_id=request_id,
  862. total_duration_ms=total_duration_ms,
  863. duration_increment_ms=increment_ms,
  864. )
  865. for stone in stones:
  866. await self.send_energy_stone_spawn(stone)
  867. except Exception as exc:
  868. logger.warning(
  869. "[EnergyStone] 对话能量石生成失败: user_id=%s error=%s",
  870. self.user_id,
  871. exc,
  872. )
  873. async def send_user_level_update(self, request_id: str, level: int, total_duration_ms: int):
  874. """发送用户等级更新消息"""
  875. message = UserLevelMessage(
  876. type="user_level",
  877. requestId=request_id,
  878. timestamp=self._get_timestamp(),
  879. level=level,
  880. totalDurationMs=total_duration_ms,
  881. )
  882. await self.websocket.send_text(message.model_dump_json())
  883. async def send_text_result(self, request_id: str, text: str, is_final: bool):
  884. """发送文本结果"""
  885. message = TextResultMessage(
  886. type="text_result",
  887. requestId=request_id,
  888. timestamp=self._get_timestamp(),
  889. text=text,
  890. isFinal=is_final,
  891. )
  892. await self.websocket.send_text(message.model_dump_json())
  893. async def send_energy_stone_spawn(self, payload: Dict[str, Any]):
  894. """发送能量石生成消息"""
  895. message = EnergyStoneSpawnMessage(
  896. type="energy_stone_spawn",
  897. requestId=payload.get("stone_id"),
  898. timestamp=self._get_timestamp(),
  899. stoneId=payload.get("stone_id"),
  900. stoneType=payload.get("stone_type"),
  901. ttlSeconds=payload.get("ttl_seconds", settings.ENERGY_STONE_TTL_SECONDS),
  902. appearAt=payload.get("appear_at", self._get_timestamp()),
  903. metadata=payload.get("metadata"),
  904. )
  905. await self.websocket.send_text(message.model_dump_json())
  906. async def send_emotion(self, request_id: str, emotion: str):
  907. """发送情感标签"""
  908. message = EmotionMessage(
  909. type="emotion",
  910. requestId=request_id,
  911. timestamp=self._get_timestamp(),
  912. emotion=emotion,
  913. )
  914. await self.websocket.send_text(message.model_dump_json())
  915. async def send_audio_response(
  916. self, request_id: str, data: str, chunk_index: int, is_last: bool,
  917. format: str = "pcm", sample_rate: int = 24000
  918. ):
  919. """发送音频响应"""
  920. message = AudioResponseMessage(
  921. type="audio_response",
  922. requestId=request_id,
  923. timestamp=self._get_timestamp(),
  924. data=data,
  925. sampleRate=sample_rate,
  926. format=format,
  927. chunkIndex=chunk_index,
  928. isLast=is_last,
  929. )
  930. await self.websocket.send_text(message.model_dump_json())
  931. async def send_error(self, request_id: str, code: str, error_message: str):
  932. """发送错误消息"""
  933. message = ErrorMessage(
  934. type="error",
  935. requestId=request_id,
  936. timestamp=self._get_timestamp(),
  937. code=code,
  938. message=error_message,
  939. )
  940. await self.websocket.send_text(message.model_dump_json())
  941. async def send_pong(self, request_id: str):
  942. """发送Pong"""
  943. message = PongMessage(
  944. type="pong",
  945. requestId=request_id,
  946. timestamp=self._get_timestamp(),
  947. )
  948. await self.websocket.send_text(message.model_dump_json())
  949. def _get_timestamp(self) -> int:
  950. """获取当前时间戳(毫秒)"""
  951. return int(time.time() * 1000)
  952. def _build_llm_input(self, user_input: str, context: dict) -> str:
  953. """
  954. 构建包含上下文的LLM输入
  955. Args:
  956. user_input: 用户当前输入
  957. context: 上下文信息(包含summary和recent_messages)
  958. Returns:
  959. 完整的LLM输入文本
  960. """
  961. parts = []
  962. # 1. 最新总结(如果有,已包含所有历史信息)
  963. summary = context.get('summary')
  964. if summary:
  965. parts.append("【历史对话总结】")
  966. parts.append(summary)
  967. parts.append("")
  968. # 2. 最近对话(如果有)
  969. recent_messages = context.get('recent_messages', [])
  970. if recent_messages:
  971. parts.append("【最近对话】")
  972. for msg in recent_messages:
  973. role_label = "User" if msg['role'] == 'user' else "Assistant"
  974. parts.append(f"{role_label}: {msg['content']}")
  975. parts.append("")
  976. # 3. 当前用户输入
  977. parts.append(f"User: {user_input}")
  978. return "\n".join(parts)
  979. async def _ensure_redis_context(self, request_id: str):
  980. """
  981. 确保Redis中有最新上下文(预加载)
  982. 策略:
  983. 1. 检查Redis是否有缓存
  984. 2. 如果没有,从MySQL加载并更新Redis
  985. 3. 如果有,直接使用(零等待)
  986. """
  987. try:
  988. start = time.time()
  989. # 检查Redis缓存
  990. current_round = await self.redis_cache.get_current_round(self.user_id)
  991. cached_context = await self.redis_cache.get_context(self.user_id)
  992. if cached_context:
  993. # ✅ Redis已有缓存,无需操作
  994. return
  995. # ❌ Redis缓存未命中,从MySQL加载
  996. # 确保服务已初始化
  997. await self._init_services()
  998. if not self.context_service:
  999. return
  1000. # 从MySQL获取上下文
  1001. context = await self.context_service.get_context_for_round(
  1002. user_id=self.user_id,
  1003. current_round=current_round
  1004. )
  1005. # 更新Redis缓存
  1006. await self.redis_cache.set_context(self.user_id, context)
  1007. # 同时缓存消息列表
  1008. for msg in context.get('recent_messages', []):
  1009. await self.redis_cache.append_message(
  1010. user_id=self.user_id,
  1011. role=msg['role'],
  1012. content=msg['content'],
  1013. round_number=msg.get('round_number', 0)
  1014. )
  1015. # 同步轮次
  1016. if current_round > 0:
  1017. await self.redis_cache.set_current_round(self.user_id, current_round)
  1018. except asyncio.CancelledError:
  1019. # 🔥 任务被取消时,确保清理资源
  1020. logger.debug(f"Redis上下文预加载任务被取消: request_id={request_id}")
  1021. raise # 重新抛出 CancelledError
  1022. except Exception as e:
  1023. import traceback
  1024. traceback.print_exc()
  1025. async def _get_context_from_redis(self, request_id: str) -> Optional[dict]:
  1026. """
  1027. 从Redis获取上下文(零延迟)
  1028. Returns:
  1029. 上下文字典 或 None(需降级到MySQL)
  1030. """
  1031. try:
  1032. if not self.redis_cache:
  1033. return None
  1034. start = time.time()
  1035. # 获取当前轮次
  1036. current_round = await self.redis_cache.get_current_round(self.user_id)
  1037. # 从Redis构建上下文(需要传入db_manager以获取所有总结)
  1038. db_manager = None
  1039. if self.context_service:
  1040. db_manager = self.context_service.db
  1041. context = await self.redis_cache.build_context_from_cache(
  1042. user_id=self.user_id,
  1043. current_round=current_round,
  1044. db_manager=db_manager
  1045. )
  1046. return context
  1047. except Exception as e:
  1048. return None
  1049. async def _persist_conversation_async(
  1050. self,
  1051. request_id: str,
  1052. user_text: str,
  1053. ai_text: str,
  1054. round_number: int,
  1055. emotion: str
  1056. ):
  1057. """
  1058. 异步持久化对话到MySQL(不阻塞主流程)
  1059. 策略:加入队列,批量写入
  1060. """
  1061. self.pending_persistence.append({
  1062. 'request_id': request_id,
  1063. 'user_id': self.user_id,
  1064. 'user_text': user_text,
  1065. 'ai_text': ai_text,
  1066. 'round_number': round_number,
  1067. 'emotion': emotion,
  1068. 'timestamp': time.time()
  1069. })
  1070. # 启动持久化任务
  1071. if not self.persistence_task or self.persistence_task.done():
  1072. self.persistence_task = asyncio.create_task(
  1073. self._batch_persistence_loop()
  1074. )
  1075. async def _batch_persistence_loop(self):
  1076. """批量持久化循环(后台任务)"""
  1077. while True:
  1078. try:
  1079. await asyncio.sleep(self.persistence_interval)
  1080. if not self.pending_persistence:
  1081. continue
  1082. # 批量提取
  1083. batch = self.pending_persistence.copy()
  1084. self.pending_persistence.clear()
  1085. start = time.time()
  1086. # 确保服务已初始化
  1087. await self._init_services()
  1088. if not self.message_service:
  1089. # 重新加入队列
  1090. self.pending_persistence.extend(batch)
  1091. continue
  1092. # 批量写入MySQL
  1093. # ⚠️ 注意:用户消息和AI消息都已在主流程中立即保存
  1094. # 这里不再重复保存,避免数据库中出现重复记录
  1095. # 如果立即保存失败,应该通过错误日志和监控告警处理,而不是在这里重试
  1096. for item in batch:
  1097. # 记录日志,但不保存(避免重复)
  1098. logger.debug(
  1099. f"跳过批量持久化(已立即保存): "
  1100. f"user={item['user_id']}, round={item['round_number']}"
  1101. )
  1102. except asyncio.CancelledError:
  1103. # 🔥 任务被取消时,确保清理资源
  1104. logger.info("批量持久化任务被取消,正在清理...")
  1105. raise # 重新抛出 CancelledError
  1106. except Exception as e:
  1107. import traceback
  1108. traceback.print_exc()
  1109. async def _generate_summary_async(
  1110. self,
  1111. request_id: str,
  1112. user_id: str,
  1113. start_round: int,
  1114. end_round: int
  1115. ):
  1116. """
  1117. 异步生成对话总结(完全不阻塞)
  1118. 触发时机:
  1119. - 每15轮触发一次(15, 30, 45...)
  1120. """
  1121. self.pending_summaries.append({
  1122. 'request_id': request_id,
  1123. 'user_id': user_id,
  1124. 'start_round': start_round,
  1125. 'end_round': end_round,
  1126. 'timestamp': time.time()
  1127. })
  1128. # 启动总结生成任务
  1129. if not self.summary_task or self.summary_task.done():
  1130. self.summary_task = asyncio.create_task(
  1131. self._summary_generation_loop()
  1132. )
  1133. async def _summary_generation_loop(self):
  1134. """总结生成循环(后台任务)"""
  1135. while True:
  1136. try:
  1137. # 等待任务或间隔
  1138. await asyncio.sleep(1.0)
  1139. if not self.pending_summaries:
  1140. continue
  1141. # 逐个处理总结任务
  1142. task = self.pending_summaries.pop(0)
  1143. start = time.time()
  1144. try:
  1145. # 确保服务已初始化
  1146. await self._init_services()
  1147. if not self.context_service or not self.redis_cache:
  1148. continue
  1149. # 调用总结服务(耗时2-3秒)
  1150. # 注意:这里假设有summary_service,如果没有需要创建
  1151. from services.summary_service import SummaryService
  1152. summary_service = SummaryService(self.context_service.db)
  1153. start_round = task['start_round']
  1154. end_round = task['end_round']
  1155. # generate_summary 会自动从数据库获取消息并保存总结
  1156. # 该方法会自动获取之前的总结并进行累积总结
  1157. await summary_service.generate_summary(
  1158. user_id=task['user_id'],
  1159. start_round=start_round,
  1160. end_round=end_round
  1161. )
  1162. # 从数据库获取生成的总结文本(使用 ORM)
  1163. from database import db_manager
  1164. from models_db import ConversationSummary
  1165. from sqlalchemy import select
  1166. # 🔥 async with 会自动管理 session 生命周期,包括 CancelledError 情况
  1167. async with db_manager.get_session() as session:
  1168. result = await session.execute(
  1169. select(ConversationSummary)
  1170. .where(
  1171. ConversationSummary.user_id == task['user_id'],
  1172. ConversationSummary.start_round == start_round,
  1173. ConversationSummary.end_round == end_round
  1174. )
  1175. .order_by(ConversationSummary.created_at.desc())
  1176. .limit(1)
  1177. )
  1178. summary_obj = result.scalar_one_or_none()
  1179. summary = summary_obj.summary_text if summary_obj else ""
  1180. # 🔥 保存到Redis(立即生效)
  1181. await self.redis_cache.set_summary(
  1182. user_id=task['user_id'],
  1183. end_round=end_round,
  1184. summary=summary
  1185. )
  1186. # 🔥 更新Redis上下文缓存(包含新总结)
  1187. await self.redis_cache.invalidate_context(task['user_id'])
  1188. except Exception as e:
  1189. import traceback
  1190. traceback.print_exc()
  1191. except asyncio.CancelledError:
  1192. # 🔥 任务被取消时,确保清理资源
  1193. logger.info("总结生成任务被取消,正在清理...")
  1194. raise # 重新抛出 CancelledError
  1195. except Exception as e:
  1196. import traceback
  1197. traceback.print_exc()