☰
Claude Code会话监控面板实战:从JSONL日志到成本可视化
2026/10/3 4:18:15 网站建设 项目流程

最近我盯着终端里密密麻麻的Claude Code会话记录,越想越不对劲:这个工具我用得越狠,对它的掌控感反而越弱。每天开七八个会话窗口,改完代码就关,日志文件在~/.claude/projects里堆成山,可你要我复盘“上周三那个重构到底花了多少token”“哪个项目的成本最高”,我完全答不上来。

于是就有了这个项目——给自己的Claude Code做一个本地会话监控面板。它能自动扫描本机会话日志,把散落的JSONL文件变成可视化列表,统计每次会话的模型、耗时、成本,还能按项目聚合、按关键词搜索。这篇文章就把完整实现思路和踩坑过程写出来,给同样重度使用Claude Code的人一个能直接照抄的参考方案。

1. 为什么我需要一个能看到“过去”的会话面板

先说清楚这个面板到底解决什么痛点,不然你可能觉得又是为了造轮子而造轮子。

1.1 原生CLI的三个盲区

Claude Code本身是个终端交互工具,它的强项是对话和改代码,弱项则是“会话生命周期管理”。我用了大半年,感受最深的是三个盲区:

第一,会话不可视。你启动claude开始对话,结束之后一切归零。终端里没有“历史会话列表”这种东西,你想回看昨天某次对话的具体内容,只能去文件系统里翻JSONL。这对于一个经常同时跑三四个项目的人来说,基本等于没有历史记录。

第二,成本不可控。Claude Code调API是按token计费的。它退出时会输出一个summary,但那是瞬时信息,你没有维度去看“这周总共花了多少钱”“哪个模型占比最大”“哪个项目最烧钱”。等账单把数字摔在你脸上的时候,已经晚了。

第三,历史不可搜。有时候你明明记得之前和Claude讨论过一个方案,当时给了很好的思路,但你再也没法把那段对话捞出来——终端工具没有“搜索历史消息”这个功能。

1.2 现成方案为什么不够用

我研究过一圈市面上的工具。有一些Claude Code的管理客户端,能帮你配置模型、切换API网关,但它们的重心在“配置”而不是“监控”。还有人在GitHub上做了对话日志查看器,但基本都是单文件阅读器,打开一个JSONL看一次对话,没有聚合统计、没有跨会话搜索、没有成本维度。

对我来说,最理想的面板应该是本地运行、自动汇总、多维度可查。我不需要它有多花哨的界面,但要做到:打开浏览器就能看到所有会话的概况,点进去能看到完整对话,搜索框能跨会话搜内容,统计页能告诉我成本和模型占比。

1.3 面板需要做到什么程度

基于上面的分析,我给这个项目定了四条验收标准:

  1. 能自动扫描本地Claude Code日志目录,解析会话元数据;
  2. 能按项目、时间、模型筛选会话,支持关键词搜索;
  3. 能统计每日token消耗和费用,并按模型和项目聚合;
  4. 能查看完整对话上下文,不依赖Claude Code原生CLI。

这四条做完,这个面板就是可用的。后来我又加了实时刷新和飞书推送,那是锦上添花的事,后面专门讲。

2. 面板的整体设计与数据来源

动手写代码之前,必须先把数据链路搞清楚:数据在哪、什么格式、怎么变成结构化记录。

2.1 数据都在本地:~/.claude目录结构解析

Claude Code所有的会话数据都存在本地。默认路径是~/.claude/,在Windows上是C:\Users\你的用户名\.claude\。这个目录的大致结构如下:

~/.claude/ ├── projects/ │ ├── -/ │ │ ├── 459738a10e2a1d9d5c649b6b5f01a033.jsonl │ │ └── ... │ ├── C%3A%5CWorkspace%5Cmy-app/ │ │ └── ... │ └── ... ├── settings.json ├── config.json └── ...

projects/目录下面每个子目录对应一个项目,目录名是项目路径经过URL编码后的结果。比如C:\Workspace\my-app会变成C%3A%5CWorkspace%5Cmy-app,根目录则是-。每个目录下是一堆JSONL文件,文件名是会话ID,扩展名是.jsonl。

这个目录结构意味着两件事:一是数据天然按项目分好了组,二是文件名本身就带会话唯一标识,可以用来做去重。

2.2 JSONL日志格式速览

每个.jsonl文件就是一个完整会话,每一行是一条消息记录,JSON格式。不同版本的Claude Code字段略有差异,但核心结构是稳定的。我根据自己机器上的日志归纳了三种关键行类型:

