feat: add per-model concurrency limits
This commit is contained in:
1 parent
24eb629773
commit
aca089e8ef
11 files changed
+695
-25
No files matched your search
@@ -7,6 +7,7 @@ import {
|
||||
ChevronsUpDown,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Gauge,
|
||||
ListTree,
|
||||
MousePointerClick,
|
||||
Settings,
|
||||
@@ -231,6 +232,14 @@ function SecondaryNavSidebar() {
|
||||
白名单管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/settings/model-concurrency")}
|
||||
>
|
||||
<Gauge className="size-4" />
|
||||
模型并发限制
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canManageSensitiveWords() && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/sensitive-words")}
|
||||
|
||||
@@ -342,6 +342,18 @@ export interface SkillCompressionUpdate {
|
||||
keep_index_in_prompt?: boolean;
|
||||
}
|
||||
|
||||
export interface ModelConcurrencySettings {
|
||||
enabled: boolean;
|
||||
default_limit: number;
|
||||
model_limits: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface ModelConcurrencyUpdate {
|
||||
enabled?: boolean;
|
||||
default_limit?: number;
|
||||
model_limits?: Record<string, number>;
|
||||
}
|
||||
|
||||
export async function loadSkillCompressionSettings(): Promise<SkillCompressionSettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/skill-compression`,
|
||||
@@ -361,6 +373,53 @@ export async function loadSkillCompressionSettings(): Promise<SkillCompressionSe
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function loadModelConcurrencySettings(): Promise<ModelConcurrencySettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/model-concurrency`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to load model concurrency settings: ${response.status}`,
|
||||
);
|
||||
}
|
||||
const data = (await response.json()) as ModelConcurrencySettings;
|
||||
writeYLog({
|
||||
optType: OptType.view,
|
||||
optDetail: "CM助手-全量-系统设置-模型并发限制查看",
|
||||
url: "/api/system-settings/model-concurrency",
|
||||
returnParam: data,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateModelConcurrencySettings(
|
||||
body: ModelConcurrencyUpdate,
|
||||
): Promise<ModelConcurrencySettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/model-concurrency`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Failed to update model concurrency settings" }));
|
||||
throw new Error(detail.detail ?? "Failed to update model concurrency settings");
|
||||
}
|
||||
const data = (await response.json()) as ModelConcurrencySettings;
|
||||
writeYLog({
|
||||
optType: OptType.edit,
|
||||
optDetail: "CM助手-全量-系统设置-模型并发限制编辑",
|
||||
url: "/api/system-settings/model-concurrency",
|
||||
reqParam: body,
|
||||
returnParam: data,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateSkillCompressionSettings(
|
||||
body: SkillCompressionUpdate,
|
||||
): Promise<SkillCompressionSettings> {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
loadAppearanceSettings,
|
||||
loadCitationDisplaySettings,
|
||||
loadLeaderboardSettings,
|
||||
loadModelConcurrencySettings,
|
||||
loadPrivateSquareStatus,
|
||||
loadPromptPrefixSettings,
|
||||
loadPublishSquares,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
updateAppearanceSettings,
|
||||
updateCitationDisplaySettings,
|
||||
updateLeaderboardSettings,
|
||||
updateModelConcurrencySettings,
|
||||
updatePrivateSquareSettings,
|
||||
updatePromptPrefixSettings,
|
||||
updatePublishSquares,
|
||||
@@ -22,6 +24,7 @@ import {
|
||||
type AppearanceUpdate,
|
||||
type CitationDisplayUpdate,
|
||||
type LeaderboardSettingsUpdate,
|
||||
type ModelConcurrencyUpdate,
|
||||
type PrivateSquareUpdate,
|
||||
type PromptPrefixUpdate,
|
||||
type PublishSquaresUpdate,
|
||||
@@ -37,6 +40,7 @@ const APPEARANCE_KEY = ["systemSettings", "appearance"] as const;
|
||||
const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const;
|
||||
const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
|
||||
const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const;
|
||||
const MODEL_CONCURRENCY_KEY = ["systemSettings", "modelConcurrency"] as const;
|
||||
|
||||
export function usePromptPrefixSettings() {
|
||||
return useQuery({
|
||||
@@ -238,6 +242,27 @@ export function useUpdateSkillCompressionSettings() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useModelConcurrencySettings() {
|
||||
return useQuery({
|
||||
queryKey: MODEL_CONCURRENCY_KEY,
|
||||
queryFn: () => loadModelConcurrencySettings(),
|
||||
staleTime: 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateModelConcurrencySettings() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: ModelConcurrencyUpdate) =>
|
||||
updateModelConcurrencySettings(body),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(MODEL_CONCURRENCY_KEY, data);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useLeaderboardSettings() {
|
||||
return useQuery({
|
||||
queryKey: LEADERBOARD_KEY,
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import {
|
||||
useModelConcurrencySettings,
|
||||
useUpdateModelConcurrencySettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
|
||||
export default function ModelConcurrencySettingsPage({
|
||||
embedded = false,
|
||||
}: {
|
||||
embedded?: boolean;
|
||||
} = {}) {
|
||||
const { data, isLoading, error } = useModelConcurrencySettings();
|
||||
const { models } = useModels();
|
||||
const update = useUpdateModelConcurrencySettings();
|
||||
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [defaultLimit, setDefaultLimit] = useState(10);
|
||||
const [modelLimits, setModelLimits] = useState<Record<string, string>>({});
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || initializedRef.current) return;
|
||||
setEnabled(data.enabled);
|
||||
setDefaultLimit(data.default_limit);
|
||||
setModelLimits(
|
||||
Object.fromEntries(
|
||||
Object.entries(data.model_limits ?? {}).map(([name, limit]) => [
|
||||
name,
|
||||
String(limit),
|
||||
]),
|
||||
),
|
||||
);
|
||||
initializedRef.current = true;
|
||||
}, [data]);
|
||||
|
||||
const modelNames = useMemo(() => models.map((model) => model.name), [models]);
|
||||
|
||||
const handleModelLimitChange = (modelName: string, value: string) => {
|
||||
setModelLimits((prev) => ({ ...prev, [modelName]: value }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const nextDefault = Math.max(1, Math.min(Math.round(defaultLimit) || 10, 10000));
|
||||
const cleaned: Record<string, number> = {};
|
||||
for (const [name, rawValue] of Object.entries(modelLimits)) {
|
||||
const trimmed = rawValue.trim();
|
||||
if (!trimmed) continue;
|
||||
const parsed = Math.round(Number(trimmed));
|
||||
if (!Number.isFinite(parsed) || parsed < 1) {
|
||||
toast.error(`模型 ${name} 的并发上限必须是大于 0 的整数`);
|
||||
return;
|
||||
}
|
||||
cleaned[name] = Math.min(parsed, 10000);
|
||||
}
|
||||
|
||||
try {
|
||||
await update.mutateAsync({
|
||||
enabled,
|
||||
default_limit: nextDefault,
|
||||
model_limits: cleaned,
|
||||
});
|
||||
setDefaultLimit(nextDefault);
|
||||
setModelLimits(
|
||||
Object.fromEntries(
|
||||
Object.entries(cleaned).map(([name, limit]) => [name, String(limit)]),
|
||||
),
|
||||
);
|
||||
toast.success("已保存");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
embedded
|
||||
? "flex w-full flex-col gap-6"
|
||||
: "mx-auto flex w-full max-w-3xl flex-col gap-6 px-6 py-10"
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<h1 className={embedded ? "text-base font-semibold" : "text-xl font-semibold"}>
|
||||
模型并发限制
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
按模型限制同时进行中的问答数。达到上限后,新请求会收到“当前并发已达到上限,请稍后再试”的提示。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2Icon className="text-muted-foreground size-6 animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-destructive rounded-xl border border-dashed py-16 text-center text-sm">
|
||||
加载失败:{error.message}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-5 rounded-xl border bg-white px-5 py-5 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">开启限制</div>
|
||||
<div className="text-muted-foreground mt-0.5 text-xs">
|
||||
关闭后,不再按模型并发数拦截问答请求。
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-sm font-medium">默认上限</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
未单独配置的模型,使用这个并发上限。
|
||||
</p>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10000}
|
||||
value={defaultLimit}
|
||||
disabled={!enabled}
|
||||
onChange={(e) => setDefaultLimit(Number(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">按模型单独设置</p>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
留空表示使用默认上限。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
{modelNames.length === 0 ? (
|
||||
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-6 text-sm">
|
||||
当前没有可配置的模型。
|
||||
</div>
|
||||
) : (
|
||||
modelNames.map((modelName) => (
|
||||
<div
|
||||
key={modelName}
|
||||
className="flex items-center justify-between gap-4 rounded-lg border px-4 py-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{modelName}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
留空时使用默认上限 {defaultLimit || 10}
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10000}
|
||||
value={modelLimits[modelName] ?? ""}
|
||||
disabled={!enabled}
|
||||
onChange={(e) => handleModelLimitChange(modelName, e.target.value)}
|
||||
className="w-32"
|
||||
placeholder={String(defaultLimit || 10)}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSave} disabled={update.isPending}>
|
||||
{update.isPending && (
|
||||
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
|
||||
)}
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -51,6 +51,7 @@ import MemoryPage from "./MemoryPage";
|
||||
import NewAgentPage from "./NewAgentPage";
|
||||
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
|
||||
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
|
||||
import ModelConcurrencySettingsPage from "./ModelConcurrencySettingsPage";
|
||||
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
||||
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
||||
import ScheduledTasksPage from "./ScheduledTasksPage";
|
||||
@@ -346,6 +347,16 @@ export default function StrategyRoutes() {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/model-concurrency"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<ModelConcurrencySettingsPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/llm-metrics"
|
||||
element={
|
||||
|
||||
@@ -31,6 +31,7 @@ import CheckpointMigrationPage from './CheckpointMigrationPage';
|
||||
import MemoryPage from './MemoryPage';
|
||||
import PromptPrefixSettingsPage from './PromptPrefixSettingsPage';
|
||||
import PrivateSquareSettingsPage from './PrivateSquareSettingsPage';
|
||||
import ModelConcurrencySettingsPage from './ModelConcurrencySettingsPage';
|
||||
import PublishSquaresSettingsPage from './PublishSquaresSettingsPage';
|
||||
import SkillCompressionSettingsPage from './SkillCompressionSettingsPage';
|
||||
import SkillEvolutionPage from './SkillEvolutionPage';
|
||||
@@ -562,6 +563,18 @@ export default function WorkspaceRoutes({
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/model-concurrency"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ModelConcurrencySettingsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/publish-squares"
|
||||
element={
|
||||
|
||||
@@ -54,6 +54,7 @@ class ActiveRunItem(BaseModel):
|
||||
user_name: str = ""
|
||||
agent_id: str = ""
|
||||
agent_name: str = ""
|
||||
model_name: str = ""
|
||||
thread_title: str = ""
|
||||
kind: str = "对话"
|
||||
multitask_strategy: str = "reject"
|
||||
@@ -245,6 +246,7 @@ async def list_active_runs(request: Request) -> ActiveRunsResponse:
|
||||
user_name=user_emails.get(user_id) or user_id or "未知用户",
|
||||
agent_id=assistant_id,
|
||||
agent_name=agent_name,
|
||||
model_name=str((r.metadata or {}).get("model_name") or ""),
|
||||
thread_title=(meta_row.display_name if meta_row else "") or "",
|
||||
kind=_kind_for(metadata),
|
||||
multitask_strategy=r.multitask_strategy,
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.gateway.deps import get_optional_user_from_request
|
||||
from deerflow.config import get_app_config
|
||||
from deerflow.config.system_settings import (
|
||||
LeaderboardSettings,
|
||||
ModelConcurrencySettings,
|
||||
PromptPrefixSettings,
|
||||
PublishSquare,
|
||||
PublishSquaresSettings,
|
||||
@@ -128,6 +129,18 @@ class SkillCompressionUpdateRequest(BaseModel):
|
||||
keep_index_in_prompt: bool | None = None
|
||||
|
||||
|
||||
class ModelConcurrencyResponse(BaseModel):
|
||||
enabled: bool = Field(..., description="Whether per-model concurrency limiting is active")
|
||||
default_limit: int = Field(..., description="Fallback max concurrent runs for models without an explicit override")
|
||||
model_limits: dict[str, int] = Field(default_factory=dict, description="Per-model overrides: model_name -> max concurrent runs")
|
||||
|
||||
|
||||
class ModelConcurrencyUpdateRequest(BaseModel):
|
||||
enabled: bool | None = None
|
||||
default_limit: int | None = Field(default=None, ge=1, le=10_000)
|
||||
model_limits: dict[str, int] | None = None
|
||||
|
||||
|
||||
def _skill_compression_to_response(settings: SkillCompressionSettings) -> SkillCompressionResponse:
|
||||
return SkillCompressionResponse(
|
||||
enabled=settings.enabled,
|
||||
@@ -136,6 +149,26 @@ def _skill_compression_to_response(settings: SkillCompressionSettings) -> SkillC
|
||||
)
|
||||
|
||||
|
||||
def _model_concurrency_to_response(settings: ModelConcurrencySettings) -> ModelConcurrencyResponse:
|
||||
cleaned: dict[str, int] = {}
|
||||
for name, limit in (settings.model_limits or {}).items():
|
||||
model_name = str(name).strip()
|
||||
if not model_name:
|
||||
continue
|
||||
try:
|
||||
normalized_limit = int(limit)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if normalized_limit < 1:
|
||||
continue
|
||||
cleaned[model_name] = min(normalized_limit, 10_000)
|
||||
return ModelConcurrencyResponse(
|
||||
enabled=settings.enabled,
|
||||
default_limit=max(1, min(int(settings.default_limit), 10_000)),
|
||||
model_limits=cleaned,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/skill-compression", response_model=SkillCompressionResponse)
|
||||
async def get_skill_compression() -> SkillCompressionResponse:
|
||||
"""Return the skill-compression configuration (readable by any user).
|
||||
@@ -174,6 +207,47 @@ async def update_skill_compression(body: SkillCompressionUpdateRequest, request:
|
||||
return _skill_compression_to_response(saved.skill_compression)
|
||||
|
||||
|
||||
@router.get("/model-concurrency", response_model=ModelConcurrencyResponse)
|
||||
async def get_model_concurrency(request: Request) -> ModelConcurrencyResponse:
|
||||
"""Return the per-model concurrency-limit configuration. Admin only."""
|
||||
await _require_admin(request)
|
||||
return _model_concurrency_to_response(load_system_settings().model_concurrency)
|
||||
|
||||
|
||||
@router.put("/model-concurrency", response_model=ModelConcurrencyResponse)
|
||||
async def update_model_concurrency(
|
||||
body: ModelConcurrencyUpdateRequest,
|
||||
request: Request,
|
||||
) -> ModelConcurrencyResponse:
|
||||
"""Update the per-model concurrency-limit configuration. Admin only."""
|
||||
await _require_admin(request)
|
||||
|
||||
current = load_system_settings()
|
||||
fields = body.model_dump(exclude_unset=True)
|
||||
update: dict[str, object] = {}
|
||||
if "enabled" in fields and fields["enabled"] is not None:
|
||||
update["enabled"] = bool(fields["enabled"])
|
||||
if "default_limit" in fields and fields["default_limit"] is not None:
|
||||
update["default_limit"] = max(1, min(int(fields["default_limit"]), 10_000))
|
||||
if "model_limits" in fields and fields["model_limits"] is not None:
|
||||
cleaned: dict[str, int] = {}
|
||||
for name, limit in (body.model_limits or {}).items():
|
||||
model_name = str(name).strip()
|
||||
if not model_name:
|
||||
continue
|
||||
try:
|
||||
normalized_limit = int(limit)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if normalized_limit < 1:
|
||||
continue
|
||||
cleaned[model_name] = min(normalized_limit, 10_000)
|
||||
update["model_limits"] = cleaned
|
||||
settings = current.model_concurrency.model_copy(update=update)
|
||||
saved = save_system_settings(current.model_copy(update={"model_concurrency": settings}))
|
||||
return _model_concurrency_to_response(saved.model_concurrency)
|
||||
|
||||
|
||||
def _leaderboard_to_response(settings: LeaderboardSettings) -> LeaderboardSettingsResponse:
|
||||
return LeaderboardSettingsResponse(
|
||||
exclude_user_ids=list(dict.fromkeys([uid for uid in settings.exclude_user_ids if uid])),
|
||||
|
||||
@@ -15,13 +15,15 @@ import time
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
from app.gateway.deps import get_run_context, get_run_manager, get_stream_bridge
|
||||
from app.gateway.utils import sanitize_log_param
|
||||
from deerflow.config.system_settings import load_system_settings
|
||||
from deerflow.runtime import (
|
||||
from fastapi import HTTPException, Request
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
from app.gateway.deps import get_run_context, get_run_manager, get_stream_bridge
|
||||
from app.gateway.utils import sanitize_log_param
|
||||
from deerflow.config import get_app_config
|
||||
from deerflow.config.agents_config import load_agent_config
|
||||
from deerflow.config.system_settings import load_system_settings
|
||||
from deerflow.runtime import (
|
||||
END_SENTINEL,
|
||||
HEARTBEAT_SENTINEL,
|
||||
ConflictError,
|
||||
@@ -366,7 +368,7 @@ def build_run_config(
|
||||
return config
|
||||
|
||||
|
||||
def _collect_requested_agent_ids(body: Any) -> set[str]:
|
||||
def _collect_requested_agent_ids(body: Any) -> set[str]:
|
||||
"""Collect agent ids from assistant_id/config/context for access checks."""
|
||||
agent_ids: set[str] = set()
|
||||
if body.assistant_id and body.assistant_id not in _BUILTIN_GRAPH_IDS:
|
||||
@@ -393,10 +395,99 @@ def _collect_requested_agent_ids(body: Any) -> set[str]:
|
||||
if isinstance(nested_configurable, Mapping):
|
||||
collect(nested_configurable)
|
||||
|
||||
return agent_ids
|
||||
|
||||
|
||||
async def _sync_legacy_agent_records(store) -> None:
|
||||
return agent_ids
|
||||
|
||||
|
||||
def _extract_requested_model_name(body: Any) -> str | None:
|
||||
"""Best-effort read of the model override requested for this run."""
|
||||
context = getattr(body, "context", None)
|
||||
if isinstance(context, Mapping):
|
||||
for key in ("model_name", "model"):
|
||||
value = str(context.get(key) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
|
||||
config = getattr(body, "config", None)
|
||||
if isinstance(config, Mapping):
|
||||
for container_key in ("context", "configurable"):
|
||||
container = config.get(container_key)
|
||||
if not isinstance(container, Mapping):
|
||||
continue
|
||||
for key in ("model_name", "model"):
|
||||
value = str(container.get(key) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_effective_run_model_name(body: Any) -> str:
|
||||
"""Resolve the model that this run will effectively use for concurrency limiting."""
|
||||
app_config = get_app_config()
|
||||
default_model_name = app_config.models[0].name if app_config.models else None
|
||||
if not default_model_name:
|
||||
raise ValueError("No chat models are configured. Please configure at least one model in config.yaml.")
|
||||
|
||||
requested_model_name = _extract_requested_model_name(body)
|
||||
if requested_model_name:
|
||||
if app_config.get_model_config(requested_model_name):
|
||||
return requested_model_name
|
||||
logger.warning(
|
||||
"Requested model '%s' not found; falling back to default model '%s'.",
|
||||
requested_model_name,
|
||||
default_model_name,
|
||||
)
|
||||
return default_model_name
|
||||
|
||||
assistant_id = str(getattr(body, "assistant_id", "") or "").strip()
|
||||
if assistant_id and assistant_id not in _BUILTIN_GRAPH_IDS:
|
||||
try:
|
||||
agent_config = load_agent_config(assistant_id)
|
||||
except FileNotFoundError:
|
||||
agent_config = None
|
||||
except Exception:
|
||||
logger.debug("Failed to load agent config for model resolution: %s", assistant_id, exc_info=True)
|
||||
agent_config = None
|
||||
agent_model_name = str(getattr(agent_config, "model", "") or "").strip()
|
||||
if agent_model_name:
|
||||
if app_config.get_model_config(agent_model_name):
|
||||
return agent_model_name
|
||||
logger.warning(
|
||||
"Agent model '%s' for agent '%s' not found; falling back to default model '%s'.",
|
||||
agent_model_name,
|
||||
assistant_id,
|
||||
default_model_name,
|
||||
)
|
||||
|
||||
return default_model_name
|
||||
|
||||
|
||||
async def _enforce_model_concurrency_limit(run_mgr: RunManager, model_name: str) -> None:
|
||||
"""Reject the run when the selected model has reached its concurrent-run limit."""
|
||||
settings = load_system_settings().model_concurrency
|
||||
if not settings.enabled:
|
||||
return
|
||||
|
||||
limit = settings.model_limits.get(model_name, settings.default_limit)
|
||||
try:
|
||||
limit = int(limit)
|
||||
except (TypeError, ValueError):
|
||||
limit = int(settings.default_limit)
|
||||
limit = max(1, limit)
|
||||
|
||||
active = await run_mgr.list_active()
|
||||
current = sum(
|
||||
1
|
||||
for record in active
|
||||
if str((record.metadata or {}).get("model_name") or "").strip() == model_name
|
||||
)
|
||||
if current >= limit:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"当前模型 {model_name} 并发已达到上限({limit}),请稍后再试。",
|
||||
)
|
||||
|
||||
|
||||
async def _sync_legacy_agent_records(store) -> None:
|
||||
"""Import existing folder-backed agents as built-in records.
|
||||
|
||||
Heavy: ``list_custom_agents()`` scans ``.deer-flow/agents/`` and reads
|
||||
@@ -567,19 +658,25 @@ async def start_run(
|
||||
# admin-configured mapping rather than from a per-user-visible assistant_id.
|
||||
# Those run under the "default" bucket, where a user-owned unpublished agent
|
||||
# would otherwise 404 the access check.
|
||||
if enforce_access:
|
||||
await _enforce_agent_access(body, request)
|
||||
t_post_enforce = time.perf_counter()
|
||||
|
||||
try:
|
||||
record = await run_mgr.create_or_reject(
|
||||
thread_id,
|
||||
body.assistant_id,
|
||||
on_disconnect=disconnect,
|
||||
metadata=body.metadata or {},
|
||||
kwargs={"input": body.input, "config": body.config},
|
||||
multitask_strategy=body.multitask_strategy,
|
||||
)
|
||||
if enforce_access:
|
||||
await _enforce_agent_access(body, request)
|
||||
t_post_enforce = time.perf_counter()
|
||||
|
||||
effective_model_name = _resolve_effective_run_model_name(body)
|
||||
await _enforce_model_concurrency_limit(run_mgr, effective_model_name)
|
||||
|
||||
run_metadata = dict(body.metadata or {})
|
||||
run_metadata["model_name"] = effective_model_name
|
||||
|
||||
try:
|
||||
record = await run_mgr.create_or_reject(
|
||||
thread_id,
|
||||
body.assistant_id,
|
||||
on_disconnect=disconnect,
|
||||
metadata=run_metadata,
|
||||
kwargs={"input": body.input, "config": body.config},
|
||||
multitask_strategy=body.multitask_strategy,
|
||||
)
|
||||
except ConflictError as exc:
|
||||
# 同一会话上一轮回答还没结束,又提交了新问题 → 拒绝。给用户中文提示,
|
||||
# 而不是把内部英文「Thread ... already has an active run」直接抛到前端。
|
||||
|
||||
@@ -249,6 +249,26 @@ class ConcurrencyMonitorSettings(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
class ModelConcurrencySettings(BaseModel):
|
||||
"""Admin-configurable per-model concurrent Q&A limits."""
|
||||
|
||||
enabled: bool = Field(
|
||||
default=False,
|
||||
description="Whether per-model concurrency limiting is enabled.",
|
||||
)
|
||||
default_limit: int = Field(
|
||||
default=10,
|
||||
ge=1,
|
||||
le=10_000,
|
||||
description="Fallback max concurrent runs for models without an explicit override.",
|
||||
)
|
||||
model_limits: dict[str, int] = Field(
|
||||
default_factory=dict,
|
||||
description="Optional per-model overrides: model_name -> max concurrent runs.",
|
||||
)
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
class SystemSettings(BaseModel):
|
||||
"""Top-level system settings document."""
|
||||
|
||||
@@ -262,6 +282,7 @@ class SystemSettings(BaseModel):
|
||||
skill_compression: SkillCompressionSettings = Field(default_factory=SkillCompressionSettings)
|
||||
agent_modules: AgentModuleSettings = Field(default_factory=AgentModuleSettings)
|
||||
concurrency_monitor: ConcurrencyMonitorSettings = Field(default_factory=ConcurrencyMonitorSettings)
|
||||
model_concurrency: ModelConcurrencySettings = Field(default_factory=ModelConcurrencySettings)
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
@@ -305,6 +326,15 @@ def get_concurrency_monitor_settings() -> ConcurrencyMonitorSettings:
|
||||
return ConcurrencyMonitorSettings()
|
||||
|
||||
|
||||
def get_model_concurrency_settings() -> ModelConcurrencySettings:
|
||||
"""Convenience accessor for the model-concurrency block (safe defaults on error)."""
|
||||
try:
|
||||
return load_system_settings().model_concurrency
|
||||
except Exception:
|
||||
logger.debug("Failed to read model concurrency settings; using defaults", exc_info=True)
|
||||
return ModelConcurrencySettings()
|
||||
|
||||
|
||||
def save_system_settings(settings: SystemSettings) -> SystemSettings:
|
||||
"""Persist the settings document atomically, returning the saved value."""
|
||||
path = _settings_path()
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Tests for per-model concurrency limit settings and enforcement."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.gateway.routers import system_settings as ss_router
|
||||
from app.gateway.services import (
|
||||
_enforce_model_concurrency_limit,
|
||||
_resolve_effective_run_model_name,
|
||||
)
|
||||
from deerflow.config import system_settings as ss
|
||||
from deerflow.runtime.runs.manager import RunManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_runtime_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
|
||||
def fake_home() -> Path:
|
||||
return tmp_path
|
||||
|
||||
monkeypatch.setattr(ss, "runtime_home", fake_home)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _patch_admin(monkeypatch: pytest.MonkeyPatch, user: object | None = None) -> None:
|
||||
admin = user or SimpleNamespace(
|
||||
id="admin1",
|
||||
system_role="admin",
|
||||
email="admin@example.com",
|
||||
)
|
||||
|
||||
async def _optional(_request):
|
||||
return admin
|
||||
|
||||
monkeypatch.setattr(ss_router, "get_optional_user_from_request", _optional)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_concurrency_settings_round_trip(
|
||||
tmp_runtime_home: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_patch_admin(monkeypatch)
|
||||
req = SimpleNamespace()
|
||||
|
||||
saved = await ss_router.update_model_concurrency(
|
||||
ss_router.ModelConcurrencyUpdateRequest(
|
||||
enabled=True,
|
||||
default_limit=8,
|
||||
model_limits={"glm5": 10, "deepseek": 4},
|
||||
),
|
||||
req,
|
||||
)
|
||||
assert saved.enabled is True
|
||||
assert saved.default_limit == 8
|
||||
assert saved.model_limits == {"glm5": 10, "deepseek": 4}
|
||||
|
||||
file_path = tmp_runtime_home / "system_settings.json"
|
||||
payload = json.loads(file_path.read_text(encoding="utf-8"))
|
||||
assert payload["model_concurrency"]["enabled"] is True
|
||||
assert payload["model_concurrency"]["default_limit"] == 8
|
||||
assert payload["model_concurrency"]["model_limits"]["glm5"] == 10
|
||||
|
||||
loaded = await ss_router.get_model_concurrency(req)
|
||||
assert loaded.enabled is True
|
||||
assert loaded.default_limit == 8
|
||||
assert loaded.model_limits["deepseek"] == 4
|
||||
|
||||
|
||||
def test_resolve_effective_run_model_name_prefers_requested_model(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
app_config = SimpleNamespace(
|
||||
models=[SimpleNamespace(name="default-model")],
|
||||
get_model_config=lambda name: {"name": name}
|
||||
if name in {"default-model", "glm5", "agent-model"}
|
||||
else None,
|
||||
)
|
||||
monkeypatch.setattr("app.gateway.services.get_app_config", lambda: app_config)
|
||||
monkeypatch.setattr(
|
||||
"app.gateway.services.load_agent_config",
|
||||
lambda _agent_id: SimpleNamespace(model="agent-model"),
|
||||
)
|
||||
|
||||
body = SimpleNamespace(
|
||||
assistant_id="custom-agent",
|
||||
context={"model_name": "glm5"},
|
||||
config=None,
|
||||
)
|
||||
assert _resolve_effective_run_model_name(body) == "glm5"
|
||||
|
||||
|
||||
def test_resolve_effective_run_model_name_uses_agent_default_when_request_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
app_config = SimpleNamespace(
|
||||
models=[SimpleNamespace(name="default-model")],
|
||||
get_model_config=lambda name: {"name": name}
|
||||
if name in {"default-model", "agent-model"}
|
||||
else None,
|
||||
)
|
||||
monkeypatch.setattr("app.gateway.services.get_app_config", lambda: app_config)
|
||||
monkeypatch.setattr(
|
||||
"app.gateway.services.load_agent_config",
|
||||
lambda _agent_id: SimpleNamespace(model="agent-model"),
|
||||
)
|
||||
|
||||
body = SimpleNamespace(
|
||||
assistant_id="custom-agent",
|
||||
context={},
|
||||
config=None,
|
||||
)
|
||||
assert _resolve_effective_run_model_name(body) == "agent-model"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enforce_model_concurrency_limit_rejects_when_limit_reached(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"app.gateway.services.load_system_settings",
|
||||
lambda: SimpleNamespace(
|
||||
model_concurrency=SimpleNamespace(
|
||||
enabled=True,
|
||||
default_limit=5,
|
||||
model_limits={"glm5": 2},
|
||||
)
|
||||
),
|
||||
)
|
||||
run_mgr = RunManager()
|
||||
await run_mgr.create("t1", metadata={"model_name": "glm5"})
|
||||
await run_mgr.create("t2", metadata={"model_name": "glm5"})
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _enforce_model_concurrency_limit(run_mgr, "glm5")
|
||||
assert exc.value.status_code == 429
|
||||
assert "并发已达到上限" in str(exc.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enforce_model_concurrency_limit_skips_when_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"app.gateway.services.load_system_settings",
|
||||
lambda: SimpleNamespace(
|
||||
model_concurrency=SimpleNamespace(
|
||||
enabled=False,
|
||||
default_limit=1,
|
||||
model_limits={"glm5": 1},
|
||||
)
|
||||
),
|
||||
)
|
||||
run_mgr = RunManager()
|
||||
await run_mgr.create("t1", metadata={"model_name": "glm5"})
|
||||
await _enforce_model_concurrency_limit(run_mgr, "glm5")
|
||||
Reference in new issue
Block a user