docs: 联网搜索服务架构方案全套(plan-final/design-arch/选型决策/整合导览/MCP文档/部署预设/联调手册) bench: 5 方案 + 代理 + 站点矩阵本机实测工程(无密钥) 部署目标:primary mgr1 先行测试(待批准后执行)
288 lines
9.8 KiB
Python
Executable file
288 lines
9.8 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Read raw metrics/stats → results.json (plan.md §5 schema + extras)."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
import statistics
|
|
import sys
|
|
from datetime import datetime, timezone, timedelta
|
|
from pathlib import Path
|
|
|
|
TZ = timezone(timedelta(hours=8))
|
|
ENGINES = ("baidu", "sogou", "360search", "bing", "wikipedia")
|
|
|
|
|
|
def percentile(vals, p: float):
|
|
if not vals:
|
|
return None
|
|
xs = sorted(vals)
|
|
if len(xs) == 1:
|
|
return xs[0]
|
|
k = (len(xs) - 1) * (p / 100.0)
|
|
f = math.floor(k)
|
|
c = math.ceil(k)
|
|
if f == c:
|
|
return xs[int(k)]
|
|
return xs[f] * (c - k) + xs[c] * (k - f)
|
|
|
|
|
|
def parse_mem_mib(usage: str) -> float | None:
|
|
if not usage:
|
|
return None
|
|
left = usage.split("/", 1)[0].strip()
|
|
m = re.match(r"([0-9.]+)\s*([KMGT]i?B)", left, re.I)
|
|
if not m:
|
|
return None
|
|
n = float(m.group(1))
|
|
unit = m.group(2).lower()
|
|
mult = {"b": 1 / 1048576, "kib": 1 / 1024, "mib": 1, "gib": 1024, "kb": 1 / 1000, "mb": 1, "gb": 1000}
|
|
return n * mult.get(unit, 1.0)
|
|
|
|
|
|
def load_stats(path: Path) -> list[dict]:
|
|
rows = []
|
|
if not path.exists():
|
|
return rows
|
|
for i, line in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines()):
|
|
if i == 0 or not line.strip():
|
|
continue
|
|
parts = line.split("\t")
|
|
if len(parts) < 3:
|
|
continue
|
|
mem = parse_mem_mib(parts[2])
|
|
cgroup = None
|
|
if len(parts) >= 5 and parts[4] not in ("", "NA"):
|
|
try:
|
|
cgroup = int(parts[4]) / 1048576.0
|
|
except ValueError:
|
|
cgroup = None
|
|
rows.append({"ts": parts[0], "name": parts[1], "mem_mib": mem, "cgroup_mib": cgroup})
|
|
return rows
|
|
|
|
|
|
def mem_series(rows: list[dict], prefer_cgroup: bool) -> list[float]:
|
|
out = []
|
|
for r in rows:
|
|
v = r["cgroup_mib"] if prefer_cgroup and r["cgroup_mib"] is not None else r["mem_mib"]
|
|
if v is not None:
|
|
out.append(v)
|
|
return out
|
|
|
|
|
|
def load_jsonl(path: Path) -> list[dict]:
|
|
rows = []
|
|
if not path.exists():
|
|
return rows
|
|
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
rows.append(json.loads(line))
|
|
return rows
|
|
|
|
|
|
def bucket(v, cuts):
|
|
# cuts: list of (threshold, score) descending
|
|
for thr, score in cuts:
|
|
if v <= thr:
|
|
return score
|
|
return 0
|
|
|
|
|
|
def main() -> None:
|
|
root = Path(sys.argv[1] if len(sys.argv) > 1 else ".")
|
|
raw = root / "raw"
|
|
extra = {}
|
|
extra_path = raw / "meta.json"
|
|
if extra_path.exists():
|
|
extra = json.loads(extra_path.read_text(encoding="utf-8"))
|
|
|
|
idle_rows = load_stats(raw / "searxng-cn-idle.tsv")
|
|
sess_rows = load_stats(raw / "searxng-cn-session.tsv")
|
|
burst_rows = load_stats(raw / "searxng-cn-t5.tsv")
|
|
combo_rows = load_stats(raw / "p0-combo.tsv")
|
|
cgroup_n = sum(1 for r in idle_rows if r["cgroup_mib"] is not None)
|
|
prefer = cgroup_n > 0
|
|
rss_source = "cgroup" if prefer else "docker_stats"
|
|
|
|
idle_vals = mem_series(idle_rows, prefer)
|
|
sess_vals = mem_series(sess_rows, prefer)
|
|
burst_vals = mem_series(burst_rows, prefer)
|
|
idle_p50 = percentile(idle_vals, 50) if idle_vals else None
|
|
sess_peak = max(sess_vals) if sess_vals else None
|
|
burst_peak = max(burst_vals) if burst_vals else None
|
|
session_delta = None
|
|
if sess_peak is not None and idle_p50 is not None:
|
|
session_delta = max(0.0, sess_peak - idle_p50)
|
|
|
|
t1 = load_jsonl(raw / "t1.jsonl")
|
|
t5 = load_jsonl(raw / "t5.jsonl")
|
|
t1_ok = [r for r in t1 if r.get("assert_ok")]
|
|
t5_ok = [r for r in t5 if r.get("assert_ok")]
|
|
t1_tot = [r.get("t_total") for r in t1 if r.get("t_total") is not None]
|
|
t5_tot = [r.get("t_total") for r in t5 if r.get("t_total") is not None]
|
|
t1_ttfb = [r.get("ttfb") for r in t5 if r.get("ttfb") is not None]
|
|
|
|
engine_matrix = extra.get("engine_matrix") or {}
|
|
# fill from t1/t5 verdicts if present
|
|
if not engine_matrix:
|
|
matrix = {e: {"results": 0, "unresponsive": 0, "runs_with_results": 0} for e in ENGINES}
|
|
for r in t1 + t5:
|
|
counts = r.get("engine_counts") or {}
|
|
unresp = r.get("unresponsive_engines") or []
|
|
names = set()
|
|
for u in unresp:
|
|
if isinstance(u, (list, tuple)) and u:
|
|
names.add(str(u[0]))
|
|
elif isinstance(u, str):
|
|
names.add(u)
|
|
for e in ENGINES:
|
|
n = int(counts.get(e) or 0)
|
|
matrix[e]["results"] += n
|
|
if n > 0:
|
|
matrix[e]["runs_with_results"] += 1
|
|
if e in names:
|
|
matrix[e]["unresponsive"] += 1
|
|
engine_matrix = matrix
|
|
|
|
t1_p50 = percentile(t1_tot, 50)
|
|
t1_p95 = percentile(t1_tot, 95)
|
|
t5_p50 = percentile(t5_tot, 50)
|
|
t5_p95 = percentile(t5_tot, 95)
|
|
|
|
http_codes_t1 = [r.get("http_code") for r in t1]
|
|
t5_429 = sum(1 for r in t5 if r.get("http_code") == 429)
|
|
t5_503 = sum(1 for r in t5 if r.get("http_code") == 503)
|
|
|
|
# scorecard
|
|
idle_score = bucket(idle_p50 if idle_p50 is not None else 9999, [(60, 100), (100, 80), (200, 60), (400, 40)])
|
|
sess_score = bucket(session_delta if session_delta is not None else 9999, [(30, 100), (80, 80), (200, 60), (350, 40)])
|
|
burst_score = 0 if extra.get("oom") else bucket(burst_peak if burst_peak is not None else 9999, [(200, 100), (400, 80), (700, 60), (1000, 40)])
|
|
mem_score = round((idle_score + sess_score + burst_score) / 3, 1)
|
|
|
|
n_t1 = len(t1)
|
|
n_t5 = len(t5)
|
|
succ_t1 = len(t1_ok)
|
|
succ_t5 = len(t5_ok)
|
|
all_n = n_t1 + n_t5
|
|
all_ok = succ_t1 + succ_t5
|
|
success_pct = (100.0 * all_ok / all_n) if all_n else 0.0
|
|
if extra.get("container_exited"):
|
|
stab = 0
|
|
elif success_pct >= 99:
|
|
stab = 100
|
|
elif success_pct >= 95:
|
|
stab = 80
|
|
elif success_pct >= 85:
|
|
stab = 60
|
|
elif success_pct >= 70:
|
|
stab = 40
|
|
else:
|
|
stab = 0
|
|
|
|
# quality from T1 median-ish first successful
|
|
quality = 0
|
|
if t1:
|
|
best = max(t1, key=lambda r: (bool(r.get("assert_ok")), r.get("n_results") or 0, len(r.get("engines") or [])))
|
|
neng = len(best.get("engines") or [])
|
|
nres = best.get("n_results") or 0
|
|
if nres >= 5 and neng >= 2 and (best.get("n_rich") or 0) >= 5:
|
|
quality = 100
|
|
elif nres >= 5 and neng >= 1:
|
|
quality = 70
|
|
elif nres > 0:
|
|
quality = 40
|
|
else:
|
|
quality = 0
|
|
|
|
lat = 0
|
|
if t1_p95 is not None:
|
|
lat_t1 = bucket(t1_p95, [(2.0, 100), (5.0, 70), (12.0, 40)])
|
|
if t5_p95 is not None and t1_p95 > 0:
|
|
ratio = t5_p95 / t1_p95
|
|
lat_t5 = bucket(ratio, [(3.0, 100), (5.0, 70), (8.0, 40)])
|
|
else:
|
|
lat_t5 = 0
|
|
lat = round((lat_t1 + lat_t5) / 2, 1)
|
|
|
|
ops = 80
|
|
total = round(
|
|
mem_score * 0.30 + quality * 0.25 + stab * 0.20 + lat * 0.15 + ops * 0.10,
|
|
1,
|
|
)
|
|
|
|
probed = datetime.now(TZ).isoformat(timespec="seconds")
|
|
results = {
|
|
"scheme": "searxng-cn",
|
|
"group": "domestic",
|
|
"image": extra.get("image")
|
|
or {"ref": "docker.io/searxng/searxng:2026.8.29-d226b78bc", "digest": ""},
|
|
"probed_at": probed,
|
|
"rss_source": rss_source,
|
|
"idle_mb_p50": round(idle_p50, 2) if idle_p50 is not None else None,
|
|
"session_delta_mb": round(session_delta, 2) if session_delta is not None else None,
|
|
"burst_peak_mb": round(burst_peak, 2) if burst_peak is not None else None,
|
|
"session_peak_mb": round(sess_peak, 2) if sess_peak is not None else None,
|
|
"templates": {
|
|
"T1": {
|
|
"n": n_t1,
|
|
"success": succ_t1,
|
|
"http_codes": http_codes_t1,
|
|
"p50_s": round(t1_p50, 4) if t1_p50 is not None else None,
|
|
"p95_s": round(t1_p95, 4) if t1_p95 is not None else None,
|
|
"assert_ok": succ_t1 > 0 and succ_t1 == n_t1,
|
|
"note": extra.get("t1_note") or "",
|
|
"engine_matrix": engine_matrix,
|
|
},
|
|
"T5": {
|
|
"n": n_t5,
|
|
"success": succ_t5,
|
|
"p50_s": round(t5_p50, 4) if t5_p50 is not None else None,
|
|
"p95_s": round(t5_p95, 4) if t5_p95 is not None else None,
|
|
"assert_ok": (succ_t5 / n_t5 >= 0.85) if n_t5 else False,
|
|
"note": extra.get("t5_note") or "",
|
|
},
|
|
},
|
|
"t5": {
|
|
"n": n_t5,
|
|
"parallelism": 60,
|
|
"queue_impl": "none",
|
|
"success": succ_t5,
|
|
"p50_s": round(t5_p50, 4) if t5_p50 is not None else None,
|
|
"p95_s": round(t5_p95, 4) if t5_p95 is not None else None,
|
|
"oom": bool(extra.get("oom")),
|
|
"http_429": t5_429,
|
|
"http_503": t5_503,
|
|
"first_ttfb_s": round(min(t1_ttfb), 4) if t1_ttfb else None,
|
|
"last_t_total_s": round(max(t5_tot), 4) if t5_tot else None,
|
|
},
|
|
"t4_waf": [],
|
|
"scorecard": {
|
|
"mem": mem_score,
|
|
"quality": quality,
|
|
"stability": stab,
|
|
"latency": lat,
|
|
"ops": ops,
|
|
"total": total,
|
|
"mem_idle": idle_score,
|
|
"mem_session": sess_score,
|
|
"mem_burst": burst_score,
|
|
"success_pct": round(success_pct, 2),
|
|
},
|
|
"engine_matrix": engine_matrix,
|
|
"t2_pick": extra.get("t2_pick"),
|
|
"pull": extra.get("pull"),
|
|
"commands": extra.get("commands"),
|
|
"combo_rss_mb": extra.get("combo_rss_mb"),
|
|
"notes": extra.get("notes") or [],
|
|
}
|
|
outp = root / "results.json"
|
|
outp.write_text(json.dumps(results, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
print(f"wrote {outp}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|