Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

**重要规则**:
- 如果用户已经在消息中明确指定了日志路径,直接使用该路径,跳过询问步骤
Expand Down Expand Up @@ -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/<timestamp>/`。
默认日志路径:`logs/router.log`(相对于 `fastdeploy/golang_router/`)。常用备选:`fd-router.log`(根目录)。不传 `--output` 时自动输出到 `skill_output/stat-cache-hitrate/<timestamp>/`。

脚本会自动根据文件大小选择解析策略:小文件(<5000 行)在内存中处理,大文件用 grep + 管道流式处理。

Expand All @@ -94,7 +94,8 @@ python3 .claude/skills/stat-cache-hitrate/scripts/stat_cache_hitrate.py <日志
详细报告和图表输出到 `skill_output/stat-cache-hitrate/<YYYYMMDD_HHMMSS>/` 目录,每次运行自动创建带时间戳的子目录。

- 主报告 `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

### 交叉诊断矩阵

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@
URI: file:///abs/path/to/skill_output/stat-cache-hitrate/<YYYYMMDD_HHMMSS>/cache_hitrate_report_<timestamp>.md
- 窗口明细: /abs/path/to/skill_output/stat-cache-hitrate/<YYYYMMDD_HHMMSS>/details/per_window_data.md
URI: file:///abs/path/to/skill_output/stat-cache-hitrate/<YYYYMMDD_HHMMSS>/details/per_window_data.md
- Session 命中详情: /abs/path/to/skill_output/stat-cache-hitrate/<YYYYMMDD_HHMMSS>/details/session_hit_details.md
URI: file:///abs/path/to/skill_output/stat-cache-hitrate/<YYYYMMDD_HHMMSS>/details/session_hit_details.md
(含 prefill_urls、worker 切换前后 request_id,以及命中率突降 request_id)
```

---
Expand Down
Original file line number Diff line number Diff line change
@@ -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),
}
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -661,21 +663,29 @@ 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("")

# 写入 details 子目录
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"},
)
Expand All @@ -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))
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Loading