Skip to content

fix(stats): 流式 usage 落库修复,统计改按模型配置分组并新增合并开关 - #1497

Open
542474846 wants to merge 3 commits into
AAswordman:devfrom
542474846:feat/token-stats-grouping
Open

542474846 wants to merge 3 commits into
AAswordman:devfrom
542474846:feat/token-stats-grouping

Conversation

@542474846

Copy link
Copy Markdown

变更说明 / Description

背景与动机 / Context and motivation

应用内排查(某模型成功生成却完全不出现在 token 统计里)确认了根因链:OPENAI_GENERIC / OPENAI_LOCAL 的流式请求不携带 stream_options.include_usage,按 OpenAI 规范省略 usage 分块的端点就不会回传用量;applyUsageToCounters 解析失败即静默返回,RequestTracker.finish() 拿不到成功 attempt 的快照后返回 null,落库被静默跳过且没有任何日志。标题与总结这类辅助请求走 stream=false,响应必带 usage 因而总能落库,于是表现为"部分模型统计不到"。

另一半是展示口径:统计页把所有记录仅按模型名合并,同一模型走不同渠道(模型配置)的用量混在一起,无法区分渠道成本。

改动范围 / Changes

  • 工厂的 OPENAI_GENERIC / OPENAI_LOCAL 分支显式传 includeUsageInStream = true,与官方 OPENAI 类型及 Kimi / DeepSeek 的既有做法对齐;不为个别端点做试探或降级
  • RequestTracker.finish() 的四个跳过分支全部留痕(取消为 debug 级,未收到完成回调 / 成功 attempt 无 usage 上报 / 快照字段全未知为 warning 级,带 configId 与 providerModel);usage 非 null 但结构不识别时输出字段名列表
  • 新增 TokenStatsGrouping(CONFIG_MODEL 默认 / MERGED_MODEL)经查询参数透传 lifetime 与 range;CONFIG_MODEL 按 config:{configId}|model:{model} 划分并暴露组内配置,legacy 导入的空 configId 记录独立成组
  • ViewModel 收集三处查询结果的配置 ID 拼装"配置名/模型名",已删除配置沿用既有文案;合并分组与 legacy 记录保持纯模型名
  • 范围分析卡新增"合并相同模型"开关:开启按模型名合并,关闭按配置名/模型;切换时清空模型筛选(displayModelId 语义随分组变化)并把选择持久化到 token_stats_preferences
  • strings 补齐 8 个语言目录

不包含:Room 表结构(records 本就带 configId 列,纯展示层改造)、取消请求不落库的既有约定、配置详情卡(原本就是配置粒度)。

兼容性与风险 / Compatibility and risks

  • 请求体变化:OPENAI_GENERIC / OPENAI_LOCAL 的流式请求新增 stream_options.include_usage,少数拒绝未知参数的端点会返回 400,属于端点不符合 OpenAI 规范
  • 统计页默认展示口径变化:模型维度从"按模型名合并"改为"按模型配置 + 模型",同名模型跨渠道不再合并;开关可切回原口径
  • 历史记录无需迁移:legacy 导入记录 configId 为空,单独成组并按模型名显示
  • 无数据库写入语义变化,无 Room 迁移

关联 Issue / Related issue

N/A。排查过程与实施记录见 docs/TODO/token_stats_config_grouping_20261113/。

验证方式 / Verification

检查或命令:./gradlew :app:compileDebugKotlin
            ./gradlew :app:testDebugUnitTest --tests "*TokenStatsDisplayGroupingTest"
环境与变体:Android debug 变体
结果:`:app:compileDebugKotlin` 通过(exit 0)。环境:JAVA_HOME 指向 Android Studio JBR,Gradle 8.13 / AGP 8.13.2,debug 变体;排除 `:app:syncSttModelAssets`(该任务每次强制重新下载 STT 模型,本机到下载源不可达会以 ConnectException 中断,与 Kotlin 编译无关)。单元测试未在本机执行,交由 CI。

流式落库回归:用同一配置向此前未落库的模型发一条流式消息,日志应出现 usage 上报且无"token统计跳过"warning,数据库中该 configId + 模型应新增记录;统计页切换合并开关,模型累计与筛选列表应在两种口径间正确切换。

