""" 推理模块单元测试 测试推理模块的所有功能,包括: - llama-cpp 后端 - vLLM 后端 - SGLang 后端 - 统一推理接口 - 性能监控 - 数据结构 使用 mock 避免实际模型加载和推理过程。 """ import os import sys import pytest from pathlib import Path from unittest.mock import MagicMock, patch, Mock import time # 添加项目路径 sys.path.insert(0, str(Path(__file__).parent.parent)) from hos_optimizer.inference import ( InferenceRequest, InferenceResult, PerformanceStats, PerformanceMonitor, InferenceBackend, LlamaCppBackend, VLLMBackend, SGLangBackend, UnifiedInferenceEngine, get_gpu_memory_usage_mb, get_total_gpu_memory_mb, detect_best_backend, ) class TestDataStructures: """数据结构测试""" def test_inference_request_creation(self): """测试推理请求数据结构创建""" request = InferenceRequest( prompt="测试提示", max_tokens=100, temperature=0.8, top_p=0.95, top_k=50, stop=["END"], json_schema={"type": "object"}, extra={"custom": "param"} ) assert request.prompt == "测试提示" assert request.max_tokens == 100 assert request.temperature == 0.8 assert request.top_p == 0.95 assert request.top_k == 50 assert request.stop == ["END"] assert request.json_schema == {"type": "object"} assert request.extra == {"custom": "param"} def test_inference_request_defaults(self): """测试推理请求默认值""" request = InferenceRequest(prompt="测试") assert request.max_tokens == 256 assert request.temperature == 0.7 assert request.top_p == 0.9 assert request.top_k == 50 assert request.stop is None assert request.json_schema is None assert request.extra is None def test_inference_result_creation(self): """测试推理结果数据结构创建""" result = InferenceResult( text="生成的文本", token_ids=[1, 2, 3], prompt="提示", latency_ms=150.5, tokens_per_second=33.3, prompt_tokens=10, completion_tokens=50, metadata={"key": "value"} ) assert result.text == "生成的文本" assert result.token_ids == [1, 2, 3] assert result.prompt == "提示" assert result.latency_ms == 150.5 assert result.tokens_per_second == 33.3 assert result.prompt_tokens == 10 assert result.completion_tokens == 50 assert result.metadata == {"key": "value"} def test_performance_stats_properties(self): """测试性能统计属性计算""" stats = PerformanceStats( total_requests=10, total_prompt_tokens=100, total_completion_tokens=500, total_latency_ms=1000.0, peak_vram_mb=2048.0, wall_time_s=5.0 ) assert stats.avg_latency_ms == 100.0 assert stats.throughput_tokens_per_s == 100.0 assert stats.requests_per_s == 2.0 def test_performance_stats_zero_requests(self): """测试零请求时的性能统计""" stats = PerformanceStats() assert stats.avg_latency_ms == 0.0 assert stats.throughput_tokens_per_s == 0.0 assert stats.requests_per_s == 0.0 def test_performance_stats_summary(self): """测试性能统计摘要生成""" stats = PerformanceStats( total_requests=5, total_prompt_tokens=50, total_completion_tokens=250, total_latency_ms=500.0, peak_vram_mb=1024.0, wall_time_s=2.5 ) summary = stats.summary() assert "性能统计摘要" in summary assert "5" in summary # total_requests assert "50" in summary # prompt tokens assert "250" in summary # completion tokens assert "1024.0" in summary # peak VRAM class TestPerformanceMonitor: """性能监控器测试""" def test_monitor_start_end(self): """测试监控器启动和结束""" monitor = PerformanceMonitor() monitor.start() time.sleep(0.1) monitor.end() assert monitor.stats.wall_time_s > 0 assert monitor.stats.wall_time_s >= 0.1 def test_monitor_record_request(self): """测试记录请求""" monitor = PerformanceMonitor() monitor.record_request(prompt_tokens=10, completion_tokens=50, latency_ms=100) monitor.record_request(prompt_tokens=20, completion_tokens=60, latency_ms=150) assert monitor.stats.total_requests == 2 assert monitor.stats.total_prompt_tokens == 30 assert monitor.stats.total_completion_tokens == 110 assert monitor.stats.total_latency_ms == 250 def test_monitor_update_peak_vram(self): """测试更新峰值显存""" monitor = PerformanceMonitor() with patch("hos_optimizer.inference.get_gpu_memory_usage_mb") as mock_vram: mock_vram.return_value = 2048.0 monitor.update_peak_vram() assert monitor.stats.peak_vram_mb == 2048.0 # 更高的值应该更新 mock_vram.return_value = 3072.0 monitor.update_peak_vram() assert monitor.stats.peak_vram_mb == 3072.0 # 更低的值不应该更新 mock_vram.return_value = 1024.0 monitor.update_peak_vram() assert monitor.stats.peak_vram_mb == 3072.0 def test_monitor_reset(self): """测试重置监控器""" monitor = PerformanceMonitor() monitor.start() monitor.record_request(10, 50, 100) monitor.end() monitor.reset() assert monitor.stats.total_requests == 0 assert monitor.stats.total_latency_ms == 0.0 assert monitor._start_time is None assert monitor._end_time is None class TestGPUUtilities: """GPU 工具函数测试""" def test_get_gpu_memory_usage_no_cuda(self): """测试无 CUDA 时的显存使用""" with patch("torch.cuda.is_available", return_value=False): usage = get_gpu_memory_usage_mb() assert usage == 0.0 def test_get_gpu_memory_usage_with_cuda(self): """测试有 CUDA 时的显存使用""" with patch("torch.cuda.is_available", return_value=True), \ patch("torch.cuda.device_count", return_value=1), \ patch("torch.cuda.max_memory_allocated", return_value=2 * 1024 ** 3): usage = get_gpu_memory_usage_mb() assert usage > 0 def test_get_total_gpu_memory_no_cuda(self): """测试无 CUDA 时的总显存""" with patch("torch.cuda.is_available", return_value=False): total = get_total_gpu_memory_mb() assert total == 0.0 def test_get_total_gpu_memory_with_cuda(self): """测试有 CUDA 时的总显存""" mock_props = MagicMock() mock_props.total_mem = 8 * 1024 ** 3 with patch("torch.cuda.is_available", return_value=True), \ patch("torch.cuda.get_device_properties", return_value=mock_props): total = get_total_gpu_memory_mb() assert total > 0 class TestBackendDetection: """后端检测测试""" def test_detect_best_backend_vllm(self): """测试检测到 vLLM 作为最优后端""" with patch("hos_optimizer.inference.get_total_gpu_memory_mb", return_value=8192), \ patch("hos_optimizer.inference._check_import") as mock_check: mock_check.side_effect = lambda name: name == "vllm" backend = detect_best_backend() assert backend == "vllm" def test_detect_best_backend_sglang(self): """测试检测到 SGLang 作为最优后端""" with patch("hos_optimizer.inference.get_total_gpu_memory_mb", return_value=4096), \ patch("hos_optimizer.inference._check_import") as mock_check: mock_check.side_effect = lambda name: name == "sglang" backend = detect_best_backend() assert backend == "sglang" def test_detect_best_backend_llama_cpp(self): """测试检测到 llama-cpp 作为最优后端""" with patch("hos_optimizer.inference.get_total_gpu_memory_mb", return_value=0), \ patch("hos_optimizer.inference._check_import") as mock_check: mock_check.side_effect = lambda name: name == "llama_cpp" backend = detect_best_backend() assert backend == "llama_cpp" def test_detect_best_backend_no_backend_available(self): """测试没有可用后端时抛出异常""" with patch("hos_optimizer.inference.get_total_gpu_memory_mb", return_value=0), \ patch("hos_optimizer.inference._check_import", return_value=False): with pytest.raises(RuntimeError) as exc_info: detect_best_backend() assert "没有可用的推理后端" in str(exc_info.value) class TestLlamaCppBackend: """llama-cpp 后端测试""" def test_llama_cpp_initialization(self): """测试 llama-cpp 后端初始化""" backend = LlamaCppBackend( model_path="/path/to/model.gguf", n_gpu_layers=32, n_ctx=2048, n_threads=4 ) assert backend.model_path == "/path/to/model.gguf" assert backend.n_gpu_layers == 32 assert backend.n_ctx == 2048 assert backend.n_threads == 4 assert backend._loaded is False def test_llama_cpp_load_success(self): """测试 llama-cpp 模型加载成功""" with patch("llama_cpp.Llama") as mock_llama: mock_model = MagicMock() mock_llama.return_value = mock_model backend = LlamaCppBackend("/path/to/model.gguf") with patch("hos_optimizer.inference.get_total_gpu_memory_mb", return_value=8192): backend.load() assert backend._loaded is True assert backend._model is not None def test_llama_cpp_load_import_error(self): """测试 llama-cpp 导入失败""" backend = LlamaCppBackend("/path/to/model.gguf") with patch.dict("sys.modules", {"llama_cpp": None}): with pytest.raises(ImportError) as exc_info: backend.load() assert "llama-cpp-python 未安装" in str(exc_info.value) def test_llama_cpp_generate_not_loaded(self): """测试未加载时生成抛出异常""" backend = LlamaCppBackend("/path/to/model.gguf") request = InferenceRequest(prompt="测试") with pytest.raises(RuntimeError) as exc_info: backend.generate(request) assert "模型未加载" in str(exc_info.value) def test_llama_cpp_generate_success(self): """测试 llama-cpp 生成成功""" backend = LlamaCppBackend("/path/to/model.gguf") backend._loaded = True backend._model = MagicMock() # Mock 模型输出 backend._model.return_value = { "choices": [{"text": "生成的文本"}], "usage": {"prompt_tokens": 10, "completion_tokens": 20} } request = InferenceRequest(prompt="测试", max_tokens=50) result = backend.generate(request) assert isinstance(result, InferenceResult) assert result.text == "生成的文本" assert result.prompt_tokens == 10 assert result.completion_tokens == 20 def test_llama_cpp_shutdown(self): """测试 llama-cpp 关闭""" backend = LlamaCppBackend("/path/to/model.gguf") backend._loaded = True backend._model = MagicMock() backend.shutdown() assert backend._loaded is False assert backend._model is None class TestVLLMBackend: """vLLM 后端测试""" def test_vllm_initialization(self): """测试 vLLM 后端初始化""" backend = VLLMBackend( model_path="/path/to/model", gpu_memory_utilization=0.85, max_model_len=2048, max_num_seqs=64 ) assert backend.model_path == "/path/to/model" assert backend.gpu_memory_utilization == 0.85 assert backend.max_model_len == 2048 assert backend.max_num_seqs == 64 assert backend._loaded is False def test_vllm_load_success(self): """测试 vLLM 模型加载成功""" with patch("vllm.LLM") as mock_llm: mock_engine = MagicMock() mock_llm.return_value = mock_engine backend = VLLMBackend("/path/to/model") backend.load() assert backend._loaded is True assert backend._llm is not None def test_vllm_load_import_error(self): """测试 vLLM 导入失败""" backend = VLLMBackend("/path/to/model") with patch.dict("sys.modules", {"vllm": None}): with pytest.raises(ImportError) as exc_info: backend.load() assert "vLLM 未安装" in str(exc_info.value) def test_vllm_generate_not_loaded(self): """测试未加载时生成抛出异常""" backend = VLLMBackend("/path/to/model") request = InferenceRequest(prompt="测试") with pytest.raises(RuntimeError) as exc_info: backend.generate(request) assert "vLLM 引擎未加载" in str(exc_info.value) def test_vllm_generate_success(self): """测试 vLLM 生成成功""" backend = VLLMBackend("/path/to/model") backend._loaded = True backend._llm = MagicMock() # Mock 输出 mock_output = MagicMock() mock_output.outputs = [MagicMock(text="生成的文本", token_ids=[1, 2, 3])] mock_output.prompt_token_ids = [10, 20, 30] backend._llm.generate.return_value = [mock_output] request = InferenceRequest(prompt="测试", max_tokens=50) with patch("vllm.SamplingParams"): result = backend.generate(request) assert isinstance(result, InferenceResult) assert result.text == "生成的文本" assert result.token_ids == [1, 2, 3] assert result.prompt_tokens == 3 def test_vllm_batch_generate(self): """测试 vLLM 批量生成""" backend = VLLMBackend("/path/to/model") backend._loaded = True backend._llm = MagicMock() # Mock 批量输出 mock_output1 = MagicMock() mock_output1.outputs = [MagicMock(text="文本1", token_ids=[1, 2])] mock_output1.prompt_token_ids = [10, 20] mock_output1.prompt = "提示1" mock_output2 = MagicMock() mock_output2.outputs = [MagicMock(text="文本2", token_ids=[3, 4])] mock_output2.prompt_token_ids = [30, 40] mock_output2.prompt = "提示2" backend._llm.generate.return_value = [mock_output1, mock_output2] requests = [ InferenceRequest(prompt="提示1"), InferenceRequest(prompt="提示2") ] with patch("vllm.SamplingParams"): results = backend.generate_batch(requests) assert len(results) == 2 assert results[0].text == "文本1" assert results[1].text == "文本2" def test_vllm_shutdown(self): """测试 vLLM 关闭""" backend = VLLMBackend("/path/to/model") backend._loaded = True backend._llm = MagicMock() with patch("torch.cuda.is_available", return_value=True), \ patch("torch.cuda.empty_cache"): backend.shutdown() assert backend._loaded is False assert backend._llm is None class TestSGLangBackend: """SGLang 后端测试""" def test_sglang_initialization(self): """测试 SGLang 后端初始化""" backend = SGLangBackend( model_path="/path/to/model", mem_fraction_static=0.85, context_length=2048, tp_size=1 ) assert backend.model_path == "/path/to/model" assert backend.mem_fraction_static == 0.85 assert backend.context_length == 2048 assert backend.tp_size == 1 assert backend._loaded is False def test_sglang_load_success(self): """测试 SGLang 模型加载成功""" with patch("sglang.Runtime") as mock_runtime: mock_rt = MagicMock() mock_runtime.return_value = mock_rt backend = SGLangBackend("/path/to/model") backend.load() assert backend._loaded is True assert backend._runtime is not None def test_sglang_load_import_error(self): """测试 SGLang 导入失败""" backend = SGLangBackend("/path/to/model") with patch.dict("sys.modules", {"sglang": None}): with pytest.raises(ImportError) as exc_info: backend.load() assert "SGLang 未安装" in str(exc_info.value) def test_sglang_generate_not_loaded(self): """测试未加载时生成抛出异常""" backend = SGLangBackend("/path/to/model") request = InferenceRequest(prompt="测试") with pytest.raises(RuntimeError) as exc_info: backend.generate(request) assert "SGLang 引擎未加载" in str(exc_info.value) def test_sglang_generate_success(self): """测试 SGLang 生成成功""" backend = SGLangBackend("/path/to/model") backend._loaded = True backend._runtime = MagicMock() # Mock 输出 backend._runtime.generate.return_value = [{ "text": "生成的文本", "token_ids": [1, 2, 3] }] request = InferenceRequest(prompt="测试", max_tokens=50) result = backend.generate(request) assert isinstance(result, InferenceResult) assert result.text == "生成的文本" assert result.token_ids == [1, 2, 3] def test_sglang_generate_with_json_schema(self): """测试 SGLang 带 JSON Schema 约束生成""" backend = SGLangBackend("/path/to/model") backend._loaded = True backend._runtime = MagicMock() backend._runtime.generate.return_value = [{ "text": '{"key": "value"}', "token_ids": [1, 2, 3] }] request = InferenceRequest( prompt="测试", json_schema={"type": "object"} ) result = backend.generate(request) assert result.metadata.get("constrained") is True def test_sglang_shutdown(self): """测试 SGLang 关闭""" backend = SGLangBackend("/path/to/model") backend._loaded = True backend._runtime = MagicMock() with patch("torch.cuda.is_available", return_value=True), \ patch("torch.cuda.empty_cache"): backend.shutdown() assert backend._loaded is False assert backend._runtime is None class TestUnifiedInferenceEngine: """统一推理引擎测试""" def test_unified_engine_initialization_auto_backend(self): """测试统一引擎自动选择后端""" with patch("hos_optimizer.inference.detect_best_backend", return_value="vllm"), \ patch("hos_optimizer.inference.VLLMBackend") as mock_backend_cls: mock_backend = MagicMock() mock_backend_cls.return_value = mock_backend engine = UnifiedInferenceEngine( model_path="/path/to/model", auto_load=False ) assert engine.backend_name == "vllm" assert engine._backend is not None def test_unified_engine_initialization_specific_backend(self): """测试统一引擎指定后端""" with patch("hos_optimizer.inference.LlamaCppBackend") as mock_backend_cls: mock_backend = MagicMock() mock_backend_cls.return_value = mock_backend engine = UnifiedInferenceEngine( model_path="/path/to/model.gguf", backend="llama-cpp", auto_load=False ) assert engine.backend_name == "llama_cpp" def test_unified_engine_invalid_backend(self): """测试统一引擎无效后端""" with pytest.raises(ValueError) as exc_info: UnifiedInferenceEngine( model_path="/path/to/model", backend="invalid_backend", auto_load=False ) assert "不支持的后端" in str(exc_info.value) def test_unified_engine_generate(self): """测试统一引擎生成""" with patch("hos_optimizer.inference.VLLMBackend") as mock_backend_cls: mock_backend = MagicMock() mock_result = InferenceResult(text="结果", prompt_tokens=10, completion_tokens=20) mock_backend.generate.return_value = mock_result mock_backend_cls.return_value = mock_backend engine = UnifiedInferenceEngine( model_path="/path/to/model", backend="vllm", auto_load=False ) result = engine.generate("测试提示", max_tokens=100) assert result.text == "结果" mock_backend.generate.assert_called_once() def test_unified_engine_generate_batch(self): """测试统一引擎批量生成""" with patch("hos_optimizer.inference.VLLMBackend") as mock_backend_cls: mock_backend = MagicMock() mock_results = [ InferenceResult(text="结果1", prompt_tokens=10, completion_tokens=20), InferenceResult(text="结果2", prompt_tokens=15, completion_tokens=25) ] mock_backend.generate_batch.return_value = mock_results mock_backend_cls.return_value = mock_backend engine = UnifiedInferenceEngine( model_path="/path/to/model", backend="vllm", auto_load=False ) results = engine.generate_batch(["提示1", "提示2"]) assert len(results) == 2 mock_backend.generate_batch.assert_called_once() def test_unified_engine_get_stats(self): """测试统一引擎获取性能统计""" with patch("hos_optimizer.inference.VLLMBackend") as mock_backend_cls: mock_backend = MagicMock() mock_stats = PerformanceStats(total_requests=5) mock_backend.get_performance_stats.return_value = mock_stats mock_backend_cls.return_value = mock_backend engine = UnifiedInferenceEngine( model_path="/path/to/model", backend="vllm", auto_load=False ) stats = engine.get_stats() assert stats.total_requests == 5 def test_unified_engine_shutdown(self): """测试统一引擎关闭""" with patch("hos_optimizer.inference.VLLMBackend") as mock_backend_cls: mock_backend = MagicMock() mock_backend_cls.return_value = mock_backend engine = UnifiedInferenceEngine( model_path="/path/to/model", backend="vllm", auto_load=False ) engine.shutdown() mock_backend.shutdown.assert_called_once() if __name__ == "__main__": pytest.main([__file__, "-v"])