dsh-agentic-router 1.4.0 → 1.4.2
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/dsh/index.js +41 -14
- package/dsh/package.json +43 -0
- package/dsh/plugin.test.js +405 -0
- package/dsh/strategies.js +13 -9
- package/dsh/strategies.test.js +218 -0
- package/package.json +2 -2
package/dsh/index.js
CHANGED
|
@@ -38,6 +38,31 @@ const KNN_GRADUATION = 30 // k-NN 毕业经验线
|
|
|
38
38
|
const SETTLE_DELAY_MS = 45000 // 反馈结算窗口:回合结束 45s 后查询 messageFeedback
|
|
39
39
|
const SWEEP_MS = 15000 // 结算轮询周期
|
|
40
40
|
|
|
41
|
+
// 同步兜底档位表:从 DEFAULT_PRICES 已知的 provider→model 推导档位,
|
|
42
|
+
// 让插件在 llm.listModels 异步加载完成之前也能立即决策与切换(不 await、不延期)。
|
|
43
|
+
// 加载完成后由 refreshModelTiers 用权威目录覆盖。
|
|
44
|
+
function fallbackTiers() {
|
|
45
|
+
const tiers = { fast: [], mid: [], strong: [] }
|
|
46
|
+
const providers = []
|
|
47
|
+
for (const provider of Object.keys(DEFAULT_PRICES)) {
|
|
48
|
+
if (provider === '*') continue
|
|
49
|
+
const models = Object.keys(DEFAULT_PRICES[provider] || {})
|
|
50
|
+
if (!models.length) continue
|
|
51
|
+
providers.push({ provider, models })
|
|
52
|
+
for (const n of models) tiers[tierOf(n)].push(provider + '/' + n)
|
|
53
|
+
}
|
|
54
|
+
return { providers, tiers }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 当前 provider 有候选模型的档位列表(UCB/LinUCB/k-NN 只在这些档位间探索)。
|
|
58
|
+
// provider 缺失或档位表不可用时回退到全部档位(保持专家函数原有行为)。
|
|
59
|
+
function availableTiersFor(provider, modelTiers) {
|
|
60
|
+
const all = ['fast', 'mid', 'strong']
|
|
61
|
+
if (!modelTiers || !provider) return all
|
|
62
|
+
const has = all.filter((t) => tierTarget(t, provider, modelTiers))
|
|
63
|
+
return has.length ? has : all
|
|
64
|
+
}
|
|
65
|
+
|
|
41
66
|
function freshState() {
|
|
42
67
|
return {
|
|
43
68
|
mode: 'shadow',
|
|
@@ -45,7 +70,7 @@ function freshState() {
|
|
|
45
70
|
decisions: [],
|
|
46
71
|
forced: null,
|
|
47
72
|
pending: new Map(),
|
|
48
|
-
modelTiers:
|
|
73
|
+
modelTiers: fallbackTiers(),
|
|
49
74
|
turnDecision: {},
|
|
50
75
|
openTurns: new Map(),
|
|
51
76
|
pendingFeedback: [],
|
|
@@ -287,20 +312,21 @@ export function apply(ctx, config = {}) {
|
|
|
287
312
|
|
|
288
313
|
// ---- 决策流水线(inbox 与 request 兜底共用) ----
|
|
289
314
|
// 返回 { key, td, record, target };已决策过本回合则返回 null。
|
|
290
|
-
//
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
try { await tiersReady } catch (e) { /* keep null */ }
|
|
294
|
-
}
|
|
315
|
+
// 必须同步执行:档位表用 fallbackTiers() 兜底(见 freshState),
|
|
316
|
+
// 不 await、不延期——否则追加 header 会落到 prompt 组装之后,被 loop 覆盖。
|
|
317
|
+
function decideAndRecord(payload, text, current, provider, source) {
|
|
295
318
|
const key = fullKey(payload)
|
|
296
319
|
if (state.turnDecision[key]) return null
|
|
297
320
|
const cls = classify(text)
|
|
298
321
|
const f = featuresOf(cls, text)
|
|
322
|
+
// 只探索当前 provider 有候选模型的档位(跳过无候选的"幽灵档",
|
|
323
|
+
// 如 mid 无模型时探索 mid 只会白费并回退到最贵的 strong)
|
|
324
|
+
const available = availableTiersFor(provider, state.modelTiers)
|
|
299
325
|
const recs = {
|
|
300
326
|
rule: recommendRule(cls),
|
|
301
|
-
ucb: recommendUcb(cls, state.ucb),
|
|
302
|
-
lin: recommendLin(cls, f, state.lin),
|
|
303
|
-
knn: recommendKnn(cls, f, state.knn),
|
|
327
|
+
ucb: recommendUcb(cls, state.ucb, available),
|
|
328
|
+
lin: recommendLin(cls, f, state.lin, available),
|
|
329
|
+
knn: recommendKnn(cls, f, state.knn, available),
|
|
304
330
|
}
|
|
305
331
|
if (state.forced) recs.rule = { tier: 'forced', reason: 'forced', conf: 1 }
|
|
306
332
|
const exp3 = {
|
|
@@ -377,14 +403,14 @@ export function apply(ctx, config = {}) {
|
|
|
377
403
|
// ---- 事件挂载 ----
|
|
378
404
|
// 回合开始(prompt 组装之前):捕获用户消息并决策,active 模式立即应用切换。
|
|
379
405
|
// 这是主路径——零延迟,回合第一步就按目标模型执行。
|
|
380
|
-
ctx.on('agent/inbox/claimed',
|
|
406
|
+
ctx.on('agent/inbox/claimed', (payload) => {
|
|
381
407
|
try {
|
|
382
408
|
const session = sessionOf(payload)
|
|
383
409
|
const text = extractText(payload && payload.message)
|
|
384
410
|
if (!text) return
|
|
385
411
|
const current = session ? currentModel(session) : null
|
|
386
412
|
const provider = session ? currentProvider(session) : ''
|
|
387
|
-
const r =
|
|
413
|
+
const r = decideAndRecord(payload, text, current, provider, 'inbox')
|
|
388
414
|
if (!r) return
|
|
389
415
|
applyDecision(session, r.key, r.td, r.record, r.target)
|
|
390
416
|
commitRecord(r.record)
|
|
@@ -428,7 +454,7 @@ export function apply(ctx, config = {}) {
|
|
|
428
454
|
const text = state.pending.get(pkey) || ''
|
|
429
455
|
state.pending.delete(pkey)
|
|
430
456
|
const provider = typeof cfg.provider === 'string' ? cfg.provider : ''
|
|
431
|
-
const r =
|
|
457
|
+
const r = decideAndRecord(payload, text, model, provider, 'request')
|
|
432
458
|
if (r) {
|
|
433
459
|
r.td.needsApply = state.mode === 'active' && r.target && r.target !== r.td.currentModel
|
|
434
460
|
commitRecord(r.record)
|
|
@@ -571,10 +597,11 @@ export function apply(ctx, config = {}) {
|
|
|
571
597
|
ctx.on('agent/turn-stopping', (payload) => finalize(payload, 'clean-close'))
|
|
572
598
|
ctx.on('agent/error', (payload) => finalize(payload, 'error'))
|
|
573
599
|
|
|
574
|
-
//
|
|
600
|
+
// 权威档位表异步加载,就绪后覆盖同步兜底表(fallbackTiers 保证首回合即可决策)。
|
|
601
|
+
// 仅在刷新成功时覆盖,避免把兜底表清成 null。
|
|
575
602
|
const tiersReady = refreshModelTiers(llm)
|
|
576
603
|
tiersReady.then((t) => {
|
|
577
|
-
state.modelTiers = t
|
|
604
|
+
if (t) state.modelTiers = t
|
|
578
605
|
log('model tiers: ' + JSON.stringify(t))
|
|
579
606
|
}).catch((e) => log('model tiers refresh failed: ' + String(e)))
|
|
580
607
|
|
package/dsh/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-agentic-router",
|
|
3
|
+
"version": "1.4.2",
|
|
4
|
+
"description": "DeepSeek Harness plugin: learning agentic router — rule + UCB + LinUCB + k-NN experts under an EXP3 meta-selector, with a durable quality-reward flywheel and REAL model switching via the session request-header precedence chain (v1.4.2)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "lhl",
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=22.19"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"deepseek-harness",
|
|
13
|
+
"dsh",
|
|
14
|
+
"dsh-plugin",
|
|
15
|
+
"agentic-router",
|
|
16
|
+
"model-routing",
|
|
17
|
+
"bandit",
|
|
18
|
+
"exp3",
|
|
19
|
+
"flywheel",
|
|
20
|
+
"llm"
|
|
21
|
+
],
|
|
22
|
+
"exports": {
|
|
23
|
+
".": "./dsh/index.js",
|
|
24
|
+
"./dsh": "./dsh/index.js",
|
|
25
|
+
"./package.json": "./package.json"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dsh",
|
|
29
|
+
"cordis.patch.yml",
|
|
30
|
+
"README.md",
|
|
31
|
+
"README.en.md",
|
|
32
|
+
"docs",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"test": "node --test"
|
|
37
|
+
},
|
|
38
|
+
"dsh": {
|
|
39
|
+
"bundle": {
|
|
40
|
+
"patch": "./cordis.patch.yml"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
// 插件端到端测试(v1.4.0 机制):inbox 决策 + request/header 追加真实切换;
|
|
2
|
+
// agent/request 只读透传;llm/stream 提供 request 路径的兜底应用。
|
|
3
|
+
import test from 'node:test'
|
|
4
|
+
import assert from 'node:assert/strict'
|
|
5
|
+
import { mkdtempSync } from 'node:fs'
|
|
6
|
+
import { tmpdir } from 'node:os'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import { apply, name, inject } from '../dsh/index.js'
|
|
9
|
+
import { Store } from '../dsh/store.js'
|
|
10
|
+
|
|
11
|
+
// 会话 mock:requestHeader 反映最近追加的 header(模拟真实 fold 行为)
|
|
12
|
+
function mockSession(id, startModel = 'deepseek-v4-pro') {
|
|
13
|
+
let model = startModel
|
|
14
|
+
const events = []
|
|
15
|
+
return {
|
|
16
|
+
id,
|
|
17
|
+
events,
|
|
18
|
+
append(type, data) {
|
|
19
|
+
events.push({ type, data })
|
|
20
|
+
if (type === 'request/header' && data && data.header && data.header.config && typeof data.header.config.model === 'string') {
|
|
21
|
+
model = data.header.config.model
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
requestHeader() {
|
|
25
|
+
return { config: { provider: 'deepseek-official', model }, adapterDefaults: {} }
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function mockCtx(opts = {}) {
|
|
31
|
+
const listeners = {}
|
|
32
|
+
const registered = []
|
|
33
|
+
return {
|
|
34
|
+
listeners,
|
|
35
|
+
registered,
|
|
36
|
+
get(k) {
|
|
37
|
+
if (k === 'tools') {
|
|
38
|
+
return { register: (def) => { registered.push(def); return () => {} } }
|
|
39
|
+
}
|
|
40
|
+
if (k === 'llm') {
|
|
41
|
+
return {
|
|
42
|
+
async listProviders() { return [{ id: 'deepseek-official' }] },
|
|
43
|
+
async listModels() {
|
|
44
|
+
if (opts.slowModels) return new Promise(() => {}) // 永不 resolve:模拟档位表加载未完成
|
|
45
|
+
return [{ id: 'deepseek-v4-flash' }, { id: 'deepseek-v4-pro' }]
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (k === 'messageFeedback') return opts.mf
|
|
50
|
+
if (k === 'timer') return opts.timer
|
|
51
|
+
if (k === 'sessions') return opts.sessions
|
|
52
|
+
if (k === 'agents') return opts.agents
|
|
53
|
+
return undefined
|
|
54
|
+
},
|
|
55
|
+
on(ev, fn) { listeners[ev] = fn; return () => {} },
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function payload(sessionId, turn, session) {
|
|
60
|
+
return { agent: { sessionId, session: session || mockSession(sessionId) }, turn, step: 1, signal: {} }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const CODE_TASK = '重构下面这段 Python 代码,使其支持多线程并发执行并安全处理异常:\n\n```python\nimport time\ndef fetch(url):\n time.sleep(1)\n```'
|
|
64
|
+
const QA_TASK = '请只用一句话回答:地球的天然卫星叫什么?'
|
|
65
|
+
|
|
66
|
+
test('插件声明', () => {
|
|
67
|
+
assert.equal(name, 'agentic-router')
|
|
68
|
+
assert.ok(inject.includes('tools'))
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('v1.4.0 机制:inbox 决策并追加 request/header 真实切换(shadow 不动、active 切换、request 只读透传)', async () => {
|
|
72
|
+
const home = mkdtempSync(join(tmpdir(), 'dsh-router-'))
|
|
73
|
+
const ctx = mockCtx()
|
|
74
|
+
apply(ctx, { home })
|
|
75
|
+
const inbox = ctx.listeners['agent/inbox/claimed']
|
|
76
|
+
const req = ctx.listeners['agent/request']
|
|
77
|
+
const stop = ctx.listeners['agent/turn-stopping']
|
|
78
|
+
assert.ok(inbox && req && stop, '事件监听应全部挂载')
|
|
79
|
+
assert.equal(ctx.registered.length, 5, '应注册 5 个工具')
|
|
80
|
+
|
|
81
|
+
const session = mockSession('s1')
|
|
82
|
+
const P = (t, text) => ({ agent: { sessionId: 's1', session }, turn: t, step: 1, signal: {}, message: { role: 'user', content: [{ type: 'text', text }] } })
|
|
83
|
+
const headers = () => session.events.filter((e) => e.type === 'request/header')
|
|
84
|
+
|
|
85
|
+
// shadow 模式(默认):复杂代码任务 → 记录决策,但不追加 header
|
|
86
|
+
await inbox(P(1, CODE_TASK))
|
|
87
|
+
assert.equal(headers().length, 0, 'shadow 模式不得切换')
|
|
88
|
+
|
|
89
|
+
// 切 active
|
|
90
|
+
const setMode = ctx.registered.find((t) => t.name === 'agentic_router_set_mode')
|
|
91
|
+
await setMode.execute({ mode: 'active' }, {})
|
|
92
|
+
|
|
93
|
+
// 简单问答 → 追加 flash header
|
|
94
|
+
await inbox(P(2, QA_TASK))
|
|
95
|
+
const appends = headers()
|
|
96
|
+
assert.equal(appends.length, 1, 'active 模式应追加一次 request/header')
|
|
97
|
+
assert.equal(appends[0].data.header.config.model, 'deepseek-v4-flash', '简单任务应切 flash')
|
|
98
|
+
assert.equal(appends[0].data.reason, 'router')
|
|
99
|
+
|
|
100
|
+
// agent/request 必须只读透传(不再替换配置)
|
|
101
|
+
const cfg = await req(payload('s1', 2, session), async () => ({ provider: 'deepseek-official', model: 'deepseek-v4-pro' }))
|
|
102
|
+
assert.equal(cfg.model, 'deepseek-v4-pro', 'agent/request 必须透传原配置')
|
|
103
|
+
|
|
104
|
+
// 收尾 → 奖励落盘且元数据完整
|
|
105
|
+
await stop(payload('s1', 2, session))
|
|
106
|
+
const store = new Store(home)
|
|
107
|
+
const rewards = store.loadJsonl('rewards.jsonl')
|
|
108
|
+
assert.equal(rewards.length, 1)
|
|
109
|
+
assert.ok(rewards[0].reward > 0)
|
|
110
|
+
assert.equal(rewards[0].turn, 's1/2')
|
|
111
|
+
assert.ok(Array.isArray(rewards[0].f) && rewards[0].f.length === 9, '奖励记录需含特征向量(供 replay)')
|
|
112
|
+
|
|
113
|
+
const decisions = store.loadJsonl('decisions.jsonl')
|
|
114
|
+
assert.equal(decisions.length, 2)
|
|
115
|
+
assert.equal(decisions[1].targetModel, 'deepseek-v4-flash')
|
|
116
|
+
assert.equal(decisions[1].applied, true, 'header 追加成功应标记 applied')
|
|
117
|
+
assert.equal(decisions[1].swapped, true, '真实切换应标记 swapped')
|
|
118
|
+
|
|
119
|
+
const stats = await ctx.registered.find((t) => t.name === 'agentic_router_stats').execute({}, {})
|
|
120
|
+
assert.equal(stats.totalDecisions, 2)
|
|
121
|
+
assert.equal(stats.mode, 'active')
|
|
122
|
+
assert.equal(stats.headerAppends, 1, 'stats 应统计追加次数')
|
|
123
|
+
assert.equal(stats.lastActual.model, 'deepseek-v4-pro')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('冷启动:档位表异步加载未完成时,inbox 仍用兜底档位立即切换(v1.4.1 修复)', async () => {
|
|
127
|
+
const home = mkdtempSync(join(tmpdir(), 'dsh-router-coldstart-'))
|
|
128
|
+
const ctx = mockCtx({ slowModels: true }) // listModels 永不 resolve,模拟冷启动
|
|
129
|
+
apply(ctx, { home, mode: 'active' })
|
|
130
|
+
const inbox = ctx.listeners['agent/inbox/claimed']
|
|
131
|
+
assert.ok(inbox)
|
|
132
|
+
|
|
133
|
+
const session = mockSession('s1') // 当前 pro
|
|
134
|
+
const P = (t, text) => ({ agent: { sessionId: 's1', session }, turn: t, step: 1, signal: {}, message: { role: 'user', content: [{ type: 'text', text }] } })
|
|
135
|
+
|
|
136
|
+
// inbox 是同步的:调用后立即断言,不等待任何异步档位表加载
|
|
137
|
+
inbox(P(1, QA_TASK))
|
|
138
|
+
const appends = session.events.filter((e) => e.type === 'request/header')
|
|
139
|
+
assert.equal(appends.length, 1, '冷启动下也应立即追加 header')
|
|
140
|
+
assert.equal(appends[0].data.header.config.model, 'deepseek-v4-flash', '兜底档位表应把简单问答切到 flash')
|
|
141
|
+
assert.equal(appends[0].data.reason, 'router')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('v1.4.2:UCB 不探索无候选的 mid 档——简单任务决策里 recs.ucb 不得为 mid', async () => {
|
|
145
|
+
const home = mkdtempSync(join(tmpdir(), 'dsh-router-notier-'))
|
|
146
|
+
const ctx = mockCtx() // 档位表只有 flash/pro(fast/strong),mid 无候选
|
|
147
|
+
apply(ctx, { home, mode: 'active' })
|
|
148
|
+
const inbox = ctx.listeners['agent/inbox/claimed']
|
|
149
|
+
const session = mockSession('s1')
|
|
150
|
+
const P = (t, text) => ({ agent: { sessionId: 's1', session }, turn: t, step: 1, signal: {}, message: { role: 'user', content: [{ type: 'text', text }] } })
|
|
151
|
+
|
|
152
|
+
// 连发 6 个简单问答,覆盖 UCB 冷启动轮转
|
|
153
|
+
for (let i = 1; i <= 6; i++) inbox(P(i, QA_TASK + '(第' + i + '问)'))
|
|
154
|
+
|
|
155
|
+
const store = new Store(home)
|
|
156
|
+
const decisions = store.loadJsonl('decisions.jsonl')
|
|
157
|
+
assert.equal(decisions.length, 6)
|
|
158
|
+
for (const d of decisions) {
|
|
159
|
+
assert.ok(!String(d.recs.ucb).startsWith('mid'), 'UCB 不得推荐 mid,实际 ' + d.recs.ucb)
|
|
160
|
+
assert.ok(!String(d.recs.lin).startsWith('mid'), 'LinUCB 不得推荐 mid,实际 ' + d.recs.lin)
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('兜底路径:request 决策 + llm/stream 延迟应用(中途热加载场景)', async () => {
|
|
165
|
+
const home = mkdtempSync(join(tmpdir(), 'dsh-router-fallback-'))
|
|
166
|
+
const session = mockSession('s1')
|
|
167
|
+
const sessions = new Map([['s1', session]])
|
|
168
|
+
const ctx = mockCtx({ sessions })
|
|
169
|
+
apply(ctx, { home, mode: 'active' })
|
|
170
|
+
const pre = ctx.listeners['agent/pre-step']
|
|
171
|
+
const req = ctx.listeners['agent/request']
|
|
172
|
+
const stream = ctx.listeners['llm/stream']
|
|
173
|
+
assert.ok(pre && req && stream)
|
|
174
|
+
|
|
175
|
+
await pre({ ...payload('s1', 1, session), messages: [{ content: [{ type: 'text', text: QA_TASK }] }] }, async () => ({}))
|
|
176
|
+
const cfg = await req(payload('s1', 1, session), async () => ({ provider: 'deepseek-official', model: 'deepseek-v4-pro' }))
|
|
177
|
+
assert.equal(cfg.model, 'deepseek-v4-pro', 'request 路径也透传')
|
|
178
|
+
|
|
179
|
+
// 流式调用结束 → 补应用切换
|
|
180
|
+
const wrapped = stream({ sessionId: 's1', provider: 'deepseek-official' }, async function* () {
|
|
181
|
+
yield { type: 'finish', reason: 'stop' }
|
|
182
|
+
})
|
|
183
|
+
for await (const c of wrapped) { /* drain */ }
|
|
184
|
+
|
|
185
|
+
const appends = session.events.filter((e) => e.type === 'request/header')
|
|
186
|
+
assert.equal(appends.length, 1, 'llm/stream 后应补应用一次 header')
|
|
187
|
+
assert.equal(appends[0].data.header.config.model, 'deepseek-v4-flash')
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test('飞轮重放:新实例从 rewards.jsonl 恢复学习状态', async () => {
|
|
191
|
+
const home = mkdtempSync(join(tmpdir(), 'dsh-router-replay-'))
|
|
192
|
+
const ctx1 = mockCtx()
|
|
193
|
+
apply(ctx1, { home })
|
|
194
|
+
const stop = ctx1.listeners['agent/turn-stopping']
|
|
195
|
+
const req = ctx1.listeners['agent/request']
|
|
196
|
+
const pre = ctx1.listeners['agent/pre-step']
|
|
197
|
+
await pre({ ...payload('s1', 1), messages: [{ content: [{ type: 'text', text: QA_TASK }] }] }, async () => ({}))
|
|
198
|
+
await req(payload('s1', 1), async () => ({ provider: 'deepseek-official', model: 'deepseek-v4-pro' }))
|
|
199
|
+
await stop(payload('s1', 1))
|
|
200
|
+
|
|
201
|
+
// 第二个实例(模拟重启):应重放出 1 条奖励
|
|
202
|
+
const ctx2 = mockCtx()
|
|
203
|
+
apply(ctx2, { home })
|
|
204
|
+
const stats = await ctx2.registered.find((t) => t.name === 'agentic_router_stats').execute({}, {})
|
|
205
|
+
assert.equal(stats.flywheel.ucb.total, 1, '重启后 UCB 应恢复 1 次拉动')
|
|
206
|
+
assert.equal(stats.flywheel.knnPoolSize, 1)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('工具失败代理:tools/result 错误 → 奖励降分', async () => {
|
|
210
|
+
const home = mkdtempSync(join(tmpdir(), 'dsh-router-toolerr-'))
|
|
211
|
+
const ctx = mockCtx()
|
|
212
|
+
apply(ctx, { home })
|
|
213
|
+
const req = ctx.listeners['agent/request']
|
|
214
|
+
const pre = ctx.listeners['agent/pre-step']
|
|
215
|
+
const stop = ctx.listeners['agent/turn-stopping']
|
|
216
|
+
const toolResult = ctx.listeners['tools/result']
|
|
217
|
+
assert.ok(toolResult)
|
|
218
|
+
|
|
219
|
+
await pre({ ...payload('s1', 1), messages: [{ content: [{ type: 'text', text: QA_TASK }] }] }, async () => ({}))
|
|
220
|
+
await req(payload('s1', 1), async () => ({ provider: 'deepseek-official', model: 'deepseek-v4-pro' }))
|
|
221
|
+
toolResult({ agent: { sessionId: 's1' }, name: 'read' }, { status: 'error', error: 'not found' })
|
|
222
|
+
await stop(payload('s1', 1))
|
|
223
|
+
|
|
224
|
+
const store = new Store(home)
|
|
225
|
+
const rewards = store.loadJsonl('rewards.jsonl')
|
|
226
|
+
assert.equal(rewards.length, 1)
|
|
227
|
+
assert.equal(rewards[0].breakdown.toolErrors, 1)
|
|
228
|
+
assert.equal(rewards[0].reward, 0.8)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('reset 工具清空飞轮', async () => {
|
|
232
|
+
const home = mkdtempSync(join(tmpdir(), 'dsh-router-reset-'))
|
|
233
|
+
const ctx = mockCtx()
|
|
234
|
+
apply(ctx, { home })
|
|
235
|
+
const req = ctx.listeners['agent/request']
|
|
236
|
+
const pre = ctx.listeners['agent/pre-step']
|
|
237
|
+
const stop = ctx.listeners['agent/turn-stopping']
|
|
238
|
+
await pre({ ...payload('s1', 1), messages: [{ content: [{ type: 'text', text: QA_TASK }] }] }, async () => ({}))
|
|
239
|
+
await req(payload('s1', 1), async () => ({ provider: 'deepseek-official', model: 'deepseek-v4-pro' }))
|
|
240
|
+
await stop(payload('s1', 1))
|
|
241
|
+
await ctx.registered.find((t) => t.name === 'agentic_router_reset').execute({}, {})
|
|
242
|
+
const stats = await ctx.registered.find((t) => t.name === 'agentic_router_stats').execute({}, {})
|
|
243
|
+
assert.equal(stats.flywheel.ucb.total, 0)
|
|
244
|
+
assert.equal(stats.totalDecisions, 0)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
test('messageFeedback 结算:回合结束 45s 后按人类点踩修正奖励', async () => {
|
|
248
|
+
const home = mkdtempSync(join(tmpdir(), 'dsh-router-mf-'))
|
|
249
|
+
const negativeItem = { messageId: 'm1', rating: 'negative', createdAt: Date.now() + 60000 }
|
|
250
|
+
const mf = { async list() { return { ok: true, value: { items: [negativeItem] } } } }
|
|
251
|
+
let sweepFn = null
|
|
252
|
+
const timer = { interval(fn) { sweepFn = fn; return () => {} } }
|
|
253
|
+
const ctx = mockCtx({ mf, timer })
|
|
254
|
+
apply(ctx, { home, settleDelayMs: 0 })
|
|
255
|
+
|
|
256
|
+
const req = ctx.listeners['agent/request']
|
|
257
|
+
const pre = ctx.listeners['agent/pre-step']
|
|
258
|
+
const stop = ctx.listeners['agent/turn-stopping']
|
|
259
|
+
await pre({ ...payload('s1', 1), messages: [{ content: [{ type: 'text', text: QA_TASK }] }] }, async () => ({}))
|
|
260
|
+
await req(payload('s1', 1), async () => ({ provider: 'deepseek-official', model: 'deepseek-v4-pro' }))
|
|
261
|
+
await stop(payload('s1', 1))
|
|
262
|
+
|
|
263
|
+
// 结算前:奖励 1,UCB mean 1
|
|
264
|
+
const store = new Store(home)
|
|
265
|
+
assert.equal(store.loadJsonl('rewards.jsonl')[0].reward, 1)
|
|
266
|
+
|
|
267
|
+
// 触发结算轮询(settleDelayMs=0 立即到期)
|
|
268
|
+
assert.ok(sweepFn, '应注册结算轮询')
|
|
269
|
+
await sweepFn()
|
|
270
|
+
|
|
271
|
+
const rewards = store.loadJsonl('rewards.jsonl')
|
|
272
|
+
const settle = rewards.find((r) => r.type === 'feedback-settle')
|
|
273
|
+
assert.ok(settle, '应产生 feedback-settle 记录')
|
|
274
|
+
assert.equal(settle.negative, 1)
|
|
275
|
+
assert.equal(settle.applied, 1)
|
|
276
|
+
assert.equal(settle.newReward, 0.5)
|
|
277
|
+
|
|
278
|
+
const stats = await ctx.registered.find((t) => t.name === 'agentic_router_stats').execute({}, {})
|
|
279
|
+
assert.equal(stats.flywheel.ucb.arms['qa:fast'].mean, 0.5, '飞轮均值应被修正')
|
|
280
|
+
assert.equal(stats.flywheel.pendingFeedback, 0, '结算后 pending 清空')
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
test('messageFeedback 缺失时优雅降级(不报错、不结算)', async () => {
|
|
284
|
+
const home = mkdtempSync(join(tmpdir(), 'dsh-router-mfnone-'))
|
|
285
|
+
const ctx = mockCtx({})
|
|
286
|
+
apply(ctx, { home, settleDelayMs: 0 })
|
|
287
|
+
const req = ctx.listeners['agent/request']
|
|
288
|
+
const pre = ctx.listeners['agent/pre-step']
|
|
289
|
+
const stop = ctx.listeners['agent/turn-stopping']
|
|
290
|
+
await pre({ ...payload('s1', 1), messages: [{ content: [{ type: 'text', text: QA_TASK }] }] }, async () => ({}))
|
|
291
|
+
await req(payload('s1', 1), async () => ({ provider: 'deepseek-official', model: 'deepseek-v4-pro' }))
|
|
292
|
+
await stop(payload('s1', 1))
|
|
293
|
+
const store = new Store(home)
|
|
294
|
+
assert.ok(store.loadJsonl('rewards.jsonl').every((r) => r.type !== 'feedback-settle'))
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
test('成本信号:llm/stream 透传并累加 usage,奖励按成本扣分', async () => {
|
|
298
|
+
const home = mkdtempSync(join(tmpdir(), 'dsh-router-cost-'))
|
|
299
|
+
const ctx = mockCtx({})
|
|
300
|
+
apply(ctx, { home })
|
|
301
|
+
const req = ctx.listeners['agent/request']
|
|
302
|
+
const pre = ctx.listeners['agent/pre-step']
|
|
303
|
+
const stop = ctx.listeners['agent/turn-stopping']
|
|
304
|
+
const stream = ctx.listeners['llm/stream']
|
|
305
|
+
assert.ok(stream, '应挂载 llm/stream 监听')
|
|
306
|
+
|
|
307
|
+
await pre({ ...payload('s1', 1), messages: [{ content: [{ type: 'text', text: QA_TASK }] }] }, async () => ({}))
|
|
308
|
+
await req(payload('s1', 1), async () => ({ provider: 'deepseek-official', model: 'deepseek-v4-pro' }))
|
|
309
|
+
|
|
310
|
+
// 模拟一次流式调用:文本 + usage chunk(strong 档 100k 输入 100k 输出)
|
|
311
|
+
const chunks = []
|
|
312
|
+
const wrapped = stream({ sessionId: 's1', provider: 'deepseek-official' }, async function* () {
|
|
313
|
+
yield { type: 'text-delta', text: '月球' }
|
|
314
|
+
yield { type: 'usage', usage: { inputTokens: 100000, outputTokens: 100000 } }
|
|
315
|
+
})
|
|
316
|
+
for await (const c of wrapped) chunks.push(c)
|
|
317
|
+
assert.equal(chunks.length, 2, '所有 chunk 必须透传')
|
|
318
|
+
assert.equal(chunks[0].text, '月球')
|
|
319
|
+
|
|
320
|
+
await stop(payload('s1', 1))
|
|
321
|
+
const store = new Store(home)
|
|
322
|
+
const rewards = store.loadJsonl('rewards.jsonl')
|
|
323
|
+
// strong 档成本 = (100000×1 + 100000×12)/1e6 = 1.3 元 → 惩罚封顶 0.5
|
|
324
|
+
assert.equal(rewards[0].breakdown.costYuan, 1.3)
|
|
325
|
+
assert.equal(rewards[0].breakdown.costPenalty, 0.5)
|
|
326
|
+
assert.equal(rewards[0].reward, 0.5)
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
test('成本信号:无 usage 的 provider 走估算兜底(estimated 标记)', async () => {
|
|
330
|
+
const home = mkdtempSync(join(tmpdir(), 'dsh-router-costest-'))
|
|
331
|
+
const ctx = mockCtx({})
|
|
332
|
+
apply(ctx, { home })
|
|
333
|
+
const req = ctx.listeners['agent/request']
|
|
334
|
+
const pre = ctx.listeners['agent/pre-step']
|
|
335
|
+
const stop = ctx.listeners['agent/turn-stopping']
|
|
336
|
+
const stream = ctx.listeners['llm/stream']
|
|
337
|
+
|
|
338
|
+
await pre({ ...payload('s1', 1), messages: [{ content: [{ type: 'text', text: QA_TASK }] }] }, async () => ({}))
|
|
339
|
+
await req(payload('s1', 1), async () => ({ provider: 'deepseek-official', model: 'deepseek-v4-pro' }))
|
|
340
|
+
const wrapped = stream({ sessionId: 's1', provider: 'deepseek-official' }, async function* () {
|
|
341
|
+
yield { type: 'text-delta', text: 'H2O' }
|
|
342
|
+
yield { type: 'finish', reason: 'stop' }
|
|
343
|
+
})
|
|
344
|
+
for await (const c of wrapped) { /* 透传 */ }
|
|
345
|
+
await stop(payload('s1', 1))
|
|
346
|
+
const rewards = new Store(home).loadJsonl('rewards.jsonl')
|
|
347
|
+
assert.ok(rewards[0].breakdown.costYuan > 0, '估算成本应 > 0')
|
|
348
|
+
assert.ok(rewards[0].breakdown.costYuan < 0.01, '小回合成本应很小')
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
test('set_prices:接入新 provider 后配置价格,成本按新价计算', async () => {
|
|
352
|
+
const home = mkdtempSync(join(tmpdir(), 'dsh-router-price-'))
|
|
353
|
+
const ctx = mockCtx({})
|
|
354
|
+
apply(ctx, { home })
|
|
355
|
+
const setPrices = ctx.registered.find((t) => t.name === 'agentic_router_set_prices')
|
|
356
|
+
const stats = ctx.registered.find((t) => t.name === 'agentic_router_stats')
|
|
357
|
+
|
|
358
|
+
// 默认无 openai 精确价 → 查询显示 prices 表
|
|
359
|
+
const view = await setPrices.execute({ provider: 'openai' }, {})
|
|
360
|
+
assert.ok(view.prices['*'], '默认含通用档位兜底')
|
|
361
|
+
|
|
362
|
+
// 配置 openai/gpt-x-pro 价格
|
|
363
|
+
const r = await setPrices.execute({ provider: 'openai', model: 'gpt-x-pro', priceIn: 15, priceOut: 60 }, {})
|
|
364
|
+
assert.equal(r.updated, 'gpt-x-pro')
|
|
365
|
+
const after = await stats.execute({}, {})
|
|
366
|
+
assert.equal(after.prices.openai['gpt-x-pro'].out, 60, '价格表热更新')
|
|
367
|
+
|
|
368
|
+
// 新 provider 决策 + 流式 usage → 成本按 15/60 计
|
|
369
|
+
const req = ctx.listeners['agent/request']
|
|
370
|
+
const pre = ctx.listeners['agent/pre-step']
|
|
371
|
+
const stop = ctx.listeners['agent/turn-stopping']
|
|
372
|
+
const stream = ctx.listeners['llm/stream']
|
|
373
|
+
await pre({ ...payload('s1', 1), messages: [{ content: [{ type: 'text', text: CODE_TASK }] }] }, async () => ({}))
|
|
374
|
+
await req(payload('s1', 1), async () => ({ provider: 'openai', model: 'gpt-x-pro' }))
|
|
375
|
+
const wrapped = stream({ sessionId: 's1', provider: 'openai' }, async function* () {
|
|
376
|
+
yield { type: 'usage', usage: { inputTokens: 1000, outputTokens: 1000 } }
|
|
377
|
+
})
|
|
378
|
+
for await (const c of wrapped) { /* 透传 */ }
|
|
379
|
+
await stop(payload('s1', 1))
|
|
380
|
+
const rewards = new Store(home).loadJsonl('rewards.jsonl')
|
|
381
|
+
// (1000×15 + 1000×60)/1e6 = 0.075 元;来源 provider-model
|
|
382
|
+
assert.ok(Math.abs(rewards[0].breakdown.costYuan - 0.075) < 1e-9)
|
|
383
|
+
assert.equal(rewards[0].breakdown.pricingSource, 'provider-model')
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
test('未知 provider 不套用 DeepSeek 价格:用通用档位并标记来源', async () => {
|
|
387
|
+
const home = mkdtempSync(join(tmpdir(), 'dsh-router-unknown-'))
|
|
388
|
+
const ctx = mockCtx({})
|
|
389
|
+
apply(ctx, { home })
|
|
390
|
+
const req = ctx.listeners['agent/request']
|
|
391
|
+
const pre = ctx.listeners['agent/pre-step']
|
|
392
|
+
const stop = ctx.listeners['agent/turn-stopping']
|
|
393
|
+
const stream = ctx.listeners['llm/stream']
|
|
394
|
+
await pre({ ...payload('s1', 1), messages: [{ content: [{ type: 'text', text: QA_TASK }] }] }, async () => ({}))
|
|
395
|
+
await req(payload('s1', 1), async () => ({ provider: 'mystery-vendor', model: 'mystery-fast' }))
|
|
396
|
+
const wrapped = stream({ sessionId: 's1', provider: 'mystery-vendor' }, async function* () {
|
|
397
|
+
yield { type: 'usage', usage: { inputTokens: 1000, outputTokens: 1000 } }
|
|
398
|
+
})
|
|
399
|
+
for await (const c of wrapped) { /* 透传 */ }
|
|
400
|
+
await stop(payload('s1', 1))
|
|
401
|
+
const rewards = new Store(home).loadJsonl('rewards.jsonl')
|
|
402
|
+
// '*' fast = (1000×1 + 1000×4)/1e6 = 0.005
|
|
403
|
+
assert.ok(Math.abs(rewards[0].breakdown.costYuan - 0.005) < 1e-9)
|
|
404
|
+
assert.equal(rewards[0].breakdown.pricingSource, 'generic-tier')
|
|
405
|
+
})
|
package/dsh/strategies.js
CHANGED
|
@@ -40,11 +40,13 @@ export function recommendRule(cls) {
|
|
|
40
40
|
return { tier: want, reason: 'rule threshold complexity=' + cls.complexity, conf: 0.5 }
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
// UCB:mean + c·√(lnN/n);未探索臂上界∞,多个∞平局时按总拉动数轮转(避免永远选 fast
|
|
44
|
-
|
|
43
|
+
// UCB:mean + c·√(lnN/n);未探索臂上界∞,多个∞平局时按总拉动数轮转(避免永远选 fast)。
|
|
44
|
+
// availableTiers 由调用方传入(当前 provider 有候选模型的档位)——不探索无候选的"幽灵档"
|
|
45
|
+
// (如 mid 无模型时,探索 mid 只会白费并回退到最贵的 strong)。
|
|
46
|
+
export function recommendUcb(cls, ucbState, availableTiers = ['fast', 'mid', 'strong']) {
|
|
45
47
|
const arms = ucbState.arms
|
|
46
48
|
const scores = []
|
|
47
|
-
for (const tier of
|
|
49
|
+
for (const tier of availableTiers) {
|
|
48
50
|
const arm = arms[cls.type + ':' + tier] || { n: 0, mean: 0 }
|
|
49
51
|
const ucb = arm.n > 0 ? arm.mean + 1.2 * Math.sqrt(Math.log(ucbState.total + 1) / arm.n) : Infinity
|
|
50
52
|
scores.push({ tier, ucb, arm })
|
|
@@ -61,11 +63,12 @@ export function recommendUcb(cls, ucbState) {
|
|
|
61
63
|
}
|
|
62
64
|
}
|
|
63
65
|
|
|
64
|
-
// LinUCB(近似实现):每档线性权重 w·f +
|
|
65
|
-
|
|
66
|
+
// LinUCB(近似实现):每档线性权重 w·f + 探索项;梯度更新,探索项随档样本数衰减。
|
|
67
|
+
// availableTiers 同上:只评估有候选模型的档位。
|
|
68
|
+
export function recommendLin(cls, f, linState, availableTiers = ['fast', 'mid', 'strong']) {
|
|
66
69
|
const tn = linState.tierN
|
|
67
70
|
let best = null
|
|
68
|
-
for (const tier of
|
|
71
|
+
for (const tier of availableTiers) {
|
|
69
72
|
const w = linState.tierW[tier] || []
|
|
70
73
|
let dot = 0
|
|
71
74
|
for (let i = 0; i < f.length; i++) dot += (w[i] || 0) * f[i]
|
|
@@ -80,8 +83,9 @@ export function recommendLin(cls, f, linState) {
|
|
|
80
83
|
}
|
|
81
84
|
}
|
|
82
85
|
|
|
83
|
-
// k-NN:欧氏距离取 k=5
|
|
84
|
-
|
|
86
|
+
// k-NN:欧氏距离取 k=5 邻居,按邻居平均奖励选档。
|
|
87
|
+
// availableTiers 同上:只从有候选模型的档位里选最佳。
|
|
88
|
+
export function recommendKnn(cls, f, knnState, availableTiers = ['fast', 'mid', 'strong']) {
|
|
85
89
|
const pool = knnState.pool
|
|
86
90
|
if (pool.length < 5) return { tier: null, reason: 'knn cold (' + pool.length + '/5 seed)', conf: 0 }
|
|
87
91
|
const scored = []
|
|
@@ -98,7 +102,7 @@ export function recommendKnn(cls, f, knnState) {
|
|
|
98
102
|
if (agg[t]) { agg[t].n += 1; agg[t].s += scored[i].e.r }
|
|
99
103
|
}
|
|
100
104
|
let best = null
|
|
101
|
-
for (const tier of
|
|
105
|
+
for (const tier of availableTiers) {
|
|
102
106
|
const a = agg[tier]
|
|
103
107
|
if (!best || (a.n > 0 && a.s / a.n > best.score)) best = { tier, score: a.n > 0 ? a.s / a.n : -1 }
|
|
104
108
|
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// 策略与飞轮测试:UCB 冷启动轮转 / mid 回退 / 奖励公式 / applyReward 更新
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import assert from 'node:assert/strict'
|
|
4
|
+
import {
|
|
5
|
+
tierOf, tierTarget, resolveTier,
|
|
6
|
+
recommendRule, recommendUcb, recommendLin, recommendKnn,
|
|
7
|
+
rewardOf, applyReward, correctReward,
|
|
8
|
+
DEFAULT_PRICES, turnCostYuan, resolvePrice,
|
|
9
|
+
} from '../dsh/strategies.js'
|
|
10
|
+
import { metaPick, DEFAULT_WEIGHTS } from '../dsh/meta.js'
|
|
11
|
+
|
|
12
|
+
const MODEL_TIERS = {
|
|
13
|
+
providers: [{ provider: 'deepseek-official', models: ['deepseek-v4-flash', 'deepseek-v4-pro'] }],
|
|
14
|
+
tiers: { fast: ['deepseek-official/deepseek-v4-flash'], mid: [], strong: ['deepseek-official/deepseek-v4-pro'] },
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test('tierOf 按 id 关键词分档', () => {
|
|
18
|
+
assert.equal(tierOf('deepseek-v4-flash'), 'fast')
|
|
19
|
+
assert.equal(tierOf('deepseek-v4-pro'), 'strong')
|
|
20
|
+
assert.equal(tierOf('some-model-x'), 'mid')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('UCB 冷启动:三个未探索臂∞平局时轮转,不会永远选 fast(实测修复)', () => {
|
|
24
|
+
const st = { total: 0, arms: {} }
|
|
25
|
+
const t0 = recommendUcb({ type: 'qa' }, st).tier
|
|
26
|
+
st.total = 1
|
|
27
|
+
const t1 = recommendUcb({ type: 'qa' }, st).tier
|
|
28
|
+
st.total = 2
|
|
29
|
+
const t2 = recommendUcb({ type: 'qa' }, st).tier
|
|
30
|
+
assert.deepEqual([t0, t1, t2].sort(), ['fast', 'mid', 'strong'])
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('UCB 利用:三臂都有样本时高均值臂胜出(未探索臂上界∞是合法探索)', () => {
|
|
34
|
+
const st = {
|
|
35
|
+
total: 30,
|
|
36
|
+
arms: {
|
|
37
|
+
'qa:fast': { n: 10, mean: 0.2 },
|
|
38
|
+
'qa:mid': { n: 10, mean: 0.1 },
|
|
39
|
+
'qa:strong': { n: 10, mean: 1 },
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
assert.equal(recommendUcb({ type: 'qa' }, st).tier, 'strong')
|
|
43
|
+
// 反之:strong 未探索时,UCB 应合法地选择探索它
|
|
44
|
+
const st2 = {
|
|
45
|
+
total: 20,
|
|
46
|
+
arms: { 'qa:fast': { n: 10, mean: 0.9 }, 'qa:mid': { n: 10, mean: 0.8 } },
|
|
47
|
+
}
|
|
48
|
+
assert.equal(recommendUcb({ type: 'qa' }, st2).tier, 'strong')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('v1.4.2:availableTiers 过滤后 UCB/LinUCB 只推荐可用档位(不探索无候选的 mid)', () => {
|
|
52
|
+
const st = { total: 0, arms: {} }
|
|
53
|
+
const lin = { total: 0, tierN: { fast: 0, mid: 0, strong: 0 }, tierW: { fast: new Array(9).fill(0), mid: new Array(9).fill(0), strong: new Array(9).fill(0) } }
|
|
54
|
+
const f = [1, 0, 0.01, 0, 0, 0, 0, 0, 0]
|
|
55
|
+
for (let i = 0; i < 8; i++) {
|
|
56
|
+
const u = recommendUcb({ type: 'qa' }, st, ['fast', 'strong'])
|
|
57
|
+
const l = recommendLin({ type: 'qa' }, f, lin, ['fast', 'strong'])
|
|
58
|
+
st.total += 1
|
|
59
|
+
assert.ok(['fast', 'strong'].includes(u.tier), 'UCB 不得推荐 mid,实际 ' + u.tier)
|
|
60
|
+
assert.ok(['fast', 'strong'].includes(l.tier), 'LinUCB 不得推荐 mid,实际 ' + l.tier)
|
|
61
|
+
}
|
|
62
|
+
// k-NN:经验池里有 mid 邻居时,也不得推荐 mid
|
|
63
|
+
const pool = [
|
|
64
|
+
{ f, tier: 'fast', r: 0.9 },
|
|
65
|
+
{ f, tier: 'fast', r: 0.8 },
|
|
66
|
+
{ f, tier: 'mid', r: 0.7 },
|
|
67
|
+
{ f, tier: 'mid', r: 0.6 },
|
|
68
|
+
{ f, tier: 'strong', r: 0.4 },
|
|
69
|
+
]
|
|
70
|
+
const knn = recommendKnn({ type: 'qa' }, f, { pool }, ['fast', 'strong'])
|
|
71
|
+
assert.ok(['fast', 'strong'].includes(knn.tier), 'k-NN 不得推荐 mid,实际 ' + knn.tier)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('resolveTier:mid 档无候选时向上取 strong(实测修复)', () => {
|
|
75
|
+
assert.deepEqual(resolveTier('mid', 'deepseek-official', MODEL_TIERS),
|
|
76
|
+
{ target: 'deepseek-v4-pro', used: 'strong', note: 'mid->strong fallback' })
|
|
77
|
+
assert.equal(resolveTier('fast', 'deepseek-official', MODEL_TIERS).target, 'deepseek-v4-flash')
|
|
78
|
+
assert.equal(resolveTier('strong', 'deepseek-official', MODEL_TIERS).target, 'deepseek-v4-pro')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('规则表阈值:低→fast,中→mid,高→strong', () => {
|
|
82
|
+
assert.equal(recommendRule({ complexity: 0.1 }).tier, 'fast')
|
|
83
|
+
assert.equal(recommendRule({ complexity: 0.5 }).tier, 'mid')
|
|
84
|
+
assert.equal(recommendRule({ complexity: 0.8 }).tier, 'strong')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('质量代理奖励:工具失败 −0.2/次(封顶 0.6),重试 −0.2/次(封顶 0.4),延迟分档', () => {
|
|
88
|
+
assert.equal(rewardOf({ toolErrors: 1, retries: 0 }, 'clean-close', 1000).reward, 0.8)
|
|
89
|
+
assert.equal(rewardOf({ toolErrors: 5, retries: 0 }, 'clean-close', 1000).reward, 0.4)
|
|
90
|
+
assert.equal(rewardOf({ toolErrors: 0, retries: 4 }, 'clean-close', 1000).reward, 0.6)
|
|
91
|
+
assert.equal(rewardOf({ toolErrors: 0, retries: 0 }, 'clean-close', 40000).reward, 0.9)
|
|
92
|
+
assert.equal(rewardOf({ toolErrors: 0, retries: 0 }, 'error', 1000).reward, 0)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('applyReward:更新 UCB/LinUCB/k-NN/EXP3 四个学习器', () => {
|
|
96
|
+
const state = {
|
|
97
|
+
ucb: { total: 0, arms: {} },
|
|
98
|
+
lin: { total: 0, tierN: { fast: 0, mid: 0, strong: 0 }, tierW: { fast: new Array(9).fill(0), mid: new Array(9).fill(0), strong: new Array(9).fill(0) } },
|
|
99
|
+
knn: { pool: [] },
|
|
100
|
+
exp3: { total: 0, weights: { ...DEFAULT_WEIGHTS } },
|
|
101
|
+
}
|
|
102
|
+
const rec = {
|
|
103
|
+
reward: 0.8, clsType: 'qa', ucbTier: 'fast', linTier: 'fast',
|
|
104
|
+
f: [1, 0.1, 0.01, 0, 0, 0, 0, 0, 1],
|
|
105
|
+
chosen: 'rule', pChosen: 0.7, pool: ['ucb', 'rule'],
|
|
106
|
+
}
|
|
107
|
+
applyReward(state, rec)
|
|
108
|
+
assert.equal(state.ucb.arms['qa:fast'].n, 1)
|
|
109
|
+
assert.equal(state.ucb.arms['qa:fast'].mean, 0.8)
|
|
110
|
+
assert.equal(state.lin.tierN.fast, 1)
|
|
111
|
+
assert.equal(state.knn.pool.length, 1)
|
|
112
|
+
assert.ok(state.exp3.weights.rule > DEFAULT_WEIGHTS.rule, '被选策略权重应上升')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('metaPick:冷启动只有 ucb+rule 入池,rule 初始权重 2.5 占优', () => {
|
|
116
|
+
const recs = {
|
|
117
|
+
rule: { tier: 'fast', conf: 0.5 },
|
|
118
|
+
ucb: { tier: 'fast', conf: 0.2 },
|
|
119
|
+
lin: { tier: 'fast', conf: 0.1 },
|
|
120
|
+
knn: { tier: null, conf: 0 },
|
|
121
|
+
}
|
|
122
|
+
const exp3 = { weights: { ...DEFAULT_WEIGHTS }, linGraduated: false, knnGraduated: false }
|
|
123
|
+
const m = metaPick(recs, exp3)
|
|
124
|
+
assert.deepEqual(m.pool.sort(), ['rule', 'ucb'])
|
|
125
|
+
assert.ok(Math.abs(m.ps.rule - 2.5 / 3.5) < 1e-9)
|
|
126
|
+
assert.ok(m.byName[m.chosen])
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('k-NN 冷启动:经验不足时给 null 档', () => {
|
|
130
|
+
const r = recommendKnn({ type: 'qa' }, [1, 0.1, 0.01, 0, 0, 0, 0, 0, 1], { pool: [] })
|
|
131
|
+
assert.equal(r.tier, null)
|
|
132
|
+
assert.equal(r.conf, 0)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('k-NN 推荐:邻居经验按均值选档', () => {
|
|
136
|
+
const pool = []
|
|
137
|
+
const f = [1, 0.1, 0.01, 0, 0, 0, 0, 0, 1]
|
|
138
|
+
for (let i = 0; i < 10; i++) {
|
|
139
|
+
pool.push({ f: [1, 0.11, 0.01, 0, 0, 0, 0, 0, 1], tier: 'strong', r: 1 })
|
|
140
|
+
pool.push({ f: [1, 0.12, 0.01, 0, 0, 0, 0, 0, 1], tier: 'fast', r: 0.3 })
|
|
141
|
+
}
|
|
142
|
+
const r = recommendKnn({ type: 'qa' }, f, { pool })
|
|
143
|
+
assert.equal(r.tier, 'strong')
|
|
144
|
+
assert.ok(r.conf > 0)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('correctReward:反馈修正精确回退 UCB 均值与 EXP3 权重', () => {
|
|
148
|
+
const state = {
|
|
149
|
+
ucb: { total: 1, arms: {} },
|
|
150
|
+
lin: { total: 1, tierN: { fast: 1, mid: 0, strong: 0 }, tierW: { fast: new Array(9).fill(0), mid: new Array(9).fill(0), strong: new Array(9).fill(0) } },
|
|
151
|
+
knn: { pool: [] },
|
|
152
|
+
exp3: { total: 1, weights: { ...DEFAULT_WEIGHTS } },
|
|
153
|
+
}
|
|
154
|
+
const rec = {
|
|
155
|
+
reward: 1, clsType: 'qa', ucbTier: 'fast', linTier: 'fast',
|
|
156
|
+
f: [1, 0.1, 0.01, 0, 0, 0, 0, 0, 1],
|
|
157
|
+
chosen: 'rule', pChosen: 0.7, pool: ['ucb', 'rule'],
|
|
158
|
+
}
|
|
159
|
+
applyReward(state, rec)
|
|
160
|
+
assert.equal(state.ucb.arms['qa:fast'].mean, 1)
|
|
161
|
+
const wBefore = state.exp3.weights.rule
|
|
162
|
+
|
|
163
|
+
correctReward(state, rec, 0.5)
|
|
164
|
+
assert.equal(state.ucb.arms['qa:fast'].n, 1, 'n 不变')
|
|
165
|
+
assert.equal(state.ucb.arms['qa:fast'].mean, 0.5, '均值修正为 0.5')
|
|
166
|
+
assert.ok(state.exp3.weights.rule < wBefore, 'EXP3 权重应乘性回退')
|
|
167
|
+
assert.ok(Math.abs(state.exp3.weights.rule - DEFAULT_WEIGHTS.rule * Math.exp(0.3 * (0.5 / 0.7) / 2)) < 1e-9, 'EXP3 回退为精确逆运算')
|
|
168
|
+
assert.equal(rec.reward, 0.5, '记录同步更新')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('correctReward:无变化时不修正', () => {
|
|
172
|
+
const state = {
|
|
173
|
+
ucb: { total: 1, arms: {} },
|
|
174
|
+
lin: { total: 1, tierN: { fast: 1, mid: 0, strong: 0 }, tierW: { fast: new Array(9).fill(0), mid: new Array(9).fill(0), strong: new Array(9).fill(0) } },
|
|
175
|
+
knn: { pool: [] },
|
|
176
|
+
exp3: { total: 1, weights: { ...DEFAULT_WEIGHTS } },
|
|
177
|
+
}
|
|
178
|
+
const rec = { reward: 0.8, clsType: 'qa', ucbTier: 'fast', linTier: 'fast', f: [1, 0, 0, 0, 0, 0, 0, 0, 1], chosen: 'rule', pChosen: 0.7, pool: ['ucb', 'rule'] }
|
|
179
|
+
applyReward(state, rec)
|
|
180
|
+
const snapshot = JSON.stringify(state.ucb)
|
|
181
|
+
correctReward(state, rec, 0.8)
|
|
182
|
+
assert.equal(JSON.stringify(state.ucb), snapshot)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test('turnCostYuan:三级价格解析(缓存读计入输入、推理计入输出)', () => {
|
|
186
|
+
const usage = { inputTokens: 1000, outputTokens: 100, cacheReadTokens: 500, reasoningTokens: 50 }
|
|
187
|
+
// 1) 精确模型价:deepseek-v4-pro = (1500×1 + 150×12)/1e6 = 0.0033
|
|
188
|
+
assert.ok(Math.abs(turnCostYuan(usage, 'deepseek-official', 'deepseek-v4-pro', 'strong', DEFAULT_PRICES) - 0.0033) < 1e-9)
|
|
189
|
+
// 2) provider 档位价:acme/xyz-pro 无精确价 → acme.strong = (1500×5 + 150×50)/1e6 = 0.015
|
|
190
|
+
const acme = { acme: { strong: { in: 5, out: 50 } } }
|
|
191
|
+
assert.ok(Math.abs(turnCostYuan(usage, 'acme', 'xyz-pro', 'strong', acme) - 0.015) < 1e-9)
|
|
192
|
+
// 3) 未知 provider 用 '*' 通用档位(绝不套用 DeepSeek 价):'*'.fast = (1500×1 + 150×4)/1e6 = 0.0021
|
|
193
|
+
assert.ok(Math.abs(turnCostYuan(usage, 'mystery', 'some-model', 'fast', DEFAULT_PRICES) - 0.0021) < 1e-9)
|
|
194
|
+
// 4) 连 '*' 都没有 → 保守兜底 (3,16) = (1500×3 + 150×16)/1e6 = 0.0069
|
|
195
|
+
assert.ok(Math.abs(turnCostYuan(usage, 'mystery', 'some-model', 'fast', {}) - 0.0069) < 1e-9)
|
|
196
|
+
assert.equal(turnCostYuan(null, 'deepseek-official', 'deepseek-v4-pro', 'strong', DEFAULT_PRICES), 0)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test('resolvePrice 返回来源标注(透明审计)', () => {
|
|
200
|
+
const acme = { acme: { strong: { in: 5, out: 50 } } }
|
|
201
|
+
assert.equal(resolvePrice('deepseek-official', 'deepseek-v4-pro', 'strong', DEFAULT_PRICES).source, 'provider-model')
|
|
202
|
+
assert.equal(resolvePrice('acme', 'xyz-pro', 'strong', acme).source, 'provider-tier')
|
|
203
|
+
assert.equal(resolvePrice('mystery', 'some-model', 'fast', DEFAULT_PRICES).source, 'generic-tier')
|
|
204
|
+
assert.equal(resolvePrice('mystery', 'some-model', 'fast', {}).source, 'unknown-conservative')
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('rewardOf 成本项:weight×元封顶 cap', () => {
|
|
208
|
+
const td = { toolErrors: 0, retries: 0, costYuan: 0.02, costWeight: 20, costCap: 0.5 }
|
|
209
|
+
const r = rewardOf(td, 'clean-close', 1000)
|
|
210
|
+
assert.ok(Math.abs(r.reward - 0.6) < 1e-9, '1 − 0.4 = 0.6,实际 ' + r.reward)
|
|
211
|
+
assert.equal(r.breakdown.costPenalty, 0.4)
|
|
212
|
+
assert.equal(r.breakdown.costYuan, 0.02)
|
|
213
|
+
|
|
214
|
+
const td2 = { toolErrors: 0, retries: 0, costYuan: 0.1, costWeight: 20, costCap: 0.5 }
|
|
215
|
+
const r2 = rewardOf(td2, 'clean-close', 1000)
|
|
216
|
+
assert.equal(r2.breakdown.costPenalty, 0.5, '惩罚封顶 0.5')
|
|
217
|
+
assert.equal(r2.reward, 0.5)
|
|
218
|
+
})
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-agentic-router",
|
|
3
|
-
"version": "1.4.
|
|
4
|
-
"description": "DeepSeek Harness plugin: learning agentic router — rule + UCB + LinUCB + k-NN experts under an EXP3 meta-selector, with a durable quality-reward flywheel and REAL model switching via the session request-header precedence chain (v1.4.
|
|
3
|
+
"version": "1.4.2",
|
|
4
|
+
"description": "DeepSeek Harness plugin: learning agentic router — rule + UCB + LinUCB + k-NN experts under an EXP3 meta-selector, with a durable quality-reward flywheel and REAL model switching via the session request-header precedence chain (v1.4.2)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "lhl",
|