dsh-router-core 0.3.11 → 0.3.13
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/README.md +32 -3
- package/docs/ext.md +224 -0
- package/docs/pool-sticky-block.md +257 -0
- package/lib/client-registry.js +267 -95
- package/lib/client.js +267 -95
- package/lib/index.js +416 -13
- package/lib/types/ext/contract.d.ts +95 -0
- package/lib/types/ext/store.d.ts +16 -0
- package/lib/types/llm/adapter.d.ts +9 -0
- package/lib/types/router/account-pool.d.ts +58 -2
- package/lib/types/router/prefix-affinity.d.ts +6 -0
- package/lib/types/router/usage-store.d.ts +8 -0
- package/lib/types/shared.d.ts +18 -0
- package/package.json +5 -5
- package/src/client/ExtTab.tsx +143 -0
- package/src/client/RouterView.tsx +10 -2
- package/src/client/SupplierDetail.tsx +1 -1
- package/src/client/router.css +19 -0
- package/src/ext/contract.ts +115 -0
- package/src/ext/store.test.ts +144 -0
- package/src/ext/store.ts +125 -0
- package/src/index.ts +71 -0
- package/src/llm/adapter.ts +22 -5
- package/src/router/account-pool.test.ts +244 -1
- package/src/router/account-pool.ts +180 -4
- package/src/router/index.ts +25 -2
- package/src/router/prefix-affinity.ts +80 -0
- package/src/router/usage-store.ts +8 -0
- package/src/shared.ts +20 -0
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
* 得落盘,届时加 stateFile 即可,接口不用变。
|
|
24
24
|
*/
|
|
25
25
|
import type { AccountState, SupplierAccountNow } from '../suppliers/contract.ts'
|
|
26
|
+
import { prefixFingerprint } from './prefix-affinity.ts'
|
|
26
27
|
|
|
27
28
|
/** 各 AccountState 的处置:冷却策略 / 是否禁用 / 是否计入连续错误。 */
|
|
28
29
|
interface Rule {
|
|
@@ -86,6 +87,24 @@ const ERR_THRESHOLD = 3
|
|
|
86
87
|
/** 攒够后的冷却时长。 */
|
|
87
88
|
const ERR_COOLDOWN_MS = 5 * MINUTE
|
|
88
89
|
|
|
90
|
+
/** 块轮询:命中率达标就放行(`留守 = 计数<N OR 命中率<M`)。见 stickyBlock 说明。 */
|
|
91
|
+
const BLOCK_HIT_RATIO = 0.85
|
|
92
|
+
/** 块轮询:最短驻留。新接到这个号的最少连续服务次数(防冷启动误切)。 */
|
|
93
|
+
const BLOCK_MIN = 16
|
|
94
|
+
/** 块轮询:绝对兜底。轮次过多(max)无论命中率如何都让位,防死守 + 保均衡下限。 */
|
|
95
|
+
const BLOCK_MAX = 48
|
|
96
|
+
/** 块轮询:判定「缓存无效」后的跳过时长。 */
|
|
97
|
+
const BLOCK_DEMOTE_MS = 10 * MINUTE
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 前缀亲和:指纹 → 号 的绑定记忆上限。
|
|
101
|
+
*
|
|
102
|
+
* 会话指纹是无限集合(每个新会话一个),不封顶就是内存泄漏。超过了就丢掉
|
|
103
|
+
* **最久没用**的那条:活跃会话的指纹每请求都在刷新,不会被误删。
|
|
104
|
+
* 单号池(<= 几十个会话)绰绰有余。
|
|
105
|
+
*/
|
|
106
|
+
const AFFINITY_MAX = 512
|
|
107
|
+
|
|
89
108
|
/** 冷却记录(键 = (supplier, model, uid))。 */
|
|
90
109
|
interface CooldownEntry {
|
|
91
110
|
until: number
|
|
@@ -100,6 +119,40 @@ interface UidEntry {
|
|
|
100
119
|
reason: string
|
|
101
120
|
}
|
|
102
121
|
|
|
122
|
+
/**
|
|
123
|
+
* 块轮询的驻留状态(键 = (supplier, model),与冷却同粒度)。
|
|
124
|
+
*
|
|
125
|
+
* 为什么按 (供应商, 模型) 而不是按号:块是「当前这一轮由谁服务」的概念,
|
|
126
|
+
* 一个模型上只有一个号在驻留;换模型意味着换前缀,块自然作废。
|
|
127
|
+
*/
|
|
128
|
+
interface BlockState {
|
|
129
|
+
/** 当前驻留的号。 */
|
|
130
|
+
uid: string
|
|
131
|
+
/** 本块已服务的成功请求数。 */
|
|
132
|
+
served: number
|
|
133
|
+
/** 本块命中缓存(cachedTokens > 0)的请求数。 */
|
|
134
|
+
hits: number
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 某号在某模型上的缓存质量样本(键 = (supplier, model, uid))。
|
|
139
|
+
*
|
|
140
|
+
* 与「驻留」解耦:开启前缀亲和后多个会话各绑各的号交错服务,驻留概念只在
|
|
141
|
+
* 无亲和的回退路径上存在;而「这个号的缓存到底热不热」是**号本身的属性**,
|
|
142
|
+
* 必须跨驻留累计——否则每次换驻留就清零,永远攒不到 BLOCK_MAX 次判据。
|
|
143
|
+
*/
|
|
144
|
+
interface Sample {
|
|
145
|
+
/** 已服务的成功请求数。 */
|
|
146
|
+
served: number
|
|
147
|
+
/** 命中缓存(cachedTokens > 0)的请求数。 */
|
|
148
|
+
hits: number
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 「缓存无效」降权记录(键 = (supplier, model, uid))。 */
|
|
152
|
+
interface DemoteEntry {
|
|
153
|
+
until: number
|
|
154
|
+
}
|
|
155
|
+
|
|
103
156
|
/** 指数退避:等级 1 = BACKOFF_BASE_MS,每级翻倍,封顶 BACKOFF_MAX_MS。 */
|
|
104
157
|
function backoffMs(level: number): number {
|
|
105
158
|
return Math.min(BACKOFF_BASE_MS * 2 ** Math.max(0, level - 1), BACKOFF_MAX_MS)
|
|
@@ -116,6 +169,14 @@ export class AccountPool {
|
|
|
116
169
|
/** uid → 连接级禁用 / 手动暂停。 */
|
|
117
170
|
private byUid = new Map<string, UidEntry>()
|
|
118
171
|
private rrCursor = 0
|
|
172
|
+
/** (supplier, model) → 当前驻留的号(只有**无亲和**的回退路径用它)。 */
|
|
173
|
+
private blocks = new Map<string, string>()
|
|
174
|
+
/** (supplier, model, uid) → 该号的缓存质量样本(降权判据,跨驻留累计)。 */
|
|
175
|
+
private samples = new Map<string, Sample>()
|
|
176
|
+
/** (supplier, model, uid) → 缓存无效降权。 */
|
|
177
|
+
private demoted = new Map<string, DemoteEntry>()
|
|
178
|
+
/** (supplier, model) → Map<前缀指纹, uid>:前缀亲和的选号记忆(LRU 封顶)。 */
|
|
179
|
+
private affinity = new Map<string, Map<string, string>>()
|
|
119
180
|
|
|
120
181
|
constructor(supplierId = '') {
|
|
121
182
|
this.supplierId = supplierId
|
|
@@ -169,13 +230,29 @@ export class AccountPool {
|
|
|
169
230
|
|
|
170
231
|
/**
|
|
171
232
|
* 按策略为「某个模型」选一个健康账号。
|
|
233
|
+
*
|
|
234
|
+
* round-robin = **前缀亲和定号 + 块统计只用来判定缓存无效**:
|
|
235
|
+
*
|
|
236
|
+
* 1. 算请求的前缀指纹(messages 前几条),查绑定表 → 命中且该号健康 → 用它。
|
|
237
|
+
* 这是第一优先:同一个会话的连续请求**永远落回同一个号**,前缀在该号的
|
|
238
|
+
* 缓存里只写一份。块轮询治不了跨会话驱逐(多会话大前缀在同一号互踩,
|
|
239
|
+
* 实测 3 会话 3.8% vs 单会话 82.5%),亲和才是根治。
|
|
240
|
+
* 2. 新指纹(或绑定的号不健康)→ 按游标挑一个号并**记下绑定**,之后就固定。
|
|
241
|
+
* 新会话用游标分发,所以不同会话天然散到不同号上(均衡)。
|
|
242
|
+
* 3. 块统计不再驱动轮转(亲和已经保证驻留,再轮转只会把会话踢出已热前缀),
|
|
243
|
+
* 只保留 `BLOCK_MAX` 次不达标 → 降权该号(缓存无效,可恢复)。
|
|
244
|
+
*
|
|
245
|
+
* 亲和失效(拿不到 messages / 指纹算不出)时**回退到块轮询**,行为与之前一致。
|
|
246
|
+
* 设计见 docs/pool-sticky-block.md §8。
|
|
247
|
+
*
|
|
172
248
|
* @param accounts 插件报告的「现在状态」(顺序即插件的自然顺序)
|
|
173
249
|
* @param poolOrder 用户在面板拖出来的顺序(核心管)
|
|
174
|
-
* @param strategy fallback 取第一个健康 / round-robin
|
|
250
|
+
* @param strategy fallback 取第一个健康 / round-robin 前缀亲和
|
|
175
251
|
* @param modelId 当前要路由的模型(决定查哪个 (model, uid) 冷却单元)
|
|
252
|
+
* @param messages 请求体的 messages(可选;给了才能算前缀指纹)
|
|
176
253
|
* @returns 选中的 uid;无健康账号返回 undefined
|
|
177
254
|
*/
|
|
178
|
-
pick(accounts: SupplierAccountNow[], poolOrder: string[], strategy: string, modelId: string): string | undefined {
|
|
255
|
+
pick(accounts: SupplierAccountNow[], poolOrder: string[], strategy: string, modelId: string, messages?: unknown): string | undefined {
|
|
179
256
|
const now = Date.now()
|
|
180
257
|
const byUid = new Map(accounts.map((a) => [a.uid, a]))
|
|
181
258
|
// 池顺序优先,未配置的按插件自然顺序追加
|
|
@@ -186,11 +263,110 @@ export class AccountPool {
|
|
|
186
263
|
const healthy = ordered.filter((uid) => this.healthy(uid, modelId, now))
|
|
187
264
|
if (healthy.length === 0) return undefined
|
|
188
265
|
if (strategy !== 'round-robin') return healthy[0]
|
|
189
|
-
|
|
190
|
-
|
|
266
|
+
// 缓存无效降权:该号缓存质量样本长期不达标 → 跳过它(可到期恢复)
|
|
267
|
+
const usable = healthy.filter((uid) => !this.isDemoted(uid, modelId, now))
|
|
268
|
+
const pool = usable.length > 0 ? usable : healthy
|
|
269
|
+
// 前缀亲和:指纹有绑定且该号仍可用 → 直接复用(不进驻留逻辑)
|
|
270
|
+
const fp = messages === undefined ? '' : prefixFingerprint(messages)
|
|
271
|
+
if (fp !== '' && pool.includes(this.affinity.get(this.blockKey(modelId))?.get(fp) ?? '')) {
|
|
272
|
+
return this.affinity.get(this.blockKey(modelId))!.get(fp)!
|
|
273
|
+
}
|
|
274
|
+
const block = this.blocks.get(this.blockKey(modelId))
|
|
275
|
+
if (fp === '' && block !== undefined && pool.includes(block) && this.blockKeeps(modelId, block)) {
|
|
276
|
+
return block
|
|
277
|
+
}
|
|
278
|
+
// 无亲和(或块满/块达标)→ 前进游标。从当前号之后开始找,保证轮转顺序稳定。
|
|
279
|
+
const from = block === undefined ? this.rrCursor : Math.max(0, pool.indexOf(block) + 1)
|
|
280
|
+
const uid = pool[from % pool.length]!
|
|
281
|
+
this.rrCursor = (pool.indexOf(uid) + 1) % pool.length
|
|
282
|
+
this.blocks.set(this.blockKey(modelId), uid)
|
|
283
|
+
if (fp !== '') {
|
|
284
|
+
this.bind(modelId, fp, uid)
|
|
285
|
+
} else {
|
|
286
|
+
// 新驻留 = 新观察窗口(旧行为:块计数从 0 开始)。样本是跨驻留累计的,
|
|
287
|
+
// 不清的话上一个大样本会立刻把新驻留判走。
|
|
288
|
+
this.samples.delete(this.key(modelId, uid))
|
|
289
|
+
}
|
|
191
290
|
return uid
|
|
192
291
|
}
|
|
193
292
|
|
|
293
|
+
/** 记下「前缀指纹 → 号」的绑定(LRU:超上限丢最久未用的)。 */
|
|
294
|
+
private bind(modelId: string, fp: string, uid: string): void {
|
|
295
|
+
const k = this.blockKey(modelId)
|
|
296
|
+
let m = this.affinity.get(k)
|
|
297
|
+
if (m === undefined) {
|
|
298
|
+
m = new Map()
|
|
299
|
+
this.affinity.set(k, m)
|
|
300
|
+
}
|
|
301
|
+
// 已存在则先删再 set:Map 的插入序即 LRU 序,重插等于刷新到最新
|
|
302
|
+
m.delete(fp)
|
|
303
|
+
m.set(fp, uid)
|
|
304
|
+
if (m.size > AFFINITY_MAX) {
|
|
305
|
+
const oldest = m.keys().next().value
|
|
306
|
+
if (oldest !== undefined) m.delete(oldest)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** 该号在本模型上是否被判定「缓存无效」(降权中)。 */
|
|
311
|
+
private isDemoted(uid: string, modelId: string, now: number): boolean {
|
|
312
|
+
const e = this.demoted.get(this.key(modelId, uid))
|
|
313
|
+
return e !== undefined && e.until > now
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* 驻留是否还应留在当前号上(**只用于无亲和的回退路径**)。
|
|
318
|
+
*
|
|
319
|
+
* OR 语义(用户拍板):`留守 = 计数 < N OR 命中率 < M`。
|
|
320
|
+
* 单一账号内部:一个号先被轮到(冷启动/热),然后
|
|
321
|
+
* - 命中率**爬上去**(>= M,缓存热了)→ 让位,轮到下一个号(均衡)
|
|
322
|
+
* - 命中率**没上去**(冷启动/短块)→ 留守,继续热(不急着跳)
|
|
323
|
+
* - 轮次**实在太多**(计数 >= N_max)→ 也换(防死守 + 保均衡下限)
|
|
324
|
+
*
|
|
325
|
+
* 关键:命中率是**留守**条件不是切号执行器(反接会正反馈死锁,
|
|
326
|
+
* 见文档 §3)。换号由「命中达标 OR 份额用够」触发,都不是「命中率低」。
|
|
327
|
+
*/
|
|
328
|
+
private blockKeeps(modelId: string, uid: string): boolean {
|
|
329
|
+
const s = this.samples.get(this.key(modelId, uid))
|
|
330
|
+
const served = s?.served ?? 0
|
|
331
|
+
if (served >= BLOCK_MAX) return false // 轮次过多 → 让位(兜底)
|
|
332
|
+
const ratio = served === 0 ? 0 : (s?.hits ?? 0) / served
|
|
333
|
+
return served < BLOCK_MIN || ratio < BLOCK_HIT_RATIO // 计数<N OR 命中率<M
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* 记录一次成功请求的缓存命中情况(降权判据的反馈信号)。
|
|
338
|
+
*
|
|
339
|
+
* 只有**成功**请求才计入:失败没产生缓存,计入会污染命中率并导致错降权。
|
|
340
|
+
* 统计按 (model, uid) 记,**不管这个号是不是「当前驻留」**——亲和开启后
|
|
341
|
+
* 多个会话各绑各的号交错服务,按驻留记会漏掉大部分真实反馈。
|
|
342
|
+
* @param uid 实际服务的号
|
|
343
|
+
* @param modelId 模型(块粒度)
|
|
344
|
+
* @param cachedTokens 上游报的命中 token 数;0 = 全量重算
|
|
345
|
+
*/
|
|
346
|
+
noteCache(uid: string, modelId: string, cachedTokens: number): void {
|
|
347
|
+
const k = this.key(modelId, uid)
|
|
348
|
+
const s = this.samples.get(k) ?? { served: 0, hits: 0 }
|
|
349
|
+
s.served += 1
|
|
350
|
+
if (cachedTokens > 0) s.hits += 1
|
|
351
|
+
this.samples.set(k, s)
|
|
352
|
+
// 累计满 N_max 仍不达标 → 判定缓存无效,降权一段时间(可恢复)
|
|
353
|
+
if (s.served >= BLOCK_MAX && s.hits / s.served < BLOCK_HIT_RATIO) {
|
|
354
|
+
this.demoted.set(k, { until: Date.now() + BLOCK_DEMOTE_MS })
|
|
355
|
+
// 降权后样本重置:冷却结束重新观察,而不是带着旧判决立刻再降
|
|
356
|
+
this.samples.delete(k)
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** 故障切走时作废当前驻留:换号后前缀归属变了,驻留计数不能沿用。 */
|
|
361
|
+
dropBlock(modelId: string): void {
|
|
362
|
+
this.blocks.delete(this.blockKey(modelId))
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** 块状态键:与冷却同粒度 (supplier, model),不含 uid。 */
|
|
366
|
+
private blockKey(modelId: string): string {
|
|
367
|
+
return `${this.supplierId}${SEP}${modelId}`
|
|
368
|
+
}
|
|
369
|
+
|
|
194
370
|
/** 记录一次失败:按状态处置(冷却/禁用/退避),落在「(模型, 连接)」这个冷却单元上。 */
|
|
195
371
|
noteFailure(uid: string, modelId: string, state: AccountState, message: string): void {
|
|
196
372
|
const rule = RULES[state]
|
package/src/router/index.ts
CHANGED
|
@@ -78,6 +78,18 @@ function isTransient(state: AccountState): boolean {
|
|
|
78
78
|
return state === 'transport' || state === 'unavailable'
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* 从请求体里取 `messages`(前缀亲和的指纹输入)。解析失败/字段缺失返回
|
|
83
|
+
* undefined —— 亲和拿不到输入就自动回退到块轮询,不影响路由本身。
|
|
84
|
+
*/
|
|
85
|
+
function reqMessages(rawBody: string): unknown {
|
|
86
|
+
try {
|
|
87
|
+
return (JSON.parse(rawBody) as { messages?: unknown }).messages
|
|
88
|
+
} catch {
|
|
89
|
+
return undefined
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
81
93
|
/** 丢弃响应的假 ServerResponse(测试模型用:记录状态+内容,用于判定成败)。 */
|
|
82
94
|
function sinkRes(): ServerResponse & { status(): number; body(): string } {
|
|
83
95
|
let status = 200
|
|
@@ -845,6 +857,8 @@ export class Router {
|
|
|
845
857
|
ok,
|
|
846
858
|
durationMs: Date.now() - probe.startedAt,
|
|
847
859
|
ttfbMs: probe.ttfbMs,
|
|
860
|
+
// 只有真被服务过才记 uid:失败请求的 uid 可能是空或上一次的残留
|
|
861
|
+
...(ok && probe.uid !== '' ? { uid: probe.uid } : {}),
|
|
848
862
|
...(error === undefined ? {} : { error }),
|
|
849
863
|
}, usage)
|
|
850
864
|
}
|
|
@@ -1004,9 +1018,12 @@ export class Router {
|
|
|
1004
1018
|
}
|
|
1005
1019
|
// 试过的号不再选:某些失败状态既不冷却也不计数(如模型不属于本供应商),
|
|
1006
1020
|
// 不排除试过的就会原地打转——死循环等于整个服务挂住。
|
|
1021
|
+
// messages 只取一次:前缀亲和用它算指纹(重试换号时指纹必须一致,
|
|
1022
|
+
// 否则同一会话的两次尝试会绑到不同的号上)。
|
|
1023
|
+
const messages = reqMessages(req.rawBody)
|
|
1007
1024
|
const tried = new Set<string>()
|
|
1008
1025
|
for (;;) {
|
|
1009
|
-
const uid = pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder, cfg.poolStrategy, req.model)
|
|
1026
|
+
const uid = pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder, cfg.poolStrategy, req.model, messages)
|
|
1010
1027
|
if (uid === undefined) return false
|
|
1011
1028
|
tried.add(uid)
|
|
1012
1029
|
const r = await s.chatOnce(uid, req.lv ?? 'auto', req)
|
|
@@ -1018,6 +1035,8 @@ export class Router {
|
|
|
1018
1035
|
return false
|
|
1019
1036
|
}
|
|
1020
1037
|
pool.noteFailure(uid, req.model, r.state, r.message)
|
|
1038
|
+
// 故障切走:下次选号会换号,旧块的驻留计数不再适用(前缀归属变了)
|
|
1039
|
+
pool.dropBlock(req.model)
|
|
1021
1040
|
if (trace !== undefined) {
|
|
1022
1041
|
trace.attempts += 1
|
|
1023
1042
|
trace.lastState = r.state
|
|
@@ -1036,8 +1055,12 @@ export class Router {
|
|
|
1036
1055
|
probe.uid = uid
|
|
1037
1056
|
}
|
|
1038
1057
|
const committed = await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0)
|
|
1058
|
+
if (committed) {
|
|
1059
|
+
// 块轮询反馈:只有成功请求才计入(失败没产生缓存,计入会污染命中率)
|
|
1060
|
+
pool.noteCache(uid, req.model, probe?.tokens?.cachedTokens ?? 0)
|
|
1061
|
+
return true
|
|
1062
|
+
}
|
|
1039
1063
|
// 一个字节都没写(上游刚连上就断)→ 这个号不算数,换下一个重试
|
|
1040
|
-
if (committed) return true
|
|
1041
1064
|
pool.noteFailure(uid, req.model, 'transport', 'stream failed before first byte')
|
|
1042
1065
|
if (trace !== undefined) {
|
|
1043
1066
|
trace.attempts += 1
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 前缀指纹 —— 把一次请求的**消息前缀**压成一个短字符串,用于「同一个会话
|
|
3
|
+
* 粘在同一个账号上」。
|
|
4
|
+
*
|
|
5
|
+
* ## 为什么要这个
|
|
6
|
+
*
|
|
7
|
+
* 上游 prompt cache 是 **per-account × per-conversation** 的命名空间。块轮询
|
|
8
|
+
* 保证的是「单会话内连续驻留」,但多个会话被轮到同一个号时,各自十几万的大
|
|
9
|
+
* 前缀会在该号的缓存里**互相驱逐**(实测 3 会话并发 → 命中 3.8%,单会话独占
|
|
10
|
+
* → 82.5%,见 docs/pool-sticky-block.md §2.1)。
|
|
11
|
+
*
|
|
12
|
+
* 破法是让每个会话固定落到一个号:前缀指纹 → 选号。指纹相同的请求(= 同一
|
|
13
|
+
* 会话的连续请求,前缀逐轮增长但开头不变)落在同一个号,各自有独立的缓存
|
|
14
|
+
* 空间。
|
|
15
|
+
*
|
|
16
|
+
* ## 为什么按「前几条消息」而不是整个 body
|
|
17
|
+
*
|
|
18
|
+
* - 整个 body 每次都不一样(会话越跑越长)→ 指纹漂移 → 同一会话换号,正好
|
|
19
|
+
* 是要避免的事。
|
|
20
|
+
* - 请求体前 N 字节也不行:system prompt 动辄几 KB,同源会话(同一个 agent)
|
|
21
|
+
* 在这 N 字节里完全一样,多个会话会撞到同一个号。
|
|
22
|
+
* - 条数取 **2**(system + 首条用户消息)是刻意的:这两个槽位在会话整个
|
|
23
|
+
* 生命周期里**永不变化**(新消息只往后追加),所以从第 1 个请求起指纹
|
|
24
|
+
* 就稳定。取 3 条会在第一条 assistant 回复落进槽位 2 时变一次指纹 →
|
|
25
|
+
* 会话开局多付一次全量重算。而「system + 首条消息逐字相同」的会话,
|
|
26
|
+
* 缓存前缀本来就相同,共享一个号的缓存是收益不是冲突。
|
|
27
|
+
*
|
|
28
|
+
* 天花板:若某个客户端的前 3 条消息在所有会话里都逐字相同(无状态短请求),
|
|
29
|
+
* 指纹会退化成一个值 → 全部压到一个号。此时退化行为 = fallback,可接受;
|
|
30
|
+
* 真要区分得等 DSH 透传会话 id(不为此改契约)。
|
|
31
|
+
*/
|
|
32
|
+
import { createHash } from 'node:crypto'
|
|
33
|
+
|
|
34
|
+
/** 取前几条消息:system + 首条用户消息。会话里这两个槽位永不变化 → 指纹从第 1 个请求起稳定。 */
|
|
35
|
+
const FP_MSGS = 2
|
|
36
|
+
/** 每条消息取前多少字符。缓存前缀的区分度集中在开头,全量哈希只增成本。 */
|
|
37
|
+
const FP_CHARS = 512
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 消息 content 可能是字符串,也可能是多模态分片数组
|
|
41
|
+
* (`[{ type: 'text', text: '...' }]`)。只取文本部分:图片是 base64,
|
|
42
|
+
* 既大又不参与前缀匹配的语义(同一张图每轮都是同一串)。
|
|
43
|
+
*/
|
|
44
|
+
function textOf(content: unknown): string {
|
|
45
|
+
if (typeof content === 'string') return content
|
|
46
|
+
if (!Array.isArray(content)) return ''
|
|
47
|
+
let s = ''
|
|
48
|
+
for (const part of content) {
|
|
49
|
+
if (typeof part === 'string') {
|
|
50
|
+
s += part
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
if (part !== null && typeof part === 'object') {
|
|
54
|
+
const t = (part as { text?: unknown }).text
|
|
55
|
+
if (typeof t === 'string') s += t
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return s
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 算请求的前缀指纹。
|
|
63
|
+
* @param messages OpenAI 请求体的 `messages` 字段(未校验,来自外部输入)
|
|
64
|
+
* @returns 指纹字符串;**空串 = 算不出来(无亲和,回退到块轮询)**
|
|
65
|
+
*/
|
|
66
|
+
export function prefixFingerprint(messages: unknown): string {
|
|
67
|
+
if (!Array.isArray(messages) || messages.length === 0) return ''
|
|
68
|
+
let buf = ''
|
|
69
|
+
const n = Math.min(messages.length, FP_MSGS)
|
|
70
|
+
for (let i = 0; i < n; i++) {
|
|
71
|
+
const m = messages[i]
|
|
72
|
+
if (m === null || typeof m !== 'object') continue
|
|
73
|
+
const { role, content } = m as { role?: unknown; content?: unknown }
|
|
74
|
+
const text = textOf(content)
|
|
75
|
+
if (typeof role !== 'string' && text === '') continue
|
|
76
|
+
buf += `${typeof role === 'string' ? role : ''}\u0000${text.slice(0, FP_CHARS)}\u0001`
|
|
77
|
+
}
|
|
78
|
+
if (buf === '') return ''
|
|
79
|
+
return createHash('sha1').update(buf).digest('base64url').slice(0, 12)
|
|
80
|
+
}
|
|
@@ -44,6 +44,14 @@ export interface UsageRecord {
|
|
|
44
44
|
durationMs: number
|
|
45
45
|
/** 首字节延迟(ms);非流式为 0。 */
|
|
46
46
|
ttfbMs: number
|
|
47
|
+
/**
|
|
48
|
+
* 实际服务的账号 uid(成功时回填)。
|
|
49
|
+
*
|
|
50
|
+
* 有了它才能把命中率按**号**下钻——prompt cache 是 per-account 命名空间,
|
|
51
|
+
* 只看供应商/模型无法判断「是某个号的缓存没热,还是策略在乱切号」。
|
|
52
|
+
* 块轮询(docs/pool-sticky-block.md)的调参完全依赖这个归因。
|
|
53
|
+
*/
|
|
54
|
+
uid?: string
|
|
47
55
|
/** 失败原因(ok=false 时)。 */
|
|
48
56
|
error?: string
|
|
49
57
|
}
|
package/src/shared.ts
CHANGED
|
@@ -139,6 +139,26 @@ export interface RouterSettingsResponse {
|
|
|
139
139
|
requireApiKey?: boolean
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/** 一条扩展器(`/router/api/ext`)。 */
|
|
143
|
+
export interface RouterExtItem {
|
|
144
|
+
id: string
|
|
145
|
+
name: string
|
|
146
|
+
description?: string
|
|
147
|
+
/** 是否启用改写(面板开关)。 */
|
|
148
|
+
enabled?: boolean
|
|
149
|
+
/** 运行时是否就绪(如 RTK 二进制是否可用)。 */
|
|
150
|
+
ready?: boolean
|
|
151
|
+
/** 一句话状态说明(如「本机未装 rtk」)。 */
|
|
152
|
+
detail?: string
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** `/router/api/ext` response(扩展插件列表 + 开关)。 */
|
|
156
|
+
export interface RouterExtResponse {
|
|
157
|
+
ok: boolean
|
|
158
|
+
error?: string
|
|
159
|
+
enhancers?: RouterExtItem[]
|
|
160
|
+
}
|
|
161
|
+
|
|
142
162
|
/** 供应商模型(含启用状态)。 */
|
|
143
163
|
export interface RouterSupplierModel {
|
|
144
164
|
id: string
|