news 2026/11/3 21:06:15

OpenCode Token监控实战:从日志解析到实时速度与缓存命中率

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
OpenCode Token监控实战:从日志解析到实时速度与缓存命中率

自从 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/s60 秒滑窗内 token 总量 / 60
累计消耗 Tokentokens采集进程启动后所有 usage 之和
缓存命中率%cache_read / (cache_read + non_cache_input)
当前模型string最后一次 usage 事件的 model 字段
会话 IDstring当前聚合的 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"
输入 tokenusage.input_tokensusage.input_tokens
输出 tokenusage.output_tokensusage.output_tokens
缓存读取usage.cache_read_input_tokensusage.cache_read_input_tokens
缓存写入usage.cache_creation_input_tokensusage.cache_creation_input_tokens
模型名modelmodel_name
会话 IDsession_idsession.id

为了不写两套臃肿的解析代码,我用了一个轻量适配层:读日志第一行有效 JSON 后,判断它是 V1 还是 V2 格式,然后在解析函数里按版本走不同的取值路径。这个判断只做一次,后面所有行都复用同一个解析策略。这样就算哪天 OpenCode 出了 V2 的补丁版本,日志结构没变,插件就不用动。

4.3 并发会话导致指标串台

V2 允许同一时间跑多个任务,这在能力上是进步,对我的监控脚本却是坑:多个 session 会往同一个日志文件里写事件,如果我的全局累加器不分 session,数字会全部混在一起。比如一边开着一个长文档分析会话,一边跑着代码重构,页面上显示的"累计消耗"毫无意义,因为你分不清哪部分在哪。

我的解法是让采集端的全局状态从"单组累加器"变成"按 session 分组的字典",页面顶部加一个会话下拉选择器。默认选择"全部聚合",但大多数时候我会选择当前活跃会话。这个分组逻辑其实很简单,套一层 dict 就行,但确实是我适配 V2 过程中花时间最多的地方——不是技术难,是容易漏。如果一个 session 的 usage 事件混进了另一个 session 的窗口,速度曲线的毛刺会很明显,排查起来十分痛苦。


5. 90 分钟实测数据与调优记录

5.1 一次真实会话的指标参考

插件跑稳之后,我记录了一次完整的 90 分钟代码重构会话,用表格复盘:

指标数值备注
会话时长90 分钟涉及多个文件的重构
累计 Token25.8 万input 19.4 万 + output 6.4 万
平均 Token 速度47 tok/s60 秒滑窗
瞬时峰值210 tok/s多段连续输出叠加
缓存命中率(整体)72%前 30 分钟明显低
模型切换次数3 次跨会话模式手动切换

有个值得说的现象:前 30 分钟的命中率只有 30% 左右,后面稳定到 75% 上下。这就是上下文缓存的"预热期"。系统提示词加项目前缀第一次完整处理时不命中,之后每次重复都命中。如果不看这个曲线,我根本不会意识到预热阶段的存在,只会觉得"为什么这次会话的 API 账单这么高"。

5.2 几个值得注意的坑

  1. 日志里不一定默认有 usage 事件。我一开始配置的 OpenCode 启动参数比较保守,日志里只有普通对话流,根本没有结构化 usage 行。后来确认需要打开对应的诊断配置,V2 里有一个--show-cost类的界面开关,但日志层面的用量输出由配置文件里的诊断级别决定。如果你复现时发现解析器一直拿不到数据,先确认日志里到底有没有llm_usage字样,别在解析代码上空耗。

  2. 文件尾部容易读到半个 JSON。日志是边写边落的,我用循环 readline 时可能读到被截断的半行,直接 json.loads 会抛异常。处理办法很简单:解析失败就跳过这一行,等下一轮读文件时再重新读同一行。前提是你要用流式指针记录文件偏移,不能每次打开都从头读。

  3. Windows 下文件句柄问题。在 Windows 上,日志文件被轮转或归档时,旧的打开句柄可能指向一个已被占用的文件,后续读不到新内容。我的做法是定时检查文件大小和 inode(Windows 上可以比较 file_id),发现异常就关闭重开。跨平台的第一版脚本在 macOS 上没问题,拿到 Windows 上就要补这段逻辑。

  4. 时间窗口的取舍。窗口太短,速度曲线在峰值和低谷之间剧烈抖动;窗口太长,实时性名存实亡。我把窗口默认设成 60 秒,每 5 秒推送一次,这是一个大家都能接受的折中值。如果你的使用场景是看"此刻模型是不是卡住了",那 60 秒窗口会掩盖单次长等待,建议再加一个"最近 30 秒无输出"的呼吸灯提示,比单纯看曲线直观得多。

