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

@@ -310,6 +310,7 @@ function modelSceneLabel(scene: string) {
return ({ return ({
background_report: "周期报告", background_report: "周期报告",
background_summary: "摘要沉淀", background_summary: "摘要沉淀",
fixed_info: "固定信息问答",
knowledge_grounded: "知识问答", knowledge_grounded: "知识问答",
general_chat: "通用对话", general_chat: "通用对话",
} as Record<string, string>)[scene] || scene || "未分类"; } as Record<string, string>)[scene] || scene || "未分类";
@@ -1459,12 +1460,15 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
<el-input v-model="modelForm.usageScenarios" placeholder="例如:正式对话、摘要、固定信息、报告批处理" /> <el-input v-model="modelForm.usageScenarios" placeholder="例如:正式对话、摘要、固定信息、报告批处理" />
</el-form-item> </el-form-item>
<el-form-item label="可用能力"> <el-form-item label="可用能力">
<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.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>
</div> </div>
<small class="model-routing-help">默认主模型勾选某项能力时会优先承担该场景如需专用模型接管请取消主模型的对应能力并为专用模型开启该能力</small>
</div>
</el-form-item> </el-form-item>
<el-form-item label="备注"> <el-form-item label="备注">
<el-input v-model="modelForm.remark" placeholder="用途、额度、注意事项等" /> <el-input v-model="modelForm.remark" placeholder="用途、额度、注意事项等" />

View File

@@ -33,6 +33,7 @@ const historyDetailOpen = ref(false);
const selectedHistory = ref<PromptDetail | null>(null); const selectedHistory = ref<PromptDetail | null>(null);
const historyDetailLoading = ref(false); const historyDetailLoading = ref(false);
const agentDebugTrace = ref<Record<string, any>[]>([]); const agentDebugTrace = ref<Record<string, any>[]>([]);
const lastDebugRoute = ref<{ modelName?: string; routeReason?: string } | null>(null);
const agentPreviewMessages = ref<{ const agentPreviewMessages = ref<{
role: "user" | "assistant" | "system"; role: "user" | "assistant" | "system";
content: string; content: string;
@@ -104,7 +105,9 @@ async function load() {
applyPrompt(promptResult); applyPrompt(promptResult);
models.value = modelRows; models.value = modelRows;
knowledge.value = knowledgeRows; knowledge.value = knowledgeRows;
agentForm.modelId = modelRows.find((item) => item.enabled === 1)?.id ?? modelRows[0]?.id; agentForm.modelId = modelRows.find((item) => item.isDefault === 1)?.id
?? modelRows.find((item) => item.enabled === 1)?.id
?? modelRows[0]?.id;
applyRuntimeConfig(formalConfig); applyRuntimeConfig(formalConfig);
applyDebugModelDefaults(agentForm.modelId); applyDebugModelDefaults(agentForm.modelId);
agentForm.knowledgeIds = knowledgeRows agentForm.knowledgeIds = knowledgeRows
@@ -313,6 +316,7 @@ async function debugAgent() {
agentForm.question = ""; agentForm.question = "";
agentDebugging.value = true; agentDebugging.value = true;
agentDebugTrace.value = []; agentDebugTrace.value = [];
lastDebugRoute.value = null;
agentDebugAbortController.value = new AbortController(); agentDebugAbortController.value = new AbortController();
scrollAgentPreview(); scrollAgentPreview();
try { try {
@@ -348,7 +352,8 @@ async function debugAgent() {
}, },
(result) => { (result) => {
agentDebugTrace.value = result.retrievalTrace || []; agentDebugTrace.value = result.retrievalTrace || [];
ElMessage.success(result.message || "Agent 调试完成"); lastDebugRoute.value = { modelName: result.modelName, routeReason: result.routeReason };
ElMessage.success(`${result.message || "Agent 调试完成"} · ${result.modelName || selectedModelName.value}`);
}, },
agentDebugAbortController.value.signal, agentDebugAbortController.value.signal,
); );
@@ -383,6 +388,7 @@ function scrollAgentPreview() {
function clearAgentPreview() { function clearAgentPreview() {
agentPreviewMessages.value = [{ role: "assistant", content: "预览已清空,可以继续发起新的 Agent 调试。" }]; agentPreviewMessages.value = [{ role: "assistant", content: "预览已清空,可以继续发起新的 Agent 调试。" }];
agentDebugTrace.value = []; agentDebugTrace.value = [];
lastDebugRoute.value = null;
} }
function changeTypeLabel(value?: string) { function changeTypeLabel(value?: string) {
@@ -588,7 +594,7 @@ function errorMessage(error: unknown, fallback: string) {
<aside class="agent-preview-panel"> <aside class="agent-preview-panel">
<div class="agent-preview-head"> <div class="agent-preview-head">
<div><h3>调试预览</h3><small>{{ selectedKnowledgeSummary }} · {{ selectedDebugUserLabel }}</small></div> <div><h3>调试预览</h3><small>{{ selectedKnowledgeSummary }} · {{ selectedDebugUserLabel }}</small></div>
<div class="agent-preview-head-actions"><span>{{ selectedModelName }}</span><el-button link :disabled="agentDebugging" @click="clearAgentPreview">清空</el-button></div> <div class="agent-preview-head-actions"><span :title="lastDebugRoute?.routeReason || ''">{{ lastDebugRoute?.modelName || selectedModelName }}</span><el-button link :disabled="agentDebugging" @click="clearAgentPreview">清空</el-button></div>
</div> </div>
<div ref="agentPreviewChat" class="agent-preview-chat"> <div ref="agentPreviewChat" class="agent-preview-chat">
<article v-for="(message, index) in agentPreviewMessages" :key="`${message.role}-${index}`" class="agent-preview-message-row" :class="message.role"> <article v-for="(message, index) in agentPreviewMessages" :key="`${message.role}-${index}`" class="agent-preview-message-row" :class="message.role">

View File

@@ -159,6 +159,13 @@ export const systemSettingSections: SystemSettingSection[] = [
defaultValue: true, defaultValue: true,
description: "后台始终记录引用;此项控制用户端是否展示。", description: "后台始终记录引用;此项控制用户端是否展示。",
}, },
{
key: "fixed_info_model_routing_enabled",
label: "固定信息模型分流",
type: "switch",
defaultValue: true,
description: "仅当本轮实际召回内容全部来自固定信息类知识库时使用对应低成本模型;关闭后统一使用默认主模型。",
},
{ {
key: "chat_max_active_requests", key: "chat_max_active_requests",
label: "问答最大并发数", label: "问答最大并发数",

View File

@@ -534,6 +534,7 @@ textarea {
.storage-grid strong { margin-top: 8px; font-size: 20px; } .storage-grid strong { margin-top: 8px; font-size: 20px; }
.model-capability-tags { display: flex; flex-wrap: wrap; gap: 6px; } .model-capability-tags { display: flex; flex-wrap: wrap; gap: 6px; }
.model-default-tag { margin-left: 6px; } .model-default-tag { margin-left: 6px; }
.model-routing-help { display: block; margin-top: 7px; color: #71827d; line-height: 1.55; }
.cleanup-controls { display: flex; align-items: center; gap: 8px; white-space: nowrap; } .cleanup-controls { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
.retention-panel { display: grid; grid-template-columns: minmax(260px, 1fr) 180px auto auto; align-items: center; gap: 12px; padding: 14px 16px; margin-bottom: 14px; border: 1px solid #dfe8e5; border-radius: 8px; background: #fff; } .retention-panel { display: grid; grid-template-columns: minmax(260px, 1fr) 180px auto auto; align-items: center; gap: 12px; padding: 14px 16px; margin-bottom: 14px; border: 1px solid #dfe8e5; border-radius: 8px; background: #fff; }
.retention-panel div span { display: block; margin-top: 4px; color: #667a73; font-size: 12px; } .retention-panel div span { display: block; margin-top: 4px; color: #667a73; font-size: 12px; }

View File

@@ -243,6 +243,8 @@ export interface AgentDebugResult {
knowledgeIds?: string; knowledgeIds?: string;
retrievalTrace?: Record<string, unknown>[]; retrievalTrace?: Record<string, unknown>[];
retrievalLogId?: number; retrievalLogId?: number;
routeReason?: string;
questionType?: string;
} }
export interface AgentDebugStreamComplete { export interface AgentDebugStreamComplete {
@@ -252,6 +254,8 @@ export interface AgentDebugStreamComplete {
knowledgeIds?: string; knowledgeIds?: string;
retrievalTrace?: Record<string, unknown>[]; retrievalTrace?: Record<string, unknown>[];
retrievalLogId?: number; retrievalLogId?: number;
routeReason?: string;
questionType?: string;
} }
export interface KnowledgeVersion { export interface KnowledgeVersion {

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from dataclasses import replace
from types import SimpleNamespace from types import SimpleNamespace
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -15,6 +16,7 @@ from app.services.entitlement_service import EntitlementService, entitlement_dic
from app.services.growth_profile_service import GrowthProfileService from app.services.growth_profile_service import GrowthProfileService
from app.services.knowledge_agent_service import KnowledgeAgentService from app.services.knowledge_agent_service import KnowledgeAgentService
from app.services.model_stream_service import ModelStreamService from app.services.model_stream_service import ModelStreamService
from app.services.model_routing_service import ModelRoutingService
from app.services.reasoning_policy_service import ReasoningPolicyService from app.services.reasoning_policy_service import ReasoningPolicyService
from app.services.rag_service import RagResult from app.services.rag_service import RagResult
from app.services.topic_session_service import TopicSessionService from app.services.topic_session_service import TopicSessionService
@@ -117,8 +119,8 @@ class AgentDebugService:
db: Session, db: Session,
current_admin: Admin, current_admin: Admin,
) -> AsyncIterator[dict]: ) -> AsyncIterator[dict]:
model = db.get(ModelConfig, payload.modelId) requested_model = db.get(ModelConfig, payload.modelId)
if model is None: if requested_model is None:
yield {"type": "error", "message": "模型不存在"} yield {"type": "error", "message": "模型不存在"}
return return
try: try:
@@ -130,10 +132,46 @@ class AgentDebugService:
"reasoningVisible": reasoning_visible, "reasoningVisible": reasoning_visible,
} }
rag_result = await cls.build_result(db, payload) rag_result = await cls.build_result(db, payload)
default_model = ModelRoutingService.default_model(db)
chat_route = ModelRoutingService.resolve_chat(
db,
[chunk.knowledge_type for chunk in rag_result.chunks],
)
automatic_route = default_model is not None and requested_model.id == default_model.id
if automatic_route:
model = chat_route.model or requested_model
route_reason = chat_route.reason
else:
model = requested_model
route_reason = "后台调试:管理员手动指定非默认模型,不执行自动模型分流"
route_trace = {
"tool": "model_route",
"order": len(rag_result.tool_trace or []) + 1,
"request": {
"requestedModelId": requested_model.id,
"automaticRoute": automatic_route,
"knowledgeTypes": sorted({chunk.knowledge_type for chunk in rag_result.chunks}),
},
"status": "success",
"durationMs": 0,
"response": {
"modelId": model.id,
"modelName": model.display_name or model.model_name,
"questionType": chat_route.question_type,
"routeReason": route_reason,
},
}
rag_result = replace(
rag_result,
tool_trace=[*(rag_result.tool_trace or []), route_trace],
)
model_response = ModelStreamService.debug_stream_async( model_response = ModelStreamService.debug_stream_async(
model, model,
rag_result, rag_result,
cls.overrides(payload), cls.overrides(payload),
fallback_model=default_model if automatic_route else None,
route_reason=route_reason,
question_type=chat_route.question_type,
) )
async for segment in ReasoningPolicyService.iter_segments(model_response.chunks): async for segment in ReasoningPolicyService.iter_segments(model_response.chunks):
if segment.kind == "content": if segment.kind == "content":
@@ -145,7 +183,7 @@ class AgentDebugService:
admin_id=current_admin.id, admin_id=current_admin.id,
module="agent", module="agent",
action="debug_stream", action="debug_stream",
target_id=model.id, target_id=model_response.model_id,
) )
db.commit() db.commit()
yield { yield {
@@ -156,6 +194,8 @@ class AgentDebugService:
"knowledgeIds": rag_result.knowledge_ids, "knowledgeIds": rag_result.knowledge_ids,
"retrievalTrace": rag_result.tool_trace or [], "retrievalTrace": rag_result.tool_trace or [],
"retrievalLogId": rag_result.retrieval_log_id, "retrievalLogId": rag_result.retrieval_log_id,
"routeReason": model_response.route_reason,
"questionType": model_response.question_type,
} }
except asyncio.CancelledError: except asyncio.CancelledError:
db.rollback() db.rollback()

View File

@@ -16,6 +16,7 @@ from app.services.external_errors import ExternalServiceError
from app.services.growth_profile_service import GrowthProfileService from app.services.growth_profile_service import GrowthProfileService
from app.services.chat_context_service import ChatContextService from app.services.chat_context_service import ChatContextService
from app.services.model_service import ModelClientService from app.services.model_service import ModelClientService
from app.services.model_routing_service import ModelRoutingService
from app.services.rag_service import RagService from app.services.rag_service import RagService
from app.services.topic_session_service import TopicSessionService from app.services.topic_session_service import TopicSessionService
@@ -142,19 +143,33 @@ class ChatService:
completion = ModelClientService.complete(db, rag_result) completion = ModelClientService.complete(db, rag_result)
except ExternalServiceError as exc: except ExternalServiceError as exc:
cost_ms = int((perf_counter() - started_at) * 1000) cost_ms = int((perf_counter() - started_at) * 1000)
failed_route = ModelRoutingService.resolve_chat(
db,
[chunk.knowledge_type for chunk in rag_result.chunks] if rag_result is not None else [],
)
AiRequestLogService.write_failed( AiRequestLogService.write_failed(
db, db,
session_id=session.id, session_id=session.id,
message_id=user_message.id, message_id=user_message.id,
user_id=user.id, user_id=user.id,
model_name=None, model_name=getattr(
exc,
"model_name",
failed_route.model.model_name if failed_route.model is not None else None,
),
prompt=rag_result.prompt if rag_result is not None else normalized_question, prompt=rag_result.prompt if rag_result is not None else normalized_question,
knowledge_ids=rag_result.knowledge_ids if rag_result is not None else None, knowledge_ids=rag_result.knowledge_ids if rag_result is not None else None,
retrieve_count=len(rag_result.chunks) if rag_result is not None else 0, retrieve_count=len(rag_result.chunks) if rag_result is not None else 0,
cost_ms=cost_ms, cost_ms=cost_ms,
error_message=str(exc), error_message=str(exc),
retrieved_chunks=rag_result.chunks if rag_result is not None else None, retrieved_chunks=rag_result.chunks if rag_result is not None else None,
model_id=None, model_id=getattr(
exc,
"model_id",
failed_route.model.id if failed_route.model is not None else None,
),
route_reason=getattr(exc, "route_reason", failed_route.reason),
question_type=getattr(exc, "question_type", failed_route.question_type),
) )
db.commit() db.commit()
raise HTTPException( raise HTTPException(
@@ -206,6 +221,8 @@ class ChatService:
cost_ms=cost_ms, cost_ms=cost_ms,
retrieved_chunks=rag_result.chunks, retrieved_chunks=rag_result.chunks,
model_id=completion.model_id, model_id=completion.model_id,
route_reason=completion.route_reason,
question_type=completion.question_type,
) )
db.commit() db.commit()
return completion.answer return completion.answer

View File

@@ -21,6 +21,7 @@ from app.services.external_errors import ExternalServiceError
from app.services.growth_profile_service import GrowthProfileService from app.services.growth_profile_service import GrowthProfileService
from app.services.human_attention_service import HumanAttentionService from app.services.human_attention_service import HumanAttentionService
from app.services.model_stream_service import ModelStreamService from app.services.model_stream_service import ModelStreamService
from app.services.model_routing_service import ModelRoutingService
from app.services.rag_async_service import AsyncRagService from app.services.rag_async_service import AsyncRagService
from app.services.rag_service import RagService from app.services.rag_service import RagService
from app.services.topic_session_service import TopicSessionService from app.services.topic_session_service import TopicSessionService
@@ -105,19 +106,22 @@ class ChatStreamService:
raise raise
except ExternalServiceError as exc: except ExternalServiceError as exc:
cost_ms = int((perf_counter() - started_at) * 1000) cost_ms = int((perf_counter() - started_at) * 1000)
failed_route = _model_log_context(db, rag_result, model_response)
AiRequestLogService.write_failed( AiRequestLogService.write_failed(
db, db,
session_id=session.id, session_id=session.id,
message_id=user_message.id, message_id=user_message.id,
user_id=user.id, user_id=user.id,
model_name=model_response.model_name if model_response is not None else None, model_name=failed_route[3],
prompt=rag_result.prompt if rag_result is not None else normalized_question, prompt=rag_result.prompt if rag_result is not None else normalized_question,
knowledge_ids=rag_result.knowledge_ids if rag_result is not None else None, knowledge_ids=rag_result.knowledge_ids if rag_result is not None else None,
retrieve_count=len(rag_result.chunks) if rag_result is not None else 0, retrieve_count=len(rag_result.chunks) if rag_result is not None else 0,
cost_ms=cost_ms, cost_ms=cost_ms,
error_message=str(exc), error_message=str(exc),
retrieved_chunks=rag_result.chunks if rag_result is not None else None, retrieved_chunks=rag_result.chunks if rag_result is not None else None,
model_id=model_response.model_id if model_response is not None else None, model_id=failed_route[2],
route_reason=failed_route[0],
question_type=failed_route[1],
) )
_mark_retrieval_failed(db, rag_result, str(exc), cost_ms) _mark_retrieval_failed(db, rag_result, str(exc), cost_ms)
db.commit() db.commit()
@@ -126,19 +130,22 @@ class ChatStreamService:
answer = "".join(answer_parts) answer = "".join(answer_parts)
if not answer.strip(): if not answer.strip():
cost_ms = int((perf_counter() - started_at) * 1000) cost_ms = int((perf_counter() - started_at) * 1000)
failed_route = _model_log_context(db, rag_result, model_response)
AiRequestLogService.write_failed( AiRequestLogService.write_failed(
db, db,
session_id=session.id, session_id=session.id,
message_id=user_message.id, message_id=user_message.id,
user_id=user.id, user_id=user.id,
model_name=model_response.model_name if model_response is not None else None, model_name=failed_route[3],
prompt=rag_result.prompt if rag_result is not None else normalized_question, prompt=rag_result.prompt if rag_result is not None else normalized_question,
knowledge_ids=rag_result.knowledge_ids if rag_result is not None else None, knowledge_ids=rag_result.knowledge_ids if rag_result is not None else None,
retrieve_count=len(rag_result.chunks) if rag_result is not None else 0, retrieve_count=len(rag_result.chunks) if rag_result is not None else 0,
cost_ms=cost_ms, cost_ms=cost_ms,
error_message="模型未返回有效内容", error_message="模型未返回有效内容",
retrieved_chunks=rag_result.chunks if rag_result is not None else None, retrieved_chunks=rag_result.chunks if rag_result is not None else None,
model_id=model_response.model_id if model_response is not None else None, model_id=failed_route[2],
route_reason=failed_route[0],
question_type=failed_route[1],
) )
_mark_retrieval_failed(db, rag_result, "模型未返回有效内容", cost_ms) _mark_retrieval_failed(db, rag_result, "模型未返回有效内容", cost_ms)
db.commit() db.commit()
@@ -188,6 +195,8 @@ class ChatStreamService:
cost_ms=cost_ms, cost_ms=cost_ms,
retrieved_chunks=rag_result.chunks if rag_result is not None else None, retrieved_chunks=rag_result.chunks if rag_result is not None else None,
model_id=model_response.model_id if model_response is not None else None, model_id=model_response.model_id if model_response is not None else None,
route_reason=model_response.route_reason if model_response is not None else None,
question_type=model_response.question_type if model_response is not None else None,
) )
db.commit() db.commit()
@@ -270,19 +279,22 @@ class ChatStreamService:
raise raise
except ExternalServiceError as exc: except ExternalServiceError as exc:
cost_ms = int((perf_counter() - started_at) * 1000) cost_ms = int((perf_counter() - started_at) * 1000)
failed_route = _model_log_context(db, rag_result, model_response)
AiRequestLogService.write_failed( AiRequestLogService.write_failed(
db, db,
session_id=session.id, session_id=session.id,
message_id=user_message.id, message_id=user_message.id,
user_id=user.id, user_id=user.id,
model_name=model_response.model_name if model_response is not None else None, model_name=failed_route[3],
prompt=rag_result.prompt if rag_result is not None else normalized_question, prompt=rag_result.prompt if rag_result is not None else normalized_question,
knowledge_ids=rag_result.knowledge_ids if rag_result is not None else None, knowledge_ids=rag_result.knowledge_ids if rag_result is not None else None,
retrieve_count=len(rag_result.chunks) if rag_result is not None else 0, retrieve_count=len(rag_result.chunks) if rag_result is not None else 0,
cost_ms=cost_ms, cost_ms=cost_ms,
error_message=str(exc), error_message=str(exc),
retrieved_chunks=rag_result.chunks if rag_result is not None else None, retrieved_chunks=rag_result.chunks if rag_result is not None else None,
model_id=model_response.model_id if model_response is not None else None, model_id=failed_route[2],
route_reason=failed_route[0],
question_type=failed_route[1],
) )
db.commit() db.commit()
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
@@ -290,19 +302,22 @@ class ChatStreamService:
answer = "".join(answer_parts) answer = "".join(answer_parts)
if not answer.strip(): if not answer.strip():
cost_ms = int((perf_counter() - started_at) * 1000) cost_ms = int((perf_counter() - started_at) * 1000)
failed_route = _model_log_context(db, rag_result, model_response)
AiRequestLogService.write_failed( AiRequestLogService.write_failed(
db, db,
session_id=session.id, session_id=session.id,
message_id=user_message.id, message_id=user_message.id,
user_id=user.id, user_id=user.id,
model_name=model_response.model_name if model_response is not None else None, model_name=failed_route[3],
prompt=rag_result.prompt if rag_result is not None else normalized_question, prompt=rag_result.prompt if rag_result is not None else normalized_question,
knowledge_ids=rag_result.knowledge_ids if rag_result is not None else None, knowledge_ids=rag_result.knowledge_ids if rag_result is not None else None,
retrieve_count=len(rag_result.chunks) if rag_result is not None else 0, retrieve_count=len(rag_result.chunks) if rag_result is not None else 0,
cost_ms=cost_ms, cost_ms=cost_ms,
error_message="模型未返回有效内容", error_message="模型未返回有效内容",
retrieved_chunks=rag_result.chunks if rag_result is not None else None, retrieved_chunks=rag_result.chunks if rag_result is not None else None,
model_id=model_response.model_id if model_response is not None else None, model_id=failed_route[2],
route_reason=failed_route[0],
question_type=failed_route[1],
) )
db.commit() db.commit()
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="模型未返回有效内容") raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="模型未返回有效内容")
@@ -387,6 +402,8 @@ def _write_success(
cost_ms=cost_ms, cost_ms=cost_ms,
retrieved_chunks=rag_result.chunks if rag_result is not None else None, retrieved_chunks=rag_result.chunks if rag_result is not None else None,
model_id=model_response.model_id if model_response is not None else None, model_id=model_response.model_id if model_response is not None else None,
route_reason=model_response.route_reason if model_response is not None else None,
question_type=model_response.question_type if model_response is not None else None,
) )
if rag_result is not None and rag_result.retrieval_log_id: if rag_result is not None and rag_result.retrieval_log_id:
retrieval_log = db.get(KnowledgeRetrievalLog, rag_result.retrieval_log_id) retrieval_log = db.get(KnowledgeRetrievalLog, rag_result.retrieval_log_id)
@@ -409,6 +426,30 @@ def _write_success(
db.commit() db.commit()
def _model_log_context(
db: Session,
rag_result,
model_response,
) -> tuple[str | None, str | None, int | None, str | None]:
if model_response is not None:
return (
model_response.route_reason,
model_response.question_type,
model_response.model_id,
model_response.model_name,
)
route = ModelRoutingService.resolve_chat(
db,
[chunk.knowledge_type for chunk in rag_result.chunks] if rag_result is not None else [],
)
return (
route.reason,
route.question_type,
route.model.id if route.model is not None else None,
route.model.model_name if route.model is not None else None,
)
def user_message_id_for_attention(db: Session, session_id: int, assistant_message_id: int) -> int: def user_message_id_for_attention(db: Session, session_id: int, assistant_message_id: int) -> int:
message_id = db.scalar( message_id = db.scalar(
select(ChatMessage.id) select(ChatMessage.id)

View File

@@ -6,7 +6,7 @@ from typing import Literal
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 from app.models.ai_config import ModelConfig, SystemConfig
ModelScenario = Literal["report", "summary", "fixed_info", "deep_chat"] ModelScenario = Literal["report", "summary", "fixed_info", "deep_chat"]
@@ -31,10 +31,11 @@ class ModelRoute:
scenario: ModelScenario scenario: ModelScenario
reason: str reason: str
fallback_used: bool fallback_used: bool
question_type: str | None = None
class ModelRoutingService: class ModelRoutingService:
"""Centralizes deterministic model selection without changing live-chat routing.""" """Centralizes deterministic, auditable model selection and safe fallback."""
@staticmethod @staticmethod
def default_model(db: Session) -> ModelConfig | None: def default_model(db: Session) -> ModelConfig | None:
@@ -87,3 +88,49 @@ class ModelRoutingService:
reason=f"场景分流:{label}无可用模型", reason=f"场景分流:{label}无可用模型",
fallback_used=True, fallback_used=True,
) )
@classmethod
def resolve_chat(cls, db: Session, knowledge_types: list[str]) -> ModelRoute:
normalized_types = {item.strip().lower() for item in knowledge_types if item and item.strip()}
if normalized_types == {"fixed"}:
if not cls._config_bool(db, "fixed_info_model_routing_enabled", True):
return ModelRoute(
model=cls.default_model(db),
scenario="deep_chat",
reason="正式聊天:仅召回固定信息类知识库,但固定信息模型分流开关已关闭;使用默认主模型",
fallback_used=False,
question_type="fixed_info",
)
route = cls.resolve(db, "fixed_info")
return ModelRoute(
model=route.model,
scenario=route.scenario,
reason=f"正式聊天:仅召回固定信息类知识库;{route.reason}",
fallback_used=route.fallback_used,
question_type="fixed_info",
)
default = cls.default_model(db)
if not normalized_types:
reason = "正式聊天:未召回知识库;使用默认主模型"
question_type = "general_chat"
elif "fixed" in normalized_types:
reason = "正式聊天:混合类型知识库召回;为保证综合判断使用默认主模型"
question_type = "knowledge_grounded"
else:
reason = "正式聊天:非固定信息类知识召回;使用默认主模型"
question_type = "knowledge_grounded"
return ModelRoute(
model=default,
scenario="deep_chat",
reason=reason if default is not None else f"{reason},但当前无可用模型",
fallback_used=False,
question_type=question_type,
)
@staticmethod
def _config_bool(db: Session, key: str, default: bool) -> bool:
config = db.scalar(select(SystemConfig).where(SystemConfig.config_key == key))
if config is None or not config.config_value.strip():
return default
return config.config_value.strip().lower() in {"1", "true", "yes", "on", "启用"}

View File

@@ -13,7 +13,7 @@ from sqlalchemy.orm import Session
from app.core.config import get_settings from app.core.config import get_settings
from app.models.ai_config import ModelConfig, SystemConfig from app.models.ai_config import ModelConfig, SystemConfig
from app.services.external_errors import ExternalServiceError from app.services.external_errors import ExternalServiceError
from app.services.model_routing_service import ModelRoutingService, ModelScenario from app.services.model_routing_service import ModelRoute, ModelRoutingService, ModelScenario
from app.services.rag_service import NO_HIT_ANSWER, RagResult from app.services.rag_service import NO_HIT_ANSWER, RagResult
from app.services.secret_service import SecretService from app.services.secret_service import SecretService
@@ -26,12 +26,17 @@ class ModelCompletion:
input_token: int input_token: int
output_token: int output_token: int
route_reason: str | None = None route_reason: str | None = None
question_type: str | None = None
class ModelClientService: class ModelClientService:
@staticmethod @staticmethod
def complete(db: Session, rag_result: RagResult) -> ModelCompletion: def complete(db: Session, rag_result: RagResult) -> ModelCompletion:
model = ModelClientService._get_enabled_model(db) route = ModelRoutingService.resolve_chat(
db,
[chunk.knowledge_type for chunk in rag_result.chunks],
)
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)
if mock_model_enabled: if mock_model_enabled:
model_name = model.model_name if model is not None else "mock-model" model_name = model.model_name if model is not None else "mock-model"
@@ -40,13 +45,41 @@ class ModelClientService:
if model is None: if model is None:
raise ExternalServiceError("未启用可用模型,请先在模型管理中启用一个模型。", provider="model") raise ExternalServiceError("未启用可用模型,请先在模型管理中启用一个模型。", provider="model")
model_name = model.model_name model_name = model.model_name
try:
answer = _call_configured_model(model, rag_result, allow_no_hit=True) answer = _call_configured_model(model, rag_result, allow_no_hit=True)
except ExternalServiceError as primary_error:
fallback = ModelRoutingService.default_model(db)
if fallback is None or fallback.id == model.id:
_annotate_model_error(primary_error, model, route.reason, route.question_type)
raise
fallback_reason = f"{route.reason};场景模型调用失败,运行时回退默认主模型"
try:
answer = _call_configured_model(fallback, rag_result, allow_no_hit=True)
except ExternalServiceError as fallback_error:
_annotate_model_error(
fallback_error,
fallback,
f"{fallback_reason};默认主模型调用仍失败",
route.question_type,
)
raise
model = fallback
model_name = fallback.model_name
route = ModelRoute(
model=fallback,
scenario=route.scenario,
reason=fallback_reason,
fallback_used=True,
question_type=route.question_type,
)
return ModelCompletion( return ModelCompletion(
answer=answer, answer=answer,
model_id=model.id if model is not None else None, model_id=model.id if model is not None else None,
model_name=model_name, model_name=model_name,
input_token=_rough_token_count(rag_result.prompt), input_token=_rough_token_count(rag_result.prompt),
output_token=_rough_token_count(answer), output_token=_rough_token_count(answer),
route_reason=route.reason,
question_type=route.question_type,
) )
@staticmethod @staticmethod
@@ -186,6 +219,18 @@ def _mock_answer(rag_result: RagResult) -> str:
) )
def _annotate_model_error(
error: ExternalServiceError,
model: ModelConfig,
route_reason: str,
question_type: str | None,
) -> None:
error.model_id = model.id
error.model_name = model.model_name
error.route_reason = route_reason
error.question_type = question_type
def _system_config_bool(db: Session, key: str, default: bool) -> bool: def _system_config_bool(db: Session, key: str, default: bool) -> bool:
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():

View File

@@ -29,30 +29,35 @@ from app.services.model_service import (
_system_and_turn_messages, _system_and_turn_messages,
_system_config_bool, _system_config_bool,
) )
from app.services.model_routing_service import ModelRoutingService from app.services.model_routing_service import ModelRoute, ModelRoutingService
from app.services.rag_service import RagResult from app.services.rag_service import RagResult
@dataclass(frozen=True) @dataclass
class StreamingModelResponse: class StreamingModelResponse:
model_id: int | None model_id: int | None
model_name: str model_name: str
input_token: int input_token: int
chunks: Iterator[str] chunks: Iterator[str]
route_reason: str | None = None
question_type: str | None = None
@dataclass(frozen=True) @dataclass
class AsyncStreamingModelResponse: class AsyncStreamingModelResponse:
model_id: int | None model_id: int | None
model_name: str model_name: str
input_token: int input_token: int
chunks: AsyncIterator[str] chunks: AsyncIterator[str]
route_reason: str | None = None
question_type: str | None = None
class ModelStreamService: class ModelStreamService:
@staticmethod @staticmethod
def stream(db: Session, rag_result: RagResult) -> StreamingModelResponse: def stream(db: Session, rag_result: RagResult) -> StreamingModelResponse:
model = _get_enabled_model(db) route = _chat_route(db, rag_result)
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)
if mock_model_enabled: if mock_model_enabled:
@@ -62,23 +67,26 @@ class ModelStreamService:
model_name=model_name, model_name=model_name,
input_token=_rough_token_count(rag_result.prompt), input_token=_rough_token_count(rag_result.prompt),
chunks=_display_chunks(model, _mock_answer(rag_result)), chunks=_display_chunks(model, _mock_answer(rag_result)),
route_reason=route.reason,
question_type=route.question_type,
) )
if model is None: model, fallback, route_reason = _prepare_routed_model(db, route)
raise ExternalServiceError("未启用可用模型,请先在模型管理中启用一个模型。", provider="model") response = StreamingModelResponse(
if not (model.api_url or model.base_url) or not model.api_key:
raise ExternalServiceError("模型 Base URL/API URL 或 API Key 未配置", provider="model")
return StreamingModelResponse(
model_id=model.id, model_id=model.id,
model_name=model.model_name, model_name=model.model_name,
input_token=_rough_token_count(rag_result.prompt), input_token=_rough_token_count(rag_result.prompt),
chunks=_stream_configured_model(model, rag_result), chunks=iter(()),
route_reason=route_reason,
question_type=route.question_type,
) )
response.chunks = _stream_with_runtime_fallback(response, model, fallback, rag_result)
return response
@staticmethod @staticmethod
def stream_async(db: Session, rag_result: RagResult) -> AsyncStreamingModelResponse: def stream_async(db: Session, rag_result: RagResult) -> AsyncStreamingModelResponse:
model = _get_enabled_model(db) route = _chat_route(db, rag_result)
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)
if mock_model_enabled: if mock_model_enabled:
@@ -88,39 +96,141 @@ class ModelStreamService:
model_name=model_name, model_name=model_name,
input_token=_rough_token_count(rag_result.prompt), input_token=_rough_token_count(rag_result.prompt),
chunks=_async_display_chunks(model, _mock_answer(rag_result)), chunks=_async_display_chunks(model, _mock_answer(rag_result)),
route_reason=route.reason,
question_type=route.question_type,
) )
if model is None: model, fallback, route_reason = _prepare_routed_model(db, route)
raise ExternalServiceError("未启用可用模型,请先在模型管理中启用一个模型。", provider="model") response = AsyncStreamingModelResponse(
if not (model.api_url or model.base_url) or not model.api_key:
raise ExternalServiceError("模型 Base URL/API URL 或 API Key 未配置", provider="model")
return AsyncStreamingModelResponse(
model_id=model.id, model_id=model.id,
model_name=model.model_name, model_name=model.model_name,
input_token=_rough_token_count(rag_result.prompt), input_token=_rough_token_count(rag_result.prompt),
chunks=_stream_configured_model_async(model, rag_result), chunks=_empty_async_chunks(),
route_reason=route_reason,
question_type=route.question_type,
) )
response.chunks = _stream_with_runtime_fallback_async(response, model, fallback, rag_result)
return response
@staticmethod @staticmethod
def debug_stream_async( def debug_stream_async(
model: ModelConfig, model: ModelConfig,
rag_result: RagResult, rag_result: RagResult,
overrides: dict[str, Any], overrides: dict[str, Any],
*,
fallback_model: ModelConfig | None = None,
route_reason: str = "后台调试:管理员手动指定模型",
question_type: str | None = None,
) -> AsyncStreamingModelResponse: ) -> AsyncStreamingModelResponse:
debug_model = _copy_model_with_overrides(model, overrides) debug_model = _copy_model_with_overrides(model, overrides)
if not (debug_model.api_url or debug_model.base_url) or not debug_model.api_key: debug_fallback = (
_copy_model_with_overrides(fallback_model, overrides)
if fallback_model is not None and fallback_model.id != model.id
else None
)
if not _is_configured(debug_model):
if debug_fallback is None or not _is_configured(debug_fallback):
raise ExternalServiceError("模型 Base URL/API URL 或 API Key 未配置", provider="model") raise ExternalServiceError("模型 Base URL/API URL 或 API Key 未配置", provider="model")
return AsyncStreamingModelResponse( debug_model = debug_fallback
model_id=model.id, route_reason = f"{route_reason};场景模型配置不可用,运行时回退默认主模型"
model_name=model.model_name, response = AsyncStreamingModelResponse(
model_id=debug_model.id,
model_name=debug_model.model_name,
input_token=_rough_token_count(rag_result.prompt), input_token=_rough_token_count(rag_result.prompt),
chunks=_stream_configured_model_async(debug_model, rag_result), chunks=_empty_async_chunks(),
route_reason=route_reason,
question_type=question_type,
)
response.chunks = _stream_with_runtime_fallback_async(
response,
debug_model,
debug_fallback,
rag_result,
)
return response
def _chat_route(db: Session, rag_result: RagResult) -> ModelRoute:
return ModelRoutingService.resolve_chat(
db,
[chunk.knowledge_type for chunk in rag_result.chunks],
) )
def _get_enabled_model(db: Session) -> ModelConfig | None: def _prepare_routed_model(
return ModelRoutingService.default_model(db) db: Session,
route: ModelRoute,
) -> tuple[ModelConfig, ModelConfig | None, str]:
model = route.model
if model is None:
raise ExternalServiceError("未启用可用模型,请先在模型管理中启用一个模型。", provider="model")
fallback = _runtime_fallback_model(db, model)
if _is_configured(model):
return model, fallback, route.reason
if fallback is None or not _is_configured(fallback):
raise ExternalServiceError("模型 Base URL/API URL 或 API Key 未配置", provider="model")
return fallback, fallback, f"{route.reason};场景模型配置不可用,运行时回退默认主模型"
def _runtime_fallback_model(db: Session, selected: ModelConfig) -> ModelConfig | None:
default = ModelRoutingService.default_model(db)
if default is None or default.id == selected.id:
return None
return default
def _is_configured(model: ModelConfig) -> bool:
return bool((model.api_url or model.base_url) and model.api_key)
def _stream_with_runtime_fallback(
response: StreamingModelResponse,
model: ModelConfig,
fallback: ModelConfig | None,
rag_result: RagResult,
) -> Iterator[str]:
emitted = False
try:
for chunk in _stream_configured_model(model, rag_result):
if chunk:
emitted = True
yield chunk
return
except ExternalServiceError:
if emitted or fallback is None or fallback.id == model.id or not _is_configured(fallback):
raise
response.model_id = fallback.id
response.model_name = fallback.model_name
response.route_reason = f"{response.route_reason};场景模型调用失败,运行时回退默认主模型"
yield from _stream_configured_model(fallback, rag_result)
async def _stream_with_runtime_fallback_async(
response: AsyncStreamingModelResponse,
model: ModelConfig,
fallback: ModelConfig | None,
rag_result: RagResult,
) -> AsyncIterator[str]:
emitted = False
try:
async for chunk in _stream_configured_model_async(model, rag_result):
if chunk:
emitted = True
yield chunk
return
except ExternalServiceError:
if emitted or fallback is None or fallback.id == model.id or not _is_configured(fallback):
raise
response.model_id = fallback.id
response.model_name = fallback.model_name
response.route_reason = f"{response.route_reason};场景模型调用失败,运行时回退默认主模型"
async for chunk in _stream_configured_model_async(fallback, rag_result):
yield chunk
async def _empty_async_chunks() -> AsyncIterator[str]:
if False:
yield ""
def _stream_configured_model(model: ModelConfig, rag_result: RagResult) -> Iterator[str]: def _stream_configured_model(model: ModelConfig, rag_result: RagResult) -> Iterator[str]:

