dsh-router-core 0.3.12 → 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 +2 -2
- package/docs/pool-sticky-block.md +51 -0
- package/lib/client-registry.js +1 -1
- package/lib/client.js +1 -1
- package/lib/index.js +188 -44
- package/lib/types/llm/adapter.d.ts +9 -0
- package/lib/types/router/account-pool.d.ts +29 -19
- package/lib/types/router/prefix-affinity.d.ts +6 -0
- package/package.json +5 -5
- package/src/client/SupplierDetail.tsx +1 -1
- package/src/llm/adapter.ts +22 -5
- 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
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<p align="center">
|
|
6
6
|
<a href="https://www.npmjs.com/package/dsh-router-core"><img src="https://img.shields.io/npm/v/dsh-router-core?style=flat-square&logo=npm&label=npm" alt="npm version"></a>
|
|
7
7
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-10b981?style=flat-square" alt="MIT license"></a>
|
|
8
|
-
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本:0.1.
|
|
8
|
+
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本:0.1.5-rc.1+" src="https://img.shields.io/badge/DSH-0.1.5--rc.1%2B-4d6bfe" /></a>
|
|
9
9
|
</p>
|
|
10
10
|
|
|
11
11
|
<p align="center">
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
## 快速安装
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
需要 **DSH `0.1.5-rc.1` 及以上**(支持 `dsh plugin` profile 插件机制)、Node.js `>= 20`,以及 `web` profile。
|
|
29
29
|
|
|
30
30
|
```bash
|
|
31
31
|
dsh plugin --profile web add dsh-router-core
|
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
> 状态:设计定稿 + 已实现。目标:保留 round-robin 的**多账号均衡**,
|
|
4
4
|
> 只把轮转的**触发方式**从「每请求」改成「满意才走」,从而大幅降低
|
|
5
5
|
> prompt cache miss。
|
|
6
|
+
>
|
|
7
|
+
> **2026-09-09 后续:round-robin 已升级为前缀亲和**(§8 天花板落地,
|
|
8
|
+
> 见文末 §9)。块轮转降级为**回退路径**——亲和拿不到 `messages`
|
|
9
|
+
> (算不出指纹)时才走;块统计(`BLOCK_MIN/BLOCK_HIT_RATIO/BLOCK_MAX`)
|
|
10
|
+
> 保留,从「驱动轮转」改为「判降权」(§9.3)。
|
|
6
11
|
|
|
7
12
|
## 1. 问题:每请求轮转把缓存打碎
|
|
8
13
|
|
|
@@ -204,3 +209,49 @@ pick():
|
|
|
204
209
|
否则触发失真。当前未启用。
|
|
205
210
|
- **TTL 仍未知**:87.5% 是保证下限,实际可能更高。要精确 N 需反推 TTL
|
|
206
211
|
(同号相邻请求间隔 vs 命中率拟合驱逐拐点)。
|
|
212
|
+
|
|
213
|
+
## 9. 前缀亲和(2026-09-09 落地,round-robin 的新语义)
|
|
214
|
+
|
|
215
|
+
### 9.1 机制
|
|
216
|
+
|
|
217
|
+
```
|
|
218
|
+
pick(round-robin):
|
|
219
|
+
fp = hash(messages 前 2 条: role + content 前 512 字符) ← src/router/prefix-affinity.ts
|
|
220
|
+
绑定表[model][fp] 存在且该号健康 → 返回它 ← 亲和:同会话永远同号
|
|
221
|
+
否则 → 游标分一个号 + 记绑定(fp → uid)
|
|
222
|
+
块统计(样本按 (model, uid) 跨驻留累计)只用来判降权,不再驱动轮转
|
|
223
|
+
fp 算不出(无 messages/解析失败)→ 回退块轮转(§2 的旧行为)
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
- **会话标识 = 前缀指纹**:取前 **2** 条(system + 首条用户消息)是刻意的
|
|
227
|
+
——这两个槽位在会话生命周期里永不变化(新消息只往后追加),指纹从第 1 个
|
|
228
|
+
请求起就稳定。取 3 条会在首条 assistant 回复落进槽位 2 时变一次指纹 →
|
|
229
|
+
会话开局多付一次全量重算。「system + 首条消息逐字相同」的会话,缓存前缀
|
|
230
|
+
本来就相同,共享一个号是收益不是冲突。
|
|
231
|
+
- **新会话按游标分发** → 不同会话天然散到不同号,per-account 缓存空间互不
|
|
232
|
+
清(§2.1 的打架根治)。
|
|
233
|
+
- **绑定失效即重绑**:绑定的号冷却/降权/被禁 → 会话让位换健康号,并更新绑定。
|
|
234
|
+
|
|
235
|
+
### 9.2 为什么亲和负责选号、块不再负责轮转
|
|
236
|
+
|
|
237
|
+
亲和本身已保证驻留(同会话永远粘同号),块轮转若继续驱动换号,会把会话
|
|
238
|
+
踢出已热的前缀——两者叠加只会互相拆台。块统计保留的真实价值是**号的缓存
|
|
239
|
+
质量判定**:某号累计 `BLOCK_MAX`(48) 次命中率仍 < `M`(0.85) → 判缓存
|
|
240
|
+
无效 → 降权 10 分钟(可恢复),亲和与回退路径都跳过它。
|
|
241
|
+
|
|
242
|
+
样本从「按驻留记」改为「按 (model, uid) 跨驻留累计」:亲和开启后多个会话
|
|
243
|
+
各绑各的号交错服务,「驻留」概念只在回退路径存在,按驻留记会漏掉大部分
|
|
244
|
+
真实反馈。回退路径换驻留时清一次样本(保持旧块语义:新驻留 = 新观察窗口)。
|
|
245
|
+
|
|
246
|
+
### 9.3 状态
|
|
247
|
+
|
|
248
|
+
| 项 | 结果 |
|
|
249
|
+
|---|---|
|
|
250
|
+
| 实现 | `src/router/prefix-affinity.ts`(指纹)、`account-pool.ts`(亲和/LRU/样本)、`index.ts`(透传 messages) |
|
|
251
|
+
| 契约 | **不变**——指纹在核心内闭环,插件与 DSH 均无感知 |
|
|
252
|
+
| 测试 | 191/191(含 9 个新亲和用例) |
|
|
253
|
+
| 天花板 | 绑定表 LRU 封顶 512 条(防指纹无限增长);无状态短请求前 2 条全同 → 退化为同号,行为等同 fallback,可接受 |
|
|
254
|
+
|
|
255
|
+
ponytail:会话标识目前靠前缀指纹推断,真要百分百准得等 DSH 在 `ChatRequest`
|
|
256
|
+
透传会话 id(需改契约,不做);指纹对「首条消息就分叉」的客户端(极少见)
|
|
257
|
+
会散得比会话更细,退化为均衡分发,无害。
|
package/lib/client-registry.js
CHANGED
|
@@ -1173,7 +1173,7 @@ window.__ModuleLoader__.load({
|
|
|
1173
1173
|
}),
|
|
1174
1174
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1175
1175
|
className: "dshr-muted",
|
|
1176
|
-
children: "回退 = 按连接池顺序取第一个健康链接;轮询 =
|
|
1176
|
+
children: "回退 = 按连接池顺序取第一个健康链接;轮询 = 按会话前缀粘住同一链接(前缀亲和,多会话不打架),拿不到会话信息时退回块轮转。"
|
|
1177
1177
|
}),
|
|
1178
1178
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
|
|
1179
1179
|
className: "dshr-select dshr-input",
|
package/lib/client.js
CHANGED
|
@@ -1173,7 +1173,7 @@ window.__ModuleLoader__.load({
|
|
|
1173
1173
|
}),
|
|
1174
1174
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1175
1175
|
className: "dshr-muted",
|
|
1176
|
-
children: "回退 = 按连接池顺序取第一个健康链接;轮询 =
|
|
1176
|
+
children: "回退 = 按连接池顺序取第一个健康链接;轮询 = 按会话前缀粘住同一链接(前缀亲和,多会话不打架),拿不到会话信息时退回块轮转。"
|
|
1177
1177
|
}),
|
|
1178
1178
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
|
|
1179
1179
|
className: "dshr-select dshr-input",
|
package/lib/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { basename, dirname, join, resolve } from "node:path";
|
|
2
2
|
import { mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
4
|
import { EMPTY_RESPONSE_CODE, LlmAdapter, LlmError, ReasoningEffortId, ToolCallId, attributionHeaders } from "@deepseek-ai/dsh-llm";
|
|
4
|
-
import { randomBytes } from "node:crypto";
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
6
|
import { DatabaseSync } from "node:sqlite";
|
|
7
7
|
import { homedir } from "node:os";
|
|
@@ -9,6 +9,84 @@ import { homedir } from "node:os";
|
|
|
9
9
|
/** Same-origin API base the client panel fetches (host half serves it). */
|
|
10
10
|
const ROUTER_API_BASE = "/router/api";
|
|
11
11
|
//#endregion
|
|
12
|
+
//#region src/router/prefix-affinity.ts
|
|
13
|
+
/**
|
|
14
|
+
* 前缀指纹 —— 把一次请求的**消息前缀**压成一个短字符串,用于「同一个会话
|
|
15
|
+
* 粘在同一个账号上」。
|
|
16
|
+
*
|
|
17
|
+
* ## 为什么要这个
|
|
18
|
+
*
|
|
19
|
+
* 上游 prompt cache 是 **per-account × per-conversation** 的命名空间。块轮询
|
|
20
|
+
* 保证的是「单会话内连续驻留」,但多个会话被轮到同一个号时,各自十几万的大
|
|
21
|
+
* 前缀会在该号的缓存里**互相驱逐**(实测 3 会话并发 → 命中 3.8%,单会话独占
|
|
22
|
+
* → 82.5%,见 docs/pool-sticky-block.md §2.1)。
|
|
23
|
+
*
|
|
24
|
+
* 破法是让每个会话固定落到一个号:前缀指纹 → 选号。指纹相同的请求(= 同一
|
|
25
|
+
* 会话的连续请求,前缀逐轮增长但开头不变)落在同一个号,各自有独立的缓存
|
|
26
|
+
* 空间。
|
|
27
|
+
*
|
|
28
|
+
* ## 为什么按「前几条消息」而不是整个 body
|
|
29
|
+
*
|
|
30
|
+
* - 整个 body 每次都不一样(会话越跑越长)→ 指纹漂移 → 同一会话换号,正好
|
|
31
|
+
* 是要避免的事。
|
|
32
|
+
* - 请求体前 N 字节也不行:system prompt 动辄几 KB,同源会话(同一个 agent)
|
|
33
|
+
* 在这 N 字节里完全一样,多个会话会撞到同一个号。
|
|
34
|
+
* - 条数取 **2**(system + 首条用户消息)是刻意的:这两个槽位在会话整个
|
|
35
|
+
* 生命周期里**永不变化**(新消息只往后追加),所以从第 1 个请求起指纹
|
|
36
|
+
* 就稳定。取 3 条会在第一条 assistant 回复落进槽位 2 时变一次指纹 →
|
|
37
|
+
* 会话开局多付一次全量重算。而「system + 首条消息逐字相同」的会话,
|
|
38
|
+
* 缓存前缀本来就相同,共享一个号的缓存是收益不是冲突。
|
|
39
|
+
*
|
|
40
|
+
* 天花板:若某个客户端的前 3 条消息在所有会话里都逐字相同(无状态短请求),
|
|
41
|
+
* 指纹会退化成一个值 → 全部压到一个号。此时退化行为 = fallback,可接受;
|
|
42
|
+
* 真要区分得等 DSH 透传会话 id(不为此改契约)。
|
|
43
|
+
*/
|
|
44
|
+
/** 取前几条消息:system + 首条用户消息。会话里这两个槽位永不变化 → 指纹从第 1 个请求起稳定。 */
|
|
45
|
+
const FP_MSGS = 2;
|
|
46
|
+
/** 每条消息取前多少字符。缓存前缀的区分度集中在开头,全量哈希只增成本。 */
|
|
47
|
+
const FP_CHARS = 512;
|
|
48
|
+
/**
|
|
49
|
+
* 消息 content 可能是字符串,也可能是多模态分片数组
|
|
50
|
+
* (`[{ type: 'text', text: '...' }]`)。只取文本部分:图片是 base64,
|
|
51
|
+
* 既大又不参与前缀匹配的语义(同一张图每轮都是同一串)。
|
|
52
|
+
*/
|
|
53
|
+
function textOf(content) {
|
|
54
|
+
if (typeof content === "string") return content;
|
|
55
|
+
if (!Array.isArray(content)) return "";
|
|
56
|
+
let s = "";
|
|
57
|
+
for (const part of content) {
|
|
58
|
+
if (typeof part === "string") {
|
|
59
|
+
s += part;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (part !== null && typeof part === "object") {
|
|
63
|
+
const t = part.text;
|
|
64
|
+
if (typeof t === "string") s += t;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return s;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* 算请求的前缀指纹。
|
|
71
|
+
* @param messages OpenAI 请求体的 `messages` 字段(未校验,来自外部输入)
|
|
72
|
+
* @returns 指纹字符串;**空串 = 算不出来(无亲和,回退到块轮询)**
|
|
73
|
+
*/
|
|
74
|
+
function prefixFingerprint(messages) {
|
|
75
|
+
if (!Array.isArray(messages) || messages.length === 0) return "";
|
|
76
|
+
let buf = "";
|
|
77
|
+
const n = Math.min(messages.length, FP_MSGS);
|
|
78
|
+
for (let i = 0; i < n; i++) {
|
|
79
|
+
const m = messages[i];
|
|
80
|
+
if (m === null || typeof m !== "object") continue;
|
|
81
|
+
const { role, content } = m;
|
|
82
|
+
const text = textOf(content);
|
|
83
|
+
if (typeof role !== "string" && text === "") continue;
|
|
84
|
+
buf += `${typeof role === "string" ? role : ""}\u0000${text.slice(0, FP_CHARS)}\u0001`;
|
|
85
|
+
}
|
|
86
|
+
if (buf === "") return "";
|
|
87
|
+
return createHash("sha1").update(buf).digest("base64url").slice(0, 12);
|
|
88
|
+
}
|
|
89
|
+
//#endregion
|
|
12
90
|
//#region src/router/account-pool.ts
|
|
13
91
|
const MINUTE = 6e4;
|
|
14
92
|
/**
|
|
@@ -85,6 +163,14 @@ const BLOCK_MIN = 16;
|
|
|
85
163
|
const BLOCK_MAX = 48;
|
|
86
164
|
/** 块轮询:判定「缓存无效」后的跳过时长。 */
|
|
87
165
|
const BLOCK_DEMOTE_MS = 10 * MINUTE;
|
|
166
|
+
/**
|
|
167
|
+
* 前缀亲和:指纹 → 号 的绑定记忆上限。
|
|
168
|
+
*
|
|
169
|
+
* 会话指纹是无限集合(每个新会话一个),不封顶就是内存泄漏。超过了就丢掉
|
|
170
|
+
* **最久没用**的那条:活跃会话的指纹每请求都在刷新,不会被误删。
|
|
171
|
+
* 单号池(<= 几十个会话)绰绰有余。
|
|
172
|
+
*/
|
|
173
|
+
const AFFINITY_MAX = 512;
|
|
88
174
|
/** 指数退避:等级 1 = BACKOFF_BASE_MS,每级翻倍,封顶 BACKOFF_MAX_MS。 */
|
|
89
175
|
function backoffMs(level) {
|
|
90
176
|
return Math.min(BACKOFF_BASE_MS * 2 ** Math.max(0, level - 1), BACKOFF_MAX_MS);
|
|
@@ -99,10 +185,14 @@ var AccountPool = class {
|
|
|
99
185
|
/** uid → 连接级禁用 / 手动暂停。 */
|
|
100
186
|
byUid = /* @__PURE__ */ new Map();
|
|
101
187
|
rrCursor = 0;
|
|
102
|
-
/** (supplier, model) →
|
|
188
|
+
/** (supplier, model) → 当前驻留的号(只有**无亲和**的回退路径用它)。 */
|
|
103
189
|
blocks = /* @__PURE__ */ new Map();
|
|
190
|
+
/** (supplier, model, uid) → 该号的缓存质量样本(降权判据,跨驻留累计)。 */
|
|
191
|
+
samples = /* @__PURE__ */ new Map();
|
|
104
192
|
/** (supplier, model, uid) → 缓存无效降权。 */
|
|
105
193
|
demoted = /* @__PURE__ */ new Map();
|
|
194
|
+
/** (supplier, model) → Map<前缀指纹, uid>:前缀亲和的选号记忆(LRU 封顶)。 */
|
|
195
|
+
affinity = /* @__PURE__ */ new Map();
|
|
106
196
|
constructor(supplierId = "") {
|
|
107
197
|
this.supplierId = supplierId;
|
|
108
198
|
}
|
|
@@ -169,22 +259,28 @@ var AccountPool = class {
|
|
|
169
259
|
/**
|
|
170
260
|
* 按策略为「某个模型」选一个健康账号。
|
|
171
261
|
*
|
|
172
|
-
* round-robin
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
262
|
+
* round-robin = **前缀亲和定号 + 块统计只用来判定缓存无效**:
|
|
263
|
+
*
|
|
264
|
+
* 1. 算请求的前缀指纹(messages 前几条),查绑定表 → 命中且该号健康 → 用它。
|
|
265
|
+
* 这是第一优先:同一个会话的连续请求**永远落回同一个号**,前缀在该号的
|
|
266
|
+
* 缓存里只写一份。块轮询治不了跨会话驱逐(多会话大前缀在同一号互踩,
|
|
267
|
+
* 实测 3 会话 3.8% vs 单会话 82.5%),亲和才是根治。
|
|
268
|
+
* 2. 新指纹(或绑定的号不健康)→ 按游标挑一个号并**记下绑定**,之后就固定。
|
|
269
|
+
* 新会话用游标分发,所以不同会话天然散到不同号上(均衡)。
|
|
270
|
+
* 3. 块统计不再驱动轮转(亲和已经保证驻留,再轮转只会把会话踢出已热前缀),
|
|
271
|
+
* 只保留 `BLOCK_MAX` 次不达标 → 降权该号(缓存无效,可恢复)。
|
|
272
|
+
*
|
|
273
|
+
* 亲和失效(拿不到 messages / 指纹算不出)时**回退到块轮询**,行为与之前一致。
|
|
274
|
+
* 设计见 docs/pool-sticky-block.md §8。
|
|
180
275
|
*
|
|
181
276
|
* @param accounts 插件报告的「现在状态」(顺序即插件的自然顺序)
|
|
182
277
|
* @param poolOrder 用户在面板拖出来的顺序(核心管)
|
|
183
|
-
* @param strategy fallback 取第一个健康 / round-robin
|
|
278
|
+
* @param strategy fallback 取第一个健康 / round-robin 前缀亲和
|
|
184
279
|
* @param modelId 当前要路由的模型(决定查哪个 (model, uid) 冷却单元)
|
|
280
|
+
* @param messages 请求体的 messages(可选;给了才能算前缀指纹)
|
|
185
281
|
* @returns 选中的 uid;无健康账号返回 undefined
|
|
186
282
|
*/
|
|
187
|
-
pick(accounts, poolOrder, strategy, modelId) {
|
|
283
|
+
pick(accounts, poolOrder, strategy, modelId, messages) {
|
|
188
284
|
const now = Date.now();
|
|
189
285
|
const byUid = new Map(accounts.map((a) => [a.uid, a]));
|
|
190
286
|
const healthy = [...poolOrder.filter((uid) => byUid.has(uid)), ...accounts.map((a) => a.uid).filter((uid) => !poolOrder.includes(uid))].filter((uid) => this.healthy(uid, modelId, now));
|
|
@@ -192,24 +288,39 @@ var AccountPool = class {
|
|
|
192
288
|
if (strategy !== "round-robin") return healthy[0];
|
|
193
289
|
const usable = healthy.filter((uid) => !this.isDemoted(uid, modelId, now));
|
|
194
290
|
const pool = usable.length > 0 ? usable : healthy;
|
|
291
|
+
const fp = messages === void 0 ? "" : prefixFingerprint(messages);
|
|
292
|
+
if (fp !== "" && pool.includes(this.affinity.get(this.blockKey(modelId))?.get(fp) ?? "")) return this.affinity.get(this.blockKey(modelId)).get(fp);
|
|
195
293
|
const block = this.blocks.get(this.blockKey(modelId));
|
|
196
|
-
if (block !== void 0 && pool.includes(block
|
|
197
|
-
const uid = pool[(block === void 0 ? this.rrCursor : Math.max(0, pool.indexOf(block
|
|
294
|
+
if (fp === "" && block !== void 0 && pool.includes(block) && this.blockKeeps(modelId, block)) return block;
|
|
295
|
+
const uid = pool[(block === void 0 ? this.rrCursor : Math.max(0, pool.indexOf(block) + 1)) % pool.length];
|
|
198
296
|
this.rrCursor = (pool.indexOf(uid) + 1) % pool.length;
|
|
199
|
-
this.blocks.set(this.blockKey(modelId),
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
hits: 0
|
|
203
|
-
});
|
|
297
|
+
this.blocks.set(this.blockKey(modelId), uid);
|
|
298
|
+
if (fp !== "") this.bind(modelId, fp, uid);
|
|
299
|
+
else this.samples.delete(this.key(modelId, uid));
|
|
204
300
|
return uid;
|
|
205
301
|
}
|
|
302
|
+
/** 记下「前缀指纹 → 号」的绑定(LRU:超上限丢最久未用的)。 */
|
|
303
|
+
bind(modelId, fp, uid) {
|
|
304
|
+
const k = this.blockKey(modelId);
|
|
305
|
+
let m = this.affinity.get(k);
|
|
306
|
+
if (m === void 0) {
|
|
307
|
+
m = /* @__PURE__ */ new Map();
|
|
308
|
+
this.affinity.set(k, m);
|
|
309
|
+
}
|
|
310
|
+
m.delete(fp);
|
|
311
|
+
m.set(fp, uid);
|
|
312
|
+
if (m.size > AFFINITY_MAX) {
|
|
313
|
+
const oldest = m.keys().next().value;
|
|
314
|
+
if (oldest !== void 0) m.delete(oldest);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
206
317
|
/** 该号在本模型上是否被判定「缓存无效」(降权中)。 */
|
|
207
318
|
isDemoted(uid, modelId, now) {
|
|
208
319
|
const e = this.demoted.get(this.key(modelId, uid));
|
|
209
320
|
return e !== void 0 && e.until > now;
|
|
210
321
|
}
|
|
211
322
|
/**
|
|
212
|
-
*
|
|
323
|
+
* 驻留是否还应留在当前号上(**只用于无亲和的回退路径**)。
|
|
213
324
|
*
|
|
214
325
|
* OR 语义(用户拍板):`留守 = 计数 < N OR 命中率 < M`。
|
|
215
326
|
* 单一账号内部:一个号先被轮到(冷启动/热),然后
|
|
@@ -219,33 +330,39 @@ var AccountPool = class {
|
|
|
219
330
|
*
|
|
220
331
|
* 关键:命中率是**留守**条件不是切号执行器(反接会正反馈死锁,
|
|
221
332
|
* 见文档 §3)。换号由「命中达标 OR 份额用够」触发,都不是「命中率低」。
|
|
222
|
-
*
|
|
223
|
-
* 每个号各自守着「这个会话在自己账号上的那段前缀」,轮流热;
|
|
224
|
-
* 命中率 = 块的命中占比。单会话下每个号块都从冷到热各付一次,
|
|
225
|
-
* 收益来自块内连续驻留(缺了它就跟每请求轮询一样)。
|
|
226
333
|
*/
|
|
227
|
-
blockKeeps(
|
|
228
|
-
|
|
229
|
-
const
|
|
230
|
-
|
|
334
|
+
blockKeeps(modelId, uid) {
|
|
335
|
+
const s = this.samples.get(this.key(modelId, uid));
|
|
336
|
+
const served = s?.served ?? 0;
|
|
337
|
+
if (served >= BLOCK_MAX) return false;
|
|
338
|
+
const ratio = served === 0 ? 0 : (s?.hits ?? 0) / served;
|
|
339
|
+
return served < BLOCK_MIN || ratio < BLOCK_HIT_RATIO;
|
|
231
340
|
}
|
|
232
341
|
/**
|
|
233
|
-
*
|
|
342
|
+
* 记录一次成功请求的缓存命中情况(降权判据的反馈信号)。
|
|
234
343
|
*
|
|
235
|
-
*
|
|
344
|
+
* 只有**成功**请求才计入:失败没产生缓存,计入会污染命中率并导致错降权。
|
|
345
|
+
* 统计按 (model, uid) 记,**不管这个号是不是「当前驻留」**——亲和开启后
|
|
346
|
+
* 多个会话各绑各的号交错服务,按驻留记会漏掉大部分真实反馈。
|
|
236
347
|
* @param uid 实际服务的号
|
|
237
348
|
* @param modelId 模型(块粒度)
|
|
238
349
|
* @param cachedTokens 上游报的命中 token 数;0 = 全量重算
|
|
239
350
|
*/
|
|
240
351
|
noteCache(uid, modelId, cachedTokens) {
|
|
241
|
-
const k = this.
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
352
|
+
const k = this.key(modelId, uid);
|
|
353
|
+
const s = this.samples.get(k) ?? {
|
|
354
|
+
served: 0,
|
|
355
|
+
hits: 0
|
|
356
|
+
};
|
|
357
|
+
s.served += 1;
|
|
358
|
+
if (cachedTokens > 0) s.hits += 1;
|
|
359
|
+
this.samples.set(k, s);
|
|
360
|
+
if (s.served >= BLOCK_MAX && s.hits / s.served < BLOCK_HIT_RATIO) {
|
|
361
|
+
this.demoted.set(k, { until: Date.now() + BLOCK_DEMOTE_MS });
|
|
362
|
+
this.samples.delete(k);
|
|
363
|
+
}
|
|
247
364
|
}
|
|
248
|
-
/**
|
|
365
|
+
/** 故障切走时作废当前驻留:换号后前缀归属变了,驻留计数不能沿用。 */
|
|
249
366
|
dropBlock(modelId) {
|
|
250
367
|
this.blocks.delete(this.blockKey(modelId));
|
|
251
368
|
}
|
|
@@ -1237,6 +1354,17 @@ const TRANSIENT_SETTLE_MS = 2e3;
|
|
|
1237
1354
|
function isTransient(state) {
|
|
1238
1355
|
return state === "transport" || state === "unavailable";
|
|
1239
1356
|
}
|
|
1357
|
+
/**
|
|
1358
|
+
* 从请求体里取 `messages`(前缀亲和的指纹输入)。解析失败/字段缺失返回
|
|
1359
|
+
* undefined —— 亲和拿不到输入就自动回退到块轮询,不影响路由本身。
|
|
1360
|
+
*/
|
|
1361
|
+
function reqMessages(rawBody) {
|
|
1362
|
+
try {
|
|
1363
|
+
return JSON.parse(rawBody).messages;
|
|
1364
|
+
} catch {
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1240
1368
|
/** 丢弃响应的假 ServerResponse(测试模型用:记录状态+内容,用于判定成败)。 */
|
|
1241
1369
|
function sinkRes() {
|
|
1242
1370
|
let status = 200;
|
|
@@ -2075,9 +2203,10 @@ var Router = class {
|
|
|
2075
2203
|
}
|
|
2076
2204
|
return await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0);
|
|
2077
2205
|
}
|
|
2206
|
+
const messages = reqMessages(req.rawBody);
|
|
2078
2207
|
const tried = /* @__PURE__ */ new Set();
|
|
2079
2208
|
for (;;) {
|
|
2080
|
-
const uid = pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder, cfg.poolStrategy, req.model);
|
|
2209
|
+
const uid = pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder, cfg.poolStrategy, req.model, messages);
|
|
2081
2210
|
if (uid === void 0) return false;
|
|
2082
2211
|
tried.add(uid);
|
|
2083
2212
|
const r = await s.chatOnce(uid, req.lv ?? "auto", req);
|
|
@@ -2174,11 +2303,16 @@ var Router = class {
|
|
|
2174
2303
|
/**
|
|
2175
2304
|
* 把 DSH 消息序列化成 openai 兼容 wire 消息(纯文本 + tool)。
|
|
2176
2305
|
*
|
|
2177
|
-
* `system` 必须由调用方从 `options.system` 传进来:
|
|
2178
|
-
* 放在 **options.system 这个独立槽位**,不放进 messages
|
|
2179
|
-
*
|
|
2180
|
-
*
|
|
2181
|
-
*
|
|
2306
|
+
* `system` 必须由调用方从 `options.system` 传进来:one-shot 调用方把系统提示词
|
|
2307
|
+
* 放在 **options.system 这个独立槽位**,不放进 messages。不读这个槽位,
|
|
2308
|
+
* one-shot 调用方(如 compaction / session-title)的模型每一轮都拿不到身份/
|
|
2309
|
+
* 规则/工具用法约束,且**不会报错**,只是行为悄悄降级。
|
|
2310
|
+
*
|
|
2311
|
+
* 0.1.5 起 loop-built 请求反过来:`options.system` 为 undefined,系统提示词作为
|
|
2312
|
+
* **messages 里的 system-role 消息**下发(dsh-agent-loop 的 SystemProjection,
|
|
2313
|
+
* 可出现在任意位置以支持提示词热更新)。两条路径都必须支持 —— 与官方
|
|
2314
|
+
* dsh-llm-deepseek 一致(serializeRequest 前置 options.system,再按位置序列化
|
|
2315
|
+
* messages 里的 system 消息)。
|
|
2182
2316
|
*/
|
|
2183
2317
|
function wireMessages(options, system) {
|
|
2184
2318
|
const out = [];
|
|
@@ -2506,7 +2640,7 @@ const ROUTER_REASONING_EFFORTS = [
|
|
|
2506
2640
|
name: "Max"
|
|
2507
2641
|
}
|
|
2508
2642
|
];
|
|
2509
|
-
var RouterAdapter = class extends LlmAdapter {
|
|
2643
|
+
var RouterAdapter = class RouterAdapter extends LlmAdapter {
|
|
2510
2644
|
baseURL;
|
|
2511
2645
|
source;
|
|
2512
2646
|
constructor(baseURL, source) {
|
|
@@ -2535,12 +2669,22 @@ var RouterAdapter = class extends LlmAdapter {
|
|
|
2535
2669
|
}
|
|
2536
2670
|
return out;
|
|
2537
2671
|
}
|
|
2672
|
+
/**
|
|
2673
|
+
* 只收文本。`resolveModel` 声明 `inputModalities: ['text']` 是**必须**的:
|
|
2674
|
+
* 0.1.5 的 runtime 只在 `inputModalities` 存在且不含 `image` 时才把 image 块
|
|
2675
|
+
* 投影成占位文本(见 dsh-llm adapterStream 的 projectImagesForTextModel)。
|
|
2676
|
+
* 不声明 = "unknown" = 不投影,而 wireMessages 只读 text 块 → 图片**静默
|
|
2677
|
+
* 丢失**,模型看到一条没有图片的用户消息却毫无提示。声明后由 runtime 统一
|
|
2678
|
+
* 投影,我们仍只处理文本。
|
|
2679
|
+
*/
|
|
2680
|
+
static INPUT_MODALITIES = ["text"];
|
|
2538
2681
|
async resolveModel(_provider, model) {
|
|
2539
2682
|
const found = (await this.source.comboModels()).find((m) => m.id === model || m.name === model);
|
|
2540
2683
|
return {
|
|
2541
2684
|
provider: "router",
|
|
2542
2685
|
id: model,
|
|
2543
2686
|
name: found?.name ?? found?.id ?? model,
|
|
2687
|
+
inputModalities: RouterAdapter.INPUT_MODALITIES,
|
|
2544
2688
|
...found?.contextWindow !== void 0 ? { context: { contextWindow: found.contextWindow } } : {},
|
|
2545
2689
|
reasoning: {
|
|
2546
2690
|
efforts: ROUTER_REASONING_EFFORTS,
|
|
@@ -38,6 +38,15 @@ export declare class RouterAdapter extends LlmAdapter {
|
|
|
38
38
|
name: string;
|
|
39
39
|
};
|
|
40
40
|
listModels(_provider: string): Promise<readonly LlmModelInfo[]>;
|
|
41
|
+
/**
|
|
42
|
+
* 只收文本。`resolveModel` 声明 `inputModalities: ['text']` 是**必须**的:
|
|
43
|
+
* 0.1.5 的 runtime 只在 `inputModalities` 存在且不含 `image` 时才把 image 块
|
|
44
|
+
* 投影成占位文本(见 dsh-llm adapterStream 的 projectImagesForTextModel)。
|
|
45
|
+
* 不声明 = "unknown" = 不投影,而 wireMessages 只读 text 块 → 图片**静默
|
|
46
|
+
* 丢失**,模型看到一条没有图片的用户消息却毫无提示。声明后由 runtime 统一
|
|
47
|
+
* 投影,我们仍只处理文本。
|
|
48
|
+
*/
|
|
49
|
+
private static readonly INPUT_MODALITIES;
|
|
41
50
|
resolveModel(_provider: string, model: string): Promise<LlmResolvedModelInfo>;
|
|
42
51
|
stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
|
|
43
52
|
}
|
|
@@ -31,10 +31,14 @@ export declare class AccountPool {
|
|
|
31
31
|
/** uid → 连接级禁用 / 手动暂停。 */
|
|
32
32
|
private byUid;
|
|
33
33
|
private rrCursor;
|
|
34
|
-
/** (supplier, model) →
|
|
34
|
+
/** (supplier, model) → 当前驻留的号(只有**无亲和**的回退路径用它)。 */
|
|
35
35
|
private blocks;
|
|
36
|
+
/** (supplier, model, uid) → 该号的缓存质量样本(降权判据,跨驻留累计)。 */
|
|
37
|
+
private samples;
|
|
36
38
|
/** (supplier, model, uid) → 缓存无效降权。 */
|
|
37
39
|
private demoted;
|
|
40
|
+
/** (supplier, model) → Map<前缀指纹, uid>:前缀亲和的选号记忆(LRU 封顶)。 */
|
|
41
|
+
private affinity;
|
|
38
42
|
constructor(supplierId?: string);
|
|
39
43
|
private key;
|
|
40
44
|
private entry;
|
|
@@ -51,26 +55,34 @@ export declare class AccountPool {
|
|
|
51
55
|
/**
|
|
52
56
|
* 按策略为「某个模型」选一个健康账号。
|
|
53
57
|
*
|
|
54
|
-
* round-robin
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
58
|
+
* round-robin = **前缀亲和定号 + 块统计只用来判定缓存无效**:
|
|
59
|
+
*
|
|
60
|
+
* 1. 算请求的前缀指纹(messages 前几条),查绑定表 → 命中且该号健康 → 用它。
|
|
61
|
+
* 这是第一优先:同一个会话的连续请求**永远落回同一个号**,前缀在该号的
|
|
62
|
+
* 缓存里只写一份。块轮询治不了跨会话驱逐(多会话大前缀在同一号互踩,
|
|
63
|
+
* 实测 3 会话 3.8% vs 单会话 82.5%),亲和才是根治。
|
|
64
|
+
* 2. 新指纹(或绑定的号不健康)→ 按游标挑一个号并**记下绑定**,之后就固定。
|
|
65
|
+
* 新会话用游标分发,所以不同会话天然散到不同号上(均衡)。
|
|
66
|
+
* 3. 块统计不再驱动轮转(亲和已经保证驻留,再轮转只会把会话踢出已热前缀),
|
|
67
|
+
* 只保留 `BLOCK_MAX` 次不达标 → 降权该号(缓存无效,可恢复)。
|
|
68
|
+
*
|
|
69
|
+
* 亲和失效(拿不到 messages / 指纹算不出)时**回退到块轮询**,行为与之前一致。
|
|
70
|
+
* 设计见 docs/pool-sticky-block.md §8。
|
|
62
71
|
*
|
|
63
72
|
* @param accounts 插件报告的「现在状态」(顺序即插件的自然顺序)
|
|
64
73
|
* @param poolOrder 用户在面板拖出来的顺序(核心管)
|
|
65
|
-
* @param strategy fallback 取第一个健康 / round-robin
|
|
74
|
+
* @param strategy fallback 取第一个健康 / round-robin 前缀亲和
|
|
66
75
|
* @param modelId 当前要路由的模型(决定查哪个 (model, uid) 冷却单元)
|
|
76
|
+
* @param messages 请求体的 messages(可选;给了才能算前缀指纹)
|
|
67
77
|
* @returns 选中的 uid;无健康账号返回 undefined
|
|
68
78
|
*/
|
|
69
|
-
pick(accounts: SupplierAccountNow[], poolOrder: string[], strategy: string, modelId: string): string | undefined;
|
|
79
|
+
pick(accounts: SupplierAccountNow[], poolOrder: string[], strategy: string, modelId: string, messages?: unknown): string | undefined;
|
|
80
|
+
/** 记下「前缀指纹 → 号」的绑定(LRU:超上限丢最久未用的)。 */
|
|
81
|
+
private bind;
|
|
70
82
|
/** 该号在本模型上是否被判定「缓存无效」(降权中)。 */
|
|
71
83
|
private isDemoted;
|
|
72
84
|
/**
|
|
73
|
-
*
|
|
85
|
+
* 驻留是否还应留在当前号上(**只用于无亲和的回退路径**)。
|
|
74
86
|
*
|
|
75
87
|
* OR 语义(用户拍板):`留守 = 计数 < N OR 命中率 < M`。
|
|
76
88
|
* 单一账号内部:一个号先被轮到(冷启动/热),然后
|
|
@@ -80,22 +92,20 @@ export declare class AccountPool {
|
|
|
80
92
|
*
|
|
81
93
|
* 关键:命中率是**留守**条件不是切号执行器(反接会正反馈死锁,
|
|
82
94
|
* 见文档 §3)。换号由「命中达标 OR 份额用够」触发,都不是「命中率低」。
|
|
83
|
-
*
|
|
84
|
-
* 每个号各自守着「这个会话在自己账号上的那段前缀」,轮流热;
|
|
85
|
-
* 命中率 = 块的命中占比。单会话下每个号块都从冷到热各付一次,
|
|
86
|
-
* 收益来自块内连续驻留(缺了它就跟每请求轮询一样)。
|
|
87
95
|
*/
|
|
88
96
|
private blockKeeps;
|
|
89
97
|
/**
|
|
90
|
-
*
|
|
98
|
+
* 记录一次成功请求的缓存命中情况(降权判据的反馈信号)。
|
|
91
99
|
*
|
|
92
|
-
*
|
|
100
|
+
* 只有**成功**请求才计入:失败没产生缓存,计入会污染命中率并导致错降权。
|
|
101
|
+
* 统计按 (model, uid) 记,**不管这个号是不是「当前驻留」**——亲和开启后
|
|
102
|
+
* 多个会话各绑各的号交错服务,按驻留记会漏掉大部分真实反馈。
|
|
93
103
|
* @param uid 实际服务的号
|
|
94
104
|
* @param modelId 模型(块粒度)
|
|
95
105
|
* @param cachedTokens 上游报的命中 token 数;0 = 全量重算
|
|
96
106
|
*/
|
|
97
107
|
noteCache(uid: string, modelId: string, cachedTokens: number): void;
|
|
98
|
-
/**
|
|
108
|
+
/** 故障切走时作废当前驻留:换号后前缀归属变了,驻留计数不能沿用。 */
|
|
99
109
|
dropBlock(modelId: string): void;
|
|
100
110
|
/** 块状态键:与冷却同粒度 (supplier, model),不含 uid。 */
|
|
101
111
|
private blockKey;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-router-core",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.13",
|
|
4
4
|
"description": "DSH plugin: a simplified 9router (AI routing gateway). Adds a 路由系统 (Routing System) sidebar entry beside 记忆系统; the panel shows suppliers, account pool, combos, and endpoint/API keys.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -66,9 +66,9 @@
|
|
|
66
66
|
"license": "MIT",
|
|
67
67
|
"peerDependencies": {
|
|
68
68
|
"@deepseek-ai/cordis": "^4.0.2",
|
|
69
|
-
"@deepseek-ai/dsh-client-locale": "^0.1.
|
|
70
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.
|
|
71
|
-
"@deepseek-ai/dsh-llm": "^0.1.
|
|
69
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.5-rc.1",
|
|
70
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.1",
|
|
71
|
+
"@deepseek-ai/dsh-llm": "^0.1.5-rc.1",
|
|
72
72
|
"cordis": "^4.0.0-rc.7",
|
|
73
73
|
"react": "^18.2.0",
|
|
74
74
|
"react-dom": "^18.2.0"
|
|
@@ -85,7 +85,7 @@
|
|
|
85
85
|
}
|
|
86
86
|
},
|
|
87
87
|
"devDependencies": {
|
|
88
|
-
"@deepseek-ai/dsh-llm": "0.1.
|
|
88
|
+
"@deepseek-ai/dsh-llm": "0.1.5-rc.1",
|
|
89
89
|
"@types/node": "^26.4.1",
|
|
90
90
|
"@types/react": "~19.2.18",
|
|
91
91
|
"@types/react-dom": "~19.2.7",
|
|
@@ -908,7 +908,7 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
|
|
|
908
908
|
</>
|
|
909
909
|
<>
|
|
910
910
|
<label className="dshr-fieldLabel">连接池策略</label>
|
|
911
|
-
<p className="dshr-muted">回退 = 按连接池顺序取第一个健康链接;轮询 =
|
|
911
|
+
<p className="dshr-muted">回退 = 按连接池顺序取第一个健康链接;轮询 = 按会话前缀粘住同一链接(前缀亲和,多会话不打架),拿不到会话信息时退回块轮转。</p>
|
|
912
912
|
<select
|
|
913
913
|
className="dshr-select dshr-input"
|
|
914
914
|
value={poolStrategy}
|
package/src/llm/adapter.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
type LlmModelInfo,
|
|
18
18
|
type LlmReasoningEffortInfo,
|
|
19
19
|
type LlmResolvedModelInfo,
|
|
20
|
+
type ModelModality,
|
|
20
21
|
type StreamChunk,
|
|
21
22
|
} from '@deepseek-ai/dsh-llm'
|
|
22
23
|
import { mergeUsage, normalizeUsage, toTokenUsage, type UsageTokens } from '../router/usage-tokens.ts'
|
|
@@ -35,11 +36,16 @@ export interface RouterAdapterSource {
|
|
|
35
36
|
/**
|
|
36
37
|
* 把 DSH 消息序列化成 openai 兼容 wire 消息(纯文本 + tool)。
|
|
37
38
|
*
|
|
38
|
-
* `system` 必须由调用方从 `options.system` 传进来:
|
|
39
|
-
* 放在 **options.system 这个独立槽位**,不放进 messages
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
39
|
+
* `system` 必须由调用方从 `options.system` 传进来:one-shot 调用方把系统提示词
|
|
40
|
+
* 放在 **options.system 这个独立槽位**,不放进 messages。不读这个槽位,
|
|
41
|
+
* one-shot 调用方(如 compaction / session-title)的模型每一轮都拿不到身份/
|
|
42
|
+
* 规则/工具用法约束,且**不会报错**,只是行为悄悄降级。
|
|
43
|
+
*
|
|
44
|
+
* 0.1.5 起 loop-built 请求反过来:`options.system` 为 undefined,系统提示词作为
|
|
45
|
+
* **messages 里的 system-role 消息**下发(dsh-agent-loop 的 SystemProjection,
|
|
46
|
+
* 可出现在任意位置以支持提示词热更新)。两条路径都必须支持 —— 与官方
|
|
47
|
+
* dsh-llm-deepseek 一致(serializeRequest 前置 options.system,再按位置序列化
|
|
48
|
+
* messages 里的 system 消息)。
|
|
43
49
|
*/
|
|
44
50
|
function wireMessages(options: GenerateOptions, system?: string): Array<Record<string, unknown>> {
|
|
45
51
|
const out: Array<Record<string, unknown>> = []
|
|
@@ -396,6 +402,16 @@ export class RouterAdapter extends LlmAdapter {
|
|
|
396
402
|
return out
|
|
397
403
|
}
|
|
398
404
|
|
|
405
|
+
/**
|
|
406
|
+
* 只收文本。`resolveModel` 声明 `inputModalities: ['text']` 是**必须**的:
|
|
407
|
+
* 0.1.5 的 runtime 只在 `inputModalities` 存在且不含 `image` 时才把 image 块
|
|
408
|
+
* 投影成占位文本(见 dsh-llm adapterStream 的 projectImagesForTextModel)。
|
|
409
|
+
* 不声明 = "unknown" = 不投影,而 wireMessages 只读 text 块 → 图片**静默
|
|
410
|
+
* 丢失**,模型看到一条没有图片的用户消息却毫无提示。声明后由 runtime 统一
|
|
411
|
+
* 投影,我们仍只处理文本。
|
|
412
|
+
*/
|
|
413
|
+
private static readonly INPUT_MODALITIES: readonly ModelModality[] = ['text']
|
|
414
|
+
|
|
399
415
|
async resolveModel(_provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
|
400
416
|
const combos = await this.source.comboModels()
|
|
401
417
|
const found = combos.find((m) => m.id === model || m.name === model)
|
|
@@ -407,6 +423,7 @@ export class RouterAdapter extends LlmAdapter {
|
|
|
407
423
|
provider: 'router',
|
|
408
424
|
id: model,
|
|
409
425
|
name,
|
|
426
|
+
inputModalities: RouterAdapter.INPUT_MODALITIES,
|
|
410
427
|
...(found?.contextWindow !== undefined ? { context: { contextWindow: found.contextWindow } } : {}),
|
|
411
428
|
reasoning: { efforts: ROUTER_REASONING_EFFORTS, defaultEffort: ReasoningEffortId('high') },
|
|
412
429
|
}
|
|
@@ -161,6 +161,132 @@ test('【块】多号间长期均衡:块大小有界 → 请求数均分', ()
|
|
|
161
161
|
assert.equal(count['c'], 4)
|
|
162
162
|
})
|
|
163
163
|
|
|
164
|
+
// ============ 前缀亲和(docs/pool-sticky-block.md §8 的根治) ============
|
|
165
|
+
|
|
166
|
+
/** 构造一次请求的 messages(前缀 = system + 首条用户消息)。 */
|
|
167
|
+
const msgs = (sessionSeed: string, turns = 1): unknown => [
|
|
168
|
+
{ role: 'system', content: `shared agent prompt` },
|
|
169
|
+
{ role: 'user', content: `${sessionSeed} ${'x'.repeat(40)}` },
|
|
170
|
+
...Array.from({ length: turns - 1 }, () => ({ role: 'assistant', content: 'ok' })),
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
test('【亲和】同会话粘同一号:指纹相同 → 每次都返回同一个号', () => {
|
|
174
|
+
const p = pool()
|
|
175
|
+
const list = accs(['a', 'b', 'c'])
|
|
176
|
+
const m = msgs('session-1')
|
|
177
|
+
const first = p.pick(list, [], 'round-robin', 'm1', m)
|
|
178
|
+
// 连续多轮(会话增长,但前缀不变)→ 永远是第一个号
|
|
179
|
+
for (let i = 0; i < 10; i++) {
|
|
180
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1', msgs('session-1', i + 2)), first)
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('【亲和】不同会话散到不同号:新指纹按游标分发', () => {
|
|
185
|
+
const p = pool()
|
|
186
|
+
const list = accs(['a', 'b'])
|
|
187
|
+
const s1 = p.pick(list, [], 'round-robin', 'm1', msgs('s1'))
|
|
188
|
+
const s2 = p.pick(list, [], 'round-robin', 'm1', msgs('s2'))
|
|
189
|
+
assert.notEqual(s1, s2, '两个会话应落到不同的号(缓存空间隔离)')
|
|
190
|
+
// 各自回访仍粘各自的号
|
|
191
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1', msgs('s1', 5)), s1)
|
|
192
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1', msgs('s2', 5)), s2)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test('【亲和】会话增长指纹稳定:前缀相同、轮数不同 → 同一号', () => {
|
|
196
|
+
const p = pool()
|
|
197
|
+
const list = accs(['a', 'b'])
|
|
198
|
+
const s1 = p.pick(list, [], 'round-robin', 'm1', msgs('s1'))
|
|
199
|
+
// 真实会话每轮 messages 变长,但前 3 条不变 → 指纹不变
|
|
200
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1', msgs('s1', 30)), s1)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test('【亲和】绑定的号冷却 → 会话让位,换健康号', () => {
|
|
204
|
+
const p = pool()
|
|
205
|
+
const list = accs(['a', 'b'])
|
|
206
|
+
const m = msgs('s1')
|
|
207
|
+
const first = p.pick(list, [], 'round-robin', 'm1', m)!
|
|
208
|
+
p.noteFailure(first, 'm1', 'rate_limit', '429')
|
|
209
|
+
// 绑定的号不可用 → 不能返回它(挂了),应落到别的号
|
|
210
|
+
const second = p.pick(list, [], 'round-robin', 'm1', m)
|
|
211
|
+
assert.notEqual(second, first)
|
|
212
|
+
// 且之后粘在新号上(绑定已更新)
|
|
213
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1', msgs('s1', 9)), second)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
test('【亲和】绑定的号降权(缓存无效)→ 跳过它', () => {
|
|
217
|
+
const p = pool()
|
|
218
|
+
const list = accs(['a', 'b'])
|
|
219
|
+
const m = msgs('s1')
|
|
220
|
+
const first = p.pick(list, [], 'round-robin', 'm1', m)!
|
|
221
|
+
// 该号累计 48 次全 miss → 判定缓存无效
|
|
222
|
+
serveMiss(p, first, 48)
|
|
223
|
+
const second = p.pick(list, [], 'round-robin', 'm1', m)
|
|
224
|
+
assert.notEqual(second, first, '降权中的号不应被亲和继续选中')
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
test('【亲和】messages 拿不到 → 回退块轮询(旧行为不破坏)', () => {
|
|
228
|
+
const p = pool()
|
|
229
|
+
const list = accs(['a', 'b'])
|
|
230
|
+
// 不传 messages → 走块驻留
|
|
231
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
|
|
232
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
|
|
233
|
+
serveHits(p, 'a', 16)
|
|
234
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'b')
|
|
235
|
+
// 传了但算不出指纹(空/坏结构)→ 同样回退
|
|
236
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm2', []), 'a')
|
|
237
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm2', [{ nope: 1 }]), 'a')
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test('【亲和】绑定表按模型隔离:冷却是 (model, uid) 粒度,不串模型', () => {
|
|
241
|
+
const p = pool()
|
|
242
|
+
const list = accs(['a', 'b'])
|
|
243
|
+
const m = msgs('s1')
|
|
244
|
+
const m1 = p.pick(list, [], 'round-robin', 'm1', m)
|
|
245
|
+
// rrCursor 跨模型共享(与旧块轮询一致):m1 的新会话消耗 a 后,m2 的新会话分到 b
|
|
246
|
+
const m2 = p.pick(list, [], 'round-robin', 'm2', m)
|
|
247
|
+
assert.equal(m1, 'a')
|
|
248
|
+
assert.equal(m2, 'b')
|
|
249
|
+
// 各自回访粘各自的号(绑定表按模型分开,m1 的 a 不会盖掉 m2 的 b)
|
|
250
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1', msgs('s1', 9)), 'a')
|
|
251
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm2', msgs('s1', 9)), 'b')
|
|
252
|
+
p.noteFailure('a', 'm1', 'rate_limit', '429') // 只冷 (m1, a)
|
|
253
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1', m), 'b', 'm1 上让位')
|
|
254
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm2', m), 'b', 'm2 不受影响,仍粘 b')
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
test('【亲和】绑定表 LRU 封顶:超上限不泄漏,旧指纹被挤掉', () => {
|
|
258
|
+
const p = pool()
|
|
259
|
+
const list = accs(['a', 'b', 'c'])
|
|
260
|
+
const first = p.pick(list, [], 'round-robin', 'm1', msgs('s0'))!
|
|
261
|
+
// 灌 600 个新会话(> AFFINITY_MAX=512)→ 最老的 s0 被挤出
|
|
262
|
+
for (let i = 0; i < 600; i++) p.pick(list, [], 'round-robin', 'm1', msgs(`flood-${i}`))
|
|
263
|
+
const again = p.pick(list, [], 'round-robin', 'm1', msgs('s0'))
|
|
264
|
+
// 被挤掉后重新分发——只要不是内存无限增长即可(uid 合法性由 pool 保证)
|
|
265
|
+
assert.equal(list.some((a) => a.uid === again), true)
|
|
266
|
+
// 活跃会话(每次都访问)不被挤掉
|
|
267
|
+
const hot = p.pick(list, [], 'round-robin', 'm1', msgs('hot'))!
|
|
268
|
+
for (let i = 0; i < 600; i++) {
|
|
269
|
+
p.pick(list, [], 'round-robin', 'm1', msgs(`flood2-${i}`))
|
|
270
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1', msgs('hot')), hot, '活跃会话的绑定应被 LRU 刷新保留')
|
|
271
|
+
}
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
test('【亲和】多模态分片 content 也能算指纹(文本片段参与,base64 不炸)', () => {
|
|
275
|
+
const p = pool()
|
|
276
|
+
const list = accs(['a', 'b'])
|
|
277
|
+
const multi = [
|
|
278
|
+
{ role: 'user', content: [{ type: 'text', text: 'look at this' }, { type: 'image_url', image_url: 'data:image/png;base64,AAAA' }] },
|
|
279
|
+
]
|
|
280
|
+
const first = p.pick(list, [], 'round-robin', 'm1', multi)
|
|
281
|
+
assert.equal(p.pick(list, [], 'round-robin', 'm1', multi), first)
|
|
282
|
+
// 文本不同的另一个会话 → 不同指纹
|
|
283
|
+
const other = [
|
|
284
|
+
{ role: 'user', content: [{ type: 'text', text: 'different' }] },
|
|
285
|
+
]
|
|
286
|
+
assert.notEqual(p.pick(list, [], 'round-robin', 'm1', other), first, '文本不同应散开')
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
|
|
164
290
|
// ============ 核心新语义:冷却按 (模型, 连接) 分号 ============
|
|
165
291
|
|
|
166
292
|
test('【颗粒度】连接在模型 A 失败,只冷 (A, 该连接),不影响它调模型 B', () => {
|
|
@@ -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 {
|
|
@@ -95,6 +96,15 @@ const BLOCK_MAX = 48
|
|
|
95
96
|
/** 块轮询:判定「缓存无效」后的跳过时长。 */
|
|
96
97
|
const BLOCK_DEMOTE_MS = 10 * MINUTE
|
|
97
98
|
|
|
99
|
+
/**
|
|
100
|
+
* 前缀亲和:指纹 → 号 的绑定记忆上限。
|
|
101
|
+
*
|
|
102
|
+
* 会话指纹是无限集合(每个新会话一个),不封顶就是内存泄漏。超过了就丢掉
|
|
103
|
+
* **最久没用**的那条:活跃会话的指纹每请求都在刷新,不会被误删。
|
|
104
|
+
* 单号池(<= 几十个会话)绰绰有余。
|
|
105
|
+
*/
|
|
106
|
+
const AFFINITY_MAX = 512
|
|
107
|
+
|
|
98
108
|
/** 冷却记录(键 = (supplier, model, uid))。 */
|
|
99
109
|
interface CooldownEntry {
|
|
100
110
|
until: number
|
|
@@ -124,6 +134,20 @@ interface BlockState {
|
|
|
124
134
|
hits: number
|
|
125
135
|
}
|
|
126
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
|
+
|
|
127
151
|
/** 「缓存无效」降权记录(键 = (supplier, model, uid))。 */
|
|
128
152
|
interface DemoteEntry {
|
|
129
153
|
until: number
|
|
@@ -145,10 +169,14 @@ export class AccountPool {
|
|
|
145
169
|
/** uid → 连接级禁用 / 手动暂停。 */
|
|
146
170
|
private byUid = new Map<string, UidEntry>()
|
|
147
171
|
private rrCursor = 0
|
|
148
|
-
/** (supplier, model) →
|
|
149
|
-
private blocks = new Map<string,
|
|
172
|
+
/** (supplier, model) → 当前驻留的号(只有**无亲和**的回退路径用它)。 */
|
|
173
|
+
private blocks = new Map<string, string>()
|
|
174
|
+
/** (supplier, model, uid) → 该号的缓存质量样本(降权判据,跨驻留累计)。 */
|
|
175
|
+
private samples = new Map<string, Sample>()
|
|
150
176
|
/** (supplier, model, uid) → 缓存无效降权。 */
|
|
151
177
|
private demoted = new Map<string, DemoteEntry>()
|
|
178
|
+
/** (supplier, model) → Map<前缀指纹, uid>:前缀亲和的选号记忆(LRU 封顶)。 */
|
|
179
|
+
private affinity = new Map<string, Map<string, string>>()
|
|
152
180
|
|
|
153
181
|
constructor(supplierId = '') {
|
|
154
182
|
this.supplierId = supplierId
|
|
@@ -203,22 +231,28 @@ export class AccountPool {
|
|
|
203
231
|
/**
|
|
204
232
|
* 按策略为「某个模型」选一个健康账号。
|
|
205
233
|
*
|
|
206
|
-
* round-robin
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
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。
|
|
214
247
|
*
|
|
215
248
|
* @param accounts 插件报告的「现在状态」(顺序即插件的自然顺序)
|
|
216
249
|
* @param poolOrder 用户在面板拖出来的顺序(核心管)
|
|
217
|
-
* @param strategy fallback 取第一个健康 / round-robin
|
|
250
|
+
* @param strategy fallback 取第一个健康 / round-robin 前缀亲和
|
|
218
251
|
* @param modelId 当前要路由的模型(决定查哪个 (model, uid) 冷却单元)
|
|
252
|
+
* @param messages 请求体的 messages(可选;给了才能算前缀指纹)
|
|
219
253
|
* @returns 选中的 uid;无健康账号返回 undefined
|
|
220
254
|
*/
|
|
221
|
-
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 {
|
|
222
256
|
const now = Date.now()
|
|
223
257
|
const byUid = new Map(accounts.map((a) => [a.uid, a]))
|
|
224
258
|
// 池顺序优先,未配置的按插件自然顺序追加
|
|
@@ -229,21 +263,50 @@ export class AccountPool {
|
|
|
229
263
|
const healthy = ordered.filter((uid) => this.healthy(uid, modelId, now))
|
|
230
264
|
if (healthy.length === 0) return undefined
|
|
231
265
|
if (strategy !== 'round-robin') return healthy[0]
|
|
232
|
-
//
|
|
266
|
+
// 缓存无效降权:该号缓存质量样本长期不达标 → 跳过它(可到期恢复)
|
|
233
267
|
const usable = healthy.filter((uid) => !this.isDemoted(uid, modelId, now))
|
|
234
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
|
+
}
|
|
235
274
|
const block = this.blocks.get(this.blockKey(modelId))
|
|
236
|
-
if (block !== undefined && pool.includes(block
|
|
237
|
-
return block
|
|
275
|
+
if (fp === '' && block !== undefined && pool.includes(block) && this.blockKeeps(modelId, block)) {
|
|
276
|
+
return block
|
|
238
277
|
}
|
|
239
|
-
//
|
|
240
|
-
const from = block === undefined ? this.rrCursor : Math.max(0, pool.indexOf(block
|
|
278
|
+
// 无亲和(或块满/块达标)→ 前进游标。从当前号之后开始找,保证轮转顺序稳定。
|
|
279
|
+
const from = block === undefined ? this.rrCursor : Math.max(0, pool.indexOf(block) + 1)
|
|
241
280
|
const uid = pool[from % pool.length]!
|
|
242
281
|
this.rrCursor = (pool.indexOf(uid) + 1) % pool.length
|
|
243
|
-
this.blocks.set(this.blockKey(modelId),
|
|
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
|
+
}
|
|
244
290
|
return uid
|
|
245
291
|
}
|
|
246
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
|
+
|
|
247
310
|
/** 该号在本模型上是否被判定「缓存无效」(降权中)。 */
|
|
248
311
|
private isDemoted(uid: string, modelId: string, now: number): boolean {
|
|
249
312
|
const e = this.demoted.get(this.key(modelId, uid))
|
|
@@ -251,7 +314,7 @@ export class AccountPool {
|
|
|
251
314
|
}
|
|
252
315
|
|
|
253
316
|
/**
|
|
254
|
-
*
|
|
317
|
+
* 驻留是否还应留在当前号上(**只用于无亲和的回退路径**)。
|
|
255
318
|
*
|
|
256
319
|
* OR 语义(用户拍板):`留守 = 计数 < N OR 命中率 < M`。
|
|
257
320
|
* 单一账号内部:一个号先被轮到(冷启动/热),然后
|
|
@@ -261,39 +324,40 @@ export class AccountPool {
|
|
|
261
324
|
*
|
|
262
325
|
* 关键:命中率是**留守**条件不是切号执行器(反接会正反馈死锁,
|
|
263
326
|
* 见文档 §3)。换号由「命中达标 OR 份额用够」触发,都不是「命中率低」。
|
|
264
|
-
*
|
|
265
|
-
* 每个号各自守着「这个会话在自己账号上的那段前缀」,轮流热;
|
|
266
|
-
* 命中率 = 块的命中占比。单会话下每个号块都从冷到热各付一次,
|
|
267
|
-
* 收益来自块内连续驻留(缺了它就跟每请求轮询一样)。
|
|
268
327
|
*/
|
|
269
|
-
private blockKeeps(
|
|
270
|
-
|
|
271
|
-
const
|
|
272
|
-
|
|
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
|
|
273
334
|
}
|
|
274
335
|
|
|
275
336
|
/**
|
|
276
|
-
*
|
|
337
|
+
* 记录一次成功请求的缓存命中情况(降权判据的反馈信号)。
|
|
277
338
|
*
|
|
278
|
-
*
|
|
339
|
+
* 只有**成功**请求才计入:失败没产生缓存,计入会污染命中率并导致错降权。
|
|
340
|
+
* 统计按 (model, uid) 记,**不管这个号是不是「当前驻留」**——亲和开启后
|
|
341
|
+
* 多个会话各绑各的号交错服务,按驻留记会漏掉大部分真实反馈。
|
|
279
342
|
* @param uid 实际服务的号
|
|
280
343
|
* @param modelId 模型(块粒度)
|
|
281
344
|
* @param cachedTokens 上游报的命中 token 数;0 = 全量重算
|
|
282
345
|
*/
|
|
283
346
|
noteCache(uid: string, modelId: string, cachedTokens: number): void {
|
|
284
|
-
const k = this.
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
if (
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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)
|
|
293
357
|
}
|
|
294
358
|
}
|
|
295
359
|
|
|
296
|
-
/**
|
|
360
|
+
/** 故障切走时作废当前驻留:换号后前缀归属变了,驻留计数不能沿用。 */
|
|
297
361
|
dropBlock(modelId: string): void {
|
|
298
362
|
this.blocks.delete(this.blockKey(modelId))
|
|
299
363
|
}
|
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
|
|
@@ -1006,9 +1018,12 @@ export class Router {
|
|
|
1006
1018
|
}
|
|
1007
1019
|
// 试过的号不再选:某些失败状态既不冷却也不计数(如模型不属于本供应商),
|
|
1008
1020
|
// 不排除试过的就会原地打转——死循环等于整个服务挂住。
|
|
1021
|
+
// messages 只取一次:前缀亲和用它算指纹(重试换号时指纹必须一致,
|
|
1022
|
+
// 否则同一会话的两次尝试会绑到不同的号上)。
|
|
1023
|
+
const messages = reqMessages(req.rawBody)
|
|
1009
1024
|
const tried = new Set<string>()
|
|
1010
1025
|
for (;;) {
|
|
1011
|
-
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)
|
|
1012
1027
|
if (uid === undefined) return false
|
|
1013
1028
|
tried.add(uid)
|
|
1014
1029
|
const r = await s.chatOnce(uid, req.lv ?? 'auto', req)
|
|
@@ -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
|
+
}
|