perf(admin): reduce tool health polling load

This commit is contained in:
caoqianming 2026-09-03 09:32:10 +08:00
parent 2ff648afe6
commit dbaa9faa6e
7 changed files with 128 additions and 19 deletions

View File

@ -8,6 +8,8 @@
## Unreleased ## Unreleased
- 管理后台会异步汇总工具健康大数,并避免短时间内重复请求;多人同时查看或自动刷新时不再反复扫描历史记录。
- 单用户可同时运行的重型任务由 2 个提升到 3 个;任务等待执行容量时,对话会直接说明是当前用户、整机或宿主内存限制,获得槽位后自动继续。 - 单用户可同时运行的重型任务由 2 个提升到 3 个;任务等待执行容量时,对话会直接说明是当前用户、整机或宿主内存限制,获得槽位后自动继续。
- 管理员现在可在管理后台安全录入、测试和更换模型、媒体、检索及语音服务凭据并查看来源、脱敏尾号和可用状态数据库凭据可随时删除并回退原有环境配置。DeepSeek 余额低于 30 元、额度耗尽或认证失败时会主动提醒。 - 管理员现在可在管理后台安全录入、测试和更换模型、媒体、检索及语音服务凭据并查看来源、脱敏尾号和可用状态数据库凭据可随时删除并回退原有环境配置。DeepSeek 余额低于 30 元、额度耗尽或认证失败时会主动提醒。

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9` > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-09-03Unreleased管理后台紧凑总览与分组工作区 最后更新:2026-09-03Unreleased工具健康按需刷新与短时缓存
--- ---
@ -20,6 +20,8 @@
--- ---
## 已完成关键能力 ## 已完成关键能力
- **09-03 / Unreleased / 工具健康监控请求收敛**Admin 首次进入时异步汇总工具健康大数,之后固定按 90 秒节流刷新并对进行中请求单飞,不再每 10 秒重复扫描近 7 天记录。服务端按查询参数增加 90 秒进程内单飞缓存多页面或多管理员并发时共享一次扫描日巡检继续直读不受缓存影响。未新增表、字段、migration 或外部配置,结构化普通工具失败事件因双写会与 messages 历史口径重复而留待独立迁移。
- **09-03 / Unreleased / 管理后台紧凑总览与分组工作区**Admin 由纵向长页改为顶部常驻的两级总览和“运行、用量、用户、资源、集成、质量”六个页签四项运行状态使用主卡、四项运营指标收进紧凑指标栏点击总览可切换页签并精确定位同口径详情任务、容量、用户活跃、Token/成本和存储均补齐详情指标。页签 hash 支持刷新与前进后退桌面端页签吸顶移动端顶栏操作收进菜单、运营指标与页签可横向滑动1440px/390px 浏览器渲染、六页签与深链交互检查通过,无 API、schema、migration 或运行方式变化。 - **09-03 / Unreleased / 管理后台紧凑总览与分组工作区**Admin 由纵向长页改为顶部常驻的两级总览和“运行、用量、用户、资源、集成、质量”六个页签四项运行状态使用主卡、四项运营指标收进紧凑指标栏点击总览可切换页签并精确定位同口径详情任务、容量、用户活跃、Token/成本和存储均补齐详情指标。页签 hash 支持刷新与前进后退桌面端页签吸顶移动端顶栏操作收进菜单、运营指标与页签可横向滑动1440px/390px 浏览器渲染、六页签与深链交互检查通过,无 API、schema、migration 或运行方式变化。
- **09-02 / Unreleased / 单用户重型执行容量与排队提示**:共享执行容量保持整机 6、后台 4单用户上限由 2 提升到 3前台重型任务首次等待时通过 SSE 告知单用户、整机、内存压力或队列顺序原因,放行后恢复正常执行提示,排队仍不计入命令 timeout 且可取消。无 schema、migration 或 API 变化。 - **09-02 / Unreleased / 单用户重型执行容量与排队提示**:共享执行容量保持整机 6、后台 4单用户上限由 2 提升到 3前台重型任务首次等待时通过 SSE 告知单用户、整机、内存压力或队列顺序原因,放行后恢复正常执行提示,排队仍不计入命令 timeout 且可取消。无 schema、migration 或 API 变化。

View File

@ -34,6 +34,8 @@ from __future__ import annotations
import json import json
import math import math
import re import re
import threading
import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
@ -47,6 +49,21 @@ from core.storage.telemetry import (
KIND_TOOL_SALVAGED, KIND_TOOL_SALVAGED,
) )
_CACHE_TTL_SECONDS = 90.0
_failure_cache_lock = threading.Lock()
_wire_cache_lock = threading.Lock()
_failure_cache: dict[tuple[float, int, int], tuple[float, List[Dict[str, Any]]]] = {}
_wire_cache: dict[int, tuple[float, Dict[str, Any]]] = {}
def _clear_tool_health_cache() -> None:
"""测试/运维进程内失效入口;不触碰数据库。"""
with _failure_cache_lock:
_failure_cache.clear()
with _wire_cache_lock:
_wire_cache.clear()
# 签名归一:同一类错误在不同 task/参数下的差异(路径/数字/uuid/十六进制)抹平, # 签名归一:同一类错误在不同 task/参数下的差异(路径/数字/uuid/十六进制)抹平,
# 让 "figures/a.png doesn't exist" 和 "figures/b.png doesn't exist" 聚成一条。 # 让 "figures/a.png doesn't exist" 和 "figures/b.png doesn't exist" 聚成一条。
_RE_UUID = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") _RE_UUID = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
@ -321,6 +338,30 @@ def scan_tool_failures(
return out return out
def scan_tool_failures_cached(
days: float = 7,
min_count: int = 5,
min_tasks: int = 2,
) -> List[Dict[str, Any]]:
"""管理页短时缓存;锁覆盖扫描,使同参数并发请求只扫库一次。"""
key = (float(days), int(min_count), int(min_tasks))
now = time.monotonic()
with _failure_cache_lock:
cached = _failure_cache.get(key)
if cached and cached[0] > now:
return cached[1]
for stale_key, (expires_at, _value) in list(_failure_cache.items()):
if expires_at <= now:
_failure_cache.pop(stale_key, None)
value = scan_tool_failures(
days=days,
min_count=min_count,
min_tasks=min_tasks,
)
_failure_cache[key] = (time.monotonic() + _CACHE_TTL_SECONDS, value)
return value
def scan_tool_wire_health(days: int = 7) -> Dict[str, Any]: def scan_tool_wire_health(days: int = 7) -> Dict[str, Any]:
"""聚合 provider 工具参数损坏的抢救/残余比例;纯只读、无派生状态。 """聚合 provider 工具参数损坏的抢救/残余比例;纯只读、无派生状态。
@ -407,6 +448,22 @@ def scan_tool_wire_health(days: int = 7) -> Dict[str, Any]:
return {"days": days, "rows": out, "total": totals} return {"days": days, "rows": out, "total": totals}
def scan_tool_wire_health_cached(days: int = 7) -> Dict[str, Any]:
"""管理页链路健康短时缓存;同窗口并发请求共享一次聚合。"""
key = min(90, max(1, int(days)))
now = time.monotonic()
with _wire_cache_lock:
cached = _wire_cache.get(key)
if cached and cached[0] > now:
return cached[1]
for stale_key, (expires_at, _value) in list(_wire_cache.items()):
if expires_at <= now:
_wire_cache.pop(stale_key, None)
value = scan_tool_wire_health(days=key)
_wire_cache[key] = (time.monotonic() + _CACHE_TTL_SECONDS, value)
return value
# ── provider 级致命错误即时告警 ── # ── provider 级致命错误即时告警 ──
# 命中判据:错误文案含余额/配额/认证类关键词 —— 这类错误不是单任务偶发,而是该 # 命中判据:错误文案含余额/配额/认证类关键词 —— 这类错误不是单任务偶发,而是该
# provider 上所有 run 全挂(如 Zai 余额不足),等日巡检的 5 次/2 task 阈值太慢。 # provider 上所有 run 全挂(如 Zai 余额不足),等日巡检的 5 次/2 task 阈值太慢。

View File

@ -145,6 +145,10 @@ class StaticVendorTests(unittest.TestCase):
self.assertIn('data-summary-tab="${escapeHtml(tab)}"', admin_js) self.assertIn('data-summary-tab="${escapeHtml(tab)}"', admin_js)
self.assertIn("function renderCapacityDrawer()", admin_js) self.assertIn("function renderCapacityDrawer()", admin_js)
self.assertIn("function openCapacityDrawer()", admin_js) self.assertIn("function openCapacityDrawer()", admin_js)
self.assertIn("const TOOL_HEALTH_REFRESH_MS = 90000", admin_js)
self.assertIn("if (toolFailuresRequest) return toolFailuresRequest", admin_js)
self.assertIn("loadToolFailures();", admin_js)
self.assertNotIn('activeTab === "quality"', admin_js)
self.assertIn('id="capacity-drawer" class="capacity-drawer"', html) self.assertIn('id="capacity-drawer" class="capacity-drawer"', html)
self.assertIn(".capacity-drawer-panel {", html) self.assertIn(".capacity-drawer-panel {", html)

View File

@ -226,6 +226,34 @@ class TestToolWireHealth(unittest.TestCase):
self.assertIsNone(out["total"]["recovery_rate_24h"]) self.assertIsNone(out["total"]["recovery_rate_24h"])
class TestToolHealthCache(unittest.TestCase):
def setUp(self):
tf._clear_tool_health_cache()
def tearDown(self):
tf._clear_tool_health_cache()
def test_failure_scan_is_reused_within_ttl(self):
expected = [{"tool": "shell"}]
with patch.object(tf, "scan_tool_failures", return_value=expected) as scan:
first = tf.scan_tool_failures_cached(days=7, min_count=3, min_tasks=1)
second = tf.scan_tool_failures_cached(days=7, min_count=3, min_tasks=1)
self.assertIs(first, expected)
self.assertIs(second, expected)
scan.assert_called_once_with(days=7, min_count=3, min_tasks=1)
def test_wire_scan_normalizes_window_before_caching(self):
expected = {"days": 90, "rows": [], "total": {}}
with patch.object(tf, "scan_tool_wire_health", return_value=expected) as scan:
first = tf.scan_tool_wire_health_cached(days=365)
second = tf.scan_tool_wire_health_cached(days=90)
self.assertIs(first, expected)
self.assertIs(second, expected)
scan.assert_called_once_with(days=90)
class TestProviderCriticalAlert(unittest.TestCase): class TestProviderCriticalAlert(unittest.TestCase):
def setUp(self): def setUp(self):
tf._alerted_at.clear() tf._alerted_at.clear()

View File

@ -533,11 +533,11 @@ def register_admin_routes(app: FastAPI, require_admin) -> None:
): ):
"""工具失败聚集(core/toolfail 同款扫描,页面用低阈值看全量; """工具失败聚集(core/toolfail 同款扫描,页面用低阈值看全量;
巡检推送走 5 /2 task 的高阈值)admin-only""" 巡检推送走 5 /2 task 的高阈值)admin-only"""
from core.toolfail import scan_tool_failures from core.toolfail import scan_tool_failures_cached
days = min(90, max(1, days)) days = min(90, max(1, days))
return { return {
"days": days, "days": days,
"clusters": scan_tool_failures( "clusters": scan_tool_failures_cached(
days=days, min_count=max(1, min_count), min_tasks=max(1, min_tasks) days=days, min_count=max(1, min_count), min_tasks=max(1, min_tasks)
), ),
} }
@ -547,8 +547,8 @@ def register_admin_routes(app: FastAPI, require_admin) -> None:
days: int = 7, user_id: UUID = Depends(require_admin), days: int = 7, user_id: UUID = Depends(require_admin),
): ):
"""工具参数 wire 损坏的抢救率,按模型档+工具聚合admin-only、纯只读。""" """工具参数 wire 损坏的抢救率,按模型档+工具聚合admin-only、纯只读。"""
from core.toolfail import scan_tool_wire_health from core.toolfail import scan_tool_wire_health_cached
return scan_tool_wire_health(days=days) return scan_tool_wire_health_cached(days=days)
@app.get("/v1/admin/tiers", tags=["admin"]) @app.get("/v1/admin/tiers", tags=["admin"])
def admin_tiers(user_id: UUID = Depends(require_admin)): def admin_tiers(user_id: UUID = Depends(require_admin)):

View File

@ -8,6 +8,9 @@ import { dialogConfirm, dialogPrompt, message } from "./dialog.js";
const LS_TOKEN = "zcbot.token"; const LS_TOKEN = "zcbot.token";
const REFRESH_MS = 10000; const REFRESH_MS = 10000;
// 工具健康需要聚合近 7 天日志:进入 Admin 立即异步读取,之后最多 90s 刷新一次;
// 后端另有同周期缓存,多个管理员也不会重复扫库。
const TOOL_HEALTH_REFRESH_MS = 90000;
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
const RANGE_OPTS = [["all", "全部"], ["7d", "近7天"], ["30d", "近30天"]]; const RANGE_OPTS = [["all", "全部"], ["7d", "近7天"], ["30d", "近30天"]];
const SORT_OPTS = [["cost", "按成本"], ["tokens", "按用量"]]; const SORT_OPTS = [["cost", "按成本"], ["tokens", "按用量"]];
@ -65,6 +68,8 @@ let sandboxCapacityData = null;
let storageSummaryData = null; let storageSummaryData = null;
let toolFailuresData = null; let toolFailuresData = null;
let toolWireData = null; let toolWireData = null;
let toolFailuresRequest = null;
let toolFailuresLastAttemptAt = 0;
let capacityDrawerTrigger = null; let capacityDrawerTrigger = null;
// ───── 格式化 ───── // ───── 格式化 ─────
@ -1480,7 +1485,12 @@ async function loadStorageSummary() {
} catch (e) { /* 保留最近一次成功的摘要 */ } } catch (e) { /* 保留最近一次成功的摘要 */ }
} }
async function loadToolFailures() { async function loadToolFailures(force = false) {
const now = Date.now();
if (toolFailuresRequest) return toolFailuresRequest;
if (!force && now - toolFailuresLastAttemptAt < TOOL_HEALTH_REFRESH_MS) return;
toolFailuresLastAttemptAt = now;
toolFailuresRequest = (async () => {
try { try {
const [failures, wire] = await Promise.allSettled([ const [failures, wire] = await Promise.allSettled([
apiGet("/v1/admin/tool-failures?days=7&min_count=3&min_tasks=1"), apiGet("/v1/admin/tool-failures?days=7&min_count=3&min_tasks=1"),
@ -1495,6 +1505,12 @@ async function loadToolFailures() {
); );
renderOpsSummary(); renderOpsSummary();
} catch (e) { /* 同上 */ } } catch (e) { /* 同上 */ }
})();
try {
await toolFailuresRequest;
} finally {
toolFailuresRequest = null;
}
} }
async function loadExternalDefinitions(force = false) { async function loadExternalDefinitions(force = false) {