Files
Jyotisha/docs/tasks/TASK-cost-accounting-gaps-20260927.md
T

22 KiB
Raw Blame History

任务书 · 成本记账补漏与缓存分价(2026-09-27)

基线:origin/staging @ 0c95a7ec("docs(bugs): BUG-1061 deployed to staging d9c9e714")。

行号只是线索,一律按符号名定位。 本单是对 TASK-billing-pricing-20260830.md、TASK-round2-cost-and-delivery-20260831.md 的续单:前者把账本、功能定价、聚合端点、定价测算页都做完了;后者的任务 0(采真实单位成本)从未执行,没有 PROGRESS 文件。本单不重做已完成部分,只补让「成本算出来是假的」的那几处漏洞。

为什么要做

产品负责人 2026-09-27 提问「项目怎么做成本计算 / 后台有没有 token 统计 / 缓存开了没有」。按代码逐条核对后的结论:

  1. 骨架完整:usage_reservations → usage_events → usage_ledger 三表;authorize_usage / complete_usage / release_usage 三个 SQL 函数;feature_pricing 按功能定价;后台 /admin/usage(每行有 Token、缓存读 tokens、成本列)、/admin/usage/aggregate(按功能 30 天 runs / avg / p50 / p95 / max)、/admin/pricing-simulator(售价 vs 成本 vs 毛利率 + 缓存命中表)、/admin/models(输入 / 输出单价)。
  2. 算出来是 0:model_config_versions.input_cost_microusd_per_million / output_cost_microusd_per_million 默认 0,staging 从未填过;账本里没有一条非零真实成本行。而且后台没有地方填:components/admin/model-management.tsx 的模型草稿弹窗只有「供应商 / 模型 / 单次点数 / 启用 / 默认模型」五个字段,saveDraft 请求体里的 inputCostMicrousdPerMillion / outputCostMicrousdPerMillion / modelTier / contextWindow 全部原样回填旧值(新建时为 0 / standard / null)。列表页「成本/百万 Token」一列永远显示 $0.0000 / $0.0000。API(api/admin/models/route.ts 的 zod schema)是收这几个字段的,缺的只是表单。见 E0。
  3. 即使填了单价,账本也系统性低估,原因就是下面「事故实证」的五条。这才是本单要修的。

事故实证(按符号定位,已逐条读代码确认)

E0 · 模型单价在后台无处可填

model-management.tsx → 模型弹窗(title 为「新增模型草稿」/「编辑 」)内 Form.Item 的 name 只有 providerId / providerModel / creditCost / enabled / isDefault。modelTier(影响 feature_pricing 匹配哪一档)、contextWindow、两个单价都不可编辑。产品负责人 2026-09-27 在 staging 后台实际打开过测算页,「单位成本」列全部是「无实测数据 / 模型估算 0」,根源之一就是这里。

E1 · 七处模型调用不进账本

# 调用点 符号 现状
1 会话标题 frontend/src/lib/session-title-agent.ts → generateSessionTitleText agent.generate 结果只取文本,usage 丢弃
2 上下文摘要 frontend/src/lib/session-context-summary.ts → generateSessionContextSummaryText 同上,默认模型,输入是整段历史,不便宜
3 校正轮意图分类 frontend/src/lib/rectification-agentic/v9/turn-intent-classifier.ts → classifyTurnIntentWithRetry 只 console.info 诊断,每一轮都调
4 采用旁白 frontend/src/lib/rectification-agentic/v9/adopt-narration-agent.ts → generateAdoptNarrationText usage 丢弃
5 校正非 message 动作(opening 等) frontend/src/lib/rectification-agentic/v9/agent-route-billing.ts → createRectificationRunBilling 的 reserve / complete 开头 if (action !== "message") return 免费是产品决定,但 agent run 的 token 一并被丢:开场轮要加载整段 Skill,是校正里最贵的单轮之一
6 生时引导 frontend/src/lib/birth-time-guide-service.ts → generator.generate(prompt) 那行(createBirthTimeGuideService 上方) 不计费、不记账
7 每日星语 frontend/src/app/api/daily-starlanguage/route.ts → getDailyStarlanguageAgent(model).generate 只限流,不记账

对照:聊天主链已经把小聊分类器的 usage 合进去了(consult/route.ts → usagePayload 里的 classificationUsage),说明「附属调用并入主账」这个模式仓库里已有,不是新发明。

E2 · 缓存命中的输入按全价算

