178 lines
6.5 KiB
Python
178 lines
6.5 KiB
Python
"""一键准备 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))
|