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

@@ -148,6 +148,7 @@ const modelForm = reactive({
allowSummary: 1, allowSummary: 1,
allowReport: 1, allowReport: 1,
allowFixedInfo: 1, allowFixedInfo: 1,
allowSimpleKnowledge: 1,
allowDeepChat: 1, allowDeepChat: 1,
}); });
@@ -311,6 +312,7 @@ function modelSceneLabel(scene: string) {
background_report: "周期报告", background_report: "周期报告",
background_summary: "摘要沉淀", background_summary: "摘要沉淀",
fixed_info: "固定信息问答", fixed_info: "固定信息问答",
simple_knowledge: "简单知识问答",
knowledge_grounded: "知识问答", knowledge_grounded: "知识问答",
general_chat: "通用对话", general_chat: "通用对话",
} as Record<string, string>)[scene] || scene || "未分类"; } as Record<string, string>)[scene] || scene || "未分类";
@@ -707,6 +709,7 @@ async function quickAddModel() {
allowSummary: 1, allowSummary: 1,
allowReport: 1, allowReport: 1,
allowFixedInfo: 1, allowFixedInfo: 1,
allowSimpleKnowledge: 1,
allowDeepChat: 1, allowDeepChat: 1,
}); });
ElMessage.success(`${provider.label} 模型已新增`); ElMessage.success(`${provider.label} 模型已新增`);
@@ -814,6 +817,7 @@ function editModel(row: ModelItem) {
allowSummary: row.allowSummary ?? 1, allowSummary: row.allowSummary ?? 1,
allowReport: row.allowReport ?? 1, allowReport: row.allowReport ?? 1,
allowFixedInfo: row.allowFixedInfo ?? 1, allowFixedInfo: row.allowFixedInfo ?? 1,
allowSimpleKnowledge: row.allowSimpleKnowledge ?? 1,
allowDeepChat: row.allowDeepChat ?? 1, allowDeepChat: row.allowDeepChat ?? 1,
}); });
} }
@@ -849,6 +853,7 @@ function resetModelForm() {
allowSummary: 1, allowSummary: 1,
allowReport: 1, allowReport: 1,
allowFixedInfo: 1, allowFixedInfo: 1,
allowSimpleKnowledge: 1,
allowDeepChat: 1, allowDeepChat: 1,
}); });
} }
@@ -905,7 +910,7 @@ function normalizeSystemSettingValue(setting: (typeof systemSettingDefinitions)[
if (setting.type === "switch") { if (setting.type === "switch") {
return ["1", "true", "yes", "on", "启用"].includes(rawValue.toLowerCase()); return ["1", "true", "yes", "on", "启用"].includes(rawValue.toLowerCase());
} }
if (setting.type === "text" || setting.type === "password") { if (setting.type === "text" || setting.type === "password" || setting.type === "select") {
return rawValue; return rawValue;
} }
const parsed = Number(rawValue); const parsed = Number(rawValue);
@@ -1463,6 +1468,7 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
<div> <div>
<div class="entitlement-capabilities"> <div class="entitlement-capabilities">
<el-checkbox v-model="modelForm.allowFixedInfo" :true-value="1" :false-value="0">固定信息</el-checkbox> <el-checkbox v-model="modelForm.allowFixedInfo" :true-value="1" :false-value="0">固定信息</el-checkbox>
<el-checkbox v-model="modelForm.allowSimpleKnowledge" :true-value="1" :false-value="0">简单知识</el-checkbox>
<el-checkbox v-model="modelForm.allowDeepChat" :true-value="1" :false-value="0">深度对话</el-checkbox> <el-checkbox v-model="modelForm.allowDeepChat" :true-value="1" :false-value="0">深度对话</el-checkbox>
<el-checkbox v-model="modelForm.allowSummary" :true-value="1" :false-value="0">摘要沉淀</el-checkbox> <el-checkbox v-model="modelForm.allowSummary" :true-value="1" :false-value="0">摘要沉淀</el-checkbox>
<el-checkbox v-model="modelForm.allowReport" :true-value="1" :false-value="0">周期报告</el-checkbox> <el-checkbox v-model="modelForm.allowReport" :true-value="1" :false-value="0">周期报告</el-checkbox>
@@ -1491,6 +1497,7 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
<div class="model-capability-tags"> <div class="model-capability-tags">
<el-tag v-if="row.allowDeepChat === 1" size="small">深度对话</el-tag> <el-tag v-if="row.allowDeepChat === 1" size="small">深度对话</el-tag>
<el-tag v-if="row.allowFixedInfo === 1" size="small">固定信息</el-tag> <el-tag v-if="row.allowFixedInfo === 1" size="small">固定信息</el-tag>
<el-tag v-if="row.allowSimpleKnowledge === 1" size="small">简单知识</el-tag>
<el-tag v-if="row.allowSummary === 1" size="small" type="success">摘要</el-tag> <el-tag v-if="row.allowSummary === 1" size="small" type="success">摘要</el-tag>
<el-tag v-if="row.allowReport === 1" size="small" type="warning">报告</el-tag> <el-tag v-if="row.allowReport === 1" size="small" type="warning">报告</el-tag>
</div> </div>
@@ -1544,6 +1551,18 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
type="password" type="password"
show-password show-password
/> />
<el-select
v-else-if="setting.type === 'select'"
v-model="systemSettingValues[setting.key]"
:placeholder="setting.placeholder"
>
<el-option
v-for="option in setting.options || []"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
<el-switch v-else v-model="systemSettingValues[setting.key]" /> <el-switch v-else v-model="systemSettingValues[setting.key]" />
<small>{{ setting.description }}</small> <small>{{ setting.description }}</small>
</label> </label>

View File

@@ -1,5 +1,10 @@
export type SystemSettingValue = string | number | boolean; export type SystemSettingValue = string | number | boolean;
type SystemSettingType = "number" | "switch" | "text" | "password"; type SystemSettingType = "number" | "switch" | "text" | "password" | "select";
interface SystemSettingOption {
label: string;
value: string;
}
export interface SystemSettingDefinition { export interface SystemSettingDefinition {
key: string; key: string;
@@ -9,6 +14,7 @@ export interface SystemSettingDefinition {
min?: number; min?: number;
max?: number; max?: number;
placeholder?: string; placeholder?: string;
options?: SystemSettingOption[];
description: string; description: string;
} }
@@ -160,11 +166,16 @@ export const systemSettingSections: SystemSettingSection[] = [
description: "后台始终记录引用;此项控制用户端是否展示。", description: "后台始终记录引用;此项控制用户端是否展示。",
}, },
{ {
key: "fixed_info_model_routing_enabled", key: "chat_model_routing_mode",
label: "固定信息模型分流", label: "正式问答模型分流规则",
type: "switch", type: "select",
defaultValue: true, defaultValue: "fixed_only",
description: "仅当本轮实际召回内容全部来自固定信息类知识库时使用对应低成本模型;关闭后统一使用默认主模型。", options: [
{ label: "关闭分流(全部使用主模型)", value: "off" },
{ label: "仅固定信息(推荐)", value: "fixed_only" },
{ label: "保守分流(固定信息 + 简单知识)", value: "conservative" },
],
description: "“简单知识”只覆盖短问题、明确知识问法且仅召回课程/问答/通用知识;涉及个人情况、情绪、关系、建议或混合固定信息时仍使用主模型。",
}, },
{ {
key: "chat_max_active_requests", key: "chat_max_active_requests",

View File

@@ -1741,18 +1741,19 @@ textarea {
gap: 7px; gap: 7px;
} }
.setting-field span { .setting-field > span {
color: #223631; color: #223631;
font-weight: 600; font-weight: 600;
} }
.setting-field small { .setting-field > small {
color: #667a73; color: #667a73;
line-height: 1.5; line-height: 1.5;
} }
.setting-field .el-input-number, .setting-field .el-input-number,
.setting-field .el-input { .setting-field .el-input,
.setting-field .el-select {
width: 100%; width: 100%;
} }

View File

@@ -213,6 +213,7 @@ export interface ModelItem {
allowSummary: number; allowSummary: number;
allowReport: number; allowReport: number;
allowFixedInfo: number; allowFixedInfo: number;
allowSimpleKnowledge: number;
allowDeepChat: number; allowDeepChat: number;
} }

View File

@@ -25,7 +25,9 @@ def upgrade() -> None:
sa.Column("period_start", sa.DateTime(), nullable=False), sa.Column("period_start", sa.DateTime(), nullable=False),
sa.Column("period_end", sa.DateTime(), nullable=False), sa.Column("period_end", sa.DateTime(), nullable=False),
sa.Column("title", sa.String(length=160), nullable=False), sa.Column("title", sa.String(length=160), nullable=False),
sa.Column("content", sa.Text(), nullable=False, server_default=""), # MySQL 8.4 rejects literal defaults on TEXT columns. The ORM already
# supplies an empty string while a report job is pending.
sa.Column("content", sa.Text(), nullable=False),
sa.Column("source_summary_ids", sa.Text(), nullable=True), sa.Column("source_summary_ids", sa.Text(), nullable=True),
sa.Column("source_topic_ids", sa.Text(), nullable=True), sa.Column("source_topic_ids", sa.Text(), nullable=True),
sa.Column("model_name", sa.String(length=100), nullable=True), sa.Column("model_name", sa.String(length=100), nullable=True),

View File

@@ -1,83 +1,79 @@
"""persist cleaned question insights """persist cleaned question insights
Revision ID: 0020_question_insight_persistence Revision ID: 0020_question_insight_store
Revises: 0019_periodic_reports Revises: 0019_periodic_reports
Create Date: 2026-07-31 00:00:00.000000 Create Date: 2026-07-31 00:00:00.000000
""" """
from __future__ import annotations from __future__ import annotations
from alembic import op from alembic import context, op
import sqlalchemy as sa import sqlalchemy as sa
revision = "0020_question_insight_persistence" revision = "0020_question_insight_store"
down_revision = "0019_periodic_reports" down_revision = "0019_periodic_reports"
branch_labels = None branch_labels = None
depends_on = None depends_on = None
def upgrade() -> None: def upgrade() -> None:
op.create_table( table_name = "sys_question_insight_cleaned_question"
"sys_question_insight_cleaned_question", table_exists = False
sa.Column( existing_indexes: set[str] = set()
"id", if not context.is_offline_mode():
sa.BigInteger().with_variant(sa.Integer(), "sqlite"), inspector = sa.inspect(op.get_bind())
primary_key=True, table_exists = inspector.has_table(table_name)
autoincrement=True, if table_exists:
), existing_indexes = {item["name"] for item in inspector.get_indexes(table_name)}
sa.Column("message_id", sa.BigInteger(), nullable=False),
sa.Column("session_id", sa.BigInteger(), nullable=False), if not table_exists:
sa.Column("user_id", sa.BigInteger(), nullable=False), op.create_table(
sa.Column("part_index", sa.Integer(), nullable=False), table_name,
sa.Column("cleaner_version", sa.String(length=20), nullable=False), sa.Column(
sa.Column("source_hash", sa.String(length=64), nullable=False), "id",
sa.Column("cleaned_text", sa.Text(), nullable=False), sa.BigInteger().with_variant(sa.Integer(), "sqlite"),
sa.Column("normalized_text", sa.Text(), nullable=False), primary_key=True,
sa.Column("category", sa.String(length=50), nullable=False, server_default="other"), autoincrement=True,
sa.Column("tokens_json", sa.Text(), nullable=False), ),
sa.Column("accepted", sa.Integer(), nullable=False, server_default="1"), sa.Column("message_id", sa.BigInteger(), nullable=False),
sa.Column("filtered_reason", sa.String(length=100), nullable=True), sa.Column("session_id", sa.BigInteger(), nullable=False),
sa.Column("source_created_at", sa.DateTime(), nullable=False), sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), sa.Column("part_index", sa.Integer(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), sa.Column("cleaner_version", sa.String(length=20), nullable=False),
sa.ForeignKeyConstraint( sa.Column("source_hash", sa.String(length=64), nullable=False),
["message_id"], sa.Column("cleaned_text", sa.Text(), nullable=False),
["sys_chat_message.id"], sa.Column("normalized_text", sa.Text(), nullable=False),
ondelete="CASCADE", sa.Column("category", sa.String(length=50), nullable=False, server_default="other"),
), sa.Column("tokens_json", sa.Text(), nullable=False),
sa.UniqueConstraint( sa.Column("accepted", sa.Integer(), nullable=False, server_default="1"),
"message_id", sa.Column("filtered_reason", sa.String(length=100), nullable=True),
"cleaner_version", sa.Column("source_created_at", sa.DateTime(), nullable=False),
"part_index", sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
name="uq_question_insight_message_version_part", sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
), sa.ForeignKeyConstraint(
) ["message_id"],
op.create_index( ["sys_chat_message.id"],
"ix_sys_question_insight_cleaned_question_session_id", ondelete="CASCADE",
"sys_question_insight_cleaned_question", ),
["session_id"], sa.UniqueConstraint(
) "message_id",
op.create_index( "cleaner_version",
"ix_sys_question_insight_cleaned_question_user_id", "part_index",
"sys_question_insight_cleaned_question", name="uq_question_insight_message_version_part",
["user_id"], ),
) )
op.create_index(
"ix_question_insight_version_accepted_created", indexes = {
"sys_question_insight_cleaned_question", "ix_sys_question_insight_cleaned_question_session_id": ["session_id"],
["cleaner_version", "accepted", "source_created_at"], "ix_sys_question_insight_cleaned_question_user_id": ["user_id"],
) "ix_question_insight_version_accepted_created": ["cleaner_version", "accepted", "source_created_at"],
op.create_index( "ix_question_insight_category_created": ["category", "source_created_at"],
"ix_question_insight_category_created", "ix_question_insight_session_created": ["session_id", "source_created_at"],
"sys_question_insight_cleaned_question", }
["category", "source_created_at"], for index_name, columns in indexes.items():
) if index_name not in existing_indexes:
op.create_index( op.create_index(index_name, table_name, columns)
"ix_question_insight_session_created",
"sys_question_insight_cleaned_question",
["session_id", "source_created_at"],
)
def downgrade() -> None: def downgrade() -> None:

View File

@@ -1,7 +1,7 @@
"""add durable periodic report job fields """add durable periodic report job fields
Revision ID: 0021_periodic_report_async_jobs Revision ID: 0021_periodic_report_async_jobs
Revises: 0020_question_insight_persistence Revises: 0020_question_insight_store
Create Date: 2026-07-31 00:00:00.000000 Create Date: 2026-07-31 00:00:00.000000
""" """
@@ -12,7 +12,7 @@ import sqlalchemy as sa
revision = "0021_periodic_report_async_jobs" revision = "0021_periodic_report_async_jobs"
down_revision = "0020_question_insight_persistence" down_revision = "0020_question_insight_store"
branch_labels = None branch_labels = None
depends_on = None depends_on = None

View File

@@ -0,0 +1,71 @@
"""add simple knowledge model capability
Revision ID: 0023_simple_knowledge_route
Revises: 0022_model_routing
"""
from alembic import op
import sqlalchemy as sa
revision = "0023_simple_knowledge_route"
down_revision = "0022_model_routing"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Existing models retain the capability so deployment behavior stays on the
# current default model until an administrator explicitly reassigns it.
op.add_column(
"sys_model",
sa.Column("allow_simple_knowledge", sa.Integer(), nullable=False, server_default="1"),
)
# Materialize the new three-state setting so the admin UI and runtime show
# the same value even when the legacy emergency switch had been disabled.
op.execute(
"""
INSERT IGNORE INTO sys_system_config (config_key, config_value, description)
SELECT
'chat_model_routing_mode',
CASE
WHEN EXISTS (
SELECT 1
FROM sys_system_config
WHERE config_key = 'fixed_info_model_routing_enabled'
AND LOWER(TRIM(config_value)) NOT IN ('1', 'true', 'yes', 'on', '启用')
) THEN 'off'
ELSE 'fixed_only'
END,
'正式问答模型分流规则off / fixed_only / conservative'
"""
)
def downgrade() -> None:
# Preserve the closest legacy behavior before removing the new setting.
op.execute(
"""
UPDATE sys_system_config AS legacy
JOIN sys_system_config AS current_mode
ON current_mode.config_key = 'chat_model_routing_mode'
SET legacy.config_value = CASE
WHEN current_mode.config_value = 'off' THEN 'false'
ELSE 'true'
END
WHERE legacy.config_key = 'fixed_info_model_routing_enabled'
"""
)
op.execute(
"""
INSERT IGNORE INTO sys_system_config (config_key, config_value, description)
SELECT
'fixed_info_model_routing_enabled',
CASE WHEN config_value = 'off' THEN 'false' ELSE 'true' END,
'固定信息模型分流'
FROM sys_system_config
WHERE config_key = 'chat_model_routing_mode'
"""
)
op.execute("DELETE FROM sys_system_config WHERE config_key = 'chat_model_routing_mode'")
op.drop_column("sys_model", "allow_simple_knowledge")

View File

@@ -294,6 +294,7 @@ def create_model(
allow_summary=payload.allowSummary, allow_summary=payload.allowSummary,
allow_report=payload.allowReport, allow_report=payload.allowReport,
allow_fixed_info=payload.allowFixedInfo, allow_fixed_info=payload.allowFixedInfo,
allow_simple_knowledge=payload.allowSimpleKnowledge,
allow_deep_chat=payload.allowDeepChat, allow_deep_chat=payload.allowDeepChat,
enabled=0, enabled=0,
is_default=0, is_default=0,
@@ -346,6 +347,7 @@ def update_model(
model.allow_summary = payload.allowSummary model.allow_summary = payload.allowSummary
model.allow_report = payload.allowReport model.allow_report = payload.allowReport
model.allow_fixed_info = payload.allowFixedInfo model.allow_fixed_info = payload.allowFixedInfo
model.allow_simple_knowledge = payload.allowSimpleKnowledge
model.allow_deep_chat = payload.allowDeepChat model.allow_deep_chat = payload.allowDeepChat
db.add(model) db.add(model)
OperationLogService.write(db, admin_id=current_admin.id, module="model", action="update", target_id=model.id) OperationLogService.write(db, admin_id=current_admin.id, module="model", action="update", target_id=model.id)
@@ -502,6 +504,7 @@ def _model_dict(model: ModelConfig) -> dict:
"allowSummary": model.allow_summary, "allowSummary": model.allow_summary,
"allowReport": model.allow_report, "allowReport": model.allow_report,
"allowFixedInfo": model.allow_fixed_info, "allowFixedInfo": model.allow_fixed_info,
"allowSimpleKnowledge": model.allow_simple_knowledge,
"allowDeepChat": model.allow_deep_chat, "allowDeepChat": model.allow_deep_chat,
} }

View File

@@ -57,6 +57,7 @@ class ModelConfig(Base):
allow_summary: Mapped[int] = mapped_column(Integer, default=1, nullable=False) allow_summary: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
allow_report: Mapped[int] = mapped_column(Integer, default=1, nullable=False) allow_report: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
allow_fixed_info: Mapped[int] = mapped_column(Integer, default=1, nullable=False) allow_fixed_info: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
allow_simple_knowledge: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
allow_deep_chat: Mapped[int] = mapped_column(Integer, default=1, nullable=False) allow_deep_chat: Mapped[int] = mapped_column(Integer, default=1, nullable=False)

View File

@@ -182,6 +182,7 @@ class ModelSaveRequest(BaseModel):
allowSummary: int = Field(default=1, ge=0, le=1) allowSummary: int = Field(default=1, ge=0, le=1)
allowReport: int = Field(default=1, ge=0, le=1) allowReport: int = Field(default=1, ge=0, le=1)
allowFixedInfo: int = Field(default=1, ge=0, le=1) allowFixedInfo: int = Field(default=1, ge=0, le=1)
allowSimpleKnowledge: int = Field(default=1, ge=0, le=1)
allowDeepChat: int = Field(default=1, ge=0, le=1) allowDeepChat: int = Field(default=1, ge=0, le=1)

View File

@@ -136,6 +136,7 @@ class AgentDebugService:
chat_route = ModelRoutingService.resolve_chat( chat_route = ModelRoutingService.resolve_chat(
db, db,
[chunk.knowledge_type for chunk in rag_result.chunks], [chunk.knowledge_type for chunk in rag_result.chunks],
rag_result.question,
) )
automatic_route = default_model is not None and requested_model.id == default_model.id automatic_route = default_model is not None and requested_model.id == default_model.id
if automatic_route: if automatic_route:

View File

@@ -146,6 +146,7 @@ class ChatService:
failed_route = ModelRoutingService.resolve_chat( failed_route = ModelRoutingService.resolve_chat(
db, db,
[chunk.knowledge_type for chunk in rag_result.chunks] if rag_result is not None else [], [chunk.knowledge_type for chunk in rag_result.chunks] if rag_result is not None else [],
rag_result.question if rag_result is not None else normalized_question,
) )
AiRequestLogService.write_failed( AiRequestLogService.write_failed(
db, db,

View File

@@ -441,6 +441,7 @@ def _model_log_context(
route = ModelRoutingService.resolve_chat( route = ModelRoutingService.resolve_chat(
db, db,
[chunk.knowledge_type for chunk in rag_result.chunks] if rag_result is not None else [], [chunk.knowledge_type for chunk in rag_result.chunks] if rag_result is not None else [],
rag_result.question if rag_result is not None else "",
) )
return ( return (
route.reason, route.reason,

View File

@@ -1,19 +1,21 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Literal from typing import Literal, cast
from sqlalchemy import desc, select from sqlalchemy import desc, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.ai_config import ModelConfig, SystemConfig from app.models.ai_config import ModelConfig, SystemConfig
ModelScenario = Literal["report", "summary", "fixed_info", "deep_chat"] ModelScenario = Literal["report", "summary", "fixed_info", "simple_knowledge", "deep_chat"]
ChatRoutingMode = Literal["off", "fixed_only", "conservative"]
SCENARIO_LABELS: dict[ModelScenario, str] = { SCENARIO_LABELS: dict[ModelScenario, str] = {
"report": "周期报告", "report": "周期报告",
"summary": "摘要沉淀", "summary": "摘要沉淀",
"fixed_info": "固定信息", "fixed_info": "固定信息",
"simple_knowledge": "简单知识问答",
"deep_chat": "深度对话", "deep_chat": "深度对话",
} }
@@ -21,9 +23,59 @@ _SCENARIO_FIELDS = {
"report": ModelConfig.allow_report, "report": ModelConfig.allow_report,
"summary": ModelConfig.allow_summary, "summary": ModelConfig.allow_summary,
"fixed_info": ModelConfig.allow_fixed_info, "fixed_info": ModelConfig.allow_fixed_info,
"simple_knowledge": ModelConfig.allow_simple_knowledge,
"deep_chat": ModelConfig.allow_deep_chat, "deep_chat": ModelConfig.allow_deep_chat,
} }
_SIMPLE_KNOWLEDGE_TYPES = {"course", "qa", "general"}
_SIMPLE_QUERY_MARKERS = (
"是什么",
"有哪些",
"有什么",
"包括什么",
"定义",
"含义",
"区别",
"时间",
"几点",
"在哪里",
"多少",
"多久",
"步骤",
"怎么操作",
"怎么练",
"注意事项",
"作业",
"功课",
)
_DEEP_QUERY_MARKERS = (
"",
"自己",
"感觉",
"感受",
"情绪",
"身体",
"害怕",
"担心",
"焦虑",
"难受",
"痛苦",
"卡住",
"怎么办",
"为什么",
"关系",
"孩子",
"父母",
"伴侣",
"创伤",
"建议",
"分析",
"帮我",
"适合我",
"对不对",
"确认一下",
)
@dataclass(frozen=True) @dataclass(frozen=True)
class ModelRoute: class ModelRoute:
@@ -90,14 +142,20 @@ class ModelRoutingService:
) )
@classmethod @classmethod
def resolve_chat(cls, db: Session, knowledge_types: list[str]) -> ModelRoute: def resolve_chat(
cls,
db: Session,
knowledge_types: list[str],
question: str = "",
) -> ModelRoute:
normalized_types = {item.strip().lower() for item in knowledge_types if item and item.strip()} normalized_types = {item.strip().lower() for item in knowledge_types if item and item.strip()}
mode = cls.chat_routing_mode(db)
if normalized_types == {"fixed"}: if normalized_types == {"fixed"}:
if not cls._config_bool(db, "fixed_info_model_routing_enabled", True): if mode == "off":
return ModelRoute( return ModelRoute(
model=cls.default_model(db), model=cls.default_model(db),
scenario="deep_chat", scenario="deep_chat",
reason="正式聊天:仅召回固定信息类知识库,但固定信息模型分流开关已关闭;使用默认主模型", reason="正式聊天:仅召回固定信息类知识库,但正式问答模型分流开关已关闭;使用默认主模型",
fallback_used=False, fallback_used=False,
question_type="fixed_info", question_type="fixed_info",
) )
@@ -110,6 +168,16 @@ class ModelRoutingService:
question_type="fixed_info", question_type="fixed_info",
) )
if mode == "conservative" and cls._is_simple_knowledge_query(question, normalized_types):
route = cls.resolve(db, "simple_knowledge")
return ModelRoute(
model=route.model,
scenario=route.scenario,
reason=f"正式聊天:命中知识库且符合保守简单知识规则;{route.reason}",
fallback_used=route.fallback_used,
question_type="simple_knowledge",
)
default = cls.default_model(db) default = cls.default_model(db)
if not normalized_types: if not normalized_types:
reason = "正式聊天:未召回知识库;使用默认主模型" reason = "正式聊天:未召回知识库;使用默认主模型"
@@ -128,9 +196,35 @@ class ModelRoutingService:
question_type=question_type, question_type=question_type,
) )
@classmethod
def chat_routing_mode(cls, db: Session) -> ChatRoutingMode:
value = cls._config_value(db, "chat_model_routing_mode")
if value in {"off", "fixed_only", "conservative"}:
return cast(ChatRoutingMode, value)
# Compatibility with the previous emergency switch.
return "fixed_only" if cls._config_bool(db, "fixed_info_model_routing_enabled", True) else "off"
@staticmethod
def _is_simple_knowledge_query(question: str, knowledge_types: set[str]) -> bool:
normalized_question = "".join(question.split()).lower()
if not normalized_question or len(normalized_question) > 80:
return False
if not knowledge_types or not knowledge_types.issubset(_SIMPLE_KNOWLEDGE_TYPES):
return False
if any(marker in normalized_question for marker in _DEEP_QUERY_MARKERS):
return False
return any(marker in normalized_question for marker in _SIMPLE_QUERY_MARKERS)
@staticmethod @staticmethod
def _config_bool(db: Session, key: str, default: bool) -> bool: def _config_bool(db: Session, key: str, default: bool) -> bool:
value = ModelRoutingService._config_value(db, key)
if value is None:
return default
return value.lower() in {"1", "true", "yes", "on", "启用"}
@staticmethod
def _config_value(db: Session, key: str) -> str | None:
config = db.scalar(select(SystemConfig).where(SystemConfig.config_key == key)) config = db.scalar(select(SystemConfig).where(SystemConfig.config_key == key))
if config is None or not config.config_value.strip(): if config is None or not config.config_value.strip():
return default return None
return config.config_value.strip().lower() in {"1", "true", "yes", "on", "启用"} return config.config_value.strip()

