From af2ad3cef1d50c9ca9196bd6d02549d1e887e085 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Fri, 5 Jun 2026 08:26:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(usage):=20=E8=AE=B0=E8=B4=A6=E7=BB=99?= =?UTF-8?q?=E5=89=8D=E7=BC=80=E7=BC=93=E5=AD=98=E5=91=BD=E4=B8=AD=E6=8A=98?= =?UTF-8?q?=E4=BB=B7=20+=20=E5=89=8D=E7=AB=AF=E4=BD=93=E7=8E=B0=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E5=91=BD=E4=B8=AD/=E7=9C=9F=E5=AE=9E=E6=88=90?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 排查"rust→PPT"task(flash,34 轮)发现累计 tokens_in 69.9 万里 88.6% 是缓存 命中,但 _fallback_chat_cost_cny 把命中段也按 input 全价算,记账虚高 2-3x。 - capabilities: 加 cache_hit_cny_per_mtoken(deepseek flash 0.1 / pro 0.2; 0=不区分按全价兜底,绝不少记) - usage: 成本公式拆三段「命中×缓存价 + (input−命中)×input价 + output×output价」; loop 把 cache_hit_tokens + 缓存单价透传进 record_chat_usage - web: 不加 DB 列。app.py 加 _usage_aggregates(单查询 GROUP BY usage_events, 复用列表 msg_counts 批量范式,无 N+1)on-the-fly 算每 task 真实成本 + 缓存命中, _task_dict 带出;dev.html 列表行显 ¥、顶栏 formatTaskUsage 显「tok·缓存命中%·¥」 - scripts: backfill_chat_cost_cache_discount.py 按 units 已存 token 重算历史 cost_cny(只改成本列,默认 dry-run,--apply 落库) 折价只对新 chat 事件即时生效;历史走 backfill 脚本(部署后跑)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- PROGRESS.md | 8 +- config/models/deepseek_v4.yaml | 2 + core/capabilities.py | 3 + core/loop.py | 2 + core/storage/usage.py | 30 +++- scripts/backfill_chat_cost_cache_discount.py | 140 +++++++++++++++++++ tests/test_usage_accounting.py | 25 ++++ web/app.py | 64 ++++++++- web/static/dev.html | 31 +++- 9 files changed, 290 insertions(+), 15 deletions(-) create mode 100644 scripts/backfill_chat_cost_cache_discount.py diff --git a/PROGRESS.md b/PROGRESS.md index 73d2261..ebe1190 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-06-04(sandbox 容器 env:shell 也注入 PYTHONPATH + HOME=/tmp) +最后更新:2026-06-05(记账给前缀缓存命中折价 + 前端体现缓存命中/真实成本) --- @@ -21,6 +21,10 @@ ## 已完成关键能力 +### 2026-06-05 + +- **记账给 DeepSeek 前缀缓存命中折价(修虚高 ~2-3x)+ 前端体现缓存命中/真实成本**:排查"rust 优势→PPT"那 task(flash,34 轮)发现 `tokens_in` 累计 69.9 万里 **88.6% 是缓存命中**,但 `usage.py::_fallback_chat_cost_cny` 把命中段也按 `input` 全价(1.0)算 → 记 ¥0.84,真实(命中按 0.1x)只 ~¥0.28,**越大的 task 虚高越多**(文献采集 53% 命中:¥33→~¥16)。修:① `ModelCapabilities` 加 `cache_hit_cny_per_mtoken`(deepseek flash 0.1 / pro 0.2;0=不区分按全价兜底,绝不少记);② 成本公式拆三段「命中×缓存价 + (input−命中)×input价 + output×output价」,`loop.py` 把 `cache_hit_tokens` + 缓存单价透传进 `record_chat_usage`;③ 前端不加 DB 列——`web/app.py` 加 `_usage_aggregates`(单查询 GROUP BY `usage_events`,复用列表 `msg_counts` 同款批量范式,无 N+1)on-the-fly 算每 task 真实成本 + 缓存命中 token,`_task_dict` 带出,列表行显 `¥`、顶栏 `formatTaskUsage` 显「总 tok · 缓存命中 N% · ¥真实花费」。**折价只对新 chat 事件生效**,历史 events 保留原记账(不回填,不删数据)。**注**:真正压低 token 体量的杠杆是减少轮数(高成本 task 全是 100+ 轮的逐步 write/run_python 循环),非本次范围。 + ### 2026-06-04 - **ppt skill 版式 helper 收进可 import 的模块 + 修中文字体没真生效**:逐页生成是「每页一个 run_python」,以前 ~150 行 helper(配色常量/`add_textbox`/`apply_brand` 等)要在每页里默写一遍 —— 烧 token 且长 deck 里坐标会漂(第 7 页 `apply_brand` 跟第 2 页对不上)。抽出 `skills/ppt/scripts/pptx_helpers.py`,每页 `import pptx_helpers as P` 调用;新增 `new_presentation`/`load`(按文件实际尺寸回填画布常量,逐页进程间同步)/`add_slide`/`set_palette`(默认商务红,`spec_path=` 自动取 spec 前 3 个 hex 作主/辅/强调)入口。**字体修复**:python-pptx `font.name` 只写 ``,中文字形走 `` 槽位没设 → 「指定微软雅黑却没真生效」的根因;`set_text` 改为同时写 latin=Arial + ea/cs=微软雅黑,中英混排各命中正确字体。改 `layouts.md`(helper 块换成 import 起手 + API 速查,9 个示例全改 `P.` 调用)、`icons.md` A5 示例、`SKILL.md` 资源/阶段二。冒烟测试过:`ea` 确写入、`set_palette` 覆盖生效、quality_check 正常解析。 @@ -185,7 +189,7 @@ - **05-06 → 05-08**:Phase 6 部分(task + state.json + tokens 累计);TUI rich Markdown + spinner 实时耗时;`/resume [last|]` + 懒创建 + `_cleanup_if_empty`。 - **05-09 → 05-10**:DESIGN §7 初版(05-12 重写);`cli.py export` + `core/export_docx.py`。 - **05-11**:`atomic_write_text` + `core/memory.py`(core.md 入 prompt,extended/* 索引);loop 事件流化 `sink.emit` 铺 SSE 路。 -- **06-04 token 优化启动**:`DESIGN.md §8.2` 写入上下文治理方案;chat usage 记录 cache hit/miss / reasoning tokens,LiteLLM cost=0 时按模型档案 CNY/Mtok 兜底;`run_python` 新增 `script_path` 模式(长代码先 write .py 再按路径执行,减少 tool_call arguments 进历史);`run_python` / `shell` 长输出只做上下文裁剪,不写 `.tool_logs`;`document_search` 默认召回量保持 6×1200 chars;`core/context.py` 先压缩旧 tool 消息和旧 `load_skill` 结果,不改持久化历史;`llm_start` SSE 输出 `context_*` 压缩统计,dev SPA 底部 hint 展示上下文压缩与 cache hit/miss。 +- **06-04 token 优化启动**:`DESIGN.md §8.2` 写入上下文治理方案;chat usage 记录 cache hit/miss / reasoning tokens,LiteLLM cost=0 时按模型档案 CNY/Mtok 兜底;`run_python` 新增 `script_path` 模式(长代码先 write .py 再按路径执行,减少 run_python code 入历史);`run_python` / `shell` 长输出只做上下文裁剪,不写 `.tool_logs`;`document_search` 默认召回量保持 6×1200 chars;`core/context.py` 先压缩旧 tool 消息、旧 `load_skill` 结果、旧 assistant tool_call arguments(`write(content=...)` 源码参数),不改持久化历史;`llm_start` SSE 输出 `context_*` 压缩统计,dev SPA 底部 hint 展示上下文压缩与 cache hit/miss。`rust介绍` 实测:task 列表 `70条 / 711k tok` 是历史累计,最近单轮 22k input 且 cache hit 高;新增 arguments 压缩后 sent_chars 估算 `49,166 → 34,415`。 --- diff --git a/config/models/deepseek_v4.yaml b/config/models/deepseek_v4.yaml index c027aef..9d372f1 100644 --- a/config/models/deepseek_v4.yaml +++ b/config/models/deepseek_v4.yaml @@ -24,6 +24,7 @@ variants: extended_thinking: false input_cny_per_mtoken: 1.0 output_cny_per_mtoken: 12.0 + cache_hit_cny_per_mtoken: 0.1 # DeepSeek 前缀缓存命中价(input 的 ~0.1x) pro: display_name: DeepSeek V4 Pro @@ -46,3 +47,4 @@ variants: extended_thinking: false input_cny_per_mtoken: 2.0 output_cny_per_mtoken: 20.0 + cache_hit_cny_per_mtoken: 0.2 # DeepSeek 前缀缓存命中价(input 的 ~0.1x) diff --git a/core/capabilities.py b/core/capabilities.py index 67e3afc..e20adb8 100644 --- a/core/capabilities.py +++ b/core/capabilities.py @@ -44,6 +44,9 @@ class ModelCapabilities: # 计费兜底(CNY / million tokens)。provider / LiteLLM cost map 缺失时使用。 input_cny_per_mtoken: float = 0.0 output_cny_per_mtoken: float = 0.0 + # 前缀缓存命中价(DeepSeek 等自动缓存 prompt 前缀,命中部分按此价,通常 ~0.1x input)。 + # 0 = 不区分,缓存命中按 input 全价记(安全兜底,不会少记)。 + cache_hit_cny_per_mtoken: float = 0.0 # API 接入 api_base: str = "" diff --git a/core/loop.py b/core/loop.py index 88baa12..2e3fe37 100644 --- a/core/loop.py +++ b/core/loop.py @@ -177,6 +177,8 @@ class AgentLoop: completion_tokens=ct, input_cny_per_mtoken=self.caps.input_cny_per_mtoken, output_cny_per_mtoken=self.caps.output_cny_per_mtoken, + cache_hit_tokens=usage_details["cache_hit_tokens"], + cache_hit_cny_per_mtoken=self.caps.cache_hit_cny_per_mtoken, extra_units={ k: v for k, v in usage_details.items() if k not in ("tokens_in", "tokens_out") and v diff --git a/core/storage/usage.py b/core/storage/usage.py index 738ac7b..a42670c 100644 --- a/core/storage/usage.py +++ b/core/storage/usage.py @@ -47,15 +47,31 @@ def _fallback_chat_cost_cny( completion_tokens: int, input_cny_per_mtoken: float, output_cny_per_mtoken: float, + cache_hit_tokens: int = 0, + cache_hit_cny_per_mtoken: float = 0.0, ) -> Decimal: - """按本地模型档案价格兜底计算 chat 成本(CNY)。""" + """按本地模型档案价格兜底计算 chat 成本(CNY)。 + + `prompt_tokens` = 全部输入(含缓存命中部分)。DeepSeek 等对前缀缓存命中按更低 + 单价计费,这里把输入拆成「命中 / 未命中」两段:命中段按 `cache_hit_cny_per_mtoken`, + 其余按 `input_cny_per_mtoken`。`cache_hit_cny_per_mtoken<=0` 时不区分,全按 input 价 + (老行为,绝不少记)。 + """ input_price = Decimal(str(input_cny_per_mtoken or 0)) output_price = Decimal(str(output_cny_per_mtoken or 0)) - pt = Decimal(str(int(prompt_tokens))) - ct = Decimal(str(int(completion_tokens))) + hit_price = Decimal(str(cache_hit_cny_per_mtoken or 0)) + if hit_price <= 0: + hit_price = input_price # 未配缓存价 → 命中段按 input 全价(安全兜底) + + pt = int(prompt_tokens) + hit = max(0, min(int(cache_hit_tokens), pt)) # clamp:命中数不超过总输入 + miss = pt - hit + ct = int(completion_tokens) + cost = ( - pt * input_price / Decimal("1000000") - + ct * output_price / Decimal("1000000") + Decimal(str(miss)) * input_price / Decimal("1000000") + + Decimal(str(hit)) * hit_price / Decimal("1000000") + + Decimal(str(ct)) * output_price / Decimal("1000000") ) return cost.quantize(Decimal("0.000001")) @@ -70,6 +86,8 @@ def record_chat_usage( completion_tokens: int, input_cny_per_mtoken: float = 0.0, output_cny_per_mtoken: float = 0.0, + cache_hit_tokens: int = 0, + cache_hit_cny_per_mtoken: float = 0.0, extra_units: Optional[Mapping[str, Any]] = None, response: Any = None, ) -> Decimal: @@ -88,6 +106,8 @@ def record_chat_usage( completion_tokens=completion_tokens, input_cny_per_mtoken=input_cny_per_mtoken, output_cny_per_mtoken=output_cny_per_mtoken, + cache_hit_tokens=cache_hit_tokens, + cache_hit_cny_per_mtoken=cache_hit_cny_per_mtoken, ) units = { "tokens_in": int(prompt_tokens), diff --git a/scripts/backfill_chat_cost_cache_discount.py b/scripts/backfill_chat_cost_cache_discount.py new file mode 100644 index 0000000..d067715 --- /dev/null +++ b/scripts/backfill_chat_cost_cache_discount.py @@ -0,0 +1,140 @@ +"""Backfill 历史 chat usage_events 的 cost_cny —— 给前缀缓存命中折价 + 修 ¥0 旧账。 + +背景:`usage.py::_fallback_chat_cost_cny` 早期(a)对未知模型 litellm 返 0 又无兜底 → +大量 chat 事件 cost_cny 记成 ¥0;(b)后来加了兜底但把缓存命中段也按 input 全价算 → +命中率高的 task 虚高 2-3x。本脚本按每条事件 units 里已存的 token 数 + 模型档案价 +**重算 cost_cny**,只改成本列,**不动任何 token 数 / units**。 + +价格来源:`ModelCapabilities.load(model_profile)`(config 当前价,含 cache_hit 折价)。 +config 里没有该 profile 或无 input/output 价(如 glm 未配价)→ 跳过,保留原值 +(不臆造价格)。缓存命中数取 units.cache_hit_tokens(缺 → 0,即按全价,绝不少记)。 + +跑法: .venv/Scripts/python.exe scripts/backfill_chat_cost_cache_discount.py + 默认 dry-run 只打印汇总,加 --apply 真写。 + +幂等:重算是确定性的;再跑一遍 0 改动。前端任务成本是现算 SUM(usage_events.cost_cny), +改完即时反映,无需动 tasks 表。 +""" +from __future__ import annotations + +import argparse +import os +import sys +from collections import defaultdict +from decimal import Decimal +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +env_file = ROOT / ".env" +if env_file.exists(): + for line in env_file.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, _, v = line.partition("=") + os.environ.setdefault(k.strip(), v.strip()) + +from sqlalchemy import select + +from core.agent_builder import ROOT as AB_ROOT, load_config +from core.capabilities import ModelCapabilities +from core.storage import session_scope +from core.storage.models import UsageEvent +from core.storage.usage import _fallback_chat_cost_cny + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--apply", action="store_true", help="真写;默认 dry-run 只打印") + args = ap.parse_args() + + cfg = load_config() + models_dir = AB_ROOT / cfg["models_dir"] + + # ModelCapabilities.load 按 profile 缓存(避免每行重读 yaml);None = 无法定价 + caps_cache: dict[str, ModelCapabilities | None] = {} + + def get_caps(profile: str) -> ModelCapabilities | None: + if profile not in caps_cache: + try: + caps_cache[profile] = ModelCapabilities.load(profile, models_dir) + except Exception: + caps_cache[profile] = None + return caps_cache[profile] + + # per-profile 统计:事件数 / 改动数 / 跳过数 / 旧总额 / 新总额 + stat_n: dict[str, int] = defaultdict(int) + stat_changed: dict[str, int] = defaultdict(int) + stat_skipped: dict[str, int] = defaultdict(int) + old_sum: dict[str, Decimal] = defaultdict(lambda: Decimal("0")) + new_sum: dict[str, Decimal] = defaultdict(lambda: Decimal("0")) + + with session_scope() as s: + rows = s.execute( + select(UsageEvent).where(UsageEvent.kind == "chat") + ).scalars().all() + + for e in rows: + profile = e.model_profile or "?" + stat_n[profile] += 1 + u = e.units or {} + caps = get_caps(profile) + if caps and (caps.input_cny_per_mtoken or caps.output_cny_per_mtoken): + inp = caps.input_cny_per_mtoken + outp = caps.output_cny_per_mtoken + chp = caps.cache_hit_cny_per_mtoken + else: + # config 无价 → 退 units 价格快照(老事件多半也没有);仍无 → 跳过 + inp = float(u.get("input_cny_per_mtoken") or 0) + outp = float(u.get("output_cny_per_mtoken") or 0) + chp = float(u.get("cache_hit_cny_per_mtoken") or 0) + + if not (inp or outp): + stat_skipped[profile] += 1 + old_sum[profile] += Decimal(str(e.cost_cny)) + new_sum[profile] += Decimal(str(e.cost_cny)) # 无价不变 + continue + + new_cost = _fallback_chat_cost_cny( + prompt_tokens=int(u.get("tokens_in") or 0), + completion_tokens=int(u.get("tokens_out") or 0), + input_cny_per_mtoken=inp, + output_cny_per_mtoken=outp, + cache_hit_tokens=int(u.get("cache_hit_tokens") or 0), + cache_hit_cny_per_mtoken=chp, + ) + old_cost = Decimal(str(e.cost_cny)) + old_sum[profile] += old_cost + new_sum[profile] += new_cost + if new_cost != old_cost: + e.cost_cny = new_cost + stat_changed[profile] += 1 + + if args.apply: + s.commit() + else: + s.rollback() + + print() + print(f"{'model_profile':<22}{'events':>8}{'changed':>9}{'skipped':>9}" + f"{'old_¥':>12}{'new_¥':>12}") + tot_old = Decimal("0") + tot_new = Decimal("0") + for profile in sorted(stat_n): + o, n = old_sum[profile], new_sum[profile] + tot_old += o + tot_new += n + print(f"{profile:<22}{stat_n[profile]:>8}{stat_changed[profile]:>9}" + f"{stat_skipped[profile]:>9}{float(o):>12.4f}{float(n):>12.4f}") + print(f"{'TOTAL':<22}{sum(stat_n.values()):>8}" + f"{sum(stat_changed.values()):>9}{sum(stat_skipped.values()):>9}" + f"{float(tot_old):>12.4f}{float(tot_new):>12.4f}") + print() + print(f"[mode] {'APPLIED (committed)' if args.apply else 'DRY-RUN (no commit, rerun with --apply)'}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_usage_accounting.py b/tests/test_usage_accounting.py index 8ba8ab1..a2dceeb 100644 --- a/tests/test_usage_accounting.py +++ b/tests/test_usage_accounting.py @@ -33,6 +33,31 @@ class UsageAccountingTests(unittest.TestCase): self.assertEqual(cost, Decimal("6.000000")) + def test_fallback_chat_cost_discounts_cache_hits(self) -> None: + # 100 万输入里 80 万命中缓存(0.1 价),20 万未命中(1.0 价),50 万输出(10 价) + cost = _fallback_chat_cost_cny( + prompt_tokens=1_000_000, + completion_tokens=500_000, + input_cny_per_mtoken=1.0, + output_cny_per_mtoken=10.0, + cache_hit_tokens=800_000, + cache_hit_cny_per_mtoken=0.1, + ) + # 0.2(miss) + 0.08(hit) + 5.0(out) = 5.28 + self.assertEqual(cost, Decimal("5.280000")) + + def test_fallback_chat_cost_no_cache_price_charges_full(self) -> None: + # 未配缓存价(0)→ 命中段不打折,按 input 全价(老行为,绝不少记) + cost = _fallback_chat_cost_cny( + prompt_tokens=1_000_000, + completion_tokens=0, + input_cny_per_mtoken=1.0, + output_cny_per_mtoken=10.0, + cache_hit_tokens=900_000, + cache_hit_cny_per_mtoken=0.0, + ) + self.assertEqual(cost, Decimal("1.000000")) + if __name__ == "__main__": unittest.main() diff --git a/web/app.py b/web/app.py index 53b194a..0d1a6ff 100644 --- a/web/app.py +++ b/web/app.py @@ -26,7 +26,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel -from sqlalchemy import func, select, update +from sqlalchemy import BigInteger, cast, func, select, update from starlette.background import BackgroundTask from core.paths import to_db_path @@ -35,7 +35,7 @@ from core.storage import ( check_no_subtask, session_scope, ) -from core.storage.models import Message, Task +from core.storage.models import Message, Task, UsageEvent from core.storage.utils import ensure_local_task_row from .auth import ( @@ -93,8 +93,47 @@ def _parse_ordering(s: Optional[str]) -> list: return cols -def _task_dict(row: Any, *, n_messages: Optional[int] = None) -> dict: - """Task ORM row → API JSON dict。""" +def _usage_aggregates(s: Any, tids: list) -> dict: + """按 task_id 批量聚合 usage_events:真实成本 + 缓存命中 token。 + + 单查询 GROUP BY(复用列表接口 msg_counts 同款批量范式,无 N+1)。on-the-fly 现算, + 不落 tasks 列 —— 对所有历史 task 即时准确,免回填。 + - cost_cny:全 kind(chat+image+video)合计 = task 真实花费 + - cache_hit:仅 chat,units.cache_hit_tokens 之和(DeepSeek 等前缀缓存命中部分) + 返回 {task_id: {"cost_cny": float, "tokens_cache_hit": int}}。 + """ + if not tids: + return {} + cache_hit_col = cast(UsageEvent.units["cache_hit_tokens"].astext, BigInteger) + rows = s.execute( + select( + UsageEvent.task_id, + func.coalesce(func.sum(UsageEvent.cost_cny), 0), + func.coalesce( + func.sum(cache_hit_col).filter(UsageEvent.kind == "chat"), 0 + ), + ) + .where(UsageEvent.task_id.in_(tids)) + .group_by(UsageEvent.task_id) + ).all() + return { + tid: {"cost_cny": float(cost or 0), "tokens_cache_hit": int(hit or 0)} + for tid, cost, hit in rows + } + + +def _task_dict( + row: Any, + *, + n_messages: Optional[int] = None, + usage: Optional[dict] = None, +) -> dict: + """Task ORM row → API JSON dict。 + + `usage`(可选)= `_usage_aggregates` 算出的本 task 概要,带真实成本与缓存命中; + 缺省回退到 tasks.cost_cny 列(多为 0)与 0 命中,前端据此显 ¥ / 缓存命中率。 + """ + u = usage or {} d = { "task_id": str(row.task_id), "name": row.name or "", @@ -107,6 +146,10 @@ def _task_dict(row: Any, *, n_messages: Optional[int] = None) -> dict: "tokens_prompt": row.tokens_prompt or 0, "tokens_completion": row.tokens_completion or 0, "tokens": (row.tokens_prompt or 0) + (row.tokens_completion or 0), + # 缓存命中 token(chat 前缀缓存)+ 真实成本(已按缓存折价,见 usage.py)。 + # on-the-fly 聚合;未传 usage 时回退列/0。 + "tokens_cache_hit": int(u.get("tokens_cache_hit", 0)), + "cost_cny": float(u["cost_cny"]) if "cost_cny" in u else float(row.cost_cny or 0), # 当前 run 状态(0004 schema 简化:原 runs 表合并入 task) "run_status": row.run_status or "idle", "run_error": row.run_error or None, @@ -914,13 +957,18 @@ def create_app() -> FastAPI: ).all()) if tids else {} ) + usage = _usage_aggregates(s, tids) return { "page": page, "page_size": page_size, "count": int(cnt), "results": [ - _task_dict(r, n_messages=msg_counts.get(r.task_id, 0)) + _task_dict( + r, + n_messages=msg_counts.get(r.task_id, 0), + usage=usage.get(r.task_id), + ) for r in rows ], } @@ -941,7 +989,8 @@ def create_app() -> FastAPI: n = s.execute( select(func.count()).select_from(Message).where(Message.task_id == tid) ).scalar_one() - return _task_dict(row, n_messages=n) + usage = _usage_aggregates(s, [tid]) + return _task_dict(row, n_messages=n, usage=usage.get(tid)) @app.get("/v1/folders", tags=["folders"]) def list_folders(user_id: UUID = Depends(require_user)): @@ -1090,7 +1139,8 @@ def create_app() -> FastAPI: n = s.execute( select(func.count()).select_from(Message).where(Message.task_id == tid) ).scalar_one() - return _task_dict(row, n_messages=n) + usage = _usage_aggregates(s, [tid]) + return _task_dict(row, n_messages=n, usage=usage.get(tid)) # ───────────── Messages ───────────── diff --git a/web/static/dev.html b/web/static/dev.html index c80bfef..7f9149a 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -1218,6 +1218,33 @@ function fmtTokens(n) { return (n / 1000000).toFixed(1) + "M"; } +// 紧凑成本显示(¥,已按缓存折价的真实花费):0 不显;<0.01 三位小数;否则两位 +function fmtCost(n) { + n = n || 0; + if (n <= 0) return ""; + if (n < 0.01) return "¥" + n.toFixed(3); + return "¥" + n.toFixed(2); +} + +// 任务级累计用量(顶栏):总 token · 缓存命中率 · 真实花费。 +// 缓存命中率 = cache_hit / 总输入(tokens_prompt);命中越高说明前缀复用越好、越省钱。 +function formatTaskUsage(t) { + const tok = t.tokens || 0; + if (!tok) return ""; + const hit = t.tokens_cache_hit || 0; + const pin = t.tokens_prompt || 0; + const bits = [`${fmtTokens(tok)} tok`]; + if (pin > 0 && hit > 0) { + bits.push(`缓存命中 ${Math.round(hit / pin * 100)}%`); + } + const cost = fmtCost(t.cost_cny); + if (cost) bits.push(cost); + const title = `累计:输入 ${pin.toLocaleString()} / 输出 ${(t.tokens_completion || 0).toLocaleString()} tok` + + (hit > 0 ? `\n前缀缓存命中 ${hit.toLocaleString()} tok(命中部分按低价计费)` : "") + + (t.cost_cny > 0 ? `\n真实花费 ¥${(t.cost_cny).toFixed(4)}(已按缓存命中折价)` : ""); + return `${bits.join(" · ")}`; +} + function formatContextStats(d) { d = d || {}; const orig = d.context_original_chars || 0; @@ -1729,7 +1756,8 @@ function renderTaskList(tasks, append = false) { ${statusLabel} ${t.skill ? `${escapeHtml(t.skill)}` : ""} ${t.n_messages || 0} 条 - ${fmtTokens(t.tokens)} tok + ${fmtTokens(t.tokens)} tok + ${t.cost_cny > 0 ? `${fmtCost(t.cost_cny)}` : ""} ${escapeHtml(fmtTimeAgo(t.updated_at))} @@ -1889,6 +1917,7 @@ function renderChatMeta() { ${wdBadge} ${t.skill ? `${escapeHtml(t.skill)}` : ""} ${t.task_id.slice(0, 8)} + ${formatTaskUsage(t)} ${t.description ? `${escapeHtml(t.description)}` : ""} ${renderModelDropdown(t)}