feat: InferenceEngine chat/chat_structured 全流程 + 文档补丁(补丁1/2/3)

This commit is contained in:
lhl
2026-08-09 08:26:37 +08:00
parent add7b1503c
commit 8bc5e5e571
8 changed files with 495 additions and 13 deletions
+1
View File
@@ -46,3 +46,4 @@
| 2026-08-09 05:57 | Agent 实现 | 里程碑3.1 Task2 实现:推理引擎 token 估算模块。新建 src/genesis/inference/token.pyapproximate_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 验证 REDModuleNotFoundError: No module named 'genesis.inference.token')→ 聚焦 7 passedtoken.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.pyPromptRegistryregister/get/list_versions/renderversion=None 取最新,jinja2 渲染,name/name@version 缺失抛 KeyError);tests/test_inference_prompt_registry.py 按 brief 5 用例 + 补 1 用例(指定不存在的 version 抛 KeyError,覆盖 get 中 key not in templates 分支);TDD 验证 REDModuleNotFoundError: 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.pyLLMClient Protocol + HttpLLMClientapi_key 空抛 LLMNotConfiguredErrorPOST {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 验证 REDModuleNotFoundError: 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.pyInferenceEngine_render_prompt/_apply_truncation/_model_namesprimary→fallback,无配置回退 deepseek-chat/chat(渲染→token 超限回调→主模型→失败备用→status ok/fallback/failed/chat_structuredschema 提示→逐次解析≤retry_count+1→parse_error+raw_text));tests/inference_helpers.pyFakeLLMClient 校验调)与 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 验证 REDModuleNotFoundError: 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 |
+2
View File
@@ -111,6 +111,8 @@ class StructuredResult:
prompt_version: str
usage: TokenUsage
duration_ms: int
status: Literal["ok", "fallback", "parse_error", "failed"] # 补丁 1:诊断状态
error: str | None = None # 失败原因(parse_error/failed 时附带)
```
### 2.3 模型管理
+13 -12
View File
@@ -296,18 +296,19 @@ docker-compose.yml(扩展):
## 7. 错误码约定
| 错误码 | HTTP | 含义 | 用户选项 |
|--------|------|------|---------|
| `FILE_TYPE_INVALID` | 400 | 文件类型不支持 | 更换文件 |
| `FILE_TOO_LARGE` | 400 | 超过 100MB 限制 | 压缩/分割 |
| `STATE_TRANSITION_INVALID` | 409 | 非法状态转移(如 uploading 直接 generate| 提示正确流程 |
| `LLM_TIMEOUT` | 502 | LLM 调用超时 | retry / skip / abort |
| `LLM_PARSE_ERROR` | 502 | 结构化输出解析失败 | retry |
| `EMBEDDING_FAILED` | 503 | Embedding 服务故障(降级为 BM25)| 继续(降级提示)|
| `RULES_HANDBOOK_MISSING` | 404 | 规则手册不存在 | 上传规则文档 |
| `CHAPTER_NOT_FOUND` | 404 | 章节不存在 | — |
| `CONFLICT_PENDING` | 409 | 规则冲突待用户决策 | 决策后继续 |
| `INTERNAL_ERROR` | 500 | 未知错误 | 重试/联系支持 |
| 错误码 | HTTP | 含义 | 用户选项 | 来源 |
|--------|------|------|---------|------|
| `FILE_TYPE_INVALID` | 400 | 文件类型不支持 | 更换文件 | — |
| `FILE_TOO_LARGE` | 400 | 超过 100MB 限制 | 压缩/分割 | — |
| `STATE_TRANSITION_INVALID` | 409 | 非法状态转移(如 uploading 直接 generate| 提示正确流程 | — |
| `LLM_TIMEOUT` | 502 | LLM 调用超时 | retry / skip / abort | exceptions.LLMTimeoutError |
| `LLM_NOT_CONFIGURED` | 503 | LLM Key 未配置 | 配置 Key | exceptions.LLMNotConfiguredError |
| `LLM_PARSE_ERROR` | 502 | 结构化输出解析失败 | retry | exceptions.LLMResponseError |
| `EMBEDDING_FAILED` | 503 | Embedding 服务故障(降级为 BM25)| 继续(降级提示)| — |
| `RULES_HANDBOOK_MISSING` | 404 | 规则手册不存在 | 上传规则文档 | — |
| `CHAPTER_NOT_FOUND` | 404 | 章节不存在 | — | — |
| `CONFLICT_PENDING` | 409 | 规则冲突待用户决策 | 决策后继续 | — |
| `INTERNAL_ERROR` | 500 | 未知错误 | 重试/联系支持 | — |
---
+1 -1
View File
@@ -133,7 +133,7 @@ models:
timeout_sec: 90
llm_calls:
token_estimation: tiktoken # tiktoken | approximate
token_estimation: tiktoken # tiktoken | approximatetiktoken 缺失自动回落 approximate(内置估算器)
max_context_tokens: 32000 # 上下文窗口上限
truncation_policy: # 超限裁剪策略(runtime §2.6
priority:
+4
View File
@@ -1,5 +1,7 @@
"""Genesis 推理引擎(统一 LLM 调用入口)。"""
from .engine import InferenceEngine
from .prompt_registry import PromptRegistry
from .types import ChatMessage, ChatResult, Prompt, StructuredResult, TokenUsage
from .exceptions import (
LLMError,
@@ -10,6 +12,8 @@ from .exceptions import (
)
__all__ = [
"InferenceEngine",
"PromptRegistry",
"ChatMessage",
"ChatResult",
"Prompt",
+185
View File
@@ -0,0 +1,185 @@
from __future__ import annotations
import json
import time
from typing import Any, Callable
from .client import LLMClient
from .exceptions import LLMError
from .prompt_registry import PromptRegistry
from .token import make_estimator
from .types import (
ChatMessage,
ChatResult,
Prompt,
StructuredResult,
TokenUsage,
)
class InferenceEngine:
"""统一 LLM 调用入口:模型选择/降级、重试、解析、Token 超限回调。"""
def __init__(
self,
*,
client: LLMClient,
models: Any | None = None,
registry: PromptRegistry | None = None,
estimator: Callable[[str], int] | None = None,
truncate_cb: Callable[[str, dict], dict] | None = None,
max_context_tokens: int = 32000,
) -> None:
self._client = client
self._models = models
self._registry = registry or PromptRegistry()
self._estimator = estimator or make_estimator()
self._truncate_cb = truncate_cb
self._max_context_tokens = max_context_tokens
# ---------- 内部 ----------
def _render_prompt(self, prompt: Prompt | str, variables: dict) -> str:
if isinstance(prompt, Prompt):
return self._registry.render(prompt.template, variables) if variables else prompt.template
return prompt
def _apply_truncation(self, text: str, variables: dict) -> dict:
"""Token 超限时触发裁剪回调(注入),返回新 variables。"""
if self._truncate_cb is not None:
new_vars = self._truncate_cb(text, variables)
if new_vars is not None:
return new_vars
return variables
def _model_names(self, model: str | None) -> list[str]:
"""返回尝试顺序;显式指定 model 时只用它,否则 primary→fallback。"""
if model:
return [model]
if self._models:
names = []
if getattr(self._models, "primary", None):
names.append(self._models.primary.name)
if getattr(self._models, "fallback", None):
names.append(self._models.fallback.name)
if names:
return names
return ["deepseek-chat"]
def _call(
self,
*,
model: str,
rendered: str,
temperature: float,
max_tokens: int,
) -> tuple[str, TokenUsage]:
messages = [ChatMessage(role="user", content=rendered)]
return self._client.chat(
model=model,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
)
# ---------- 公开 ----------
def chat(
self,
*,
session_id: str,
prompt: Prompt | str,
variables: dict,
model: str | None = None,
temperature: float = 0.2,
max_tokens: int = 4096,
) -> ChatResult:
rendered = self._render_prompt(prompt, variables)
if self._estimator(rendered) > self._max_context_tokens:
variables = self._apply_truncation(rendered, variables)
rendered = self._render_prompt(prompt, variables)
start = time.monotonic()
last_error: str | None = None
for idx, name in enumerate(self._model_names(model)):
try:
text, usage = self._call(
model=name, rendered=rendered,
temperature=temperature, max_tokens=max_tokens,
)
status = "ok" if idx == 0 else "fallback"
return ChatResult(
text=text, model=name, prompt_version=getattr(prompt, "version", "inline"),
usage=usage, duration_ms=int((time.monotonic() - start) * 1000),
status=status,
)
except LLMError as exc:
last_error = str(exc)
return ChatResult(
text="", model=name,
prompt_version=getattr(prompt, "version", "inline"),
usage=TokenUsage(), duration_ms=int((time.monotonic() - start) * 1000),
status="failed", error=last_error,
)
def chat_structured(
self,
*,
session_id: str,
prompt: Prompt | str,
variables: dict,
schema: dict,
retry_count: int = 2,
) -> StructuredResult:
rendered = self._render_prompt(prompt, variables)
# 追加 schema 约束说明(不强制模板支持)
schema_hint = json.dumps(schema, ensure_ascii=False) if schema else ""
base_rendered = rendered + (f'\n\n请输出符合以下 JSON Schema 的 JSON{schema_hint}' if schema_hint else "")
start = time.monotonic()
attempts = 0
last_raw = ""
last_error: str | None = None
while attempts <= retry_count:
attempts += 1
try:
text, usage = self._call(
model=self._model_names(None)[0], # 解析重试用首选模型
rendered=base_rendered,
temperature=0.0, max_tokens=4096,
)
last_raw = text
data = json.loads(text)
return StructuredResult(
data=data, raw_text=text, parse_attempts=attempts,
model=self._model_names(None)[0],
prompt_version=getattr(prompt, "version", "inline"),
usage=usage,
duration_ms=int((time.monotonic() - start) * 1000),
# fallback 语义保留给模型降级;解析重试成功仍为 ok
status="ok",
)
except json.JSONDecodeError as exc:
last_error = f"JSON 解析失败: {exc}"
# 带错误信息重试
base_rendered = base_rendered + f"\n\n上次解析失败:{exc}。请重新输出合法 JSON。"
except LLMError as exc:
return StructuredResult(
data={}, raw_text="", parse_attempts=attempts,
model=self._model_names(None)[0],
prompt_version=getattr(prompt, "version", "inline"),
usage=TokenUsage(),
duration_ms=int((time.monotonic() - start) * 1000),
status="failed", error=str(exc),
)
return StructuredResult(
data={}, raw_text=last_raw, parse_attempts=attempts,
model=self._model_names(None)[0],
prompt_version=getattr(prompt, "version", "inline"),
usage=TokenUsage(),
duration_ms=int((time.monotonic() - start) * 1000),
status="parse_error", error=last_error,
)
+27
View File
@@ -0,0 +1,27 @@
from __future__ import annotations
from genesis.inference.types import TokenUsage
class FakeLLMClient:
"""可编程的假 LLM 客户端:记录调用,按脚本返回(离线)。"""
def __init__(self, script=None):
# script: list[(status, content)]status: "ok" | "raise_timeout" | "raise_network" | "parse_fail"
self.script = script or [("ok", "hello")]
self.calls: list[dict] = []
def chat(self, *, model, messages, temperature, max_tokens):
self.calls.append({"model": model, "messages": [m.content for m in messages]})
status, content = self.script.pop(0)
if status == "raise_timeout":
from genesis.inference.exceptions import LLMTimeoutError
raise LLMTimeoutError("timeout")
if status == "raise_network":
from genesis.inference.exceptions import LLMNetworkError
raise LLMNetworkError("network")
if status == "parse_fail":
content = "NOT JSON"
return content, TokenUsage(input_tokens=10, output_tokens=2)
+262
View File
@@ -0,0 +1,262 @@
from __future__ import annotations
import json
import pytest
from genesis.inference.engine import InferenceEngine
from genesis.inference.exceptions import LLMError
from genesis.inference.prompt_registry import PromptRegistry
from genesis.inference.token import approximate_token_count
from genesis.inference.types import ChatMessage, Prompt
from tests.inference_helpers import FakeLLMClient
class Models:
"""模拟 config.InferenceModelspydantic 结构,测试中直接构建)"""
def __init__(self):
import types as t
self.primary = t.SimpleNamespace(name="deepseek-chat", provider="deepseek")
self.fallback = t.SimpleNamespace(name="qwen-max", provider="qwen")
class ModelsNoFallback:
"""无 fallback 的模型配置(覆盖 getattr(fallback) 假值分支)"""
def __init__(self):
import types as t
self.primary = t.SimpleNamespace(name="deepseek-chat", provider="deepseek")
self.fallback = None
class ModelsEmpty:
"""主/备用模型均为空(覆盖 names 为空 → 返回内置默认)"""
def __init__(self):
self.primary = None
self.fallback = None
def make_engine(client=None, *, constants=None):
eng = InferenceEngine(
client=client or FakeLLMClient(),
models=Models(),
registry=PromptRegistry(),
estimator=approximate_token_count,
)
if constants:
eng._max_context_tokens = constants # 内部测试钩子
return eng
# ---------- chat 主路径 ----------
def test_chat_ok():
client = FakeLLMClient([("ok", "正文")])
eng = make_engine(client)
r = eng.chat(
session_id="s1",
prompt=Prompt(name="writer", version="v1", template="章节:{{ chapter }}"),
variables={"chapter": "DB設計"},
)
assert r.status == "ok" and r.text == "正文"
assert client.calls[0]["model"] == "deepseek-chat"
def test_chat_fallback_after_primary_failure():
client = FakeLLMClient([("raise_timeout", ""), ("ok", "备用输出")])
eng = make_engine(client)
r = eng.chat(
session_id="s1",
prompt=Prompt(name="p", version="v1", template="t:{{ x }}"),
variables={"x": "1"},
)
assert r.status == "fallback"
# 先主模型,后备模型(修正:断言首次调用为主模型)
assert client.calls[0]["model"] == "deepseek-chat"
def test_chat_all_failed_returns_failed():
client = FakeLLMClient([("raise_network", ""), ("raise_network", "")])
eng = make_engine(client)
r = eng.chat(
session_id="s1",
prompt=Prompt(name="p", version="v1", template="t"),
variables={},
)
assert r.status == "failed" and r.error
def test_chat_plain_string_prompt():
eng = make_engine(FakeLLMClient([("ok", "hi")]))
r = eng.chat(session_id="s1", prompt="直接文本", variables={})
assert r.text == "hi" and r.status == "ok"
def test_chat_truncation_callback_triggered():
seen = {}
def truncate_cb(prompt_text, variables):
seen["called"] = True
seen["len"] = len(prompt_text)
return {**variables, "chapter": "裁剪版"}
eng = InferenceEngine(
client=FakeLLMClient([("ok", "x")]),
models=Models(),
registry=PromptRegistry(),
estimator=approximate_token_count,
truncate_cb=truncate_cb,
)
eng._max_context_tokens = 2 # 强制超限(复习:'abcd很长很长的标题' 约 3 token > 2
r = eng.chat(
session_id="s1",
prompt=Prompt(name="p", version="v1", template="abcd{{ chapter }}"),
variables={"chapter": "很长很长的标题"},
)
assert seen["called"] is True
assert r.status == "ok"
# ---------- chat_structured 主路径 ----------
def test_chat_structured_ok():
client = FakeLLMClient([("ok", '{"a": 1}')])
eng = make_engine(client)
r = eng.chat_structured(
session_id="s1", prompt=Prompt(name="p", version="v1", template="提取"),
variables={"text": "内容"},
schema={"type": "object", "properties": {"a": {"type": "number"}}},
)
assert r.status == "ok" and r.data == {"a": 1}
def test_chat_structured_retry_parse():
client = FakeLLMClient([("parse_fail", ""), ("ok", '{"a": 2}')])
eng = make_engine(client)
r = eng.chat_structured(
session_id="s1", prompt=Prompt(name="p", version="v1", template="提取"),
variables={}, schema={},
)
assert r.status == "ok" and r.data == {"a": 2} and r.parse_attempts == 2
def test_chat_structured_parse_error_returns_raw():
client = FakeLLMClient([("parse_fail", ""), ("parse_fail", "")])
eng = make_engine(client)
r = eng.chat_structured(
session_id="s1", prompt=Prompt(name="p", version="v1", template="提取"),
variables={}, schema={}, retry_count=1,
)
assert r.status == "parse_error"
assert r.raw_text == "NOT JSON"
assert r.parse_attempts == 2
def test_chat_structured_failed_on_network():
client = FakeLLMClient([("raise_network", "")])
eng = make_engine(client)
r = eng.chat_structured(
session_id="s1", prompt=Prompt(name="p", version="v1", template="提取"),
variables={}, schema={},
)
assert r.status == "failed"
# ---------- 补充分支覆盖(defensive / 缺失配置) ----------
def test_chat_fallback_all_failed_when_only_primary():
"""models 无 fallback:仅尝试主模型,失败后直接 failed。"""
client = FakeLLMClient([("raise_network", "")])
eng = InferenceEngine(
client=client,
models=ModelsNoFallback(),
registry=PromptRegistry(),
estimator=approximate_token_count,
)
r = eng.chat(session_id="s1", prompt="t", variables={})
assert r.status == "failed"
assert len(client.calls) == 1
assert client.calls[0]["model"] == "deepseek-chat"
def test_chat_empty_models_uses_default():
"""主/备用均为空配置时回退内置默认模型 deepseek-chat。"""
eng = InferenceEngine(
client=FakeLLMClient([("ok", "x")]),
models=ModelsEmpty(),
registry=PromptRegistry(),
estimator=approximate_token_count,
)
r = eng.chat(session_id="s1", prompt="t", variables={})
assert r.status == "ok"
assert eng._model_names(None) == ["deepseek-chat"]
def test_chat_defaults_without_registry_estimator():
"""registry/estimator/models 均未配置时使用内置默认(PromptRegistry + approximate + deepseek-chat)。"""
client = FakeLLMClient([("ok", "默认")])
eng = InferenceEngine(client=client, models=None)
r = eng.chat(
session_id="s1",
prompt=Prompt(name="p", version="v1", template="tt"),
variables={},
)
assert r.status == "ok" and r.text == "默认"
assert eng._model_names(None) == ["deepseek-chat"]
def test_chat_explicit_model_param():
"""显式指定 model,跳过主/备选择,仅调用该模型。"""
client = FakeLLMClient([("ok", "c")])
eng = make_engine(client)
r = eng.chat(session_id="s1", prompt="t", variables={}, model="qwen-custom")
assert r.status == "ok"
assert client.calls[0]["model"] == "qwen-custom"
assert len(client.calls) == 1
def test_chat_truncation_without_callback_keeps_variables():
"""超限但无 truncate_cbvariables 原样保留(裁剪分支不触发)。"""
client = FakeLLMClient([("ok", "ok")])
eng = make_engine(client)
eng._max_context_tokens = 2 # 强制超限(渲染后约 11 token)
r = eng.chat(
session_id="s1",
prompt=Prompt(name="p", version="v1", template="abcd{{ chapter }}"),
variables={"chapter": "很长很长的标题"},
)
assert r.status == "ok"
assert client.calls[0]["model"] == "deepseek-chat"
def test_chat_structured_empty_schema_no_hint():
"""schema 为空时不追加 schema 提示,仍可正常解析。"""
client = FakeLLMClient([("ok", '{"v": true}')])
eng = make_engine(client)
r = eng.chat_structured(
session_id="s1", prompt="直接文本", variables={}, schema={},
)
assert r.status == "ok" and r.data == {"v": True}
def test_chat_truncation_callback_returns_none_keeps_variables():
"""truncate_cb 返回 None 时回退原 variables(覆盖 new_vars is None 分支)。"""
def truncate_cb(prompt_text, variables):
return None
client = FakeLLMClient([("ok", "x")])
eng = InferenceEngine(
client=client,
models=Models(),
registry=PromptRegistry(),
estimator=approximate_token_count,
truncate_cb=truncate_cb,
)
eng._max_context_tokens = 2
r = eng.chat(
session_id="s1",
prompt=Prompt(name="p", version="v1", template="abcd{{ chapter }}"),
variables={"chapter": "很长很长的标题"},
)
assert r.status == "ok"
# 未替换变量:发送内容仍为原样渲染
assert "很长很长的标题" in client.calls[0]["messages"][0]