四个成本计算点是同一段公式复制四份,都只有 inputTokens × 输入单价 + outputTokens × 输出单价:

  • frontend/src/app/api/consult/route.ts → usagePayload
  • frontend/src/app/api/reports/route.ts → 报告同步路径的 costMicrousd 计算
  • frontend/src/lib/personal-report-worker.ts → worker 路径的 costMicrousd 计算
  • frontend/src/lib/rectification-agentic/v9/agent-route-billing.ts → complete(usage)

metadata.cache.readTokens 已经被解析并写入账本(promptCacheUsage 覆盖 AI SDK、Mastra、OpenAI 原始三种形状;Mastra 1.50.1 会把 OpenAI 兼容返回的 prompt_tokens_details.cached_tokens 映射进 usage),但它只用于展示命中率,不参与算钱。model_config_versions 表也没有缓存读单价列。

供应商事实(2026-09-27 查官方文档,执行时须再核一次):DeepSeek 上下文缓存默认对所有用户开启、不需要任何请求参数,命中与否只看请求前缀是否与已缓存前缀完全一致;usage 返回 prompt_cache_hit_tokens / prompt_cache_miss_tokens,且 prompt_tokens_details.cached_tokens 与 hit 数相同;命中价约为未命中价的 1/30(V4-Pro 峰时 0.044 vs 1.32 美元 / 百万 token;非峰时半价)。所以「咱们是否开缓存了」的答案是:开着,但账本把命中的那部分按 30 倍价格记。本仓 cachedSystemMessage / cachedHistoryMessage 的 Anthropic 标记对 DeepSeek 无作用也无害。

E3 · 多事件账本的 metadata.cache 被覆盖而不是累加

complete_usage 的 upsert 分支 metadata = ledger.metadata || excluded.metadata(frontend/supabase/migrations/20260806030000_settle_order_usage_authorization.sql)。校正一个 case 多轮、报告多章节都是多事件合一行;jsonb || 是浅合并,cache 键被最后一个事件整个替换,聚合端点与测算页的命中率因此失真。token 与成本列是累加的,只有 metadata 不是。

E4 · 失败 / 释放的运行不留任何成本痕迹

release_usage(p_user_id, p_request_id, p_reason) 没有 usage 参数,只退积分、改状态。模型流到一半失败、用户中止、empty_answer 整轮失败(BUG-633 / 634 / 1049 那类)都已经花了供应商的钱,账本看不见。以校正为例,runV9AgentTurn 失败时走 billing.release(),此时 result.totalUsage 是有的,只是没人接。

E5 · 后台聚合只展示成本,不展示 token 分位数

/api/admin/usage/aggregate 已返回 inputTokens / outputTokens 的 p50 / p95,但 components/admin/pricing-simulator.tsx 的「单功能表」只渲染售价、单位成本、毛利率。产品要看「一次校正到底吃多少 token」目前只能翻每行明细。

非缺陷、本单不做

  • 推理 token:DeepSeek 把推理 token 计入 completion_tokens 一起按输出价收费,outputTokens 已经包含,不需要单独定价;只需把 completion_tokens_details.reasoning_tokens 存进 metadata 供观察(并入 T4)。
  • 非模型算力(VedAstro 外部调用、Swiss Ephemeris 重计算):只有限流没有计量。另立单,本单不碰。
  • 峰 / 非峰半价:不按时段分价,单价表填峰时价做保守估计。决策记录 D3。

根因

计费闭环那一轮的目标是「钱不出错」,账本是围绕收费动作建的:有预留才有账,免费的就没账。成本核算需要的是围绕模型调用建账。两者的差集就是 E1、E4;E2、E3 是缓存观测在 8 月 31 日那轮只做到「可见」没做到「计价」的遗留(PROGRESS-billing-pricing 的「任务 4 的缺口」已经写明)。

决策记录

  • D1 产品负责人 2026-09-27 授权开本单:补记账、缓存分价、后台可看 token。不动任何售价、积分价、会员公平使用参数(沿用 0830 单红线 1)。
  • D2 免费调用照旧免费,只记账不扣积分。E1 七处不得因此开始收费。
  • D3 单价表按峰时价填;不实现按时段分价。
  • D4 usage_reservations.feature_key 与 usage_ledger.source 的 check 约束需要加宽枚举以容纳辅助调用(新 feature_key)与系统来源(新 source system)。这是 schema 扩展,不是放宽计费校验;authorize_usage / complete_usage / release_usage 的公平使用、配额、model_not_included 判定一条不得放宽(0830 单红线 4 继续有效)。辅助调用不得通过 authorize_usage 走一遍预留再释放来「借道」记账,那会污染公平使用计数。
  • D5 供应商契约(缓存字段名、命中价)以执行当日官方文档为准,任务书里的数字只是线索。
  • D6 8 月 31 日第二轮任务 0(真实成本采集)的执行主体是产品负责人,在本单部署后做;执行方只负责把清单写进 docs/testing/,并把 TASK-round2-cost-and-delivery-20260831.md 状态板行改为「任务 0 并入本单,其余未执行」。

