From 14062d1954a99f14a97d639e2d337f27bf365129 Mon Sep 17 00:00:00 2001 From: lhl Date: Sun, 9 Aug 2026 08:36:02 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=AE=A2=E6=88=B7=E7=AB=AF=E5=93=8D?= =?UTF-8?q?=E5=BA=94=E7=BB=93=E6=9E=84=E6=8D=9F=E5=9D=8F=E5=BD=92=E5=85=A5?= =?UTF-8?q?=20LLMResponseError=EF=BC=9B3xx=20=E4=B8=8D=E5=86=8D=E8=AF=AF?= =?UTF-8?q?=E5=88=A4=E6=88=90=E5=8A=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- _AI_USAGE_LOG.md | 5 +++- src/genesis/inference/client.py | 19 ++++++++++++--- tests/test_inference_client.py | 41 +++++++++++++++++++++++++++++++-- 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/_AI_USAGE_LOG.md b/_AI_USAGE_LOG.md index a871921..3aa22c4 100644 --- a/_AI_USAGE_LOG.md +++ b/_AI_USAGE_LOG.md @@ -46,4 +46,7 @@ | 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 | -| 2026-08-09 08:26 | Agent 实现 | 里程碑3.1 Task5 实现:InferenceEngine 编排器。新建 src/genesis/inference/engine.py(InferenceEngine:_render_prompt/_apply_truncation/_model_names(primary→fallback,无配置回退 deepseek-chat)/chat(渲染→token 超限回调→主模型→失败备用→status ok/fallback/failed)/chat_structured(schema 提示→逐次解析≤retry_count+1→parse_error+raw_text));tests/inference_helpers.py(FakeLLMClient 校验调)与 tests/test_inference_engine.py 按 brief 10 用例 + 补 5 用例(无 fallback、空 models、默认 registry/estimator、显式 model、truncate_cb 返回 None);2 处 brief 测试数值修正(truncate 内 max_context_tokens=3→2 因渲染 11 字≈3 token 不超限、fallback 断言 calls[-1]→calls[0] 因最后调用为备用模型);__init__.py 补导出 InferenceEngine/PromptRegistry;文档补丁 3 处(agent-runtime §2.2 StructuredResult.status、api-design §7 错误码表 LLM_NOT_CONFIGURED 行并扩表加来源列、config-design §4 token_estimation 注明 tiktoken 缺失回落 approximate);TDD 验证 RED(ModuleNotFoundError: No module named 'genesis.inference.engine')→ GREEN(聚焦 16 passed);pytest 全量 115 passed 覆盖 100.00%(779 stmts/184 br),fail_under=99 达标;提交见 git log | src/genesis/inference/engine.py, src/genesis/inference/__init__.py, tests/inference_helpers.py, tests/test_inference_engine.py, docs/agent-runtime-design.md, docs/api-design.md, docs/config-design.md, _AI_USAGE_LOG.md | deepseek-v4-flash-free | +| 2026-08-09 08:26 | Agent 实现 | 里程碑3.1 Task5 实现:InferenceEngine 编排器。新建 src/genesis/inference/engine.py(InferenceEngine:_render_prompt/_apply_truncation/_model_names(primary→fallback,无配置回退 deepseek-chat)/chat(渲染→token 超限回调→主模型→失败备用→status ok/fallback/failed)/chat_structured(schema 提示→逐次解析≤retry_count+1→parse_error+raw_text));tests/inference_helpers.py(FakeLLMClient)与 tests/test_inference_engine.py 按 brief 10 用例 + 补 5 用例(无 fallback、空 models、默认 registry/estimator、显式 model、truncate_cb 返回 None);2 处 brief 测试数值修正(truncate 内 max_context_tokens=3→2 因渲染 11 字≈3 token 不超限、fallback 断言 calls[-1]→calls[0] 因最后调用为备用模型);__init__.py 补导出 InferenceEngine/PromptRegistry;文档补丁 3 处(agent-runtime §2.2 StructuredResult.status、api-design §7 错误码表 LLM_NOT_CONFIGURED 行并扩表加来源列、config-design §4 token_estimation 注明 tiktoken 缺失回落 approximate);TDD 验证 RED(ModuleNotFoundError: No module named 'genesis.inference.engine')→ GREEN(聚焦 16 passed);pytest 全量 115 passed 覆盖 100.00%(779 stmts/184 br),fail_under=99 达标;提交见 git log | src/genesis/inference/engine.py, src/genesis/inference/__init__.py, tests/inference_helpers.py, tests/test_inference_engine.py, docs/agent-runtime-design.md, docs/api-design.md, docs/config-design.md, _AI_USAGE_LOG.md | deepseek-v4-flash-free | +| 2026-08-09 10:40 | 反馈迭代 | 里程碑3.1 最终全分支评审(base 2fbff07..8bc5e5e)修正:评审查到 client 两处缺陷并修复——① 2xx 但响应结构损坏(非 JSON/缺 choices 字段)从裸 KeyError/JSONDecodeError 逃逸出 engine 的 except LLMError,改为捕获 (json.JSONDecodeError, KeyError, IndexError, TypeError) 后抛 LLMResponseError(spec §3.8 既有类型首次被触发,封闭 api-design §7 LLM_PARSE_ERROR 来源列虚指);② 3xx 重定向被当作成功(httpx 不自动跟随),补显式 2xx 判定,非 2xx 一律 LLMNetworkError。TDD:新增 2 用例(test_chat_3xx_no_retry、test_chat_malformed_response_raises_llm_response_error 含 not-json/missing-key 两种 handler)先 RED→实现→GREEN;聚焦 9 passed;pytest 全量 117 passed 覆盖 100.00%(785 stmts/186 br),fail_under=99 达标 | src/genesis/inference/client.py, tests/test_inference_client.py, _AI_USAGE_LOG.md | deepseek-v4-flash-free | + +(End of file - total 50 lines) diff --git a/src/genesis/inference/client.py b/src/genesis/inference/client.py index bfc5163..2ffca79 100644 --- a/src/genesis/inference/client.py +++ b/src/genesis/inference/client.py @@ -1,11 +1,17 @@ from __future__ import annotations +import json import time from typing import Protocol, Sequence import httpx -from .exceptions import LLMNetworkError, LLMNotConfiguredError, LLMTimeoutError +from .exceptions import ( + LLMNetworkError, + LLMNotConfiguredError, + LLMResponseError, + LLMTimeoutError, +) from .types import ChatMessage, TokenUsage @@ -81,9 +87,16 @@ class HttpLLMClient: 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]}") - data = resp.json() - content = data["choices"][0]["message"]["content"] + 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), diff --git a/tests/test_inference_client.py b/tests/test_inference_client.py index 1c2d732..cca307e 100644 --- a/tests/test_inference_client.py +++ b/tests/test_inference_client.py @@ -2,7 +2,12 @@ import pytest import httpx from genesis.inference.client import HttpLLMClient, LLMClient -from genesis.inference.exceptions import LLMNetworkError, LLMNotConfiguredError, LLMTimeoutError +from genesis.inference.exceptions import ( + LLMNetworkError, + LLMNotConfiguredError, + LLMResponseError, + LLMTimeoutError, +) from genesis.inference.types import ChatMessage @@ -99,4 +104,36 @@ def test_chat_4xx_no_retry(): 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 + assert calls["n"] == 1 # 4xx 不重试 + + +def test_chat_3xx_no_retry(): + # 3xx(重定向,httpx 不自动跟随)不得被当作成功,应立即抛 LLMNetworkError + calls = {"n": 0} + + def handler(request): + calls["n"] += 1 + return httpx.Response(302, headers={"Location": "https://elsewhere"}) + + 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 # 3xx 不重试 + + +def test_chat_malformed_response_raises_llm_response_error(): + # 2xx 但响应体结构损坏(非 JSON / 缺 choices/message/content)→ LLMResponseError + def not_json(request): + return httpx.Response(200, text="not-json") + + def missing_key(request): + return httpx.Response(200, json={"choices": []}) + + for handler in (not_json, missing_key): + with pytest.raises(LLMResponseError): + make_client(handler).chat( + model="m", messages=[ChatMessage(role="user", content="x")], + temperature=0.2, max_tokens=100, + ) \ No newline at end of file