5.3 监控反过来改变了我的使用习惯

装上这个插件,最大的收获不只是一个数字面板,而是它反过来改变了我的使用方式。以前我是"一个会话从头跑到尾,卡住就 Ctrl+C 重来",往往会话刚开始崩溃一次,缓存白建,成本白花。现在我看到命中率曲线,会刻意避免在预热期反复打断,也会在命中率已经很高的时候尽量保持对话连续性,而不是为了"清爽"就随手New Session,把辛苦养起来的缓存全部清零。

面板还让"模型成本对比"变得顺手。OpenCode 可以按场景切换模型,我根据日志里的 model 字段按天分组统计,能清楚看到每个模型在 input、cache_read、output 上的分布。这几天我正在做一个按模型分段计价的小扩展,争取把每一轮会话的估算成本也展示到页面上。有了这次适配 V2 的解析层,后面再加这类功能基本就是往上叠数据源,不需要再动核心链路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/3 21:05:03

MTBF计算书深度拆解:失效率累加模型与π系数修正机制

简介&#xff1a;这份MTBF计算书面向可靠性工程师、硬件设计人员及电子产品质量评估学习者&#xff0c;以金属膜电阻器为切入点&#xff0c;完整演示平均故障间隔时间的计算流程。内容围绕失效率公式λpλbπEπCVπQ展开&#xff0c;逐项列出各型号电阻的基本失效率、环境系数…

作者头像 李华
网站建设 2026/11/3 21:04:11

Oracle AI Database 26ai本地部署版核心技术解析与应用实践

1. Oracle AI Database 26ai本地服务器版发布背景作为一名数据库行业从业者&#xff0c;我清楚地记得三年前Oracle首次发布AI Database概念时的场景。当时Oracle在OpenWorld大会上展示的AI与数据库融合的愿景&#xff0c;让整个行业为之震动。而今天&#xff0c;我们终于迎来了…

作者头像 李华
网站建设 2026/11/3 21:02:53

Paperclip:面向AI Agent的Node.js+React轻量运行时框架

1. 项目概述&#xff1a;Paperclip 不是回形针&#xff0c;而是一个面向 AI Agent 开发的轻量级 Node.js 运行时框架你搜“paperclip”时&#xff0c;第一反应可能是办公桌抽屉里那枚银色小金属件——但最近在掘金、GitHub 和 React 前端技术群聊里&#xff0c;“Paperclip”正…

作者头像 李华
网站建设 2026/11/3 21:02:31

把后见之明变成系统能力:三层拆解法的复盘方法论

接手上一个项目复盘会的时候&#xff0c;我盯着白板上那句“下次一定要提前确认接口联调时间”&#xff0c;突然意识到一个扎心的事实&#xff1a;这句话我们上上个项目就在写&#xff0c;但这次依然踩了同一个坑。不是大家不想改&#xff0c;而是“事后看明白”这件事本身&…

作者头像 李华
网站建设 2026/11/3 21:02:28

微信小程序事件传参原理与实战技巧

1. 微信小程序事件传参核心原理剖析在小程序开发中&#xff0c;事件传参是组件间通信的基石。与网页开发不同&#xff0c;小程序采用独特的双线程架构&#xff08;视图层WebView与逻辑层JavascriptCore&#xff09;&#xff0c;导致传统DOM事件机制无法直接使用。微信团队为此设…

作者头像 李华
网站建设 2026/11/3 21:02:04

SQL Server聊天记录查询实战:从索引设计到性能调优

数据库里翻聊天记录这件事&#xff0c;我做过不止一次。最早是帮一个做社群运营的朋友整理他手里几个客户群的互动数据&#xff0c;后来陆续又碰到售后团队要追溯工单对话、项目组要复盘协作过程里的关键决策。这些场景的共同点是&#xff1a;数据都躺在某个关系型数据库里&…

作者头像 李华