别再手算 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();
}, []);
界面上同时显示 pricingVersion 和 loadedAt。价格快照超过设定时长后,计算按钮仍可保留,但结果必须标注"基于旧快照",不能把历史缓存展示成实时价格。
四、结果要能被复算,而不是只有一个总数
结果卡片应分别显示输入、输出、缓存三项费用,并附模型、分组、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、历史提示词或用户业务内容带入日志。成本工具只有在数字可复算、状态可解释、失败可恢复时,才真正减少工程沟通成本。