@leonhardthust/dsh-decision-engine 0.0.0-stage → 1.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +55 -2
- package/README.zh.md +57 -0
- package/config.example.yml +436 -0
- package/cordis.patch.yml +14 -0
- package/docs/assets/config-page.png +0 -0
- package/docs/assets/decisions-tab.png +0 -0
- package/docs/assets/hero.jpeg +0 -0
- package/docs/assets/stats-charts-preview.png +0 -0
- package/docs/assets/stats-donut-toggle.png +0 -0
- package/docs/assets/stats-interactive-charts.png +0 -0
- package/docs/assets/stats-point-donut.png +0 -0
- package/docs/assets/stats-ranking-bars.png +0 -0
- package/docs/auto-review-hotfix.md +33 -0
- package/docs/lessons/cloudflare-builds-root-directory.md +51 -0
- package/docs/lessons/cold-start-timeout.md +134 -0
- package/docs/lessons/endpoint-path-vs-model.md +26 -0
- package/docs/lessons/model-field-required-by-provider.md +31 -0
- package/docs/local-dev-link.md +54 -0
- package/docs/releasing.md +69 -0
- package/docs/security-revision.md +130 -0
- package/icon.svg +6 -0
- package/lib/adapters/agent.js +314 -0
- package/lib/adapters/route.js +144 -0
- package/lib/adapters/tools.js +186 -0
- package/lib/adapters/translate.js +143 -0
- package/lib/approval-detail.js +325 -0
- package/lib/client.charts.js +36 -0
- package/lib/client.js +8715 -0
- package/lib/config-file.js +136 -0
- package/lib/credentials.js +187 -0
- package/lib/decision-cache.js +188 -0
- package/lib/decision-points.js +205 -0
- package/lib/decision-store.js +774 -0
- package/lib/endpoint-fallback.js +143 -0
- package/lib/engine.js +312 -0
- package/lib/guard/advisory.js +100 -0
- package/lib/guard/audit.js +81 -0
- package/lib/guard/context.js +128 -0
- package/lib/guard/effects.js +104 -0
- package/lib/guard/fallback.js +139 -0
- package/lib/guard/listener.js +214 -0
- package/lib/guard/messages.js +31 -0
- package/lib/guard/policy.js +28 -0
- package/lib/home-paths.js +86 -0
- package/lib/index.js +3823 -0
- package/lib/outcomes.js +169 -0
- package/lib/points/spec.js +645 -0
- package/lib/providers.js +958 -0
- package/lib/resilience.js +308 -0
- package/lib/scope.js +67 -0
- package/lib/server-config.js +351 -0
- package/lib/session-events.js +75 -0
- package/lib/stats-store.js +916 -0
- package/lib/stats.js +1293 -0
- package/lib/telemetry.js +465 -0
- package/lib/tokenizer.js +201 -0
- package/lib/tolerance.js +498 -0
- package/lib/vendor/microcharts-charts.css +47 -0
- package/lib/vendor-src/entry.js +13 -0
- package/lib/vendor-src/jsx-shim.js +34 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +101 -3
- package/scripts/migrate-profile.mjs +102 -0
package/lib/stats.js
ADDED
|
@@ -0,0 +1,1293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 统计与对比的**纯计算**部分。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独拆一个零依赖模块:统计逻辑是这个插件里最容易出「数字对不上」
|
|
5
|
+
* 的地方——时间边界(跨月、跨年、夏令时)、基线口径(节省量到底相对什么
|
|
6
|
+
* 算)、聚合维度(provider × model)三件事各自都能写错,而它们必须能被
|
|
7
|
+
* 单独验证。因此这里只做纯函数:不读文件、不发请求、不依赖宿主。
|
|
8
|
+
*
|
|
9
|
+
* 两个概念要分清:
|
|
10
|
+
*
|
|
11
|
+
* **实际消耗** 这次决策真实花了什么:input token、output token、耗时。
|
|
12
|
+
* **基线消耗** 「走普通 LLM 路径」时,同一份工作会花什么。
|
|
13
|
+
*
|
|
14
|
+
* ## 节省量只统计「观察」模式
|
|
15
|
+
*
|
|
16
|
+
* 这是本模块最重要的一条口径,**不能**因为「数字好看」而放宽。
|
|
17
|
+
*
|
|
18
|
+
* 「节省」是一个**反事实**(counterfactual)数字:它回答的是
|
|
19
|
+
* 「如果采用决策模型,这次能少花多少」。而反事实只有在
|
|
20
|
+
* **两种路径跑在同一份数据上**时才成立:
|
|
21
|
+
*
|
|
22
|
+
* 观察(observe) 决策模型只记录、不改变行为。于是同一批工作既有
|
|
23
|
+
* 「普通 LLM 实际怎么跑的」(真实数据),又有
|
|
24
|
+
* 「决策模型说本该怎么跑」(记录下来的裁决)。
|
|
25
|
+
* 两边比的是同一批工作,结论才有意义。
|
|
26
|
+
*
|
|
27
|
+
* 生效(enforce) 决策已经落地,普通 LLM 路径**根本没有发生**。
|
|
28
|
+
* 此时「省了多少」只能是事后估算(而且必然掺进
|
|
29
|
+
* 猜测),与观察模式下的实测量不是一回事。
|
|
30
|
+
*
|
|
31
|
+
* 把两种模式的数字混在同一张表里,是这类统计最典型的自欺:读者看到
|
|
32
|
+
* 一个漂亮的节省量,却不知道它是实测对比还是估算模型算出来的。
|
|
33
|
+
* 因此本模块**只**把观察模式的记录计入节省,界面上也必须写明这一点。
|
|
34
|
+
*
|
|
35
|
+
* 基线口径(观察模式下可归因的两类):
|
|
36
|
+
*
|
|
37
|
+
* 1. **结果缓存** 缓存命中时一次请求根本没发出去 → 基线 = 原本那次
|
|
38
|
+
* 完整请求的开销,实际 = 0。这是省得最多的一类。
|
|
39
|
+
* 2. **上下文截断** state 被裁到 `maxStateChars` 之内 → 基线 = 未截断
|
|
40
|
+
* 时的 input token,实际 = 截断后的。
|
|
41
|
+
*
|
|
42
|
+
* 刻意**不**把「思考模式」算进节省:`think_mode` 是用户自己的配置,不是
|
|
43
|
+
* 插件的优化;把它算进来会让「节省量」随配置摆动,数字失去意义。
|
|
44
|
+
*
|
|
45
|
+
* @module dsh-decision-engine/stats
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { OUTCOME_LABELS, OUTCOME_MATRIX, OUTCOMES, outcomeOf } from "./outcomes.js";
|
|
49
|
+
|
|
50
|
+
export { OUTCOME_LABELS, OUTCOME_MATRIX, OUTCOMES, outcomeOf };
|
|
51
|
+
|
|
52
|
+
/** 预设时间范围。`custom` 表示用户自己给了起止时刻。 */
|
|
53
|
+
export const RANGE_PRESETS = ["today", "week", "month", "year", "custom"];
|
|
54
|
+
|
|
55
|
+
/** 预设范围的中文名(界面直接用,服务端下发避免前端抄一份)。 */
|
|
56
|
+
export const RANGE_LABELS = {
|
|
57
|
+
today: "当日",
|
|
58
|
+
week: "本周",
|
|
59
|
+
month: "本月",
|
|
60
|
+
year: "本年",
|
|
61
|
+
custom: "自定义",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* token 数的来源标记。
|
|
66
|
+
*
|
|
67
|
+
* 用户必须能分清「这个数字是服务端报的还是估的」——两者混在一起时,
|
|
68
|
+
* 一个自建服务(不报 usage)的数字会看起来和另一家的实测数字一样可信。
|
|
69
|
+
*/
|
|
70
|
+
export const TOKEN_SOURCES = {
|
|
71
|
+
/** 服务端 usage 字段返回的精确值。 */
|
|
72
|
+
api: "api",
|
|
73
|
+
/** 本插件用估算器算出来的值。 */
|
|
74
|
+
estimated: "estimated",
|
|
75
|
+
/** 拿不到任何值(0)。 */
|
|
76
|
+
unknown: "unknown",
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 记录所属的决策点模式。
|
|
81
|
+
*
|
|
82
|
+
* 与 `lib/decision-points.js` 的三态一致:`off` 不参与、`observe` 只记录、
|
|
83
|
+
* `enforce` 真正生效。
|
|
84
|
+
*
|
|
85
|
+
* 为什么统计要记它:节省量是**反事实**数字,只有在「观察」模式下才有
|
|
86
|
+
* 实测意义(两种路径跑在同一批工作上)。生效模式下普通 LLM 路径根本没
|
|
87
|
+
* 发生,节省只能是估算——把它和实测混在一起是最典型的自欺。详见本模块
|
|
88
|
+
* 顶部的说明。
|
|
89
|
+
*
|
|
90
|
+
* **历史记录没有这个字段**:老记录一律按 `unknown` 处理,**不计入节省**
|
|
91
|
+
* (无法证明它是在观察模式下产生的)。宁可少算,不可把不确定的数字当成
|
|
92
|
+
* 实测展示。
|
|
93
|
+
*/
|
|
94
|
+
export const RECORD_MODES = ["observe", "enforce", "off", "unknown"];
|
|
95
|
+
|
|
96
|
+
/** 观察模式:只记录不生效——节省量的唯一有效来源。 */
|
|
97
|
+
export const MODE_OBSERVE = "observe";
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 归一化记录的模式字段。
|
|
101
|
+
*
|
|
102
|
+
* 非法值一律降级成 `unknown`,而不是猜一个:猜错会让「生效模式下的
|
|
103
|
+
* 估算」被当成「观察模式下的实测」,那正是要避免的事。
|
|
104
|
+
*
|
|
105
|
+
* @param value - 原始模式值。
|
|
106
|
+
* @returns `observe` / `enforce` / `off` / `unknown`。
|
|
107
|
+
*/
|
|
108
|
+
export function normalizeMode(value) {
|
|
109
|
+
return RECORD_MODES.includes(value) ? value : "unknown";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 一条记录是否计入「节省量」。
|
|
114
|
+
*
|
|
115
|
+
* **只有观察模式计入。** 这是本模块最关键的一条过滤:省下来的量必须在
|
|
116
|
+
* 两种路径跑在同一批工作上的前提下才有意义,而这只发生在观察模式。
|
|
117
|
+
*
|
|
118
|
+
* @param mode - 记录的模式。
|
|
119
|
+
* @returns 是否计入节省量。
|
|
120
|
+
*/
|
|
121
|
+
export function contributesSavings(mode) {
|
|
122
|
+
return mode === MODE_OBSERVE;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 造一条统计记录。
|
|
127
|
+
*
|
|
128
|
+
* 这是**唯一**的写入端构造入口:基线口径在这里定死,别处不再各算一套,
|
|
129
|
+
* 否则「省了多少」在不同页面上会给出不同数字。
|
|
130
|
+
*
|
|
131
|
+
* @param options - 字段:
|
|
132
|
+
* - `ts` ISO 时间串(默认现在)。
|
|
133
|
+
* - `provider` / `model` 维度。
|
|
134
|
+
* - `point` 决策点 id。
|
|
135
|
+
* - `mode` 决策点模式(observe / enforce / off)。**决定这条记录是否
|
|
136
|
+
* 计入节省量**:只有 observe 计入(见模块顶部说明)。
|
|
137
|
+
* - `action` / `detail` 裁决与原因。
|
|
138
|
+
* - `cached` 是否命中缓存。
|
|
139
|
+
* - `degraded` 是否降级。
|
|
140
|
+
* - `inputTokens` / `outputTokens` 实际消耗。
|
|
141
|
+
* - `inputSource` / `outputSource` 上面两个数字的来源。
|
|
142
|
+
* - `durationMs` 端到端耗时。
|
|
143
|
+
* - `baseInputTokens` 未截断时的 input token(基线)。
|
|
144
|
+
* - `baseOutputTokens` 基线的 output token(默认与实际相同)。
|
|
145
|
+
* - `baseDurationMs` 基线耗时(默认与实际相同)。
|
|
146
|
+
* @returns 记录对象(字段齐整,可直接 JSON 化)。
|
|
147
|
+
*/
|
|
148
|
+
export function buildRecord(options = {}) {
|
|
149
|
+
const num = (value) => (typeof value === "number" && Number.isFinite(value) ? value : 0);
|
|
150
|
+
const source = (value) =>
|
|
151
|
+
TOKEN_SOURCES[value] === TOKEN_SOURCES.api ||
|
|
152
|
+
TOKEN_SOURCES[value] === TOKEN_SOURCES.estimated
|
|
153
|
+
? value
|
|
154
|
+
: TOKEN_SOURCES.unknown;
|
|
155
|
+
const inputTokens = num(options.inputTokens);
|
|
156
|
+
const outputTokens = num(options.outputTokens);
|
|
157
|
+
const durationMs = num(options.durationMs);
|
|
158
|
+
// 基线默认 = 实际:没有优化可归因时,节省量就是 0,而不是某个假想的数。
|
|
159
|
+
//
|
|
160
|
+
// 三个基线字段的默认值必须**一致**(都跟随实际值)。曾经只有 output 与
|
|
161
|
+
// duration 跟随实际、input 默认为 0,于是「没有优化」的那次请求会算出
|
|
162
|
+
// savedInputTokens = 0 - 实际值 = **负数**——界面显示「省了 -1200 token」,
|
|
163
|
+
// 而实际是「没省也没多」。
|
|
164
|
+
const baseInputTokens =
|
|
165
|
+
options.baseInputTokens === undefined ? inputTokens : num(options.baseInputTokens);
|
|
166
|
+
const baseOutputTokens =
|
|
167
|
+
options.baseOutputTokens === undefined ? outputTokens : num(options.baseOutputTokens);
|
|
168
|
+
const baseDurationMs =
|
|
169
|
+
options.baseDurationMs === undefined ? durationMs : num(options.baseDurationMs);
|
|
170
|
+
return {
|
|
171
|
+
ts: typeof options.ts === "string" ? options.ts : new Date().toISOString(),
|
|
172
|
+
provider: typeof options.provider === "string" && options.provider.length > 0 ? options.provider : "unknown",
|
|
173
|
+
model: typeof options.model === "string" && options.model.length > 0 ? options.model : "unknown",
|
|
174
|
+
point: typeof options.point === "string" && options.point.length > 0 ? options.point : "unknown",
|
|
175
|
+
// 模式决定这条记录是否计入节省量。老记录没有这个字段 → unknown →
|
|
176
|
+
// 不计入(见 normalizeMode 的说明)。
|
|
177
|
+
mode: normalizeMode(options.mode),
|
|
178
|
+
action: typeof options.action === "string" ? options.action : "",
|
|
179
|
+
detail: typeof options.detail === "string" ? options.detail : "",
|
|
180
|
+
cached: options.cached === true,
|
|
181
|
+
degraded: options.degraded === true,
|
|
182
|
+
inputTokens,
|
|
183
|
+
outputTokens,
|
|
184
|
+
durationMs,
|
|
185
|
+
baseInputTokens,
|
|
186
|
+
baseOutputTokens,
|
|
187
|
+
baseDurationMs,
|
|
188
|
+
inputSource: source(options.inputSource),
|
|
189
|
+
outputSource: source(options.outputSource),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* 一天的毫秒数。
|
|
195
|
+
*
|
|
196
|
+
* 用 `Date` 的本地时间语义做加减(`setDate` / `setHours`),而不是
|
|
197
|
+
* `24 * 3600 * 1000`:后者在有夏令时的时区上会让「当日」变成 23 或 25 小时。
|
|
198
|
+
*/
|
|
199
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 把「年-月-日 时:分」解析成本地时间的时刻。
|
|
203
|
+
*
|
|
204
|
+
* `Date` 构造器对 `YYYY-MM-DDTHH:mm` 的解析在 ES 规范里是**本地时间**,
|
|
205
|
+
* 而 `YYYY-MM-DD`(不带时间)会被当成 UTC——两者差一个时区,跨时区部署时
|
|
206
|
+
* 「当日 00:00」会差一整天。因此这里**手工拆字段**再 `new Date(y, m-1, d, …)`,
|
|
207
|
+
* 语义完全由本函数说了算,不依赖引擎的字符串解析细节。
|
|
208
|
+
*
|
|
209
|
+
* @param value - `YYYY-MM-DDTHH:mm` 或 `YYYY-MM-DD HH:mm` 形态的字符串。
|
|
210
|
+
* @returns 本地时刻;解析失败返回 undefined。
|
|
211
|
+
*/
|
|
212
|
+
export function parseLocalDateTime(value) {
|
|
213
|
+
if (typeof value !== "string") return undefined;
|
|
214
|
+
const matched = value
|
|
215
|
+
.trim()
|
|
216
|
+
.match(/^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?$/);
|
|
217
|
+
if (matched === null) return undefined;
|
|
218
|
+
const [, year, month, day, hour, minute, second] = matched;
|
|
219
|
+
const y = Number(year);
|
|
220
|
+
const mo = Number(month) - 1;
|
|
221
|
+
const d = Number(day);
|
|
222
|
+
const h = Number(hour ?? "0");
|
|
223
|
+
const mi = Number(minute ?? "0");
|
|
224
|
+
const s = Number(second ?? "0");
|
|
225
|
+
// 月/日越界会让 Date 静默滚到下个月(例如 2 月 31 日 → 3 月 3 日),
|
|
226
|
+
// 这种「看起来正常」的越界比直接报错更难发现,因此显式校验。
|
|
227
|
+
if (mo < 0 || mo > 11 || d < 1 || d > 31 || h > 23 || mi > 59 || s > 59) return undefined;
|
|
228
|
+
const date = new Date(y, mo, d, h, mi, s, 0);
|
|
229
|
+
if (Number.isNaN(date.getTime())) return undefined;
|
|
230
|
+
// 回读一遍:挡住「2 月 31 日」这类会被 Date 静默进位的输入。
|
|
231
|
+
if (date.getFullYear() !== y || date.getMonth() !== mo || date.getDate() !== d) return undefined;
|
|
232
|
+
return date;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* 把一个时刻规整到当天 00:00(本地时间)。
|
|
237
|
+
*
|
|
238
|
+
* @param date - 任意时刻。
|
|
239
|
+
* @returns 当天 00:00:00.000 的时刻。
|
|
240
|
+
*/
|
|
241
|
+
function startOfDay(date) {
|
|
242
|
+
const out = new Date(date.getTime());
|
|
243
|
+
out.setHours(0, 0, 0, 0);
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* 本周周一 00:00(本地时间)。
|
|
249
|
+
*
|
|
250
|
+
* `getDay()` 里 0 是周日,因此周一需要减 1。这个偏移用 `setDate` 做,
|
|
251
|
+
* 跨月跨年由 Date 自己处理。
|
|
252
|
+
*
|
|
253
|
+
* @param date - 任意时刻。
|
|
254
|
+
* @returns 本周周一 00:00 的时刻。
|
|
255
|
+
*/
|
|
256
|
+
function startOfWeek(date) {
|
|
257
|
+
const out = startOfDay(date);
|
|
258
|
+
const weekday = out.getDay();
|
|
259
|
+
const offset = weekday === 0 ? 6 : weekday - 1;
|
|
260
|
+
out.setDate(out.getDate() - offset);
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* 本月 1 日 00:00(本地时间)。
|
|
266
|
+
*
|
|
267
|
+
* @param date - 任意时刻。
|
|
268
|
+
* @returns 本月 1 日 00:00 的时刻。
|
|
269
|
+
*/
|
|
270
|
+
function startOfMonth(date) {
|
|
271
|
+
const out = startOfDay(date);
|
|
272
|
+
out.setDate(1);
|
|
273
|
+
return out;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* 本年 1 月 1 日 00:00(本地时间)。
|
|
278
|
+
*
|
|
279
|
+
* @param date - 任意时刻。
|
|
280
|
+
* @returns 本年 1 月 1 日 00:00 的时刻。
|
|
281
|
+
*/
|
|
282
|
+
function startOfYear(date) {
|
|
283
|
+
const out = startOfDay(date);
|
|
284
|
+
out.setMonth(0, 1);
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* 把范围请求解析成具体的时间区间。
|
|
290
|
+
*
|
|
291
|
+
* 区间统一用**左闭右开** `[start, end)`:
|
|
292
|
+
*
|
|
293
|
+
* - 「当日 00:00-24:00」= 今天 00:00 到明天 00:00;
|
|
294
|
+
* - 「本周周一至周日」= 本周一 00:00 到下周一 00:00(周日整天都在里面);
|
|
295
|
+
* - 「自定义」精确到分钟:结束时刻按「含该整分钟」处理,因此 end 取该
|
|
296
|
+
* 分钟的下一分钟起点。
|
|
297
|
+
*
|
|
298
|
+
* 左闭右开还有个好处:相邻区间(本月 → 本年)不会在边界上重复计入同一条
|
|
299
|
+
* 记录,而闭区间会。
|
|
300
|
+
*
|
|
301
|
+
* @param options.preset - `RANGE_PRESETS` 之一;非法值按 `today` 处理。
|
|
302
|
+
* @param options.start - 自定义起始(`YYYY-MM-DDTHH:mm`)。
|
|
303
|
+
* @param options.end - 自定义结束(`YYYY-MM-DDTHH:mm`)。
|
|
304
|
+
* @param options.now - 「当前时刻」,便于测试注入固定时间。
|
|
305
|
+
* @returns `{ preset, label, start, end, startMs, endMs, valid, error }`。
|
|
306
|
+
*/
|
|
307
|
+
export function resolveRange(options = {}, now = new Date()) {
|
|
308
|
+
const requested = typeof options.preset === "string" ? options.preset : "today";
|
|
309
|
+
const preset = RANGE_PRESETS.includes(requested) ? requested : "today";
|
|
310
|
+
const base = now instanceof Date && !Number.isNaN(now.getTime()) ? now : new Date();
|
|
311
|
+
|
|
312
|
+
if (preset === "custom") {
|
|
313
|
+
const start = parseLocalDateTime(options.start);
|
|
314
|
+
const end = parseLocalDateTime(options.end);
|
|
315
|
+
if (start === undefined || end === undefined) {
|
|
316
|
+
return {
|
|
317
|
+
preset,
|
|
318
|
+
label: RANGE_LABELS.custom,
|
|
319
|
+
start: undefined,
|
|
320
|
+
end: undefined,
|
|
321
|
+
startMs: 0,
|
|
322
|
+
endMs: 0,
|
|
323
|
+
valid: false,
|
|
324
|
+
error: "自定义范围需要合法的起止时间(YYYY-MM-DDTHH:mm)",
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
// 结束时刻按「含该整分钟」处理:用户填 12:30 表示到 12:30:59.999 为止。
|
|
328
|
+
const endExclusive = new Date(end.getTime() + 60 * 1000);
|
|
329
|
+
if (endExclusive.getTime() <= start.getTime()) {
|
|
330
|
+
return {
|
|
331
|
+
preset,
|
|
332
|
+
label: RANGE_LABELS.custom,
|
|
333
|
+
start,
|
|
334
|
+
end: endExclusive,
|
|
335
|
+
startMs: start.getTime(),
|
|
336
|
+
endMs: endExclusive.getTime(),
|
|
337
|
+
valid: false,
|
|
338
|
+
error: "结束时间必须晚于开始时间",
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
return {
|
|
342
|
+
preset,
|
|
343
|
+
label: RANGE_LABELS.custom,
|
|
344
|
+
start,
|
|
345
|
+
end: endExclusive,
|
|
346
|
+
startMs: start.getTime(),
|
|
347
|
+
endMs: endExclusive.getTime(),
|
|
348
|
+
valid: true,
|
|
349
|
+
error: "",
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
let start;
|
|
354
|
+
let end;
|
|
355
|
+
if (preset === "week") {
|
|
356
|
+
start = startOfWeek(base);
|
|
357
|
+
end = new Date(start.getTime());
|
|
358
|
+
end.setDate(end.getDate() + 7);
|
|
359
|
+
} else if (preset === "month") {
|
|
360
|
+
start = startOfMonth(base);
|
|
361
|
+
end = new Date(start.getTime());
|
|
362
|
+
end.setMonth(end.getMonth() + 1);
|
|
363
|
+
} else if (preset === "year") {
|
|
364
|
+
start = startOfYear(base);
|
|
365
|
+
end = new Date(start.getTime());
|
|
366
|
+
end.setFullYear(end.getFullYear() + 1);
|
|
367
|
+
} else {
|
|
368
|
+
start = startOfDay(base);
|
|
369
|
+
end = new Date(start.getTime() + DAY_MS);
|
|
370
|
+
// 用 setDate 而不是 +DAY_MS:夏令时切换日当天的长度可能不是 24 小时。
|
|
371
|
+
end = new Date(start.getTime());
|
|
372
|
+
end.setDate(end.getDate() + 1);
|
|
373
|
+
}
|
|
374
|
+
return {
|
|
375
|
+
preset,
|
|
376
|
+
label: RANGE_LABELS[preset] ?? RANGE_LABELS.today,
|
|
377
|
+
start,
|
|
378
|
+
end,
|
|
379
|
+
startMs: start.getTime(),
|
|
380
|
+
endMs: end.getTime(),
|
|
381
|
+
valid: true,
|
|
382
|
+
error: "",
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* 一条统计记录的**规范化**形状。
|
|
388
|
+
*
|
|
389
|
+
* 写入端与读取端都走它:字段缺失时补零,而不是让 `undefined` 渗进聚合
|
|
390
|
+
* 运算里变成 NaN。NaN 出现在「节省比例」上时,界面显示的是空白或
|
|
391
|
+
* `NaN%`——那是最难自查的一类数字错误。
|
|
392
|
+
*/
|
|
393
|
+
function normalizeRecord(raw) {
|
|
394
|
+
const ts = typeof raw?.ts === "string" ? Date.parse(raw.ts) : Number.NaN;
|
|
395
|
+
const num = (value) => (typeof value === "number" && Number.isFinite(value) ? value : 0);
|
|
396
|
+
const provider = typeof raw?.provider === "string" && raw.provider.length > 0 ? raw.provider : "unknown";
|
|
397
|
+
const model = typeof raw?.model === "string" && raw.model.length > 0 ? raw.model : "unknown";
|
|
398
|
+
return {
|
|
399
|
+
ts: Number.isNaN(ts) ? 0 : ts,
|
|
400
|
+
provider,
|
|
401
|
+
model,
|
|
402
|
+
point: typeof raw?.point === "string" && raw.point.length > 0 ? raw.point : "unknown",
|
|
403
|
+
// 老记录没有 mode → unknown → 不计入节省量。
|
|
404
|
+
mode: normalizeMode(raw?.mode),
|
|
405
|
+
action: typeof raw?.action === "string" ? raw.action : "",
|
|
406
|
+
cached: raw?.cached === true,
|
|
407
|
+
degraded: raw?.degraded === true,
|
|
408
|
+
detail: typeof raw?.detail === "string" ? raw.detail : "",
|
|
409
|
+
inputTokens: num(raw?.inputTokens),
|
|
410
|
+
outputTokens: num(raw?.outputTokens),
|
|
411
|
+
durationMs: num(raw?.durationMs),
|
|
412
|
+
baseInputTokens: num(raw?.baseInputTokens),
|
|
413
|
+
baseOutputTokens: num(raw?.baseOutputTokens),
|
|
414
|
+
baseDurationMs: num(raw?.baseDurationMs),
|
|
415
|
+
inputSource: typeof raw?.inputSource === "string" ? raw.inputSource : "unknown",
|
|
416
|
+
outputSource: typeof raw?.outputSource === "string" ? raw.outputSource : "unknown",
|
|
417
|
+
// 预拼的「provider\0model」组合键:aggregate 过去每条记录拼一次、
|
|
418
|
+
// 基线补齐再拼一次,两次结果必然相同。放在归一化时算一次即可。
|
|
419
|
+
// 前缀 `\u0000` 保证它不会与被当作记录字段读取的键撞名。
|
|
420
|
+
comboKey: `${provider}\u0000${model}`,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* 判断一条记录是否落在区间内(左闭右开)。
|
|
426
|
+
*
|
|
427
|
+
* @param record - 原始记录。
|
|
428
|
+
* @param range - `resolveRange()` 的结果。
|
|
429
|
+
* @returns 是否在区间内。
|
|
430
|
+
*/
|
|
431
|
+
export function isInRange(record, range) {
|
|
432
|
+
const ts = normalizeRecord(record).ts;
|
|
433
|
+
if (ts === 0) return false;
|
|
434
|
+
return ts >= range.startMs && ts < range.endMs;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* 把一组记录累加成一个「桶」。
|
|
439
|
+
*
|
|
440
|
+
* **降级记录单独计数,不进用量口径。**
|
|
441
|
+
*
|
|
442
|
+
* 降级(超时、鉴权失败、端点 404……)的这次决策:
|
|
443
|
+
*
|
|
444
|
+
* - 没有拿到任何模型答案 → 没有 token 可统计;
|
|
445
|
+
* - 也没有裁决 → 在裁决分账里属于「未裁决」;
|
|
446
|
+
* - 但它确实花掉了等待时间(实测 280 次降级共 35 分钟,其中 124 次是
|
|
447
|
+
* 8 秒超时)——这段时间是真实成本,只是它**不是**「一次模型请求的耗时」。
|
|
448
|
+
*
|
|
449
|
+
* 因此降级记录累加到 `degraded` 与 `degradedDurationMs` 两个**独立**字段,
|
|
450
|
+
* 不混进 `requests` / `durationMs`。混进去的话,「均耗时」的分母会多出
|
|
451
|
+
* 一批根本没得到回答的请求,数字就不再是「一次请求多快」。
|
|
452
|
+
*
|
|
453
|
+
* @param bucket - 累加目标(原地修改)。
|
|
454
|
+
* @param record - 已规范化的记录。
|
|
455
|
+
*/
|
|
456
|
+
function accumulate(bucket, record) {
|
|
457
|
+
if (record.degraded === true) {
|
|
458
|
+
bucket.degraded += 1;
|
|
459
|
+
bucket.degradedDurationMs += record.durationMs;
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
bucket.requests += 1;
|
|
463
|
+
if (record.cached === true) bucket.cached += 1;
|
|
464
|
+
bucket.inputTokens += record.inputTokens;
|
|
465
|
+
bucket.outputTokens += record.outputTokens;
|
|
466
|
+
bucket.durationMs += record.durationMs;
|
|
467
|
+
/**
|
|
468
|
+
* 节省量只认**观察模式**的记录。
|
|
469
|
+
*
|
|
470
|
+
* 两套数字必须分开累加,不能混:
|
|
471
|
+
*
|
|
472
|
+
* 实际消耗(上面三行) 全部记录——决策引擎真实花了多少,与模式无关。
|
|
473
|
+
* 节省量(这里) 只有观察模式——只有那时「普通 LLM 路径」与
|
|
474
|
+
* 「决策模型路径」跑在同一批工作上,反事实才成立。
|
|
475
|
+
*
|
|
476
|
+
* 若把生效模式的记录也算进节省,它带来的「省下的量」是事后估算,
|
|
477
|
+
* 却会和观察模式的实测数字摆在同一张表里——读者无从分辨,这正是
|
|
478
|
+
* 最难自查也最难解释的偏差。
|
|
479
|
+
*
|
|
480
|
+
* 基线与实际必须**成对**累加:省下的量 = 基线 - 实际,两边口径一致
|
|
481
|
+
* 才有意义。
|
|
482
|
+
*/
|
|
483
|
+
if (!contributesSavings(record.mode)) return;
|
|
484
|
+
bucket.observeRequests += 1;
|
|
485
|
+
bucket.observeInputTokens += record.inputTokens;
|
|
486
|
+
bucket.observeOutputTokens += record.outputTokens;
|
|
487
|
+
bucket.observeDurationMs += record.durationMs;
|
|
488
|
+
bucket.observeBaseInputTokens += record.baseInputTokens;
|
|
489
|
+
bucket.observeBaseOutputTokens += record.baseOutputTokens;
|
|
490
|
+
bucket.observeBaseDurationMs += record.baseDurationMs;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* 把桶清零(原地)。
|
|
495
|
+
*
|
|
496
|
+
* 基线补齐后需要重新累加一遍,而重新累加前必须先清零——否则旧值会被
|
|
497
|
+
* 再加一遍,数字直接翻倍。
|
|
498
|
+
*
|
|
499
|
+
* @param bucket - 累加桶。
|
|
500
|
+
*/
|
|
501
|
+
function resetBucket(bucket) {
|
|
502
|
+
const fresh = emptyBucket();
|
|
503
|
+
for (const key of Object.keys(fresh)) bucket[key] = fresh[key];
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* 造一个空的累加桶。
|
|
508
|
+
*
|
|
509
|
+
* @returns 桶。
|
|
510
|
+
*/
|
|
511
|
+
function emptyBucket() {
|
|
512
|
+
return {
|
|
513
|
+
requests: 0,
|
|
514
|
+
cached: 0,
|
|
515
|
+
degraded: 0,
|
|
516
|
+
/** 降级累计等待耗时(不进「总耗时」,见 accumulate 的说明)。 */
|
|
517
|
+
degradedDurationMs: 0,
|
|
518
|
+
inputTokens: 0,
|
|
519
|
+
outputTokens: 0,
|
|
520
|
+
durationMs: 0,
|
|
521
|
+
/**
|
|
522
|
+
* 观察模式专属的累加桶。
|
|
523
|
+
*
|
|
524
|
+
* 单独一套字段而不是复用上面三个:节省量**只**由观察模式的记录
|
|
525
|
+
* 贡献,而实际消耗要统计全部记录。共用一套字段就必须二选一,
|
|
526
|
+
* 而两种都要——所以分成两套,各算各的。
|
|
527
|
+
*/
|
|
528
|
+
observeRequests: 0,
|
|
529
|
+
observeInputTokens: 0,
|
|
530
|
+
observeOutputTokens: 0,
|
|
531
|
+
observeDurationMs: 0,
|
|
532
|
+
observeBaseInputTokens: 0,
|
|
533
|
+
observeBaseOutputTokens: 0,
|
|
534
|
+
observeBaseDurationMs: 0,
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* 造一个空的「裁决分账桶」。
|
|
540
|
+
*
|
|
541
|
+
* 三格之外另有一个 `none`:表示「这次没有裁决」(模型降级、答案形状
|
|
542
|
+
* 不符、点被关掉)。它**不是**第四种裁决——降级时插件根本没有改变
|
|
543
|
+
* 任何行为,把它算成「放行」会虚增放行次数,算成「拦截」会虚增拦截
|
|
544
|
+
* 次数,两者都是错的。所以它单列一格,界面上明确写出「未裁决」。
|
|
545
|
+
*
|
|
546
|
+
* @returns 桶。
|
|
547
|
+
*/
|
|
548
|
+
function emptyOutcomeBucket() {
|
|
549
|
+
return {
|
|
550
|
+
allow: 0,
|
|
551
|
+
ask: 0,
|
|
552
|
+
reject: 0,
|
|
553
|
+
none: 0,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* 把一次裁决记进桶。
|
|
559
|
+
*
|
|
560
|
+
* @param bucket - 累加目标(原地修改)。
|
|
561
|
+
* @param outcome - `outcomeOf()` 的结果。
|
|
562
|
+
*/
|
|
563
|
+
function accumulateOutcome(bucket, outcome) {
|
|
564
|
+
if (outcome === "allow" || outcome === "ask" || outcome === "reject") {
|
|
565
|
+
bucket[outcome] += 1;
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
bucket.none += 1;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* 把桶整理成对外的形状:补上节省量、平均值与比例。
|
|
573
|
+
*
|
|
574
|
+
* 节省量**只由观察模式的记录**算出(见 accumulate 的说明):
|
|
575
|
+
* `observe*` 系列是节省量的分子分母,其余记录一概不参与。
|
|
576
|
+
*
|
|
577
|
+
* @param bucket - 累加桶。
|
|
578
|
+
* @returns 对外字段。
|
|
579
|
+
*/
|
|
580
|
+
function finalizeBucket(bucket) {
|
|
581
|
+
/**
|
|
582
|
+
* 节省量:基线 - 实际,两边都取**观察模式**的累加值。
|
|
583
|
+
*
|
|
584
|
+
* 分母是基线而不是实际值——「省了多少」永远是相对基线说的。
|
|
585
|
+
*/
|
|
586
|
+
const savedInputTokens = bucket.observeBaseInputTokens - bucket.observeInputTokens;
|
|
587
|
+
const savedOutputTokens = bucket.observeBaseOutputTokens - bucket.observeOutputTokens;
|
|
588
|
+
const savedDurationMs = bucket.observeBaseDurationMs - bucket.observeDurationMs;
|
|
589
|
+
const baseTotalTokens = bucket.observeBaseInputTokens + bucket.observeBaseOutputTokens;
|
|
590
|
+
const actualTotalTokens = bucket.observeInputTokens + bucket.observeOutputTokens;
|
|
591
|
+
const baseTotalMs = bucket.observeBaseDurationMs;
|
|
592
|
+
// 比例的分母是基线,不是实际值——「省了多少」永远是相对基线说的。
|
|
593
|
+
const savedRatio = baseTotalTokens > 0 ? (baseTotalTokens - actualTotalTokens) / baseTotalTokens : 0;
|
|
594
|
+
const savedTimeRatio = baseTotalMs > 0 ? (baseTotalMs - bucket.observeDurationMs) / baseTotalMs : 0;
|
|
595
|
+
/**
|
|
596
|
+
* 「平均」一律按**真实请求**算,不含缓存命中。
|
|
597
|
+
*
|
|
598
|
+
* 缓存命中的那次没有发请求:token 是 0、耗时也是 0。把它算进分母,
|
|
599
|
+
* 「平均耗时」会随着命中率升高而变好看——那说的就不是「一次请求多快」,
|
|
600
|
+
* 而是「我们有多久没请求」,两个意思混在一个数字里,最容易被误读。
|
|
601
|
+
* 需求原话是「每次请求从发起至完整响应的端到端耗时」,缓存命中根本没有
|
|
602
|
+
* 这段过程,所以它不该出现在分母里。
|
|
603
|
+
*/
|
|
604
|
+
const realRequests = Math.max(0, bucket.requests - bucket.cached);
|
|
605
|
+
return {
|
|
606
|
+
requests: bucket.requests,
|
|
607
|
+
realRequests,
|
|
608
|
+
cached: bucket.cached,
|
|
609
|
+
degraded: bucket.degraded,
|
|
610
|
+
/** 降级累计等待耗时(与「总耗时」分开,见 accumulate 的说明)。 */
|
|
611
|
+
degradedDurationMs: bucket.degradedDurationMs,
|
|
612
|
+
inputTokens: bucket.inputTokens,
|
|
613
|
+
outputTokens: bucket.outputTokens,
|
|
614
|
+
totalTokens: bucket.inputTokens + bucket.outputTokens,
|
|
615
|
+
durationMs: bucket.durationMs,
|
|
616
|
+
/**
|
|
617
|
+
* 观察模式的样本量:**节省量是从多少条记录算出来的**。
|
|
618
|
+
*
|
|
619
|
+
* 这个数必须露出来:节省量是 0 有两种完全不同的原因——
|
|
620
|
+
* 「观察模式下确实没省」与「这段时间根本没有观察模式的记录」。
|
|
621
|
+
* 没有这个数,读者会以为前者。
|
|
622
|
+
*/
|
|
623
|
+
observeRequests: bucket.observeRequests,
|
|
624
|
+
observeInputTokens: bucket.observeInputTokens,
|
|
625
|
+
observeOutputTokens: bucket.observeOutputTokens,
|
|
626
|
+
observeDurationMs: bucket.observeDurationMs,
|
|
627
|
+
observeBaseInputTokens: bucket.observeBaseInputTokens,
|
|
628
|
+
observeBaseOutputTokens: bucket.observeBaseOutputTokens,
|
|
629
|
+
observeBaseDurationMs: bucket.observeBaseDurationMs,
|
|
630
|
+
savedInputTokens,
|
|
631
|
+
savedOutputTokens,
|
|
632
|
+
savedTotalTokens: savedInputTokens + savedOutputTokens,
|
|
633
|
+
savedDurationMs,
|
|
634
|
+
savedRatio,
|
|
635
|
+
savedTimeRatio,
|
|
636
|
+
avgDurationMs: realRequests === 0 ? 0 : Math.round(bucket.durationMs / realRequests),
|
|
637
|
+
avgInputTokens: realRequests === 0 ? 0 : Math.round(bucket.inputTokens / realRequests),
|
|
638
|
+
avgOutputTokens: realRequests === 0 ? 0 : Math.round(bucket.outputTokens / realRequests),
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* 按指标排序对比表。
|
|
644
|
+
*
|
|
645
|
+
* 支持的 `key`:inputTokens / outputTokens / totalTokens / durationMs /
|
|
646
|
+
* avgDurationMs / requests / savedInputTokens / savedOutputTokens /
|
|
647
|
+
* savedDurationMs / savedRatio。
|
|
648
|
+
*
|
|
649
|
+
* 未知键一律按 `inputTokens` 排——排序写错不该让整张表乱序。
|
|
650
|
+
*
|
|
651
|
+
* @param rows - 对比表。
|
|
652
|
+
* @param key - 排序字段。
|
|
653
|
+
* @param direction - `desc`(默认)或 `asc`。
|
|
654
|
+
* @returns 排序后的**新**数组(不改原数组)。
|
|
655
|
+
*/
|
|
656
|
+
/**
|
|
657
|
+
* 允许排序的字段白名单。
|
|
658
|
+
*
|
|
659
|
+
* 提到模块级:过去每次调用都新建一个 13 元素的 Set。统计页一次刷新会按
|
|
660
|
+
* 三四列各排一遍,这些 Set 全部立刻变成垃圾。白名单是常量,没有理由重建。
|
|
661
|
+
*/
|
|
662
|
+
const SORTABLE_FIELDS = new Set([
|
|
663
|
+
"inputTokens",
|
|
664
|
+
"outputTokens",
|
|
665
|
+
"totalTokens",
|
|
666
|
+
"durationMs",
|
|
667
|
+
"avgDurationMs",
|
|
668
|
+
"requests",
|
|
669
|
+
"savedInputTokens",
|
|
670
|
+
"savedOutputTokens",
|
|
671
|
+
"savedDurationMs",
|
|
672
|
+
"savedRatio",
|
|
673
|
+
// 观察模式的实际消耗:节省量对比表的「实际」三列按它们排序。
|
|
674
|
+
"observeInputTokens",
|
|
675
|
+
"observeOutputTokens",
|
|
676
|
+
"observeDurationMs",
|
|
677
|
+
]);
|
|
678
|
+
|
|
679
|
+
export function sortRows(rows, key, direction = "desc") {
|
|
680
|
+
const field = SORTABLE_FIELDS.has(key) ? key : "inputTokens";
|
|
681
|
+
const sign = direction === "asc" ? 1 : -1;
|
|
682
|
+
return [...rows].sort((a, b) => {
|
|
683
|
+
const left = Number(a[field] ?? 0);
|
|
684
|
+
const right = Number(b[field] ?? 0);
|
|
685
|
+
if (left === right) {
|
|
686
|
+
// 同值时按 provider/model 排,保证顺序稳定(不稳定的排序会让界面
|
|
687
|
+
// 每次刷新都换顺序,用户以为数字在动)。
|
|
688
|
+
const byProvider = String(a.provider ?? "").localeCompare(String(b.provider ?? ""));
|
|
689
|
+
if (byProvider !== 0) return byProvider;
|
|
690
|
+
return String(a.model ?? "").localeCompare(String(b.model ?? ""));
|
|
691
|
+
}
|
|
692
|
+
return (left - right) * sign;
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* 统计与对比的主入口。
|
|
698
|
+
*
|
|
699
|
+
* @param records - 原始记录数组(不限于区间内的,函数内部自己筛)。
|
|
700
|
+
* @param range - `resolveRange()` 的结果。
|
|
701
|
+
* @returns
|
|
702
|
+
* - `range`:区间信息(含标签与起止 ISO 字符串)
|
|
703
|
+
* - `totals`:全量汇总
|
|
704
|
+
* - `rows`:provider × model 的对比表
|
|
705
|
+
* - `byProvider`:只按 provider 汇总
|
|
706
|
+
* - `byPoint`:按决策点汇总
|
|
707
|
+
*/
|
|
708
|
+
/**
|
|
709
|
+
* 走势图的分桶粒度:按区间长度自适应。
|
|
710
|
+
*
|
|
711
|
+
* 三个候选,各自对应一种阅读场景:
|
|
712
|
+
* hour 区间 ≤ 2 天:按小时看「今天什么时候最忙」。
|
|
713
|
+
* day 区间 ≤ 62 天:按天看「这一周/这个月的节奏」。
|
|
714
|
+
* month 更长:按月,年内四个季度各花了多少。
|
|
715
|
+
*
|
|
716
|
+
* 上界不是随便取的:一个桶至少要有可读的宽度,而统计页的走势图
|
|
717
|
+
* 只有几百像素宽;超过 62 个桶就挤成一片,读的人只看到噪声。
|
|
718
|
+
*
|
|
719
|
+
* @param spanMs - 区间长度(毫秒)。
|
|
720
|
+
* @returns `hour` | `day` | `month`
|
|
721
|
+
*/
|
|
722
|
+
export function pickBucket(spanMs) {
|
|
723
|
+
const HOUR = 3600 * 1000;
|
|
724
|
+
const DAY = 24 * HOUR;
|
|
725
|
+
if (spanMs <= 2 * DAY) return "hour";
|
|
726
|
+
if (spanMs <= 62 * DAY) return "day";
|
|
727
|
+
return "month";
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* 把一个时刻归到它所属的桶(本地时区)。
|
|
732
|
+
*
|
|
733
|
+
* 为什么按**本地**时间切桶而不是 UTC:用户看的是「今天哪几个小时忙」,
|
|
734
|
+
* 而「今天」是他所在时区的今天。按 UTC 切会让东八区的用户在早上 8 点
|
|
735
|
+
* 之前看到的前一天数据错位成「今天」——这种偏差不会报错,只会让图
|
|
736
|
+
* 看起来「和表格对不上」。
|
|
737
|
+
*
|
|
738
|
+
* 月桶用 `YYYY-MM` 作键;小时/天桶用本地时间构造的 Date 的 ISO 片段,
|
|
739
|
+
* 保证同一个本地时刻永远落进同一个桶。
|
|
740
|
+
*
|
|
741
|
+
* @param ts - 毫秒时间戳。
|
|
742
|
+
* @param bucket - `pickBucket()` 的结果。
|
|
743
|
+
* @returns 桶键(可排序的字符串)。
|
|
744
|
+
*/
|
|
745
|
+
export function bucketKey(ts, bucket) {
|
|
746
|
+
const date = new Date(ts);
|
|
747
|
+
if (bucket === "month") {
|
|
748
|
+
// getMonth() 是 0 基,补 1 再补零,保证 `2026-03` 这种键按字符串排也对。
|
|
749
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
750
|
+
return `${date.getFullYear()}-${month}`;
|
|
751
|
+
}
|
|
752
|
+
const day = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
|
753
|
+
if (bucket === "day") return day;
|
|
754
|
+
return `${day}T${String(date.getHours()).padStart(2, "0")}`;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* 把一个桶键解析回该桶**起点**的毫秒时间戳(本地时区)。
|
|
759
|
+
*
|
|
760
|
+
* `bucketKey(ts, bucket)` 的反向操作,且必须严格互逆:对任意 ts,
|
|
761
|
+
* `bucketStartMs(bucketKey(ts, bucket), bucket) <= ts`,并且解析出的起点
|
|
762
|
+
* 再喂回 `bucketKey` 必须得到同一个键。做不到这一点的输入(格式不认识、
|
|
763
|
+
* 日期越界、本地时区里不存在的那一刻)一律返回 NaN,让调用方走退化分支,
|
|
764
|
+
* 而不是猜一个时刻——猜出来的起点差一小时,`futureStartIndex` 的虚线边界
|
|
765
|
+
* 就会错一格,而图上完全看不出来。
|
|
766
|
+
*
|
|
767
|
+
* 为什么手工拆字段再 `new Date(y, m-1, d, h)`:`Date.parse("2026-11-10")`
|
|
768
|
+
* 会把**不带时间**的日期按 UTC 零点解析(ES 规范如此),东八区解出来是
|
|
769
|
+
* 本地早上 8 点——比日桶真实起点晚 8 个小时,正好吞掉当天前 8 个未来桶。
|
|
770
|
+
* 与 `parseLocalDateTime` 是同一个坑,见它上方的注释。
|
|
771
|
+
*
|
|
772
|
+
* @param key - `bucketKey()` 产出的键(`2026-11` / `2026-11-10` / `2026-11-10T14`)。
|
|
773
|
+
* @param bucket - `pickBucket()` 的结果,决定按哪种格式解析。
|
|
774
|
+
* @returns 桶起点的毫秒时间戳;无法识别时 `NaN`。
|
|
775
|
+
*/
|
|
776
|
+
export function bucketStartMs(key, bucket) {
|
|
777
|
+
if (typeof key !== "string") return Number.NaN;
|
|
778
|
+
let matched = null;
|
|
779
|
+
if (bucket === "month") matched = key.match(/^(\d{4})-(\d{2})$/);
|
|
780
|
+
else if (bucket === "day") matched = key.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
781
|
+
else if (bucket === "hour") matched = key.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2})$/);
|
|
782
|
+
else return Number.NaN;
|
|
783
|
+
if (matched === null) return Number.NaN;
|
|
784
|
+
|
|
785
|
+
const y = Number(matched[1]);
|
|
786
|
+
const mo = Number(matched[2]) - 1;
|
|
787
|
+
const d = bucket === "month" ? 1 : Number(matched[3]);
|
|
788
|
+
const h = bucket === "hour" ? Number(matched[4]) : 0;
|
|
789
|
+
// 字段越界先拦一道:月 00/13、日 00/35、小时 24 都不是 `bucketKey` 会
|
|
790
|
+
// 产出的键,落到 `new Date` 会被静默进位成相邻的合法时刻。
|
|
791
|
+
if (mo < 0 || mo > 11 || d < 1 || d > 31 || h < 0 || h > 23) return Number.NaN;
|
|
792
|
+
const date = new Date(y, mo, d, h, 0, 0, 0);
|
|
793
|
+
const ms = date.getTime();
|
|
794
|
+
if (Number.isNaN(ms)) return Number.NaN;
|
|
795
|
+
// 回读校验(与 `parseLocalDateTime` 同一手法):进位后的字段必须仍是原值,
|
|
796
|
+
// 挡两类输入——「2 月 31 日」这类被 Date 滚进下个月的假键,以及年 0000-0099
|
|
797
|
+
// (`new Date(5, …)` 会被映射到 1905 年);日/月桶还顺带挡住本地时区里
|
|
798
|
+
// 不存在的 00:00 / 0 时(历史上的夏令时切换),解析不回来就宁可 NaN。
|
|
799
|
+
if (
|
|
800
|
+
date.getFullYear() !== y ||
|
|
801
|
+
date.getMonth() !== mo ||
|
|
802
|
+
date.getDate() !== d ||
|
|
803
|
+
date.getHours() !== h
|
|
804
|
+
) {
|
|
805
|
+
return Number.NaN;
|
|
806
|
+
}
|
|
807
|
+
return ms;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* 为区间生成**连续**的桶键序列(含空桶)。
|
|
812
|
+
*
|
|
813
|
+
* 这一步是走势图能不能读的关键。只返回「有记录的那些桶」会得到一个
|
|
814
|
+
* 骗人的图:周一有 100 次、周五有 100 次,中间三天没请求,折线却会
|
|
815
|
+
* 把周一直连到周五——看上去像是「一直在稳定请求」。
|
|
816
|
+
*
|
|
817
|
+
* 空桶补 0 让「没有请求」在图上真的表现为**掉到零**,这才是那三天的
|
|
818
|
+
* 事实。代价是长区间要生成很多键,所以桶粒度上面已经按区间收敛过了。
|
|
819
|
+
*
|
|
820
|
+
* @param range - `resolveRange()` 的结果。
|
|
821
|
+
* @param bucket - `pickBucket()` 的结果。
|
|
822
|
+
* @returns 桶键数组,从区间起点到终点。
|
|
823
|
+
*/
|
|
824
|
+
export function bucketKeys(range, bucket) {
|
|
825
|
+
const keys = [];
|
|
826
|
+
const start = range.startMs;
|
|
827
|
+
const end = range.endMs;
|
|
828
|
+
if (!(end > start)) return keys;
|
|
829
|
+
const cursor = new Date(start);
|
|
830
|
+
// 上限兜底:异常区间(比如手填了一个横跨百年的自定义范围)不该让
|
|
831
|
+
// 服务端在这里生成几十万个键。超过上限就返回空序列,界面显示的是
|
|
832
|
+
// 「这段区间太长了,换短一点看」而不是卡死。
|
|
833
|
+
const MAX_KEYS = 400;
|
|
834
|
+
if (bucket === "month") {
|
|
835
|
+
cursor.setDate(1);
|
|
836
|
+
cursor.setHours(0, 0, 0, 0);
|
|
837
|
+
while (cursor.getTime() < end && keys.length < MAX_KEYS) {
|
|
838
|
+
keys.push(bucketKey(cursor.getTime(), "month"));
|
|
839
|
+
cursor.setMonth(cursor.getMonth() + 1);
|
|
840
|
+
}
|
|
841
|
+
return keys;
|
|
842
|
+
}
|
|
843
|
+
if (bucket === "day") {
|
|
844
|
+
cursor.setHours(0, 0, 0, 0);
|
|
845
|
+
while (cursor.getTime() < end && keys.length < MAX_KEYS) {
|
|
846
|
+
keys.push(bucketKey(cursor.getTime(), "day"));
|
|
847
|
+
cursor.setDate(cursor.getDate() + 1);
|
|
848
|
+
}
|
|
849
|
+
return keys;
|
|
850
|
+
}
|
|
851
|
+
cursor.setMinutes(0, 0, 0);
|
|
852
|
+
while (cursor.getTime() < end && keys.length < MAX_KEYS) {
|
|
853
|
+
keys.push(bucketKey(cursor.getTime(), "hour"));
|
|
854
|
+
cursor.setHours(cursor.getHours() + 1);
|
|
855
|
+
}
|
|
856
|
+
return keys;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* 找出「第一个还没开始的桶」的下标——`series.nowIndex` 就在这里算。
|
|
861
|
+
*
|
|
862
|
+
* 判定只看桶**起点**:`bucketStartMs(key) > nowMs` 才算未来。为什么不用
|
|
863
|
+
* 桶终点:进行中的桶(起点 ≤ now < 终点)里已经有真实数据了,而它的终点
|
|
864
|
+
* 永远晚于 now——按终点判定会把正在进行的这一格划进未来段,图上最后一段
|
|
865
|
+
* 真实折线被画成虚线,读者以为「还没发生」,其实那就是刚刚发生的事。
|
|
866
|
+
* 起点判定则天然满足「严格大于」:now 恰好等于桶起点时该桶已开始,算过去。
|
|
867
|
+
*
|
|
868
|
+
* 为什么必须在**服务端**算好下发:前端手上只有桶键字符串与全 0 的序列,
|
|
869
|
+
* 不知道「现在」是几点;让它自己解析键再比较,时间运算就出现了第二处,
|
|
870
|
+
* 解析口径(本地时区、补零、夏令时)只要与服务端差一点,虚线边界就漂移,
|
|
871
|
+
* 而漂移表现为「图看着还挺正常」,是最难排查的一类错。本仓库的原则是
|
|
872
|
+
* 时间运算只在服务端出现一次(见 `buildSeries` 与 `aggregate` 里的注释),
|
|
873
|
+
* 这里就是那条原则在未来段上的落点。
|
|
874
|
+
*
|
|
875
|
+
* 复杂度 O(n):桶键按时间升序(`bucketKeys` 保证),找到第一个起点晚于
|
|
876
|
+
* now 的键立即返回。遇到解析不了的键**立即放弃整段判断**,返回
|
|
877
|
+
* `buckets.length`(=「没有未来段」),而不是返回出错位置:非法键之前的
|
|
878
|
+
* 桶都已确认是过去(否则早已从循环返回),之后的桶却无法归类——返回
|
|
879
|
+
* 出错位置等于把「说不清」画成「还没发生」,前端会凭空画出一段假虚线;
|
|
880
|
+
* 返回 length 至多是虚线缺席,图上不出现编造的内容。宁可没有,不可造假
|
|
881
|
+
* ——键不认识往往意味着生成端与解析端版本没对齐,猜出来的分界线比没有
|
|
882
|
+
* 分界线更危险。
|
|
883
|
+
*
|
|
884
|
+
* @param buckets - `bucketKeys()` 产出的、按时间升序的桶键数组。
|
|
885
|
+
* @param bucket - `pickBucket()` 的结果,决定键格式。
|
|
886
|
+
* @param nowMs - 「现在」的毫秒时间戳;非有限数字按退化处理。
|
|
887
|
+
* @returns 第一个未来桶的下标;全段都是过去(或退化情况)为 `buckets.length`,
|
|
888
|
+
* 全段都在未来为 0。
|
|
889
|
+
*/
|
|
890
|
+
export function futureStartIndex(buckets, bucket, nowMs) {
|
|
891
|
+
const list = Array.isArray(buckets) ? buckets : [];
|
|
892
|
+
if (typeof nowMs !== "number" || !Number.isFinite(nowMs)) return list.length;
|
|
893
|
+
for (let i = 0; i < list.length; i++) {
|
|
894
|
+
const start = bucketStartMs(list[i], bucket);
|
|
895
|
+
if (Number.isNaN(start)) return list.length;
|
|
896
|
+
if (start > nowMs) return i;
|
|
897
|
+
}
|
|
898
|
+
return list.length;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* 把 buildSeries 的 `now` 参数规整成毫秒数。
|
|
903
|
+
*
|
|
904
|
+
* 接受 `Date` 或毫秒数;两者之外的值(含无效 Date、NaN、字符串)退回系统
|
|
905
|
+
* 时钟——与 `resolveRange` 对 `now` 的容忍方式一致:宁可多算一毫秒的偏差,
|
|
906
|
+
* 也不要因为一个坏参数让整个统计接口 500。
|
|
907
|
+
*
|
|
908
|
+
* @param now - `Date` 或毫秒时间戳。
|
|
909
|
+
* @returns 毫秒时间戳。
|
|
910
|
+
*/
|
|
911
|
+
function toNowMs(now) {
|
|
912
|
+
if (now instanceof Date && !Number.isNaN(now.getTime())) return now.getTime();
|
|
913
|
+
if (typeof now === "number" && Number.isFinite(now)) return now;
|
|
914
|
+
return Date.now();
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* 构造走势序列。
|
|
919
|
+
*
|
|
920
|
+
* 每条序列都是与 `buckets` **等长且同序**的数组,界面按索引对齐即可,
|
|
921
|
+
* 不需要再做任何时间运算——时间运算只在这里出现一次。
|
|
922
|
+
*
|
|
923
|
+
* 序列的选择直接对应统计页要回答的问题:
|
|
924
|
+
* requests 请求次数:量有多大、忙不忙
|
|
925
|
+
* input input token:钱主要花在哪(input 通常远大于 output)
|
|
926
|
+
* output output token
|
|
927
|
+
* durationMs 总耗时
|
|
928
|
+
* allow / ask / reject 裁决随时间的构成
|
|
929
|
+
*
|
|
930
|
+
* 序列尾部可能带一段**还没开始**的桶:`resolveRange` 的区间上界经常落在
|
|
931
|
+
* 未来(「当日」到明天 0 点、「本年」到年末)。那段桶的 0 与「已经发生、
|
|
932
|
+
* 真的没有请求」的 0 是两种不同的事实,画成同一条实线就是在骗人,所以
|
|
933
|
+
* 这里随序列一起下发 `nowIndex`(判定只看桶起点、为什么必须在服务端算,
|
|
934
|
+
* 见 `futureStartIndex`)。
|
|
935
|
+
*
|
|
936
|
+
* @param records - 区间内已归一化的记录。
|
|
937
|
+
* @param range - `resolveRange()` 的结果。
|
|
938
|
+
* @param now - 判定「哪些桶还没开始」的基准时刻(`Date` 或毫秒);
|
|
939
|
+
* 省略时取当前时间。坏参数同样退回系统时钟(见 `toNowMs`)。
|
|
940
|
+
* @returns `{ bucket, buckets, series, total, nowIndex }`
|
|
941
|
+
*/
|
|
942
|
+
export function buildSeries(records, range, now = new Date()) {
|
|
943
|
+
const spanMs = Math.max(0, (range.endMs ?? 0) - (range.startMs ?? 0));
|
|
944
|
+
const bucket = pickBucket(spanMs);
|
|
945
|
+
const buckets = bucketKeys(range, bucket);
|
|
946
|
+
/** 桶键 → 索引,避免每个记录都去 buckets 里线性找一遍。 */
|
|
947
|
+
const indexOf = new Map();
|
|
948
|
+
buckets.forEach((key, index) => indexOf.set(key, index));
|
|
949
|
+
|
|
950
|
+
const series = {
|
|
951
|
+
requests: new Array(buckets.length).fill(0),
|
|
952
|
+
inputTokens: new Array(buckets.length).fill(0),
|
|
953
|
+
outputTokens: new Array(buckets.length).fill(0),
|
|
954
|
+
durationMs: new Array(buckets.length).fill(0),
|
|
955
|
+
allow: new Array(buckets.length).fill(0),
|
|
956
|
+
ask: new Array(buckets.length).fill(0),
|
|
957
|
+
reject: new Array(buckets.length).fill(0),
|
|
958
|
+
none: new Array(buckets.length).fill(0),
|
|
959
|
+
};
|
|
960
|
+
|
|
961
|
+
for (const record of Array.isArray(records) ? records : []) {
|
|
962
|
+
if (record.ts === 0) continue;
|
|
963
|
+
const index = indexOf.get(bucketKey(record.ts, bucket));
|
|
964
|
+
// 落在区间外的记录(理论上已被筛掉)直接跳过:宁少一个点,
|
|
965
|
+
// 也不要把它的值塞进错误的桶里——那会让图与表对不上。
|
|
966
|
+
if (index === undefined) continue;
|
|
967
|
+
series.requests[index] += 1;
|
|
968
|
+
series.inputTokens[index] += record.inputTokens;
|
|
969
|
+
series.outputTokens[index] += record.outputTokens;
|
|
970
|
+
series.durationMs[index] += record.durationMs;
|
|
971
|
+
const hasVerdict = record.action.length > 0 && record.degraded !== true;
|
|
972
|
+
const outcome = hasVerdict ? outcomeOf(record.point, record.action) : "none";
|
|
973
|
+
series[outcome === "none" ? "none" : outcome][index] += 1;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
return {
|
|
977
|
+
/** 分桶粒度:`hour` | `day` | `month`。 */
|
|
978
|
+
bucket,
|
|
979
|
+
/** 桶键(`2026-11-10` 或 `2026-11-10T14` 或 `2026-11`),等长同序。 */
|
|
980
|
+
buckets,
|
|
981
|
+
series,
|
|
982
|
+
/** 桶的总数(= 每条序列的长度),界面拿它校验对齐。 */
|
|
983
|
+
total: buckets.length,
|
|
984
|
+
/**
|
|
985
|
+
* 第一个「还没开始」的桶的下标(见 `futureStartIndex`)。
|
|
986
|
+
*
|
|
987
|
+
* `total` 表示没有未来段;`0` 表示整段都在未来。区间的上界经常落在
|
|
988
|
+
* 未来(`today`→次日 0 点、`month`→月末、`year`→年末),那之后的桶
|
|
989
|
+
* 值恒为 0——**必须**由界面把这一段画成虚线,否则读者会把「还没到的
|
|
990
|
+
* 时间」读成「这段时间一次请求都没有」。
|
|
991
|
+
*/
|
|
992
|
+
nowIndex: futureStartIndex(buckets, bucket, toNowMs(now)),
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
export function aggregate(records, range) {
|
|
997
|
+
const totals = emptyBucket();
|
|
998
|
+
/** @type {Map<string, ReturnType<typeof emptyBucket>>} */
|
|
999
|
+
const byCombo = new Map();
|
|
1000
|
+
/** @type {Map<string, ReturnType<typeof emptyBucket>>} */
|
|
1001
|
+
const byProvider = new Map();
|
|
1002
|
+
/** @type {Map<string, ReturnType<typeof emptyBucket>>} */
|
|
1003
|
+
const byPoint = new Map();
|
|
1004
|
+
/** 区间内的规范化记录,留给基线补齐阶段复用。 */
|
|
1005
|
+
const scoped = [];
|
|
1006
|
+
|
|
1007
|
+
// ---- 裁决分账(allow / ask / reject)--------------------------------
|
|
1008
|
+
//
|
|
1009
|
+
// 与上面的用量分账是**两套独立的口径**:
|
|
1010
|
+
//
|
|
1011
|
+
// 用量分账 按「这一次决策花了多少」算,含缓存命中与降级。
|
|
1012
|
+
// 裁决分账 按「这一次裁决让事情过去了还是被拦下了」算。
|
|
1013
|
+
//
|
|
1014
|
+
// 两套不能合并:一次降级没有裁决(不计入任何一格),但它照样花了一次
|
|
1015
|
+
// 请求的 token;反过来一次缓存命中的裁决也是真实裁决(照常计入),
|
|
1016
|
+
// 却没花任何 token。硬合成一张表,两边都得打折。
|
|
1017
|
+
const outcomeTotals = emptyOutcomeBucket();
|
|
1018
|
+
/** @type {Map<string, ReturnType<typeof emptyOutcomeBucket>>} */
|
|
1019
|
+
const outcomeByPoint = new Map();
|
|
1020
|
+
/**
|
|
1021
|
+
* 未登记的裁决词(point/kind 组合在 OUTCOME_MATRIX 里查不到)。
|
|
1022
|
+
*
|
|
1023
|
+
* 单独收集而不是直接丢弃:新增决策点忘了登记时,数字会**少得悄无声息**
|
|
1024
|
+
* ——界面看着仍然合理,只是偏小。这里把它显式列出来,界面上提示
|
|
1025
|
+
* 「有 N 条裁决未分类」,让漏登记变成一眼可见的事。
|
|
1026
|
+
*/
|
|
1027
|
+
const otherVerdicts = new Map();
|
|
1028
|
+
/**
|
|
1029
|
+
* 按模式分账:observe / enforce / off / unknown 各多少条。
|
|
1030
|
+
*
|
|
1031
|
+
* 界面必须靠它解释「为什么节省量是 0」:节省量只由 observe 贡献,
|
|
1032
|
+
* 所以「这段时间全是 enforce」与「观察了但没省」是两种完全不同的
|
|
1033
|
+
* 事实,读者有权区分。
|
|
1034
|
+
*/
|
|
1035
|
+
const modeCounts = { observe: 0, enforce: 0, off: 0, unknown: 0 };
|
|
1036
|
+
|
|
1037
|
+
for (const raw of Array.isArray(records) ? records : []) {
|
|
1038
|
+
// 只归一化一次:`isInRange` 内部也会 `normalizeRecord`,过去这就意味
|
|
1039
|
+
// 着每条记录被完整归一化两遍(19 个字段 + 一次 Date.parse)。这里先
|
|
1040
|
+
// 归一化、再就地判区间,口径与 `isInRange` 逐字相同(ts===0 视为越界)。
|
|
1041
|
+
const record = normalizeRecord(raw);
|
|
1042
|
+
if (record.ts === 0 || record.ts < range.startMs || record.ts >= range.endMs) continue;
|
|
1043
|
+
scoped.push(record);
|
|
1044
|
+
modeCounts[record.mode] = (modeCounts[record.mode] ?? 0) + 1;
|
|
1045
|
+
accumulate(totals, record);
|
|
1046
|
+
// 组合键必须用**不会出现在 id 里**的分隔符:provider 与 model 直接拼接
|
|
1047
|
+
// 时,"ab" + "c" 与 "a" + "bc" 会撞成同一个键,两组数据被合并统计——
|
|
1048
|
+
// 而 provider/model 都可能含任意字符(自定义服务的模型名尤其)。
|
|
1049
|
+
const comboKey = record.comboKey;
|
|
1050
|
+
let combo = byCombo.get(comboKey);
|
|
1051
|
+
if (combo === undefined) {
|
|
1052
|
+
combo = emptyBucket();
|
|
1053
|
+
byCombo.set(comboKey, combo);
|
|
1054
|
+
}
|
|
1055
|
+
accumulate(combo, record);
|
|
1056
|
+
let providerBucket = byProvider.get(record.provider);
|
|
1057
|
+
if (providerBucket === undefined) {
|
|
1058
|
+
providerBucket = emptyBucket();
|
|
1059
|
+
byProvider.set(record.provider, providerBucket);
|
|
1060
|
+
}
|
|
1061
|
+
accumulate(providerBucket, record);
|
|
1062
|
+
let pointBucket = byPoint.get(record.point);
|
|
1063
|
+
if (pointBucket === undefined) {
|
|
1064
|
+
pointBucket = emptyBucket();
|
|
1065
|
+
byPoint.set(record.point, pointBucket);
|
|
1066
|
+
}
|
|
1067
|
+
accumulate(pointBucket, record);
|
|
1068
|
+
|
|
1069
|
+
// ---- 裁决分类 ---------------------------------------------------
|
|
1070
|
+
//
|
|
1071
|
+
// 「没有裁决」与「有裁决但查不到分类」必须分开:
|
|
1072
|
+
//
|
|
1073
|
+
// action 为空 / degraded → 这次根本没裁决(模型降级、答案形状不符)
|
|
1074
|
+
// action 有值但未登记 → 有裁决,只是没归类(新增 kind 忘了登记)
|
|
1075
|
+
//
|
|
1076
|
+
// 前者计入 `none`;后者单独列进 `otherVerdicts` 并在界面上提示。
|
|
1077
|
+
// 把两者混为一谈会让「未登记」看起来像「降级」,掩盖真正的漏登记。
|
|
1078
|
+
const hasVerdict = record.action.length > 0 && record.degraded !== true;
|
|
1079
|
+
if (!hasVerdict) {
|
|
1080
|
+
accumulateOutcome(outcomeTotals, "none");
|
|
1081
|
+
let noneBucket = outcomeByPoint.get(record.point);
|
|
1082
|
+
if (noneBucket === undefined) {
|
|
1083
|
+
noneBucket = emptyOutcomeBucket();
|
|
1084
|
+
outcomeByPoint.set(record.point, noneBucket);
|
|
1085
|
+
}
|
|
1086
|
+
accumulateOutcome(noneBucket, "none");
|
|
1087
|
+
continue;
|
|
1088
|
+
}
|
|
1089
|
+
const outcome = outcomeOf(record.point, record.action);
|
|
1090
|
+
if (outcome === "none") {
|
|
1091
|
+
const verdictKey = `${record.point}/${record.action}`;
|
|
1092
|
+
otherVerdicts.set(verdictKey, (otherVerdicts.get(verdictKey) ?? 0) + 1);
|
|
1093
|
+
}
|
|
1094
|
+
accumulateOutcome(outcomeTotals, outcome);
|
|
1095
|
+
let pointOutcome = outcomeByPoint.get(record.point);
|
|
1096
|
+
if (pointOutcome === undefined) {
|
|
1097
|
+
pointOutcome = emptyOutcomeBucket();
|
|
1098
|
+
outcomeByPoint.set(record.point, pointOutcome);
|
|
1099
|
+
}
|
|
1100
|
+
accumulateOutcome(pointOutcome, outcome);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/**
|
|
1104
|
+
* 补齐「缓存命中」缺的那份基线。
|
|
1105
|
+
*
|
|
1106
|
+
* 一条缓存命中记录表示「这次没发请求,0 token、0 毫秒」,但它**替掉**了
|
|
1107
|
+
* 一次完整请求。那次请求的开销就是省下来的量。
|
|
1108
|
+
*
|
|
1109
|
+
* 两种来源:
|
|
1110
|
+
* - 新记录自带 `baseInputTokens` / `baseDurationMs`(写入时就算好了);
|
|
1111
|
+
* - 历史记录(从 telemetry.jsonl 回填)没有基线字段,只能用同组真实
|
|
1112
|
+
* 请求的**平均**耗时与**平均**token 估算。
|
|
1113
|
+
*
|
|
1114
|
+
* 估算必须覆盖 token 与耗时两项,否则「省了多少 token」会是 0——
|
|
1115
|
+
* 而历史记录里缓存命中的占比往往不低(实测 telemetry.jsonl 里 678/2029
|
|
1116
|
+
* 条是缓存命中),只补耗时会让节省量看起来偏小。
|
|
1117
|
+
*/
|
|
1118
|
+
const realSamples = new Map();
|
|
1119
|
+
for (const record of scoped) {
|
|
1120
|
+
if (record.cached === true || record.degraded === true) continue;
|
|
1121
|
+
if (record.durationMs <= 0) continue;
|
|
1122
|
+
const comboKey = `${record.provider}\u0000${record.model}`;
|
|
1123
|
+
const sample = realSamples.get(comboKey) ?? { duration: 0, input: 0, output: 0, count: 0 };
|
|
1124
|
+
sample.duration += record.durationMs;
|
|
1125
|
+
sample.input += record.inputTokens;
|
|
1126
|
+
sample.output += record.outputTokens;
|
|
1127
|
+
sample.count += 1;
|
|
1128
|
+
realSamples.set(comboKey, sample);
|
|
1129
|
+
}
|
|
1130
|
+
let needsRecount = false;
|
|
1131
|
+
for (const record of scoped) {
|
|
1132
|
+
if (record.cached !== true) continue;
|
|
1133
|
+
const sample = realSamples.get(record.comboKey);
|
|
1134
|
+
if (sample === undefined || sample.count === 0) continue;
|
|
1135
|
+
/**
|
|
1136
|
+
* 缺哪个补哪个,而不是「有一个就不补」。
|
|
1137
|
+
*
|
|
1138
|
+
* 回填出来的历史命中记录是有 input 基线的(老遥测记了被复用的那份
|
|
1139
|
+
* token 数),但**耗时**基线老遥测确实没有。若按「任一基线存在就跳过」
|
|
1140
|
+
* 判断,这批记录的耗时节省会永远是 0——而缓存命中省下的时间恰恰是
|
|
1141
|
+
* 用户最想看到的数字。
|
|
1142
|
+
*/
|
|
1143
|
+
if (record.baseInputTokens === 0) {
|
|
1144
|
+
record.baseInputTokens = Math.round(sample.input / sample.count);
|
|
1145
|
+
needsRecount = true;
|
|
1146
|
+
}
|
|
1147
|
+
if (record.baseOutputTokens === 0) {
|
|
1148
|
+
record.baseOutputTokens = Math.round(sample.output / sample.count);
|
|
1149
|
+
needsRecount = true;
|
|
1150
|
+
}
|
|
1151
|
+
if (record.baseDurationMs === 0) {
|
|
1152
|
+
record.baseDurationMs = Math.round(sample.duration / sample.count);
|
|
1153
|
+
needsRecount = true;
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// 基线补齐后要重新累加一遍:上面的 accumulate 发生在补齐之前,
|
|
1158
|
+
// 补出来的基线不会自动进桶。重新累加的代价是一次 O(n) 遍历,
|
|
1159
|
+
// 与统计本身相比可忽略。
|
|
1160
|
+
if (needsRecount) {
|
|
1161
|
+
for (const bucket of [totals, ...byCombo.values(), ...byProvider.values(), ...byPoint.values()]) {
|
|
1162
|
+
resetBucket(bucket);
|
|
1163
|
+
}
|
|
1164
|
+
for (const record of scoped) {
|
|
1165
|
+
accumulate(totals, record);
|
|
1166
|
+
accumulate(byCombo.get(record.comboKey), record);
|
|
1167
|
+
accumulate(byProvider.get(record.provider), record);
|
|
1168
|
+
accumulate(byPoint.get(record.point), record);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
const rows = [...byCombo.entries()].map(([comboKey, bucket]) => {
|
|
1173
|
+
const [provider, model] = comboKey.split("\u0000");
|
|
1174
|
+
return { provider, model, ...finalizeBucket(bucket) };
|
|
1175
|
+
});
|
|
1176
|
+
const providers = [...byProvider.entries()].map(([provider, bucket]) => ({
|
|
1177
|
+
provider,
|
|
1178
|
+
...finalizeBucket(bucket),
|
|
1179
|
+
}));
|
|
1180
|
+
const points = [...byPoint.entries()].map(([point, bucket]) => ({
|
|
1181
|
+
point,
|
|
1182
|
+
...finalizeBucket(bucket),
|
|
1183
|
+
}));
|
|
1184
|
+
const outcomePoints = [...outcomeByPoint.entries()].map(([point, bucket]) => ({
|
|
1185
|
+
point,
|
|
1186
|
+
...bucket,
|
|
1187
|
+
}));
|
|
1188
|
+
/**
|
|
1189
|
+
* 裁决总览:三格 + 未裁决 + 未分类明细。
|
|
1190
|
+
*
|
|
1191
|
+
* `decided` 是三格之和——「这次到底裁决了多少次」。它与用量分账里的
|
|
1192
|
+
* `requests` 不同:后者数的是**决策请求**(含降级与缓存命中),
|
|
1193
|
+
* 前者数的是**产生了裁决的次数**。两个数不一样是正常的,也是必要的。
|
|
1194
|
+
*/
|
|
1195
|
+
const decided = outcomeTotals.allow + outcomeTotals.ask + outcomeTotals.reject;
|
|
1196
|
+
const otherVerdictList = [...otherVerdicts.entries()]
|
|
1197
|
+
.map(([verdict, count]) => ({ verdict, count }))
|
|
1198
|
+
.sort((a, b) => b.count - a.count || a.verdict.localeCompare(b.verdict));
|
|
1199
|
+
|
|
1200
|
+
/**
|
|
1201
|
+
* 时间序列(折线/柱状走势的数据源)。
|
|
1202
|
+
*
|
|
1203
|
+
* 为什么必须在**服务端**算而不是让前端从 rows 里自己推:rows 是
|
|
1204
|
+
* provider × model 的聚合,记录的时间维度在聚合时已经被合并掉了。
|
|
1205
|
+
* 前端手上没有任何时间戳,想画走势就只能另开一个接口把原始记录
|
|
1206
|
+
* 全量传过去——那是几千条记录乘以十九个字段,比多返回一条序列贵得多。
|
|
1207
|
+
*
|
|
1208
|
+
* 分桶粒度按区间长度自适应(见 `pickBucket`):固定成「一天」时,
|
|
1209
|
+
* 「当日」区间只有一个点,画出来的走势是一条直线,看着像坏了;
|
|
1210
|
+
* 固定成「一小时」时,「本年」区间会有 8760 个点,既画不出也读不出。
|
|
1211
|
+
*
|
|
1212
|
+
* `now` 在这里现取(不给 aggregate 加参数:对外签名保持 `(records, range)`
|
|
1213
|
+
* 不变)——必须与 `resolveRange` 同一台机器同一个时钟。区间端点与未来段
|
|
1214
|
+
* 边界一旦来自两处时钟(哪怕差一个时区),`nowIndex` 的虚线起点就整体
|
|
1215
|
+
* 漂移,而漂移在图上表现为「看起来仍然正常」,是最难排查的错。
|
|
1216
|
+
*/
|
|
1217
|
+
const series = buildSeries(scoped, range, new Date());
|
|
1218
|
+
|
|
1219
|
+
return {
|
|
1220
|
+
range: {
|
|
1221
|
+
preset: range.preset,
|
|
1222
|
+
label: range.label,
|
|
1223
|
+
start: range.start instanceof Date ? range.start.toISOString() : "",
|
|
1224
|
+
end: range.end instanceof Date ? range.end.toISOString() : "",
|
|
1225
|
+
startMs: range.startMs,
|
|
1226
|
+
endMs: range.endMs,
|
|
1227
|
+
valid: range.valid !== false,
|
|
1228
|
+
error: range.error ?? "",
|
|
1229
|
+
},
|
|
1230
|
+
totals: finalizeBucket(totals),
|
|
1231
|
+
rows,
|
|
1232
|
+
byProvider: providers,
|
|
1233
|
+
byPoint: points,
|
|
1234
|
+
/**
|
|
1235
|
+
* 时间序列:走势图的数据源(见 `buildSeries`)。
|
|
1236
|
+
*
|
|
1237
|
+
* 与 `rows` 并列而不是嵌进去:`rows` 是「按 provider × model 分」,
|
|
1238
|
+
* 序列是「按时间分」,两者的分桶维度不同,硬合进一个对象只会让
|
|
1239
|
+
* 前端两边都得拆。桶粒度随区间自适应,随响应一起下发,前端不猜。
|
|
1240
|
+
*
|
|
1241
|
+
* 里面的 `nowIndex` 是「未来段从哪里开始」的下标:区间的上界常常
|
|
1242
|
+
* 在未来(月末、年末),那之后的桶恒为 0。界面靠它把那段画成虚线,
|
|
1243
|
+
* 不然读者会把「还没到」看成「一次请求都没有」。
|
|
1244
|
+
*/
|
|
1245
|
+
series,
|
|
1246
|
+
recordCount: scoped.length,
|
|
1247
|
+
/**
|
|
1248
|
+
* 节省量的**来源说明**。
|
|
1249
|
+
*
|
|
1250
|
+
* 节省量只由观察模式的记录贡献(见模块顶部说明)。这个对象把
|
|
1251
|
+
* 「有多少条记录参与了节省」与「节省是怎么来的」一起交给界面,
|
|
1252
|
+
* 让它能明确告诉用户:节省量是 0,是因为**观察模式下没省**,
|
|
1253
|
+
* 还是因为**这段时间根本没有观察模式的记录**。
|
|
1254
|
+
*
|
|
1255
|
+
* `observeRequests === 0` 而 `requests > 0` → 这段时间全在「生效」
|
|
1256
|
+
* 模式跑,普通 LLM 路径没发生过,无从对比——这时界面必须说明,
|
|
1257
|
+
* 而不是显示一个 0 的节省量了事。
|
|
1258
|
+
*/
|
|
1259
|
+
savings: {
|
|
1260
|
+
/** 参与节省量计算的记录数(= 观察模式的记录数)。 */
|
|
1261
|
+
observeRequests: modeCounts.observe,
|
|
1262
|
+
/** 区间内的全部记录数。 */
|
|
1263
|
+
totalRequests: scoped.length,
|
|
1264
|
+
/** 各模式的记录数。 */
|
|
1265
|
+
byMode: modeCounts,
|
|
1266
|
+
/**
|
|
1267
|
+
* 节省量是否**不可用**。
|
|
1268
|
+
*
|
|
1269
|
+
* true = 这段时间没有观察模式的记录,省下的量无从对比。
|
|
1270
|
+
* 界面此时不该显示一个 0 的节省量(那会被读成「一点也没省」),
|
|
1271
|
+
* 而应说明「需要在观察模式下才有对比数据」。
|
|
1272
|
+
*/
|
|
1273
|
+
unavailable: modeCounts.observe === 0,
|
|
1274
|
+
},
|
|
1275
|
+
/**
|
|
1276
|
+
* 裁决统计(allow / ask / reject)。
|
|
1277
|
+
*
|
|
1278
|
+
* 与 `totals`(用量)并列,互不换算——两套口径回答两个不同问题。
|
|
1279
|
+
*/
|
|
1280
|
+
outcomes: {
|
|
1281
|
+
...outcomeTotals,
|
|
1282
|
+
decided,
|
|
1283
|
+
total: outcomeTotals.allow + outcomeTotals.ask + outcomeTotals.reject + outcomeTotals.none,
|
|
1284
|
+
byPoint: outcomePoints,
|
|
1285
|
+
/**
|
|
1286
|
+
* 未登记的裁决词。列表非空说明 `OUTCOME_MATRIX` 漏登记了某个
|
|
1287
|
+
* (point, kind)——界面必须提示,否则这类错误会静默地少算。
|
|
1288
|
+
*/
|
|
1289
|
+
otherVerdicts: otherVerdictList,
|
|
1290
|
+
otherVerdictCount: otherVerdictList.reduce((sum, item) => sum + item.count, 0),
|
|
1291
|
+
},
|
|
1292
|
+
};
|
|
1293
|
+
}
|