别再手算 Token 成本:用 React 状态机做一个可追溯的模型费用计算器

别再手算 Token 成本:用 React 状态机做一个可追溯的模型费用计算器

模型价格页能回答"单价是多少",但工程团队更常问的是另一件事:这批任务跑完大概要花多少?只拿输入单价乘总 Token 会漏掉输出倍率、缓存倍率、分组倍率,遇到阶梯计费还可能直接算错。

这篇文章不做静态价格表,而是实现一个可交互、可复算、能识别失效数据的前端成本计算器。输入是模型、分组和三类 Token 数量,输出不仅有总价,还要保留计算依据与价格版本。

一、先把状态分成四类

页面至少包含四类状态:远端价格快照、用户输入、计算结果、异常信息。把它们混在多个 useState 中,很容易出现"模型已切换,但结果仍使用旧分组"的瞬时错误。

ts 复制代码
type CalculatorState = {
  pricingVersion: string | null;
  loadedAt: string | null;
  model: string;
  group: string;
  inputTokens: number;
  outputTokens: number;
  cachedTokens: number;
  result: CostResult | null;
  error: string | null;
};

type Action =
  | { type: 'pricingLoaded'; version: string; loadedAt: string }
  | { type: 'fieldChanged'; field: keyof CalculatorState; value: string | number }
  | { type: 'calculated'; result: CostResult }
  | { type: 'failed'; message: string };

useReducer 的价值不是代码更"高级",而是一次动作只产生一次可解释的状态迁移。后续加入 URL 参数、历史方案或埋点时,也能知道结果是由哪次输入触发的。

二、把价格换算写成纯函数

实时接口返回模型倍率、输出倍率、缓存倍率和分组倍率。普通按 Token 计费的模型可以归一化为每百万 Token 的三类价格;含 billing_expr 的模型必须转交阶梯计费解释器,不能硬套公式。

ts 复制代码
const BASE_PRICE = 2;

function normalizePrice(model: PricingModel, groupRatio: number) {
  if (model.billing_expr) {
    return { mode: 'tiered' as const, expression: model.billing_expr };
  }

  const input = BASE_PRICE * model.model_ratio * groupRatio;
  return {
    mode: 'token' as const,
    input,
    output: input * model.completion_ratio,
    cache: input * model.cache_ratio,
  };
}

function calculateCost(price: TokenPrice, usage: Usage) {
  const million = 1_000_000;
  const input = usage.inputTokens / million * price.input;
  const output = usage.outputTokens / million * price.output;
  const cache = usage.cachedTokens / million * price.cache;
  return { input, output, cache, total: input + output + cache };
}

这里有两个边界必须前置:Token 输入只能是有限的非负数;缓存 Token 不能被同时计入普通输入。若日志口径中的输入已经包含缓存,需要先拆分成"未缓存输入"和"缓存输入",否则会重复计费。

三、避免旧请求覆盖新选择

用户连续切换模型时,前一个请求可能后返回。最简单的处理是给每次加载绑定 AbortController,卸载或条件变化时取消旧请求。

ts 复制代码
useEffect(() => {
  const controller = new AbortController();

  fetch('https://yufish.cc/api/pricing', { signal: controller.signal })
    .then(r => {
      if (!r.ok) throw new Error(`pricing http ${r.status}`);
      return r.json();
    })
    .then(payload => dispatch({
      type: 'pricingLoaded',
      version: payload.pricing_version,
      loadedAt: new Date().toISOString(),
    }))
    .catch(error => {
      if (error.name !== 'AbortError') {
        dispatch({ type: 'failed', message: error.message });
      }
    });

  return () => controller.abort();
}, []);

界面上同时显示 pricingVersionloadedAt。价格快照超过设定时长后,计算按钮仍可保留,但结果必须标注"基于旧快照",不能把历史缓存展示成实时价格。

四、结果要能被复算,而不是只有一个总数

结果卡片应分别显示输入、输出、缓存三项费用,并附模型、分组、Token 数量、价格版本和计算时间。用户复制结果时,复制的是一份最小审计记录,而不是孤立的"合计 1.425"。

ts 复制代码
const auditRecord = {
  model: state.model,
  group: state.group,
  usage,
  unitPrice: price,
  pricingVersion: state.pricingVersion,
  calculatedAt: new Date().toISOString(),
  result,
};
localStorage.setItem('last-cost-calculation', JSON.stringify(auditRecord));

