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
+5 -1
View File
@@ -13,13 +13,17 @@ htmlcov/
*.env
api_keys*.json
# Models / data
# Models / data / runtime
models/
data/
bin/
runs/
*.bin
*.gguf
*.safetensors
cached_results/
# OS / editor
.DS_Store
Thumbs.db
+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"),
)
+8
View File
@@ -0,0 +1,8 @@
"""测试辅助:获取空闲 TCP 端口。"""
import socket
def free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
+54
View File
@@ -0,0 +1,54 @@
"""测试替身:模拟 llama-server(供 LlamaServerManager 封闭单测,D11)。
- 解析 --port / -m / -ngl / -c(与真实 llama-server 参数对齐)
- 把 pid / 收到的参数写入环境变量 FAKE_MARKER 指向的 JSON 文件
- 在本机端口起一个最小 http 服务:/health 返回 {"status":"ok"}
- 进程被终止时正常退出
"""
import argparse
import http.server
import json
import os
import sys
def main() -> int:
parser = argparse.ArgumentParser(prog="fake-llama-server")
parser.add_argument("--port", type=int, default=8901)
parser.add_argument("-m", dest="model", default="")
parser.add_argument("-ngl", dest="ngl", default="0")
parser.add_argument("-c", dest="ctx", default="8192")
parser.add_argument("-ctk", dest="ctk", default="")
parser.add_argument("-ctv", dest="ctv", default="")
args, _ = parser.parse_known_args()
marker = os.environ.get("FAKE_MARKER")
if marker:
os.makedirs(os.path.dirname(marker) or ".", exist_ok=True)
with open(marker, "w", encoding="utf-8") as f:
json.dump({"pid": os.getpid(), "port": args.port,
"model": args.model, "args": sys.argv[1:]}, f)
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
if self.path.startswith("/health"):
body = json.dumps({"status": "ok", "server": "fake-llama"}).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
else:
self.send_response(404)
self.end_headers()
def log_message(self, *a):
pass
srv = http.server.ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
srv.serve_forever()
return 0
if __name__ == "__main__":
sys.exit(main())
+114
View File
@@ -0,0 +1,114 @@
"""T2 硬件档位检测单测(封闭,纯逻辑 + 注入 runner)。"""
import subprocess
import pytest
from runtime.hw_profile import (
TIER_SPECS,
detect,
nvidia_vram_gb,
pick_tier,
tier_spec,
vulkan_present,
)
class _FakeRun:
"""注入 subprocess runner:按命令返回预置输出。"""
def __init__(self, mapping):
self.mapping = mapping # {关键子串: CompletedProcess}
def __call__(self, cmd, timeout):
joined = " ".join(cmd)
for key, cp in self.mapping.items():
if key in joined:
return cp
raise FileNotFoundError(cmd)
def _cp(stdout="", rc=0):
return subprocess.CompletedProcess(args=[], returncode=rc, stdout=stdout, stderr="")
def test_pick_tier_thresholds():
assert pick_tier(None) == "cpu"
assert pick_tier(6.0) == "cpu"
assert pick_tier(8.0) == "gpu8"
assert pick_tier(11.9) == "gpu8"
assert pick_tier(12.0) == "gpu12"
assert pick_tier(24.0) == "gpu12"
def test_tier_specs_have_required_fields():
for tier, spec in TIER_SPECS.items():
assert spec["tier"] == tier
assert "ngl" in spec and "ctx" in spec and "kv_quant" in spec
# gpu12 ngl 全量卸载,cpu ngl 0
assert TIER_SPECS["gpu12"]["ngl"] == 99
assert TIER_SPECS["cpu"]["ngl"] == 0
def test_tier_spec_unknown_raises():
with pytest.raises(ValueError):
tier_spec("nonexistent")
def test_nvidia_vram_gb_parses():
fake = _FakeRun({"nvidia-smi": _cp("24576\n")})
assert nvidia_vram_gb(runner=fake) == 24.0
def test_nvidia_vram_gb_multi_gpu_takes_max():
fake = _FakeRun({"nvidia-smi": _cp("8192\n12288\n")})
assert nvidia_vram_gb(runner=fake) == 12.0
def test_nvidia_vram_gb_missing_tool_returns_none(monkeypatch):
import shutil
monkeypatch.setattr(shutil, "which", lambda name: None)
assert nvidia_vram_gb() is None
def test_vulkan_present_true():
fake = _FakeRun({"vulkaninfo": _cp("deviceName : NVIDIA GeForce RTX 4090")})
assert vulkan_present(runner=fake) is True
def test_vulkan_present_software_false():
fake = _FakeRun({"vulkaninfo": _cp("deviceName : llvmpipe (LLVM)")})
assert vulkan_present(runner=fake) is False
def test_detect_nvidia_gpu12(monkeypatch):
# 让 shutil.which 只对 nvidia-smi 生效
real_which = __import__("shutil").which
def fake_which(name):
return "C:/x/nvidia-smi.exe" if name == "nvidia-smi" else None
monkeypatch.setattr(__import__("shutil"), "which", fake_which)
fake = _FakeRun({"nvidia-smi": _cp("24576\n")})
spec = detect(runner=fake)
assert spec["tier"] == "gpu12"
assert spec["ngl"] == 99 and spec["ctx"] == 32768
assert spec["probe"] == "nvidia"
def test_detect_cpu_fallback(monkeypatch):
monkeypatch.setattr(__import__("shutil"), "which", lambda name: None)
spec = detect()
assert spec["tier"] == "cpu"
assert spec["ngl"] == 0
assert spec["probe"] == "cpu"
def test_detect_override_tier():
spec = detect(override={"tier": "gpu8"})
assert spec["tier"] == "gpu8"
assert spec["ngl"] == 14 and spec["ctx"] == 16384
assert spec["probe"] == "override"
def test_detect_override_field():
spec = detect(override={"tier": "cpu", "ctx": 16384})
assert spec["ctx"] == 16384
assert spec["tier"] == "cpu"
+152
View File
@@ -0,0 +1,152 @@
"""T2 llama-server 进程管理单测(封闭:假二进制 + 注入,D11)。"""
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
import pytest
from runtime.llama_server import LlamaServerManager, LlamaServerError, build_llama_server
from tests._ports import free_port
FAKE_SCRIPT = Path(__file__).parent / "fixtures" / "fake_llama_server.py"
PYTHON = sys.executable
def _make_fake_binary(tmp: Path) -> Path:
"""生成一个 .cmd 包装器:把 venv python + 假脚本当作"二进制"启动。"""
cmd = tmp / "fake-llama-server.cmd"
cmd.write_text(
f'@echo off\r\n"{PYTHON}" "{FAKE_SCRIPT}" %*\r\n',
encoding="utf-8",
)
return cmd
def _make_manager(tmp, binary, port, model, **kw):
marker = tmp / "marker.json"
env = dict(os.environ)
env["FAKE_MARKER"] = str(marker)
return LlamaServerManager(
binary=str(binary),
model=str(model),
port=port,
hw={"tier": "cpu"},
health_timeout_s=15.0,
poll_interval_s=0.2,
log_dir=str(tmp / "runs"),
env=env,
**kw,
), marker
def test_build_command_uses_hw_tier():
m = LlamaServerManager(binary="bin/x.exe", model="models/m.gguf", port=8901,
hw={"tier": "gpu12"})
cmd = m._build_command()
assert Path(cmd[0]) == Path("bin/x.exe")
assert cmd[1] == "-m" and Path(cmd[2]) == Path("models/m.gguf")
assert "--port" in cmd and "8901" in cmd
assert cmd[cmd.index("-ngl") + 1] == "99"
assert cmd[cmd.index("-c") + 1] == "32768"
def test_build_command_extra_args_appended():
m = LlamaServerManager(binary="bin/x.exe", model="models/m.gguf", port=1,
hw={"tier": "cpu"}, extra_args=["--cache-reuse", "256"])
cmd = m._build_command()
assert cmd[-2:] == ["--cache-reuse", "256"]
def test_start_missing_binary_raises(tmp_path):
m = LlamaServerManager(binary=str(tmp_path / "nope.exe"), model=str(tmp_path / "m.gguf"),
port=free_port())
with pytest.raises(LlamaServerError):
m.start()
def test_start_missing_model_raises(tmp_path):
binary = _make_fake_binary(tmp_path)
m = LlamaServerManager(binary=str(binary), model=str(tmp_path / "missing.gguf"),
port=free_port())
with pytest.raises(LlamaServerError):
m.start()
def test_full_lifecycle(tmp_path):
binary = _make_fake_binary(tmp_path)
model = tmp_path / "model.gguf"
model.write_bytes(b"fake")
port = free_port()
m, marker = _make_manager(tmp_path, binary, port, model)
assert m.running is False
assert m.health() is False # 无进程时不健康
ok = m.start()
assert ok is True
assert m.running is True
assert m.health() is True
# 假脚本确实收到了参数
data = json.loads(marker.read_text(encoding="utf-8"))
assert data["port"] == port
assert data["model"] == str(model)
# 再 start 幂等(已运行返回健康)
assert m.start() is True
m.stop()
assert m.running is False
assert m.health() is False
def test_stop_idempotent(tmp_path):
binary = _make_fake_binary(tmp_path)
model = tmp_path / "model.gguf"
model.write_bytes(b"fake")
m, _ = _make_manager(tmp_path, binary, free_port(), model)
m.stop() # 未启动时 stop 不抛
assert m.running is False
def test_ensure_alive_healthy_no_restart(tmp_path):
binary = _make_fake_binary(tmp_path)
model = tmp_path / "model.gguf"
model.write_bytes(b"fake")
m, _ = _make_manager(tmp_path, binary, free_port(), model)
m.start()
assert m.ensure_alive() is True
# 不应触发重启
assert m._restart_count == 0
m.stop()
def test_ensure_alive_restart_exhausted(tmp_path):
binary = _make_fake_binary(tmp_path)
model = tmp_path / "model.gguf"
model.write_bytes(b"fake")
m, _ = _make_manager(tmp_path, binary, free_port(), model, max_restarts=0)
# 未启动:restart 上限 0 -> False
assert m.ensure_alive() is False
def test_endpoint_format():
m = LlamaServerManager(binary="x", model="m", port=8901, hw={"tier": "cpu"})
assert m.endpoint() == "http://127.0.0.1:8901"
def test_build_from_config(tmp_path):
binary = _make_fake_binary(tmp_path)
cfg = {
"binary": str(binary),
"model": str(tmp_path / "m.gguf"),
"port": 8999,
"hw": {"tier": "cpu"},
"extra_args": ["-fa"],
}
m = build_llama_server(cfg)
assert m.port == 8999
assert "-fa" in m.extra_args
+108
View File
@@ -0,0 +1,108 @@
# 任务拆解与执行计划
> 任务体系:主任务(整体项目)→ 附加任务(先行实现:整体项目部分拆解)
> 建立日期:2026-08-14
---
## 一、任务体系总览
```
主任务:多专业小模型 + 路由模型系统(整体项目)
├─ 已完成部分(专家系统内核):
│ · 知识库(8 领域 67 规则 + 17 模板 + 45 事实)
│ · 黑板/前向链/Planner/DAG 路由(L0 零参数)
│ · 三级子领域(domain → subdomain → subdomain2
│ · 两级路由体系(domain_group → 组内路由模型)
│ · 92 项单元测试全绿
└─ 附加任务(★ 先行实现):整体项目部分拆解
· 目标:把整体项目剩余工作拆解为可独立执行的部分任务,
并优先实现第一批(P0),为后续铺路
· 状态:进行中
```
## 二、整体项目剩余工作拆解清单
| # | 任务 | 内容 | 依赖 | 优先级 | 预估 | 状态 |
|---|------|------|------|--------|------|------|
| T1 | NodeExecutor 接口抽象 | 解耦 `_execute_node` 的 if-elserule/model/未来后端统一接口 + 工厂 | — | **P0** | 0.5 天 | ✅ 完成 |
| T2 | 评测基准扩充 | eval 扩到 8 领域 24 样例 + 组路由/子领域识别指标 | — | **P0** | 0.5 天 | ✅ 完成 |
| T3 | 推理链查询接口 | 请求 ID 化 + 内存轨迹存储 + `GET /traces/{id}` | T1 | **P0** | 1 天 | ✅ 完成 |
| T12 | **Agent-Skill 路由器** | 路由器独立:Skill 注册表(21 技能)+ RouteAgent 自主分析需求→技能调用计划→执行,无需用户指定领域/模型 | T1,T2,T3 | **P0** | 1-2 天 | ✅ 完成 |
| T4 | 知识库 CRUD + 热重载 | `/knowledge/rules|facts` CRUD + `POST /config/reload` | T1 | P1 | 1-2 天 | ⬜ 待办 |
| T5 | 本地模型推理接入(L2 | Ollama/vLLM 封装 + `fallback.local` 验证 + 组内模型按需加载 | T1 | P1 | 2-3 天 | ⬜ 待办 |
| T6 | embedding 语义缓存 | BGE 本地向量化替代 n-gram L2 | — | P1 | 1-2 天 | ⬜ 待办 |
| T7 | 分类器训练流水线 | 数据构建 + 0.6B QLoRA 训练(8 领域) | — | P1 | 2-3 天 | ⬜ 待办 |
| T8 | 领域专家微调 | QLoRA 微调 5-8 领域专家 + 数据收集 | T7 | P2 | 3-4 周 | ⬜ 待办 |
| T9 | RouterArena 评测 | 标准 5 维评测接入 | T2 | P2 | 2-3 天 | ⬜ 待办 |
| T10 | 监控 + 模型注册表 | Prometheus 指标 + 模型版本/热替换 | T4,T5 | P2 | 2-3 天 | ⬜ 待办 |
| T11 | 部署生产化 | Docker/Compose + 灰度 + 安全 | T5,T10 | P2 | 3-5 天 | ⬜ 待办 |
## 三、先行实现批次(P0)—— 已完成 ✅
1. **T1 NodeExecutor 接口抽象** —— 子任务执行后端统一接口(rule/model 两实现 + 工厂),L2 模型接入无需改 Router
2. **T2 评测基准扩充** —— 24 样例 × 8 领域,指标:分类 100% / 大领域组识别 100% / 子领域识别 100%
3. **T3 推理链查询接口** —— `GET /traces/{request_id}`:完整推理链可追溯(两级路由 → 三级子领域 → 拆解 → 规则 → 评分)
4. **T12 Agent-Skill 路由器** —— 路由器独立为"技能注册表 + Agent 规划器"
- 21 个内置技能(es.* 模板 ×17、kb.retrieve/kb.answer、judge.evaluate、fallback.call
- RouteAgent 自行分析需求 → 规划技能调用(多技能组合/依赖)→ 执行 → 校验 → 升级
- **用户只提供 query,无需指定领域/模型**;技能调用轨迹可追溯(skill:es.analyze@facts
- 实测:法律咨询自动组合 es.analyze + kb.retrieve + es.conclude + es.disclaimer
P0 完成后的能力:干净的后端抽象 + 可量化的评测 + 可追溯的推理链 + 技能化 Agent 路由(118 项测试全绿)。
## 四、执行规则
- 每任务独立验收(测试 + 文档),完成后更新状态 ✅
- 依赖任务未完成时,先做无依赖任务
- P1/P2 任务在 P0 完成后按序推进,不阻塞主线
---
## 五、v2 任务登记(端云协同 LLM 协作系统,见《实现方案_v2_端云协同LLM协作系统.md》)
> 每个任务一个 commit`feat(v2): Tn 描述`),交付含封闭单测;v1 的 126 项测试保持全绿。
| T | 内容 | 状态 | commit |
|---|------|------|--------|
| T1 | 环境与基线确认(126 测试全绿;README 环境备忘) | ✅ 完成 | (并入 T2 commit |
| T2 | 运维层:hw_profile + llama_server 进程管理 | ✅ 完成 | T2 |
| T3 | ArchitectClientDeepSeek APIJSON 约束) | ⬜ | |
| T4 | Workspace(交流文本 schema/校验/渲染/rollup | ⬜ | |
| T5 | WorkerLoop + 接地验证 | ⬜ | |
| T6 | CollaborativePipeline 编排 | ⬜ | |
| T7 | 网关扩展(/chat 切 v2/chat/legacy | ⬜ | |
| T8 | 人工检验队列 ReviewQueue | ⬜ | |
| T9 | token 计量与账单 | ⬜ | |
| T10 | rollup + prefix cache 调优 | ⬜ | |
| T11 | 打包分发 setup_runtime.py | ⬜ | |
| T12 | 实验脚本 bench_tokens.py + 数据集 | ⬜ | |
| T13 | E1E5 跑数到 research/v2_experiments/ | ⬜ | |
| T14 | 文档收口(README v2 改写) | ⬜ | |
---
## 五、v2 任务登记(端云协同 LLM 协作系统,见《实现方案_v2_端云协同LLM协作系统.md》)
> 每个任务一个 commit`feat(v2): Tn 描述`),交付含封闭单测;v1 的 126 项测试保持全绿。
| T | 内容 | 状态 | commit |
|---|------|------|--------|
| T1 | 环境与基线确认(126 测试全绿;README 环境备忘) | ✅ 完成 | (并入 T2 commit |
| T2 | 运维层:hw_profile + llama_server 进程管理 | ✅ 完成 | T2 |
| T3 | ArchitectClientDeepSeek APIJSON 约束) | ⬜ | |
| T4 | Workspace(交流文本 schema/校验/渲染/rollup | ⬜ | |
| T5 | WorkerLoop + 接地验证 | ⬜ | |
| T6 | CollaborativePipeline 编排 | ⬜ | |
| T7 | 网关扩展(/chat 切 v2/chat/legacy | ⬜ | |
| T8 | 人工检验队列 ReviewQueue | ⬜ | |
| T9 | token 计量与账单 | ⬜ | |
| T10 | rollup + prefix cache 调优 | ⬜ | |
| T11 | 打包分发 setup_runtime.py | ⬜ | |
| T12 | 实验脚本 bench_tokens.py + 数据集 | ⬜ | |
| T13 | E1E5 跑数到 research/v2_experiments/ | ⬜ | |
| T14 | 文档收口(README v2 改写) | ⬜ | |