Files
CyberGril/tests/test_router.py
T
mobai 8dea68a25b feat(think): four-tier model routing with budget guard
Corrects a common misconception: the 60s heartbeat must not call any LLM.
It runs the mood decay equation -- microseconds of float math. Giving it a
model is an architecture error, not a resource problem.

Real token spend lands in two places:
  - per-turn input context (ontology + state + dyad + memory)
  - background task frequency

So route by scene, not by time:

  none      rule/math     tick, reach-out decision, fillers, avatar drive
  low       local small   life events, memory gist, fact extract
  standard  mid + short   speculative-response second segment
  high      flagship      main dialogue, proactive message, crisis

Hard constraints:
  - dialogue floor is LOW, never NONE (can get dumber, cannot lose lines)
  - crisis never degrades (product constraint, not perf tuning)
  - degradation is predictable: one rung per request, floor only at
    the hard budget threshold

Cost levers, by actual payoff:
  1. prompt caching (fixed prefix) -- by far the largest
  2. upstream compression (small model condenses 20 memories to 3)
  3. batching (one batch per 30min, not 20 single calls)
  4. swapping to a smaller model -- smallest payoff, hurts persona

Also: cost degradation is narratable. 'Net error -> I zoned out' already
exists in the projection table, so being throttled is in-character and true.

- config.py: tiers / routing / budget, replacing llm+llm_fast+llm_background
- think/: scenes, budget, router, wiring
- models.yaml restructured around tiers
- SPEC gains 9.5
- 20 new tests; 78 total pass; ruff clean
2026-10-19 20:14:41 +08:00

