118 lines
4.2 KiB
Python
Executable File
118 lines
4.2 KiB
Python
Executable File
"""Scene-based routing table (架构文档 §8.1 场景化路由).
|
|
|
|
``scene → (provider, model, temperature, max_tokens, timeout)`` is configuration,
|
|
so switching models never requires a code change (NFR-MAINT-05). Defaults live
|
|
here; ``LLM_SCENE_OVERRIDES_JSON`` overrides any field per scene at deploy time.
|
|
|
|
Token budgets follow 架构文档 §7.2: 输入合计 ≤ 4200, 输出 ≤ 800.
|
|
|
|
``thinking`` 在每个场景都显式给值。deepseek-v4 默认开启思考模式,实测同一个单句
|
|
问答开启要 13.5s、关闭只要 1.8s,且思考 token 按输出价计费 —— 漏配就会同时踩中
|
|
「响应超时」和「成本翻倍」。要开某个场景的思考,改 ``LLM_SCENE_OVERRIDES_JSON``
|
|
即可,不必改代码。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from packages.llm.errors import SceneNotRoutedError
|
|
from packages.llm.types import Scene, SceneConfig
|
|
|
|
_DEFAULTS: dict[Scene, dict[str, Any]] = {
|
|
Scene.AGENT_CHAT: {
|
|
"temperature": 0.7,
|
|
"max_tokens": 800,
|
|
"timeout_seconds": 30.0,
|
|
"prompt_id": "agent_chat",
|
|
"prompt_version": "v1",
|
|
"critical": False,
|
|
# 对话要在 NFR-PERF-02b 的整轮 15s 内返回,思考模式做不到
|
|
"thinking": False,
|
|
},
|
|
Scene.INTENT_CLASSIFY: {
|
|
"temperature": 0.0,
|
|
"max_tokens": 128,
|
|
"timeout_seconds": 10.0,
|
|
"prompt_id": "intent_classify",
|
|
"prompt_version": "v1",
|
|
# 分类是短输出的判别任务,思考只会拖慢并多花钱
|
|
"thinking": False,
|
|
# Intent classification gates 越界拒答 (FR-AGT-02); degrading it silently
|
|
# would let out-of-scope questions through, so it is never budget-gated.
|
|
"critical": True,
|
|
},
|
|
Scene.PREFERENCE_EXTRACT: {
|
|
"temperature": 0.0,
|
|
"max_tokens": 256,
|
|
"timeout_seconds": 15.0,
|
|
"prompt_id": "preference_extract",
|
|
"prompt_version": "v1",
|
|
"critical": False,
|
|
"thinking": False,
|
|
},
|
|
Scene.REPORT_COPY: {
|
|
"temperature": 0.6,
|
|
"max_tokens": 3000,
|
|
"timeout_seconds": 90.0,
|
|
"prompt_id": "report_copy",
|
|
"prompt_version": "v1",
|
|
"critical": False,
|
|
# 报告文案是异步任务,延迟宽松,理论上最适合开思考;V1 先关,
|
|
# 等黄金集能量化"思考带来的质量提升"再按场景打开(改配置即可)。
|
|
"thinking": False,
|
|
},
|
|
Scene.FULL_REPORT_COPY: {
|
|
"temperature": 0.6,
|
|
# 深度诊断四大块(总评+章节+季型解析+4 场景+30 天计划)一次产出,
|
|
# 输出预算按 REPORT_COPY 翻倍;异步任务,延迟宽松。
|
|
"max_tokens": 6000,
|
|
"timeout_seconds": 180.0,
|
|
"prompt_id": "full_report_copy",
|
|
"prompt_version": "v1",
|
|
"critical": False,
|
|
"thinking": False,
|
|
},
|
|
}
|
|
|
|
|
|
def build_scene_configs(
|
|
*,
|
|
primary_provider: str,
|
|
backup_provider: str | None = None,
|
|
structured_max_retries: int = 2,
|
|
models: dict[str, str] | None = None,
|
|
overrides: dict[str, dict[str, Any]] | None = None,
|
|
) -> dict[Scene, SceneConfig]:
|
|
"""Materialise the routing table from settings."""
|
|
providers = tuple(p for p in (primary_provider, backup_provider) if p)
|
|
configs: dict[Scene, SceneConfig] = {}
|
|
for scene, defaults in _DEFAULTS.items():
|
|
merged: dict[str, Any] = {
|
|
**defaults,
|
|
"structured_max_retries": structured_max_retries,
|
|
**(overrides or {}).get(scene.value, {}),
|
|
}
|
|
configs[scene] = SceneConfig(
|
|
scene=scene,
|
|
providers=tuple(merged.pop("providers", providers)),
|
|
models=dict(merged.pop("models", models or {})),
|
|
**merged,
|
|
)
|
|
return configs
|
|
|
|
|
|
class SceneRouter:
|
|
def __init__(self, configs: dict[Scene, SceneConfig]) -> None:
|
|
self._configs = dict(configs)
|
|
|
|
def resolve(self, scene: Scene) -> SceneConfig:
|
|
config = self._configs.get(scene)
|
|
if config is None:
|
|
msg = f"no routing configured for scene {scene.value}"
|
|
raise SceneNotRoutedError(msg)
|
|
return config
|
|
|
|
def scenes(self) -> tuple[Scene, ...]:
|
|
return tuple(self._configs)
|