localStorage 只适合保存最近一次非敏感估算,不应存 API Key、请求正文或用户数据。需要团队共享时,再把审计记录提交到有权限控制的后端。

五、用三组测试保护计算边界

至少覆盖三个用例:普通输入/输出/缓存同时存在;缓存为零;阶梯计费模型拒绝进入固定公式。对浮点结果使用容差断言,不比较格式化后的字符串。

ts 复制代码
expect(calculateCost(
  { input: 2.5, output: 12.5, cache: 0.25 },
  { inputTokens: 300_000, outputTokens: 50_000, cachedTokens: 200_000 },
).total).toBeCloseTo(1.425, 8);

最后再做一次真实回读:打开 FishAI 模型与价格页,选择同一模型和分组,对照三类单价;随后用公开价格接口的版本号确认页面与计算器来自同一快照。这样,计算器给出的不是"看起来合理"的数字,而是一条从接口、状态、公式到结果都能追溯的链路。

这套结构同样适合配额估算、图片按张计费和视频按秒计费:先把计费模式归一化,再让状态机决定哪个计算器接管,避免一个万能公式吞掉所有差异。

六、界面只保留三层信息

计算器很容易变成"字段大卖场"。更实用的做法是把界面分成三层:第一层只放模型、分组和三类 Token 输入;第二层展示合计与三项拆分;第三层折叠保存价格版本、计算时间、公式和原始输入。普通用户先得到答案,需要核验的人再展开依据。

输入框不要依赖占位符表达单位,应在标签中明确"输入 Token""输出 Token""缓存 Token"。金额统一保留足够的计算精度,展示时再格式化;不要先把每一项四舍五入后再求和。对于无缓存价格、按次计费或阶梯计费模型,禁用不适用的字段并解释原因,比返回零更不容易误导。

结果变化后使用 aria-live="polite" 通知辅助技术,但模型列表加载、输入变更和结果更新不要同时播报。移动端把三类费用改为纵向卡片,复制审计记录和重新加载价格放在次要操作区,避免误触清空输入。

七、上线前做一次故障演练

正常计算通过不代表组件可靠。至少手动验证五种失败场景:价格接口超时、返回空模型、当前分组不存在、输入超过安全上限、用户加载价格后长时间未刷新。每种失败都要给出下一步,而不是只显示"计算失败"。

例如接口超时时保留上一次快照,并明确标注时间;分组不存在时停止计算并要求重新选择;输入超限时在字段旁提示允许范围,不发送请求;价格版本变化时先刷新单价,再由用户主动重新计算,避免后台悄悄改变已经保存的审计结果。

上线验收也分两层:单元测试保护纯函数和边界,端到端测试负责"选择模型---输入用量---得到拆分结果---复制记录"的完整路径。最后用浏览器网络面板确认页面只请求公开价格数据,没有把 API Key、历史提示词或用户业务内容带入日志。成本工具只有在数字可复算、状态可解释、失败可恢复时,才真正减少工程沟通成本。

相关推荐
右耳朵猫AI6 小时前
Web前端周刊2026W37 | Shopify 转原生、React 编译 Rust 化、Vitest 5.0、Rslib 1.0
前端·javascript·react.js·typescript·node.js
csj506 小时前
前端基础之《React(13)—表单绑定、列表渲染》
前端·react.js
csj506 小时前
前端基础之《React(12)—条件渲染》
前端·react.js
晚安日记wanna1 天前
SSR 为什么不适合登录态从水合冲突到缓存串号
前端·react.js·面试
晚安日记wanna1 天前
TS const 类型参数一道面试题的四层追问
前端·面试·typescript
掰头战士1 天前
你说你折这玩意干什么-agent工作的核心,还真得好好考虑怎么折叠上下文
typescript·llm·agent
ynchyong1 天前
VUE 中 不能将类型“R[]”分配给类型“UnwrapRefSimple<R>[]”
typescript·vue·ts·unwraprefsimple
掰头战士1 天前
让散乱的工具调用成为正规军,今天咱们聊聊 Tool System管线的读写锁
typescript·llm·agent
半糖程序员1 天前
从零构建 Agent(5):让事件流同时提供过程和结果
typescript·agent