feat(v2): T2 运维层 hw_profile + llama_server 进程管理

This commit is contained in:
tzt
2026-08-30 21:00:52 +08:00
parent 1e51167ea5
commit 78a410b773
9 changed files with 851 additions and 1 deletions
+6
View File
@@ -0,0 +1,6 @@
"""运维层(runtime):本地 llama.cpp 运行时与硬件档位管理。
与 router_system 核心解耦:本包允许使用 httpx/fastapi 等第三方依赖,
用于真实本地模型(llama-server)的进程生命周期管理与硬件适配。
核心协议(交流文本)仍在 router_system 内保持零第三方依赖。
"""
+140
View File
@@ -0,0 +1,140 @@
"""硬件档位检测(runtime 运维层,纯标准库)。
把真实机器映射到三档保守模板之一:
- gpu12 : 约 ≥12GB 显存(NVIDIA / Vulkan 可探测) -> ngl 99, ctx 32768
- gpu8 : 约 ≥8GB 显存 -> ngl 14, ctx 16384
- cpu : 无独显或探测失败(保守兜底) -> ngl 0, ctx 8192
探测来源:nvidia-smiNVIDIA 显存)优先;其次 vulkaninfoAMD/Intel/通用,
只能判断是否存在 Vulkan 设备,无法可靠拿到显存 -> 保守回退 cpu,并在结果标注
probe:"conservative")。总系统内存仅作为 cpu 档提示参考,不作为分档依据。
任何探测失败都回退到 cpu 保守档,保证不崩、可离线运行(D5 / D8)。
"""
from __future__ import annotations
import shutil
import subprocess
from typing import Any, Callable, Dict, List, Optional
# 三档硬件模板(保守默认,可被 config.tiers 手动覆盖)
TIER_SPECS: Dict[str, Dict[str, Any]] = {
"gpu12": {"tier": "gpu12", "ngl": 99, "ctx": 32768, "kv_quant": "q8_0"},
"gpu8": {"tier": "gpu8", "ngl": 14, "ctx": 16384, "kv_quant": "q8_0"},
"cpu": {"tier": "cpu", "ngl": 0, "ctx": 8192, "kv_quant": "q8_0"},
}
_GPU12_THRESHOLD_GB = 12.0
_GPU8_THRESHOLD_GB = 8.0
def _run(cmd: List[str], timeout: float = 10.0,
runner: Optional[Callable[[List[str], float], subprocess.CompletedProcess]] = None
) -> Optional[subprocess.CompletedProcess]:
"""执行命令并捕获输出;失败/超时返回 None(不抛异常)。"""
if runner is not None:
try:
return runner(cmd, timeout)
except Exception:
return None
try:
return subprocess.run(
cmd, capture_output=True, text=True, timeout=timeout,
creationflags=subprocess.CREATE_NO_WINDOW,
)
except Exception:
return None
def nvidia_vram_gb(runner: Optional[Callable[..., subprocess.CompletedProcess]] = None) -> Optional[float]:
"""通过 nvidia-smi 读取显存总量(GB);无 NVIDIA 返回 None。"""
exe = shutil.which("nvidia-smi")
if not exe:
return None
out = _run([exe, "--query-gpu=memory.total", "--format=csv,noheader,nounits"], runner=runner)
if out is None or out.returncode != 0 or not out.stdout.strip():
return None
try:
# 多卡取最大值(第一行也接受,但保守起见取最大以保证模板内存够用)
vals = [float(v.strip()) for v in out.stdout.strip().splitlines() if v.strip().isdigit()]
if not vals:
return None
return max(vals) / 1024.0
except Exception:
return None
def vulkan_present(runner: Optional[Callable[..., subprocess.CompletedProcess]] = None) -> bool:
"""检测是否存在 Vulkan 设备(无法可靠拿显存 -> 只用于判定非 cpu 的候选)。"""
exe = shutil.which("vulkaninfo")
if not exe:
return False
out = _run([exe, "--summary"], timeout=15.0, runner=runner)
if out is None or out.returncode != 0:
return False
low = out.stdout.lower()
# 出现 deviceName 且非 "llvmpipe"/"software" 视为有真实设备
return ("devicename" in low or "gpu" in low) and "llvmpipe" not in low and "lavapipe" not in low
def pick_tier(vram_gb: Optional[float]) -> str:
"""按显存选择档位;None/未知 -> cpu 保守档。"""
if vram_gb is None:
return "cpu"
if vram_gb >= _GPU12_THRESHOLD_GB:
return "gpu12"
if vram_gb >= _GPU8_THRESHOLD_GB:
return "gpu8"
return "cpu"
def detect(override: Optional[Dict[str, Any]] = None,
runner: Optional[Callable[..., subprocess.CompletedProcess]] = None) -> Dict[str, Any]:
"""检测并返回当前档位规格。
override(可选):{"tier": "gpu12"} 强制指定档位;或覆盖单个字段如 {"ctx": 16384}。
返回形如 {"tier": "cpu", "ngl": 0, "ctx": 8192, "kv_quant": "q8_0",
"probe": "nvidia|vulkan|cpu|override", "note": str}
"""
if override and override.get("tier") in TIER_SPECS:
spec = dict(TIER_SPECS[override["tier"]])
spec.update({k: v for k, v in override.items() if k in spec})
spec["probe"] = "override"
spec["note"] = f"手动指定档位 {override['tier']}"
return spec
vram = nvidia_vram_gb(runner=runner)
probe = "nvidia"
if vram is None:
if vulkan_present(runner=runner):
probe = "vulkan"
note = "检测到 Vulkan 设备但无法读取显存,按保守档 cpu 运行(可在 config 手动覆盖 tier"
else:
probe = "cpu"
note = "未检测到 GPU,按 cpu 档运行(-ngl 0,速度受限)"
else:
note = f"nvidia-smi 探测显存 {vram:.1f}GB"
tier = pick_tier(vram)
spec = dict(TIER_SPECS[tier])
spec["probe"] = probe
spec["note"] = note if probe != "nvidia" else f"{note} -> 档位 {tier}"
return spec
def tier_spec(tier: str) -> Dict[str, Any]:
"""返回指定档位的规格副本(供 config.tiers 兜底)。"""
if tier not in TIER_SPECS:
raise ValueError(f"未知硬件档位: {tier}(支持: {sorted(TIER_SPECS)}")
return dict(TIER_SPECS[tier])
def detect_summary() -> str:
"""人类可读的检测摘要(setup_runtime / serve 启动时打印)。"""
spec = detect()
return (
f"硬件档位: {spec['tier']} (ngl={spec['ngl']}, ctx={spec['ctx']}, "
f"kv_quant={spec['kv_quant']}) [{spec.get('note', '')}]"
)
+264
View File
@@ -0,0 +1,264 @@
"""llama-server 子进程生命周期管理(runtime 运维层)。
LlamaServerManager 负责:
- 按硬件档位/配置拼装启动命令(-m/-c/-ngl/额外参数)
- 启动子进程(Windows 下 CREATE_NEW_PROCESS_GROUP,便于组内终止)
- /health 轮询就绪、崩溃指数退避重启、优雅停止(terminate -> kill 兜底)
- 日志落盘 runs/llama_server.log
设计(D1 / D8 / D11):
- 不修改 llama.cpp 源码,只捆绑上游 release 二进制。
- 本模块可用第三方依赖(httpx),但健康检查默认用 urllib 保持轻量、可注入。
- 一切外部副作用(health 探测、进程 spawn)均可注入替身,保证封闭单测。
"""
from __future__ import annotations
import datetime
import json
import os
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
from .hw_profile import tier_spec
def _now() -> str:
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
class LlamaServerError(RuntimeError):
"""llama-server 启动/运行异常。"""
class LlamaServerManager:
"""管理单个 llama-server 子进程(单模型单实例,D5)。"""
def __init__(
self,
binary: str,
model: str,
port: int = 8901,
hw: Optional[Dict[str, Any]] = None,
extra_args: Optional[List[str]] = None,
health_timeout_s: float = 120.0,
poll_interval_s: float = 1.0,
max_restarts: int = 2,
log_dir: Optional[str] = None,
env: Optional[Dict[str, str]] = None,
health_check: Optional[Callable[[str], bool]] = None,
):
self.binary = Path(binary)
self.model = Path(model)
self.port = int(port)
# 档位规格:默认取 config 传入的 hw;缺少时按 tier 从内置表补全
self.hw = dict(hw or {"tier": "cpu"})
self.extra_args = list(extra_args or [])
self.health_timeout_s = health_timeout_s
self.poll_interval_s = poll_interval_s
self.max_restarts = max_restarts
self.log_dir = Path(log_dir) if log_dir else Path("runs")
self.env = dict(env) if env else None
self._health_check = health_check or self._default_health_check
self._proc: Optional[subprocess.Popen] = None
self._log_path: Optional[Path] = None
self._started_at: Optional[float] = None
self._restart_count = 0
# ---------------------------------------------------------------
# 命令拼装(纯函数,便于单测)
# ---------------------------------------------------------------
def _build_command(self) -> List[str]:
spec = tier_spec(self.hw.get("tier", "cpu"))
ngl = self.hw.get("ngl", spec["ngl"])
ctx = self.hw.get("ctx", spec["ctx"])
kv = self.hw.get("kv_quant", spec["kv_quant"])
cmd = [
str(self.binary),
"-m", str(self.model),
"--port", str(self.port),
"-ngl", str(ngl),
"-c", str(ctx),
"-ctk", kv,
"-ctv", kv,
]
cmd.extend(self.extra_args)
return cmd
def command_preview(self) -> str:
"""启动命令预览(供日志/诊断打印,不执行)。"""
return " ".join(self._build_command())
# ---------------------------------------------------------------
# 健康检查
# ---------------------------------------------------------------
def _default_health_check(self, endpoint: str) -> bool:
"""GET {endpoint}/health,2 秒超时;网络异常视为不健康。"""
url = f"{endpoint}/health"
try:
with urllib.request.urlopen(url, timeout=2.0) as resp:
if resp.status != 200:
return False
body = resp.read(200).decode("utf-8", errors="replace")
data = json.loads(body) if body else {}
return data.get("status", "").lower() == "ok" or "llama" in body.lower()
except Exception:
return False
def health(self) -> bool:
"""探测当前是否健康(进程在且 /health 通过)。"""
if self._proc is None or self._proc.poll() is not None:
return False
return self._health_check(self.endpoint())
# ---------------------------------------------------------------
# 生命周期
# ---------------------------------------------------------------
def endpoint(self) -> str:
return f"http://127.0.0.1:{self.port}"
def _log(self, msg: str) -> None:
self.log_dir.mkdir(parents=True, exist_ok=True)
line = f"[{_now()}] {msg}"
path = self._log_path or (self.log_dir / "llama_server.log")
self._log_path = path
try:
with open(path, "a", encoding="utf-8") as f:
f.write(line + "\n")
except OSError:
pass
def start(self) -> bool:
"""启动子进程并轮询至健康就绪。
返回 True 表示健康就绪;False 表示启动失败/超时(进程可能已退出)。
"""
if self._proc is not None and self._proc.poll() is None:
return self.health()
if not self.binary.exists():
raise LlamaServerError(
f"llama-server 二进制不存在: {self.binary}。请先运行 "
f"scripts/setup_runtime.py 下载,或将上游 release 放入 bin/(D1 不改源码)。"
)
if not self.model.exists():
raise LlamaServerError(
f"模型文件不存在: {self.model}。请先运行 scripts/setup_runtime.py 下载 GGUF。"
)
cmd = self._build_command()
self.log_dir.mkdir(parents=True, exist_ok=True)
logf = self.log_dir / "llama_server.log"
self._log_path = logf
self._log(f"启动: {self.command_preview()}")
kwargs: Dict[str, Any] = {}
if os.name == "nt":
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW
try:
self._proc = subprocess.Popen(
cmd,
stdout=open(logf, "ab", buffering=0),
stderr=subprocess.STDOUT,
env=self.env,
**kwargs,
)
except OSError as e:
self._log(f"spawn 失败: {e}")
self._proc = None
raise LlamaServerError(f"无法启动 llama-server: {e}") from e
self._started_at = time.time()
return self._wait_healthy()
def _wait_healthy(self) -> bool:
deadline = time.time() + self.health_timeout_s
while time.time() < deadline:
if self._proc.poll() is not None:
self._log(f"进程过早退出 rc={self._proc.returncode}")
return False
if self.health():
self._log(f"健康就绪 @ {self.endpoint()} (pid={self._proc.pid})")
return True
time.sleep(self.poll_interval_s)
self._log("健康检查超时,标记为启动失败")
return False
def stop(self, timeout_s: float = 8.0) -> None:
"""优雅停止:terminateCTRL_BREAK-> 等待 -> kill 兜底(Windows 语义)。"""
proc = self._proc
if proc is None:
return
if proc.poll() is not None:
self._proc = None
return
try:
proc.terminate()
except OSError:
pass
try:
proc.wait(timeout=timeout_s)
except subprocess.TimeoutExpired:
self._log("terminate 超时,kill 兜底")
try:
proc.kill()
except OSError:
pass
try:
proc.wait(timeout=5.0)
except subprocess.TimeoutExpired:
pass
self._proc = None
self._log("已停止")
def ensure_alive(self) -> bool:
"""保活:不健康则按指数退避重启(最多 max_restarts 次)。"""
if self._proc is not None and self._proc.poll() is None and self.health():
return True
if self._restart_count >= self.max_restarts:
return False
backoff = min(2.0 ** self._restart_count, 8.0)
self._restart_count += 1
self._log(f"检测到异常,{backoff:.1f}s 后重启(第 {self._restart_count}/{self.max_restarts} 次)")
time.sleep(backoff)
if self._proc is not None and self._proc.poll() is None:
self.stop()
return self.start()
# ---------------------------------------------------------------
@property
def running(self) -> bool:
return self._proc is not None and self._proc.poll() is None
@property
def pid(self) -> Optional[int]:
return self._proc.pid if self._proc is not None else None
def __enter__(self) -> "LlamaServerManager":
self.start()
return self
def __exit__(self, *exc) -> None:
self.stop()
def build_llama_server(cfg: Dict[str, Any]) -> LlamaServerManager:
"""从 config.runtime.llama_server 段构建管理器。cfg 含 binary/model/port/hw_profile/extra_args。"""
binary = cfg.get("binary", "bin/llama-server.exe")
model = cfg.get("model", "models/qwen3.5-4b-q4_k_m.gguf")
port = int(cfg.get("port", 8901))
hw = cfg.get("hw", {}) or {}
extra = cfg.get("extra_args", [])
return LlamaServerManager(
binary=binary,
model=model,
port=port,
hw=hw,
extra_args=extra,
health_timeout_s=float(cfg.get("health_timeout_s", 120)),
max_restarts=int(cfg.get("max_restarts", 2)),
log_dir=cfg.get("log_dir"),
)