- 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)
212 lines
11 KiB
Markdown
212 lines
11 KiB
Markdown
# 里程碑 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。 |