perf(modelscope): fetch a repository's model card once per run

A collection repository publishes many model files under a single source id,
but enrichment re-read the README and the model-detail payload for every one
of them: eight checkpoints meant sixteen HTTP requests, each detail payload
being 10-22 KB of JSON.

Add `ModelSourceCache`, created by `execute_skill()` for the duration of a
run and passed to the provider through a new optional `cache` argument on
`fetch_model_card_context()`. The agent caches the README (repository-wide
and provider-agnostic), and ModelScope caches its detail payload under a
provider-namespaced key.

Only successful reads are memoised, so a transient failure is still retried
for the next file, and the per-file selection is redone from the cached
payload so a checkpoint never inherits a sibling's example images. Nothing
is retained across runs — a model card can change at any time — and download
URLs are not routed through the cache.

Measured over the eight checkpoints of one ModelScope repository: 16
requests before, 2 after.

To keep the two concerns separable, `_build_card_context()` now turns a
detail payload into a `ModelCardContext` as a pure function.
This commit is contained in:
Will Miao
2026-09-14 21:24:57 +08:00
parent f0ee30fc68
commit e9e9ee20c6
7 changed files with 247 additions and 26 deletions
+26 -4
View File
@@ -28,6 +28,7 @@ from ...config import config
from ..llm_service import LLMService
from ..model_sources import (
ModelCardContext,
ModelSourceCache,
get_source,
resolve_source_ref,
source_label,
@@ -259,6 +260,11 @@ class AgentService:
llm = await self._ensure_llm()
llm_configured = llm.is_configured() if skill.llm_required else True
# A collection repository holds many model files under one source id;
# this memo keeps the README and the repository metadata from being
# re-fetched once per file. It lives for this run only.
source_cache = ModelSourceCache()
for model_path in model_paths:
model_filename = os.path.basename(model_path)
logger.info(
@@ -288,7 +294,7 @@ class AgentService:
# or not an LLM is available: a user without a key still gets
# the author summary, the example images and the tags.
source_vars, source_context = await self._load_source_card(
model_path, metadata,
model_path, metadata, cache=source_cache,
)
resolved_base_model = ""
if skill_name == "enrich_hf_metadata" and not (
@@ -423,13 +429,22 @@ class AgentService:
return "\n".join(f"- {m}" for m in models)
async def _load_source_card(
self, model_path: str, metadata: Dict[str, Any]
self,
model_path: str,
metadata: Dict[str, Any],
*,
cache: Optional[ModelSourceCache] = None,
) -> tuple[Dict[str, Any], ModelCardContext]:
"""Fetch the model card and site-published extras for one model.
Runs for every source-backed enrichment regardless of LLM
availability, because everything it returns is deterministic data that
should be applied even without a configured provider.
*cache* is the per-run memo created by :meth:`execute_skill`. The
README is repository-wide, so it is fetched once per source id; only
successful reads are memoised, leaving a transient failure to be
retried for the next file.
"""
variables: Dict[str, Any] = {
@@ -450,11 +465,18 @@ class AgentService:
raw_basename = os.path.splitext(os.path.basename(model_path))[0]
variables["asset_base_url"] = source.asset_base_url(ref.source_id)
readme = await source.fetch_model_card(ref.source_id)
cache_key = f"{ref.platform}:{ref.source_id}"
readme = cache.readmes.get(cache_key) if cache is not None else None
if readme is None:
readme = await source.fetch_model_card(ref.source_id)
if cache is not None and readme:
cache.readmes[cache_key] = readme
# Sites such as ModelScope keep part of the model card outside the
# README (author summary, curated tags, per-file example images).
card_context = await source.fetch_model_card_context(
ref.source_id, os.path.basename(model_path)
ref.source_id, os.path.basename(model_path), cache=cache,
)
variables["source_description"] = card_context.description
variables["source_base_model"] = card_context.base_model
+2
View File
@@ -13,6 +13,7 @@ from .base import (
HTTP_TIMEOUT,
ModelCardContext,
ModelSource,
ModelSourceCache,
ModelSourceError,
SourceRef,
USER_AGENT,
@@ -48,6 +49,7 @@ __all__ = [
"LEGACY_HF_URL_FIELD",
"ModelCardContext",
"ModelSource",
"ModelSourceCache",
"ModelSourceError",
"HuggingFaceSource",
"ModelScopeSource",
+31 -2
View File
@@ -25,7 +25,7 @@ import logging
import os
import re
from dataclasses import dataclass, field
from typing import Any, Iterable, Optional
from typing import Any, Dict, Iterable, Optional
import aiohttp
@@ -125,6 +125,26 @@ class ModelSourceError(Exception):
self.status = status
class ModelSourceCache:
"""Per-run memo shared between the agent pipeline and a model source.
A collection repository publishes many model files under a single source
id, so enriching each file re-fetches the same README and the same
repository metadata. One cache is created per enrichment run and thrown
away afterwards: nothing is retained across runs (a model card can change
at any time), and download URLs are never routed through it.
"""
def __init__(self) -> None:
#: Provider-agnostic: ``"<platform>:<source_id>"`` → raw README text.
self.readmes: Dict[str, str] = {}
#: Provider-owned scratch space. Keys must be namespaced by the
#: provider (``(platform, kind, source_id)``) so two providers can
#: never collide. Only successful results should be stored, so a
#: transient failure is still retried for the next file.
self.provider: Dict[Any, Any] = {}
#: Repository ids are always exactly ``owner/name``. Components may contain
#: dots (``black-forest-labs/FLUX.1-dev``) but must not be empty, ``.`` / ``..``,
#: or start with a dot - the id is used as a path segment on disk.
@@ -283,7 +303,11 @@ class ModelSource:
return ""
async def fetch_model_card_context(
self, source_id: str, filename: str = ""
self,
source_id: str,
filename: str = "",
*,
cache: Optional["ModelSourceCache"] = None,
) -> ModelCardContext:
"""Return the card extras the site keeps outside the README.
@@ -292,6 +316,10 @@ class ModelSource:
model card is fully described by :meth:`fetch_model_card` need no
override and inherit this empty context.
*cache* is an optional per-run memo (see :class:`ModelSourceCache`)
that lets a provider avoid re-fetching repository-wide data for every
file in a collection repository.
Implementations must never raise: enrichment treats a missing
context as "the site had nothing extra to say".
"""
@@ -371,6 +399,7 @@ __all__ = [
"HTTP_TIMEOUT",
"ModelCardContext",
"ModelSource",
"ModelSourceCache",
"ModelSourceError",
"SourceRef",
"USER_AGENT",
+64 -17
View File
@@ -24,6 +24,10 @@ which redirects to a CDN URL carrying a time-limited ``auth_key``.
Requesting the resolve URL fresh on every attempt (which the shared
downloader does, including for resumable Range requests) keeps that key
valid; the CDN URL must never be cached.
The README and the detail payload both describe the whole repository rather
than one file, so a per-run ``ModelSourceCache`` keeps them from being read
again for every checkpoint of a collection repository.
"""
from __future__ import annotations
@@ -32,7 +36,7 @@ import json
import logging
import os
import re
from typing import Any
from typing import TYPE_CHECKING, Any, Optional
from .base import (
ModelCardContext,
@@ -43,6 +47,9 @@ from .base import (
filter_weight_files,
)
if TYPE_CHECKING: # pragma: no cover - typing only
from .base import ModelSourceCache
logger = logging.getLogger(__name__)
_URL_PATTERN = re.compile(
@@ -106,7 +113,11 @@ class ModelScopeSource(ModelSource):
return ""
async def fetch_model_card_context(
self, source_id: str, filename: str = ""
self,
source_id: str,
filename: str = "",
*,
cache: Optional["ModelSourceCache"] = None,
) -> ModelCardContext:
"""Read the model-detail API that backs the ModelScope model page.
@@ -121,30 +132,44 @@ class ModelScopeSource(ModelSource):
``stats.fileList``, which means the images returned belong to the
exact ``.safetensors`` being enriched — essential for collection
repositories, where every checkpoint has its own sample image.
The detail payload describes the whole repository and is therefore
shared across every file in it, so it is read through *cache* when the
caller supplies one; only the per-file selection is redone.
"""
data = await self._fetch_detail(source_id, cache=cache)
if data is None:
return ModelCardContext()
return _build_card_context(data, filename)
async def _fetch_detail(
self,
source_id: str,
*,
cache: Optional["ModelSourceCache"] = None,
) -> Optional[dict[str, Any]]:
"""Fetch (or reuse) the model-detail payload for *source_id*."""
cache_key = (self.platform, "detail", source_id)
if cache is not None and cache_key in cache.provider:
return cache.provider[cache_key]
status, payload = await fetch_json(
f"https://modelscope.cn/api/v1/models/{source_id}"
)
if status != 200 or not isinstance(payload, dict):
logger.debug("ModelScope detail API returned HTTP %s for %s", status, source_id)
return ModelCardContext()
logger.debug(
"ModelScope detail API returned HTTP %s for %s", status, source_id
)
return None
data = payload.get("Data")
if not isinstance(data, dict):
return ModelCardContext()
return None
context = ModelCardContext(
description=_clean_text(data.get("Description")),
base_model=_first_string(data.get("BaseModel")),
base_model_aliases=_base_model_aliases(data),
official_tags=_official_tags(data.get("OfficialTags")),
)
versions = _matching_versions(data.get("MuseInfo"), filename)
if versions:
context.example_images = _cover_image_urls(versions)
context.trigger_words = _version_trigger_words(versions)
return context
if cache is not None:
cache.provider[cache_key] = data
return data
async def list_files(
self, source_id: str, revision: str = ""
@@ -220,6 +245,28 @@ def _first_string(value: Any) -> str:
return ""
def _build_card_context(data: dict[str, Any], filename: str) -> ModelCardContext:
"""Turn a model-detail payload into a :class:`ModelCardContext`.
Separated from the HTTP fetch so the repository-wide payload can be cached
across the files of a collection repository while the per-file selection
is still redone for each one.
"""
context = ModelCardContext(
description=_clean_text(data.get("Description")),
base_model=_first_string(data.get("BaseModel")),
base_model_aliases=_base_model_aliases(data),
official_tags=_official_tags(data.get("OfficialTags")),
)
versions = _matching_versions(data.get("MuseInfo"), filename)
if versions:
context.example_images = _cover_image_urls(versions)
context.trigger_words = _version_trigger_words(versions)
return context
def _base_model_aliases(data: dict[str, Any]) -> list[str]:
"""Return the site's own names for the base model.