mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat(usage): 新增输出速度统计与请求详情性能分析
- 把 upstream_is_stream 物化到 usage 与 billing facts,避免 Provider 聚合回连 public.usage - 统一前端标准/流式 TPS 计算与显示,流式按首字后生成耗时计算 - 新增请求详情抽屉展示单请求输出速度与 Provider 聚合 TPS
This commit is contained in:
@@ -207,6 +207,7 @@ export interface RequestDetail {
|
||||
errors?: RequestErrorDomains | null
|
||||
error_flow?: RequestErrorFlow | null
|
||||
response_time_ms: number
|
||||
first_byte_time_ms?: number | null
|
||||
created_at: string
|
||||
request_headers?: Record<string, unknown>
|
||||
request_body?: Record<string, unknown>
|
||||
|
||||
101
frontend/src/features/usage/__tests__/performance.spec.ts
Normal file
101
frontend/src/features/usage/__tests__/performance.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
calculateOutputRate,
|
||||
formatDurationMs,
|
||||
formatOutputRate,
|
||||
formatOutputRateValue,
|
||||
getDisplayOutputRate,
|
||||
getGenerationTimeMs,
|
||||
getOutputRateDurationMs,
|
||||
shouldHideOutputRate,
|
||||
} from '../performance'
|
||||
|
||||
describe('usage performance metrics', () => {
|
||||
it('calculates generation time after first byte', () => {
|
||||
expect(getGenerationTimeMs({
|
||||
response_time_ms: 1000,
|
||||
first_byte_time_ms: 250,
|
||||
})).toBe(750)
|
||||
})
|
||||
|
||||
it('calculates stream output tokens per generated second after first byte', () => {
|
||||
expect(calculateOutputRate({
|
||||
output_tokens: 50,
|
||||
response_time_ms: 1000,
|
||||
first_byte_time_ms: 500,
|
||||
upstream_is_stream: true,
|
||||
})).toBe(100)
|
||||
})
|
||||
|
||||
it('calculates standard output tokens per total response second', () => {
|
||||
const timing = {
|
||||
output_tokens: 50,
|
||||
response_time_ms: 1000,
|
||||
first_byte_time_ms: 500,
|
||||
upstream_is_stream: false,
|
||||
}
|
||||
|
||||
expect(getOutputRateDurationMs(timing)).toBe(1000)
|
||||
expect(calculateOutputRate(timing)).toBe(50)
|
||||
expect(getDisplayOutputRate(timing)).toBe(50)
|
||||
})
|
||||
|
||||
it('does not calculate output rate without output tokens', () => {
|
||||
expect(calculateOutputRate({
|
||||
output_tokens: 0,
|
||||
response_time_ms: 1000,
|
||||
first_byte_time_ms: 500,
|
||||
})).toBeNull()
|
||||
})
|
||||
|
||||
it('does not calculate output rate when first byte is not before completion', () => {
|
||||
expect(calculateOutputRate({
|
||||
output_tokens: 50,
|
||||
response_time_ms: 1000,
|
||||
first_byte_time_ms: 1000,
|
||||
upstream_is_stream: true,
|
||||
})).toBeNull()
|
||||
})
|
||||
|
||||
it('hides implausible rates from very short generation tails', () => {
|
||||
const timing = {
|
||||
output_tokens: 300,
|
||||
response_time_ms: 1000,
|
||||
first_byte_time_ms: 950,
|
||||
upstream_is_stream: true,
|
||||
}
|
||||
const rate = calculateOutputRate(timing)
|
||||
|
||||
expect(rate).toBe(6000)
|
||||
expect(shouldHideOutputRate(rate, timing)).toBe(true)
|
||||
expect(getDisplayOutputRate(timing)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps normal output rates visible', () => {
|
||||
const timing = {
|
||||
output_tokens: 50,
|
||||
response_time_ms: 1000,
|
||||
first_byte_time_ms: 500,
|
||||
upstream_is_stream: true,
|
||||
}
|
||||
|
||||
expect(getDisplayOutputRate(timing)).toBe(100)
|
||||
})
|
||||
|
||||
it('calculates standard output rate without first byte timing', () => {
|
||||
expect(getDisplayOutputRate({
|
||||
output_tokens: 50,
|
||||
response_time_ms: 1000,
|
||||
upstream_is_stream: false,
|
||||
})).toBe(50)
|
||||
})
|
||||
|
||||
it('formats durations and output rates for compact UI display', () => {
|
||||
expect(formatDurationMs(456)).toBe('456ms')
|
||||
expect(formatDurationMs(1234)).toBe('1.23s')
|
||||
expect(formatOutputRateValue(87.65)).toBe('87.7')
|
||||
expect(formatOutputRate(87.65)).toBe('87.7 tps')
|
||||
expect(formatOutputRate(1200)).toBe('1,200 tps')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
85
frontend/src/features/usage/performance.ts
Normal file
85
frontend/src/features/usage/performance.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
export interface UsagePerformanceTiming {
|
||||
output_tokens?: number | null
|
||||
response_time_ms?: number | null
|
||||
first_byte_time_ms?: number | null
|
||||
is_stream?: boolean | null
|
||||
upstream_is_stream?: boolean | null
|
||||
}
|
||||
|
||||
export function getGenerationTimeMs(timing: UsagePerformanceTiming): number | null {
|
||||
const responseTimeMs = timing.response_time_ms
|
||||
const firstByteTimeMs = timing.first_byte_time_ms
|
||||
|
||||
if (responseTimeMs == null || firstByteTimeMs == null) return null
|
||||
if (!Number.isFinite(responseTimeMs) || !Number.isFinite(firstByteTimeMs)) return null
|
||||
if (responseTimeMs <= 0 || firstByteTimeMs < 0 || firstByteTimeMs >= responseTimeMs) return null
|
||||
|
||||
return responseTimeMs - firstByteTimeMs
|
||||
}
|
||||
|
||||
export function isOutputRateUsingGenerationTime(timing: UsagePerformanceTiming): boolean {
|
||||
return timing.upstream_is_stream ?? timing.is_stream ?? false
|
||||
}
|
||||
|
||||
export function getOutputRateDurationMs(timing: UsagePerformanceTiming): number | null {
|
||||
if (isOutputRateUsingGenerationTime(timing)) {
|
||||
return getGenerationTimeMs(timing)
|
||||
}
|
||||
|
||||
const responseTimeMs = timing.response_time_ms
|
||||
if (responseTimeMs == null || !Number.isFinite(responseTimeMs) || responseTimeMs <= 0) return null
|
||||
return responseTimeMs
|
||||
}
|
||||
|
||||
export function calculateOutputRate(timing: UsagePerformanceTiming): number | null {
|
||||
const outputTokens = timing.output_tokens ?? 0
|
||||
const outputRateDurationMs = getOutputRateDurationMs(timing)
|
||||
|
||||
if (!Number.isFinite(outputTokens) || outputTokens <= 0 || outputRateDurationMs == null) return null
|
||||
|
||||
const outputRateSeconds = outputRateDurationMs / 1000
|
||||
if (outputRateSeconds <= 0) return null
|
||||
|
||||
return outputTokens / outputRateSeconds
|
||||
}
|
||||
|
||||
export function shouldHideOutputRate(
|
||||
outputRate: number | null,
|
||||
timing: UsagePerformanceTiming,
|
||||
): boolean {
|
||||
if (!isOutputRateUsingGenerationTime(timing)) return false
|
||||
|
||||
const responseTimeMs = timing.response_time_ms
|
||||
const generationTimeMs = getGenerationTimeMs(timing)
|
||||
|
||||
if (outputRate == null || responseTimeMs == null || generationTimeMs == null) return false
|
||||
if (!Number.isFinite(outputRate) || !Number.isFinite(responseTimeMs) || responseTimeMs <= 0) return true
|
||||
|
||||
return generationTimeMs / responseTimeMs < 0.1 && outputRate > 5000
|
||||
}
|
||||
|
||||
export function getDisplayOutputRate(timing: UsagePerformanceTiming): number | null {
|
||||
const outputRate = calculateOutputRate(timing)
|
||||
if (shouldHideOutputRate(outputRate, timing)) return null
|
||||
return outputRate
|
||||
}
|
||||
|
||||
export function formatDurationMs(ms: number | null | undefined, fractionDigits = 2): string {
|
||||
if (ms == null || !Number.isFinite(ms)) return '-'
|
||||
if (ms >= 1000) return `${(ms / 1000).toFixed(fractionDigits)}s`
|
||||
return `${Math.round(ms)}ms`
|
||||
}
|
||||
|
||||
export function formatOutputRateValue(outputRate: number | null | undefined): string {
|
||||
if (outputRate == null || !Number.isFinite(outputRate)) return '-'
|
||||
if (outputRate >= 1000) return Math.round(outputRate).toLocaleString()
|
||||
if (outputRate >= 100) return `${Math.round(outputRate)}`
|
||||
if (outputRate >= 10) return outputRate.toFixed(1)
|
||||
return outputRate.toFixed(2)
|
||||
}
|
||||
|
||||
export function formatOutputRate(outputRate: number | null | undefined): string {
|
||||
const value = formatOutputRateValue(outputRate)
|
||||
if (value === '-') return value
|
||||
return `${value} tps`
|
||||
}
|
||||
@@ -594,7 +594,7 @@
|
||||
{{ formatProviderPerformanceMetric(provider.success_rate, '%') }}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right">
|
||||
{{ formatProviderPerformanceMetric(provider.avg_output_tps, '/s') }}
|
||||
{{ formatProviderPerformanceMetric(provider.avg_output_tps, ' tps') }}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right">
|
||||
{{ formatProviderPerformanceMetric(provider.avg_first_byte_time_ms, 'ms') }}
|
||||
@@ -1093,7 +1093,7 @@ const providerPerformanceSummaryCards = computed(() => {
|
||||
return [
|
||||
{
|
||||
title: '输出 TPS',
|
||||
value: formatProviderPerformanceMetric(summary?.avg_output_tps, '/s'),
|
||||
value: formatProviderPerformanceMetric(summary?.avg_output_tps, ' tps'),
|
||||
hint: `请求 ${formatMetricNumber(summary?.request_count)}`,
|
||||
icon: Zap,
|
||||
iconClass: 'text-amber-500',
|
||||
@@ -1142,7 +1142,7 @@ const providerTpsChartOptions = computed(() => ({
|
||||
scales: {
|
||||
y: {
|
||||
ticks: {
|
||||
callback: (value: string | number) => `${value}/s`,
|
||||
callback: (value: string | number) => `${value} tps`,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user