Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0199rbQDTsUbCVw84wc8BTFe
22 KiB
任务书 · 成本记账补漏与缓存分价(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 统计 / 缓存开了没有」。按代码逐条核对后的结论:
- 骨架完整:
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(输入 / 输出单价)。 - 算出来是 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。 - 即使填了单价,账本也系统性低估,原因就是下面「事故实证」的五条。这才是本单要修的。
事故实证(按符号定位,已逐条读代码确认)
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→usagePayloadfrontend/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)与系统来源(新 sourcesystem)。这是 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 并入本单,其余未执行」。
硬红线
- 钱不出错:每条现有计费路径的
reserve → complete / release三态不得改语义;本单只加「记」,不加「扣」。任何改动后application-billing-contract、consultation-billing、consult-reports-billing、high-risk-billing-routes-contract、feature-pricing-contract五个测试文件必须全绿且断言不改。 - 成本公式只保留一份:新建
frontend/src/lib/usage-cost.ts(名字可调),导出一个纯函数computeUsageCostMicrousd(model, usage);四个调用点全部改成调它,git grep 'inputCostMicrousdPerMillion ?? 0'在src/下只剩这一处。 - 辅助调用记账失败不得影响主流程:写账的 promise 用
void/catch隔离,且失败要console.warn带 feature_key,不带用户内容。 - 动表必须真跑
npm run test:db(需要 Docker)。没有 Docker 就把 migration 与 SQL 测试写好、BLOCKED.md记环境缺口,不得推 staging。 - 不得修改既有测试断言;确需改的,断言上方写「原值 / 新值 / 原因」三栏并在 PROGRESS 单列。测试总数不低于开工时
origin/staging实测。 - 账本、日志、测试 fixture 不得出现用户内容、姓名、出生资料、模型原文;
metadata只放数字与枚举。 scripts/jyotish_api_server.py与frontend/src/app/page.tsx零增长;不改.gitea/workflows/**;不动 DNS;不自行提升main。- 不得顺手升级
@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,门控)· 核实供应商契约与运行事实
- 在 staging 后台
/admin/models或只读查询确认当前发布模型的provider_type、provider_model、model_tier,写进 PROGRESS(不写 key、不写 base_url 的凭据部分)。 - 按当日 DeepSeek 官方文档(
api-docs.deepseek.com的 kv_cache 与 pricing 页)核对:缓存是否默认开启、usage字段名、命中 / 未命中 / 输出三档价格。若 provider 不是 DeepSeek,按对应供应商文档核。 - 写一条单测:用 DeepSeek 形状的原始
usage(含prompt_tokens_details.cached_tokens与completion_tokens_details.reasoning_tokens)喂promptCacheUsage,断言readTokens正确。若 Mastra 已把它映射成cachedInputTokens,两种形状都要覆盖。
验收:PROGRESS 有「供应商 / 模型 / 缓存契约 / 三档价格 / 文档 URL / 核对日期」一张表;新单测绿。
T1(P0)· 缓存分价 + 成本公式收敛
- 先把表单补上(E0,独立可交付,建议第一个 commit):模型草稿弹窗增加「模型档位」(下拉,沿用
MODEL_TIER_OPTIONS标签)、「上下文窗口」、「输入价 $/百万 token」、「输出价 $/百万 token」,以及本任务新增的「缓存读价」「缓存写价」。表单以美元 / 百万 token 输入(小数,精度 4 位),提交时乘 1,000,000 取整成微美元;编辑既有版本时回显换算后的值。保存后仍走既有「草稿 → 连接测试 → 发布」流程,不得绕过。列表页「成本/百万 Token」一列加上缓存读价。组件测试断言这六个字段存在且换算正确。 - 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,行为不变。 - 新建
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",聚合端点把这类行单独计数。这是「宁缺勿假」。
- 四个调用点改为调用该函数;删掉四份复制的公式。
- 单测:纯函数覆盖「无缓存 / 有命中 / 缓存价缺失 / 全命中」四例;四个调用点各一条合同测试证明走了新函数(现有
consult-reports-billing.test.ts等按需扩展,不改旧断言)。
验收:git grep -n 'inputCostMicrousdPerMillion ?? 0' frontend/src 只剩 usage-cost.ts;npm run test:db 含新列的读写用例;tsc 0 错。
T2(P0)· 辅助调用进账本(不扣费)
- 加宽枚举(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。 - 新 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行不会被算进去,并写测试钉死)。 - TS 封装
recordSystemUsage(accounting, ...),七处调用点接入;request_id 规则写进代码注释与 PROGRESS:- 标题 / 摘要:
<consultRequestId>:title、<consultRequestId>:summary - 校正:
<turnRequestId>:opening、:intent、:narration(同一轮可能有多次意图分类,用:intent:<n>) - 生时引导 / 每日星语:各自路由的 requestId
- 标题 / 摘要:
- 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)· 失败 / 释放的运行记成本
release_usage增加可选参数p_actual_usage jsonb default null;非空时向usage_events写一行(metadata.outcome = "released",其余字段同complete_usage),不写usage_ledger(保持「ledger = 已完成交付」的语义),退款逻辑逐字不变。releaseUsage()TS 封装加可选 usage;三条计费路径(聊天settleRun失败分支、报告completeReportFailed、校正billing.release())在拿得到totalUsage的地方传进去,拿不到就照旧传空。- 聚合端点每个 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 可见
complete_usage的 upsert 分支:cache.readTokens / writeTokens / noCacheTokens三个数按事件累加,hit= 累加后readTokens > 0;metadata其他键维持浅合并。写在 SQL 里,不靠调用方先读后写。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 摆出来
pricing-simulator.tsx「单功能表」增加列:输入 p50 / 输出 p50 / 缓存读 p50 / 命中率(复用聚合端点已有字段,命中率来自metadata.cache);source === "unavailable"时显示「无实测数据」不显示 0。- 表格上方一行说明文字:「成本 = (输入 − 缓存读) × 输入价 + 缓存读 × 缓存价 + 输出 × 输出价;单价在
/admin/models配置;cache_price_missing行表示有命中但未配缓存价」。 - 写
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 --noEmit0 错;npm run lint0 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。在采到非零真实成本之前,本单不得被写成「成本已可信」,只能写「记账链路已补全」。