feat(v2): T11 打包分发 setup_runtime(下载/续传/解压/硬件档位)

This commit is contained in:
tzt
2026-08-30 21:17:22 +08:00
parent ff7e1bc8de
commit 70b9907a89
3 changed files with 301 additions and 1 deletions
+177
View File
@@ -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.comenv 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))