211 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""模型路由与预算测试。
守的是这一层的核心判断:
**大模型按关键时刻分配,不按时间分配。**
如果哪天有人把 tick 路由到 high,test_heartbeat_never_uses_model 会红。
如果哪天预算紧张把主对话降成"无模型",test_dialogue_floor_is_low 会红。
"""
from __future__ import annotations
import pytest
from cybergril.providers import EchoLLM
from cybergril.think import (
BudgetConfig,
BudgetGuard,
ModelRouter,
Scene,
Tier,
build_router,
floor_for,
is_model_scene,
)
from cybergril.think.scenes import CONTEXT_BUDGET, NEVER_NONE
@pytest.fixture
def providers() -> dict[Tier, EchoLLM | None]:
return {
Tier.HIGH: EchoLLM(prefix="[high]"),
Tier.STANDARD: EchoLLM(prefix="[std]"),
Tier.LOW: EchoLLM(prefix="[low]"),
Tier.NONE: None,
}
@pytest.fixture
def router(providers) -> ModelRouter:
return build_router(providers=providers)
# ---------------------------------------------------------------- 路由基表
@pytest.mark.parametrize(
"scene",
[Scene.TICK, Scene.FILLER, Scene.REACH_OUT, Scene.AVATAR_DRIVE],
)
def test_heartbeat_and_rules_never_use_model(router: ModelRouter, scene: Scene) -> None:
"""心跳、填充词、主动性判定、表情:不调用任何模型。"""
d = router.route(scene)
assert d.effective is Tier.NONE
assert d.provider is None
assert d.is_rule_based
assert d.context_budget == 0
def test_dialogue_main_uses_high(router: ModelRouter) -> None:
d = router.route(Scene.DIALOGUE_MAIN)
assert d.effective is Tier.HIGH
assert d.provider is not None
assert d.context_budget == CONTEXT_BUDGET[Tier.HIGH]
def test_background_tasks_use_low(router: ModelRouter) -> None:
for scene in (Scene.MEMORY_GIST, Scene.FACT_EXTRACT, Scene.LIFE_EVENT):
d = router.route(scene)
assert d.effective is Tier.LOW, scene
def test_is_model_scene_helper() -> None:
assert not is_model_scene(Scene.TICK)
assert is_model_scene(Scene.DIALOGUE_MAIN)
# ---------------------------------------------------------------- 预算降级
def test_budget_tight_drops_to_floor(providers) -> None:
"""到达硬阈值时直接降到该场景下限,而不是温和过渡。"""
guard = BudgetGuard(BudgetConfig(daily_tokens=1000, hard_stop_ratio=0.95), today="2026-01-01")
guard.record(960, "test") # 96%
router = build_router(providers=providers)
router.guard = guard
d = router.route(Scene.DIALOGUE_MAIN)
assert d.requested is Tier.HIGH
assert d.effective is Tier.LOW # dialogue 的下限
assert d.degraded
assert "预算" in (d.degraded_reason or "")
def test_dialogue_floor_is_low_not_none(providers) -> None:
"""预算耗尽时,主对话可以降成小模型,但绝不能没有台词。"""
guard = BudgetGuard(BudgetConfig(daily_tokens=1000, hard_stop_ratio=0.5), today="2026-01-01")
guard.record(999, "test")
router = build_router(providers=providers)
router.guard = guard
d = router.route(Scene.DIALOGUE_MAIN)
assert d.effective is Tier.LOW
assert d.provider is not None # 还有模型可用
assert floor_for(Scene.DIALOGUE_MAIN) is Tier.LOW
def test_crisis_never_downgrades(providers) -> None:
"""危机场景即使预算耗尽也保持高档位。
这是产品意义上的强制约束,不是性能优化。
"""
guard = BudgetGuard(BudgetConfig(daily_tokens=100, hard_stop_ratio=0.1), today="2026-01-01")
guard.record(1000, "test")
router = build_router(providers=providers)
router.guard = guard
d = router.route(Scene.CRISIS)
assert d.effective is Tier.HIGH
assert floor_for(Scene.CRISIS) is Tier.HIGH
def test_never_none_scenes_are_declared() -> None:
assert Scene.DIALOGUE_MAIN in NEVER_NONE
assert Scene.CRISIS in NEVER_NONE
def test_estimated_tokens_downgrades_one_step(providers) -> None:
"""单次请求放不下时只降一档——降级要可预测。"""
guard = BudgetGuard(BudgetConfig(daily_tokens=1000, hard_stop_ratio=0.95), today="2026-01-01")
guard.record(900, "test") # 90%,还没到硬阈值
router = build_router(providers=providers)
router.guard = guard
d = router.route(Scene.DIALOGUE_MAIN, estimated_tokens=500)
assert d.effective is Tier.STANDARD
assert "降一档" in (d.degraded_reason or "")
# ---------------------------------------------------------------- 缺 provider
def test_missing_tier_falls_through(providers) -> None:
"""某一档没配 provider 时应该往下找,而不是崩。"""
providers[Tier.HIGH] = None
router = build_router(providers=providers)
d = router.route(Scene.PROACTIVE_MESSAGE)
assert d.requested is Tier.HIGH
assert d.effective is Tier.STANDARD
assert d.provider is not None
assert d.degraded
# ---------------------------------------------------------------- 预算记账
def test_budget_records_by_scene() -> None:
guard = BudgetGuard(BudgetConfig(daily_tokens=10_000), today="2026-01-01")
guard.record(100, Scene.DIALOGUE_MAIN.value)
guard.record(300, Scene.MEMORY_GIST.value)
guard.record(50, Scene.DIALOGUE_MAIN.value)
assert guard.used == 450
top = guard.top_scenes()
assert top[0][0] == Scene.MEMORY_GIST.value
assert top[0][1] == 300
def test_budget_usage_dict() -> None:
guard = BudgetGuard(BudgetConfig(daily_tokens=10_000), today="2026-01-01")
guard.record_usage({"prompt_tokens": 100, "completion_tokens": 50}, "x")
assert guard.used == 150
def test_budget_ratio_and_remaining() -> None:
guard = BudgetGuard(BudgetConfig(daily_tokens=1000), today="2026-01-01")
guard.record(250, "x")
assert guard.ratio() == 0.25
assert guard.remaining() == 750
assert not guard.is_tight()
assert not guard.is_warning()
def test_budget_warning_threshold() -> None:
guard = BudgetGuard(BudgetConfig(daily_tokens=1000, warn_ratio=0.7), today="2026-01-01")
guard.record(750, "x")
assert guard.is_warning()
assert not guard.is_tight()
def test_budget_summary_readable() -> None:
guard = BudgetGuard(BudgetConfig(daily_tokens=1000), today="2026-01-01")
guard.record(300, "dialogue_main")
text = guard.summary()
assert "300" in text
assert "dialogue_main" in text
# ---------------------------------------------------------------- 上下文预算
def test_context_budget_decreases_with_tier() -> None:
assert CONTEXT_BUDGET[Tier.HIGH] > CONTEXT_BUDGET[Tier.STANDARD]
assert CONTEXT_BUDGET[Tier.STANDARD] > CONTEXT_BUDGET[Tier.LOW]
assert CONTEXT_BUDGET[Tier.NONE] == 0
def test_routing_decision_str(router: ModelRouter) -> None:
assert "dialogue_main" in str(router.route(Scene.DIALOGUE_MAIN))
assert "tick" in str(router.route(Scene.TICK))