feat(ops): toolfail 聚集加时间分布,活跃/已安静一眼分(bump 0.58.14)

修掉的问题旧记录挂满 7 天窗口,和还在发生的没视觉区别。聚合加
daily(24h 分桶)/count_24h/first_at,活跃在前安静沉底;admin 表
加近24h+趋势列,安静行淡化;告警邮件带近24h。API 只加字段。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-07-13 09:27:40 +08:00
parent 073d9ef025
commit dadcc8e7e7
5 changed files with 51 additions and 13 deletions

View File

@ -23,6 +23,7 @@
### 2026-07 ### 2026-07
- **07-13 / 0.58.14**:**toolfail 聚集加时间分布,活跃/已安静一眼分**(0.58.11 的可观测性补强):此前聚集只有全窗口累计 count+last_at——修掉的问题旧记录仍挂满 7 天窗口,和"还在发生"的没视觉区别,只能人肉盯"最近时间"是否前进。`core/toolfail.py` 聚合时加 `daily`(从 now 往回按 24h 分桶,旧→新,非日历日)/ `count_24h`(=尾桶)/ `first_at`,排序改"近 24h 活跃在前(count_24h 降序),安静的按 count 降序沉底";兼补 naive timestamp 的 tzinfo 兜底。admin 页表格加"近24h"(>0 红)和"趋势"列(unicode block mini 条形图,悬浮看逐桶数值),近24h=0 的行整体淡化;告警邮件每条带"近24h N 次/已安静"。API 只加字段不删,向后兼容。冒烟:伪造行 patch session_scope 全绿(分桶/排序/naive ts/邮件格式)。修复部署后次日看板:对应行变灰+趋势尾格归零 = 修好;仍红 = 没修好。
- **07-13 / 0.58.13**:**look_at_image 引导口径改"慢、谨慎调"**:tool description 原写 "Cheap (usually < ¥0.01)"、system prompt 媒体段写"每次很便宜"——都在鼓励随手调,而实际痛点是慢(一次几十秒)。两处(`tools/look_at_image.py` description + `core/agent_builder.py` `_MEDIA_LOOK_SEG`)改为强调 SLOW / 谨慎调用 / 只在确实需要图内容时调 / 想问的一次在 `question` 里问全、绝不对同一张图反复看;"何时不调"加"图内容对当前任务可有可无"。纯 prompt 调整,无行为代码变化。 - **07-13 / 0.58.13**:**look_at_image 引导口径改"慢、谨慎调"**:tool description 原写 "Cheap (usually < ¥0.01)"、system prompt 媒体段写"每次很便宜"——都在鼓励随手调,而实际痛点是慢(一次几十秒)。两处(`tools/look_at_image.py` description + `core/agent_builder.py` `_MEDIA_LOOK_SEG`)改为强调 SLOW / 谨慎调用 / 只在确实需要图内容时调 / 想问的一次在 `question` 里问全、绝不对同一张图反复看;"何时不调"加"图内容对当前任务可有可无"。纯 prompt 调整,无行为代码变化。
- **07-13 / 0.58.12**:**沙箱镜像补 `file` 命令**(toolfail 巡检首批产出):7 天 13 任务 9 用户撞 `bash: file: command not found`——模型爱用 `file` 验产物真实类型,slim 基底不带。Dockerfile 加独立小 apt 层(pip/chromium 大层之后,不打穿 cache)。同批评估过 olefile/cairosvg **决定不装**:量小(各 2-3 任务)、模型容器内 pip install 可自愈(样例实证),且 cairosvg 进共享 requirements 会坑 Windows host(cairo DLL);待巡检趋势说话。 - **07-13 / 0.58.12**:**沙箱镜像补 `file` 命令**(toolfail 巡检首批产出):7 天 13 任务 9 用户撞 `bash: file: command not found`——模型爱用 `file` 验产物真实类型,slim 基底不带。Dockerfile 加独立小 apt 层(pip/chromium 大层之后,不打穿 cache)。同批评估过 olefile/cairosvg **决定不装**:量小(各 2-3 任务)、模型容器内 pip install 可自愈(样例实证),且 cairosvg 进共享 requirements 会坑 Windows host(cairo DLL);待巡检趋势说话。
- **07-13 / 0.58.11**:**工具失败聚集巡检**(反 9dcae061 "挂 90 天无人知"的结构性缺口):新模块 `core/toolfail.py`——扫近 7 天 messages 里 role=tool 的错误结果(`[Error` 开头 / `command timed out` / 尾部 `[exit 非0]`),按 (工具名+归一化签名:路径/数字/uuid 抹平) 聚合,同签名 ≥5 次且跨 ≥2 task 判聚集(单 task 内试错自愈不触发)。三个出口:① web/app.py `_toolfail_scanner` 周期任务(仿 _disk_scanner,每天跑,签名进程内去重)→ 发 `ZCBOT_DEVELOPER_EMAIL`(复用 SMTP_*,未配则日志);② `GET /v1/admin/tool-failures`;③ admin 页新"工具失败"表(低阈值 3 次/1 task 看全量,悬浮看样例)。生产 DB 冒烟:一次扫出 21 类真聚集(沙箱缺 `file` 命令 x13 跨 13 task、web_fetch Network unreachable x7、mmdc 超时批次等)。env:`ZCBOT_DEVELOPER_EMAIL` / `ZCBOT_TOOLFAIL_SCAN_INTERVAL`(RUN.md 环境段已记)。无新表无 migration;重启后同签名重推一次(聚集还在=问题还在,可接受)。 - **07-13 / 0.58.11**:**工具失败聚集巡检**(反 9dcae061 "挂 90 天无人知"的结构性缺口):新模块 `core/toolfail.py`——扫近 7 天 messages 里 role=tool 的错误结果(`[Error` 开头 / `command timed out` / 尾部 `[exit 非0]`),按 (工具名+归一化签名:路径/数字/uuid 抹平) 聚合,同签名 ≥5 次且跨 ≥2 task 判聚集(单 task 内试错自愈不触发)。三个出口:① web/app.py `_toolfail_scanner` 周期任务(仿 _disk_scanner,每天跑,签名进程内去重)→ 发 `ZCBOT_DEVELOPER_EMAIL`(复用 SMTP_*,未配则日志);② `GET /v1/admin/tool-failures`;③ admin 页新"工具失败"表(低阈值 3 次/1 task 看全量,悬浮看样例)。生产 DB 冒烟:一次扫出 21 类真聚集(沙箱缺 `file` 命令 x13 跨 13 task、web_fetch Network unreachable x7、mmdc 超时批次等)。env:`ZCBOT_DEVELOPER_EMAIL` / `ZCBOT_TOOLFAIL_SCAN_INTERVAL`(RUN.md 环境段已记)。无新表无 migration;重启后同签名重推一次(聚集还在=问题还在,可接受)。

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。 # 改版本只动这一行。
__version__ = "0.58.13" __version__ = "0.58.14"

