feat(v2): T2 运维层 hw_profile + llama_server 进程管理
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
"""运维层(runtime):本地 llama.cpp 运行时与硬件档位管理。
|
||||
|
||||
与 router_system 核心解耦:本包允许使用 httpx/fastapi 等第三方依赖,
|
||||
用于真实本地模型(llama-server)的进程生命周期管理与硬件适配。
|
||||
核心协议(交流文本)仍在 router_system 内保持零第三方依赖。
|
||||
"""
|
||||
@@ -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-smi(NVIDIA 显存)优先;其次 vulkaninfo(AMD/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', '')}]"
|
||||
)
|
||||
@@ -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:
|
||||
"""优雅停止:terminate(CTRL_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"),
|
||||
)
|
||||
Reference in New Issue
Block a user