- 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)
11 KiB
11 KiB
里程碑 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:
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)
@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)
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承担(按 configtruncation_policy.priority),引擎只负责检测与触发,不内置裁剪实现(便于单测与职责分离)
3.6 Prompt 注册表(对齐 runtime §2.7)
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)
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
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. 验收标准
InferenceEngine.chat/chat_structured接口与 runtime §2.2 一致(含补丁 1 字段)- 重试/降级/超时/未配置/解析失败/Token 裁剪 —— 全路径注入测试通过(零网络)
- 结构化失败返回
parse_error且附 raw_text,不抛异常 - api-design §7 表含
LLM_NOT_CONFIGURED(补丁 2);文档补丁到位 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。