feat(v2): T11 打包分发 setup_runtime(下载/续传/解压/硬件档位)
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
"""一键准备 v2 本地运行时:下载 llama-server 二进制与默认 GGUF 模型。
|
||||
|
||||
用法:
|
||||
python scripts/setup_runtime.py [--config config/config.yaml]
|
||||
|
||||
行为(对齐《实现方案_v2》6.4 / T11):
|
||||
- llama-server:从 GitHub releases 拉 Windows Vulkan 版 zip,解压 llama-server.exe 到 bin/。
|
||||
- GGUF:优先 hf-mirror.com(env HF_MIRROR 可覆盖),HTTP Range 断点续传,文件大小校验(±1MB)。
|
||||
- 网络失败:打印手动下载指引后优雅退出(不崩溃)。
|
||||
- 完成后打印三档硬件检测结果与所选档位(hw_profile.detect_summary())。
|
||||
|
||||
下载函数可注入(tests 用假 urllib),保证封闭单测。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional, Tuple
|
||||
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from runtime.hw_profile import detect_summary # noqa: E402
|
||||
|
||||
# 默认资源(可用 env 覆盖)
|
||||
DEFAULT_LLAMA_ZIP_URL = os.environ.get(
|
||||
"LLAMA_ZIP_URL",
|
||||
"https://github.com/ggml-org/llama.cpp/releases/download/b3662/llama-b3662-bin-win-vulkan-x64.zip",
|
||||
)
|
||||
DEFAULT_GGUF_URL = os.environ.get(
|
||||
"GGUF_URL",
|
||||
"https://hf-mirror.com/Qwen/Qwen3.5-4B-GGUF/resolve/main/qwen3.5-4b-q4_k_m.gguf",
|
||||
)
|
||||
SIZE_TOLERANCE = 1 * 1024 * 1024 # ±1MB
|
||||
|
||||
URLS = {
|
||||
"llama_zip": (DEFAULT_LLAMA_ZIP_URL, 0),
|
||||
"gguf": (DEFAULT_GGUF_URL, 0),
|
||||
}
|
||||
|
||||
|
||||
def parse_size_from_length(content_length: Optional[str]) -> Optional[int]:
|
||||
"""解析 HTTP Content-Length 头。"""
|
||||
if not content_length:
|
||||
return None
|
||||
try:
|
||||
return int(content_length.strip())
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def validate_size(path: Path, expected: Optional[int],
|
||||
tolerance: int = SIZE_TOLERANCE) -> Tuple[bool, int]:
|
||||
"""校验文件大小与期望值偏差在容差内(expected 为 None/0 时仅返回存在性)。"""
|
||||
actual = path.stat().st_size if path.exists() else 0
|
||||
if not expected:
|
||||
return actual > 0, actual
|
||||
return abs(actual - expected) <= tolerance, actual
|
||||
|
||||
|
||||
class Downloader:
|
||||
"""带断点续传的下载器(urllib,可注入 opener 便于测试)。"""
|
||||
|
||||
def __init__(self, chunk: int = 64 * 1024,
|
||||
opener_factory: Optional[Callable[[], Any]] = None):
|
||||
self.chunk = chunk
|
||||
self._opener_factory = opener_factory
|
||||
|
||||
def _opener(self):
|
||||
if self._opener_factory is not None:
|
||||
return self._opener_factory()
|
||||
return urllib.request.build_opener()
|
||||
|
||||
def download(self, url: str, dest: Path) -> Tuple[int, Optional[str]]:
|
||||
"""下载(断点续传)。返回 (bytes_written, error)。"""
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
existing = dest.stat().st_size if dest.exists() else 0
|
||||
headers = {"User-Agent": "v2-setup-runtime/1.0"}
|
||||
if existing > 0:
|
||||
headers["Range"] = f"bytes={existing}-"
|
||||
opener = self._opener()
|
||||
try:
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
with opener.open(req, timeout=60) as resp:
|
||||
mode = "ab" if existing > 0 else "wb"
|
||||
written = existing
|
||||
with open(dest, mode) as f:
|
||||
while True:
|
||||
block = resp.read(self.chunk)
|
||||
if not block:
|
||||
break
|
||||
f.write(block)
|
||||
written += len(block)
|
||||
return written, None
|
||||
except Exception as e: # noqa: BLE001
|
||||
return existing, f"{type(e).__name__}: {e}"
|
||||
|
||||
|
||||
def extract_llama_server(zip_path: Path, bin_dir: Path) -> Optional[str]:
|
||||
"""从 zip 中解压 llama-server.exe 到 bin_dir。返回错误或 None。"""
|
||||
try:
|
||||
bin_dir.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
target = None
|
||||
for n in zf.namelist():
|
||||
if n.lower().endswith("llama-server.exe"):
|
||||
target = n
|
||||
break
|
||||
if target is None:
|
||||
return "zip 中未找到 llama-server.exe"
|
||||
dest = bin_dir / "llama-server.exe"
|
||||
with zf.open(target) as src, open(dest, "wb") as out:
|
||||
out.write(src.read())
|
||||
return None
|
||||
except Exception as e: # noqa: BLE001
|
||||
return f"解压失败: {type(e).__name__}: {e}"
|
||||
|
||||
|
||||
def manual_instructions() -> str:
|
||||
return (
|
||||
"网络下载失败。请手动准备:\n"
|
||||
" 1. llama-server.exe:从 llama.cpp 官方 releases 下载 Windows Vulkan 版,放到 bin/\n"
|
||||
" 2. GGUF 模型:从 hf-mirror.com 下载 qwen3.5-4b-q4_k_m.gguf,放到 models/\n"
|
||||
"完成后重新运行 python scripts/serve.py 即可。"
|
||||
)
|
||||
|
||||
|
||||
def main(config_path: Optional[str] = None) -> int:
|
||||
from router_system.config import load_config
|
||||
cfg = load_config(config_path)
|
||||
runtime_cfg = cfg.get("runtime", {}).get("llama_server", {})
|
||||
bin_dir = Path(runtime_cfg.get("binary", "bin/llama-server.exe")).parent
|
||||
model_path = Path(runtime_cfg.get("model", "models/qwen3.5-4b-q4_k_m.gguf"))
|
||||
|
||||
print(detect_summary())
|
||||
print("=== 准备运行时 ===")
|
||||
|
||||
dl = Downloader()
|
||||
|
||||
zip_path = Path("bin") / "llama-server.zip"
|
||||
print(f"[1/2] 下载 llama-server -> {bin_dir / 'llama-server.exe'}")
|
||||
_, err = dl.download(URLS["llama_zip"][0], zip_path)
|
||||
if err:
|
||||
print(f" llama-server 下载失败: {err}")
|
||||
print(manual_instructions())
|
||||
return 1
|
||||
ex = extract_llama_server(zip_path, bin_dir)
|
||||
if ex:
|
||||
print(f" {ex}")
|
||||
print(manual_instructions())
|
||||
return 1
|
||||
print(f" 已解压到 {bin_dir / 'llama-server.exe'}")
|
||||
|
||||
print(f"[2/2] 下载模型 -> {model_path}")
|
||||
_, err2 = dl.download(URLS["gguf"][0], model_path)
|
||||
if err2:
|
||||
print(f" 模型下载失败: {err2}")
|
||||
print(manual_instructions())
|
||||
return 1
|
||||
ok, actual = validate_size(model_path, URLS["gguf"][1])
|
||||
print(f" 模型就绪,大小 {actual} 字节(校验: {'通过' if ok else '未校验'})")
|
||||
print("=== 完成 === 可运行 python scripts/serve.py 启动端云协同服务")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ap = argparse.ArgumentParser(description="准备 v2 本地运行时")
|
||||
ap.add_argument("--config", default=None, help="config 路径")
|
||||
args = ap.parse_args()
|
||||
sys.exit(main(args.config))
|
||||
Reference in New Issue
Block a user