Files
Cyrene_v2/frontend/web/src/admin/modules/llm/LlmUsageCard.tsx
T
AskaEth be38651ba4
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(llm): 用量落库 + 只读汇总端点 + 面板用量小卡(契约 v0.64)
- 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 侧在请求上打标,留列后接
2026-10-04 22:16:26 +08:00

135 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}