user类型——代表你输入的内容,结构大致长这样:

{"type":"user","message":{"role":"user","content":"帮我优化这个函数"},"timestamp":"2025-06-01T10:23:45.123Z"}

assistant类型——代表Claude的回复,可能包含文本片段、工具调用等:

{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"我建议把这段逻辑拆成三个函数..."}]},"timestamp":"2025-06-01T10:23:50.201Z"}

summary类型——这是最关键的元数据行,会话结束时写入,包含模型的用量和费用:

{"type":"summary","summary":{"model":"claude-sonnet-4-20250514","usage":{"input_tokens":2314,"output_tokens":1456},"cost_usd":0.0523,"duration_ms":238000,"num_turns":6},"timestamp":"2025-06-01T10:27:33.874Z"}

注意,cost_usd在不同版本可能不叫这个名字,有的叫total_cost_usd或cost。解析的时候要做好字段兼容,否则直接报KeyError。

2.3 技术选型:为什么是Python + Flask + SQLite

选技术栈的时候我只花了两分钟:Python + Flask + SQLite + ECharts。

原因很简单。Python解析JSONL是零成本操作,标准库的json就够了;Flask起一个本地Web服务只需要十几行代码;SQLite做数据存储足够应付几千个会话,不需要额外装数据库;ECharts画统计图是最省事的方案,不用写一行SVG。

如果你想用Node.js也没问题,关键在于解析逻辑,语言本身不是瓶颈。我选Python纯粹是因为日志解析脚本写完顺便就能跑,不用单独起一个运行时。

2.4 面板目录结构

整个项目的文件结构我设计成这样:

claude-monitor/ ├── app.py # Flask主程序 + API接口 ├── parser.py # 日志扫描与解析模块 ├── schema.sql # SQLite建表语句 ├── static/ │ ├── index.html # 主页面(会话列表) │ ├── detail.html # 会话详情页 │ ├── stats.html # 统计页 │ └── lib/ │ ├── echarts.min.js │ └── ... └── data/ └── monitor.db # 自动生成的SQLite数据库

这样拆分的好处是职责清晰:parser.py只管把文件变成结构化数据,app.py只负责Web接口,前端页面互相独立,互不牵连。

3. 数据采集层:解析会话日志

这是整个面板的核心。解析做不好,后面都是空中楼阁。

3.1 扫描项目目录并识别JSONL文件

第一步是遍历~/.claude/projects/下的所有子目录。这里有个坑:目录名是URL编码过的,比如C:\Workspace\my-app在Windows上会编码成C%3A%5CWorkspace%5Cmy-app。要把它还原成可读的项目路径,用urllib.parse.unquote解码就行。

import os from urllib.parse import unquote def scan_projects(base_dir): projects = {} for entry in os.scandir(base_dir): if entry.is_dir(): raw_name = entry.name display_name = unquote(raw_name) if raw_name != "-" else "未命名项目" jsonl_files = [f.name for f in os.scandir(entry.path) if f.name.endswith(".jsonl")] projects[entry.path] = { "display_name": display_name, "count": len(jsonl_files), "files": jsonl_files } return projects

拿到文件列表后,就可以逐个解析了。

3.2 解析三种关键行类型

解析单个JSONL文件的过程,就是逐行读取、json.loads、按type字段分发的循环。核心逻辑如下:

