| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512 |
- """
- 请求日志记录器 - 记录每个语音对话请求的详细性能数据
- 使用JSON Lines格式,按天轮转日志文件
- """
- import json
- import time
- import queue
- import threading
- from datetime import datetime
- from pathlib import Path
- from typing import Optional, Dict, Any
- from dataclasses import dataclass
- from config import settings
- @dataclass
- class PerformanceMetrics:
- """性能指标数据类"""
- # 用户说话
- user_speech_start: Optional[float] = None
- user_speech_end: Optional[float] = None
- user_text: str = ""
-
- # STT相关
- stt_first_audio_received: Optional[float] = None
- stt_first_text_generated: Optional[float] = None
- stt_last_audio_received: Optional[float] = None
- stt_completed: Optional[float] = None
- stt_audio_chunks: int = 0 # 音频块数量
- stt_total_bytes: int = 0 # 总字节数
-
- # LLM提示词准备
- llm_prompt_prepare_start: Optional[float] = None
- llm_prompt_prepare_end: Optional[float] = None
- llm_prompt_length: int = 0 # 提示词长度
- llm_context_round: int = 0 # 上下文轮次
- llm_full_prompt: str = "" # 完整prompt(system_prompt + user_input)
-
- # LLM生成
- llm_call_start: Optional[float] = None
- llm_first_text_output: Optional[float] = None
- llm_all_text_completed: Optional[float] = None
- llm_call_end: Optional[float] = None
- llm_emotion: str = "neutral" # 情感
- llm_sentences: int = 0 # 句子数
-
- # TTS生成
- tts_call_start: Optional[float] = None
- tts_first_audio_output: Optional[float] = None
- tts_all_audio_completed: Optional[float] = None
- tts_call_end: Optional[float] = None
- tts_voice: str = "Cherry" # 音色
- tts_audio_chunks: int = 0 # 音频块数量
- tts_total_bytes: int = 0 # 总字节数
-
- # AI响应
- ai_response_text: str = ""
-
- # 消息ID(数据库关联)
- user_message_id: Optional[str] = None # 用户消息的 message_id
- assistant_message_id: Optional[str] = None # AI消息的 message_id
-
- def calculate_user_speech_duration_ms(self) -> Optional[float]:
- """计算用户说话时长(ms)"""
- if self.user_speech_start and self.user_speech_end:
- return (self.user_speech_end - self.user_speech_start) * 1000
- return None
-
- def calculate_stt_latency_ms(self) -> Optional[float]:
- """计算STT延迟(ms): STT完成时间 - 收到最后一个音频时间"""
- if self.stt_completed and self.stt_last_audio_received:
- return (self.stt_completed - self.stt_last_audio_received) * 1000
- return None
-
- def calculate_llm_prompt_latency_ms(self) -> Optional[float]:
- """计算LLM提示词准备延迟(ms)"""
- if self.llm_prompt_prepare_end and self.llm_prompt_prepare_start:
- return (self.llm_prompt_prepare_end - self.llm_prompt_prepare_start) * 1000
- return None
-
- def calculate_llm_generation_latency_ms(self) -> Optional[float]:
- """计算LLM生成延迟(ms)"""
- if self.llm_first_text_output and self.llm_call_start:
- return (self.llm_first_text_output - self.llm_call_start) * 1000
- return None
-
- def calculate_tts_latency_ms(self) -> Optional[float]:
- """计算TTS延迟(ms)"""
- if self.tts_first_audio_output and self.tts_call_start:
- return (self.tts_first_audio_output - self.tts_call_start) * 1000
- return None
-
- def calculate_total_latency_ms(self) -> Optional[float]:
- """计算总延迟(ms): 开始输出第一个音频时间 - 用户结束说话时间"""
- if self.tts_first_audio_output and self.user_speech_end:
- return (self.tts_first_audio_output - self.user_speech_end) * 1000
- return None
- class RequestLogger:
- """请求日志记录器(线程安全,异步写入)"""
-
- def __init__(self, log_dir: str = "logs"):
- """
- 初始化日志记录器
-
- Args:
- log_dir: 日志目录
- """
- self.log_dir = Path(log_dir)
- self.log_dir.mkdir(parents=True, exist_ok=True)
-
- # 当前日志文件路径
- self.current_log_file: Optional[Path] = None
- self.current_date: Optional[str] = None
-
- # 异步写入队列
- self.log_queue: queue.Queue = queue.Queue()
- self.writer_thread: Optional[threading.Thread] = None
- self.running = True
-
- # 启动后台写入线程
- self._start_writer_thread()
-
- def _get_log_file_path(self, date_str: str) -> Path:
- """获取指定日期的日志文件路径"""
- return self.log_dir / f"request_{date_str}.log"
-
- def _get_current_log_file(self) -> Path:
- """获取当前日期的日志文件路径(自动轮转)"""
- today = datetime.now().strftime("%Y-%m-%d")
-
- # 如果日期变化,切换日志文件
- if self.current_date != today:
- self.current_date = today
- self.current_log_file = self._get_log_file_path(today)
-
- return self.current_log_file
-
- def _start_writer_thread(self):
- """启动后台写入线程"""
- def writer_loop():
- """后台写入循环"""
- while self.running:
- try:
- # 从队列获取日志条目(超时1秒)
- log_entry = self.log_queue.get(timeout=1.0)
-
- # 获取当前日志文件
- log_file = self._get_current_log_file()
-
- # 写入JSON格式日志文件(追加模式)
- with open(log_file, 'a', encoding='utf-8') as f:
- f.write(json.dumps(log_entry, ensure_ascii=False) + '\n')
-
- # 如果是请求日志,同时写入conversation_log.txt
- if log_entry.get("类型") == "请求":
- self._write_conversation_log(log_entry)
-
- self.log_queue.task_done()
-
- except queue.Empty:
- continue
- except Exception as e:
- # 写入失败,记录到stderr(避免循环)
- import sys
- print(f"[RequestLogger] 写入日志失败: {e}", file=sys.stderr)
-
- self.writer_thread = threading.Thread(target=writer_loop, daemon=True)
- self.writer_thread.start()
-
- def _format_timestamp(self, timestamp: Optional[float]) -> Optional[str]:
- """格式化时间戳为ISO 8601格式"""
- if timestamp is None:
- return None
- dt = datetime.fromtimestamp(timestamp)
- return dt.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
-
- def _format_iso_time_to_conversation_log(self, iso_time: Optional[str]) -> str:
- """将ISO时间戳格式化为conversation_log.txt格式 (HH:MM:SS.mmm)"""
- if not iso_time:
- return "N/A"
- try:
- # 从ISO格式解析: 2025-11-11T22:42:46.123Z
- dt = datetime.fromisoformat(iso_time.replace('Z', '+00:00'))
- return dt.strftime("%H:%M:%S.%f")[:-3]
- except:
- return "N/A"
-
- def _write_conversation_log(self, log_entry: Dict[str, Any]):
- """写入conversation_log.txt格式的日志"""
- try:
- conversation_log_path = Path("conversation_log.txt")
-
- stages = log_entry.get("阶段", {})
- summary = log_entry.get("总结", {})
- request_id = log_entry.get("请求ID", "unknown")
- timestamp = log_entry.get("时间戳", "")
-
- # 解析时间戳获取日期时间
- if timestamp:
- try:
- # 从ISO格式解析: 2025-11-11T22:42:46.123Z
- dt = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
- date_str = dt.strftime("%Y-%m-%d %H:%M:%S")
- except:
- date_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
- else:
- date_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
-
- lines = []
- lines.append(f"[{date_str}] [{request_id}]")
-
- # 用户说话
- user_speech = stages.get("用户说话", {})
- user_text = ""
- if "语音识别" in stages and "输出" in stages["语音识别"]:
- user_text = stages["语音识别"]["输出"].get("文本", "")
-
- user_start = user_speech.get("开始时间")
- user_end = user_speech.get("结束时间")
- user_duration = user_speech.get("时长_毫秒")
-
- user_start_str = self._format_iso_time_to_conversation_log(user_start)
- user_end_str = self._format_iso_time_to_conversation_log(user_end)
- user_duration_str = f"{int(user_duration)}ms" if user_duration else "N/A"
-
- lines.append(f"用户说话: {user_start_str}, {user_text}, {user_end_str}, {user_duration_str}")
-
- # STT
- stt = stages.get("语音识别", {})
- stt_first_audio = stt.get("首次收到音频")
- stt_first_text = stt.get("首次生成文本")
- stt_last_audio = stt.get("最后收到音频")
- stt_completed = stt.get("完成时间")
- stt_latency = stt.get("延迟_毫秒")
-
- stt_first_audio_str = self._format_iso_time_to_conversation_log(stt_first_audio)
- stt_first_text_str = self._format_iso_time_to_conversation_log(stt_first_text)
- stt_last_audio_str = self._format_iso_time_to_conversation_log(stt_last_audio)
- stt_completed_str = self._format_iso_time_to_conversation_log(stt_completed)
- stt_latency_str = f"{int(stt_latency)}ms" if stt_latency else "N/A"
-
- lines.append(f"STT: {stt_first_audio_str}, {stt_first_text_str}, {stt_last_audio_str}, {stt_completed_str}, {stt_latency_str}")
-
- # LLM提示词
- llm_prompt = stages.get("LLM提示词准备", {})
- llm_prompt_start = llm_prompt.get("开始时间")
- llm_prompt_end = llm_prompt.get("结束时间")
- llm_prompt_duration = llm_prompt.get("时长_毫秒")
-
- llm_prompt_start_str = self._format_iso_time_to_conversation_log(llm_prompt_start)
- llm_prompt_end_str = self._format_iso_time_to_conversation_log(llm_prompt_end)
- llm_prompt_duration_str = f"{int(llm_prompt_duration)}ms" if llm_prompt_duration else "N/A"
-
- lines.append(f"LLM提示词: {llm_prompt_start_str}, {llm_prompt_end_str}, {llm_prompt_duration_str}")
-
- # LLM生成
- llm = stages.get("LLM生成", {})
- llm_call_start = llm.get("调用开始")
- llm_first_text = llm.get("首次文本输出")
- llm_all_completed = llm.get("全部文本完成")
- llm_call_end = llm.get("调用结束")
- llm_latency = llm.get("延迟_毫秒")
-
- llm_call_start_str = self._format_iso_time_to_conversation_log(llm_call_start)
- llm_first_text_str = self._format_iso_time_to_conversation_log(llm_first_text)
- llm_all_completed_str = self._format_iso_time_to_conversation_log(llm_all_completed)
- llm_call_end_str = self._format_iso_time_to_conversation_log(llm_call_end)
- llm_latency_str = f"{int(llm_latency)}ms" if llm_latency else "N/A"
-
- lines.append(f"LLM生成: {llm_call_start_str}, {llm_first_text_str}, {llm_all_completed_str}, {llm_call_end_str}, {llm_latency_str}")
-
- # TTS生成
- tts = stages.get("TTS生成", {})
- tts_call_start = tts.get("调用开始")
- tts_first_audio = tts.get("首次音频输出")
- tts_all_completed = tts.get("全部音频完成")
- tts_call_end = tts.get("调用结束")
- tts_latency = tts.get("延迟_毫秒")
-
- tts_call_start_str = self._format_iso_time_to_conversation_log(tts_call_start)
- tts_first_audio_str = self._format_iso_time_to_conversation_log(tts_first_audio)
- tts_all_completed_str = self._format_iso_time_to_conversation_log(tts_all_completed)
- tts_call_end_str = self._format_iso_time_to_conversation_log(tts_call_end)
- tts_latency_str = f"{int(tts_latency)}ms" if tts_latency else "N/A"
-
- lines.append(f"TTS生成: {tts_call_start_str}, {tts_first_audio_str}, {tts_all_completed_str}, {tts_call_end_str}, {tts_latency_str}")
-
- # 总结
- ai_response_text = ""
- if "LLM生成" in stages and "输出" in stages["LLM生成"]:
- ai_response_text = stages["LLM生成"]["输出"].get("文本", "")
-
- total_latency = summary.get("首次音频延迟_毫秒")
- total_latency_str = f"{int(total_latency)}ms" if total_latency else "N/A"
-
- lines.append(f"总结: {ai_response_text}, {total_latency_str}")
- lines.append("-" * 120)
- lines.append("")
-
- # 写入文件
- with open(conversation_log_path, 'a', encoding='utf-8') as f:
- f.write('\n'.join(lines) + '\n')
-
- except Exception as e:
- # 写入失败不影响主流程
- import sys
- print(f"[RequestLogger] 写入conversation_log.txt失败: {e}", file=sys.stderr)
-
- def _build_request_log(
- self,
- request_id: str,
- user_id: str,
- metrics: PerformanceMetrics
- ) -> Dict[str, Any]:
- """构建请求日志JSON对象"""
-
- # 构建各阶段数据
- stages = {}
-
- # 用户说话阶段
- if metrics.user_speech_start and metrics.user_speech_end:
- stages["用户说话"] = {
- "开始时间": self._format_timestamp(metrics.user_speech_start),
- "结束时间": self._format_timestamp(metrics.user_speech_end),
- "时长_毫秒": metrics.calculate_user_speech_duration_ms()
- }
-
- # STT阶段
- if metrics.stt_first_audio_received:
- stt_stage = {
- "首次收到音频": self._format_timestamp(metrics.stt_first_audio_received),
- "最后收到音频": self._format_timestamp(metrics.stt_last_audio_received),
- "完成时间": self._format_timestamp(metrics.stt_completed),
- "输入": {
- "音频块数": metrics.stt_audio_chunks,
- "总字节数": metrics.stt_total_bytes
- },
- "输出": {
- "文本": metrics.user_text,
- "是否最终": True
- }
- }
- if metrics.stt_first_text_generated:
- stt_stage["首次生成文本"] = self._format_timestamp(metrics.stt_first_text_generated)
- if metrics.stt_completed and metrics.stt_last_audio_received:
- stt_stage["延迟_毫秒"] = metrics.calculate_stt_latency_ms()
- stages["语音识别"] = stt_stage
-
- # LLM提示词准备阶段
- if metrics.llm_prompt_prepare_start and metrics.llm_prompt_prepare_end:
- stages["LLM提示词准备"] = {
- "开始时间": self._format_timestamp(metrics.llm_prompt_prepare_start),
- "结束时间": self._format_timestamp(metrics.llm_prompt_prepare_end),
- "时长_毫秒": metrics.calculate_llm_prompt_latency_ms()
- }
-
- # LLM生成阶段
- if metrics.llm_call_start:
- llm_stage = {
- "调用开始": self._format_timestamp(metrics.llm_call_start),
- "调用结束": self._format_timestamp(metrics.llm_call_end),
- "输入": {
- "提示词长度": metrics.llm_prompt_length,
- "上下文轮次": metrics.llm_context_round,
- "完整提示词": metrics.llm_full_prompt if metrics.llm_full_prompt else ""
- },
- "输出": {
- "文本": metrics.ai_response_text,
- "情感": metrics.llm_emotion,
- "句子数": metrics.llm_sentences
- }
- }
- if metrics.llm_first_text_output:
- llm_stage["首次文本输出"] = self._format_timestamp(metrics.llm_first_text_output)
- if metrics.llm_all_text_completed:
- llm_stage["全部文本完成"] = self._format_timestamp(metrics.llm_all_text_completed)
- if metrics.llm_first_text_output and metrics.llm_call_start:
- llm_stage["延迟_毫秒"] = metrics.calculate_llm_generation_latency_ms()
- stages["LLM生成"] = llm_stage
-
- # TTS阶段
- if metrics.tts_call_start:
- tts_stage = {
- "调用开始": self._format_timestamp(metrics.tts_call_start),
- "调用结束": self._format_timestamp(metrics.tts_call_end),
- "输入": {
- "文本": metrics.ai_response_text,
- "情感": metrics.llm_emotion,
- "音色": metrics.tts_voice
- },
- "输出": {
- "音频块数": metrics.tts_audio_chunks,
- "总字节数": metrics.tts_total_bytes
- }
- }
- if metrics.tts_first_audio_output:
- tts_stage["首次音频输出"] = self._format_timestamp(metrics.tts_first_audio_output)
- if metrics.tts_all_audio_completed:
- tts_stage["全部音频完成"] = self._format_timestamp(metrics.tts_all_audio_completed)
- if metrics.tts_first_audio_output and metrics.tts_call_start:
- tts_stage["延迟_毫秒"] = metrics.calculate_tts_latency_ms()
- stages["TTS生成"] = tts_stage
-
- # 构建总结
- summary = {}
- if metrics.tts_first_audio_output and metrics.user_speech_end:
- summary["首次音频延迟_毫秒"] = metrics.calculate_total_latency_ms()
- if metrics.tts_all_audio_completed and metrics.user_speech_start:
- total_duration = (metrics.tts_all_audio_completed - metrics.user_speech_start) * 1000
- summary["总延迟_毫秒"] = total_duration
-
- # 构建完整日志对象
- log_entry = {
- "类型": "请求",
- "时间戳": self._format_timestamp(time.time()),
- "请求ID": request_id,
- "用户ID": user_id,
- "阶段": stages
- }
-
- if summary:
- log_entry["总结"] = summary
-
- # 添加消息ID(数据库关联)
- if metrics.user_message_id:
- log_entry["用户消息ID"] = metrics.user_message_id
- if metrics.assistant_message_id:
- log_entry["助手消息ID"] = metrics.assistant_message_id
-
- return log_entry
-
- def log_request(
- self,
- user_id: str,
- request_id: str,
- metrics: PerformanceMetrics
- ):
- """
- 记录一条对话请求
-
- Args:
- user_id: 用户ID
- request_id: 请求ID
- metrics: 性能指标对象
- """
- try:
- log_entry = self._build_request_log(request_id, user_id, metrics)
- self.log_queue.put(log_entry)
- except Exception as e:
- # 记录失败不影响主流程
- import sys
- print(f"[RequestLogger] 记录请求日志失败: {e}", file=sys.stderr)
-
- def log_error(
- self,
- user_id: str,
- request_id: str,
- error_code: str,
- error_message: str,
- stage: Optional[str] = None,
- user_text: Optional[str] = None
- ):
- """
- 记录错误请求
-
- Args:
- user_id: 用户ID
- request_id: 请求ID
- error_code: 错误代码
- error_message: 错误信息
- stage: 错误发生的阶段(可选)
- user_text: 用户输入文本(可选)
- """
- try:
- log_entry = {
- "类型": "错误",
- "时间戳": self._format_timestamp(time.time()),
- "请求ID": request_id,
- "用户ID": user_id,
- "错误": {
- "代码": error_code,
- "消息": error_message
- }
- }
-
- if stage:
- log_entry["错误"]["阶段"] = stage
-
- if user_text:
- log_entry["上下文"] = {
- "用户文本": user_text,
- "阶段": stage or "unknown"
- }
-
- self.log_queue.put(log_entry)
- except Exception as e:
- import sys
- print(f"[RequestLogger] 记录错误日志失败: {e}", file=sys.stderr)
-
- def shutdown(self):
- """关闭日志记录器(等待队列清空)"""
- self.running = False
- if self.writer_thread:
- self.log_queue.join() # 等待队列清空
- self.writer_thread.join(timeout=5.0)
- # 全局单例
- request_logger = RequestLogger(log_dir=settings.LOG_DIR)
|