feat: add per-model concurrency limits

This commit is contained in:
Administrator committed 2026-06-27 18:57:01 +08:00
1 parent 24eb629773
commit aca089e8ef
11 files changed
+695 -25

No files matched your search

@@ -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")