import json from datetime import datetime def parse_session_file(filepath): messages = [] summary = None start_time = None end_time = None with open(filepath, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue try: obj = json.loads(line) except json.JSONDecodeError: continue t = obj.get("type") ts = obj.get("timestamp") if ts: dt = datetime.fromisoformat(ts.replace("Z", "+00:00")) if start_time is None or dt < start_time: start_time = dt if end_time is None or dt > end_time: end_time = dt if t == "user": content = obj.get("message", {}).get("content", "") messages.append({ "role": "user", "content": content if isinstance(content, str) else json.dumps(content), "timestamp": ts }) elif t == "assistant": content = obj.get("message", {}).get("content", "") if isinstance(content, list): text_parts = [] for item in content: if item.get("type") == "text": text_parts.append(item.get("text", "")) content = "\n".join(text_parts) messages.append({ "role": "assistant", "content": content, "timestamp": ts }) elif t == "summary": summary = obj.get("summary", {}) return { "filepath": filepath, "messages": messages, "summary": summary, "start_time": start_time, "end_time": end_time }

这里有两个容易忽略的细节。

第一个:assistant的content字段可能是列表而不是字符串。Claude的回复经常是富文本结构,里面包含多个text块、工具调用块、代码块。如果直接存字符串,工具调用的信息就丢了。我的做法是把所有type == "text"的块提取出来拼成纯文本,虽然是简化处理,但对展示和搜索来说够用了。

第二个:JSONL可能包含损坏的行。文件写到一半进程被杀死,最后一行就会不完整。解析时务必对json.JSONDecodeError做容错,否则一个坏文件会让整个批次导入失败。

3.3 summary字段提取与费用统计

summary行是成本统计的数据来源,但不同版本的字段名可能不同。我做了兼容处理:

def extract_summary_data(summary): if not summary: return { "model": "unknown", "input_tokens": 0, "output_tokens": 0, "cost_usd": 0.0, "duration_ms": 0, "num_turns": 0 } usage = summary.get("usage", {}) return { "model": summary.get("model", "unknown"), "input_tokens": usage.get("input_tokens", 0), "output_tokens": usage.get("output_tokens", 0), "cost_usd": summary.get("cost_usd") or summary.get("total_cost_usd") or 0.0, "duration_ms": summary.get("duration_ms", 0), "num_turns": summary.get("num_turns", 0) }

有一点要注意:cost_usd的精度。Claude Code日志里存的是美元小数,比如0.052345。统计的时候如果直接浮点累加,几千条会话之后会出现精度漂移。我的做法是后端存浮点,前端展示时用toFixed(4),报表聚合用SQL的ROUND(SUM(cost_usd), 4)。不算严谨,但对监控面板足够了。

3.4 增量导入策略

第一次扫描几百个JSONL文件没问题,但后续每次都要全量重扫就太蠢了。我用SQLite自带的INSERT OR IGNORE配合文件名去重,实现增量导入。数据库表结构里给session_id加了唯一索引:

CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, project TEXT NOT NULL, model TEXT, start_time TEXT, end_time TEXT, input_tokens INTEGER DEFAULT 0, output_tokens INTEGER DEFAULT 0, cost_usd REAL DEFAULT 0, duration_ms INTEGER DEFAULT 0, num_turns INTEGER DEFAULT 0 ); CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT, timestamp TEXT );

导入时,id取文件名去掉.jsonl的部分。重复执行导入任务时,因为主键冲突会被忽略,所以天然支持幂等操作。

4. 展示层:一个够用的本地Web面板

数据有了,接下来就是让数据可见。我不打算做重交互的SPA,一个简单的多页面站点就够了。

4.1 Flask API设计与路由

后端接口一共六个,基本覆盖面板所有功能:

from flask import Flask, jsonify, request, render_template import sqlite3 app = Flask(__name__) DB_PATH = "data/monitor.db" def query_db(sql, args=()): conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row cur = conn.execute(sql, args) rows = [dict(row) for row in cur.fetchall()] conn.close() return rows @app.route("/") def index(): return app.send_static_file("index.html") @app.route("/api/sessions") def api_sessions(): project = request.args.get("project") model = request.args.get("model") keyword = request.args.get("q") sql = "SELECT * FROM sessions WHERE 1=1" args = [] if project: sql += " AND project = ?" args.append(project) if model: sql += " AND model = ?" args.append(model) if keyword: sql += " AND id IN (SELECT DISTINCT session_id FROM messages WHERE content LIKE ?)" args.append(f"%{keyword}%") sql += " ORDER BY start_time DESC LIMIT 200" return jsonify(query_db(sql, args))

/api/messages/<session_id>返回单个会话的完整对话,/api/stats/daily返回每日成本曲线数据,/api/stats/model返回模型占比,/api/stats/project返回项目聚合数据。

4.2 会话列表页:一眼看清所有信息

列表页是面板的主页,每一行代表一个会话,展示字段包括:所属项目、模型、开始时间、耗时、token用量、费用。设计这个表格的时候我做了一个取舍:默认只显示最近200条,并支持翻页。原因是Claude Code重度用户很容易积累上千个会话,一次性渲染所有行会卡死浏览器。

表格还有一个我很满意的功能:失败会话高亮。Claude Code的日志里,如果会话中出现了严重错误,summary里经常没有cost_usd,或者duration很短、turns很少。我在渲染时兜底判断:如果num_turns == 0且cost_usd == 0,就把这行标灰。这对于排查“是不是有会话根本没跑起来”非常有效。

4.3 详情页:完整对话回顾

点击列表行会跳转到详情页。这里把messages表的记录按时间顺序渲染成聊天气泡,用户消息靠右,助手消息靠左。代码块用<pre>包裹,保留格式。

