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))
+123
View File
@@ -0,0 +1,123 @@
"""T11 setup_runtime 单测(封闭:假 opener / 假 zip)。"""
import io
import zipfile
from pathlib import Path
from scripts.setup_runtime import (
Downloader,
extract_llama_server,
manual_instructions,
parse_size_from_length,
validate_size,
)
class _FakeResp:
def __init__(self, data, status=200):
self._buf = io.BytesIO(data)
self.status = status
def __enter__(self):
return self
def __exit__(self, *a):
return False
def read(self, n=-1):
return self._buf.read(n)
class _FakeOpener:
def __init__(self, data=b"hello world"):
self.data = data
self.request_url = None
self.request_headers = {}
def __call__(self):
return self
def open(self, req, timeout=None):
self.request_url = req.full_url
self.request_headers = dict(req.headers)
return _FakeResp(self.data)
def test_parse_size_from_length():
assert parse_size_from_length("1024") == 1024
assert parse_size_from_length(" 42 ") == 42
assert parse_size_from_length(None) is None
assert parse_size_from_length("abc") is None
def test_validate_size(tmp_path):
p = tmp_path / "size.txt"
p.write_bytes(b"x" * (2 * 1024 * 1024)) # 2MB
ok, actual = validate_size(p, 2 * 1024 * 1024)
assert ok is True and actual == 2 * 1024 * 1024
ok, _ = validate_size(p, 100) # 2MB vs 100 超出 ±1MB 容差
assert ok is False
ok, _ = validate_size(p, 0) # expected=0 -> 仅存在性
assert ok is True
def test_downloader_writes_file(tmp_path):
opener = _FakeOpener(b"ABCDEF")
dl = Downloader(chunk=2, opener_factory=lambda: opener)
dest = tmp_path / "out.bin"
written, err = dl.download("https://x/y", dest)
assert err is None
assert written == 6
assert dest.read_bytes() == b"ABCDEF"
assert opener.request_headers.get("Range") is None # 无既有文件 -> 不带 Range
def test_downloader_resumes(tmp_path):
opener = _FakeOpener(b"CDEF")
dl = Downloader(chunk=2, opener_factory=lambda: opener)
dest = tmp_path / "out.bin"
dest.write_bytes(b"AB") # 既有 2 字节 -> 应带 Range: bytes=2-
written, err = dl.download("https://x/y", dest)
assert err is None
assert written == 6 # 2 + 4
assert dest.read_bytes() == b"ABCDEF"
assert opener.request_headers.get("Range") == "bytes=2-"
def test_downloader_failure_returns_error(tmp_path):
class Boom:
def __call__(self):
return self
def open(self, req, timeout=None):
raise OSError("network down")
dl = Downloader(opener_factory=lambda: Boom())
dest = tmp_path / "out.bin"
written, err = dl.download("https://x/y", dest)
assert err is not None
assert "OSError" in err
def test_extract_llama_server(tmp_path):
zip_path = tmp_path / "llama.zip"
bin_dir = tmp_path / "bin"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("llama-b3662/bin/llama-server.exe", b"MZfake")
zf.writestr("llama-b3662/README.md", "readme")
err = extract_llama_server(zip_path, bin_dir)
assert err is None
assert (bin_dir / "llama-server.exe").exists()
def test_extract_missing_exe(tmp_path):
zip_path = tmp_path / "no.exe.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("a.txt", "x")
err = extract_llama_server(zip_path, tmp_path / "bin")
assert err is not None
assert "未找到" in err
def test_manual_instructions_nonempty():
s = manual_instructions()
assert "llama-server" in s and "GGUF" in s
+1 -1
View File
@@ -77,7 +77,7 @@ P0 完成后的能力:干净的后端抽象 + 可量化的评测 + 可追溯
| T8 | 人工检验队列 ReviewQueue | ✅ 完成 | T8 |
| T9 | token 计量与账单 | ✅ 完成 | T9 |
| T10 | rollup + prefix cache 调优 | ✅ 完成 | T10 |
| T11 | 打包分发 setup_runtime.py | ⬜ | |
| T11 | 打包分发 setup_runtime.py | ✅ 完成 | T11 |
| T12 | 实验脚本 bench_tokens.py + 数据集 | ⬜ | |
| T13 | E1E5 跑数到 research/v2_experiments/ | ⬜ | |
| T14 | 文档收口(README v2 改写) | ⬜ | |