Files
Mutual_Fund/test_llm.py
T
2026-09-08 19:17:35 +08:00

118 lines
4.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""tool.llm 自检:本地 Mock OpenAI 兼容服务,验证双模式 URL、重试、备用模型降级、embedding。
运行:python test_llm.py(纯 assert,无框架;可通过 exitcode 判断)
"""
import asyncio
import http.server
import json
import threading
from config.settings import settings
from tool.llm import LLMFailError, LLMClient
INDEX = json.dumps({"index": 0}).encode()
class MockLLMServer(http.server.BaseHTTPRequestHandler):
"""OpenAI 兼容 Mock:chat/embeddings/tags。model=boom:1b 返回 500 用于测降级。"""
def log_message(self, *args):
pass
def _reply(self, code: int, obj: dict):
body = json.dumps(obj).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self): # ollama /api/tags 健康探测
self._reply(200, {"models": [{"name": "qwen2.5:7b"}]})
def do_POST(self):
n = int(self.headers.get("Content-Length", 0))
req = json.loads(self.rfile.read(n))
if self.path == "/v1/chat/completions":
model = req["model"]
if model.startswith("boom"):
self._reply(500, {"error": "model overloaded"})
return
self._reply(200, {"choices": [{"message": {"content": f"reply:{model}"}}]})
elif self.path == "/v1/embeddings":
texts = req["input"]
self._reply(200, {"data": [{"embedding": [float(i + 1)] * 4} for i in range(len(texts))]})
else:
self._reply(404, {"error": "not found"})
def start_server() -> tuple[threading.Thread, int]:
srv = http.server.ThreadingHTTPServer(("127.0.0.1", 0), MockLLMServer)
port = srv.server_address[1]
t = threading.Thread(target=srv.serve_forever, daemon=True)
t.start()
return t, port
def cfg(base_url: str, **kw):
"""从 .env 加载的 settings.llm 派生测试配置(只覆盖用例所需字段)。"""
kw.setdefault("mode", "ollama")
kw.setdefault("ollama_base", base_url)
kw.setdefault("max_retries", 2)
kw.setdefault("retry_backoff_sec", 0)
return settings.llm.model_copy(update=kw)
async def achat(c: LLMClient, text: str = "hi") -> str:
return await c.chat([{"role": "user", "content": text}])
def main():
_, port = start_server()
base = f"http://127.0.0.1:{port}"
# 1. ollama 模式 URL + 正常 chat
c = LLMClient(cfg(base, ollama_chat_model="ok:1b"))
assert c.is_ollama and c.base_url == f"{base}/v1", c.base_url
out = asyncio.run(achat(c))
assert out == "reply:ok:1b", out
# 2. 备用模型降级:主模型 500 → 自动切 fallback_chat_model
c2 = LLMClient(cfg(base, ollama_chat_model="boom:1b", ollama_embed_model="bge-m3",
fallback_chat_model="ok:1b"))
out = asyncio.run(achat(c2))
assert out == "reply:ok:1b", out
# 3. 全部失败 → LLMFailError(重试耗尽)
c3 = LLMClient(cfg(base, ollama_chat_model="boom:1b", max_retries=1))
try:
asyncio.run(achat(c3))
raise AssertionError("should have raised LLMFailError")
except LLMFailError:
pass
# 4. API 模式:端点/鉴权头/模型选择
c4 = LLMClient(settings.llm.model_copy(update={
"mode": "api", "api_base": base + "/v1", "api_key": "sk-test",
"api_chat_model": "gpt-x", "max_retries": 1,
}))
assert not c4.is_ollama and c4.base_url == f"{base}/v1"
assert c4._headers()["Authorization"] == "Bearer sk-test"
assert c4.chat_model == "gpt-x"
out = asyncio.run(achat(c4))
assert out == "reply:gpt-x", out
# 5. embedding 批量
c5 = LLMClient(cfg(base))
vecs = asyncio.run(c5.embed(["基金", "债券"]))
assert len(vecs) == 2 and len(vecs[0]) == 4, vecs
assert asyncio.run(c5.embed_one("货币")) == [1.0] * 4
# 6. 健康探测(ollama /api/tags)
asyncio.run(c5.check_health())
print("ALL LLM TESTS PASSED")
if __name__ == "__main__":
main()