dsh-router-core 0.3.12 → 0.3.14
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 +2 -2
- package/docs/pool-sticky-block.md +51 -0
- package/lib/client-registry.js +56 -1
- package/lib/client.js +56 -1
- package/lib/index.js +320 -59
- package/lib/types/llm/adapter.d.ts +37 -2
- package/lib/types/router/account-pool.d.ts +29 -19
- package/lib/types/router/prefix-affinity.d.ts +6 -0
- package/package.json +9 -5
- package/src/client/SupplierDetail.tsx +1 -1
- package/src/client/index.tsx +3 -0
- package/src/client/model-hint-copy.test.ts +31 -0
- package/src/client/model-hint-copy.ts +55 -0
- package/src/index.ts +61 -2
- package/src/llm/adapter.test.ts +82 -0
- package/src/llm/adapter.ts +145 -15
- package/src/router/account-pool.test.ts +126 -0
- package/src/router/account-pool.ts +103 -39
- package/src/router/index.ts +16 -1
- package/src/router/prefix-affinity.ts +80 -0
|
@@ -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
|
+
}
|