websocket_handler.py 60 KB

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