import { useAsyncData } from "../../../lib/useAsyncData"; import { adminLlmUsage } from "../../../lib/api"; import { Notice } from "../../components"; import { Surface, Tag } from "../../../components/primitives"; import { Icon } from "../../../components/Icon"; import { useAutoReload } from "../../hooks"; /** LLM 用量小卡(切片 U1,契约 v0.64):近 24h 的**调用数 / 输入 token / 缓存 * 命中率**,数源是只读端点 `GET /api/v1/admin/llm/usage`。 * * 三条纪律: * * 1. **失败显示错误,绝不显示 0** —— 端点没接通或报错时渲染错误态,而不是把 * 「读不到」画成「用量为零」(那正是这块卡要消灭的错觉)。 * 2. **缓存命中率只在有输入侧明细时才给数** —— 窗口内 `cacheHit + cacheMiss` * 为 0 时显示 `—` 并说明原因:后端口径是「分母 0 ⇒ 0」,但把「没有明细」 * 写成「0% 命中」会让人以为缓存坏了。 * 3. **取数与其它卡同一条路** —— 复用 `useAsyncData` 的加载/错误态与 * `useAutoReload` 的慢轮询,不另造一套状态机。 */ /** 命中率显示口径:有输入侧明细才给百分比,否则一个诚实的破折号。 */ function hitRateLabel(totals: { cacheHit: number; cacheMiss: number; hitRate: number }): string { if (totals.cacheHit + totals.cacheMiss <= 0) return "—"; return `${(totals.hitRate * 100).toFixed(1)}%`; } /** token 数的人类可读量级(K/M),保留一位小数;0 如实显示 0。 */ function formatTokens(value: number): string { if (!Number.isFinite(value) || value <= 0) return "0"; if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`; return String(value); } /** 时间窗 → 「MM-DD HH:mm ~ MM-DD HH:mm(UTC)」。解析不出来的原样显示。 */ function formatWindow(since: string, until: string): string { const fmt = (iso: string): string => { const d = new Date(iso); if (Number.isNaN(d.getTime())) return iso; const pad = (n: number) => String(n).padStart(2, "0"); return `${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`; }; return `${fmt(since)} ~ ${fmt(until)}(UTC)`; } /** 一格指标:标签 + 数值 + 可选补充说明。 */ function UsageCell({ label, value, hint }: { label: string; value: string; hint: string }): React.ReactNode { return (
{label}
{value}
{hint}
); } export function LlmUsageCard(): React.ReactNode { const usage = useAsyncData(adminLlmUsage, []); // 成本/用量变化很慢,比供应商列表再放慢一档(仍是同一个 useAutoReload)。 useAutoReload(usage.reload, 60000); const header = (

用量(近 24h)

{usage.error ? "端点报错" : usage.data ? "已接入" : "加载中"}
); if (usage.loading) { return ( {header}

加载中…

); } if (usage.data == null) { // 错误态:显示端点自己的错误文本,**不回落成 0**。 return ( {header} 读取 LLM 用量失败:{usage.error ?? "未知错误"} —— 这是错误态,不是「用量为零」。 ); } const { totals, window } = usage.data; return ( {header} {usage.error ? ( 刷新失败,下方仍是上次成功读取的用量:{usage.error} ) : null}
0 ? `命中 ${formatTokens(totals.cacheHit)} / 未命中 ${formatTokens(totals.cacheMiss)} token` : "本窗口没有输入侧缓存明细(不是 0% 命中)" } />

窗口 {formatWindow(window.since, window.until)} {window.purpose ? ` · 用途过滤 ${window.purpose}` : ""} {usage.data.byModel.length > 0 ? ` · 模型 ${usage.data.byModel.map((m) => `${m.model} ${m.calls} 次`).join(",")}` : ""}

每次成功模型调用都会把一条**非敏感**用量样本(token 计数 / 缓存两档 / 模型名 / 用途 / 耗时)异步落进本机 `llm_usage` 表:写入走有界队列 + 单写者批量事务,队列满即丢弃并告警,**绝不阻塞模型调用**;**凭据与消息正文绝不入库、绝不进日志**。 命中率口径 = 命中 /(命中 + 未命中),分母为 0 时按 0 处理。
); }