给 LLM 调用装上“流量计“:Token 用量存储与费用核算的一次实践
脱敏说明:本文涉及的内部项目以化名出现——
lab-aidoc(文档智能后端,Go)、桌面端(Electron 壳)、自建推理平台(vLLM)。opencode、DeepSeek、Qwen、GLM、PaddleOCR-VL、SQLite、阿里云百炼(DashScope)等开源/公开技术名词保留原称(均为公开服务,不指向具体身份)。文中的费用、Token 数字为真实核算结果。
这天的任务表面上是"算一笔账",但顺手把它演变成了一个"给线上所有 LLM 调用装流量计"的小工程。整条线从一次 SQLite 数据恢复开始,最后落到一个分层清晰、能精确对应到每条业务任务的用量记录表。值得记下来的是中间几个判断和转折,而不是最后那几行代码。
一、今日概览
| 阶段 | 事项 | 类型 |
|---|---|---|
| 核算 | 从导出的 opencode 会话库核算 Token 消耗与费用 | 数据处理 |
| 核算 | 1.1GB SQLite 运行中拷贝损坏,做 .recover 恢复 | 排障 |
| 落地 | 给 lab-aidoc 新增 LLMUsageRecord 表,记录每次 LLM 调用的用量 | 功能 |
| 落地 | OpenAI 兼容客户端层挂钩,成功/失败均落库 | 重构 |
| 调研 | 确认 PaddleOCR-VL 本地调用不返回 usage | 调研 |
| 排障 | 解释 reasoning_tokens 为何常为 0(上游不拆分) | 排障 |
| 演进 | SDK 纯净化 + llmctx(迁至 pkg/utils),用量精确绑定到业务任务 | 重构 |
二、起点:opencode 的会话数据存在哪
事情从一个问题开始:桌面端用 opencode server 跑爬虫任务,它的会话数据存在 Windows 的哪个路径?
我一开始本能地以为会跟应用数据放一起(%APPDATA%\应用名\),去翻代码才发现并非如此——启动 opencode server 时只重定向了配置目录(OPENCODE_CONFIG_DIR 指向应用自己的 config),并没有重定向 XDG_DATA_HOME。也就是说,会话数据走的是 opencode 自己的默认全局数据目录:
%USERPROFILE%\.local\share\opencode\opencode.db
这个判断不是拍脑袋,是从锁定的 opencode v1.17.11 二进制里确认的路径逻辑:数据目录 = $XDG_DATA_HOME 或 ~/.local/share,再拼 opencode。这里有一个容易被忽略的副作用:所有跑过 opencode 的机器共用同一个 opencode.db,看到的是全部历史会话。这个细节后面会再踩一次。
三、第一笔账:1.1GB 的 opencode.db 与一次意外损坏
把生产环境的 opencode 对话数据导出后,需求很直接:按 deepseek-v4-flash 的价格(缓存命中 0.02 元/百万、缓存未命中 1 元/百万、输出 2 元/百万),算出每个会话的 Token 消耗和费用,再给个总量。
拿到的是个 1.1GB 的 SQLite。第一反应是直接 sqlite3 查 session 表的 tokens_* 列——结果报错。PRAGMA integrity_check 一跑,发现约 200 页损坏、丢了 14 条 message 记录。原因很快定位:这份拷贝是在数据库运行中复制的(带了 -wal/-shm),没做 checkpoint,落盘不一致。
恢复走的是标准流程:sqlite3 opencode.db ".recover" > recover.sql,再导入到新库。恢复后 401 个会话一条不少。
一个关键的口径判断:Token 计数取 session 表,而不是去 message 表逐条累加。原因是 session 表的累计值与 opencode 的 session.updated event 日志完全对齐——它就是 opencode 自己维护的权威计数。去 message 表累加不仅慢(上亿条),还容易在损坏/边界上出错。选对数据源,这笔账才立得住。
最终结果:
| 项目 | Token 数 | 费用(元) |
|---|---|---|
| 输入(缓存未命中) | 1,312,382,694 | 1,312.38 |
| 输入(缓存命中) | 289,028,096 | 5.78 |
| 输出 | 6,188,596 | — |
| 推理(按输出计费) | 641,124 | 13.66 |
| 合计 | 1,608,240,510 | 1,331.82 |
401 个会话,平均每会话 3.32 元,单会话最高 14.35 元。一个有意思的观察:缓存命中量(2.89 亿)相对未命中(13.1 亿)占比很小,说明这套调用模式的 prompt 复用率不高(爬虫任务每次上下文差异大),缓存几乎没省到钱。这个结论比账面数字更有价值——它直接指向"如果想降本,得从 prompt 结构化复用入手"。
四、把"算账"变成"记账":给 lab-aidoc 装流量计
算完别人的账,自然就想到:我们自己的 lab-aidoc 后端每天都在调一堆 LLM(文档分类、字段抽取、AI 审核、国产化报告、厂商匹配……),却没有任何一条记录能回答"今天哪个服务、调了哪个模型、花了多少 Token"。这笔糊涂账不能再继续。
4.1 关键决策:在客户端层挂钩,而不是逐个服务改
第一反应是"在哪些服务里加记录"。但我先把所有 LLM 调用点列了一遍,发现一个事实:所有调用都收敛到同一个 OpenAI 兼容客户端(third_party/llm/openai 的 Client.Chat / ChatWithImage / Embedding,以及 SmartRouter.SmartChat)。
这意味着只要在客户端层挂钩一次,就能覆盖全部业务,而不用去十几个服务文件里各贴一遍。这个判断省掉了一大半工作量,也避免了"漏改某个服务"的隐患。
4.2 模型与表设计
新增 LLMUsageRecord 模型,由框架自动建表。字段分三组:
- 请求维度:调用服务(caller)、请求类型(chat/chat_with_image/embedding)、模型、服务地址、请求 ID、耗时
- Token 维度:prompt/completion/reasoning/cached/total + 原始
raw_usageJSON - 结果维度:是否成功、错误信息
这里有个容易踩的坑:OpenAI 标准的 usage 只有 prompt_tokens/completion_tokens/total_tokens 三项,但 vLLM、DeepSeek、Qwen 各家扩展字段五花八门。所以我把原始 usage 子对象整段存进 raw_usage,结构化字段只解析常见的几个扩展(reasoning_tokens、prompt_tokens_details.cached_tokens),其余的以后需要再从 raw 里捞。这比"猜全所有字段"稳得多。
4.3 成功要记,失败更要记
记录逻辑挂在 doChatRequest / embeddingOnce 的包装层,无论成功失败都回调。失败时 token 全记 0,但错误信息、耗时、模型名照样留。这里有个明确的边界:只有真正发出 HTTP 请求后的失败才记——model is required 这种调用前校验错误不记,因为压根没有 LLM 请求发生,也没有任何 token 可言。
写入用 best-effort:落库失败只打日志,绝不让记账逻辑拖垮业务。这一点很重要——用量记录是"锦上添花",不能变成新的故障点。
一个验证时顺带抓到的认知:写完不等于对。我特意写了成功/失败/Embedding 三个单测,用本地回环 HTTP 模拟。测试不是为了走流程,是为了把"失败时 token 为 0 但有 error_msg"这个契约钉死。
4.4 发版前的一句"等下"
代码写完、测试通过、准备 commit 发版时,我多问了一句:“这个改动会影响之前流程的数据存储吗?”——这是个值得每次发版前都问一句的问题。结论是纯增量:新表由 AutoMigrate 只创建不修改、写入 best-effort、对客户端唯一的行为变化是把流式解码改成 io.ReadAll 读完再解析(对非流式响应等价)。确认无侵入后才放心发版。
五、PaddleOCR-VL:一个"查不到 usage"的调研
需求里特别问了一句:PaddleOCR-VL 的本地调用(非云端)会不会返回 usage?
我把它的响应类型(InferResponse/InferResult)翻了个遍——只有 layoutParsingResults(markdown/图片)和 dataInfo(图片/PDF 尺寸),没有任何 token 字段。它本身就不是 OpenAI 兼容接口,所以这次用量记录覆盖不到它。
顺带发现一个更有用的点:用户给的最新 OpenAPI spec 比我们 Go 客户端新——缺 exports(docx 导出)、TIFF 支持、/restructure-pages 端点(页面重建后处理)。其中 restructure-pages 是个值得单独理解的能力:它在多页 PDF 推理后做跨页整合(合并跨页表格 merge_tables、重建多级标题 relevel_titles),是纯 CPU 后处理、不跑 VLM,所以本身不产生 token。这个调研结论同时回答了"为什么它没有 usage"。
六、reasoning_tokens 为什么是 0:一次认知纠偏
表上线后,一个反直觉的现象:版面感知 OCR 用的明明是推理模型(qwen3.7-plus,开了 enable_thinking),可记录里 reasoning_tokens 却是 0。
我一开始怀疑是解析 bug,但很快排除了——不是没推理,是这个接口的 usage 里压根不单独上报推理 token。DashScope 的 OpenAI 兼容接口返回的 usage 只有标准三项,推理 token 已经被算进 completion_tokens 里了,只是不拆给你。
为了把这件事钉死,我用本地存的 Qwen3.6 tokenizer 对一条真实响应做了实测:
| 部分 | Token 数 |
|---|---|
| content | 79 |
| reasoning_content | 592 |
| 两者合计 | 671 |
| API 返回的 completion_tokens | 674 |
差 3 个 token 是模板/结束标记(<|im_end|> 等)。结论非常清楚:completion_tokens 是 content + reasoning_content 的合并值,reasoning_tokens: 0 不是"没推理",而是上游没拆分。跟 DeepSeek 不同——DeepSeek 会在 usage 里返回独立的 reasoning_tokens,所以那边能记上。
这件事的教训是:"字段为 0"和"没有这项能力"是两回事。看到 0 先别急着当 bug,先看上游到底返没返这个字段,必要时拿 tokenizer 自己数一遍对账。
七、从"哪个服务"到"哪个任务":一次必要的演进
表跑起来后,发现一个新问题:能回答"哪个服务调了哪个模型、花了多少 token",但回答不了"这条 usage 是哪一次 OCR 任务、哪个文档、哪条审核产生的"。表里没有任何业务主键,只能靠时间戳去跟别的表对——并发场景下基本不可靠。
这是个典型的"第一版只解决有没有,第二版才解决准不准"。于是做了一轮重构。
7.1 关键取舍:SDK 保持纯净,业务标签全部移到应用层
最初 caller(调用服务名)是写在 openai SDK 里的(WithCaller)。重构时面临一个选择:biz(业务 ID)也塞进 SDK 吗?
结论是不。 SDK 是个通用客户端,不该背任何业务概念。所以:
- SDK 回归纯净:只保留通用
UsageRecordDTO +SetUsageRecorder钩子,ctx 原样透传,连原来的WithCaller/CallerFromContext也一并移出。 - 应用层新建
llmctx包:提供WithCaller/WithBiz/WithTask,caller 与 biz 用独立的 context key,互不覆盖——这让分层注入成为可能:外层(任务入口)只注入 biz,内层(具体服务)只注入 caller,两边各管各的。
llmctx 该放哪? 起初放在 lab-aidoc 自己的业务包下。但很快发现另一个业务模块(API 服务层,源单提取等链路同样要标 caller/biz)也要用到它。一个被多个业务模块共用的上下文工具,不该隶属于任何一个业务模块。于是把它迁到通用工具位置 pkg/utils/llmctx——这是"SDK 不背业务"理念的延伸:业务标签工具本身也不该绑死在某个业务包里,它是基础设施,就该待在通用工具层。
7.2 type + id 两段式绑定
biz_type(任务实体类型)+ biz_id(那条任务记录自己的主键)。查某条任务的全部 LLM 消耗 = WHERE biz_type='structured_data' AND biz_id='123'。biz_id 统一用字符串,因为既有数字主键也有 UUID。
一个关键认知帮了大忙:任务数据先存库、再调 LLM。既然任务记录一定先于 LLM 调用存在,那调用时几乎总能拿到对应的任务 ID。顺着这条线把所有调用点追了一遍,能绑定的比预想的多得多:
| 调用点 | biz_type | biz_id |
|---|---|---|
| 版面感知 OCR | ocr_file | OcrFile.ID |
| 文档分类(3 处)/ 字段抽取 / AI 审核 | structured_data | data.ID |
| 审核:厂商匹配(MatchEnhanced / ReconcileDedup) | structured_data | data.ID |
| 审核:厂商 AI 补全 / 联网佐证 | manufacturer | manufacturerID |
| 国产化报告 | report | reportID |
| 厂商归一化(含 ProcessMatch) | manufacturer | existing.ID |
| 源单提取(提取 + 厂商匹配) | ocr_file | OcrFile.ID |
| OCR job 标准引擎路径 | ocr_file | OcrFile.ID |
| 快速查询(验证 LLM) | quick_query_session | sessionID(提前分配) |
这里有个提前分配 ID 的小技巧:快速查询的会话记录原本是验证通过后才创建的,但为了让 usage 能绑上,把 sessionID 的生成挪到了调用 LLM 之前。这种"为可观测性前置 ID 生成"的调整,比事后靠时间戳对账可靠得多。
真正拿不到任务 ID 的,最后收敛到只有两处,而且都不是最初设想的"批量",而是任务根本还不存在:
- 创建厂商前的重复检测:在厂商记录创建之前做的重名/相似检测,此时记录还没落库,只有请求里的厂商名。
- 后台 Embedding 索引刷新:维护性定时任务,不属于任何用户任务。
这两处只标 caller、biz 留空。原则没变——绝不伪造绑定:一个错误的绑定比没有绑定更糟,因为它会让后续的查询给出错误的归因。但"拿不到 ID"的原因,从最初假定的"批量场景",精确化成了"任务记录尚未创建"和"非用户任务"。这本身就是一次认知校准:先别假设"拿不到",顺着"数据先于调用存在"这条线去追,覆盖率往往比想象的高。
八、复盘
几条值得留给以后的:
- 选对数据源胜过写对算法。 算 token 账时直接用
session表的权威计数,而不是去 message 表累加;记 usage 时挂在收敛的客户端层,而不是逐服务贴。两件事本质一样:找到那个"天然的单一收口点"。 - “字段为 0"不等于"没有”。 reasoning_tokens 的那次纠偏提醒我,跨厂商的 usage 语义不一致是常态,看到异常先验证上游原始返回,必要时自己拿 tokenizer 数。
- 第二版才解决准不准。 第一版先让数据落下来(“哪个服务花了多少”),有了数据才发现真正想要的是"哪个任务花了多少",这时候再做 biz 绑定。如果一开始就过度设计,反而可能因为不知道要绑什么而卡住。
- SDK 不背业务,业务工具也不该绑死业务包。 caller/biz 移到应用层后 SDK 干净了;后来发现另一个业务模块也要用 llmctx,于是把它再从业务包迁到通用的
pkg/utils。两次下移(SDK → 应用层 → 通用工具)是同一个理念的递进:谁都不该背不属于自己的概念。 - 先别假设"拿不到"。 做 biz 绑定时,最初假设"批量场景拿不到唯一 ID",结果顺着"任务数据先于 LLM 调用存在"这条线一追,真正拿不到的只有"记录创建前的检测"和"非用户任务的维护任务"两处;把 ID 生成前置(如快速查询的 sessionID)还能再补上一类。遇到"拿不到"先验证,别让假设缩窄了覆盖范围。
下一步:按 biz 维度做一个用量看板(哪个文档/哪条审核最烧 token),让这张表真正产生决策价值,而不只是躺在数据库里。
更多推荐


所有评论(0)