自从 OpenCode 升到 V2 之后,我在终端里写代码越来越依赖它,但很快就发现一个问题:我根本不知道自己这半天到底烧了多少 Token。OpenCode 原生界面里没有实时的 Token 速度、没有成本估算,连上下文缓存命中率这种直接影响结算的指标都只能靠猜。于是我花了一晚上写了个配套的小插件:读取 OpenCode 的运行日志,解析出每次 LLM 调用的 usage 信息,在前端页面上实时刷新显示 Token 消耗速度、累计消耗量和缓存命中率。这里把从方案选型到适配 V2 的完整过程梳理一遍,给想自己写 OpenCode 辅助工具的同学做个参考。
这个插件主要解决三个问题:第一,OpenCode 执行长任务时,缺少 Token 消耗的可视化监控,你盯着一个空转的终端窗口心里没底;第二,V2 版本改动导致旧的日志解析逻辑失效,需要一套能自动适配新旧格式的解析层;第三,需要一种够轻的方式随时查看,而不是每次打开一个冗长的统计文件去肉眼找数据。如果你在用 OpenCode 做日常开发、对 token 成本比较敏感,或者单纯想了解如何给一个 CLI 类 AI 工具做外围监控,这篇都值得看看。
1. 为什么偏要给 OpenCode 加一套外部监控
1.1 终端里的 Token 消耗是笔糊涂账
OpenCode 这个工具的核心体验是在终端里和模型连续对话,让它改代码、跑测试、分析报错。它的界面设计倾向于展示对话内容和代码 diff,指标信息被压缩到非常有限的位置。我在用 V1 的时候就忍了很久:往上翻历史输出偶尔能看到一行 cost,但没有持续刷新的概念。跑一个长任务,比如让模型批量重构几十个文件,可能持续半小时以上,这段时间里到底是在高速输出还是卡在思考环节,我完全不知道。
更麻烦的是成本口径。现在主流模型计费都分 input、output,还有 prompt 缓存的独立档位。缓存命中率高了,同样一段长上下文能便宜很多;命中率低了,等于每轮都在为重复的系统提示词付费。OpenCode 的会话通常会把项目结构、最近文件内容、工具调用历史全部塞进上下文,这些正是缓存优化最该发挥作用的区域。可官方终端里看不到命中率数字,我连自己当前会话处于高缓存收益还是低缓存收益状态都判断不了。
所以我的直接需求就是做一个外部监控面板,不需要改 OpenCode 本体,不依赖官方埋点,白天挂着浏览器标签页就能随时瞄一眼。
1.2 三个可选方案,最后为什么选日志解析
给 OpenCode 这类工具做监控,摆在面前的路大致有三条。我在动手前把优劣势列过一张表:
| 方案 | 侵入性 | 实时性 | 字段完整度 | 主要风险 |
|---|---|---|---|---|
| 抓取终端输出(截图 / tmux / ANSI 解析) | 低 | 高 | 低,屏幕输出不含结构化 usage | 终端文本混入换行和 ANSI 转义,解析极其脆弱 |
| 在 OpenCode 与 API 之间加 HTTP 代理 | 高 | 最高 | 中,只能看到请求响应头部字段 | 会接触敏感凭据,影响 TLS 信任链 |
| 解析 OpenCode 日志文件 | 低 | 中高 | 高,包含完整 usage 字段 | 日志格式会随版本变化 |
终端输出方案第一个被我否了。OpenCode 是一个全屏 TUI,输出里混着大量光标控制字符和渲染刷新序列,就算用 tmux pipe-pane 抓出来,时间戳和字段结构也完全不可靠。代理方案我没有任何犹豫直接排除:在模型 API 之间插一层,等于让自己写的脚本碰用户的 API key,一旦脚本有 bug,密钥泄露风险太不值得。日志解析是最自然的选择,OpenCode 本来就会落地写入调试日志,里面每次 LLM 调用都有结构化 JSON 记录,我只做只读 tail,不干预进程,风险最小。
方案定下来之后,剩下的问题就聚焦在两个:日志里的字段怎么归一化成统一的指标口径,以及怎么把文件流变成页面上的实时数字。这两块分别对应下面两节。
2. Token 速度与命中率的口径,先得把账算明白
2.1 从 usage 字段说起
OpenCode 的日志以 JSONL 为主,每次 LLM 调用结束,都会有一条包含请求元数据和用量数据的记录。我第一次打开日志文件时,看到的字段大致有:input_tokens、output_tokens、cache_read_input_tokens、cache_creation_input_tokens。听起来很好懂,但真正开始写解析器才发现坑不少。
最大的坑是不同模型服务商对字段的命名和语义不统一。Anthropic 风格的回复里,缓存拆成 cache_creation 和 cache_read 两个字段;OpenAI 兼容接口上经常叫 cached_tokens;还有些统一网关会把 usage 做成嵌套结构,input 内部再带上一次调用是否命中缓存的标记。另外,input_tokens这个值在不同服务商那里口径也不一样:有的已经包含了缓存命中的部分,有的则排除在外。如果不先做归一化,后面算命中率时会得到一个完全离谱的数字。
我第一版解析函数是这样的:
# opencode_v2_usage.py import json def parse_usage_line(raw_line: str) -> dict | None: try: data = json.loads(raw_line) except json.JSONDecodeError: return None # 不同版本的日志事件类型不一样,V1 用 type,V2 用 event if data.get("type") not in ("usage", "llm_call") and data.get("event") != "llm_usage": return None usage = data.get("usage") or data.get("tokens") or {} input_tokens = int(usage.get("input_tokens") or usage.get("prompt_tokens") or 0) output_tokens = int(usage.get("output_tokens") or usage.get("completion_tokens") or 0) cache_read = int( usage.get("cache_read_input_tokens") or usage.get("cache_read_tokens") or usage.get("cached_tokens") or 0 ) cache_write = int(usage.get("cache_creation_input_tokens") or 0) return { "input": input_tokens, "output": output_tokens, "cache_read": cache_read, "cache_write": cache_write, "model": data.get("model") or data.get("model_name") or "unknown", "session": data.get("session_id") or data.get("session", {}).get("id", "default"), "ts": data.get("ts") or data.get("timestamp") or 0, }这里有个经验:尽量只读原始字段,不要自己去加减。因为你并不知道上游在统计 input 时是否已经把 cache_read 算进去了。我踩过一次:某网关返回的 input_tokens 是 5000,cache_read 是 3000,看起来命中率是 3000/5000=60%,实际上它的语义是"未命中缓存的新输入是 5000,另外命中了 3000",真实命中率应该是 3000/8000=37.5%。这种差异用代码无法完全排除,只能靠统计几个请求后人工校准。
2.2 实时速度为什么不能用瞬时值
第一次看到速度曲线图时我差点以为写错了。模型输出时经常是一整段瞬间返回,可能 15 秒内吐出 18000 个 output token,那一个瞬间算出来的速度是 1200 tok/s;可下一段是模型在思考、在调工具,好几十秒一个 token 都没有,瞬时速度直接掉到 0。这种图挂在页面上基本等于心电图,完全没法看。
所以要的是平滑后的速度,我用的是最简单的滑窗均值:维护最近 60 秒内所有 usage 事件消耗的 token 总数,除以 60 秒。窗口内每 5 秒更新一次,可调参数。这个窗口长度是我试过的折中方案——太短的话峰值仍然压不住,太长的话又失去"实时刷新"的意义。
speed_tps = (input_tokens + output_tokens + cache_read + cache_write) / window_seconds把所有 token 类型都计进去,是因为上下文预填充也要花时间。用户关心的其实是模型吞吐的总量,而不是单分 output 的快慢。如果你只想看"生成速度",那就只统计 output,面板上我做了两个口径切换,默认展示总值。
2.3 命中率到底是什么
面板上的"命中率",我明确指的是 LLM 上下文缓存命中率,不是代码生成正确率,也不是 RAG 检索命中率。它定义成:
hit_rate = cache_read / (cache_read + non_cache_input) * 100%这里的 non_cache_input 是真正需要模型重新处理的输入 token。为什么这个指标值得单拎出来看?因为缓存命中的 token 计费大概只有不命中的十分之一。对于 OpenCode 这种会把项目结构、文件内容、历史工具调用堆进上下文的工具,会话越长,context 里重复的前缀越多,缓存命中率越值得关注。
我自己的经验数值是:新开会话前 5 到 10 分钟,命中率常常在 20% 以下;一旦同一个会话持续跑,系统提示词和项目前缀沉淀下来,命中率能稳定爬到 70% 以上。这直接决定了同样一轮长对话的成本差出 2 到 3 倍。如果没有实时数字,你根本感知不到这种变化,很容易在缓存已经养起来之后莫名开一个新会话,把优势全部扔掉。
3. 实时刷新链路:从 tail 日志到页面数字
3.1 采集端:tail -F 加累加器
采集端的核心逻辑特别简单:循环读取日志文件的新增内容,逐行解析,把有效 usage 事件丢进一个全局累加器。为了做滑窗平均,我用 collections.deque 保存最近 60 秒内的事件时间戳和 token 值,这样每次快照只需要遍历 deque 求和,不用记录全量会话数据。
# collector.py import time from collections import deque # 全局状态 window_deque = deque(maxlen=10000) total_input = 0 total_output = 0 total_cache_read = 0 def ingest_event(usage: dict): global total_input, total_output, total_cache_read total_input += usage["input"] total_output += usage["output"] total_cache_read += usage["cache_read"] window_deque.append({ "ts": usage["ts"], "tokens": usage["input"] + usage["output"] + usage["cache_read"] + usage["cache_write"], }) def snapshot(): # 清理 60 秒外的旧事件 cutoff = time.time() - 60 while window_deque and window_deque[0]["ts"] < cutoff: window_deque.popleft() tokens = sum(item["tokens"] for item in window_deque) return { "speed_tps": tokens / 60 if tokens else 0, "total_tokens": total_input + total_output + total_cache_read, "hit_rate": _hit_rate(), }这个脚本用tail -F的方式打开日志文件。-F会自动处理文件轮转,这对 V2 的场景很重要,因为 V2 的日志可能按会话归档,主日志文件会被重命名。Python 里我直接用循环读行,遇到 EOF 就 sleep 0.2 秒继续等,而不是真去调用系统 tail,这样能减少一个外部进程依赖。
3.2 SSE 推送而不是 WebSocket
数据从采集端到浏览器,我选了 Server-Sent Events。理由很实在:这是一个纯单向推送场景,SSE 走普通 HTTP,浏览器原生支持 EventSource,断线还能自动重连。WebSocket 能做到的双向能力在这里用不上,反而要自己处理心跳和连接状态,完全是给自己加戏。
服务端我用 FastAPI 写了十几行代码:
# server.py import json import asyncio from fastapi import FastAPI from fastapi.responses import StreamingResponse app = FastAPI() @app.get("/metrics/stream") async def metrics_stream(): async def gen(): while True: # 注意:这里对每个连接都构建一份 payload data = json.dumps(snapshot()) yield f"data: {data}\n\n" await asyncio.sleep(5) return StreamingResponse(gen(), media_type="text/event-stream")一个小细节:sse 流必须设置 media_type 为text/event-stream,否则前端 EventSource 不会触发 onmessage。另外每个连接都是独立的异步生成器,多开一个标签页就会多一个读队列,好在这点负载可以忽略。
3.3 前端展示:一个不引框架的 Dashboard
页面这部分我不想引入任何前端框架,一个 HTML 加几十行 JavaScript 就够。核心指标用卡片形式铺开,实时数据通过 EventSource 接住后直接改 DOM 文本。
<div class="metric-card"> <div class="metric-title">当前 Token 速度</div> <div class="metric-value" id="speed">--</div> <div class="metric-unit">tok/s</div> </div> <div class="metric-card"> <div class="metric-title">缓存命中率</div> <div class="metric-value" id="hit">--</div> <div class="metric-unit">%</div> </div> <div class="metric-card"> <div class="metric-title">累计消耗</div> <div class="metric-value" id="total">--</div> <div class="metric-unit">tokens</div> </div>const es = new EventSource("/metrics/stream"); es.onmessage = (e) => { const m = JSON.parse(e.data); document.getElementById("speed").textContent = m.speed_tps.toFixed(1); document.getElementById("hit").textContent = m.hit_rate.toFixed(1); document.getElementById("total").textContent = m.total_tokens.toLocaleString(); };页面上的指标口径我整理成了一张表,避免以后自己看懵:
| 指标 | 单位 | 展示口径 |
|---|---|---|
| 当前 Token 速度 | tok/s | 60 秒滑窗内 token 总量 / 60 |
| 累计消耗 Token | tokens | 采集进程启动后所有 usage 之和 |
| 缓存命中率 | % | cache_read / (cache_read + non_cache_input) |
| 当前模型 | string | 最后一次 usage 事件的 model 字段 |
| 会话 ID | string | 当前聚合的 session id,可切换 |
前端遇到 SSE 断线重连时,我会先拉一次/metrics/summary接口,把累计值校准一下,避免只靠增量推送导致页面数值和真实值越差越多。这是从实际使用里学到的,浏览器标签页挂久了,EventSource 一旦重连,中间丢掉的几帧数据会让 total 永久偏小。
4. 适配 V2:这次升级到底改了多少东西
4.1 日志路径和轮转方式变了
V1 时期,日志文件的路径是固定的,每天一个文件,路径不会变化,tail -F 一挂就是一天。V2 升级之后,我照常启动采集器,结果页面上一片空白,检查发现日志文件路径变了,V2 不再把内容写在原来的全局文件里,而是按会话归档到独立目录。
我现在的处理方式比较务实:启动时扫描日志目录,按文件修改时间排序,取最新的一个文件作为候选;同时支持命令行参数--log-dir覆盖。文件轮转的场景也被接住了——V2 会在会话归档时生成新文件,旧文件被重命名,因此我在读取循环里定期检查当前文件的大小和 inode,发现变化就重新打开。Python 里判断 inode 用os.stat(path).st_ino,跨平台也能用。
# file_watcher.py import glob import os def pick_latest_log(log_dir: str) -> str: files = glob.glob(os.path.join(log_dir, "*.log")) + glob.glob(os.path.join(log_dir, "*.ndjson")) if not files: raise RuntimeError(f"no log files in {log_dir}") latest = max(files, key=os.path.getmtime) return latest这个逻辑不完美,但足够皮实。它不会因为某个历史会话文件被重新归档而读错,因为 mtime 最大的永远是最新的活动文件。
4.2 usage 字段命名与层级变化
V2 最折磨人的改动是事件字段的重命名。V1 里我匹配的是type: "usage",V2 改成了event: "llm_usage",而且 model 字段从model变成了model_name,session_id 从顶层挪进了session.id。如果不做兼容,解析器会静默地漏掉所有事件,页面自然全零。
我把新旧字段对应关系整理成了表,写进代码注释:
| 含义 | V1 日志字段 | V2 日志字段 |
|---|---|---|
| 事件标识 | type: "usage" | event: "llm_usage" |
| 输入 token | usage.input_tokens | usage.input_tokens |
| 输出 token | usage.output_tokens | usage.output_tokens |
| 缓存读取 | usage.cache_read_input_tokens | usage.cache_read_input_tokens |
| 缓存写入 | usage.cache_creation_input_tokens | usage.cache_creation_input_tokens |
| 模型名 | model | model_name |
| 会话 ID | session_id | session.id |
为了不写两套臃肿的解析代码,我用了一个轻量适配层:读日志第一行有效 JSON 后,判断它是 V1 还是 V2 格式,然后在解析函数里按版本走不同的取值路径。这个判断只做一次,后面所有行都复用同一个解析策略。这样就算哪天 OpenCode 出了 V2 的补丁版本,日志结构没变,插件就不用动。
4.3 并发会话导致指标串台
V2 允许同一时间跑多个任务,这在能力上是进步,对我的监控脚本却是坑:多个 session 会往同一个日志文件里写事件,如果我的全局累加器不分 session,数字会全部混在一起。比如一边开着一个长文档分析会话,一边跑着代码重构,页面上显示的"累计消耗"毫无意义,因为你分不清哪部分在哪。
我的解法是让采集端的全局状态从"单组累加器"变成"按 session 分组的字典",页面顶部加一个会话下拉选择器。默认选择"全部聚合",但大多数时候我会选择当前活跃会话。这个分组逻辑其实很简单,套一层 dict 就行,但确实是我适配 V2 过程中花时间最多的地方——不是技术难,是容易漏。如果一个 session 的 usage 事件混进了另一个 session 的窗口,速度曲线的毛刺会很明显,排查起来十分痛苦。
5. 90 分钟实测数据与调优记录
5.1 一次真实会话的指标参考
插件跑稳之后,我记录了一次完整的 90 分钟代码重构会话,用表格复盘:
| 指标 | 数值 | 备注 |
|---|---|---|
| 会话时长 | 90 分钟 | 涉及多个文件的重构 |
| 累计 Token | 25.8 万 | input 19.4 万 + output 6.4 万 |
| 平均 Token 速度 | 47 tok/s | 60 秒滑窗 |
| 瞬时峰值 | 210 tok/s | 多段连续输出叠加 |
| 缓存命中率(整体) | 72% | 前 30 分钟明显低 |
| 模型切换次数 | 3 次 | 跨会话模式手动切换 |
有个值得说的现象:前 30 分钟的命中率只有 30% 左右,后面稳定到 75% 上下。这就是上下文缓存的"预热期"。系统提示词加项目前缀第一次完整处理时不命中,之后每次重复都命中。如果不看这个曲线,我根本不会意识到预热阶段的存在,只会觉得"为什么这次会话的 API 账单这么高"。
5.2 几个值得注意的坑
日志里不一定默认有 usage 事件。我一开始配置的 OpenCode 启动参数比较保守,日志里只有普通对话流,根本没有结构化 usage 行。后来确认需要打开对应的诊断配置,V2 里有一个
--show-cost类的界面开关,但日志层面的用量输出由配置文件里的诊断级别决定。如果你复现时发现解析器一直拿不到数据,先确认日志里到底有没有llm_usage字样,别在解析代码上空耗。文件尾部容易读到半个 JSON。日志是边写边落的,我用循环 readline 时可能读到被截断的半行,直接 json.loads 会抛异常。处理办法很简单:解析失败就跳过这一行,等下一轮读文件时再重新读同一行。前提是你要用流式指针记录文件偏移,不能每次打开都从头读。
Windows 下文件句柄问题。在 Windows 上,日志文件被轮转或归档时,旧的打开句柄可能指向一个已被占用的文件,后续读不到新内容。我的做法是定时检查文件大小和 inode(Windows 上可以比较 file_id),发现异常就关闭重开。跨平台的第一版脚本在 macOS 上没问题,拿到 Windows 上就要补这段逻辑。
时间窗口的取舍。窗口太短,速度曲线在峰值和低谷之间剧烈抖动;窗口太长,实时性名存实亡。我把窗口默认设成 60 秒,每 5 秒推送一次,这是一个大家都能接受的折中值。如果你的使用场景是看"此刻模型是不是卡住了",那 60 秒窗口会掩盖单次长等待,建议再加一个"最近 30 秒无输出"的呼吸灯提示,比单纯看曲线直观得多。
5.3 监控反过来改变了我的使用习惯
装上这个插件,最大的收获不只是一个数字面板,而是它反过来改变了我的使用方式。以前我是"一个会话从头跑到尾,卡住就 Ctrl+C 重来",往往会话刚开始崩溃一次,缓存白建,成本白花。现在我看到命中率曲线,会刻意避免在预热期反复打断,也会在命中率已经很高的时候尽量保持对话连续性,而不是为了"清爽"就随手New Session,把辛苦养起来的缓存全部清零。
面板还让"模型成本对比"变得顺手。OpenCode 可以按场景切换模型,我根据日志里的 model 字段按天分组统计,能清楚看到每个模型在 input、cache_read、output 上的分布。这几天我正在做一个按模型分段计价的小扩展,争取把每一轮会话的估算成本也展示到页面上。有了这次适配 V2 的解析层,后面再加这类功能基本就是往上叠数据源,不需要再动核心链路。