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
package/README.md
CHANGED
|
@@ -5,14 +5,15 @@
|
|
|
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">
|
|
12
12
|
<a href="#快速安装">快速安装</a> ·
|
|
13
13
|
<a href="#面板设置--路由">面板</a> ·
|
|
14
14
|
<a href="#api-端点openai-兼容3080v1">API 端点</a> ·
|
|
15
|
-
<a href="docs/suppliers.md">供应商开发</a>
|
|
15
|
+
<a href="docs/suppliers.md">供应商开发</a> ·
|
|
16
|
+
<a href="docs/ext.md">扩展开发</a>
|
|
16
17
|
</p>
|
|
17
18
|
|
|
18
19
|
**插件版的 9router** —— 不是另开一个网关服务,而是直接作为 DSH 插件嵌进 DSH web,
|
|
@@ -24,7 +25,7 @@
|
|
|
24
25
|
|
|
25
26
|
## 快速安装
|
|
26
27
|
|
|
27
|
-
|
|
28
|
+
需要 **DSH `0.1.5-rc.1` 及以上**(支持 `dsh plugin` profile 插件机制)、Node.js `>= 20`,以及 `web` profile。
|
|
28
29
|
|
|
29
30
|
```bash
|
|
30
31
|
dsh plugin --profile web add dsh-router-core
|
|
@@ -44,6 +45,7 @@ dsh plugin --profile web add dsh-router-core
|
|
|
44
45
|
| 能力 | 说明 |
|
|
45
46
|
|---|---|
|
|
46
47
|
| 零额外进程 | 就是 DSH 插件,随 `dsh web` 启停,天然同源(`/router/api/*` 无 CORS、面板嵌在设置里)。 |
|
|
48
|
+
| 扩展即插即拔 | 扩展插件(如 [`dsh-router-ext-rtk`](https://github.com/CARVIN94/dsh-router-ext-rtk))经 `router.ext` 注册,在 bash 执行前改写命令(如加 `rtk` 前缀压缩输出)。面板「扩展」页一键开关,带自检。 |
|
|
47
49
|
| 供应商即插即拔 | 内置供应商随插件分发;更多供应商 = 装一个 DSH 插件(`dsh-router-*`)或放一个 js 文件到 `~/.dsh/profiles/web/suppliers/`。 |
|
|
48
50
|
| 模型不内置 | 供应商只实现差异化能力,模型拉取与缓存由核心统一管,不写死、不过时。 |
|
|
49
51
|
| 策略只写一次 | 组合回退、账号池(选号/冷却/禁用)、响应写入、凭证存储、积分持久化、模型管理都由核心提供。供应商 js 只对**单个账号**调一次上游并报告成败,不自己遍历账号、不维护冷却表、不落盘积分——否则每个插件都会长出一份互相不一致的实现,而核心也就无从判断「该不该换号」。 |
|
|
@@ -139,6 +141,7 @@ curl -X POST http://localhost:3080/v1/chat/completions \
|
|
|
139
141
|
| `/keys/toggle` | POST | `{id, isActive}` |
|
|
140
142
|
| `/keys/delete` | POST | `{id}` |
|
|
141
143
|
| `/settings` | GET/PATCH | `{requireApiKey}` |
|
|
144
|
+
| `/ext` | GET/PATCH | 扩展插件列表 + 开关 `{id, enabled}`(见下) |
|
|
142
145
|
| `/stats` | GET | 用量统计 `?period=today\|24h\|7d\|30d`(汇总 + Top 榜 + 最近请求 20 条) |
|
|
143
146
|
| `/stats/chart` | GET | 趋势图数据 `?period=…`(today/24h = 24 小时桶,7d/30d = 天桶) |
|
|
144
147
|
| `/stats/clear` | POST | 清空全部用量统计 |
|
|
@@ -147,6 +150,32 @@ curl -X POST http://localhost:3080/v1/chat/completions \
|
|
|
147
150
|
| `/suppliers/:id/models` | GET | 模型 + 启用状态 |
|
|
148
151
|
| `/suppliers/:id/models/toggle` | POST | `{id, enabled}` |
|
|
149
152
|
|
|
153
|
+
## 扩展插件(`router.ext`)
|
|
154
|
+
|
|
155
|
+
> 完整契约、注册方式、自检与降级约定见 [`docs/ext.md`](docs/ext.md)。
|
|
156
|
+
|
|
157
|
+
面板「扩展」页列出所有扩展插件,每个一个开关。扩展插件是**独立 npm 包**
|
|
158
|
+
(如 [`dsh-router-ext-rtk`](https://github.com/CARVIN94/dsh-router-ext-rtk)),
|
|
159
|
+
经 cordis service `router.ext` 注册自己 —— 同 `router.suppliers` 的共享表模式,
|
|
160
|
+
与加载顺序无关。
|
|
161
|
+
|
|
162
|
+
分工:
|
|
163
|
+
|
|
164
|
+
- **dsh-router 核心**:持有 `router.ext` 空表;在 `tools/execute` 拦截 bash 工具
|
|
165
|
+
调用,把命令委派给表里 **enabled 且 ready** 的扩展器改写;命中则短路。只拦
|
|
166
|
+
`bash`,其他工具(含 `run_code` 体内自起的子进程)不动。
|
|
167
|
+
- **扩展插件**:实现 `rewrite(command)`(同步、不能做 IO)+ 自管开关状态
|
|
168
|
+
(何时 enabled、是否 ready、怎么持久化)。核心不感知具体扩展器实现。
|
|
169
|
+
|
|
170
|
+
开关打开时会**自检**:扩展器 `getState().ready === false` 的(如没装 rtk)拒绝开启
|
|
171
|
+
(API 返回 409 + 问题描述),面板内容区红字显示原因。
|
|
172
|
+
|
|
173
|
+
### 已知坑
|
|
174
|
+
|
|
175
|
+
- `ctx.tools.get(name)` **必须带 agent scope**(`exec.agent`):bash 工具注册在
|
|
176
|
+
agent scope,不带 scope 只查全局视图会查不到,静默走原样执行、从不改写。
|
|
177
|
+
- 改写在一次已被审批授权的工具调用内发生,不绕过 sandbox / 审批。
|
|
178
|
+
|
|
150
179
|
## 架构
|
|
151
180
|
|
|
152
181
|
```
|
package/docs/ext.md
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# 扩展插件开发
|
|
2
|
+
|
|
3
|
+
扩展插件是**独立的 DSH 插件(npm 包)**,只提供**差异化能力** —— 目前是改写一条
|
|
4
|
+
bash 命令。装好后出现在「设置 → 路由 → 扩展」,每个一个开关。
|
|
5
|
+
|
|
6
|
+
**dsh-router 核心只做管理面,不做拦截。** 扩展插件自己挂监听、自己裁决、自己短路
|
|
7
|
+
(2026-09 重构,见下)。
|
|
8
|
+
|
|
9
|
+
数据目录是 `<profile>/data/`,**不跟进程 cwd 跑**——从任何目录启动 `dsh web`
|
|
10
|
+
读到的都是同一份配置(见 `src/data-dir.ts`)。
|
|
11
|
+
|
|
12
|
+
参考实现:[dsh-router-ext-rtk](https://github.com/CARVIN94/dsh-router-ext-rtk)
|
|
13
|
+
(把命令改写成 `rtk <cmd>` 压缩输出)。
|
|
14
|
+
|
|
15
|
+
## 分工:管理归核心,执行归插件
|
|
16
|
+
|
|
17
|
+
| | 谁负责 |
|
|
18
|
+
|---|---|
|
|
19
|
+
| 持有 `router.ext` 注册表(发现) | 核心 |
|
|
20
|
+
| 面板渲染、开关交互、`/router/api/ext` | 核心 |
|
|
21
|
+
| **开关状态 + 插件数据落盘**(`router.extStore` → `<dataDir>/ext.json`) | **核心** |
|
|
22
|
+
| 开启自检(不可用时拒绝开启,面板开关禁用) | 核心 |
|
|
23
|
+
| **挂 `tools/execute` 拦截** | **插件** |
|
|
24
|
+
| **按 enabled / ready 裁决、命中则短路执行** | **插件** |
|
|
25
|
+
| **怎么改写一条命令** | **插件**(私有,不在契约里) |
|
|
26
|
+
| **自己是否可用**(`getState().ready`) | **插件** |
|
|
27
|
+
|
|
28
|
+
**插件不自己 file IO。** 落盘位置由核心用 `dataDirOf(ctx.baseUrl)` 锚定,插件经
|
|
29
|
+
`router.extStore` service 读写(`isEnabled` / `setEnabled` / `readData` / `writeData`),
|
|
30
|
+
同一文件、同一次原子写。插件无状态。
|
|
31
|
+
|
|
32
|
+
### 为什么拦截从核心挪到插件
|
|
33
|
+
|
|
34
|
+
`tools/execute` 是任何插件都能自己挂的 around-dispatch waterfall(插件 ctx 不在 agent
|
|
35
|
+
scope 下就能收到全部派发)。核心代挂在**只有一个消费者**时是纯亏损:为留 40 行委派
|
|
36
|
+
逻辑,付了共享表 + inject 广播 + 契约两处同步的代价。核心归位成管理面后,扩展插件与
|
|
37
|
+
dsh-router 只在**注册表 + 存储**两处耦合。
|
|
38
|
+
|
|
39
|
+
> ponytail: 天花板 —— 核心代挂原本顺带解决「多个扩展插件各自挂监听会互相踩 / 顺序
|
|
40
|
+
> 不可控」。这个保护现在没了。目前只有 rtk 一家消费,无所谓;**第二家 ext 出现时要
|
|
41
|
+
> 重新收敛顺序**(升级路径:核心暴露一个按顺序委派的共享工具方法,而不是收回拦截)。
|
|
42
|
+
|
|
43
|
+
## 契约
|
|
44
|
+
|
|
45
|
+
注册到 `router.ext` 表的对象是**纯声明 + 状态**:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
interface RouterExt {
|
|
49
|
+
readonly id: string // 唯一 id(注册键,如 'rtk')
|
|
50
|
+
readonly name: string // 面板显示名(如 'RTK')
|
|
51
|
+
readonly description?: string // 面板内容区说明
|
|
52
|
+
getState(): ExtState // 运行时事实
|
|
53
|
+
dispose?(): void
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 只报「运行时事实」,不报开关
|
|
57
|
+
interface ExtState {
|
|
58
|
+
ready: boolean // 运行时是否就绪;false 时即使开启也不生效
|
|
59
|
+
detail?: string // 不就绪时的说明,面板红字显示
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**没有 `rewrite`。** 怎么改命令是插件的实现细节,核心不感知、不调用。
|
|
64
|
+
|
|
65
|
+
插件经 `router.extStore` 读写(核心 provide):
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
interface ExtStoreService {
|
|
69
|
+
isEnabled(id: string): boolean
|
|
70
|
+
setEnabled(id: string, enabled: boolean): void
|
|
71
|
+
readData<T = unknown>(id: string): T | undefined // 插件自己的数据抽屉
|
|
72
|
+
writeData(id: string, value: unknown): void
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
落盘形状(`<dataDir>/ext.json`):
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
{ "rtk": { "enabled": true, "data": { "...插件自己的东西..." } } }
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**插件没有 `setEnabled` 的职责,也不自己存开关。** 开关由核心持久化(默认关),
|
|
83
|
+
插件是被调用方:自己问 `isEnabled`、自己按 ready 裁决。
|
|
84
|
+
|
|
85
|
+
### `getState()`:只报运行时事实
|
|
86
|
+
|
|
87
|
+
- `ready: true` 才能生效;`false` 表示运行时不可用(如没装 rtk)。核心据此:
|
|
88
|
+
- 面板开关**禁用**,点不开
|
|
89
|
+
- 即使绕过面板直连 API 开启,核心也**拒 409** 并带上 `detail`
|
|
90
|
+
- 面板内容区用红字显示 `detail`
|
|
91
|
+
- **不报 `enabled`** —— 开关在核心
|
|
92
|
+
- 建议在插件**启动时**就探测一次(而不是等第一次拦截),让面板首屏就有状态
|
|
93
|
+
|
|
94
|
+
> 旧版本插件曾自己把开关写在 `enhance.json`。现在核心读 `ext.json`,
|
|
95
|
+
> 历史上开着的会在首次启动时自动迁移过去(旧文件保留不删)。
|
|
96
|
+
|
|
97
|
+
## 注册:共享聚合表
|
|
98
|
+
|
|
99
|
+
cordis 每个 service name 只允许一个插件 `provide`,所以扩展插件**不要自己
|
|
100
|
+
`provide('router.ext')`** —— 核心已持有空表,插件 `inject` 等它出现后追加进同一个
|
|
101
|
+
live 对象,再广播一次 `internal/service` 触发核心重扫。**与加载顺序无关**。
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
105
|
+
import { currentExts, currentExtStore } from './contract.ts'
|
|
106
|
+
|
|
107
|
+
export const name = 'my-ext'
|
|
108
|
+
|
|
109
|
+
export function apply(ctx: Context): void {
|
|
110
|
+
ctx.inject(['router.ext'], (sctx) => {
|
|
111
|
+
const exts = currentExts(sctx)
|
|
112
|
+
if (!exts) return undefined
|
|
113
|
+
if (exts['my-ext']) return undefined // 已注册
|
|
114
|
+
|
|
115
|
+
const ext = createMyExt()
|
|
116
|
+
exts['my-ext'] = ext
|
|
117
|
+
ctx.emit('internal/service', 'router.ext', exts)
|
|
118
|
+
|
|
119
|
+
// 执行面:自己挂拦截。开关问核心存储,ready 问自己。
|
|
120
|
+
const store = currentExtStore(sctx)
|
|
121
|
+
const unmount = mountMyIntercept(ctx, () => store?.isEnabled('my-ext') === true, () => ext.getState().ready)
|
|
122
|
+
|
|
123
|
+
return () => { // 卸载清理
|
|
124
|
+
unmount()
|
|
125
|
+
ext.dispose?.()
|
|
126
|
+
delete exts['my-ext']
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`currentExts` / `currentExtStore` 帮你从 context 取服务(`ctx.get(...)` 或 `ctx.router.*`)。
|
|
133
|
+
契约在扩展包里**自含一份副本**(不能 import dsh-router 的 src,否则安装期要拉整个
|
|
134
|
+
路由核心)——改契约必须**两处同步**,鸭子类型,tsc 抓不到跨仓漂移。
|
|
135
|
+
|
|
136
|
+
## 插件包结构
|
|
137
|
+
|
|
138
|
+
最小结构(照 `dsh-router-ext-rtk`):
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
src/
|
|
142
|
+
index.ts 插件入口,经 router.ext 注册 + 自挂拦截
|
|
143
|
+
intercept.ts 拦截实现(挂 tools/execute、裁决、短路)—— 无状态,不存开关
|
|
144
|
+
rtk.ts 扩展器实现(改写 + 探活)—— 无状态
|
|
145
|
+
contract.ts router.ext 契约副本(与核心同步)
|
|
146
|
+
*.test.ts 测试
|
|
147
|
+
package.json 需声明 dsh.bundle.patch,否则不会被加入 profile bundles
|
|
148
|
+
cordis.patch.yml
|
|
149
|
+
tsdown.config.ts
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
> 插件**不需要数据目录**:开关与数据在核心的 `<dataDir>/ext.json`,
|
|
153
|
+
> 经 `router.extStore` 读写。别自己开文件。
|
|
154
|
+
|
|
155
|
+
`package.json` 关键项:
|
|
156
|
+
|
|
157
|
+
```json
|
|
158
|
+
{
|
|
159
|
+
"main": "lib/index.js",
|
|
160
|
+
"dsh": { "bundle": { "patch": "./cordis.patch.yml" } },
|
|
161
|
+
"peerDependencies": { "@deepseek-ai/cordis": "^4.0.1" }
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## 自挂拦截:两个坑(原在核心,现归插件,必须守住)
|
|
166
|
+
|
|
167
|
+
### `ctx.tools.get(name)` 必须带 agent scope
|
|
168
|
+
|
|
169
|
+
bash 工具注册在 **agent scope** 里,查工具必须传 `exec.agent`:
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
tools.get('bash', exec.agent) // ✅
|
|
173
|
+
tools.get('bash') // ❌ 只查全局视图,查不到 → 静默走原样,从不改写
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
症状极具迷惑性:wrapper 被触发、command 是正常字符串、开关 enabled + ready 齐全,
|
|
177
|
+
但从不改写。这条知识原先在核心一处,现在每个自挂拦截的插件各守一份 —— 抄漏了就是
|
|
178
|
+
「开关全开但从不生效」(已在 `dsh-router-ext-rtk/src/intercept.ts` 文件头 + 测试锁定)。
|
|
179
|
+
|
|
180
|
+
### 改写发生在一次已授权的调用内
|
|
181
|
+
|
|
182
|
+
核心改为插件自挂后语义不变:`tools/execute` 拦截时 sandbox 审批 / guard 等前置已在
|
|
183
|
+
该调用的 prepare 阶段完成。改写只是换命令字符串,**不绕过任何审批**。
|
|
184
|
+
|
|
185
|
+
## 自检与降级:失败一律放行
|
|
186
|
+
|
|
187
|
+
扩展器是**增强**,不是门禁。任何一步出问题都必须退回原样执行:
|
|
188
|
+
|
|
189
|
+
- 命令不是非空字符串 → 放行
|
|
190
|
+
- 开关未开 / 插件未就绪 → 放行
|
|
191
|
+
- 不命中改写 → 放行
|
|
192
|
+
- 拿不到 bash 工具 → 放行
|
|
193
|
+
- 执行抛错 → 转成 error envelope,不向外抛
|
|
194
|
+
|
|
195
|
+
命令**永不因扩展而失败**。
|
|
196
|
+
|
|
197
|
+
## 验证
|
|
198
|
+
|
|
199
|
+
判定代理是否真的生效(以 RTK 为例):
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
# 1) 看 API:扩展器 enabled / ready 是否都为 true
|
|
203
|
+
curl -s http://127.0.0.1:3080/router/api/ext
|
|
204
|
+
|
|
205
|
+
# 2) 跑一条有等价改写的命令,看输出是否变成压缩格式
|
|
206
|
+
ls # 被改写时是 rtk 树形格式,不是原生列表
|
|
207
|
+
|
|
208
|
+
# 3) rtk 自己的统计(rtk rewrite 查询本身不计入)
|
|
209
|
+
rtk gain
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
`tools/execute` 是 scope-filtered 事件,但只要插件 context 不在某个 agent scope 下
|
|
213
|
+
(`scopeOf(ctx) === undefined`),就能收到所有 agent 的派发——这是 hook 插件的常规用法。
|
|
214
|
+
|
|
215
|
+
## 加载顺序
|
|
216
|
+
|
|
217
|
+
核心 `apply` 里同步 `ctx.provide('router.ext', {})` 与 `ctx.provide('router.extStore', ...)`,
|
|
218
|
+
扩展插件 `inject` 等它。所以:
|
|
219
|
+
|
|
220
|
+
- 先装核心、后装插件 → `provide` 时插件的 `inject` 回调被触发
|
|
221
|
+
- 先装插件、后装核心 → 插件 `inject` 挂起,核心 `provide` 后触发
|
|
222
|
+
|
|
223
|
+
两条路径都能注册成功,顺序无关。拦截在 `inject` 回调内挂起(注册 → 拦截),所以存储
|
|
224
|
+
就绪前不会有半开的拦截窗口。卸载时插件的清理函数从表里删掉自己的键并注销监听。
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# 块轮询(sticky block round-robin)
|
|
2
|
+
|
|
3
|
+
> 状态:设计定稿 + 已实现。目标:保留 round-robin 的**多账号均衡**,
|
|
4
|
+
> 只把轮转的**触发方式**从「每请求」改成「满意才走」,从而大幅降低
|
|
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)。
|
|
11
|
+
|
|
12
|
+
## 1. 问题:每请求轮转把缓存打碎
|
|
13
|
+
|
|
14
|
+
上游 prompt cache 按 **API key(账号)** 分命名空间。会话前缀是同一段,
|
|
15
|
+
被轮转切到 N 个号上,就等于在 N 个互不相干的缓存命名空间里各写一份。
|
|
16
|
+
|
|
17
|
+
实测(2026-09-09,`usage.json`,codebuddy,`round-robin`,8 个健康号):
|
|
18
|
+
|
|
19
|
+
| 指标 | 值 |
|
|
20
|
+
|---|---|
|
|
21
|
+
| 会话 token 命中率 | **71%~75%** |
|
|
22
|
+
| `cached` 分布 | **双峰**:非 0 即 ~98%;miss 即 `cached = 0` |
|
|
23
|
+
| miss 时 `prompt` | **全量**(约 190k),不是部分失效 |
|
|
24
|
+
| 同会话相邻请求间隔 T | **中位 8s**(n=315) |
|
|
25
|
+
|
|
26
|
+
双峰说明一件事:不是「前缀部分匹配」,而是**这个号手里有没有本会话的缓存**
|
|
27
|
+
——全有或全无。所以 miss 的代价是**整段前缀重算**。
|
|
28
|
+
|
|
29
|
+
### 为什么当前 N=1 是最坏档
|
|
30
|
+
|
|
31
|
+
8 个号每请求轮转 → 同一号的复用间隔 = `K × T = 8 × 8s = 64s`。
|
|
32
|
+
这 64s 是否还在上游缓存 TTL 内,**完全未知**。实测命中 79%(请求数)/ 71%(token)
|
|
33
|
+
说明大致踩在 TTL 边缘。
|
|
34
|
+
|
|
35
|
+
即:现状把命中率**100% 押在了一个未知的外部参数上**。
|
|
36
|
+
|
|
37
|
+
## 2. 方案:轮转单位 = 块,不是请求
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
pick():
|
|
41
|
+
留守 = 健康 AND 块内计数 < N_max AND (块内计数 < N OR 块内命中率 < M)
|
|
42
|
+
留守 → 返回当前号
|
|
43
|
+
否则 → 游标前进,切下一个号
|
|
44
|
+
故障(rate_limit / quota / session_dead) → 立即切,不等块满(已有)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**均衡没变**:块大小有界([N, N_max]) → 长期看各号请求数仍是 1:1。
|
|
48
|
+
变的只是轮转的时间尺度:从 8s 拉到 N×64s。
|
|
49
|
+
|
|
50
|
+
### 保证下限,不依赖 TTL
|
|
51
|
+
|
|
52
|
+
块内第 2..N 次请求距上次仅 **8s**,8 秒内的缓存**不可能**被驱逐。
|
|
53
|
+
所以块内命中率有个**与 TTL 无关**的下限:
|
|
54
|
+
|
|
55
|
+
| 块内请求数 k | 块内命中下限 |
|
|
56
|
+
|---|---|
|
|
57
|
+
| 1(现状) | **0%**(全押 TTL) |
|
|
58
|
+
| 4 | 75% |
|
|
59
|
+
| 8 | 87.5% |
|
|
60
|
+
| 16 | 93.75% |
|
|
61
|
+
|
|
62
|
+
> 这是**下限**。TTL 宽裕时实际更高。
|
|
63
|
+
|
|
64
|
+
## 2.1 实测结论(2026-09-09,块轮询首日):多会话打架才是主因
|
|
65
|
+
|
|
66
|
+
上线后两组数据对照:
|
|
67
|
+
|
|
68
|
+
| 时段 | 场景 | 命中 |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| 11:59–12:03 | 3 个会话并行走 `money`(cb-2/cb-5/cb-3 交替) | **3.8%** |
|
|
71
|
+
| 12:03 之后 | 只剩 1 会话独占(cb-3 单号) | **82.5%** |
|
|
72
|
+
|
|
73
|
+
**账号没坏,是并发会话把缓存挤爆了。** prompt cache 是
|
|
74
|
+
**per-account × per-conversation** 命名空间;多会话各自 18 万的大前缀,
|
|
75
|
+
被块轮询聚到同一号连续服务,在同一账号缓存里互相驱逐 → 轮流全 miss。
|
|
76
|
+
|
|
77
|
+
这解释了:块轮询在**单会话**下能到 82%(比旧 71% 好),**多会话并发下反而更差**
|
|
78
|
+
(3.8%,因为把打架的请求聚到单号密集互踩)。
|
|
79
|
+
|
|
80
|
+
**根因不是轮询公式,是缺少会话隔离。** 真正的破法是 **per-session 亲和**
|
|
81
|
+
(见 §8 天花板),把每个会话绑定到不同账号,让前缀各自有独立缓存空间。
|
|
82
|
+
块轮询只能保证单会话内的块,保证不了跨会话的前缀不被别的会话清掉。
|
|
83
|
+
|
|
84
|
+
## 2.2 参数语义(用户拍板:单账号内 OR)
|
|
85
|
+
|
|
86
|
+
真实场景是**单对话长跑**(一个会话几千步),不是多会话并发。痛点是:
|
|
87
|
+
同一个会话在 8 个号之间跳,每跳一次前缀全重算。亲和解多会话打架,
|
|
88
|
+
**解不了单会话换号**。回到块轮询本身。
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
pick():
|
|
92
|
+
留守 = 健康 AND 计数 < N_max AND (计数 < N OR 命中率 < M) ← OR
|
|
93
|
+
留守 → 返回当前号
|
|
94
|
+
否则 → 游标前进,切下一个号
|
|
95
|
+
故障(rate_limit / quota / session_dead) → 立即切,不等块满(已有)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
单一账号内部:
|
|
99
|
+
- 命中率**爬上去**(>= M,缓存热了)→ 让位,轮到下一个号(均衡)
|
|
100
|
+
- 命中率**没上去**(冷启动/短块)→ 留守继续热(不急着跳)
|
|
101
|
+
- 轮次**实在太多**(计数 >= N_max)→ 也换(防死守 + 保均衡下限)
|
|
102
|
+
|
|
103
|
+
参数:
|
|
104
|
+
| 参数 | 值 | 依据 |
|
|
105
|
+
|---|---|---|
|
|
106
|
+
| `M`(BLOCK_HIT_RATIO) | 0.85 | 实测 hit 样本 98~99.7%,85% 是"确实热了"的宽松判据 |
|
|
107
|
+
| `N`(BLOCK_MIN) | 16 | 最短驻留,防冷启动误切。块越大越少付块间驱逐,但均衡粒度越粗 |
|
|
108
|
+
| `N_max`(BLOCK_MAX) | 48 | 3×N:命中率一直上不去时也要让位,防死守 |
|
|
109
|
+
|
|
110
|
+
> ⚠️ `N` 的真实成本受 TTL 约束:这块该号空闲期间它守的前缀若被上游
|
|
111
|
+
> 驱逐,下轮回来仍是 cold miss。TTL 未知,16 是**保守起步值**,跑一轮
|
|
112
|
+
> usage.json 按 uid 下钻看块内命中率再调。
|
|
113
|
+
|
|
114
|
+
## 3. 换号:命中率达标 / 份额用够,都不是"命中率低"
|
|
115
|
+
|
|
116
|
+
**换号 = 付费**——下一个号要重算这段前缀。所以换号只能由"这个号已经把
|
|
117
|
+
价值榨干"触发:
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
换号 = (计数 >= N OR 命中率 >= M) OR 计数 >= N_max OR 故障
|
|
121
|
+
└── 命中达标(热了)或份额用够 ──┘ └─ 兜底 ─┘
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
> 注意:「命中率 >= M 就换」是 OR 语义下的让位规则。**正反馈死锁不复存在**:
|
|
125
|
+
> 换号由 `计数>=N` 或 `命中率>=M` 触发,都不是「命中率低」触发的。
|
|
126
|
+
> (若误把「命中率低」当切号条件,会切→cold miss→命中率 0%→再切→…锁死 0%,见 §5。)
|
|
127
|
+
|
|
128
|
+
### 命中率在 OR 语义下的角色
|
|
129
|
+
|
|
130
|
+
它是**留守条件**、不是切号执行器:
|
|
131
|
+
- `命中率 < M` → 留守这个号(继续热),不逃
|
|
132
|
+
- `命中率 >= M`(缓存热)→ 放行,让位给下一个号(均衡)
|
|
133
|
+
- 冷启动(cold miss)无害:第一个请求命中率 0% → 留守,继续热身
|
|
134
|
+
|
|
135
|
+
### M 与 N 的取舍(单账号)
|
|
136
|
+
|
|
137
|
+
`N`(BLOCK_MIN)= 最短驻留,防冷启动误切。热会话命中率很快爬到 M,
|
|
138
|
+
所以一个号实际服务约 `min(N, 达到M所需的次数)` 就换:
|
|
139
|
+
- N 越小 → 换号越频繁 → 均衡粒度细,但块间驱逐 miss 多
|
|
140
|
+
- N 越大 → 一块守得久 → 少付驱逐,但均衡粒度粗、冷号占份额久
|
|
141
|
+
| 90% | 10 |
|
|
142
|
+
| 95% | 20 |
|
|
143
|
+
|
|
144
|
+
实测 hit 样本本身 98~99.7%,故 M=85% 是「确实热了」的宽松判据。
|
|
145
|
+
**M 与 N 只需定一个**,另一个是它的函数;取 `N = ceil(1/(1-M))`。
|
|
146
|
+
|
|
147
|
+
## 4. 两个必须补的洞
|
|
148
|
+
|
|
149
|
+
### 4.1 N_max 绝对兜底(防死锁)
|
|
150
|
+
|
|
151
|
+
若某号缓存就是热不起来(上游不支持 / 容量被别的会话挤爆),
|
|
152
|
+
命中率**永远 < M** → 永远留守 → **整个会话锁死在这一个号上**,均衡归零。
|
|
153
|
+
|
|
154
|
+
`N_max` 保证无论命中率如何,到点必走。默认 `4 × N`。
|
|
155
|
+
|
|
156
|
+
### 4.2 留守到 N_max 仍不达标 → 降权
|
|
157
|
+
|
|
158
|
+
兜底切走后若不处理,下一轮它又会来占一个完整 N_max 块。缓存无效的号
|
|
159
|
+
反复拿满额份额,会把均衡拖成「按号均分」而非「按**有效**号均分」。
|
|
160
|
+
|
|
161
|
+
补:留守 `N_max` 次命中率仍 < M → 标记该号**缓存无效** → 后续轮转跳过。
|
|
162
|
+
冷却一段时间后可恢复(上游容量/策略会变,不做永久判决)。
|
|
163
|
+
|
|
164
|
+
> 这是**账号质量判断**,不是每次请求的路由决策,不进 `pick()` 热路径。
|
|
165
|
+
|
|
166
|
+
## 5. 参数
|
|
167
|
+
|
|
168
|
+
| 参数 | 默认 | 说明 |
|
|
169
|
+
|---|---|---|
|
|
170
|
+
| `N` | 8 | 最小块:达到才考虑切 |
|
|
171
|
+
| `M` | 0.85 | 切号门槛:块内命中率 >= 此值才允许因「满意」而切 |
|
|
172
|
+
| `N_max` | 32 | 绝对兜底,防死锁 |
|
|
173
|
+
| 降权冷却 | 10 min | 判定缓存无效后的跳过时长 |
|
|
174
|
+
|
|
175
|
+
`N = ceil(1/(1-M))`:M=0.85 → N=7,取 8 留余量。
|
|
176
|
+
|
|
177
|
+
## 6. 状态与边界
|
|
178
|
+
|
|
179
|
+
- **块状态按 (supplier, model)** 记**,账号池本来就是这个粒度
|
|
180
|
+
(冷却键同理),不新增维度。
|
|
181
|
+
- **失败请求不计入块统计**:失败没产生缓存,计入会污染命中率。
|
|
182
|
+
- **冷启动无害**:头两次 miss → 命中率 0%/50% → 反正 < M → 留守,
|
|
183
|
+
而这正是期望行为。噪声只会让你多待,不会让你错切。
|
|
184
|
+
- **游标跨会话持久**:`rrCursor` 是实例字段(池随进程存活),新会话
|
|
185
|
+
不会从首号重新开始。否则短会话全压 `poolOrder[0]`,均衡散掉。
|
|
186
|
+
- **并发会话共享块**:多会话交错时,块按「(模型) 累计请求数」推进,
|
|
187
|
+
不按单会话。这会让单会话的连续请求被别的会话插队,实际块内命中
|
|
188
|
+
低于理论值——**已知天花板**,根治需 per-session 亲和(见 §8)。
|
|
189
|
+
|
|
190
|
+
## 7. 可观测性前提
|
|
191
|
+
|
|
192
|
+
`UsageRecord` **无 `uid` 字段**,当前无法归因「哪个号命中差」,
|
|
193
|
+
任何调参都是盲调。已补 `uid` 以按号下钻。
|
|
194
|
+
|
|
195
|
+
验收指标:`usage.json` 的 `cached/prompt`,按 `uid` 分组。
|
|
196
|
+
- 健康:>=95% 且 miss 稀疏(只在冷启动/切号)
|
|
197
|
+
- 病态:miss 均匀分布(= 每请求切号)
|
|
198
|
+
|
|
199
|
+
## 8. 天花板(ponytail)
|
|
200
|
+
|
|
201
|
+
- **per-session 亲和——真正的根治**。实测已证明主因是并发会话打架
|
|
202
|
+
(多会话大前缀在同一号缓存里互相驱逐,§2.1)。块轮询只治单会话内的块,
|
|
203
|
+
**治不了跨会话驱逐**。需 `ChatRequest` 透传会话标识,或用前缀指纹
|
|
204
|
+
`hash(messages 前缀)` 做一致哈希(闭环在 router 内,不需 DSH 改契约):
|
|
205
|
+
每个会话绑定不同账号,前缀各有独立缓存空间,互不清。块轮询是它的低风险
|
|
206
|
+
前置,二者可叠加(亲和负责选号、块负责驻留)。
|
|
207
|
+
- **积分感知换挡**:`credits` 已持久化,可做「积分跌破阈值才换挡」。
|
|
208
|
+
但 `credits` 只存最后一次值不带时间戳,需先确认 `status()` 刷新频率,
|
|
209
|
+
否则触发失真。当前未启用。
|
|
210
|
+
- **TTL 仍未知**:87.5% 是保证下限,实际可能更高。要精确 N 需反推 TTL
|
|
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
|
+
会散得比会话更细,退化为均衡分发,无害。
|