feat(multimodal): 实现本地模型管线与请求用量配置
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
"""Bounded, cancellable model subprocesses with CPU as the default device."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from contextlib import closing
|
||||
from contextvars import ContextVar
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.config import BACKEND_DIR
|
||||
from app.database.db import connect
|
||||
from app.errors import ApiError
|
||||
from app.local_models.catalog import CATALOG
|
||||
from app.local_models.manager import model_path, read_state
|
||||
from app.providers.base import ProviderError
|
||||
|
||||
|
||||
class RuntimeConfig(BaseModel):
|
||||
device: Literal["cpu", "cuda"] = "cpu"
|
||||
cpu_threads: int = Field(default=2, ge=1, le=32)
|
||||
memory_limit_mb: int = Field(default=8192, ge=1024, le=131072)
|
||||
gpu_memory_limit_mb: int = Field(default=4096, ge=512, le=65536)
|
||||
timeout_seconds: int = Field(default=1800, ge=30, le=14400)
|
||||
embedding_model: Literal["bekko", "granite"] = "bekko"
|
||||
version: int = Field(default=1, ge=1)
|
||||
|
||||
|
||||
runtime_context = ContextVar("runtime_config", default=None)
|
||||
runtime_progress = ContextVar("runtime_progress", default=None)
|
||||
|
||||
|
||||
def configuration():
|
||||
if runtime_context.get() is not None:
|
||||
return runtime_context.get()
|
||||
with closing(connect()) as conn:
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS local_runtime_config (id INTEGER PRIMARY KEY CHECK(id=1), config_json TEXT NOT NULL)")
|
||||
row = conn.execute("SELECT config_json FROM local_runtime_config WHERE id=1").fetchone()
|
||||
return RuntimeConfig.model_validate_json(row[0]) if row else RuntimeConfig()
|
||||
|
||||
|
||||
def configure(request):
|
||||
from app.database.db import transaction
|
||||
configuration()
|
||||
with closing(connect()) as conn, transaction(conn):
|
||||
row = conn.execute("SELECT config_json FROM local_runtime_config WHERE id=1").fetchone()
|
||||
previous = RuntimeConfig.model_validate_json(row[0]) if row else RuntimeConfig()
|
||||
if request.version != previous.version:
|
||||
raise ApiError(409, "VERSION_CONFLICT", "Local runtime settings changed; reload first.")
|
||||
request = request.model_copy(update={"version": request.version + 1})
|
||||
conn.execute("INSERT OR REPLACE INTO local_runtime_config VALUES (1,?)", (request.model_dump_json(),))
|
||||
return request
|
||||
|
||||
|
||||
def interpreter():
|
||||
return Path(os.getenv("APP_MODEL_PYTHON", str(BACKEND_DIR / ".venv-models" / ("Scripts/python.exe" if os.name == "nt" else "bin/python"))))
|
||||
|
||||
|
||||
class Runtime:
|
||||
def __init__(self):
|
||||
self.active = {}
|
||||
self.active_files = {}
|
||||
self.waiters = []
|
||||
self.counter = 0
|
||||
self.diagnostics = []
|
||||
|
||||
def in_use(self, key):
|
||||
return key in self.active.values()
|
||||
|
||||
def media_in_use(self, path):
|
||||
target = str(Path(path).resolve())
|
||||
return any(target in paths for paths in self.active_files.values())
|
||||
|
||||
async def infer(self, key, operation, payload, *, priority=10):
|
||||
if read_state(key)["status"] != "installed":
|
||||
raise ProviderError("LOCAL_MODEL_NOT_INSTALLED", "请先在模型配置中下载本地模型。")
|
||||
if not interpreter().is_file():
|
||||
raise ProviderError("LOCAL_RUNTIME_NOT_INSTALLED", "请先运行本地模型 CPU/CUDA 安装脚本。")
|
||||
config = configuration()
|
||||
self.counter += 1
|
||||
ticket = (priority, self.counter)
|
||||
self.waiters.append(ticket)
|
||||
process = None
|
||||
attempt = None
|
||||
try:
|
||||
# One resident model at a time prevents overlapping CPU/GPU allocations.
|
||||
while self.active or ticket != min(self.waiters):
|
||||
await asyncio.sleep(0.05)
|
||||
self.waiters.remove(ticket)
|
||||
self.active[ticket] = key
|
||||
self.active_files[ticket] = {str(Path(payload[name]).resolve()) for name in ("source", "reference") if payload.get(name)}
|
||||
# Deletion may have occurred while this request was queued.
|
||||
if read_state(key)["status"] != "installed":
|
||||
raise ProviderError("LOCAL_MODEL_NOT_INSTALLED", "模型文件已被删除。")
|
||||
from app.services.usage_service import UsageAttempt
|
||||
attempt = UsageAttempt("local-models", CATALOG[key].repository, "local", operation, source="local")
|
||||
env = {**os.environ, "HF_HUB_OFFLINE": "1", "TRANSFORMERS_OFFLINE": "1",
|
||||
"HF_HUB_DISABLE_TELEMETRY": "1", "OMP_NUM_THREADS": str(config.cpu_threads),
|
||||
"PYTHONIOENCODING": "utf-8"}
|
||||
process = await asyncio.create_subprocess_exec(str(interpreter()), str(Path(__file__).with_name("worker.py")),
|
||||
stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
|
||||
env=env, limit=16 * 1024 * 1024, **({"creationflags": 0x08000000} if os.name == "nt" else {}))
|
||||
request = {"key": key, "operation": operation, "model_path": str(model_path(key).resolve()),
|
||||
"config": config.model_dump(), "payload": payload}
|
||||
async def receive():
|
||||
process.stdin.write(json.dumps(request).encode())
|
||||
await process.stdin.drain()
|
||||
process.stdin.close()
|
||||
final = None
|
||||
while line := await process.stdout.readline():
|
||||
if len(line) > 16 * 1024 * 1024:
|
||||
raise ProviderError("LOCAL_MODEL_INVALID_RESPONSE", "本地模型输出超限。")
|
||||
message = json.loads(line)
|
||||
if "progress" in message:
|
||||
callback = runtime_progress.get()
|
||||
if callback:
|
||||
callback(message)
|
||||
else:
|
||||
final = message
|
||||
await process.wait()
|
||||
return final
|
||||
try:
|
||||
result = await asyncio.wait_for(receive(), config.timeout_seconds)
|
||||
except TimeoutError as exc:
|
||||
raise ProviderError("LOCAL_MODEL_TIMEOUT", "本地模型处理超时。") from exc
|
||||
if process.returncode != 0:
|
||||
raise ProviderError("LOCAL_MODEL_PROCESS_FAILED", "本地模型进程退出,请检查依赖与资源预算。")
|
||||
if not isinstance(result, dict):
|
||||
raise ProviderError("LOCAL_MODEL_INVALID_RESPONSE", "本地模型进程未返回有效结果。")
|
||||
if "error_code" in result:
|
||||
raise ProviderError(result["error_code"], result.get("message", "本地推理失败。"))
|
||||
attempt.observe(result)
|
||||
attempt.completed = True
|
||||
self.diagnostics.append({"model": CATALOG[key].repository, "revision": CATALOG[key].revision,
|
||||
**result.get("diagnostics", {})})
|
||||
self.diagnostics = self.diagnostics[-100:]
|
||||
return result["result"]
|
||||
finally:
|
||||
if ticket in self.waiters:
|
||||
self.waiters.remove(ticket)
|
||||
if process is not None and process.returncode is None:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
self.active.pop(ticket, None)
|
||||
self.active_files.pop(ticket, None)
|
||||
if attempt:
|
||||
attempt.persist()
|
||||
|
||||
|
||||
runtime = Runtime()
|
||||
|
||||
|
||||
class LocalEmbedding:
|
||||
dim = 384
|
||||
|
||||
@property
|
||||
def model_id(self):
|
||||
spec = CATALOG[configuration().embedding_model]
|
||||
return f"{spec.repository}@{spec.revision}"
|
||||
|
||||
@property
|
||||
def version(self):
|
||||
return CATALOG[configuration().embedding_model].revision
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
return read_state(configuration().embedding_model)["status"] == "installed" and interpreter().is_file()
|
||||
|
||||
async def embed_documents(self, texts):
|
||||
return await runtime.infer(configuration().embedding_model, "embedding", {"texts": texts}, priority=0)
|
||||
|
||||
async def embed_query(self, query):
|
||||
return (await self.embed_documents([query]))[0]
|
||||
|
||||
|
||||
class LocalSpeech:
|
||||
@property
|
||||
def available(self):
|
||||
return self.available_for("transcription")
|
||||
|
||||
def available_for(self, capability):
|
||||
key = "qwen3-asr" if capability == "transcription" else "eres2netv2"
|
||||
return read_state(key)["status"] == "installed" and interpreter().is_file()
|
||||
|
||||
async def transcribe(self, source, language):
|
||||
from app.providers.routing import RoutedTranscript
|
||||
from app.contracts import TranscriptSegment
|
||||
result = await runtime.infer("qwen3-asr", "transcription", {"source": str(source.resolve()), "language": language})
|
||||
return RoutedTranscript(text=result["text"], source="local",
|
||||
segments=[TranscriptSegment(**s) for s in result["segments"]])
|
||||
|
||||
async def match(self, source, reference):
|
||||
result = await runtime.infer("eres2netv2", "speaker_matching",
|
||||
{"source": str(source.resolve()), "reference": str(reference.resolve())}, priority=0)
|
||||
return result["score"]
|
||||
Reference in New Issue
Block a user