脱敏说明:本文涉及的内部项目以化名出现——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。第一反应是直接 sqlite3session 表的 tokens_* 列——结果报错。PRAGMA integrity_check 一跑,发现约 200 页损坏、丢了 14 条 message 记录。原因很快定位:这份拷贝是在数据库运行中复制的(带了 -wal/-shm),没做 checkpoint,落盘不一致。

运行中拷贝 opencode.db
带 WAL/SHM

integrity_check
报约 200 页损坏

.recover 导出 SQL

重建 recovered.db

integrity_check 通过
401 会话完整

恢复走的是标准流程:sqlite3 opencode.db ".recover" > recover.sql,再导入到新库。恢复后 401 个会话一条不少。

一个关键的口径判断:Token 计数取 session 表,而不是去 message 表逐条累加。原因是 session 表的累计值与 opencode 的 session.updated event 日志完全对齐——它就是 opencode 自己维护的权威计数。去 message 表累加不仅慢(上亿条),还容易在损坏/边界上出错。选对数据源,这笔账才立得住。

最终结果:

项目Token 数费用(元)
输入(缓存未命中)1,312,382,6941,312.38
输入(缓存命中)289,028,0965.78
输出6,188,596
推理(按输出计费)641,12413.66
合计1,608,240,5101,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/openaiClient.Chat / ChatWithImage / Embedding,以及 SmartRouter.SmartChat)。

业务服务层

文档分类

字段抽取

AI 审核

国产化报告

厂商匹配/归一化

源单提取

版面感知 OCR

OpenAI 兼容客户端
Chat / ChatWithImage / Embedding

SetUsageRecorder 钩子
成功/失败统一回调

LLMUsageRecord 表

这意味着只要在客户端层挂钩一次,就能覆盖全部业务,而不用去十几个服务文件里各贴一遍。这个判断省掉了一大半工作量,也避免了"漏改某个服务"的隐患。

4.2 模型与表设计

新增 LLMUsageRecord 模型,由框架自动建表。字段分三组:

  • 请求维度:调用服务(caller)、请求类型(chat/chat_with_image/embedding)、模型、服务地址、请求 ID、耗时
  • Token 维度:prompt/completion/reasoning/cached/total + 原始 raw_usage JSON
  • 结果维度:是否成功、错误信息

这里有个容易踩的坑:OpenAI 标准的 usage 只有 prompt_tokens/completion_tokens/total_tokens 三项,但 vLLM、DeepSeek、Qwen 各家扩展字段五花八门。所以我把原始 usage 子对象整段存进 raw_usage,结构化字段只解析常见的几个扩展(reasoning_tokensprompt_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 数
content79
reasoning_content592
两者合计671
API 返回的 completion_tokens674

差 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 回归纯净:只保留通用 UsageRecord DTO + SetUsageRecorder 钩子,ctx 原样透传,连原来的 WithCaller/CallerFromContext 也一并移出。
  • 应用层新建 llmctx:提供 WithCaller / WithBiz / WithTask,caller 与 biz 用独立的 context key,互不覆盖——这让分层注入成为可能:外层(任务入口)只注入 biz,内层(具体服务)只注入 caller,两边各管各的。

llmctx 该放哪? 起初放在 lab-aidoc 自己的业务包下。但很快发现另一个业务模块(API 服务层,源单提取等链路同样要标 caller/biz)也要用到它。一个被多个业务模块共用的上下文工具,不该隶属于任何一个业务模块。于是把它迁到通用工具位置 pkg/utils/llmctx——这是"SDK 不背业务"理念的延伸:业务标签工具本身也不该绑死在某个业务包里,它是基础设施,就该待在通用工具层。

SDK层

应用层

llmctx 包
WithCaller / WithBiz

Recorder
从 ctx 取 caller+biz 落库

OpenAI 兼容客户端
只透传 ctx + 回调 record

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_typebiz_id
版面感知 OCRocr_fileOcrFile.ID
文档分类(3 处)/ 字段抽取 / AI 审核structured_datadata.ID
审核:厂商匹配(MatchEnhanced / ReconcileDedup)structured_datadata.ID
审核:厂商 AI 补全 / 联网佐证manufacturermanufacturerID
国产化报告reportreportID
厂商归一化(含 ProcessMatch)manufacturerexisting.ID
源单提取(提取 + 厂商匹配)ocr_fileOcrFile.ID
OCR job 标准引擎路径ocr_fileOcrFile.ID
快速查询(验证 LLM)quick_query_sessionsessionID(提前分配)

这里有个提前分配 ID 的小技巧:快速查询的会话记录原本是验证通过后才创建的,但为了让 usage 能绑上,把 sessionID 的生成挪到了调用 LLM 之前。这种"为可观测性前置 ID 生成"的调整,比事后靠时间戳对账可靠得多。

真正拿不到任务 ID 的,最后收敛到只有两处,而且都不是最初设想的"批量",而是任务根本还不存在:

  1. 创建厂商前的重复检测:在厂商记录创建之前做的重名/相似检测,此时记录还没落库,只有请求里的厂商名。
  2. 后台 Embedding 索引刷新:维护性定时任务,不属于任何用户任务。

这两处只标 caller、biz 留空。原则没变——绝不伪造绑定:一个错误的绑定比没有绑定更糟,因为它会让后续的查询给出错误的归因。但"拿不到 ID"的原因,从最初假定的"批量场景",精确化成了"任务记录尚未创建"和"非用户任务"。这本身就是一次认知校准:先别假设"拿不到",顺着"数据先于调用存在"这条线去追,覆盖率往往比想象的高。

八、复盘

几条值得留给以后的:

  1. 选对数据源胜过写对算法。 算 token 账时直接用 session 表的权威计数,而不是去 message 表累加;记 usage 时挂在收敛的客户端层,而不是逐服务贴。两件事本质一样:找到那个"天然的单一收口点"。
  2. “字段为 0"不等于"没有”。 reasoning_tokens 的那次纠偏提醒我,跨厂商的 usage 语义不一致是常态,看到异常先验证上游原始返回,必要时自己拿 tokenizer 数。
  3. 第二版才解决准不准。 第一版先让数据落下来(“哪个服务花了多少”),有了数据才发现真正想要的是"哪个任务花了多少",这时候再做 biz 绑定。如果一开始就过度设计,反而可能因为不知道要绑什么而卡住。
  4. SDK 不背业务,业务工具也不该绑死业务包。 caller/biz 移到应用层后 SDK 干净了;后来发现另一个业务模块也要用 llmctx,于是把它再从业务包迁到通用的 pkg/utils。两次下移(SDK → 应用层 → 通用工具)是同一个理念的递进:谁都不该背不属于自己的概念
  5. 先别假设"拿不到"。 做 biz 绑定时,最初假设"批量场景拿不到唯一 ID",结果顺着"任务数据先于 LLM 调用存在"这条线一追,真正拿不到的只有"记录创建前的检测"和"非用户任务的维护任务"两处;把 ID 生成前置(如快速查询的 sessionID)还能再补上一类。遇到"拿不到"先验证,别让假设缩窄了覆盖范围。

下一步:按 biz 维度做一个用量看板(哪个文档/哪条审核最烧 token),让这张表真正产生决策价值,而不只是躺在数据库里。

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