From 4da5f7087da78afa05c118df6b4e867fadb2e8a8 Mon Sep 17 00:00:00 2001 From: lhl Date: Fri, 14 Aug 2026 00:09:29 +0800 Subject: [PATCH] =?UTF-8?q?fix(inference):=20chat=20=E6=AF=8F=E6=AC=A1?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E6=96=B0=E5=BB=BA=20httpx=20client=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=90=8C=E6=AD=A5=E9=97=A8=E7=A6=81=20Event?= =?UTF-8?q?=20loop=20is=20closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- _AI_USAGE_LOG.md | 3 +- src/genesis/inference/client.py | 77 ++++++++++++++++++--------------- 2 files changed, 43 insertions(+), 37 deletions(-) diff --git a/_AI_USAGE_LOG.md b/_AI_USAGE_LOG.md index 38fef1a..5301ba6 100644 --- a/_AI_USAGE_LOG.md +++ b/_AI_USAGE_LOG.md @@ -106,4 +106,5 @@ | 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:35 | 测试验证 | P5-T10 门禁诊断:修复 WriterGenerationError 吞掉底层 LLM 错误(如 401 详情),补透传测试 | src/genesis/writer/writer_agent.py; tests/test_phase5_writer_agent.py | hy3-free | \ No newline at end of file +| 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 | \ No newline at end of file diff --git a/src/genesis/inference/client.py b/src/genesis/inference/client.py index c21af01..a046d85 100644 --- a/src/genesis/inference/client.py +++ b/src/genesis/inference/client.py @@ -50,6 +50,7 @@ class HttpLLMClient: self._api_key = api_key self._timeout_sec = timeout_sec self._retry_backoff = retry_backoff + self._transport = transport self._client = httpx.AsyncClient(timeout=timeout_sec, transport=transport) async def __aenter__(self) -> HttpLLMClient: @@ -79,42 +80,46 @@ class HttpLLMClient: "Content-Type": "application/json", } - attempts = 1 + len(self._retry_backoff) - last_error: Exception | None = None - for attempt in range(attempts): - if attempt > 0: - await asyncio.sleep(self._retry_backoff[attempt - 1]) - try: - resp = await 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 + # 每次调用新建 httpx.AsyncClient,绑定到当前事件循环。 + # 兼容「同步门禁中多次 asyncio.run 驱动 async chat_structured」场景, + # 避免复用 __init__ 中创建的 client 因首个循环关闭而报 Event loop is closed。 + async with httpx.AsyncClient(timeout=self._timeout_sec, transport=self._transport) as client: + attempts = 1 + len(self._retry_backoff) + last_error: Exception | None = None + for attempt in range(attempts): + if attempt > 0: + await asyncio.sleep(self._retry_backoff[attempt - 1]) + try: + resp = await 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]}") - if not (200 <= resp.status_code < 300): - # 3xx 重定向不自动跟随,不得误判为成功 - raise LLMNetworkError(f"LLM HTTP {resp.status_code}: {resp.text[:200]}") + 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]}") + if not (200 <= resp.status_code < 300): + # 3xx 重定向不自动跟随,不得误判为成功 + raise LLMNetworkError(f"LLM HTTP {resp.status_code}: {resp.text[:200]}") - try: - data = resp.json() - content = data["choices"][0]["message"]["content"] - except (json.JSONDecodeError, KeyError, IndexError, TypeError) as exc: - # 2xx 但响应结构损坏(非 JSON / 缺字段)→ 结构化错误,不重试 - raise LLMResponseError(f"LLM 响应结构损坏: {exc}") from exc - 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 + try: + data = resp.json() + content = data["choices"][0]["message"]["content"] + except (json.JSONDecodeError, KeyError, IndexError, TypeError) as exc: + # 2xx 但响应结构损坏(非 JSON / 缺字段)→ 结构化错误,不重试 + raise LLMResponseError(f"LLM 响应结构损坏: {exc}") from exc + 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 + 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