feat: HttpLLMClient(httpx + 重试退避/超时/鉴权)

This commit is contained in:
lhl
2026-08-09 08:08:04 +08:00
parent 59c1ea7abe
commit add7b1503c
3 changed files with 199 additions and 0 deletions
+1
View File
@@ -45,3 +45,4 @@
| 2026-08-09 05:53 | Agent 实现 | 里程碑3.1 Task1 实现:推理引擎数据模型与异常层。新建 src/genesis/inference/__init__/types/exceptionsTokenUsage、ChatMessage、ChatResult、StructuredResult、Prompt 五 dataclass 与 LLMError 体系五异常,__init__ 暂不导入 engine/client 防循环);pyproject.toml 增加 httpx>=0.28/jinja2>=3.1 依赖;tests/test_inference_types.py6 用例)与 tests/test_inference_errors.py2 用例);TDD 验证 REDModuleNotFoundError: No module named 'genesis.inference')→ GREEN(聚焦 8 passed);pytest 全量 79 passed 覆盖 100.00%608 stmts/140 br),fail_under=99 达标;提交见 git log | src/genesis/inference/__init__.py, src/genesis/inference/types.py, src/genesis/inference/exceptions.py, pyproject.toml, tests/test_inference_types.py, tests/test_inference_errors.py, _AI_USAGE_LOG.md | deepseek-v4-flash-free |
| 2026-08-09 05:57 | Agent 实现 | 里程碑3.1 Task2 实现:推理引擎 token 估算模块。新建 src/genesis/inference/token.pyapproximate_token_count 每 4 字符≈1 token 最少 1、_tiktoken_estimator tiktoken 编码未安装返回 None、make_estimator backend="tiktoken" 默认优先 tiktoken 缺失回落 approximate);tests/test_inference_token.py 按 brief 5 用例 + 补充 2 用例(假 tiktoken 成功路径、encoding 抛异常回落)覆盖 tiktoken 成功/异常两分支,保证全量覆盖不回落;TDD 验证 REDModuleNotFoundError: No module named 'genesis.inference.token')→ 聚焦 7 passedtoken.py 100%pytest 全量 86 passed 覆盖 100.00%626 stmts/142 br);提交见 git log | src/genesis/inference/token.py, tests/test_inference_token.py, _AI_USAGE_LOG.md | deepseek-v4-flash-free |
| 2026-08-09 08:02 | Agent 实现 | 里程碑3.1 Task3 实现:Prompt 注册表。新建 src/genesis/inference/prompt_registry.pyPromptRegistryregister/get/list_versions/renderversion=None 取最新,jinja2 渲染,name/name@version 缺失抛 KeyError);tests/test_inference_prompt_registry.py 按 brief 5 用例 + 补 1 用例(指定不存在的 version 抛 KeyError,覆盖 get 中 key not in templates 分支);TDD 验证 REDModuleNotFoundError: No module named genesis.inference.prompt_registry)→ GREEN(聚焦 6 passed);pytest 全量 92 passed 覆盖 100.00%653 stmts/150 br),fail_under=99 达标 | src/genesis/inference/prompt_registry.py, tests/test_inference_prompt_registry.py, _AI_USAGE_LOG.md | deepseek-v4-flash-free |
| 2026-08-09 09:40 | Agent 实现 | 里程碑3.1 Task4 实现:LLM 客户端。新建 src/genesis/inference/client.pyLLMClient Protocol + HttpLLMClientapi_key 空抛 LLMNotConfiguredErrorPOST {base_url}/v1/chat/completions Bearer 鉴权,指数退避重试 5xx/网络错误(attempts=1+len(retry_backoff)),超时抛 LLMTimeoutError,重试耗尽/4xx 抛 LLMNetworkError);tests/test_inference_client.py 按 brief 6 用例(结构协议兼容/成功/无key/超时/5xx重试耗尽/4xx不重试)+ 补 1 用例(非超时 ConnectError 走 httpx.HTTPError 分支覆盖剩余分支);TDD 验证 REDModuleNotFoundError: No module named 'genesis.inference.client')→ GREEN(聚焦 7 passed);pytest 全量 99 passed 覆盖 100.00%699 stmts/162 br),fail_under=99 达标;提交见 git log | src/genesis/inference/client.py, tests/test_inference_client.py, _AI_USAGE_LOG.md | deepseek-v4-flash-free |
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
import time
from typing import Protocol, Sequence
import httpx
from .exceptions import LLMNetworkError, LLMNotConfiguredError, LLMTimeoutError
from .types import ChatMessage, TokenUsage
class LLMClient(Protocol):
"""LLM 调用适配器(可注入替换为 Fake)。"""
def chat(
self,
*,
model: str,
messages: list[ChatMessage],
temperature: float,
max_tokens: int,
) -> tuple[str, TokenUsage]: ...
class HttpLLMClient:
"""OpenAI Chat Completions 兼容的 httpx 实现;支持重试(指数退避)。"""
def __init__(
self,
*,
base_url: str,
api_key: str,
timeout_sec: float = 60.0,
retry_backoff: Sequence[float] = (1.0, 3.0, 7.0),
transport: httpx.BaseTransport | None = None,
) -> None:
if not api_key:
raise LLMNotConfiguredError("LLM API key 未配置(DEEPSEEK_API_KEY / LLM_BASE_URL")
self._base_url = base_url.rstrip("/")
self._api_key = api_key
self._timeout_sec = timeout_sec
self._retry_backoff = retry_backoff
self._client = httpx.Client(timeout=timeout_sec, transport=transport)
def chat(
self,
*,
model: str,
messages: list[ChatMessage],
temperature: float,
max_tokens: int,
) -> tuple[str, TokenUsage]:
url = f"{self._base_url}/v1/chat/completions"
payload = {
"model": model,
"messages": [{"role": m.role, "content": m.content} for m in messages],
"temperature": temperature,
"max_tokens": max_tokens,
}
headers = {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
}
attempts = 1 + len(self._retry_backoff)
last_error: Exception | None = None
for attempt in range(attempts):
if attempt > 0:
time.sleep(self._retry_backoff[attempt - 1])
try:
resp = self._client.post(url, json=payload, headers=headers)
except httpx.TimeoutException as exc:
last_error = exc
continue
except httpx.HTTPError as exc:
last_error = exc
continue
if resp.status_code >= 500:
last_error = LLMNetworkError(f"LLM 5xx: {resp.status_code}")
continue
if resp.status_code >= 400:
raise LLMNetworkError(f"LLM HTTP {resp.status_code}: {resp.text[:200]}")
data = resp.json()
content = data["choices"][0]["message"]["content"]
usage_raw = data.get("usage", {})
usage = TokenUsage(
input_tokens=usage_raw.get("prompt_tokens", 0),
output_tokens=usage_raw.get("completion_tokens", 0),
)
return content, usage
if isinstance(last_error, httpx.TimeoutException):
raise LLMTimeoutError(f"LLM 超时({self._timeout_sec}s") from last_error
raise LLMNetworkError(f"LLM 调用失败(重试耗尽): {last_error}") from last_error
+102
View File
@@ -0,0 +1,102 @@
import pytest
import httpx
from genesis.inference.client import HttpLLMClient, LLMClient
from genesis.inference.exceptions import LLMNetworkError, LLMNotConfiguredError, LLMTimeoutError
from genesis.inference.types import ChatMessage
def make_client(handler, *, api_key="sk-test", retry_backoff=(0.0, 0.0)):
return HttpLLMClient(
base_url="https://api.test.local",
api_key=api_key,
timeout_sec=0.1,
retry_backoff=retry_backoff,
transport=httpx.MockTransport(handler),
)
def _ok_handler(request):
return httpx.Response(200, json={
"choices": [{"message": {"content": "Hello"}}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5},
})
def test_client_implements_protocol():
# 结构性断言:HttpLLMClient.chat 的关键字参数签名与 LLMClient Protocol 一致
import inspect
proto_params = set(inspect.signature(LLMClient.chat).parameters)
impl_params = set(inspect.signature(HttpLLMClient.chat).parameters)
assert proto_params.issubset(impl_params)
def test_chat_success():
client = make_client(_ok_handler)
text, usage = client.chat(
model="deepseek-chat",
messages=[ChatMessage(role="user", content="hi")],
temperature=0.2,
max_tokens=100,
)
assert text == "Hello"
assert usage.input_tokens == 10 and usage.output_tokens == 5
def test_chat_requires_api_key():
with pytest.raises(LLMNotConfiguredError):
HttpLLMClient(base_url="https://x", api_key="")
def test_chat_timeout():
def slow(request):
raise httpx.ReadTimeout("slow")
with pytest.raises(LLMTimeoutError):
make_client(slow, retry_backoff=(0, 0)).chat(
model="m", messages=[ChatMessage(role="user", content="x")],
temperature=0.2, max_tokens=100,
)
def test_chat_5xx_retry_then_network_error():
calls = {"n": 0}
def handler(request):
calls["n"] += 1
return httpx.Response(500, text="boom")
with pytest.raises(LLMNetworkError):
make_client(handler, retry_backoff=(0, 0)).chat(
model="m", messages=[ChatMessage(role="user", content="x")],
temperature=0.2, max_tokens=100,
)
assert calls["n"] == 3 # 初始 + 2 次退避重试(间隔 0/0.01)
def test_chat_network_error_retry_then_network_error():
# 非超时的网络错误走 httpx.HTTPError 分支,重试耗尽后抛 LLMNetworkError
def handler(request):
raise httpx.ConnectError("connection refused")
with pytest.raises(LLMNetworkError):
make_client(handler, retry_backoff=(0, 0)).chat(
model="m", messages=[ChatMessage(role="user", content="x")],
temperature=0.2, max_tokens=100,
)
def test_chat_4xx_no_retry():
calls = {"n": 0}
def handler(request):
calls["n"] += 1
return httpx.Response(429, text="rate limit")
with pytest.raises(LLMNetworkError):
make_client(handler).chat(
model="m", messages=[ChatMessage(role="user", content="x")],
temperature=0.2, max_tokens=100,
)
assert calls["n"] == 1 # 4xx 不重试