feat(inference): LLM 客户端全异步化(T8 架构审查整改)

- Issue9: client.py 由同步 httpx.Client 全异步化
  - LLMClient Protocol / HttpLLMClient.chat → async;httpx.AsyncClient + asyncio.sleep 退避
  - __enter__/__exit__ → __aenter__/__aexit__(async with 生命周期闭环)
- engine.py chat/chat_structured/_call 全部 async + await
- FakeLLMClient.chat → async;测试用 anyio pytest 插件转换(engine 32 + client 10 用例)
- 同步 inference-engine spec 与 milestone3 review 的 httpx 描述
- 全量 182 passed / 100.00%(987 stmts/252 br)
This commit is contained in:
lhl
2026-08-12 09:50:37 +08:00
parent 1f931228e7
commit 8239a37a99
8 changed files with 161 additions and 111 deletions
+17 -13
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
import asyncio
import json
import time
from typing import Protocol, Sequence
import httpx
@@ -16,9 +16,9 @@ from .types import ChatMessage, TokenUsage
class LLMClient(Protocol):
"""LLM 调用适配器(可注入替换为 Fake)。"""
"""LLM 调用适配器(可注入替换为 Fake)。T8 起为 async 接口。"""
def chat(
async def chat(
self,
*,
model: str,
@@ -29,7 +29,11 @@ class LLMClient(Protocol):
class HttpLLMClient:
"""OpenAI Chat Completions 兼容的 httpx 实现;支持重试(指数退避)。"""
"""OpenAI Chat Completions 兼容的 httpx 异步实现;支持重试(指数退避)。
T8(架构审查整改):由同步 httpx.Client 全异步化——async def chat、
httpx.AsyncClient、asyncio.sleep 退避、__aenter__/__aexit__ 生命周期闭环。
"""
def __init__(
self,
@@ -46,16 +50,16 @@ class HttpLLMClient:
self._api_key = api_key
self._timeout_sec = timeout_sec
self._retry_backoff = retry_backoff
self._client = httpx.Client(timeout=timeout_sec, transport=transport)
self._client = httpx.AsyncClient(timeout=timeout_sec, transport=transport)
def __enter__(self) -> HttpLLMClient:
"""支持 with 块:退出时自动关闭底层连接。"""
async def __aenter__(self) -> HttpLLMClient:
"""支持 async with 块:退出时自动关闭底层连接。"""
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
self._client.close()
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
await self._client.aclose()
def chat(
async def chat(
self,
*,
model: str,
@@ -79,9 +83,9 @@ class HttpLLMClient:
last_error: Exception | None = None
for attempt in range(attempts):
if attempt > 0:
time.sleep(self._retry_backoff[attempt - 1])
await asyncio.sleep(self._retry_backoff[attempt - 1])
try:
resp = self._client.post(url, json=payload, headers=headers)
resp = await self._client.post(url, json=payload, headers=headers)
except httpx.TimeoutException as exc:
last_error = exc
continue
@@ -113,4 +117,4 @@ class HttpLLMClient:
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
raise LLMNetworkError(f"LLM 调用失败(重试耗尽): {last_error}") from last_error
+6 -6
View File
@@ -87,7 +87,7 @@ class InferenceEngine:
return names
return ["deepseek-chat"]
def _call(
async def _call(
self,
*,
model: str,
@@ -100,7 +100,7 @@ class InferenceEngine:
ChatMessage(role="system", content=self._system_instruction),
ChatMessage(role="user", content=self._wrap_user_data(rendered)),
]
return self._client.chat(
return await self._client.chat(
model=model,
messages=messages,
temperature=temperature,
@@ -109,7 +109,7 @@ class InferenceEngine:
# ---------- 公开 ----------
def chat(
async def chat(
self,
*,
session_id: str,
@@ -129,7 +129,7 @@ class InferenceEngine:
last_error_code: str | None = None
for idx, name in enumerate(self._model_names(model)):
try:
text, usage = self._call(
text, usage = await self._call(
model=name, rendered=rendered,
temperature=temperature, max_tokens=max_tokens,
)
@@ -150,7 +150,7 @@ class InferenceEngine:
status="failed", error=last_error, error_code=last_error_code,
)
def chat_structured(
async def chat_structured(
self,
*,
session_id: str,
@@ -179,7 +179,7 @@ class InferenceEngine:
attempts += 1
for idx, name in enumerate(names):
try:
text, usage = self._call(
text, usage = await self._call(
model=name,
rendered=base_rendered,
temperature=0.0, max_tokens=4096,