View File

@@ -21,7 +21,7 @@ from app.services.model_stream_service import (
_stream_configured_model_async, _stream_configured_model_async,
) )
from app.services.model_service import ModelClientService, _copy_model_with_overrides, _max_output_tokens 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: 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): with patch("app.services.model_stream_service._call_configured_model", return_value=answer):
chunks = asyncio.run(collect()) chunks = asyncio.run(collect())
assert chunks == [answer] 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(): 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

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from decimal import Decimal from decimal import Decimal
import pytest import pytest
@@ -16,6 +17,9 @@ from app.models.logs import AiRequestLog
from app.schemas.admin import DefaultModelRequest, EnableModelRequest from app.schemas.admin import DefaultModelRequest, EnableModelRequest
from app.services.model_routing_service import ModelRoutingService from app.services.model_routing_service import ModelRoutingService
from app.services.model_service import ModelClientService 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 from app.services.tracked_generation_service import TrackedGenerationService
@@ -36,6 +40,7 @@ def _model(
is_default: int, is_default: int,
allow_report: int, allow_report: int,
allow_summary: int, allow_summary: int,
allow_fixed_info: int = 1,
) -> ModelConfig: ) -> ModelConfig:
return ModelConfig( return ModelConfig(
id=model_id, id=model_id,
@@ -48,7 +53,7 @@ def _model(
is_default=is_default, is_default=is_default,
allow_report=allow_report, allow_report=allow_report,
allow_summary=allow_summary, allow_summary=allow_summary,
allow_fixed_info=1, allow_fixed_info=allow_fixed_info,
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"),
@@ -86,6 +91,127 @@ def test_background_scenario_falls_back_to_default_model():
assert "回退默认主模型" in route.reason 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(): def test_tracked_background_generation_records_actual_route_tokens_and_cost():
with _db() as db: with _db() as db:
db.add(SystemConfig(config_key="mock_model_enabled", config_value="true")) 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, current_admin=admin,
) )
assert exc_info.value.status_code == 400 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="本周上课时间是什么?",
)

