diff --git a/fastdeploy/golang_router/.claude/skills/stat-cache-hitrate/SKILL.md b/fastdeploy/golang_router/.claude/skills/stat-cache-hitrate/SKILL.md index 6534fb332f2..f9c5156ca69 100644 --- a/fastdeploy/golang_router/.claude/skills/stat-cache-hitrate/SKILL.md +++ b/fastdeploy/golang_router/.claude/skills/stat-cache-hitrate/SKILL.md @@ -23,10 +23,10 @@ IMPORTANT: 执行前阅读 references/log_formats.md 了解日志格式和解析 运行脚本前,Claude 必须先向用户确认以下参数: ### 1. 日志文件路径 -使用 AskUserQuestion 工具向用户询问日志文件路径。提供常见的默认选项,同时允许用户直接输入自定义路径(支持绝对路径和相对路径): +使用 AskUserQuestion 工具向用户询问日志文件路径。提供两个常用快捷选项 + Other 自定义输入(支持绝对路径和相对路径): - 选项 1: `logs/router.log`(默认) -- 选项 2: `fd-router.log`(golang_router 根目录) -- 选项 3: 用户通过 Other 输入自定义路径 +- 选项 2: `fd-router.log`(golang_router 根目录常用文件名) +- 选项 3: Other(用户直接输入任意路径,例如 `logs/fd-router.log`、`/home/user/logs/router.log`) **重要规则**: - 如果用户已经在消息中明确指定了日志路径,直接使用该路径,跳过询问步骤 @@ -75,7 +75,7 @@ python3 .claude/skills/stat-cache-hitrate/scripts/stat_cache_hitrate.py <日志 python3 .claude/skills/stat-cache-hitrate/scripts/stat_cache_hitrate.py <日志文件> --start "03/31" --end "03/31 18:00" ``` -默认日志路径:`logs/router.log` 或 `fd-router.log`(相对于 `fastdeploy/golang_router/`)。不传 `--output` 时自动输出到 `skill_output/stat-cache-hitrate//`。 +默认日志路径:`logs/router.log`(相对于 `fastdeploy/golang_router/`)。常用备选:`fd-router.log`(根目录)。不传 `--output` 时自动输出到 `skill_output/stat-cache-hitrate//`。 脚本会自动根据文件大小选择解析策略:小文件(<5000 行)在内存中处理,大文件用 grep + 管道流式处理。 @@ -94,7 +94,8 @@ python3 .claude/skills/stat-cache-hitrate/scripts/stat_cache_hitrate.py <日志 详细报告和图表输出到 `skill_output/stat-cache-hitrate//` 目录,每次运行自动创建带时间戳的子目录。 - 主报告 `cache_hitrate_report_*.md` — Per-Worker 统计 + Fallback 明细 -- `details/per_window_data.md` — 每5s窗口的完整明细数据(Prefix HR / Session HR / Scoring / Fallback / Running) +- `details/per_window_data.md` — 每5s窗口明细(连续空窗口自动合并为 3 行:起始/合并说明/结束) +- `details/session_hit_details.md` — 每个 session 的命中明细(`session / req_count / first_hit / avg_hit(excl_first) / max_hit / min_hit / all_hits`),并附带 `prefill_urls`、prefill URL 切换前后 request_id(或 req_id/trace_id)以及命中率突降 request_id ### 交叉诊断矩阵 diff --git a/fastdeploy/golang_router/.claude/skills/stat-cache-hitrate/references/report_templates.md b/fastdeploy/golang_router/.claude/skills/stat-cache-hitrate/references/report_templates.md index 7f060cacb6a..f5a0def5f55 100644 --- a/fastdeploy/golang_router/.claude/skills/stat-cache-hitrate/references/report_templates.md +++ b/fastdeploy/golang_router/.claude/skills/stat-cache-hitrate/references/report_templates.md @@ -72,6 +72,9 @@ URI: file:///abs/path/to/skill_output/stat-cache-hitrate//cache_hitrate_report_.md - 窗口明细: /abs/path/to/skill_output/stat-cache-hitrate//details/per_window_data.md URI: file:///abs/path/to/skill_output/stat-cache-hitrate//details/per_window_data.md + - Session 命中详情: /abs/path/to/skill_output/stat-cache-hitrate//details/session_hit_details.md + URI: file:///abs/path/to/skill_output/stat-cache-hitrate//details/session_hit_details.md + (含 prefill_urls、worker 切换前后 request_id,以及命中率突降 request_id) ``` --- diff --git a/fastdeploy/golang_router/.claude/skills/stat-cache-hitrate/scripts/session_analysis.py b/fastdeploy/golang_router/.claude/skills/stat-cache-hitrate/scripts/session_analysis.py new file mode 100644 index 00000000000..355ba8fc947 --- /dev/null +++ b/fastdeploy/golang_router/.claude/skills/stat-cache-hitrate/scripts/session_analysis.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +""" +Session 维度分析:聚合每个 session 的命中率、worker 切换与突降请求。 +""" + +from collections import defaultdict + + +def compute_session_details(strategies, strip_scheme): + """按 session 统计命中详情。""" + + def _req_id_from_tags(tags, fallback): + return tags.get("request_id") or tags.get("req_id") or tags.get("trace_id") or fallback + + session_records = defaultdict(list) + for idx, rec in enumerate(strategies): + if rec.get("strategy") != "cache_aware_scoring": + continue + tags = rec.get("tags", {}) or {} + session_id = tags.get("session_id") + if not session_id: + continue + session_records[session_id].append((idx, rec)) + + rows = [] + for session_id, items in session_records.items(): + items.sort(key=lambda x: (x[1].get("ts_ms", ""), x[1].get("ts", ""), x[0])) + recs = [r for _, r in items] + hits = [int(r.get("selected_hitRatio", 0)) for r in recs] + if not hits: + continue + + non_first = hits[1:] + avg_excl_first = round(sum(non_first) / len(non_first), 1) if non_first else "-" + workers = {r.get("selected", "") for r in recs if r.get("selected")} + + prefill_urls = [] + for r in recs: + u = r.get("selected", "") + if u and u not in prefill_urls: + prefill_urls.append(u) + + switch_events = [] + sharp_drop_req_ids = [] + for i in range(1, len(recs)): + prev_r = recs[i - 1] + curr_r = recs[i] + prev_url = prev_r.get("selected", "") + curr_url = curr_r.get("selected", "") + prev_tags = prev_r.get("tags", {}) or {} + curr_tags = curr_r.get("tags", {}) or {} + prev_req = _req_id_from_tags(prev_tags, f"idx#{i}") + curr_req = _req_id_from_tags(curr_tags, f"idx#{i+1}") + + if prev_url and curr_url and prev_url != curr_url: + switch_events.append(f"{prev_req}->{curr_req} ({strip_scheme(prev_url)}→{strip_scheme(curr_url)})") + + prev_hit = int(prev_r.get("selected_hitRatio", 0)) + curr_hit = int(curr_r.get("selected_hitRatio", 0)) + if curr_hit - prev_hit <= -30: + sharp_drop_req_ids.append(f"{curr_req} ({prev_hit}%→{curr_hit}%)") + + rows.append( + { + "session": session_id, + "req_count": len(hits), + "first_hit": f"{hits[0]}%", + "avg_hit(excl_first)": f"{avg_excl_first}%" if avg_excl_first != "-" else "-", + "max_hit": f"{max(hits)}%", + "min_hit": f"{min(hits)}%", + "all_hits": ", ".join(f"{h}%" for h in hits), + "sticky": "yes" if len(workers) <= 1 else "no", + "unique_workers": len(workers), + "prefill_urls": " | ".join(strip_scheme(u) for u in prefill_urls), + "switch_req_pairs": " ; ".join(switch_events) if switch_events else "-", + "sharp_drop_request_ids": " ; ".join(sharp_drop_req_ids) if sharp_drop_req_ids else "-", + } + ) + + rows.sort(key=lambda r: (r["req_count"], r["session"]), reverse=True) + return rows + + +def summarize_session_details(rows): + """生成 session 级摘要指标。""" + if not rows: + return { + "total_sessions": 0, + "multi_req": 0, + "single_req": 0, + "sticky_multi": 0, + "non_sticky_multi": 0, + "non_first_avg": 0, + "non_first_total": 0, + } + + multi_req_rows = [r for r in rows if r["req_count"] > 1] + sticky_multi = [r for r in multi_req_rows if r["sticky"] == "yes"] + non_sticky_multi = [r for r in multi_req_rows if r["sticky"] == "no"] + + non_first_vals = [] + for r in rows: + hit_tokens = [h.strip().rstrip("%") for h in r["all_hits"].split(",") if h.strip()] + nums = [int(x) for x in hit_tokens if x.isdigit()] + if len(nums) > 1: + non_first_vals.extend(nums[1:]) + + return { + "total_sessions": len(rows), + "multi_req": len(multi_req_rows), + "single_req": len(rows) - len(multi_req_rows), + "sticky_multi": len(sticky_multi), + "non_sticky_multi": len(non_sticky_multi), + "non_first_avg": round(sum(non_first_vals) / len(non_first_vals), 2) if non_first_vals else 0, + "non_first_total": len(non_first_vals), + } diff --git a/fastdeploy/golang_router/.claude/skills/stat-cache-hitrate/scripts/stat_cache_hitrate.py b/fastdeploy/golang_router/.claude/skills/stat-cache-hitrate/scripts/stat_cache_hitrate.py index 6d09bc1915d..066c15330d7 100644 --- a/fastdeploy/golang_router/.claude/skills/stat-cache-hitrate/scripts/stat_cache_hitrate.py +++ b/fastdeploy/golang_router/.claude/skills/stat-cache-hitrate/scripts/stat_cache_hitrate.py @@ -33,7 +33,9 @@ parse_stats_line, parse_ts, ) +from session_analysis import compute_session_details, summarize_session_details from stats import compute_statistics, count_by, time_bucket +from window_utils import merge_blank_window_rows def _strip_scheme(url): @@ -661,11 +663,15 @@ def save_detailed_report( # 每窗口明细 → 拆分到 details/ window_rows = build_per_window_rows(strategies, stats_recs) + window_rows_merged = merge_blank_window_rows(window_rows) + session_rows = compute_session_details(strategies, _strip_scheme) + session_summary = summarize_session_details(session_rows) if window_rows: # 主报告中添加引用 parts.append( - f"> 每5s窗口明细数据 ({len(window_rows)} 条): [details/per_window_data.md](details/per_window_data.md)" + f"> 每5s窗口明细数据(原始 {len(window_rows)} 条,合并后 {len(window_rows_merged)} 条):" + " [details/per_window_data.md](details/per_window_data.md)" ) parts.append("") @@ -673,9 +679,13 @@ def save_detailed_report( details_dir = os.path.join(output_dir, "details") os.makedirs(details_dir, exist_ok=True) detail_parts = ["# 每5s窗口明细数据", ""] + detail_parts.append( + "> 注:连续空窗口(Prefix/Session 都为空、且 Scoring/Fallback=0)已按 3 行格式合并展示(起始/合并说明/结束)。" + ) + detail_parts.append("") detail_parts.append( render_table( - window_rows, + window_rows_merged, columns=["Time", "Prefix HR", "Session HR", "Scoring", "Fallback", "Total Running"], right_align={"Scoring", "Fallback", "Total Running"}, ) @@ -686,6 +696,57 @@ def save_detailed_report( with open(detail_path, "w") as f: f.write("\n".join(detail_parts)) + if session_rows: + parts.append( + f"> Session 命中详情 ({len(session_rows)} sessions): [details/session_hit_details.md](details/session_hit_details.md)" + ) + parts.append("") + + session_parts = ["# Session 命中详情", ""] + session_parts.append("## 概览") + session_parts.append(f'- Total sessions: **{session_summary["total_sessions"]}**') + session_parts.append( + f'- Sessions with >1 request: **{session_summary["multi_req"]}**' + f' | single request: **{session_summary["single_req"]}**' + ) + if session_summary["multi_req"] > 0: + sticky_pct = round(session_summary["sticky_multi"] / session_summary["multi_req"] * 100, 1) + session_parts.append( + f'- Sticky (multi-request): **{session_summary["sticky_multi"]} ({sticky_pct}%)**' + f' | non-sticky: **{session_summary["non_sticky_multi"]}**' + ) + session_parts.append( + f'- Non-first request avg hit: **{session_summary["non_first_avg"]}%**' + f' (N={session_summary["non_first_total"]})' + ) + session_parts.append("") + session_parts.append("## 明细表") + session_parts.append( + render_table( + session_rows, + columns=[ + "session", + "req_count", + "first_hit", + "avg_hit(excl_first)", + "max_hit", + "min_hit", + "all_hits", + "prefill_urls", + "switch_req_pairs", + "sharp_drop_request_ids", + "sticky", + "unique_workers", + ], + right_align={"req_count", "first_hit", "avg_hit(excl_first)", "max_hit", "min_hit", "unique_workers"}, + ) + ) + session_parts.append("") + + session_path = os.path.join(details_dir, "session_hit_details.md") + with open(session_path, "w") as f: + f.write("\n".join(session_parts)) + os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True) with open(output_path, "w") as f: f.write("\n".join(parts)) diff --git a/fastdeploy/golang_router/.claude/skills/stat-cache-hitrate/scripts/window_utils.py b/fastdeploy/golang_router/.claude/skills/stat-cache-hitrate/scripts/window_utils.py new file mode 100644 index 00000000000..526fe2382ce --- /dev/null +++ b/fastdeploy/golang_router/.claude/skills/stat-cache-hitrate/scripts/window_utils.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +""" +窗口明细压缩工具:合并连续空窗口,降低 per_window_data.md 噪声。 +""" + + +def _is_blank_window_row(row): + """判断是否为空窗口(无 Prefix/Session 明细值)。""" + return ( + row.get("Prefix HR") == "-" + and row.get("Session HR") == "-" + and row.get("Scoring") in {"0", 0} + and row.get("Fallback") in {"0", 0} + ) + + +def merge_blank_window_rows(rows, min_merge_len=5): + """合并连续空窗口,避免明细表被大量 '-' 行淹没。 + + 对于连续空窗口段(长度 >= min_merge_len),压缩成 3 行: + 1) 起始时间行 + 2) 合并说明行(含窗口数量) + 3) 结束时间行 + """ + if not rows: + return rows + + merged = [] + i = 0 + while i < len(rows): + if not _is_blank_window_row(rows[i]): + merged.append(rows[i]) + i += 1 + continue + + j = i + while j < len(rows) and _is_blank_window_row(rows[j]): + j += 1 + + seg_len = j - i + if seg_len < min_merge_len: + merged.extend(rows[i:j]) + i = j + continue + + start_t = rows[i]["Time"] + end_t = rows[j - 1]["Time"] + merged.append( + { + "Time": start_t, + "Prefix HR": "-", + "Session HR": "-", + "Scoring": "0", + "Fallback": "0", + "Total Running": rows[i].get("Total Running", "-"), + } + ) + merged.append( + { + "Time": f"... {start_t} ~ {end_t} merged ({seg_len} windows) ...", + "Prefix HR": "-", + "Session HR": "-", + "Scoring": "0", + "Fallback": "0", + "Total Running": "-", + } + ) + merged.append( + { + "Time": end_t, + "Prefix HR": "-", + "Session HR": "-", + "Scoring": "0", + "Fallback": "0", + "Total Running": rows[j - 1].get("Total Running", "-"), + } + ) + i = j + + return merged