diff --git a/ruoyi-fastapi-backend/requirements.txt b/ruoyi-fastapi-backend/requirements.txt index e3e6e7c..5cd8560 100644 --- a/ruoyi-fastapi-backend/requirements.txt +++ b/ruoyi-fastapi-backend/requirements.txt @@ -3,6 +3,7 @@ asyncmy==0.2.10 DateTime==5.5 fastapi[all]==0.115.8 httpx==0.27.2 +jieba==0.42.1 loguru==0.7.3 openpyxl==3.1.5 pandas==2.2.3 diff --git a/ruoyi-fastapi-backend/test/test_similarity_debug.py b/ruoyi-fastapi-backend/test/test_similarity_debug.py new file mode 100644 index 0000000..00e504c --- /dev/null +++ b/ruoyi-fastapi-backend/test/test_similarity_debug.py @@ -0,0 +1,43 @@ +""" +测试语义缓存的相似度计算 +""" +import sys +import os + +# 添加项目路径 +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from utils.semantic_cache_service import SemanticCacheService + +def test_similarity(): + """测试相似度计算""" + service = SemanticCacheService() + + # 测试问题对 + test_cases = [ + ("公司有什么产品?", "公司有什么产品?"), + ("公司有什么产品?", "公司有哪些产品?"), + ("公司有什么产品?", "你们公司有哪些产品?"), + ("公司有什么产品?", "今天天气怎么样?"), + ] + + print("🔍 相似度计算测试") + print("=" * 60) + + for q1, q2 in test_cases: + similarity = service._calculate_text_similarity(q1, q2) + keywords1 = service._extract_keywords(q1) + keywords2 = service._extract_keywords(q2) + + threshold = service.SIMILARITY_THRESHOLD + status = "✅ 命中" if similarity >= threshold else "❌ 未命中" + + print(f"\n问题1: {q1}") + print(f"问题2: {q2}") + print(f"关键词1: {keywords1}") + print(f"关键词2: {keywords2}") + print(f"相似度: {similarity:.3f} (阈值: {threshold})") + print(f"状态: {status}") + +if __name__ == "__main__": + test_similarity() diff --git a/ruoyi-fastapi-backend/test/test_sse.py b/ruoyi-fastapi-backend/test/test_sse.py index ea00417..1c0fd0d 100644 --- a/ruoyi-fastapi-backend/test/test_sse.py +++ b/ruoyi-fastapi-backend/test/test_sse.py @@ -49,6 +49,13 @@ class PerformanceTimer: self.start_time = time.perf_counter() return self + def restart(self): + """重新开始计时""" + self.start_time = time.perf_counter() + self.end_time = None + self.elapsed_ms = 0 + return self + def stop(self): if self.start_time is None: raise ValueError("计时器未启动") @@ -57,17 +64,23 @@ class PerformanceTimer: return self def get_elapsed_ms(self) -> float: - return self.elapsed_ms + if self.start_time is not None and self.end_time is not None: + return self.elapsed_ms + elif self.start_time is not None: + # 如果没有stop,返回当前经过的时间 + return (time.perf_counter() - self.start_time) * 1000 + return 0 def get_elapsed_seconds(self) -> float: - return self.elapsed_ms / 1000 + return self.get_elapsed_ms() / 1000 def format(self) -> str: """格式化输出时间""" - if self.elapsed_ms < 1000: - return f"{self.elapsed_ms:.2f}ms" + elapsed = self.get_elapsed_ms() + if elapsed < 1000: + return f"{elapsed:.2f}ms" else: - return f"{self.get_elapsed_seconds():.2f}s ({self.elapsed_ms:.0f}ms)" + return f"{self.get_elapsed_seconds():.2f}s ({elapsed:.0f}ms)" async def login_get_token() -> str: @@ -121,10 +134,10 @@ async def stream_chat_async( chat_id: str, session_id: str, request_number: int = 1 -) -> tuple[str, PerformanceTimer]: +) -> dict: """ 异步版(aiohttp),适合已有事件循环的应用,不阻塞主线程。 - 返回完整回答和耗时 + 返回完整回答和相关时间统计 """ print(f"\n{'='*60}") print(f"📤 请求 #{request_number}") @@ -133,8 +146,14 @@ async def stream_chat_async( print(f"🔗 Session ID: {session_id}") print(f"{'='*60}") + # 创建计时器 + timer = PerformanceTimer().start() + token = await login_get_token() + # 重启计时器,只计算流式请求的时间(不包含登录时间) + timer.restart() + payload = { "chat_id": chat_id, "question": question, @@ -149,9 +168,9 @@ async def stream_chat_async( answer_parts = [] event_type = "message" - # 性能计时 - timer = PerformanceTimer().start() + # 性能计时 - timer 已在登录前创建并 restart first_token_time = None + connection_time = None async with aiohttp.ClientSession() as session: async with session.post( @@ -160,16 +179,23 @@ async def stream_chat_async( print( f"# status={resp.status} content-type={resp.headers.get('content-type')}") resp.raise_for_status() + + # 记录连接建立时间 + connection_time = timer.get_elapsed_ms() + print(f"[debug] 连接建立时间: {connection_time:.0f}ms") + event_count = 0 while True: raw_line = await resp.content.readline() if not raw_line: + print(f"[debug] 收到 {event_count} 个事件,流结束") break line = raw_line.decode(errors="ignore").strip() # 逐行读取 SSE 数据 if not line or line.startswith(":"): continue if line.startswith("event:"): event_type = line.split(":", 1)[1].strip() or "message" + print(f"[debug] 事件类型: {event_type}") continue if not line.startswith("data:"): print(f"[skip] {line}") @@ -177,16 +203,36 @@ async def stream_chat_async( data_str = line[len("data:"):].strip() if not data_str: continue + event_count += 1 try: payload_obj = json.loads(data_str) except json.JSONDecodeError: print(f"[{event_type}] {data_str}") event_type = "message" continue + + # 打印所有收到的响应,便于调试 + print(f"[debug] 收到事件 #{event_count}: {list(payload_obj.keys())}") - # 记录首Token时间 - if first_token_time is None and "answer" in payload_obj: - first_token_time = timer.get_elapsed_ms() + # 记录首Token时间(遇到任何包含内容的字段时) + if first_token_time is None: + # 检测缓存命中情况 + if "from_cache" in payload_obj and payload_obj.get("from_cache") is True: + # 缓存命中!首Token就是连接建立时间 + first_token_time = connection_time + cache_total_time = payload_obj.get("total_time") + print(f"[debug] 🔥 缓存命中! 连接时间: {connection_time:.0f}ms, 缓存总耗时: {cache_total_time}ms") + elif "answer" in payload_obj: + first_token_time = timer.get_elapsed_ms() + print(f"[debug] 首Token时间(answer): {first_token_time:.0f}ms") + elif "data" in payload_obj and payload_obj.get("data") is not True: + # 缓存命中时可能直接返回完整答案 + first_token_time = timer.get_elapsed_ms() + print(f"[debug] 首Token时间(data): {first_token_time:.0f}ms") + elif payload_obj.get("status") == "completed" or event_type == "end": + # 如果一开始就收到completed,可能是即时响应 + first_token_time = connection_time # 使用连接时间作为近似 + print(f"[debug] 首Token时间(即时响应): {first_token_time:.0f}ms") if event_type == "end" or payload_obj.get("status") == "completed": print("\n[stream] completed") @@ -199,47 +245,71 @@ async def stream_chat_async( answer_parts.append(piece) # 流式输出:每个片段都单独打印 print(piece, end="", flush=True) - time.sleep(0.05) # 每个片段间隔50ms,让流式效果更明显 + await asyncio.sleep(0.05) # 使用异步sleep而不是time.sleep else: print(f"[{event_type}] {payload_obj}") event_type = "message" timer.stop() + full_answer = "".join(answer_parts) + print(f"\n\n{'='*60}") print(f"✅ 请求 #{request_number} 完成") print(f"⏱️ 总耗时: {timer.format()}") - if first_token_time: + if connection_time is not None: + print(f"🔗 连接建立: {connection_time:.0f}ms") + if first_token_time is not None: print(f"🚀 首Token延迟: {first_token_time:.0f}ms") - print(f"📝 回答长度: {len(''.join(answer_parts))} 字符") + if connection_time is not None: + ttft = first_token_time - connection_time # Time To First Token + print(f"⏱️ 首Token耗时(不含连接): {ttft:.0f}ms") + else: + print(f"🚀 首Token延迟: 未记录") + print(f"📝 回答长度: {len(full_answer)} 字符") print(f"{'='*60}") - full_answer = "".join(answer_parts) - return full_answer, timer + # 返回完整统计信息 + return { + "answer": full_answer, + "timer": timer, + "first_token_ms": first_token_time, + "connection_ms": connection_time + } async def run_performance_test( - question: str, + questions: list[str], chat_id: str, session_id: str, - num_requests: int = 3, delay_between_requests: float = 2.0 ): """ 运行性能测试,多次请求以测试缓存效果 + + Args: + questions: 问题列表,每个请求使用对应的问题 + chat_id: 聊天会话ID + session_id: 会话ID + delay_between_requests: 请求之间的间隔时间(秒) """ + num_requests = len(questions) + print("\n" + "🚀"*30) print("🎯 性能测试开始") print(f"📊 测试配置:") print(f" - 请求次数: {num_requests}") print(f" - 请求间隔: {delay_between_requests}s") - print(f" - 问题: {question}") + print("📝 测试问题:") + for i, q in enumerate(questions): + print(f" {i+1}. {q}") print("🚀"*30) results = [] for i in range(1, num_requests + 1): + question = questions[i-1] try: - answer, timer = await stream_chat_async( + result = await stream_chat_async( question=question, chat_id=chat_id, session_id=session_id, @@ -247,9 +317,13 @@ async def run_performance_test( ) results.append({ "request_number": i, - "elapsed_ms": timer.get_elapsed_ms(), - "elapsed_seconds": timer.get_elapsed_seconds(), - "answer_length": len(answer) + "question": question, + "elapsed_ms": result["timer"].get_elapsed_ms(), + "elapsed_seconds": result["timer"].get_elapsed_seconds(), + "first_token_ms": result["first_token_ms"], + "connection_ms": result["connection_ms"], + "answer_length": len(result["answer"]), + "from_cache": False # 后续可以根据响应判断 }) # 如果不是最后一次请求,等待一段时间 @@ -261,8 +335,11 @@ async def run_performance_test( print(f"\n❌ 请求 #{i} 失败: {str(e)}") results.append({ "request_number": i, + "question": question, "elapsed_ms": -1, "elapsed_seconds": -1, + "first_token_ms": None, + "connection_ms": None, "answer_length": 0, "error": str(e) }) @@ -283,12 +360,12 @@ async def run_performance_test( min_time = min(elapsed_times) max_time = max(elapsed_times) - print(f"\n{'请求#':<8} {'耗时':<15} {'速度评估'}") - print("-" * 50) + print(f"\n{'请求#':<8} {'问题':<30} {'总耗时':<12} {'首Token':<10} {'速度评估'}") + print("-" * 80) for r in results: if r["elapsed_ms"] < 0: - print(f"#{r['request_number']:<6} {'失败':<15} ❌") + print(f"#{r['request_number']:<6} {r['question'][:28]:<30} {'失败':<12} {'-':<10} ❌") else: # 速度评估 elapsed = r["elapsed_ms"] @@ -301,9 +378,11 @@ async def run_performance_test( else: speed = "🐢 较慢" - print(f"#{r['request_number']:<6} {r['elapsed_ms']:.0f}ms".ljust(15) + speed) + first_token = f"{r['first_token_ms']:.0f}ms" if r["first_token_ms"] else "-" + question_short = r['question'][:28] + ".." if len(r['question']) > 28 else r['question'] + print(f"#{r['request_number']:<6} {question_short:<30} {r['elapsed_ms']:.0f}ms{'':<5} {first_token:<10} {speed}") - print("-" * 50) + print("-" * 80) print(f"\n📊 统计信息:") print(f" 平均耗时: {avg_time:.0f}ms ({avg_time/1000:.2f}s)") print(f" 最快耗时: {min_time:.0f}ms") @@ -322,6 +401,19 @@ async def run_performance_test( else: print(f" 🤔 响应时间相近,缓存效果不确定") + # 首Token时间分析 + first_tokens = [r["first_token_ms"] for r in valid_results if r["first_token_ms"] is not None] + if len(first_tokens) >= 2: + first_ttft = first_tokens[0] + last_ttft = first_tokens[-1] + print(f"\n🚀 首Token时间分析:") + if last_ttft < first_ttft * 0.5: + print(f" ✅ 后续请求首Token更快,可能命中缓存") + elif last_ttft > first_ttft * 1.5: + print(f" ⚠️ 后续请求首Token更慢,可能是新问题") + else: + print(f" 🤔 首Token时间相近") + print("📊"*30 + "\n") @@ -393,12 +485,20 @@ if __name__ == "__main__": ) else: # 性能测试模式 + # 定义三个测试问题 + # 问题1和2完全相同,用于测试精确缓存 + # 问题3与问题1意思相同但表述略不同,用于测试语义缓存 + questions = [ + "公司有什么产品?", # 问题1 + "公司有什么产品?", # 问题2(完全相同,测试精确缓存) + "公司有哪些产品?", # 问题3(表述略不同,测试语义缓存) + ] + asyncio.run( run_performance_test( - question=question, + questions=questions, chat_id=args.chat_id, session_id=args.session_id, - num_requests=args.num_requests, delay_between_requests=args.delay ) ) diff --git a/ruoyi-fastapi-backend/utils/semantic_cache_service.py b/ruoyi-fastapi-backend/utils/semantic_cache_service.py index a6df828..a54149e 100644 --- a/ruoyi-fastapi-backend/utils/semantic_cache_service.py +++ b/ruoyi-fastapi-backend/utils/semantic_cache_service.py @@ -25,6 +25,14 @@ import logging from typing import Optional, Tuple, Dict, Any, List from dataclasses import dataclass, asdict +# 尝试导入jieba分词库 +try: + import jieba + JIEBA_AVAILABLE = True +except ImportError: + JIEBA_AVAILABLE = False + logger.warning("jieba未安装,语义匹配将使用简单的关键词提取") + # 配置日志 logger = logging.getLogger(__name__) @@ -107,18 +115,29 @@ class SemanticCacheService: 提取问题关键词(用于语义匹配) 策略: - 1. 保留完整问题作为主要匹配依据 - 2. 提取名词、动词等核心词汇 - 3. 过滤停用词 + 1. 使用jieba分词(如果可用)提取中文词语 + 2. 保留完整问题作为主要匹配依据 + 3. 提取名词、动词等核心词汇 + 4. 过滤停用词 """ # 简单停用词列表 - stopwords = {'的', '是', '了', '在', '有', '和', '与', '或', '吗', '呢', '吧', '啊', '哦', '请问', '能不能', '可以', '怎么', '如何', '什么', '多少', '几个'} + stopwords = {'的', '是', '了', '在', '有', '和', '与', '或', '吗', '呢', '吧', '啊', '哦', '请问', '能不能', '可以', '怎么', '如何', '什么', '多少', '几个', '哪些', '那个', '这个'} - # 分词(简单按字符或词语切分) - words = re.findall(r'[\w\u4e00-\u9fff]+', question.lower()) - - # 过滤停用词和过短的词 - keywords = [w for w in words if w not in stopwords and len(w) > 1] + if JIEBA_AVAILABLE: + # 使用jieba分词 + words = list(jieba.cut(question)) + + # 过滤停用词、过短的词和标点 + keywords = [w.strip() for w in words + if w.strip() and w.strip() not in stopwords + and len(w.strip()) > 1 + and not re.match(r'^[\s\d\W]+$', w)] + else: + # 分词(简单按字符或词语切分) + words = re.findall(r'[\w\u4e00-\u9fff]+', question.lower()) + + # 过滤停用词和过短的词 + keywords = [w for w in words if w not in stopwords and len(w) > 1] return keywords @@ -203,6 +222,7 @@ class SemanticCacheService: # 遍历缓存,计算相似度 best_match = None + best_match_key = None # 记录最佳匹配的key best_similarity = 0.0 for key in cache_keys: @@ -221,6 +241,7 @@ class SemanticCacheService: if similarity > best_similarity: best_similarity = similarity best_match = entry + best_match_key = key # 保存正确的key except (json.JSONDecodeError, KeyError) as e: logger.warning(f"[SemanticCache] 解析缓存失败: {key}, {e}") @@ -231,7 +252,7 @@ class SemanticCacheService: logger.info(f"[SemanticCache] 语义命中 (相似度={best_similarity:.2f}): {question[:30]}...") # 更新命中次数 best_match.hit_count += 1 - await redis.set(key, json.dumps(best_match.to_dict()), ex=self.CACHE_TTL_HOURS * 3600) + await redis.set(best_match_key, json.dumps(best_match.to_dict()), ex=self.CACHE_TTL_HOURS * 3600) return best_match.answer, best_similarity return None