硬红线

  1. 钱不出错:每条现有计费路径的 reserve → complete / release 三态不得改语义;本单只加「记」,不加「扣」。任何改动后 application-billing-contract、consultation-billing、consult-reports-billing、high-risk-billing-routes-contract、feature-pricing-contract 五个测试文件必须全绿且断言不改。
  2. 成本公式只保留一份:新建 frontend/src/lib/usage-cost.ts(名字可调),导出一个纯函数 computeUsageCostMicrousd(model, usage);四个调用点全部改成调它,git grep 'inputCostMicrousdPerMillion ?? 0' 在 src/ 下只剩这一处。
  3. 辅助调用记账失败不得影响主流程:写账的 promise 用 void/catch 隔离,且失败要 console.warn 带 feature_key,不带用户内容。
  4. 动表必须真跑 npm run test:db(需要 Docker)。没有 Docker 就把 migration 与 SQL 测试写好、BLOCKED.md 记环境缺口,不得推 staging。
  5. 不得修改既有测试断言;确需改的,断言上方写「原值 / 新值 / 原因」三栏并在 PROGRESS 单列。测试总数不低于开工时 origin/staging 实测。
  6. 账本、日志、测试 fixture 不得出现用户内容、姓名、出生资料、模型原文;metadata 只放数字与枚举。
  7. scripts/jyotish_api_server.py 与 frontend/src/app/page.tsx 零增长;不改 .gitea/workflows/**;不动 DNS;不自行提升 main。
  8. 不得顺手升级 @mastra/core 或任何依赖。

让步顺序:钱不出错 > 数据不损坏 > 功能与测试不回归 > 成本数字诚实(宁缺勿假)> 后台好看 > 代码整洁。

开工前置

git fetch origin --prune
git worktree add -b codex/cost-accounting-gaps-20260927 \
  .worktrees/cost-accounting-gaps-20260927 origin/staging

读 AGENTS.md §1–§7、frontend/AGENTS.md、docs/tasks/PROGRESS-billing-pricing-20260830.md 全文(尤其「外部核对」一节)、docs/tasks/TASK-billing-pricing-20260830.md 的硬红线。在 docs/BUG_HISTORY.md 检索 usage_ledger、cost_microusd、complete_usage、release_usage、cache、计费。跑 python3 scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45(本单动了 SQL 与运行链,按 §9 要求跑)。

开工时记录基线:tsc、lint warning 数、npm test 总数与无 Docker 失败清单(存文件,收工逐条 diff)。

任务分解

T0(P0,门控)· 核实供应商契约与运行事实

  1. 在 staging 后台 /admin/models 或只读查询确认当前发布模型的 provider_type、provider_model、model_tier,写进 PROGRESS(不写 key、不写 base_url 的凭据部分)。
  2. 按当日 DeepSeek 官方文档(api-docs.deepseek.com 的 kv_cache 与 pricing 页)核对:缓存是否默认开启、usage 字段名、命中 / 未命中 / 输出三档价格。若 provider 不是 DeepSeek,按对应供应商文档核。
  3. 写一条单测:用 DeepSeek 形状的原始 usage(含 prompt_tokens_details.cached_tokens 与 completion_tokens_details.reasoning_tokens)喂 promptCacheUsage,断言 readTokens 正确。若 Mastra 已把它映射成 cachedInputTokens,两种形状都要覆盖。

验收:PROGRESS 有「供应商 / 模型 / 缓存契约 / 三档价格 / 文档 URL / 核对日期」一张表;新单测绿。

T1(P0)· 缓存分价 + 成本公式收敛

  1. 先把表单补上(E0,独立可交付,建议第一个 commit):模型草稿弹窗增加「模型档位」(下拉,沿用 MODEL_TIER_OPTIONS 标签)、「上下文窗口」、「输入价 $/百万 token」、「输出价 $/百万 token」,以及本任务新增的「缓存读价」「缓存写价」。表单以美元 / 百万 token 输入(小数,精度 4 位),提交时乘 1,000,000 取整成微美元;编辑既有版本时回显换算后的值。保存后仍走既有「草稿 → 连接测试 → 发布」流程,不得绕过。列表页「成本/百万 Token」一列加上缓存读价。组件测试断言这六个字段存在且换算正确。
  2. migration:model_config_versions 新增 cache_read_cost_microusd_per_million bigint not null default 0 check (>= 0) 与 cache_write_cost_microusd_per_million(同型;DeepSeek 为 0,Anthropic 有写入溢价,留位)。admin_save_model_config_draft(或现名)与 model_catalog.ts 的读取、/admin/models 表单、返回 JSON 一并加字段;旧行默认 0,行为不变。
  3. 新建 computeUsageCostMicrousd(model, usage):
    • billableInput = inputTokens - cache.readTokens(下限 0)按输入价;
    • cache.readTokens 按缓存读价;cache.writeTokens 按缓存写价(为 0 则等于不计);
    • outputTokens 按输出价;四舍五入到整数微美元。
    • 缓存读价为 0 且 readTokens > 0 时不得静默按 0 算:返回值附带 pricingComplete: false,调用方写进 metadata.pricing = "cache_price_missing",聚合端点把这类行单独计数。这是「宁缺勿假」。
  4. 四个调用点改为调用该函数;删掉四份复制的公式。
  5. 单测:纯函数覆盖「无缓存 / 有命中 / 缓存价缺失 / 全命中」四例;四个调用点各一条合同测试证明走了新函数(现有 consult-reports-billing.test.ts 等按需扩展,不改旧断言)。

验收:git grep -n 'inputCostMicrousdPerMillion ?? 0' frontend/src 只剩 usage-cost.ts;npm run test:db 含新列的读写用例;tsc 0 错。

T2(P0)· 辅助调用进账本(不扣费)

  1. 加宽枚举(D4):usage_reservations.feature_key、usage_ledger.feature_key、feature_pricing.feature_key 不动(定价表不需要新键);只在 usage_reservations / usage_ledger / 聚合端点的 FEATURE_KEYS 加:chat.title、chat.summary、rectification.opening、rectification.intent、rectification.narration、birth_time_guide、daily_starlanguage。source 加 system。
  2. 新 SQL 函数 record_system_usage(p_user_id, p_request_id, p_feature_key, p_actual_usage jsonb):自建一条 status='completed'、source='system'、credit_amount=0 的预留并直接调 complete_usage 落账;幂等键仍是 (user_id, request_id);不触碰 profiles.credits、不写 credit_transactions、不参与公平使用计数(authorize_usage 的窗口查询若按 feature_key in (...) 或 source 过滤,要确认 system 行不会被算进去,并写测试钉死)。
  3. TS 封装 recordSystemUsage(accounting, ...),七处调用点接入;request_id 规则写进代码注释与 PROGRESS:
    • 标题 / 摘要:<consultRequestId>:title、<consultRequestId>:summary
    • 校正:<turnRequestId>:opening、:intent、:narration(同一轮可能有多次意图分类,用 :intent:<n>)
    • 生时引导 / 每日星语:各自路由的 requestId
  4. E1 #5(校正非 message 动作):createRectificationRunBilling.complete 在 action !== "message" 分支改为调 recordSystemUsage(... "rectification.opening" ...),不再直接 return true;reserve / release 分支不变。

验收:七处各有一条测试证明 usage 被转发(mock accounting 断言 feature_key 与 token 数);database-billing-*.test.ts 新增 record_system_usage 幂等与「不动积分、不进公平使用窗口」两条 SQL 用例;聚合端点 JSON 出现七个新键且 hasData 语义不变。

T3(P1)· 失败 / 释放的运行记成本

  1. release_usage 增加可选参数 p_actual_usage jsonb default null;非空时向 usage_events 写一行(metadata.outcome = "released",其余字段同 complete_usage),不写 usage_ledger(保持「ledger = 已完成交付」的语义),退款逻辑逐字不变。
  2. releaseUsage() TS 封装加可选 usage;三条计费路径(聊天 settleRun 失败分支、报告 completeReportFailed、校正 billing.release())在拿得到 totalUsage 的地方传进去,拿不到就照旧传空。
  3. 聚合端点每个 feature 增加 released: { runs, costMicrousd: { avg, p50, p95, max } },从 usage_events where metadata->>'outcome' = 'released' 聚合;测算页「单功能表」加一列「失败成本占比」= released 成本 / (completed + released 成本)。

验收:SQL 用例:释放带 usage 后 usage_events 有行、usage_ledger 无行、积分退回金额与之前测试一致;admin-usage-aggregate-contract.test.ts 扩展 released 结构。

T4(P1)· metadata.cache 累加 + 推理 token 可见

  1. complete_usage 的 upsert 分支:cache.readTokens / writeTokens / noCacheTokens 三个数按事件累加,hit = 累加后 readTokens > 0;metadata 其他键维持浅合并。写在 SQL 里,不靠调用方先读后写。
  2. ActualUsage.metadata 加可选 reasoningTokens;promptCacheUsage 旁新增 reasoningTokenUsage(raw) 解析 completion_tokens_details.reasoning_tokens(及 AI SDK 的 outputTokenDetails.reasoningTokens),四个调用点带上。不参与算钱(见「非缺陷」)。

验收:SQL 用例:同一预留两次 complete_usage 各带 cache 后 ledger 的 readTokens 等于两次之和;agent-generation-settings.test.ts 加推理 token 解析用例。

T5(P1)· 后台把 token 摆出来

  1. pricing-simulator.tsx 「单功能表」增加列:输入 p50 / 输出 p50 / 缓存读 p50 / 命中率(复用聚合端点已有字段,命中率来自 metadata.cache);source === "unavailable" 时显示「无实测数据」不显示 0。
  2. 表格上方一行说明文字:「成本 = (输入 − 缓存读) × 输入价 + 缓存读 × 缓存价 + 输出 × 输出价;单价在 /admin/models 配置;cache_price_missing 行表示有命中但未配缓存价」。
  3. 写 docs/testing/cost-accounting-20260927.md:产品负责人部署后照做的清单——在 /admin/models 给当前发布模型新建一版草稿、填三档单价、连接测试、发布(T1.0 之前做不到这一步),在测算页填汇率 → 用可控账号每类功能跑 ≥5 次(聊天含多领域、校正跑到出卡、报告完整生成、另各触发一次标题 / 摘要 / 开场 / 每日星语)→ 在 /admin/pricing-simulator 读表 → 把「功能 → p50 成本 → p95 成本 → 命中率 → 失败成本占比」抄回 PROGRESS。

验收:pricing-simulation.test.ts / 组件测试覆盖新列的空态与有数态;docs/testing/ 清单存在且每步可照做;DESIGN.md 不需要改(后台 antd 页不在 C 端设计合同内),若改了 C 端任何文件必须说明。

T6(P2)· 状态板与文档收口

  • docs/tasks/README.md:本单加行;TASK-round2-cost-and-delivery-20260831.md 行改为「任务 0 并入 TASK-cost-accounting-gaps-20260927;任务 1–3 未执行」。
  • CHANGELOG.md:一条「后台成本统计:缓存分价、辅助调用与失败运行入账、token 分位数可见」;用户可感知行为无变化要写明。
  • docs/BUG_HISTORY.md:见下节编号。

BUG 编号起点

开工时核对 docs/BUG_HISTORY.md 当前最大号(2026-09-27 为 BUG-1063),从 BUG-1064 起连续编号。建议拆法(执行方可合并,但不得漏记):

建议编号 现象 对应
BUG-1064 缓存命中输入按全价计入 cost_microusd,账本高估 / 低估方向取决于单价配置 E2 / T1
BUG-1065 七处模型调用不进账本,成本系统性低估 E1 / T2
BUG-1066 校正开场轮 agent run 的 token 被 action !== "message" 分支丢弃 E1 #5 / T2.4
BUG-1067 失败 / 释放运行无成本记录 E4 / T3
BUG-1068 多事件账本 metadata.cache 被覆盖,命中率失真 E3 / T4
BUG-1069 后台模型草稿表单缺输入 / 输出单价、模型档位、上下文窗口字段,单价只能是 0 E0 / T1.0

每条按 §5 格式写:状态、现象、触发条件、根因、修复、验证、防复发、关联记录(关联 PROGRESS-billing-pricing-20260830.md 任务 4 缺口)。部署前状态写 resolved(待部署),部署核对后由验收方回填 SHA。

验收口径(验收方执行)

  • 前端:./node_modules/.bin/tsc --noEmit 0 错;npm run lint 0 error 且 warning ≤ 开工基线;npm test 失败清单与基线逐条一致、总数不降;next build 后 / 仍 ○ Static,首屏 gzip ±2%(本单不应触碰 C 端首屏,超出即异常)。
  • 数据库:npm run test:db 全绿(Docker);无 Docker 视为未通过而非缺口,因为本单动表。
  • 部署:https://staging.jyotisha.chat/api/health 的 deployment.gitCommit 等于合入 SHA;随后由产品负责人按 docs/testing/cost-accounting-20260927.md 采数,采到的表回填 PROGRESS。在采到非零真实成本之前,本单不得被写成「成本已可信」,只能写「记账链路已补全」。