# 里程碑 3.1:InferenceEngine(推理引擎)设计 - 日期:2026-08-09 - 状态:已批准(用户 2026-08-09 确认;plan-design-review 已执行,无 UI scope) - 目的:落地 `implementation-plan` 阶段 1.6「LLM Client 抽象化」扩展为推理引擎(agent-runtime §2),使所有 Agent 的 LLM 调用经统一入口 ## 0. 审查结论(plan-design-review) - **UI 维度**:无 UI 范围(纯后端基础设施),designer's-eye 审查不适用(按 skill 退出条款) - **一致性审查**:接口/模型/退避/Token/PromptRegistry/环境变量 与 agent-runtime §2、config-design §2/§4、api-design §7 全部对齐 - **审查产出 2 项补丁(已并入本 spec §3.8 / §3.9)**: - 补丁 1:`StructuredResult` 增加 `status: Literal["ok","parse_error","failed"]` 字段(基线 runtime §2.2 无此字段) - 补丁 2:错误码 `LLM_NOT_CONFIGURED`(503)补入 api-design §7 错误码表(config-design §7 有定义但 api-design 表缺失) ## 1. 背景与触发条件 - `implementation-plan.md` 阶段 1.6 要求「LLM Client 抽象化」;`agent-runtime-design.md` §2 将该抽象扩展为完整的推理引擎(统一 LLM 调用入口、降级、重试、Token 管理、Prompt 版本化) - 当前仓库**尚无任何 LLM 客户端实现**,4 个 Agent(Parser / Impact / Writer / QA)均等待推理引擎基盘 - 技术选型已定:原生 HTTP(不使用 SDK 封装);HTTP 客户端选择 **httpx**(pyproject 新增依赖),采用**异步 `AsyncClient`**(T8 架构审查整改:由同步 Client 全异步化,适配编排层 asyncio 任务池,退避用 asyncio.sleep,async with 生命周期闭环) ## 2. 目标 - 提供 `InferenceEngine.chat()` / `chat_structured()` 统一 LLM 入口,接口与 agent-runtime §2.2 一致 - 完整实现:模型选择优先级、重试(指数退避 1s/3s/7s)、超时(默认 60s)、主模型→备用模型降级、Token 估算与超限裁剪回调、PromptRegistry 注册/渲染/版本化 - **可注入 HTTP 适配器**:单测全路径离线运行(注入 FakeAdapter),零真实网络依赖;真实网络走可选在线用例 - 结构化输出失败不抛异常:以 `status="parse_error"` + 原始文本返回,调用方决定(对齐 api-design §7 LLM_PARSE_ERROR 语义) ## 3. 设计 ### 3.1 模块结构 ``` src/genesis/inference/ ├── __init__.py # 导出 InferenceEngine / 数据类 / 异常 ├── engine.py # InferenceEngine(chat / chat_structured) ├── client.py # LLMClient 抽象 + HttpLLMClient(httpx) + 重试/退避 ├── token.py # token 估算(approximate,tiktoken 可选)+ 超限回调 ├── prompt_registry.py # PromptRegistry(register/get/render/版本化) ├── types.py # TokenUsage / ChatMessage / ChatResult / StructuredResult / Prompt └── exceptions.py # LLMError 层级(LLMTimeoutError / LLMNotConfiguredError) ``` ### 3.2 数据模型(`types.py`) ```python @dataclass class TokenUsage: input_tokens: int = 0 output_tokens: int = 0 @dataclass class ChatMessage: role: Literal["system", "user", "assistant"] content: str @dataclass class ChatResult: text: str model: str prompt_version: str usage: TokenUsage duration_ms: int status: Literal["ok", "fallback", "failed"] # 对齐 runtime §2.2 @dataclass class StructuredResult: data: dict raw_text: str parse_attempts: int model: str prompt_version: str usage: TokenUsage duration_ms: int status: Literal["ok", "fallback", "parse_error", "failed"] # 补丁 1:status 字段(覆盖 runtime 语义:ok / parse_error / failed; # fallback 表示主模型失败后经备用模型成功) error: str | None = None # parse_error/failed 时的可读原因(诊断用) @dataclass class Prompt: name: str version: str template: str ``` ### 3.3 LLM 客户端(`client.py`) ```python class LLMClient(Protocol): """HTTP 适配器(可注入)。""" def chat(self, *, model: str, messages: list[ChatMessage], temperature: float, max_tokens: int) -> tuple[str, TokenUsage]: ... class HttpLLMClient: """基于 httpx 的 OpenAI Chat Completions 兼容实现;base_url/api_key 从配置注入。 接受可选 transport(httpx.MockTransport)便于离线测试。""" ``` - 端点:`POST {base_url}/v1/chat/completions`;`Authorization: Bearer {api_key}` - `base_url` / `api_key` 来自 `LLM_BASE_URL` / `DEEPSEEK_API_KEY`(config-design §2) - 超时:`timeout_sec`(默认 60)→ **LLMTimeoutError** - 网络/HTTP 非 2xx → **LLMNetworkError**(可重试) - 5xx 重试(指数退避 `retry_backoff=[1,3,7]` 秒)→ 重试耗尽 → 主模型失败 ### 3.4 重试 / 超时 / 降级(对齐 runtime §2.5) ``` 主模型超时或 5xx → 指数退避重试(1s/3s/7s)→ 仍失败 → 降级备用模型(fallback,重试同上)→ 备用也失败 → status="failed" 结构化输出:JSON 解析失败 → 带错误信息重试(structured_output.max_parse_retry=2) → 重试仍失败 → status="parse_error"(不抛异常),附原始文本 ``` ### 3.5 Token 管理(对齐 runtime §2.6 / config §4) - 估算:先 `tiktoken`(若已安装),否则 `approximate` 近似(4 字符≈1 token),实现 `TokenEstimator`(tiktoken/approximate 双后端,配置驱动 `token_estimation`) - 超限策略由**注入的裁剪回调** `truncate_cb(prompt_text, vars) -> vars` 承担(按 config `truncation_policy.priority`),引擎只负责检测与触发,不内置裁剪实现(便于单测与职责分离) ### 3.6 Prompt 注册表(对齐 runtime §2.7) ```python class PromptRegistry: def register(self, name: str, version: str, template: str) -> None: ... def get(self, name: str, version: str | None = None, variables: dict | None = None) -> str: ... def list_versions(self, name: str) -> list[str]: ... ``` - v1 实现:代码内 `register` + 目录加载(`prompts/{name}/{version}.txt`,配置 `prompts_dir`) - 渲染:Jinja2 语法(`{{ var }}`);`get(..., variables)` 返回渲染后文本;记录 `prompt_version` 供可追溯 ### 3.7 引擎(`__init__.py`) ```python class InferenceEngine: def __init__(self, *, client: LLMClient, models: InferenceModels, registry: PromptRegistry | None = None, config: InferenceConfig | None = None): ... def chat(self, *, session_id: str, prompt: Prompt | str, variables: dict, model: str | None = None, temperature: float = 0.2, max_tokens: int = 4096) -> ChatResult: ... def chat_structured(self, *, session_id: str, prompt: Prompt | str, variables: dict, schema: JSONSchema, retry_count: int = 2) -> StructuredResult: ... ``` ### 3.8 异常模型(`exceptions.py`)——补丁 2 ```python class LLMError(Exception): ... # 基类,api-design §7 映射基底 class LLMNetworkError(LLMError): ... # 网络失败/5xx 重试耗尽(可重试语义) class LLMTimeoutError(LLMError): ... # 超时 → api-error LLM_TIMEOUT(502) class LLMNotConfiguredError(LLMError): ... # Key/模型缺失 → api-error LLM_NOT_CONFIGURED(503) class LLMResponseError(LLMError): ... # 结构损坏(JSON 解析失败) ``` **api-design §7 错误码表修订(补丁 2)**: | 错误码 | HTTP | 含义 | 用户选项 | 来源 | |--------|------|------|---------|------| | `LLM_TIMEOUT` | 502 | LLM 调用超时 | retry / skip / abort | exceptions.LLMTimeoutError | | `LLM_PARSE_ERROR` | 502 | 结构化输出解析失败 | retry | exceptions.LLMResponseError | | `LLM_NOT_CONFIGURED` | 503 | 主/备用模型 Key 缺失 | 配置 Key | exceptions.LLMNotConfiguredError ← **新增行** | ### 3.9 既有文档修订汇总(补丁落实) | 文档 | 修订 | |------|------| | `agent-runtime-design.md` §2.2 | `StructuredResult` 增加 `status` 字段(补丁 1) | | `api-design.md` §7 | 错误码表新增 `LLM_NOT_CONFIGURED` 行(补丁 2) | | `config-design.md` §4 | `token_estimation` 默认值由 `tiktoken` 改为 `approximate`(恢复 config.py 现有默认) | > 注意:`config.py` 的 `LlmCallsConfig.token_estimation` 默认值目前是 `"tiktoken"`(config.py:61)。实现 > 时以 **approximate 为内置实现**(零依赖、可离线),`tiktoken` 作为可选后端:若已安装则优先使用、 > 未安装则回落 approximate。因此 **不改 config 默认值**,保持 `"tiktoken"`(语义=优先 tiktoken, > 缺失自动降级);上述 config-design 修订为**注明该降级行为**,不改变配置默认值。 ### 3.10 测试策略 - **单元测试(注入 FakeAdapter / FakeClient,零网络)**: - 主模型成功 / 主模型失败→重试→备用模型成功(status="fallback")/ 双模型全失败(status="failed") - 超时→LLMTimeoutError;网络 5xx 重试耗尽→LLMNetworkError;未配置 Key→LLMNotConfiguredError - `chat_structured`:一次成功 / 解析失败重试成功 / 重试耗尽→parse_error+raw_text - Token 超限→裁剪回调被触发且返回新 variables - Prompt 渲染 / 版本回退 / 注册表 list_versions - **在线用例(可选,`@pytest.mark.llm_online`,默认不跑)**:真实 `DEEPSEEK_API_KEY` 已配时执行 - 全量回归 `python -m pytest` 全绿(含既有 53 用例) ## 4. 验收标准 1. `InferenceEngine.chat/chat_structured` 接口与 runtime §2.2 一致(含补丁 1 字段) 2. 重试/降级/超时/未配置/解析失败/Token 裁剪 —— 全路径注入测试通过(零网络) 3. 结构化失败返回 `parse_error` 且附 raw_text,不抛异常 4. api-design §7 表含 `LLM_NOT_CONFIGURED`(补丁 2);文档补丁到位 5. `pytest -m pytest -v` 全绿(新增用例 beyond 53) ## 5. 非目标(显式延后) - LLM 调用缓存、成本/限流监控(runtime v2 §9) - function calling(ImageAnalyzer 的 tool call 由 v2 预留) - 在线真实调用回归(仅可选标记,不在默认 CI) ## 6. 涉及文件 - 新建:`src/genesis/inference/{__init__,errors,client,token,prompt_registry,types,exceptions}.py` - 修改:`pyproject.toml`(新增 `httpx` 依赖;`jinja2` 用于 PromptRegistry 渲染) - 修改:`docs/agent-runtime-design.md`(§2.2 补丁 1)、`docs/api-design.md`(§7 补丁 2)、`docs/config-design.md`(§4 注明 tiktoken 双语义) - 新建:`tests/test_inference_types.py`、`tests/test_inference_errors.py`、`tests/test_inference_token.py`、`tests/test_inference_prompt_registry.py`、`tests/test_inference_client.py`、`tests/test_inference_engine.py`(平铺于 tests/,与既有 excel_helpers.py 惯例一致);`tests/inference_helpers.py`(FakeClient / FakeTransport 构造) ## 7. 版本与状态 | 版本 | 日期 | 说明 | |------|------|------| | v1.0 | 2026-08-09 | 批准版(含补丁 1/2)| 后续:由 `writing-plans` 生成实施计划(task A-G→5 个实操包),进入 `/implement` backlog。