fix(inference): chat 每次调用新建 httpx client,修复同步门禁 Event loop is closed
This commit is contained in:
@@ -107,3 +107,4 @@
|
|||||||
| 2026-08-13 23:11 | Agent 实现 | 接通真实 LLM 引擎工厂(P5-T10 门禁接线):新增 inference/factory.py,orchestrator/qa_loop 接入,脚本校验适配,补工厂测试 | src/genesis/inference/factory.py; src/genesis/writer/orchestrator.py; src/genesis/qa/qa_loop.py; scripts/run_phase5_slice.py; tests/test_inference_factory.py | hy3-free |
|
| 2026-08-13 23:11 | Agent 实现 | 接通真实 LLM 引擎工厂(P5-T10 门禁接线):新增 inference/factory.py,orchestrator/qa_loop 接入,脚本校验适配,补工厂测试 | src/genesis/inference/factory.py; src/genesis/writer/orchestrator.py; src/genesis/qa/qa_loop.py; scripts/run_phase5_slice.py; tests/test_inference_factory.py | hy3-free |
|
||||||
| 2026-08-13 23:20 | 测试验证 | P5-T10 引擎工厂接线:补 engine=None 委托工厂测试(orchestrator/qa_loop),全量 296 passed / 99.04% | tests/test_phase5_writer_orchestrator.py; tests/test_phase5_qa_loop.py | hy3-free |
|
| 2026-08-13 23:20 | 测试验证 | P5-T10 引擎工厂接线:补 engine=None 委托工厂测试(orchestrator/qa_loop),全量 296 passed / 99.04% | tests/test_phase5_writer_orchestrator.py; tests/test_phase5_qa_loop.py | hy3-free |
|
||||||
| 2026-08-13 23:35 | 测试验证 | P5-T10 门禁诊断:修复 WriterGenerationError 吞掉底层 LLM 错误(如 401 详情),补透传测试 | src/genesis/writer/writer_agent.py; tests/test_phase5_writer_agent.py | hy3-free |
|
| 2026-08-13 23:35 | 测试验证 | P5-T10 门禁诊断:修复 WriterGenerationError 吞掉底层 LLM 错误(如 401 详情),补透传测试 | src/genesis/writer/writer_agent.py; tests/test_phase5_writer_agent.py | hy3-free |
|
||||||
|
| 2026-08-13 23:50 | Agent 实现 | 修复 HttpLLMClient 在同步门禁中多次 asyncio.run 复用已关闭事件循环致 Event loop is closed:chat 改为每次调用新建 client(保留 async with 协议)| src/genesis/inference/client.py | hy3-free |
|
||||||
@@ -50,6 +50,7 @@ class HttpLLMClient:
|
|||||||
self._api_key = api_key
|
self._api_key = api_key
|
||||||
self._timeout_sec = timeout_sec
|
self._timeout_sec = timeout_sec
|
||||||
self._retry_backoff = retry_backoff
|
self._retry_backoff = retry_backoff
|
||||||
|
self._transport = transport
|
||||||
self._client = httpx.AsyncClient(timeout=timeout_sec, transport=transport)
|
self._client = httpx.AsyncClient(timeout=timeout_sec, transport=transport)
|
||||||
|
|
||||||
async def __aenter__(self) -> HttpLLMClient:
|
async def __aenter__(self) -> HttpLLMClient:
|
||||||
@@ -79,42 +80,46 @@ class HttpLLMClient:
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
attempts = 1 + len(self._retry_backoff)
|
# 每次调用新建 httpx.AsyncClient,绑定到当前事件循环。
|
||||||
last_error: Exception | None = None
|
# 兼容「同步门禁中多次 asyncio.run 驱动 async chat_structured」场景,
|
||||||
for attempt in range(attempts):
|
# 避免复用 __init__ 中创建的 client 因首个循环关闭而报 Event loop is closed。
|
||||||
if attempt > 0:
|
async with httpx.AsyncClient(timeout=self._timeout_sec, transport=self._transport) as client:
|
||||||
await asyncio.sleep(self._retry_backoff[attempt - 1])
|
attempts = 1 + len(self._retry_backoff)
|
||||||
try:
|
last_error: Exception | None = None
|
||||||
resp = await self._client.post(url, json=payload, headers=headers)
|
for attempt in range(attempts):
|
||||||
except httpx.TimeoutException as exc:
|
if attempt > 0:
|
||||||
last_error = exc
|
await asyncio.sleep(self._retry_backoff[attempt - 1])
|
||||||
continue
|
try:
|
||||||
except httpx.HTTPError as exc:
|
resp = await client.post(url, json=payload, headers=headers)
|
||||||
last_error = exc
|
except httpx.TimeoutException as exc:
|
||||||
continue
|
last_error = exc
|
||||||
|
continue
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
last_error = exc
|
||||||
|
continue
|
||||||
|
|
||||||
if resp.status_code >= 500:
|
if resp.status_code >= 500:
|
||||||
last_error = LLMNetworkError(f"LLM 5xx: {resp.status_code}")
|
last_error = LLMNetworkError(f"LLM 5xx: {resp.status_code}")
|
||||||
continue
|
continue
|
||||||
if resp.status_code >= 400:
|
if resp.status_code >= 400:
|
||||||
raise LLMNetworkError(f"LLM HTTP {resp.status_code}: {resp.text[:200]}")
|
raise LLMNetworkError(f"LLM HTTP {resp.status_code}: {resp.text[:200]}")
|
||||||
if not (200 <= resp.status_code < 300):
|
if not (200 <= resp.status_code < 300):
|
||||||
# 3xx 重定向不自动跟随,不得误判为成功
|
# 3xx 重定向不自动跟随,不得误判为成功
|
||||||
raise LLMNetworkError(f"LLM HTTP {resp.status_code}: {resp.text[:200]}")
|
raise LLMNetworkError(f"LLM HTTP {resp.status_code}: {resp.text[:200]}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
content = data["choices"][0]["message"]["content"]
|
content = data["choices"][0]["message"]["content"]
|
||||||
except (json.JSONDecodeError, KeyError, IndexError, TypeError) as exc:
|
except (json.JSONDecodeError, KeyError, IndexError, TypeError) as exc:
|
||||||
# 2xx 但响应结构损坏(非 JSON / 缺字段)→ 结构化错误,不重试
|
# 2xx 但响应结构损坏(非 JSON / 缺字段)→ 结构化错误,不重试
|
||||||
raise LLMResponseError(f"LLM 响应结构损坏: {exc}") from exc
|
raise LLMResponseError(f"LLM 响应结构损坏: {exc}") from exc
|
||||||
usage_raw = data.get("usage", {})
|
usage_raw = data.get("usage", {})
|
||||||
usage = TokenUsage(
|
usage = TokenUsage(
|
||||||
input_tokens=usage_raw.get("prompt_tokens", 0),
|
input_tokens=usage_raw.get("prompt_tokens", 0),
|
||||||
output_tokens=usage_raw.get("completion_tokens", 0),
|
output_tokens=usage_raw.get("completion_tokens", 0),
|
||||||
)
|
)
|
||||||
return content, usage
|
return content, usage
|
||||||
|
|
||||||
if isinstance(last_error, httpx.TimeoutException):
|
if isinstance(last_error, httpx.TimeoutException):
|
||||||
raise LLMTimeoutError(f"LLM 超时({self._timeout_sec}s)") from last_error
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user