实际使用中我发现一个非常实用的功能,就是在详情页顶部显示该会话的“关键元数据”卡片:模型、总token、费用、耗时。这比翻日志文件去查优雅太多了。

4.4 统计页:成本与占比可视化

统计页用ECharts画三张图:每日成本趋势折线图、模型调用占比饼图、项目成本柱状图。

每日成本趋势是我用得最多的功能。它直接告诉我“昨天花了多少钱”“哪个时间点出现了异常峰值”。有了这张图,你会发现很多有意思的规律——比如我通常周二最烧钱,因为周一积压了一批重构需求。

ECharts的引入方式很简单,直接在HTML里<script src="/static/lib/echarts.min.js"></script>,初始化图表时从API拉数据:

fetch('/api/stats/daily') .then(res => res.json()) .then(data => { const chart = echarts.init(document.getElementById('dailyChart')); chart.setOption({ tooltip: { trigger: 'axis' }, xAxis: { type: 'category', data: data.map(d => d.day) }, yAxis: { type: 'value' }, series: [{ name: '费用(USD)', type: 'line', data: data.map(d => d.cost) }] }); });

5. 实测中踩过的坑与排查思路

面板第一版跑通后,我兴冲冲在测试环境部署,结果被现实教育了好几次。这几条经验写在这里,能帮你省下至少半天调试时间。

5.1 Windows路径与URL编码的坑

我的主力开发机是Windows,最初扫描时发现C:\Workspace\my-app对应的目录名不是直观的路径名,而是C%3A%5CWorkspace%5Cmy-app。我用unquote解码后,显示名是C:\Workspace\my-app,这个没问题。

但真正的问题是:有中文和空格的项目路径,URL编码后的目录名极其难认。面板能显示解码后的名字,但如果你直接在文件系统里找对应目录,会非常拗手。我的建议是:展示层面用解码名,排序逻辑上用原始编码名,不要混用。

5.2 日志文件和时区问题

Claude Code的timestamp用的是ISO 8601格式,结尾是Z(UTC时区)。如果你在国内(UTC+8),直接读出来存库,显示的会话时间会比实际时间早8小时。尤其是统计“每日成本”时,跨时区计算会造成凌晨前后半小时的归组错乱。

我的处理方式是在解析层统一转成本地时间再入库:

from datetime import datetime, timezone, timedelta def parse_timestamp(ts): dt = datetime.fromisoformat(ts.replace("Z", "+00:00")) local_tz = timezone(timedelta(hours=8)) return dt.astimezone(local_tz).isoformat()

当然更通用的是让面板读取系统本地时区,但为了快速落地,我直接加了固定8小时偏移。如果你在别的时区,自己改timedelta即可。

5.3 有些会话没有summary行

这是坑王。你以为每个JSONL文件都有summary行,事实上有相当比例的会话没有正常结束——可能是Ctrl+C中断、可能是Claude Code报错退出、可能是进程被杀。这类会话没有summary,也就没有model、cost这些字段。

处理不当会导致两种后果:一是会话不在统计里出现,但确实消耗了token;二是会话列表里出现大量“未知模型”的脏数据。

我的对策是兜底赋值:没有summary的会话,model标记为interrupted,cost标记为0,但有完整对话记录可查。这样至少能追踪到“那次会话的开销到哪去了”。

5.4 识别失败会话:internetopenurl failed那段经历的启发

在解析日志的过程中,我发现不少会话的assistant消息里含有报错信息。最典型的是Windows环境下Claude Code调用CLI时出现internetopenurl() failed. 0x80072efd之类的网络错误,以及your organization has disabled claude subscription access这类权限提示。

这些错误不会导致JSONL损坏,但会让整个会话的内容失去意义——你问了一堆问题,模型没答出来,净是报错文本。面板的搜索功能会把它们搜出来,很干扰判断。

后来我在导入时加了一道“内容标记”逻辑:如果assistant消息里匹配到特定错误关键字(internetopenurl、disabled、error等),就给这条消息打上is_error标记。前端详情页对这类消息用不同底色渲染,列表页也显示“异常”标签。这个功能虽然简单,但排查“为什么这次会话花了钱却什么都没干”时非常好用。

5.5 会话文件量大到解析不动

