Files
Mutual_Fund/test_llm.py
T

118 lines
4.0 KiB
Python
Raw Normal View History

2026-09-08 19:17:35 +08:00
"""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()