feat: route fixed information chats by model

This commit is contained in:
2026-07-31 17:42:25 +08:00
parent da313f88ed
commit 85a6da5949
15 changed files with 607 additions and 57 deletions

View File

@@ -21,7 +21,7 @@ from app.services.model_stream_service import (
_stream_configured_model_async,
)
from app.services.model_service import ModelClientService, _copy_model_with_overrides, _max_output_tokens
from app.services.rag_service import PromptService, RagResult
from app.services.rag_service import PromptService, RagResult, RetrievedChunk
def _database() -> Session:
@@ -148,10 +148,90 @@ def test_disabled_stream_returns_one_complete_chunk():
with patch("app.services.model_stream_service._call_configured_model", return_value=answer):
chunks = asyncio.run(collect())
assert chunks == [answer]
def test_agent_preview_uses_same_fixed_info_route_when_default_model_is_selected(monkeypatch):
async def fixed_chunks():
yield "本周三晚八点上课。"
async def build_result(_db, _payload):
return RagResult(
question="本周什么时候上课?",
knowledge_scopes=[],
chunks=[
RetrievedChunk(
knowledge_id=1,
knowledge_name="当前安排",
title="本周安排",
content="本周三晚八点上课。",
knowledge_type="fixed",
)
],
prompt="本周什么时候上课?",
tool_trace=[],
)
captured = {}
def debug_stream(model, _rag_result, _overrides, **kwargs):
captured["model"] = model.model_name
captured["fallback"] = kwargs["fallback_model"].model_name
captured["routeReason"] = kwargs["route_reason"]
return AsyncStreamingModelResponse(
model_id=model.id,
model_name=model.model_name,
input_token=10,
chunks=fixed_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_fixed_info = 0
fixed = ModelConfig(
id=2,
provider="fixed",
display_name="固定信息模型",
api_type="openai_compatible",
model_name="fixed-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_fixed_info=1,
)
db.add_all([admin, main, fixed])
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"] == "fixed-model"
assert captured["fallback"] == "production-model"
assert "仅召回固定信息类" in captured["routeReason"]
assert complete["modelName"] == "fixed-model"
assert complete["questionType"] == "fixed_info"
assert any(item.get("tool") == "model_route" for item in complete["retrievalTrace"])
def test_debug_stream_setting_overrides_model_without_changing_it():
model = _model()
debug_model = _copy_model_with_overrides(model, {"stream_enabled": 0})

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
from decimal import Decimal
import pytest
@@ -16,6 +17,9 @@ from app.models.logs import AiRequestLog
from app.schemas.admin import DefaultModelRequest, EnableModelRequest
from app.services.model_routing_service import ModelRoutingService
from app.services.model_service import ModelClientService
from app.services.model_stream_service import ModelStreamService
from app.services.external_errors import ExternalServiceError
from app.services.rag_service import RagResult, RetrievedChunk
from app.services.tracked_generation_service import TrackedGenerationService
@@ -36,6 +40,7 @@ def _model(
is_default: int,
allow_report: int,
allow_summary: int,
allow_fixed_info: int = 1,
) -> ModelConfig:
return ModelConfig(
id=model_id,
@@ -48,7 +53,7 @@ def _model(
is_default=is_default,
allow_report=allow_report,
allow_summary=allow_summary,
allow_fixed_info=1,
allow_fixed_info=allow_fixed_info,
allow_deep_chat=1,
input_price_per_1k=Decimal("0.002"),
output_price_per_1k=Decimal("0.006"),
@@ -86,6 +91,127 @@ def test_background_scenario_falls_back_to_default_model():
assert "回退默认主模型" in route.reason
def test_fixed_info_chat_routes_only_when_all_recalled_knowledge_is_fixed():
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])
db.commit()
fixed_route = ModelRoutingService.resolve_chat(db, ["fixed", "fixed"])
mixed_route = ModelRoutingService.resolve_chat(db, ["fixed", "course"])
no_hit_route = ModelRoutingService.resolve_chat(db, [])
assert fixed_route.model is fixed
assert fixed_route.question_type == "fixed_info"
assert mixed_route.model is main
assert mixed_route.question_type == "knowledge_grounded"
assert "混合类型" in mixed_route.reason
assert no_hit_route.model is main
assert no_hit_route.question_type == "general_chat"
db.add(SystemConfig(config_key="fixed_info_model_routing_enabled", config_value="false"))
db.commit()
disabled_route = ModelRoutingService.resolve_chat(db, ["fixed"])
assert disabled_route.model is main
assert disabled_route.question_type == "fixed_info"
assert "分流开关已关闭" in disabled_route.reason
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)
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="mock_model_enabled", config_value="false")])
db.commit()
calls: list[str] = []
def call(model, _rag_result, *, allow_no_hit=False):
calls.append(model.model_name)
if model.id == fixed.id:
raise ExternalServiceError("固定信息模型暂时不可用", provider="model")
return "主模型回退回答"
monkeypatch.setattr("app.services.model_service._call_configured_model", call)
completion = ModelClientService.complete(db, _rag_result("fixed"))
assert calls == ["fixed-model", "main-model"]
assert completion.model_id == main.id
assert completion.answer == "主模型回退回答"
assert completion.question_type == "fixed_info"
assert "运行时回退默认主模型" in (completion.route_reason or "")
def test_fixed_info_async_stream_falls_back_before_first_output(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_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="mock_model_enabled", config_value="false")])
db.commit()
async def stream(model, _rag_result):
if model.id == fixed.id:
raise ExternalServiceError("固定信息模型暂时不可用", provider="model")
yield "主模型流式回退回答"
monkeypatch.setattr("app.services.model_stream_service._stream_configured_model_async", stream)
response = ModelStreamService.stream_async(db, _rag_result("fixed"))
chunks = asyncio.run(collect(response))
assert chunks == ["主模型流式回退回答"]
assert response.model_id == main.id
assert response.question_type == "fixed_info"
assert "运行时回退默认主模型" in (response.route_reason or "")
def test_fixed_info_stream_does_not_restart_after_partial_output(monkeypatch):
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="mock_model_enabled", config_value="false")])
db.commit()
calls: list[str] = []
async def stream(model, _rag_result):
calls.append(model.model_name)
if model.id == fixed.id:
yield "已经输出的部分"
raise ExternalServiceError("输出中断", provider="model")
yield "不应重新生成"
async def collect(response):
chunks = []
with pytest.raises(ExternalServiceError):
async for chunk in response.chunks:
chunks.append(chunk)
return chunks
monkeypatch.setattr("app.services.model_stream_service._stream_configured_model_async", stream)
response = ModelStreamService.stream_async(db, _rag_result("fixed"))
chunks = asyncio.run(collect(response))
assert chunks == ["已经输出的部分"]
assert calls == ["fixed-model"]
assert response.model_id == fixed.id
def test_tracked_background_generation_records_actual_route_tokens_and_cost():
with _db() as db:
db.add(SystemConfig(config_key="mock_model_enabled", config_value="true"))
@@ -152,3 +278,20 @@ def test_model_pool_keeps_one_default_and_rejects_disabling_last_default():
current_admin=admin,
)
assert exc_info.value.status_code == 400
def _rag_result(knowledge_type: str) -> RagResult:
return RagResult(
question="本周上课时间是什么?",
knowledge_scopes=[],
chunks=[
RetrievedChunk(
knowledge_id=1,
knowledge_name="当前安排",
title="本周安排",
content="本周三晚八点上课。",
knowledge_type=knowledge_type,
)
],
prompt="本周上课时间是什么?",
)