- model 层新增可选 UsageSink(nil ⇒ 逐字不变,既有用例零改动) - 组合根注入 UsageWriter:有界队列 1024 + 单写者 + 批量事务(128/批、2s 兜底), 队列满丢弃 + 限流 WARN,绝不阻塞模型调用;sink 挂 RuntimeLLM(热重载不断) - 迁移 0017:表 llm_usage(13 列,session_id 可空)+ idx_created_at + idx_session_id - 新端点 GET /api/v1/admin/llm/usage(owner|admin 读;缺省近 24h、上限 90 天、 超限 400 不静默裁剪;hitRate = cacheHit/(cacheHit+cacheMiss),分母 0 ⇒ 0; 绝不含凭据与正文;未接线/查库失败 ⇒ 500 usage_unavailable) - LLM_USAGE_RETENTION(默认 720h,≤0 = 关闭清理):启动清一次 + 每小时 - 面板 LLM 页「用量(近 24h)」小卡 + Docs 两份同步 v0.64 - 已知缺口(如实登记):session_id 恒 NULL,需 loop 侧在请求上打标,留列后接
135 lines
5.8 KiB
TypeScript
135 lines
5.8 KiB
TypeScript
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 (
|
||
<div className="rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2">
|
||
<div className="text-xs text-[var(--mu-text-lo)]">{label}</div>
|
||
<div className="mt-0.5 text-lg font-semibold leading-tight">{value}</div>
|
||
<div className="mt-0.5 text-xs text-[var(--mu-text-lo)]">{hint}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function LlmUsageCard(): React.ReactNode {
|
||
const usage = useAsyncData(adminLlmUsage, []);
|
||
// 成本/用量变化很慢,比供应商列表再放慢一档(仍是同一个 useAutoReload)。
|
||
useAutoReload(usage.reload, 60000);
|
||
|
||
const header = (
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Icon name="trend" size={16} />
|
||
<h3 className="text-sm font-semibold">用量(近 24h)</h3>
|
||
<Tag tone={usage.error ? "err" : usage.data ? "ok" : "neutral"} className="ml-auto">
|
||
{usage.error ? "端点报错" : usage.data ? "已接入" : "加载中"}
|
||
</Tag>
|
||
</div>
|
||
);
|
||
|
||
if (usage.loading) {
|
||
return (
|
||
<Surface className="space-y-3">
|
||
{header}
|
||
<p className="py-4 text-center text-xs text-[var(--mu-text-lo)]">加载中…</p>
|
||
</Surface>
|
||
);
|
||
}
|
||
if (usage.data == null) {
|
||
// 错误态:显示端点自己的错误文本,**不回落成 0**。
|
||
return (
|
||
<Surface className="space-y-3">
|
||
{header}
|
||
<Notice tone="err">
|
||
读取 LLM 用量失败:{usage.error ?? "未知错误"} —— 这是错误态,不是「用量为零」。
|
||
</Notice>
|
||
</Surface>
|
||
);
|
||
}
|
||
|
||
const { totals, window } = usage.data;
|
||
return (
|
||
<Surface className="space-y-3">
|
||
{header}
|
||
{usage.error ? (
|
||
<Notice tone="err">刷新失败,下方仍是上次成功读取的用量:{usage.error}</Notice>
|
||
) : null}
|
||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||
<UsageCell
|
||
label="调用数"
|
||
value={String(totals.calls)}
|
||
hint={`成功调用 · 输出 ${formatTokens(totals.completion)} token`}
|
||
/>
|
||
<UsageCell
|
||
label="输入 token"
|
||
value={formatTokens(totals.prompt)}
|
||
hint="prompt 侧合计(含缓存命中部分)"
|
||
/>
|
||
<UsageCell
|
||
label="缓存命中率"
|
||
value={hitRateLabel(totals)}
|
||
hint={
|
||
totals.cacheHit + totals.cacheMiss > 0
|
||
? `命中 ${formatTokens(totals.cacheHit)} / 未命中 ${formatTokens(totals.cacheMiss)} token`
|
||
: "本窗口没有输入侧缓存明细(不是 0% 命中)"
|
||
}
|
||
/>
|
||
</div>
|
||
<p className="text-xs text-[var(--mu-text-lo)]">
|
||
窗口 {formatWindow(window.since, window.until)}
|
||
{window.purpose ? ` · 用途过滤 ${window.purpose}` : ""}
|
||
{usage.data.byModel.length > 0
|
||
? ` · 模型 ${usage.data.byModel.map((m) => `${m.model} ${m.calls} 次`).join(",")}`
|
||
: ""}
|
||
</p>
|
||
<Notice>
|
||
每次成功模型调用都会把一条**非敏感**用量样本(token 计数 / 缓存两档 / 模型名 / 用途 / 耗时)异步落进本机
|
||
`llm_usage` 表:写入走有界队列 + 单写者批量事务,队列满即丢弃并告警,**绝不阻塞模型调用**;**凭据与消息正文绝不入库、绝不进日志**。
|
||
命中率口径 = 命中 /(命中 + 未命中),分母为 0 时按 0 处理。
|
||
</Notice>
|
||
</Surface>
|
||
);
|
||
}
|