From add7b1503cfa6d15fac31e89100010ac83bb7a6b Mon Sep 17 00:00:00 2001 From: lhl Date: Sun, 9 Aug 2026 08:08:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20HttpLLMClient=EF=BC=88httpx=20+=20?= =?UTF-8?q?=E9=87=8D=E8=AF=95=E9=80=80=E9=81=BF/=E8=B6=85=E6=97=B6/?= =?UTF-8?q?=E9=89=B4=E6=9D=83=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- _AI_USAGE_LOG.md | 1 + src/genesis/inference/client.py | 96 ++++++++++++++++++++++++++++++ tests/test_inference_client.py | 102 ++++++++++++++++++++++++++++++++ 3 files changed, 199 insertions(+) create mode 100644 src/genesis/inference/client.py create mode 100644 tests/test_inference_client.py diff --git a/_AI_USAGE_LOG.md b/_AI_USAGE_LOG.md index cc02662..b60c4a9 100644 --- a/_AI_USAGE_LOG.md +++ b/_AI_USAGE_LOG.md @@ -45,3 +45,4 @@ | 2026-08-09 05:53 | Agent 实现 | 里程碑3.1 Task1 实现:推理引擎数据模型与异常层。新建 src/genesis/inference/(__init__/types/exceptions:TokenUsage、ChatMessage、ChatResult、StructuredResult、Prompt 五 dataclass 与 LLMError 体系五异常,__init__ 暂不导入 engine/client 防循环);pyproject.toml 增加 httpx>=0.28/jinja2>=3.1 依赖;tests/test_inference_types.py(6 用例)与 tests/test_inference_errors.py(2 用例);TDD 验证 RED(ModuleNotFoundError: 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.py(approximate_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 验证 RED(ModuleNotFoundError: No module named 'genesis.inference.token')→ 聚焦 7 passed,token.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.py(PromptRegistry:register/get/list_versions/render,version=None 取最新,jinja2 渲染,name/name@version 缺失抛 KeyError);tests/test_inference_prompt_registry.py 按 brief 5 用例 + 补 1 用例(指定不存在的 version 抛 KeyError,覆盖 get 中 key not in templates 分支);TDD 验证 RED(ModuleNotFoundError: 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.py(LLMClient Protocol + HttpLLMClient:api_key 空抛 LLMNotConfiguredError,POST {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 验证 RED(ModuleNotFoundError: 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 | diff --git a/src/genesis/inference/client.py b/src/genesis/inference/client.py new file mode 100644 index 0000000..bfc5163 --- /dev/null +++ b/src/genesis/inference/client.py @@ -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 \ No newline at end of file diff --git a/tests/test_inference_client.py b/tests/test_inference_client.py new file mode 100644 index 0000000..1c2d732 --- /dev/null +++ b/tests/test_inference_client.py @@ -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 不重试 \ No newline at end of file