智能体查询优化
This commit is contained in:
1 parent
4142029f48
commit
9ccc473982
3 files changed
+179
-15
No files matched your search
@@ -502,6 +502,24 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Skill ownership migration failed (non-fatal)")
|
logger.exception("Skill ownership migration failed (non-fatal)")
|
||||||
|
|
||||||
|
# 一次性把 .deer-flow/agents/{id}/ 目录里历史遗留的 agent 导入 agents 表。
|
||||||
|
# 路由层原本每次请求都跑一遍这个 sync(扫文件系统 + N 个 agent 各一次 DB
|
||||||
|
# SELECT/UPDATE),把它挪到启动期 + 路由层只保留 TTL 兜底,显著降低
|
||||||
|
# /api/agents 列表延迟。
|
||||||
|
try:
|
||||||
|
from app.gateway.routers.agents import (
|
||||||
|
_mark_legacy_sync_fresh,
|
||||||
|
_sync_legacy_agents,
|
||||||
|
)
|
||||||
|
|
||||||
|
agent_store = getattr(app.state, "agent_store", None)
|
||||||
|
if agent_store is not None:
|
||||||
|
await _sync_legacy_agents(agent_store)
|
||||||
|
_mark_legacy_sync_fresh()
|
||||||
|
logger.info("Legacy folder-backed agents synced at startup")
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Legacy agent sync failed at startup (non-fatal)")
|
||||||
|
|
||||||
# 启动 skill curator 调度器:常驻后台任务,每小时检查一次需不需要进化
|
# 启动 skill curator 调度器:常驻后台任务,每小时检查一次需不需要进化
|
||||||
# (per-user interval_hours 仍是真正决定要不要跑进化的闸)。
|
# (per-user interval_hours 仍是真正决定要不要跑进化的闸)。
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -192,6 +194,11 @@ async def _sync_legacy_agents(store: AgentStore) -> None:
|
|||||||
|
|
||||||
This preserves old deployments where agents already existed under
|
This preserves old deployments where agents already existed under
|
||||||
``.deer-flow/agents/{name}`` before the agents table was introduced.
|
``.deer-flow/agents/{name}`` before the agents table was introduced.
|
||||||
|
|
||||||
|
Heavy: scans the agents directory + parses each ``config.yaml`` + issues a
|
||||||
|
SELECT-then-INSERT/UPDATE per agent. Call once at startup via the lifespan,
|
||||||
|
and rely on :func:`_maybe_sync_legacy_agents` (TTL-gated) inside request
|
||||||
|
handlers as a safety net for environments that bypassed the lifespan hook.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
for agent_cfg in list_custom_agents():
|
for agent_cfg in list_custom_agents():
|
||||||
@@ -210,6 +217,44 @@ async def _sync_legacy_agents(store: AgentStore) -> None:
|
|||||||
logger.exception("Failed to sync legacy folder-backed agents")
|
logger.exception("Failed to sync legacy folder-backed agents")
|
||||||
|
|
||||||
|
|
||||||
|
# Throttle the per-request legacy-sync fallback. The real sync runs once at
|
||||||
|
# startup (lifespan); request handlers used to call ``_sync_legacy_agents`` on
|
||||||
|
# every hit, which scanned the filesystem + did N DB upserts per agent each
|
||||||
|
# time. After the first successful sync we skip the work for ``_LEGACY_SYNC_TTL_SECONDS``
|
||||||
|
# — long enough to be effectively "startup-only" in steady state, short enough
|
||||||
|
# that a freshly dropped agent directory gets picked up on the next bucket.
|
||||||
|
_LEGACY_SYNC_TTL_SECONDS = 3600.0
|
||||||
|
_legacy_sync_state: dict[str, Any] = {"last_at": 0.0, "lock": None}
|
||||||
|
|
||||||
|
|
||||||
|
async def _maybe_sync_legacy_agents(store: AgentStore) -> None:
|
||||||
|
"""TTL-gated wrapper around :func:`_sync_legacy_agents`.
|
||||||
|
|
||||||
|
Becomes a no-op in steady state — the heavy lifting happens in the lifespan
|
||||||
|
hook. Inside the TTL window we don't even take the lock; outside it we
|
||||||
|
serialize callers so a thundering herd doesn't fan out into N filesystem
|
||||||
|
scans + N*M DB queries.
|
||||||
|
"""
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - float(_legacy_sync_state["last_at"]) < _LEGACY_SYNC_TTL_SECONDS:
|
||||||
|
return
|
||||||
|
lock = _legacy_sync_state["lock"]
|
||||||
|
if lock is None:
|
||||||
|
lock = asyncio.Lock()
|
||||||
|
_legacy_sync_state["lock"] = lock
|
||||||
|
async with lock:
|
||||||
|
# Re-check under the lock — a sibling coroutine may have just refreshed.
|
||||||
|
if time.monotonic() - float(_legacy_sync_state["last_at"]) < _LEGACY_SYNC_TTL_SECONDS:
|
||||||
|
return
|
||||||
|
await _sync_legacy_agents(store)
|
||||||
|
_legacy_sync_state["last_at"] = time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_legacy_sync_fresh() -> None:
|
||||||
|
"""Called by the lifespan hook after running :func:`_sync_legacy_agents`."""
|
||||||
|
_legacy_sync_state["last_at"] = time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
def _ensure_agent_dir_from_record(record: dict[str, Any]) -> None:
|
def _ensure_agent_dir_from_record(record: dict[str, Any]) -> None:
|
||||||
"""Lazily restore an agent's on-disk artifacts from its DB record.
|
"""Lazily restore an agent's on-disk artifacts from its DB record.
|
||||||
|
|
||||||
@@ -247,14 +292,29 @@ def _record_to_response(
|
|||||||
record: dict[str, Any],
|
record: dict[str, Any],
|
||||||
*,
|
*,
|
||||||
include_soul: bool = False,
|
include_soul: bool = False,
|
||||||
|
include_extras: bool = True,
|
||||||
) -> AgentResponse:
|
) -> AgentResponse:
|
||||||
_ensure_agent_dir_from_record(record)
|
"""Convert a DB row to an API response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
include_soul: Read SOUL.md from disk and embed in ``soul``. Set False
|
||||||
|
in list endpoints — the frontend (gallery / recommend / selector)
|
||||||
|
never reads ``soul``, only the edit dialog does. Skipping this
|
||||||
|
avoids one synchronous file read per agent.
|
||||||
|
include_extras: Self-heal a missing agent dir and read deprecated
|
||||||
|
``model`` / ``tool_groups`` / ``skills`` from ``config.yaml``. Set
|
||||||
|
False in list endpoints to skip a stat + yaml parse per agent —
|
||||||
|
those fields are unused by current frontends and the dir restore
|
||||||
|
can happen lazily when the agent is actually opened.
|
||||||
|
"""
|
||||||
soul: str | None = None
|
soul: str | None = None
|
||||||
|
extras: dict[str, Any] = {"skills": None, "model": None, "tool_groups": None}
|
||||||
|
if include_extras:
|
||||||
|
_ensure_agent_dir_from_record(record)
|
||||||
|
extras = _load_config_extras(record["id"])
|
||||||
if include_soul:
|
if include_soul:
|
||||||
soul = load_agent_soul(record["id"]) or ""
|
soul = load_agent_soul(record["id"]) or ""
|
||||||
|
|
||||||
extras = _load_config_extras(record["id"])
|
|
||||||
|
|
||||||
return AgentResponse(
|
return AgentResponse(
|
||||||
id=record["id"],
|
id=record["id"],
|
||||||
name=record["name"],
|
name=record["name"],
|
||||||
@@ -272,6 +332,25 @@ def _record_to_response(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _record_to_response_async(
|
||||||
|
record: dict[str, Any],
|
||||||
|
*,
|
||||||
|
include_soul: bool = False,
|
||||||
|
include_extras: bool = True,
|
||||||
|
) -> AgentResponse:
|
||||||
|
"""Async wrapper: when we actually touch disk, hop to a worker thread so
|
||||||
|
we don't block the asyncio loop. List endpoints (no soul, no extras) skip
|
||||||
|
this overhead and stay on the calling task."""
|
||||||
|
if not include_soul and not include_extras:
|
||||||
|
return _record_to_response(record, include_soul=False, include_extras=False)
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
_record_to_response,
|
||||||
|
record,
|
||||||
|
include_soul=include_soul,
|
||||||
|
include_extras=include_extras,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _get_owned_or_raise(store: AgentStore, agent_id: str, user_id: str) -> dict[str, Any]:
|
async def _get_owned_or_raise(store: AgentStore, agent_id: str, user_id: str) -> dict[str, Any]:
|
||||||
owned = await store.get_owned(agent_id, user_id)
|
owned = await store.get_owned(agent_id, user_id)
|
||||||
if owned is not None:
|
if owned is not None:
|
||||||
@@ -321,10 +400,17 @@ async def list_agents(
|
|||||||
try:
|
try:
|
||||||
store = get_agent_store(request)
|
store = get_agent_store(request)
|
||||||
tag_store: TagStore = get_tag_store(request)
|
tag_store: TagStore = get_tag_store(request)
|
||||||
await _sync_legacy_agents(store)
|
await _maybe_sync_legacy_agents(store)
|
||||||
user_id = _current_user_id(request)
|
user_id = _current_user_id(request)
|
||||||
agents = await store.list_visible(user_id)
|
agents = await store.list_visible(user_id)
|
||||||
responses = [_record_to_response(a, include_soul=True) for a in agents]
|
# List endpoint stays lean: skip per-agent SOUL.md read, config.yaml
|
||||||
|
# parse, and dir self-heal — none of those feed UI fields the gallery /
|
||||||
|
# recommend dialog / selector actually render. Detail endpoints (single
|
||||||
|
# agent + create/update responses) still load them.
|
||||||
|
responses = [
|
||||||
|
_record_to_response(a, include_soul=False, include_extras=False)
|
||||||
|
for a in agents
|
||||||
|
]
|
||||||
|
|
||||||
assignments = await tag_store.list_assignments("agent", [r.id for r in responses])
|
assignments = await tag_store.list_assignments("agent", [r.id for r in responses])
|
||||||
for resp in responses:
|
for resp in responses:
|
||||||
@@ -368,11 +454,18 @@ async def get_agent_selector(request: Request) -> AgentSelectorResponse:
|
|||||||
_require_agents_api_enabled()
|
_require_agents_api_enabled()
|
||||||
try:
|
try:
|
||||||
store = get_agent_store(request)
|
store = get_agent_store(request)
|
||||||
await _sync_legacy_agents(store)
|
await _maybe_sync_legacy_agents(store)
|
||||||
|
|
||||||
user_id = _current_user_id(request)
|
user_id = _current_user_id(request)
|
||||||
records = await store.list_featured(user_id)
|
records = await store.list_featured(user_id)
|
||||||
return AgentSelectorResponse(agents=[_record_to_response(r) for r in records])
|
# Selector dropdown only renders name/description/featured_order — same
|
||||||
|
# rationale as list_agents: skip soul + extras + self-heal.
|
||||||
|
return AgentSelectorResponse(
|
||||||
|
agents=[
|
||||||
|
_record_to_response(r, include_soul=False, include_extras=False)
|
||||||
|
for r in records
|
||||||
|
]
|
||||||
|
)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -392,12 +485,15 @@ async def get_agent(agent_id: str, request: Request) -> AgentResponse:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
store = get_agent_store(request)
|
store = get_agent_store(request)
|
||||||
await _sync_legacy_agents(store)
|
await _maybe_sync_legacy_agents(store)
|
||||||
user_id = _current_user_id(request)
|
user_id = _current_user_id(request)
|
||||||
record = await store.get_visible(agent_id, user_id)
|
record = await store.get_visible(agent_id, user_id)
|
||||||
if record is None:
|
if record is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
||||||
return _record_to_response(record, include_soul=True)
|
# Detail view legitimately reads SOUL.md + config.yaml — hop to a
|
||||||
|
# worker thread so the asyncio loop stays responsive under concurrent
|
||||||
|
# detail fetches.
|
||||||
|
return await _record_to_response_async(record, include_soul=True, include_extras=True)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -449,7 +545,7 @@ async def create_agent_endpoint(request: AgentCreateRequest, http_request: Reque
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
logger.info("Created agent '%s' (%s) at %s", name, agent_id, agent_dir)
|
logger.info("Created agent '%s' (%s) at %s", name, agent_id, agent_dir)
|
||||||
return _record_to_response(record, include_soul=True)
|
return await _record_to_response_async(record, include_soul=True, include_extras=True)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
@@ -518,7 +614,7 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
|
|||||||
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
||||||
|
|
||||||
logger.info("Updated agent '%s' (%s)", record["name"], agent_id)
|
logger.info("Updated agent '%s' (%s)", record["name"], agent_id)
|
||||||
return _record_to_response(record, include_soul=True)
|
return await _record_to_response_async(record, include_soul=True, include_extras=True)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
@@ -642,7 +738,7 @@ async def set_agent_featured(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
store = get_agent_store(request)
|
store = get_agent_store(request)
|
||||||
await _sync_legacy_agents(store)
|
await _maybe_sync_legacy_agents(store)
|
||||||
record = await store.get_any(agent_id)
|
record = await store.get_any(agent_id)
|
||||||
if record is None:
|
if record is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
||||||
@@ -654,7 +750,7 @@ async def set_agent_featured(
|
|||||||
if updated is None:
|
if updated is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
||||||
logger.info("Set agent '%s' featured_order=%s by admin", agent_id, body.order)
|
logger.info("Set agent '%s' featured_order=%s by admin", agent_id, body.order)
|
||||||
return _record_to_response(updated, include_soul=True)
|
return await _record_to_response_async(updated, include_soul=True, include_extras=True)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"""Configuration and loaders for custom agents."""
|
"""Configuration and loaders for custom agents."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
import threading
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
@@ -13,6 +15,25 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
SOUL_FILENAME = "SOUL.md"
|
SOUL_FILENAME = "SOUL.md"
|
||||||
AGENT_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
|
AGENT_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||||
|
|
||||||
|
# Per-file mtime caches. These are read on every /api/agents list/detail request
|
||||||
|
# (one read per agent) — caching keyed by (path, mtime_ns, size) is correct
|
||||||
|
# because any edit to the file updates mtime, and an external tool re-writing
|
||||||
|
# the same bytes doesn't actually change the result we'd return. Cache size is
|
||||||
|
# unbounded but lookup is O(1) and entries are tiny (one parsed dict / string
|
||||||
|
# per agent file); for the order of N≤10⁴ agents the memory cost is negligible.
|
||||||
|
_config_cache: dict[str, tuple[tuple[int, int], "AgentConfig | None"]] = {}
|
||||||
|
_soul_cache: dict[str, tuple[tuple[int, int], str | None]] = {}
|
||||||
|
_cache_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _file_stat_key(path) -> tuple[int, int] | None:
|
||||||
|
"""Return (mtime_ns, size) for cache invalidation, or None when missing."""
|
||||||
|
try:
|
||||||
|
st = os.stat(path)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return (st.st_mtime_ns, st.st_size)
|
||||||
# Backward-compatible alias. Historically this validated the display name;
|
# Backward-compatible alias. Historically this validated the display name;
|
||||||
# after the DB-backed agent table, display names can be Chinese and duplicated.
|
# after the DB-backed agent table, display names can be Chinese and duplicated.
|
||||||
# The pattern now applies only to storage/runtime ids.
|
# The pattern now applies only to storage/runtime ids.
|
||||||
@@ -94,6 +115,18 @@ def load_agent_config(agent_id: str | None) -> AgentConfig | None:
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# mtime-based cache. The config file is rewritten every time the agent is
|
||||||
|
# edited via PUT /api/agents/{id}, which bumps mtime — so the next read
|
||||||
|
# picks up the change automatically. Missing config_file = key None below,
|
||||||
|
# bypassing the cache (cheap path: just return AgentConfig with id/name).
|
||||||
|
cache_path_key = str(config_file)
|
||||||
|
stat_key = _file_stat_key(config_file) if config_file.exists() else None
|
||||||
|
if stat_key is not None:
|
||||||
|
with _cache_lock:
|
||||||
|
cached = _config_cache.get(cache_path_key)
|
||||||
|
if cached is not None and cached[0] == stat_key:
|
||||||
|
return cached[1]
|
||||||
|
|
||||||
data: dict[str, Any] = {}
|
data: dict[str, Any] = {}
|
||||||
if config_file.exists():
|
if config_file.exists():
|
||||||
try:
|
try:
|
||||||
@@ -112,7 +145,11 @@ def load_agent_config(agent_id: str | None) -> AgentConfig | None:
|
|||||||
known_fields = set(AgentConfig.model_fields.keys())
|
known_fields = set(AgentConfig.model_fields.keys())
|
||||||
data = {k: v for k, v in data.items() if k in known_fields}
|
data = {k: v for k, v in data.items() if k in known_fields}
|
||||||
|
|
||||||
return AgentConfig(**data)
|
result = AgentConfig(**data)
|
||||||
|
if stat_key is not None:
|
||||||
|
with _cache_lock:
|
||||||
|
_config_cache[cache_path_key] = (stat_key, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def load_agent_soul(agent_id: str | None) -> str | None:
|
def load_agent_soul(agent_id: str | None) -> str | None:
|
||||||
@@ -131,8 +168,21 @@ def load_agent_soul(agent_id: str | None) -> str | None:
|
|||||||
soul_path = agent_dir / SOUL_FILENAME
|
soul_path = agent_dir / SOUL_FILENAME
|
||||||
if not soul_path.exists():
|
if not soul_path.exists():
|
||||||
return None
|
return None
|
||||||
|
# mtime-based cache, same rationale as load_agent_config: SOUL.md is
|
||||||
|
# rewritten on edit (PUT /api/agents/{id}), which bumps mtime.
|
||||||
|
cache_path_key = str(soul_path)
|
||||||
|
stat_key = _file_stat_key(soul_path)
|
||||||
|
if stat_key is not None:
|
||||||
|
with _cache_lock:
|
||||||
|
cached = _soul_cache.get(cache_path_key)
|
||||||
|
if cached is not None and cached[0] == stat_key:
|
||||||
|
return cached[1]
|
||||||
content = soul_path.read_text(encoding="utf-8").strip()
|
content = soul_path.read_text(encoding="utf-8").strip()
|
||||||
return content or None
|
result = content or None
|
||||||
|
if stat_key is not None:
|
||||||
|
with _cache_lock:
|
||||||
|
_soul_cache[cache_path_key] = (stat_key, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def list_custom_agents() -> list[AgentConfig]:
|
def list_custom_agents() -> list[AgentConfig]:
|
||||||
|
|||||||
Reference in new issue
Block a user