perf(startup): defer LLM SDK imports and gate the volume chown

Two things dominated container start:

- The LLM SDKs (anthropic + openai + google-genai) imported at module load
  via parser.analyser/__init__ → llm.py, costing ~0.7s on every boot even
  though the LLM parser is off by default. Move those three heavy imports
  into the provider branch of __init__ and each _parse_* method, so they
  load only when an LLMParser is actually built. Measured: importing
  parser.analyser drops 782ms → 203ms; none of the SDKs are in sys.modules
  after the startup import chain. httpx/httpx_socks stay module-level (cheap,
  and the proxy tests patch them by module path).

- entrypoint.sh ran `chown -R /app/data /app/config` unconditionally every
  start — O(files), which a large poster cache on a NAS turns into the
  dominant startup cost. Walk the tree only when ownership needs fixing
  (first run, changed PUID/PGID, or a wrong-owned volume root), tracked by a
  marker; the legacy bangumi.json migration chowns its one file explicitly so
  the gate can't miss it. /home/ab (image-internal, tiny) is still chowned
  every time.

Also regenerates the auto-imports dts for onResponseError (exported for the
401 test in the previous commit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAxVyRwrY7z5NotTtgnwVs
This commit is contained in:
Estrella Pan
2026-07-04 19:16:49 +02:00
parent b4e1113298
commit 6f72cfe1df
3 changed files with 36 additions and 7 deletions

View File

@@ -14,18 +14,17 @@ import json
import logging
from typing import Any, Optional
import anthropic
import httpx
import openai as openai_sdk
from google import genai
from google.genai import errors as genai_errors
from google.genai import types as genai_types
from httpx_socks import AsyncProxyTransport
from openai import AsyncOpenAI
from pydantic import BaseModel
from module.conf import settings
# anthropic / openai / google-genai 合计导入约 0.7s,而 LLM 默认关闭。
# 这些重型 SDK 改为在真正构造/调用某提供商时才导入(见 __init__ 与各
# _parse_* 方法把这段耗时移出容器启动路径。httpx / httpx_socks 保持
# 顶层导入:它们本就在网络层被加载,且测试按模块级名字 patch 它们。
logger = logging.getLogger(__name__)
@@ -123,6 +122,8 @@ class LLMParser:
self.model = model
self._http_client: httpx.AsyncClient | None = None
if provider == "openai":
from openai import AsyncOpenAI
self._http_client = _build_http_client(timeout)
self._openai_client = AsyncOpenAI(
api_key=api_key,
@@ -131,6 +132,8 @@ class LLMParser:
http_client=self._http_client,
)
elif provider == "anthropic":
import anthropic
self._http_client = _build_http_client(timeout)
self._anthropic_client = anthropic.AsyncAnthropic(
api_key=api_key,
@@ -138,6 +141,8 @@ class LLMParser:
http_client=self._http_client,
)
elif provider == "gemini":
from google import genai
self._gemini_client = genai.Client(api_key=api_key)
else:
raise ValueError(f"Unsupported LLM provider: {provider}")
@@ -187,6 +192,8 @@ class LLMParser:
return result
async def _parse_openai(self, raw: str) -> dict | None:
import openai as openai_sdk
try:
resp = await self._openai_client.beta.chat.completions.parse(
model=self.model,
@@ -208,6 +215,8 @@ class LLMParser:
return parsed.model_dump()
async def _parse_anthropic(self, raw: str) -> dict | None:
import anthropic
try:
resp = await self._anthropic_client.messages.create(
model=self.model,
@@ -236,6 +245,9 @@ class LLMParser:
return None
async def _parse_gemini(self, raw: str) -> dict | None:
from google.genai import errors as genai_errors
from google.genai import types as genai_types
try:
resp = await self._gemini_client.aio.models.generate_content(
model=self.model,

View File

@@ -5,6 +5,9 @@ umask ${UMASK}
if [ -f /config/bangumi.json ]; then
mv /config/bangumi.json /app/data/bangumi.json
# Created as root by the mv; the gated chown below may skip the tree walk,
# so fix this one legacy file explicitly.
chown ab:ab /app/data/bangumi.json
fi
groupmod -o -g "${PGID}" ab
@@ -29,7 +32,20 @@ while true; do
# 兜底 try/except这里再加 `|| true`:覆盖层失败绝不能阻断容器启动。
python /app/boot_overlay.py || true
chown ab:ab -R /app/data /app/config /home/ab
# Recursive chown of the mounted volumes is O(files): a large poster cache
# on a NAS makes it the dominant cost of every container start. Only walk
# the tree when ownership actually needs fixing — first run or a changed
# PUID/PGID — recorded by a marker. /home/ab is image-internal and tiny, so
# chown it every time.
chown ab:ab -R /home/ab
own_marker="/app/config/.ab_owner"
want="${PUID}:${PGID}"
if [ "$(cat "${own_marker}" 2>/dev/null)" != "${want}" ] ||
[ "$(stat -c '%u:%g' /app/data 2>/dev/null)" != "${want}" ] ||
[ "$(stat -c '%u:%g' /app/config 2>/dev/null)" != "${want}" ]; then
echo "${want}" >"${own_marker}"
chown ab:ab -R /app/data /app/config
fi
su-exec "${PUID}:${PGID}" python main.py &
child=$!

View File

@@ -68,6 +68,7 @@ declare global {
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onResponseError: typeof import('../../src/utils/axios')['onResponseError']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']