
最近我盯着终端里密密麻麻的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 面板需要做到什么程度基于上面的分析我给这个项目定了四条验收标准能自动扫描本地Claude Code日志目录解析会话元数据能按项目、时间、模型筛选会话支持关键词搜索能统计每日token消耗和费用并按模型和项目聚合能查看完整对话上下文不依赖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, encodingutf-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 11 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格式结尾是ZUTC时区。如果你在国内UTC8直接读出来存库显示的会话时间会比实际时间早8小时。尤其是统计“每日成本”时跨时区计算会造成凌晨前后半小时的归组错乱。我的处理方式是在解析层统一转成本地时间再入库from datetime import datetime, timezone, timedelta def parse_timestamp(ts): dt datetime.fromisoformat(ts.replace(Z, 00:00)) local_tz timezone(timedelta(hours8)) return dt.astimezone(local_tz).isoformat()当然更通用的是让面板读取系统本地时区但为了快速落地我直接加了固定8小时偏移。如果你在别的时区自己改timedelta即可。5.3 有些会话没有summary行这是坑王。你以为每个JSONL文件都有summary行事实上有相当比例的会话没有正常结束——可能是CtrlC中断、可能是Claude Code报错退出、可能是进程被杀。这类会话没有summary也就没有model、cost这些字段。处理不当会导致两种后果一是会话不在统计里出现但确实消耗了token二是会话列表里出现大量“未知模型”的脏数据。我的对策是兜底赋值没有summary的会话model标记为interruptedcost标记为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 fToken{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, jsonpayload)企业微信和钉钉的Webhook原理一样改个payload格式就行。6.4 后续还能做什么这套面板的可扩展空间其实还很大。我列几个打算做的方向给每个项目设定月度预算上限超过后自动在面板上飘红告警把成本数据导出成CSV方便报销通过本地LLM对会话内容做自动摘要生成周报。当然这些都是后话先把面板跑起来最重要。7. 一点实用建议与个人体会最后说几句掏心窝的。如果你也是Claude Code的重度用户我强烈建议不要只把面板当成一个“事后看账单”的工具而是把日常复盘流程嵌进去。我现在的习惯是每天下班前花三分钟看统计页——今天哪个项目耗了最多token有没有异常峰值第三方模型用量是否正常。这可能是我近期做过性价比最高的效率投资。对于面板的部署方式没必要上云本地跑就够了。我把它注册成Windows计划任务每次开机自动启动Flask服务浏览器书签指向localhost:5000几乎没有感知成本。还有一个小技巧也是踩了不少坑才学到的Claude Code的日志文件是持续追加写入的面板扫描时如果正好赶上半截写操作读出来的最后一行往往是坏的。我的扫描逻辑里专门加了“最后一行不完整就跳过”的容错别小看这一行判断它救了我好多次。这个项目的源码并不复杂核心解析不到两百行Flask接口和前端页面加起来也就五百行左右。如果你愿意折腾一个晚上就能把它跑起来。真正有价值的地方在于你对自己AI编程助手的运行状态终于有了一个可量化的观察窗口。对于那些想进一步优化自己工作流的人来说这个起点应该够用了。