feat: make chat model routing configurable

This commit is contained in:
2026-07-31 17:57:26 +08:00
parent 85a6da5949
commit ab2c945f0b
22 changed files with 567 additions and 88 deletions

View File

@@ -232,6 +232,92 @@ def test_agent_preview_uses_same_fixed_info_route_when_default_model_is_selected
assert complete["questionType"] == "fixed_info"
assert any(item.get("tool") == "model_route" for item in complete["retrievalTrace"])
def test_agent_preview_uses_conservative_simple_knowledge_route(monkeypatch):
async def answer_chunks():
yield "原生课程包含静水流深等练习。"
async def build_result(_db, _payload):
return RagResult(
question="原生里的作业都有哪些",
knowledge_scopes=[],
chunks=[
RetrievedChunk(
knowledge_id=1,
knowledge_name="原生课程",
title="课程作业",
content="静水流深静心。",
knowledge_type="course",
)
],
prompt="原生里的作业都有哪些",
tool_trace=[],
)
captured = {}
def debug_stream(model, _rag_result, _overrides, **kwargs):
captured["model"] = model.model_name
captured["questionType"] = kwargs["question_type"]
return AsyncStreamingModelResponse(
model_id=model.id,
model_name=model.model_name,
input_token=10,
chunks=answer_chunks(),
route_reason=kwargs["route_reason"],
question_type=kwargs["question_type"],
)
with _database() as db:
admin = _admin()
main = _model()
main.is_default = 1
main.allow_simple_knowledge = 0
simple = ModelConfig(
id=2,
provider="simple",
display_name="简单知识模型",
api_type="openai_compatible",
model_name="simple-model",
base_url="https://example.com/v1",
api_url="",
api_key="encrypted",
auth_type="bearer",
max_token=8192,
stream_enabled=1,
timeout_second=30,
enabled=1,
is_default=0,
allow_simple_knowledge=1,
)
db.add_all(
[
admin,
main,
simple,
SystemConfig(config_key="chat_model_routing_mode", config_value="conservative"),
]
)
db.commit()
monkeypatch.setattr(AgentDebugService, "build_result", build_result)
monkeypatch.setattr(ModelStreamService, "debug_stream_async", debug_stream)
payload = AgentDebugRequest(
promptContent="你是测试助手",
modelId=main.id,
question="原生里的作业都有哪些",
)
async def collect_events():
return [item async for item in AgentDebugService.stream(payload, db, admin)]
events = asyncio.run(collect_events())
complete = next(item for item in events if item["type"] == "complete")
assert captured == {"model": "simple-model", "questionType": "simple_knowledge"}
assert complete["modelName"] == "simple-model"
assert complete["questionType"] == "simple_knowledge"
def test_debug_stream_setting_overrides_model_without_changing_it():
model = _model()
debug_model = _copy_model_with_overrides(model, {"stream_enabled": 0})

View File