View File

@@ -172,6 +172,8 @@ PERIODIC_REPORT_MAX_ATTEMPTS=3
周期报告会选择“周期报告”能力已开启的可用模型,主题摘要和成长档案会选择“摘要沉淀”能力已开启的可用模型。若找不到匹配模型,会自动回退默认主模型,不会因为分流配置缺失直接中断任务。 周期报告会选择“周期报告”能力已开启的可用模型,主题摘要和成长档案会选择“摘要沉淀”能力已开启的可用模型。若找不到匹配模型,会自动回退默认主模型,不会因为分流配置缺失直接中断任务。
固定信息问答采用保守分流:只有本轮最终召回并采用的知识全部属于固定信息类时,才选择“固定信息”能力模型;混合召回、未命中和其他知识问答仍使用默认主模型。场景模型在尚未输出任何内容前调用失败,会自动重试默认主模型;已经输出部分内容后不会重新生成,避免重复内容。
部署迁移后,旧版本原来启用的模型会自动成为默认主模型。新增其他模型时建议按以下顺序操作: 部署迁移后,旧版本原来启用的模型会自动成为默认主模型。新增其他模型时建议按以下顺序操作:
1. 保存模型并执行“测试”; 1. 保存模型并执行“测试”;
@@ -180,6 +182,8 @@ PERIODIC_REPORT_MAX_ATTEMPTS=3
4. 如需让低成本模型承担报告,应取消默认主模型的“周期报告”能力,避免默认模型优先命中; 4. 如需让低成本模型承担报告,应取消默认主模型的“周期报告”能力,避免默认模型优先命中;
5. 在数据看板“模型使用与成本”中核对实际模型和成本。 5. 在数据看板“模型使用与成本”中核对实际模型和成本。
如线上发现固定信息模型质量或稳定性异常,可在“系统配置 / AI 问答”关闭“固定信息模型分流”,下一次提问立即恢复为默认主模型,无需重新部署。后台 Agent 预览选择默认主模型时会复用正式分流规则;显式选择非默认模型时视为人工调试覆盖,不执行自动分流。
停用或删除唯一默认主模型会被后端拒绝,必须先启用并设置替代主模型。该限制用于避免生产聊天突然变成无模型可用。 停用或删除唯一默认主模型会被后端拒绝,必须先启用并设置替代主模型。该限制用于避免生产聊天突然变成无模型可用。
## 回滚原则 ## 回滚原则

View File

@@ -651,6 +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` 即时关闭该分流。
--- ---