View File

@@ -35,6 +35,7 @@ class ModelClientService:
route = ModelRoutingService.resolve_chat( route = ModelRoutingService.resolve_chat(
db, db,
[chunk.knowledge_type for chunk in rag_result.chunks], [chunk.knowledge_type for chunk in rag_result.chunks],
rag_result.question,
) )
model = route.model model = route.model
mock_model_enabled = _system_config_bool(db, "mock_model_enabled", get_settings().mock_model_enabled) mock_model_enabled = _system_config_bool(db, "mock_model_enabled", get_settings().mock_model_enabled)

View File

@@ -154,6 +154,7 @@ def _chat_route(db: Session, rag_result: RagResult) -> ModelRoute:
return ModelRoutingService.resolve_chat( return ModelRoutingService.resolve_chat(
db, db,
[chunk.knowledge_type for chunk in rag_result.chunks], [chunk.knowledge_type for chunk in rag_result.chunks],
rag_result.question,
) )

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 complete["questionType"] == "fixed_info"
assert any(item.get("tool") == "model_route" for item in complete["retrievalTrace"]) 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(): def test_debug_stream_setting_overrides_model_without_changing_it():
model = _model() model = _model()
debug_model = _copy_model_with_overrides(model, {"stream_enabled": 0}) debug_model = _copy_model_with_overrides(model, {"stream_enabled": 0})