我有一次连续跑了半个月没清理,~/.claude/projects下积攒了快2000个JSONL文件,全量解析一次要好几分钟。优化方案是三个词:增量、缓存、异步。

  • 增量:每次启动只扫描文件修改时间晚于上次导入时间的文件;
  • 缓存:SQLite里记录每条会话的解析时间,last_scanned_at字段,重复导入时跳过;
  • 异步:Flask启动后立即触发扫描线程,页面先展示已有数据,扫完再刷新。

这三种方案都落地后,我的面板启动时间从3分钟降到3秒。

6. 进阶扩展:多模型监控与消息推送

基础面板跑通之后,我开始琢磨一些提升效率的扩展。尤其是Claude Code社区现在流行通过CC Switch之类的工具接入DeepSeek、Qwen、GLM这些第三方模型,监控面板也要跟上这个玩法。

6.1 适配CC Switch接入的第三方模型

CC Switch这类工具的工作方式,本质上是改Claude Code的配置,把默认的模型端点替换成第三方API网关。会话日志里summary字段的model也会跟着变成deepseek-v4、qwen3-coder、glm-4.5之类的名字。

对面板来说,这意味着不用改任何解析逻辑,只要统计页的模型维度足够灵活,就能自动展示各模型的用量对比。我甚至在模型占比图里加了一个“非Claude原生模型”的聚合项,一眼扫过去就能知道第三方模型用了多少。

这方面有个小建议:第三方模型的成本口径和官方API不同,CLI日志里的cost_usd可能是按接入方的定价算的。统计时最好在面板里做一个“定价配置”字段,允许你手动覆盖各模型的单价,重新计算真实成本。

6.2 识别本地模型(LMStudio等)的会话

也有不少人在本地跑LMStudio提供的OpenAI兼容API,再指给Claude Code用。这类会话的特征是model字段比较杂,常见像local-model或者你自定义的模型名,而且cost_usd通常为0——本地模型不花钱。

我在面板里给这些费用为0的会话单独加了一个“本地”标签,并把它们排除在成本报表之外,但保留token用量统计。这样看成本趋势不会失真,看资源消耗又不会漏掉本地推理的负载。

6.3 飞书Webhook推送会话摘要

这个扩展是我日常用着最爽的。思路很简单:每次有新会话导入完成,面板就通过飞书自定义机器人Webhook推送一条摘要,内容包括项目名、模型、总token、费用、会话链接。这样我不用主动打开面板,手机就能收到每天的Claude Code活动简报。

推送模块核心就一个函数:

import requests def send_feishu_alert(webhook_url, session_info): text = ( f"【Claude Code会话完成】\n" f"项目:{session_info['project']}\n" f"模型:{session_info['model']}\n" f"Token:{session_info['input_tokens'] + session_info['output_tokens']}\n" f"费用:${session_info['cost_usd']:.4f}\n" f"耗时:{session_info['duration_ms']}ms\n" f"回合:{session_info['num_turns']}" ) payload = {"msg_type": "text", "content": {"text": text}} requests.post(webhook_url, json=payload)

企业微信和钉钉的Webhook原理一样,改个payload格式就行。

6.4 后续还能做什么

这套面板的可扩展空间其实还很大。我列几个打算做的方向:给每个项目设定月度预算上限,超过后自动在面板上飘红告警;把成本数据导出成CSV方便报销;通过本地LLM对会话内容做自动摘要,生成周报。当然这些都是后话,先把面板跑起来最重要。

7. 一点实用建议与个人体会

最后说几句掏心窝的。

如果你也是Claude Code的重度用户,我强烈建议不要只把面板当成一个“事后看账单”的工具,而是把日常复盘流程嵌进去。我现在的习惯是:每天下班前花三分钟看统计页——今天哪个项目耗了最多token,有没有异常峰值,第三方模型用量是否正常。这可能是我近期做过性价比最高的效率投资。

对于面板的部署方式,没必要上云,本地跑就够了。我把它注册成Windows计划任务,每次开机自动启动Flask服务,浏览器书签指向localhost:5000,几乎没有感知成本。

还有一个小技巧,也是踩了不少坑才学到的:Claude Code的日志文件是持续追加写入的,面板扫描时如果正好赶上半截写操作,读出来的最后一行往往是坏的。我的扫描逻辑里专门加了“最后一行不完整就跳过”的容错,别小看这一行判断,它救了我好多次。

这个项目的源码并不复杂,核心解析不到两百行,Flask接口和前端页面加起来也就五百行左右。如果你愿意折腾,一个晚上就能把它跑起来。真正有价值的地方在于:你对自己AI编程助手的运行状态,终于有了一个可量化的观察窗口。对于那些想进一步优化自己工作流的人来说,这个起点应该够用了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询