@@ -41,6 +41,7 @@ def _model(
allow_report: int,
allow_summary: int,
allow_fixed_info: int = 1,
allow_simple_knowledge: int = 1,
) -> ModelConfig:
return ModelConfig(
id=model_id,
@@ -54,6 +55,7 @@ def _model(
allow_report=allow_report,
allow_summary=allow_summary,
allow_fixed_info=allow_fixed_info,
allow_simple_knowledge=allow_simple_knowledge,
allow_deep_chat=1,
input_price_per_1k=Decimal("0.002"),
output_price_per_1k=Decimal("0.006"),
@@ -132,6 +134,171 @@ def test_fixed_info_chat_routes_only_when_all_recalled_knowledge_is_fixed():
assert "分流开关已关闭" in disabled_route.reason
def test_simple_knowledge_routing_defaults_to_fixed_only_for_safe_rollout():
with _db() as db:
main = _model(
1,
"main-model",
is_default=1,
allow_report=1,
allow_summary=1,
allow_simple_knowledge=0,
)
simple = _model(
2,
"simple-model",
is_default=0,
allow_report=0,
allow_summary=0,
allow_simple_knowledge=1,
)
db.add_all([main, simple])
db.commit()
route = ModelRoutingService.resolve_chat(db, ["course"], "原生里的作业都有哪些")
assert ModelRoutingService.chat_routing_mode(db) == "fixed_only"
assert route.model is main
assert route.question_type == "knowledge_grounded"
def test_conservative_mode_routes_only_clear_simple_knowledge_questions():
with _db() as db:
main = _model(
1,
"main-model",
is_default=1,
allow_report=1,
allow_summary=1,
allow_fixed_info=0,
allow_simple_knowledge=0,
)
simple = _model(
2,
"simple-model",
is_default=0,
allow_report=0,
allow_summary=0,
allow_fixed_info=0,
allow_simple_knowledge=1,
)
db.add_all(
[
main,
simple,
SystemConfig(config_key="chat_model_routing_mode", config_value="conservative"),
]
)
db.commit()
simple_route = ModelRoutingService.resolve_chat(db, ["course", "qa"], "原生里的作业都有哪些")
personal_route = ModelRoutingService.resolve_chat(
db,
["course"],
"我做阴影人格练习时身体很难受怎么办",
)
mixed_route = ModelRoutingService.resolve_chat(db, ["fixed", "course"], "课程作业有哪些")
assert simple_route.model is simple
assert simple_route.question_type == "simple_knowledge"
assert "保守简单知识规则" in simple_route.reason
assert personal_route.model is main
assert personal_route.question_type == "knowledge_grounded"
assert mixed_route.model is main
assert mixed_route.question_type == "knowledge_grounded"
def test_chat_routing_off_keeps_fixed_information_on_main_model():
with _db() as db:
main = _model(
1,
"main-model",
is_default=1,
allow_report=1,
allow_summary=1,
allow_fixed_info=0,
)
fixed = _model(
2,
"fixed-model",
is_default=0,
allow_report=0,
allow_summary=0,
allow_fixed_info=1,
)
db.add_all(
[
main,
fixed,
SystemConfig(config_key="chat_model_routing_mode", config_value="off"),
]
)
db.commit()
route = ModelRoutingService.resolve_chat(db, ["fixed"], "本周上课时间是什么")
assert route.model is main
assert route.question_type == "fixed_info"
assert "分流开关已关闭" in route.reason
def test_user_stream_uses_conservative_simple_knowledge_route(monkeypatch):
async def collect(response):
return [chunk async for chunk in response.chunks]
with _db() as db:
main = _model(
1,
"main-model",
is_default=1,
allow_report=1,
allow_summary=1,
allow_simple_knowledge=0,
)
simple = _model(
2,
"simple-model",
is_default=0,
allow_report=0,
allow_summary=0,
allow_simple_knowledge=1,
)
db.add_all(
[
main,
simple,
SystemConfig(config_key="chat_model_routing_mode", config_value="conservative"),
SystemConfig(config_key="mock_model_enabled", config_value="false"),
]
)
db.commit()
async def stream(model, _rag_result):
yield f"{model.model_name}回答"
rag_result = RagResult(
question="原生里的作业都有哪些",
knowledge_scopes=[],
chunks=[
RetrievedChunk(
knowledge_id=1,
knowledge_name="原生课程",
title="课程作业",
content="静水流深静心。",
knowledge_type="course",
)
],
prompt="原生里的作业都有哪些",
)
monkeypatch.setattr("app.services.model_stream_service._stream_configured_model_async", stream)
response = ModelStreamService.stream_async(db, rag_result)
chunks = asyncio.run(collect(response))
assert chunks == ["由simple-model回答"]
assert response.model_id == simple.id
assert response.question_type == "simple_knowledge"
def test_fixed_info_non_stream_call_falls_back_to_main_model_on_provider_failure(monkeypatch):
with _db() as db:
main = _model(1, "main-model", is_default=1, allow_report=1, allow_summary=1, allow_fixed_info=0)

View File

@@ -3,6 +3,8 @@ from __future__ import annotations
from datetime import date
import asyncio
import json
from pathlib import Path
import re
import time
import pytest
@@ -41,6 +43,19 @@ def test_secret_is_encrypted_and_masked():
assert SecretService.masked(encrypted) == MASKED_SECRET
def test_migration_revision_ids_fit_default_alembic_version_column():
versions_dir = Path(__file__).parents[1] / "alembic" / "versions"
revision_pattern = re.compile(r'^revision\s*=\s*["\']([^"\']+)', re.MULTILINE)
revision_ids = []
for migration in versions_dir.glob("*.py"):
match = revision_pattern.search(migration.read_text(encoding="utf-8"))
if match:
revision_ids.append((migration.name, match.group(1)))
too_long = [(filename, revision) for filename, revision in revision_ids if len(revision) > 32]
assert not too_long, f"Alembic revision ID 超过默认 VARCHAR(32): {too_long}"
def test_rate_limit_blocks_after_limit():
SecurityStateService.enforce_limit("test:rate", limit=2, window_seconds=60, message="too many")
SecurityStateService.enforce_limit("test:rate", limit=2, window_seconds=60, message="too many")