View File

@@ -41,6 +41,7 @@ def _model(
allow_report: int, allow_report: int,
allow_summary: int, allow_summary: int,
allow_fixed_info: int = 1, allow_fixed_info: int = 1,
allow_simple_knowledge: int = 1,
) -> ModelConfig: ) -> ModelConfig:
return ModelConfig( return ModelConfig(
id=model_id, id=model_id,
@@ -54,6 +55,7 @@ def _model(
allow_report=allow_report, allow_report=allow_report,
allow_summary=allow_summary, allow_summary=allow_summary,
allow_fixed_info=allow_fixed_info, allow_fixed_info=allow_fixed_info,
allow_simple_knowledge=allow_simple_knowledge,
allow_deep_chat=1, allow_deep_chat=1,
input_price_per_1k=Decimal("0.002"), input_price_per_1k=Decimal("0.002"),
output_price_per_1k=Decimal("0.006"), 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 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): def test_fixed_info_non_stream_call_falls_back_to_main_model_on_provider_failure(monkeypatch):
with _db() as db: with _db() as db:
main = _model(1, "main-model", is_default=1, allow_report=1, allow_summary=1, allow_fixed_info=0) 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 from datetime import date
import asyncio import asyncio
import json import json
from pathlib import Path
import re
import time import time
import pytest import pytest
@@ -41,6 +43,19 @@ def test_secret_is_encrypted_and_masked():
assert SecretService.masked(encrypted) == MASKED_SECRET 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(): 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")
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")

View File

@@ -168,21 +168,27 @@ PERIODIC_REPORT_MAX_ATTEMPTS=3
模型管理现在区分两个概念: 模型管理现在区分两个概念:
- 可用模型:允许后台任务选择,可同时启用多个; - 可用模型:允许后台任务选择,可同时启用多个;
- 默认主模型:只能有一个,正式用户聊天、追问改写和检索重排始终使用它 - 默认主模型:只能有一个,承担未分流的正式聊天、追问改写和检索重排。
周期报告会选择“周期报告”能力已开启的可用模型,主题摘要和成长档案会选择“摘要沉淀”能力已开启的可用模型。若找不到匹配模型,会自动回退默认主模型,不会因为分流配置缺失直接中断任务。 周期报告会选择“周期报告”能力已开启的可用模型,主题摘要和成长档案会选择“摘要沉淀”能力已开启的可用模型。若找不到匹配模型,会自动回退默认主模型,不会因为分流配置缺失直接中断任务。
固定信息问答采用保守分流:只有本轮最终召回并采用的知识全部属于固定信息类时,才选择“固定信息”能力模型;混合召回、未命中和其他知识问答仍使用默认主模型。场景模型在尚未输出任何内容前调用失败,会自动重试默认主模型;已经输出部分内容后不会重新生成,避免重复内容。 正式问答的分流规则在“系统配置 / AI 问答 / 正式问答模型分流规则”中配置:
- 关闭分流:全部正式问答使用默认主模型;
- 仅固定信息(升级后的默认值):只有本轮最终召回并采用的知识全部属于固定信息类时,才选择“固定信息”能力模型;
- 保守分流:在上一条基础上,把短、明确、只命中课程/问答/通用知识,且不涉及个人感受、关系、建议或判断的问题交给“简单知识”能力模型。
混合固定信息、未命中、深度问题和个人化问题始终使用默认主模型。场景模型在尚未输出任何内容前调用失败,会自动重试默认主模型;已经输出部分内容后不会重新生成,避免重复内容。
部署迁移后,旧版本原来启用的模型会自动成为默认主模型。新增其他模型时建议按以下顺序操作: 部署迁移后,旧版本原来启用的模型会自动成为默认主模型。新增其他模型时建议按以下顺序操作:
1. 保存模型并执行“测试”; 1. 保存模型并执行“测试”;
2. 加入可用池; 2. 加入可用池;
3. 只勾选它实际承担的能力; 3. 只勾选它实际承担的能力;
4. 如需让低成本模型承担报告,应取消默认主模型的“周期报告”能力,避免默认模型优先命中; 4. 如需让低成本模型承担某一场景,应取消默认主模型的对应能力,并在专用模型上开启该能力,避免默认模型优先命中;
5. 在数据看板“模型使用与成本”中核对实际模型和成本。 5. 在数据看板“模型使用与成本”中核对实际模型和成本。
如线上发现固定信息模型质量或稳定性异常,可在“系统配置 / AI 问答”关闭“固定信息模型分流”,下一次提问立即恢复为默认主模型,无需重新部署。后台 Agent 预览选择默认主模型时会复用正式分流规则;显式选择非默认模型时视为人工调试覆盖,不执行自动分流。 如线上发现分流模型质量或稳定性异常,可把“正式问答模型分流规则”改为“关闭分流”,下一次提问立即恢复为默认主模型,无需重新部署。后台 Agent 预览选择默认主模型时会复用正式分流规则;显式选择非默认模型时视为人工调试覆盖,不执行自动分流。旧版本的 `fixed_info_model_routing_enabled` 开关仍兼容,但保存新规则后以 `chat_model_routing_mode` 为准。
停用或删除唯一默认主模型会被后端拒绝,必须先启用并设置替代主模型。该限制用于避免生产聊天突然变成无模型可用。 停用或删除唯一默认主模型会被后端拒绝,必须先启用并设置替代主模型。该限制用于避免生产聊天突然变成无模型可用。

View File

@@ -651,7 +651,7 @@ AI 日志增加:
- 2026-07-31一期已新增模型输入/输出千 Token 单价、币种、适用场景和可用能力字段AI 请求日志记录模型 ID、估算成本、币种、问题类型、知识命中和路由原因数据看板展示筛选范围内估算成本。暂未自动切换模型避免影响正式回答稳定性后续再基于这些字段做模型分流。 - 2026-07-31一期已新增模型输入/输出千 Token 单价、币种、适用场景和可用能力字段AI 请求日志记录模型 ID、估算成本、币种、问题类型、知识命中和路由原因数据看板展示筛选范围内估算成本。暂未自动切换模型避免影响正式回答稳定性后续再基于这些字段做模型分流。
- 2026-07-31二期先完成低风险后台任务分流。模型管理支持“多个可用模型 + 一个默认主模型”周期报告和主题摘要按能力标签选模型无匹配时回退默认主模型正式聊天、追问改写和检索重排仍固定使用默认主模型。后台生成会记录实际模型、分流原因、Token、耗时和估算成本数据看板增加按调用场景、模型和币种拆分的成本明细。固定信息和正式聊天的自动分流暂不启用待后台任务运行稳定并核对成本后再做。 - 2026-07-31二期先完成低风险后台任务分流。模型管理支持“多个可用模型 + 一个默认主模型”周期报告和主题摘要按能力标签选模型无匹配时回退默认主模型正式聊天、追问改写和检索重排仍固定使用默认主模型。后台生成会记录实际模型、分流原因、Token、耗时和估算成本数据看板增加按调用场景、模型和币种拆分的成本明细。固定信息和正式聊天的自动分流暂不启用待后台任务运行稳定并核对成本后再做。
- 2026-07-31固定信息问答已接入保守自动分流。只有本轮最终采用的召回内容全部来自固定信息类知识库时,才选择“固定信息”能力模型;未命中、非固定信息召回、固定信息与其他类型混合召回均继续使用默认主模型。场景模型在输出任何内容前失败时自动回退主模型,已开始输出后不重放,防止用户看到重复回答。用户端 AI 日志和后台 Agent 预览均展示实际模型、问题类型和路由原因;系统设置可通过 `fixed_info_model_routing_enabled` 即时关闭该分流 - 2026-07-31正式问答分流增加可配置规则,系统配置提供“关闭分流 / 仅固定信息 / 保守分流”三档,升级后默认保持“仅固定信息”,不会静默扩大分流范围。“保守分流”只把短、明确、仅命中课程/问答/通用知识,且不涉及个人感受、关系、建议、判断的问题交给“简单知识”能力模型;混合固定信息、未命中、深度或个人化问题仍使用默认主模型。用户端和后台 Agent 预览共用同一规则,日志继续记录实际模型、问题类型和路由原因;旧的 `fixed_info_model_routing_enabled` 配置仍作为兼容回退
--- ---