检查清单 / Checklist

  • 我已记录可复现验证和未运行项原因 / Reproducible verification and reasons for unrun checks are recorded
  • 日常开发 PR 的目标分支为 dev;如目标为 main,我已说明这是维护者发布同步 / The target branch is dev for regular work; if it is main, I explained why this is a maintainer-led release sync
  • 我已确认 Candidate checks 覆盖改动范围,并会处理技术失败项 / Candidate checks covers the change scope and technical failures will be addressed
  • 最终 diff 无无关、临时、生成、二进制或敏感内容 / Final diff has no unrelated, temporary, generated, binary, or secret content
  • 已提供对应的回归、UI、文档/字符串或兼容性证据 / Relevant regression, UI, docs/strings, or compatibility evidence is provided

cnb.ccXJz9ZBAFA added 2 commits November 13, 2026 12:26
应用内排查(GLM-5.2 未统计)确认根因链:OPENAI_GENERIC/OPENAI_LOCAL 流式请求
不带 stream_options.include_usage,严格端点按规范省略 usage 分块,
applyUsageToCounters 静默 early-return,RequestTracker.finish() 拿不到成功
attempt 的快照返回 null,落库被静默跳过且无任何日志;标题/总结等非流式辅助
请求走固定模型索引,导致只承担主对话流式的模型永远没有记录。

- 工厂对两个类型显式传 includeUsageInStream = true,与官方 OPENAI 类型及
  Kimi/DeepSeek 既有做法对齐,不做按端点降级
- finish() 四个跳过分支全部留痕(取消为 debug 级,其余 warning 级,
  带 configId 与 providerModel),下次排查看日志即可定位
- usage 非 null 但解析失败时输出字段名列表;usage 为 null 的分块不刷屏,
  由 finish() 的无上报日志兜住观测面
统计页此前按模型名合并展示,不同渠道(模型配置)的同名模型 token 混在一起。
DB 与 DAO 聚合本就携带 configId 粒度,本次纯展示层改造:

- TokenStatsGrouping 枚举(CONFIG_MODEL 默认 / MERGED_MODEL)经
  TokenStatsQueryParams 透传 lifetime 与 range 查询,CONFIG_MODEL 按组键
  config:{configId}|model:{model} 划分并暴露组内配置,legacy 空 configId
  独立成组;MERGED_MODEL 维持原键
- ViewModel 收集三处查询结果的配置 ID 拼装"配置名/模型名",已删除配置沿用
  既有文案,合并组与 legacy 记录保持纯模型名
- 范围分析卡新增"合并相同模型"开关:开启按模型名合并,关闭按配置名/模型;
  切换时清空模型筛选(displayModelId 语义随分组变化),设置持久化到
  token_stats_preferences
- strings 补齐全部 8 个语言目录
- 新增 TokenStatsDisplayGroupingTest 覆盖配置拆分、legacy 组、合并归一与
  同配置不同模型不合并
- TODO 计划与实施记录见 docs/TODO/token_stats_config_grouping_20261113/
@CATMIAOZHI
CATMIAOZHI self-requested a review November 13, 2026 07:35

@CATMIAOZHI CATMIAOZHI left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

审计结论:❌ 请求修改

改动(25 个文件,+488/−22):流式 usage 落库修复 + token 统计按模型配置分组并新增「合并相同模型」开关。

通过的检查:

  • 根因链核对无误:OpenAIProvider.createRequestBodyInternal 仅在 stream && includeUsageInStream 时附加 stream_options.include_usage;工厂此前只有官方 OPENAI 分支传 true,本次把 OPENAI_GENERIC / OPENAI_LOCAL 对齐到同一做法,改动点准确。
  • TokenTrackingAIService.finish() 四个静默跳过分支全部改为带 configId / providerModel 的日志(取消 debug、其余 warning),语义与旧代码一致(取消仍不落库),只是补观测面;applyUsageToCounters 解析失败时只在 usage 非 null 时输出字段名,不刷屏。
  • 分组数据层正确:CONFIG_MODEL 用 config:{configId}|model:{model} 作组键(legacy 空 configId 独立成组),MERGED_MODEL 保持原 model: 组键;configId 字段只在 CONFIG_MODEL 下填充,MERGED_MODEL 为 null,与注释一致;新增单测覆盖分组键、legacy 成组、合并聚合、不同模型不合并四种情形。
  • ViewModel 层:分组选择经三处查询(lifetime / range / available)透传,配置名解析覆盖三处结果;切换分组时清空模型筛选(displayModelId 语义随分组变化,旧选中集会错指),选择持久化到 token_stats_preferences;已删除配置沿用「已删除的配置」文案。
  • 无 Room 结构变化、无新增权限、无密钥/敏感信息;8 个语言目录的 strings 均已补齐新文案;目标分支为 dev。