View File

@ -16,6 +16,7 @@
""" """
from __future__ import annotations from __future__ import annotations
import math
import re import re
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
@ -70,13 +71,20 @@ def scan_tool_failures(
min_count: int = 5, min_count: int = 5,
min_tasks: int = 2, min_tasks: int = 2,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""扫近 `days` 天的 tool 错误消息,返回超阈值的聚集(count 降序) """扫近 `days` 天的 tool 错误消息,返回超阈值的聚集
阈值语义:同签名 >= min_count >= min_tasks task task 阈值语义:同签名 >= min_count >= min_tasks task task
模型试错几次就自愈的正常噪音不触发; task 复现的才是平台性问题 模型试错几次就自愈的正常噪音不触发; task 复现的才是平台性问题
时间分布:每个聚集带 `daily`( now 往回按 24h 分桶的次数,,
非日历日) `count_24h`(= daily 尾桶) 修复部署后看尾桶是否归零,
区分还在发生窗口内的存量记录排序: 24h 活跃的在前
(count_24h 降序),其后按 count 降序
同步阻塞(DB 查询),asyncio 调用方放 to_thread/executor 同步阻塞(DB 查询),asyncio 调用方放 to_thread/executor
""" """
cutoff = datetime.now(timezone.utc) - timedelta(days=days) now = datetime.now(timezone.utc)
cutoff = now - timedelta(days=days)
n_buckets = max(1, math.ceil(days))
rows = [] rows = []
with session_scope() as s: with session_scope() as s:
rows = s.execute( rows = s.execute(
@ -102,6 +110,8 @@ def scan_tool_failures(
if hit is None: if hit is None:
continue continue
kind, sig_line = hit kind, sig_line = hit
# DB 列若是 naive timestamp(存 UTC),补 tzinfo 才能和 now 做减法
ts = created_at if created_at.tzinfo else created_at.replace(tzinfo=timezone.utc)
key = (tool_name or "?", _normalize(sig_line)) key = (tool_name or "?", _normalize(sig_line))
c = agg.get(key) c = agg.get(key)
if c is None: if c is None:
@ -112,14 +122,21 @@ def scan_tool_failures(
"count": 0, "count": 0,
"tasks": set(), "tasks": set(),
"users": set(), "users": set(),
"last_at": created_at, "first_at": ts,
"last_at": ts,
"sample": content[:300], "sample": content[:300],
"daily": [0] * n_buckets,
} }
c["count"] += 1 c["count"] += 1
c["tasks"].add(task_id) c["tasks"].add(task_id)
c["users"].add(user_id) c["users"].add(user_id)
if created_at > c["last_at"]: # 分桶:距 now 每满 24h 退一桶,尾桶 = 近 24h(时钟漂移/边界值 clamp 进首尾桶)
c["last_at"] = created_at age_days = int((now - ts).total_seconds() // 86400)
c["daily"][n_buckets - 1 - min(n_buckets - 1, max(0, age_days))] += 1
if ts < c["first_at"]:
c["first_at"] = ts
if ts > c["last_at"]:
c["last_at"] = ts
c["sample"] = content[:300] c["sample"] = content[:300]
out = [] out = []
@ -131,22 +148,28 @@ def scan_tool_failures(
"signature": c["signature"], "signature": c["signature"],
"kind": c["kind"], "kind": c["kind"],
"count": c["count"], "count": c["count"],
"count_24h": c["daily"][-1],
"daily": c["daily"],
"task_count": len(c["tasks"]), "task_count": len(c["tasks"]),
"user_count": len(c["users"]), "user_count": len(c["users"]),
"first_at": c["first_at"].isoformat(),
"last_at": c["last_at"].isoformat(), "last_at": c["last_at"].isoformat(),
"sample": c["sample"], "sample": c["sample"],
}) })
out.sort(key=lambda x: x["count"], reverse=True) # 活跃的(近 24h 还在发生)排前面,已安静的沉底 —— 面板/邮件都先看还在烧的
out.sort(key=lambda x: (x["count_24h"], x["count"]), reverse=True)
return out return out
def format_alert(clusters: List[Dict[str, Any]], days: float) -> str: def format_alert(clusters: List[Dict[str, Any]], days: float) -> str:
"""聚集列表 → 告警邮件正文(纯文本)。""" """聚集列表 → 告警邮件正文(纯文本)。"""
lines = [f"{days:g} 天内检出 {len(clusters)} 类工具失败聚集:", ""] lines = [f"{days:g} 天内检出 {len(clusters)} 类工具失败聚集(近 24h 活跃的在前):", ""]
for c in clusters: for c in clusters:
n24 = c.get("count_24h", 0)
lines.append( lines.append(
f"- [{c['tool']}/{c['kind']}] x{c['count']}" f"- [{c['tool']}/{c['kind']}] x{c['count']}"
f"(task {c['task_count']} 个 / 用户 {c['user_count']} 人," f"(近24h {n24}{',已安静' if not n24 else ''},"
f"task {c['task_count']} 个 / 用户 {c['user_count']} 人,"
f"最近 {c['last_at']})" f"最近 {c['last_at']})"
) )
lines.append(f" 签名: {c['signature']}") lines.append(f" 签名: {c['signature']}")

View File

@ -96,6 +96,9 @@
th:first-child, td:first-child { text-align: left; } th:first-child, td:first-child { text-align: left; }
th { color: var(--muted); font-weight: 600; } th { color: var(--muted); font-weight: 600; }
td.num { font-family: var(--mono); } td.num { font-family: var(--mono); }
td.num.hot { color: var(--danger); font-weight: 700; }
td.trend { font-family: var(--mono); letter-spacing: 1px; color: var(--accent); }
tr.quiet td { opacity: .45; }
td.email { font-family: var(--mono); max-width: 220px; overflow: hidden; text-overflow: ellipsis; } td.email { font-family: var(--mono); max-width: 220px; overflow: hidden; text-overflow: ellipsis; }
.bar-cell { position: relative; } .bar-cell { position: relative; }
tfoot .total-row td { border-top: 2px solid var(--border); border-bottom: none; font-weight: 700; } tfoot .total-row td { border-top: 2px solid var(--border); border-bottom: none; font-weight: 700; }

View File

@ -290,21 +290,32 @@ function renderStorage(d) {
// 工具失败聚集(近7天,低阈值看全量;巡检邮件走 core/toolfail 高阈值)。 // 工具失败聚集(近7天,低阈值看全量;巡检邮件走 core/toolfail 高阈值)。
// 签名已在后端归一(路径/数字抹平),悬浮 title 给最近一次的原始样例。 // 签名已在后端归一(路径/数字抹平),悬浮 title 给最近一次的原始样例。
// 后端已按活跃度排序(近24h 有发生的在前);近24h=0 的行淡化 —— 修复部署后
// 看对应行是否变灰 + 趋势尾格归零,即知修没修好(旧记录会挂满窗口,行不消失)。
function trendBar(daily) {
if (!daily || !daily.length) return "";
const max = Math.max(...daily, 1);
const blocks = "▁▂▃▄▅▆▇█";
return daily.map(v => v === 0 ? "·"
: blocks[Math.min(blocks.length - 1, Math.ceil(v / max * blocks.length) - 1)]).join("");
}
function renderToolFailures(d) { function renderToolFailures(d) {
const rows = d.clusters || []; const rows = d.clusters || [];
const body = rows.map(c => `<tr>` const body = rows.map(c => `<tr${c.count_24h ? "" : ` class="quiet"`}>`
+ `<td>${escapeHtml(c.tool)}</td>` + `<td>${escapeHtml(c.tool)}</td>`
+ `<td>${escapeHtml(c.kind)}</td>` + `<td>${escapeHtml(c.kind)}</td>`
+ `<td class="email" title="${escapeHtml(c.sample || "")}">${escapeHtml(c.signature)}</td>` + `<td class="email" title="${escapeHtml(c.sample || "")}">${escapeHtml(c.signature)}</td>`
+ `<td class="num">${c.count}</td>` + `<td class="num">${c.count}</td>`
+ `<td class="num${c.count_24h ? " hot" : ""}">${c.count_24h || 0}</td>`
+ `<td class="trend" title="每格 24h旧 → 新:${(c.daily || []).join(" / ")}">${trendBar(c.daily)}</td>`
+ `<td class="num">${c.task_count}</td>` + `<td class="num">${c.task_count}</td>`
+ `<td class="num">${c.user_count}</td>` + `<td class="num">${c.user_count}</td>`
+ `<td>${c.last_at ? fmtTime(c.last_at) : "—"}</td>` + `<td>${c.last_at ? fmtTime(c.last_at) : "—"}</td>`
+ `</tr>`).join("") + `</tr>`).join("")
|| `<tr><td colspan="7" class="empty">近 ${d.days || 7} 天无失败聚集</td></tr>`; || `<tr><td colspan="9" class="empty">近 ${d.days || 7} 天无失败聚集</td></tr>`;
$("s-toolfail").innerHTML = `<div class="card"><h2>工具失败聚集(近 ${d.days || 7} 天,同签名 ≥3 次</h2>` $("s-toolfail").innerHTML = `<div class="card"><h2>工具失败聚集(近 ${d.days || 7} 天,同签名 ≥3 次;近 24h 活跃在前,灰行 = 已安静</h2>`
+ `<div class="scroll-x"><table>` + `<div class="scroll-x"><table>`
+ `<thead><tr><th>工具</th><th>类型</th><th>签名(悬浮看样例)</th><th>次数</th><th>任务数</th><th>用户数</th><th>最近</th></tr></thead>` + `<thead><tr><th>工具</th><th>类型</th><th>签名(悬浮看样例)</th><th>次数</th><th>近24h</th><th>趋势</th><th>任务数</th><th>用户数</th><th>最近</th></tr></thead>`
+ `<tbody>${body}</tbody></table></div></div>`; + `<tbody>${body}</tbody></table></div></div>`;
} }