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))
|
||||
@@ -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
@@ -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 | E1–E5 跑数到 research/v2_experiments/ | ⬜ | |
|
||||
| T14 | 文档收口(README v2 改写) | ⬜ | |
|
||||
|
||||
Reference in New Issue
Block a user