智能体查询优化

This commit is contained in:
董宏凯 committed 2026-05-27 22:23:37 +08:00
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]: