feat(usage): 新增输出速度统计与请求详情性能分析

- 把 upstream_is_stream 物化到 usage 与 billing facts,避免 Provider 聚合回连 public.usage
- 统一前端标准/流式 TPS 计算与显示,流式按首字后生成耗时计算
- 新增请求详情抽屉展示单请求输出速度与 Provider 聚合 TPS
This commit is contained in:
mayrain
2026-05-05 17:34:12 +08:00
parent f0197b685f
commit 6b6d32b4a3
14 changed files with 712 additions and 64 deletions
@@ -179,6 +179,47 @@
<span class="text-lg font-bold">{{ detail.response_time_ms ? formatResponseTime(detail.response_time_ms).value : 'N/A' }}</span>
<span class="text-sm text-muted-foreground ml-1">{{ detail.response_time_ms ? formatResponseTime(detail.response_time_ms).unit : '' }}</span>
</div>
<template v-if="detailOutputRate != null">
<Separator
orientation="vertical"
class="h-6 mx-6"
/>
<div class="flex items-center">
<span class="text-xs text-muted-foreground w-[56px]">输出速度</span>
<span class="text-lg font-bold text-primary">{{ formatOutputRateValue(detailOutputRate) }}</span>
<span class="text-sm text-muted-foreground ml-1">tps</span>
</div>
</template>
</div>
<div
v-if="hasDetailPerformanceBreakdown"
class="grid grid-cols-1 sm:grid-cols-3 gap-2 mb-4 text-xs"
>
<div class="rounded-md border border-border/50 bg-muted/20 px-3 py-2">
<div class="text-muted-foreground mb-1">
首字时间
</div>
<div class="font-mono text-foreground">
{{ formatDurationMs(detail.first_byte_time_ms) }}
</div>
</div>
<div class="rounded-md border border-border/50 bg-muted/20 px-3 py-2">
<div class="text-muted-foreground mb-1">
生成耗时
</div>
<div class="font-mono text-foreground">
{{ formatDurationMs(detailGenerationTimeMs) }}
</div>
</div>
<div class="rounded-md border border-border/50 bg-muted/20 px-3 py-2">
<div class="text-muted-foreground mb-1">
输出 Tokens
</div>
<div class="font-mono text-foreground">
{{ formatNumber(detailOutputTokens) }}
</div>
</div>
</div>
<!-- 分隔线 -->
@@ -714,6 +755,13 @@ import { formatApiFormat } from '@/api/endpoints/types/api-format'
import { formatShortRequestId } from '@/utils/format'
import { log } from '@/utils/logger'
import { getEffectiveInputTokens } from '../token-normalization'
import {
formatDurationMs,
formatOutputRate,
formatOutputRateValue,
getDisplayOutputRate,
getGenerationTimeMs,
} from '../performance'
import {
formatUsageStreamLabel,
isUsageUpstreamStream,
@@ -892,6 +940,37 @@ const displayInputTokens = computed(() => {
})
})
const detailOutputTokens = computed(() => {
if (!detail.value) return 0
return detail.value.tokens?.output ?? detail.value.output_tokens ?? 0
})
const detailGenerationTimeMs = computed(() => {
if (!detail.value) return null
return getGenerationTimeMs({
response_time_ms: detail.value.response_time_ms,
first_byte_time_ms: detail.value.first_byte_time_ms,
is_stream: detail.value.is_stream,
upstream_is_stream: detail.value.upstream_is_stream,
})
})
const detailOutputRate = computed(() => {
if (!detail.value) return null
return getDisplayOutputRate({
output_tokens: detailOutputTokens.value,
response_time_ms: detail.value.response_time_ms,
first_byte_time_ms: detail.value.first_byte_time_ms,
is_stream: detail.value.is_stream,
upstream_is_stream: detail.value.upstream_is_stream,
})
})
const hasDetailPerformanceBreakdown = computed(() => {
if (!detail.value) return false
return detail.value.first_byte_time_ms != null || detailGenerationTimeMs.value != null || detailOutputTokens.value > 0
})
// 监听标签页切换
watch(activeTab, (newTab) => {
if (!['request-headers', 'response-headers'].includes(newTab) && viewMode.value === 'compare') {
@@ -273,8 +273,15 @@
/></span>
<span
v-else-if="record.response_time_ms != null || record.first_byte_time_ms != null"
class="tabular-nums"
>{{ record.first_byte_time_ms != null ? (record.first_byte_time_ms / 1000).toFixed(1) + '/' : '' }}{{ record.response_time_ms != null ? (record.response_time_ms / 1000).toFixed(1) : '-' }}{{ record.response_time_ms != null ? 's' : '' }}</span>
class="flex flex-col items-end tabular-nums leading-3 shrink-0"
:title="getRecordPerformanceTitle(record)"
>
<span class="whitespace-nowrap">{{ formatRecordLatencyPair(record) }}</span>
<span
v-if="getRecordDisplayOutputRate(record) != null"
class="text-primary/80 whitespace-nowrap"
>{{ formatOutputRate(getRecordDisplayOutputRate(record)) }}</span>
</span>
<span
v-else
class="tabular-nums"
@@ -288,17 +295,17 @@
</div>
<!-- 桌面端表格视图 -->
<Table :class="isAdmin ? 'hidden md:table table-fixed min-w-[1288px]' : 'hidden md:table table-fixed min-w-[1178px]'">
<Table :class="isAdmin ? 'hidden md:table table-fixed min-w-[1304px]' : 'hidden md:table table-fixed min-w-[1218px]'">
<colgroup v-if="isAdmin">
<col class="w-[88px]">
<col class="w-[180px]">
<col class="w-[190px]">
<col class="w-[160px]">
<col class="w-[190px]">
<col class="w-[120px]">
<col class="w-[108px]">
<col class="w-[150px]">
<col class="w-[120px]">
<col class="w-[90px]">
<col class="w-[108px]">
<col class="w-[130px]">
</colgroup>
<colgroup v-else>
<col class="w-[88px]">
@@ -308,7 +315,7 @@
<col class="w-[120px]">
<col class="w-[150px]">
<col class="w-[120px]">
<col class="w-[90px]">
<col class="w-[130px]">
</colgroup>
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
@@ -396,7 +403,7 @@
</template>
</SortableTableHead>
<SortableTableHead
class="h-12 font-semibold w-[110px] text-center"
class="h-12 font-semibold w-[108px] text-center"
column-key="status"
:sortable="false"
align="center"
@@ -417,13 +424,13 @@
<TableHead class="h-12 font-semibold w-[140px] text-right">
Tokens
</TableHead>
<TableHead class="h-12 font-semibold w-[100px] text-right">
<TableHead class="h-12 font-semibold w-[108px] text-right">
费用
</TableHead>
<TableHead class="h-12 font-semibold w-[70px] text-right">
<TableHead class="h-12 font-semibold w-[130px] text-right">
<div class="flex flex-col items-end text-xs gap-0.5">
<span>首字</span>
<span class="text-muted-foreground font-normal">总耗时</span>
<span class="whitespace-nowrap">首字/总耗时</span>
<span class="text-muted-foreground font-normal">输出速度</span>
</div>
</TableHead>
</TableRow>
@@ -605,7 +612,7 @@
class="text-muted-foreground text-xs"
>-</span>
</TableCell>
<TableCell class="text-center py-4 w-[70px]">
<TableCell class="text-center py-4 w-[108px]">
<!-- 优先显示请求状态 -->
<Badge
v-if="getDisplayStatus(record) === 'pending'"
@@ -670,7 +677,7 @@
</div>
</div>
</TableCell>
<TableCell class="text-right py-4 w-[100px]">
<TableCell class="text-right py-4 w-[108px]">
<div class="flex flex-col items-end text-xs gap-0.5">
<span class="text-primary font-medium">{{ formatCurrency(record.cost || 0) }}</span>
<span
@@ -681,7 +688,7 @@
</span>
</div>
</TableCell>
<TableCell class="text-right py-4 w-[70px]">
<TableCell class="text-right py-4 w-[130px]">
<!-- pending 状态:只显示增长的总时间 -->
<div
v-if="getDisplayStatus(record) === 'pending'"
@@ -717,23 +724,13 @@
<div
v-else-if="record.response_time_ms != null || record.first_byte_time_ms != null"
class="flex flex-col items-end text-xs gap-0.5"
:title="getRecordPerformanceTitle(record)"
>
<span class="tabular-nums whitespace-nowrap">{{ formatRecordLatencyPair(record) }}</span>
<span
v-if="record.first_byte_time_ms != null"
class="tabular-nums"
>{{ (record.first_byte_time_ms / 1000).toFixed(2) }}s</span>
<span
v-else
class="text-muted-foreground"
>-</span>
<span
v-if="record.response_time_ms != null"
class="text-muted-foreground tabular-nums"
>{{ (record.response_time_ms / 1000).toFixed(2) }}s</span>
<span
v-else
class="text-muted-foreground"
>-</span>
v-if="getRecordDisplayOutputRate(record) != null"
class="text-primary/80 tabular-nums whitespace-nowrap"
>{{ formatOutputRate(getRecordDisplayOutputRate(record)) }}</span>
</div>
<span
v-else
@@ -787,6 +784,13 @@ import { RefreshCcw, Search } from 'lucide-vue-next'
import { formatTokens, formatCurrency } from '@/utils/format'
import { formatDateTime } from '../composables'
import { getCacheCreationTokens, getCacheReadTokens, getEffectiveInputTokens } from '../token-normalization'
import {
formatDurationMs,
formatOutputRate,
getDisplayOutputRate,
getGenerationTimeMs,
isOutputRateUsingGenerationTime,
} from '../performance'
import {
formatUsageStreamLabel,
isUsageRecordFailed,
@@ -978,6 +982,27 @@ function getRecordCacheCreationTokens(record: UsageRecord): number {
return getCacheCreationTokens(record)
}
function getRecordDisplayOutputRate(record: UsageRecord): number | null {
return getDisplayOutputRate(record)
}
function formatRecordLatencyPair(record: UsageRecord): string {
const firstByte = formatDurationMs(record.first_byte_time_ms)
const total = formatDurationMs(record.response_time_ms)
return `${firstByte} / ${total}`
}
function getRecordPerformanceTitle(record: UsageRecord): string {
const outputRate = getRecordDisplayOutputRate(record)
const outputRateBasis = isOutputRateUsingGenerationTime(record) ? '首字后生成耗时' : '总耗时'
return [
`首字: ${formatDurationMs(record.first_byte_time_ms)}`,
`总耗时: ${formatDurationMs(record.response_time_ms)}`,
`生成耗时: ${formatDurationMs(getGenerationTimeMs(record))}`,
`输出速度: ${formatOutputRate(outputRate)} (${outputRateBasis})`,
].join('\n')
}
function hasPositiveTokens(value: number | null | undefined): boolean {
return typeof value === 'number' && Number.isFinite(value) && value > 0
}