118 lines
4.0 KiB
Python
118 lines
4.0 KiB
Python
"""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()
|