问题:

  1. [P1] stream_options.include_usage 无降级:OPENAI_GENERIC / OPENAI_LOCAL 现在每次流式请求都带该参数,拒绝未知参数的端点会直接返回 400——影响面从「统计缺失」扩大到「流式对话本身不可用」。PR 描述已披露该风险并明确选择不降级,但这是需要 maintainer 明确接受的取舍:请 @AAswordman 确认接受该风险,或改为请求失败时去掉该参数重试一次(仅 OPENAI_GENERIC / OPENAI_LOCAL)。在 maintainer 拍板前不建议合并。
  2. [P3] TokenStatsPreferences.loadModelGrouping() 用 valueOf 直接解析存值,与 loadTargetCurrency / loadActivityViewMode 等既有函数同款写法;存值非法时会抛 IllegalArgumentException,虽被 loadInternal 的通用 catch 接住并显示加载失败,但顺手可改为 runCatching { valueOf(it) }.getOrDefault(CONFIG_MODEL) 更稳妥。
  3. [P3] docs/TODO/token_stats_config_grouping_20261113/index.md 顶部的 working_branch: feat/file-tool-hardening 与实际分支 feat/token-stats-grouping 不符,应为复制粘贴残留,顺手改掉。
  4. [P3] 单测只覆盖了 buildDisplayModels 的分组键;resolveDisplayNames 的「配置名/模型名」拼装、已删除配置兜底、setModelGrouping 切换清空选中集等 ViewModel 层行为没有对应测试(index.md 提到「fake store 适配」但 diff 中未见相关测试文件),建议补上。
  5. [question] PR 描述称单元测试未在本地执行、交由 CI:请确认 CI 中 TokenStatsDisplayGroupingTest 已通过。

后续可跟进(不阻塞):统计页默认口径由「按模型名合并」改为「按配置 + 模型」展示是已披露的有意变化,开关可切回旧口径;合并后请在真机上按 PR 描述的回归步骤验证一次流式落库与开关切换。

由 水晴喵的muse 审计

对应 PR AAswordman#1497 评审(水晴喵的muse 审计):

- P1:OPENAI_GENERIC / OPENAI_LOCAL 携带 stream_options.include_usage 可能被
  严格端点以 400 拒绝,影响面从统计缺失扩大到对话不可用。按评审建议与用户
  决策加入降级:仅这两个类型的基类注入在首次 400 时去掉该参数立即重试一次
  (不消耗 enableRetry 的重试预算;400 在响应头阶段失败、无内容可回滚);
  官方 OPENAI / Xai / Kimi / DeepSeek 不参与降级。实现取"构建后剥离"——
  createRequestBody 有大量子类覆写(Kimi/Deepseek 自己注入 stream_options),
  改签名会破坏覆写链
- P3 loadModelGrouping 用 runCatching 解析存值,非法值回退默认口径
- P3 docs TODO index 的 working_branch 元数据改为实际分支名
- P3 展示名拼装抽为 TokenStatsDisplayNames 纯函数、分组切换状态转移抽为
  applyGroupingChange,新增 TokenStatsDisplayNamesTest 五个用例
@542474846

Copy link
Copy Markdown
Author

感谢审计,按评审处理完毕(commit 3276a4f2),并已在本地跑通完整单测套件。逐项回复:

P1 stream_options.include_usage 无降级 — 已按评审建议的方案二处理:仅 OPENAI_GENERIC / OPENAI_LOCAL,首次以 400 拒绝时去掉该参数立即重试一次,不消耗 enableRetry 的重试预算(400 在响应头阶段即失败、无内容可回滚;若去掉后仍 400,则走正常重试路径并暴露原始错误)。实现采用"构建后剥离"而非改 createRequestBody 签名——该方法有大量子类覆写(Xai/Qwen/OpenRouter/Nvidia/OpenCode/Kimi/Doubao/Deepseek),改签名会破坏覆写链;且仅这两个类型走基类实现,Kimi / DeepSeek 自行注入的 stream_options 不受影响,官方 OPENAI / Xai 不参与降级。

P3-1 loadModelGrouping 的 valueOf — 已改为 runCatching { valueOf }.getOrNull(),非法存值回退默认口径,与"缺失即默认"的读法一致。

P3-2 docs 元数据 — working_branch 已改为 feat/token-stats-grouping。

P3-3 ViewModel 层测试 — 已补一部分:展示名拼装抽为纯函数 TokenStatsDisplayNames(配置名前缀 / MERGED 保持原名 / legacy 空 configId / 已删除配置兜底 4 个用例),分组切换清空选中集抽为 TokenUsageStatisticsViewModel.applyGroupingChange 纯函数(1 个用例),共 5 个新用例随 TokenStatsDisplayNamesTest 提交。完整 ViewModel 的行为测试需要 Robolectric 级别的环境(ModelConfigManager 与 Room 依赖 Context),暂以纯函数抽测覆盖,完整 VM 测试待测试基建补齐后再加。

[question] CI 中 TokenStatsDisplayGroupingTest 是否通过 — 本地完整 :app:testDebugUnitTest 已通过(含 TokenStatsDisplayGroupingTest 与新增的 TokenStatsDisplayNamesTest);PR 的 CI 检查结果以仓库流水线为准。

[question] 真机回归 — 合并前会按 PR 描述的步骤在真机验证流式落库与开关切换。

@CATMIAOZHI CATMIAOZHI left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

审计结论:✅ 批准合并

复审(针对新提交 3276a4f2,作者逐项回复并修复后请求复审):上次评审的 [P1]×1、[P3]×3、[question]×2 均已处理,无新的 P0/P1。

改动(新提交 6 个文件,+209/−27):OPENAI_GENERIC / OPENAI_LOCAL 流式 400 后去掉 stream_options.include_usage 重试一次;展示名拼装与分组切换抽为纯函数并补 5 个单测;loadModelGrouping 非法存值回退默认;docs working_branch 修正。

通过的检查:

  • [P1] 已修好且实现经源码核对无误:降级重试路径与基类 createRequestBody 的构建步骤完全一致(createRequestBodyInternal → 剥离 stream_options → ThinkingConfigurationApplier.apply → createJsonRequestBody);400 只在 call.execute() 返回非 2xx 的响应头阶段抛出,此时尚未读取流式内容,continue 前无需回滚;循环内每次重新构建请求体并 newCall;降级后 includeUsageForRequest=false,再次 400 走正常重试路径并暴露原始错误;工厂确认仅 GENERIC/LOCAL 在基类 OpenAIProvider 上带 includeUsageInStream=true,且无 GENERIC/LOCAL 子类覆写 createRequestBody,Kimi/DeepSeek 自注入的 stream_options 不受影响,官方 OPENAI / Xai 不参与降级。
  • [P3-1] loadModelGrouping 已改为 runCatching { valueOf }.getOrNull(),非法存值回退默认口径。
  • [P3-2] docs working_branch 已改为 feat/token-stats-grouping。
  • [P3-3] 展示名拼装(TokenStatsDisplayNames)与分组切换清空选中集(applyGroupingChange)已抽为纯函数并补 5 个单测(配置名前缀 / 合并组保持原名 / legacy 空 configId / 已删除配置兜底 / 切换清空选中集),与 ViewModel 内原有逻辑逐行一致;完整 ViewModel 行为测试需 Robolectric 级环境,暂以纯函数抽测覆盖,作者已说明原因。
  • 作者回复确认:本地完整 :app:testDebugUnitTest 已通过(含 TokenStatsDisplayGroupingTest 与新增的 TokenStatsDisplayNamesTest);真机回归承诺合并前按 PR 描述步骤验证。

问题:

  1. [P3] 降级日志措辞不够严谨:catch 分支对 includeUsageForRequest 为 true 时的任何 400 都会触发降级并记录"端点以 400 拒绝 stream_options.include_usage",但未校验 errorBody 是否与该参数相关;若 400 实为模型名、参数等其他原因,日志会误导排查。建议措辞改为"疑似"或仅在错误体提及 stream_options/未知参数时再断言。非阻塞,顺手修即可。

后续可跟进(不阻塞):

  • 新提交的 CI 检查结果尚未回传(check-runs 为空),合并前以仓库流水线为准。
  • 真机回归(流式落库与开关切换)作者承诺合并前验证,尚未提供实测证据,合并前请确认已完成。

由 水晴喵的muse 审计

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants