dsh-agentic-router 1.4.2 → 1.5.0
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 +6 -0
- package/docs/DESIGN.md +37 -0
- package/dsh/index.js +89 -13
- package/package.json +2 -2
- package/dsh/package.json +0 -43
- package/dsh/plugin.test.js +0 -405
- package/dsh/strategies.test.js +0 -218
package/README.md
CHANGED
|
@@ -14,6 +14,12 @@ DeepSeek Harness(DSH)插件:**学习型智能路由(agentic router)+
|
|
|
14
14
|
向会话日志追加 `request/header` 事件(reason: `router`),走 DSH 官方模型选择优先级链
|
|
15
15
|
(本进程选择 → 会话日志最新 header → 默认模型)真实切换。
|
|
16
16
|
|
|
17
|
+
**v1.5.0 步骤级路由**:在回合级基线之上,`agent/pre-step` 按**上一步的工具调用**做
|
|
18
|
+
步骤级调整——重型工具(写代码/执行/子 agent 等,见 `HEAVY_TOOLS`)→ 下一步升级 strong
|
|
19
|
+
(pro);轻量/纯文本 → 回到回合基线档。实现"简单步骤 flash、复杂步骤 pro"的近似形态
|
|
20
|
+
(步骤切换对下一步生效,一阶延迟)。步骤级决策落盘 `steps.jsonl`,`agentic_router_stats`
|
|
21
|
+
可见 `stepSwitches` 与 `stepLog`。
|
|
22
|
+
|
|
17
23
|
## 架构
|
|
18
24
|
|
|
19
25
|
```
|
package/docs/DESIGN.md
CHANGED
|
@@ -226,3 +226,40 @@ dsh-agentic-router/
|
|
|
226
226
|
- 离线回放:拿历史 session 日志重放,对比 shadow 路由与实际路由的评分分布
|
|
227
227
|
- 影子期 A/B:shadow 评分 vs 默认路由评分,达标才切 active
|
|
228
228
|
- 集成冒烟:mock ctx 注入 agent/request payload,验证 LlmCallConfig 替换与 next() 透传
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
## 13. v1.5.0 步骤级路由设计(已实现)
|
|
233
|
+
|
|
234
|
+
### 13.1 动机
|
|
235
|
+
|
|
236
|
+
回合级路由(v1.4.x)每回合决策一次、整回合同一模型。用户期望"单任务内简单步骤用 flash、复杂步骤用 pro"。步骤级路由在回合级基线之上做**按步骤升降档**。
|
|
237
|
+
|
|
238
|
+
### 13.2 时序依据(为什么对下一步生效)
|
|
239
|
+
|
|
240
|
+
loop 每步顺序:`inbox.claim()`(发 `agent/inbox/claimed`)→ `systemPrompt.assemble()`(快照模型)→ `agent/pre-step` waterfall → `buildRequest`。因此:
|
|
241
|
+
|
|
242
|
+
- **inbox 决策**在组装之前 → 回合第一步即生效(零延迟,v1.4.x 主路径)
|
|
243
|
+
- **pre-step 决策**在组装之后 → 追加 header 对**下一步**生效(一阶延迟,设计如此)
|
|
244
|
+
|
|
245
|
+
### 13.3 信号:上一步的工具调用
|
|
246
|
+
|
|
247
|
+
`session.deriveMessages()` 取最后一条 assistant 消息的 `tool-call` 块(只读叶子字段):
|
|
248
|
+
|
|
249
|
+
| 上一步工具 | 下一步档位 |
|
|
250
|
+
|---|---|
|
|
251
|
+
| 重型:bash/pwsh/write/edit/batch_edit/run_code/workflow/subagent/subagent_fork/terminal/pty/mcp | **strong(pro)** |
|
|
252
|
+
| 轻量:read/glob/grep 等 | 回合基线档 |
|
|
253
|
+
| 无工具(纯文本,通常回合收尾) | 保持现状 |
|
|
254
|
+
|
|
255
|
+
### 13.4 决策与落盘
|
|
256
|
+
|
|
257
|
+
- 步骤档位 → `resolveTier` → 目标模型 ≠ 当前模型 → 追加 `request/header`(reason: `router-step`)
|
|
258
|
+
- 每步一条 `steps.jsonl`:`{turn, step, lastTools, stepTier, target, current, applied}`
|
|
259
|
+
- stats 暴露 `stepSwitches` 与 `stepLog`(最近 20 条)
|
|
260
|
+
|
|
261
|
+
### 13.5 边界与后续
|
|
262
|
+
|
|
263
|
+
- 奖励飞轮仍按回合结算(回合基线的策略选择),步骤级是确定性规则;未来可把步骤级升降档纳入学习(如按工具调用频率学阈值)
|
|
264
|
+
- 重型集合 `HEAVY_TOOLS` 是静态清单,可配置化
|
|
265
|
+
- 降级方向:重型步骤后的轻量步骤会回到基线(简单任务场景:写代码阶段 pro、前后问答 flash)
|
package/dsh/index.js
CHANGED
|
@@ -77,6 +77,8 @@ function freshState() {
|
|
|
77
77
|
recByTurn: new Map(),
|
|
78
78
|
headerAppends: 0,
|
|
79
79
|
lastActual: null,
|
|
80
|
+
stepLog: [],
|
|
81
|
+
counters: { stepSwitches: 0 },
|
|
80
82
|
ucb: { total: 0, arms: {} },
|
|
81
83
|
lin: {
|
|
82
84
|
total: 0,
|
|
@@ -188,10 +190,10 @@ function currentProvider(session) {
|
|
|
188
190
|
return c && typeof c.provider === 'string' ? c.provider : ''
|
|
189
191
|
}
|
|
190
192
|
|
|
191
|
-
// 核心切换:向会话日志追加 request/header(reason: 'router'),
|
|
193
|
+
// 核心切换:向会话日志追加 request/header(reason: 'router' / 'router-step'),
|
|
192
194
|
// 下一轮 prompt 组装即读到新模型。fast/mid 档去掉 reasoningEffort,
|
|
193
195
|
// 让目标模型走自身默认 effort(避免 flash 不支持 max 而整步失败)。
|
|
194
|
-
function appendRouterHeader(session, targetModel) {
|
|
196
|
+
function appendRouterHeader(session, targetModel, reason = 'router') {
|
|
195
197
|
try {
|
|
196
198
|
const last = session.requestHeader()
|
|
197
199
|
const header = {}
|
|
@@ -204,13 +206,40 @@ function appendRouterHeader(session, targetModel) {
|
|
|
204
206
|
config.model = targetModel
|
|
205
207
|
if (tierOf(targetModel) !== 'strong') delete config.reasoningEffort
|
|
206
208
|
header.config = config
|
|
207
|
-
session.append('request/header', { header, reason
|
|
209
|
+
session.append('request/header', { header, reason })
|
|
208
210
|
return true
|
|
209
211
|
} catch (e) {
|
|
210
212
|
return false
|
|
211
213
|
}
|
|
212
214
|
}
|
|
213
215
|
|
|
216
|
+
// 步骤级路由(v1.5.0):视为"重型工作"的工具调用——上一步出现它们时,
|
|
217
|
+
// 下一步升级到 strong(pro)处理其结果/继续执行;轻量工具保持回合基线。
|
|
218
|
+
const HEAVY_TOOLS = new Set([
|
|
219
|
+
'bash', 'pwsh', 'write', 'edit', 'batch_edit', 'run_code', 'workflow',
|
|
220
|
+
'subagent', 'subagent_fork', 'terminal', 'pty', 'mcp',
|
|
221
|
+
])
|
|
222
|
+
|
|
223
|
+
// 读取会话里最后一条 assistant 消息的工具调用名(只取叶子字段,不碰活数据)。
|
|
224
|
+
function lastToolCalls(session) {
|
|
225
|
+
try {
|
|
226
|
+
const msgs = typeof session.deriveMessages === 'function' ? session.deriveMessages() : null
|
|
227
|
+
if (!Array.isArray(msgs)) return []
|
|
228
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
229
|
+
const m = msgs[i]
|
|
230
|
+
if (!m || typeof m !== 'object' || m.role !== 'assistant') continue
|
|
231
|
+
const blocks = m.content
|
|
232
|
+
if (!Array.isArray(blocks)) return []
|
|
233
|
+
const out = []
|
|
234
|
+
for (const b of blocks) {
|
|
235
|
+
if (b && typeof b === 'object' && b.type === 'tool-call' && typeof b.name === 'string') out.push(b.name)
|
|
236
|
+
}
|
|
237
|
+
return out
|
|
238
|
+
}
|
|
239
|
+
} catch (e) { /* read-only observation */ }
|
|
240
|
+
return []
|
|
241
|
+
}
|
|
242
|
+
|
|
214
243
|
async function refreshModelTiers(llm) {
|
|
215
244
|
if (!llm || typeof llm.listProviders !== 'function') return null
|
|
216
245
|
const providers = await llm.listProviders()
|
|
@@ -347,6 +376,8 @@ export function apply(ctx, config = {}) {
|
|
|
347
376
|
currentModel: current, provider, costWeight, costCap,
|
|
348
377
|
estInputTokens: Math.ceil(text.length / 1.5),
|
|
349
378
|
target, applied: false, needsApply: false, actualModel: current,
|
|
379
|
+
// 回合级基线(供步骤级路由 v1.5.0 参考:无重型工具时保持该档)
|
|
380
|
+
turnTier: target ? tierOf(target) : null, turnTarget: target,
|
|
350
381
|
}
|
|
351
382
|
const record = {
|
|
352
383
|
seq: state.seq,
|
|
@@ -429,6 +460,38 @@ export function apply(ctx, config = {}) {
|
|
|
429
460
|
} catch (e) {
|
|
430
461
|
log('pre-step capture failed: ' + String(e))
|
|
431
462
|
}
|
|
463
|
+
// ---- 步骤级路由(v1.5.0)----
|
|
464
|
+
// pre-step 在组装之后触发 → 此处追加 header 对下一步生效(一阶延迟,设计如此)。
|
|
465
|
+
// 信号:上一步 assistant 消息的工具调用。重型工具(写代码/执行/子agent)→ 下一步
|
|
466
|
+
// 升级 strong(pro);轻量/无工具 → 回到回合基线档。
|
|
467
|
+
try {
|
|
468
|
+
const session = sessionOf(payload)
|
|
469
|
+
if (!session) return next()
|
|
470
|
+
const key = fullKey(payload)
|
|
471
|
+
const td = state.turnDecision[key]
|
|
472
|
+
if (!td || td.finalized) return next()
|
|
473
|
+
const tools = lastToolCalls(session)
|
|
474
|
+
if (!tools.length) return next() // 上一步是纯文本(通常回合收尾),保持现状
|
|
475
|
+
const heavy = tools.some((n) => HEAVY_TOOLS.has(n))
|
|
476
|
+
const stepTier = heavy ? 'strong' : (td.turnTier || null)
|
|
477
|
+
if (!stepTier) return next()
|
|
478
|
+
const provider = td.provider || currentProvider(session) || ''
|
|
479
|
+
const resolved = resolveTier(stepTier, provider, state.modelTiers)
|
|
480
|
+
const target = resolved.target
|
|
481
|
+
const cur = currentModel(session)
|
|
482
|
+
const changed = target && target !== cur
|
|
483
|
+
// 注意:pre-step 在组装之后、buildRequest(loop 追加自己的 header)之前触发。
|
|
484
|
+
// 这里直接追加会被本步 buildRequest 的 change(旧模型) 覆盖(与 v1.4.0 冷启动同款竞态)。
|
|
485
|
+
// 因此只决策并挂起,真正追加推迟到 llm/stream 收尾(此时 loop 的 header 已落盘)。
|
|
486
|
+
if (changed) td.stepTarget = target
|
|
487
|
+
const stepRec = { turn: key, step: payload.step, lastTools: tools.slice(0, 5), stepTier, target, current: cur, applied: false, pending: changed, ts: Date.now() }
|
|
488
|
+
if (changed) td.stepRec = stepRec
|
|
489
|
+
state.stepLog.push(stepRec)
|
|
490
|
+
if (state.stepLog.length > 30) state.stepLog.shift()
|
|
491
|
+
try { store.appendJsonl('steps.jsonl', stepRec) } catch (e) { /* 落盘尽力而为 */ }
|
|
492
|
+
} catch (e) {
|
|
493
|
+
log('step routing failed: ' + String(e))
|
|
494
|
+
}
|
|
432
495
|
return next()
|
|
433
496
|
})
|
|
434
497
|
|
|
@@ -499,14 +562,25 @@ export function apply(ctx, config = {}) {
|
|
|
499
562
|
const key = sid ? state.openTurns.get(sid) : null
|
|
500
563
|
const td = key ? state.turnDecision[key] : null
|
|
501
564
|
if (td && !td.finalized) {
|
|
502
|
-
//
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
565
|
+
// 延迟应用(此时循环自身 header 已落盘,追加无竞态):
|
|
566
|
+
// 1) 步骤级路由挂起的升降档(router-step)
|
|
567
|
+
// 2) request 路径兜底决策(needsApply)
|
|
568
|
+
let session = null
|
|
569
|
+
try {
|
|
570
|
+
const sessions = ctx.get('sessions')
|
|
571
|
+
if (sessions && typeof sessions.get === 'function') session = sessions.get(sid) || null
|
|
572
|
+
} catch (e) { session = null }
|
|
573
|
+
if (session) {
|
|
574
|
+
if (td.stepTarget && td.stepTarget !== currentModel(session)) {
|
|
575
|
+
const ok = appendRouterHeader(session, td.stepTarget, 'router-step')
|
|
576
|
+
if (ok) {
|
|
577
|
+
state.counters.stepSwitches++
|
|
578
|
+
if (td.stepRec) { td.stepRec.applied = true }
|
|
579
|
+
log('STEP-SWAP -> ' + td.stepTarget + (td.stepRec ? ' (tools: ' + td.stepRec.lastTools.join(',') + ')' : ''))
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
td.stepTarget = null
|
|
583
|
+
if (td.needsApply && !td.applied) applyDecision(session, key, td, td.rec, td.target)
|
|
510
584
|
}
|
|
511
585
|
const u = td.usage || { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, reasoningTokens: 0, estimated: false }
|
|
512
586
|
const model = (options && typeof options.model === 'string' && options.model) || td.currentModel || ''
|
|
@@ -632,9 +706,11 @@ export function apply(ctx, config = {}) {
|
|
|
632
706
|
totalDecisions: state.seq,
|
|
633
707
|
modelTiers: state.modelTiers,
|
|
634
708
|
prices,
|
|
635
|
-
mechanism: 'request/header append (
|
|
709
|
+
mechanism: 'request/header append (inbox turn-level + pre-step step-level v1.5.0)',
|
|
636
710
|
lastActual: state.lastActual,
|
|
637
711
|
headerAppends: state.headerAppends,
|
|
712
|
+
stepSwitches: state.counters.stepSwitches,
|
|
713
|
+
stepLog: state.stepLog.slice(-20),
|
|
638
714
|
flywheel: {
|
|
639
715
|
ucb: state.ucb,
|
|
640
716
|
lin: { total: state.lin.total, tierN: state.lin.tierN, tierW: state.lin.tierW },
|
|
@@ -645,7 +721,7 @@ export function apply(ctx, config = {}) {
|
|
|
645
721
|
recentRewards: store.loadJsonl('rewards.jsonl').slice(-20),
|
|
646
722
|
},
|
|
647
723
|
recent: state.decisions.slice(-20),
|
|
648
|
-
note: '切换真实性验证:headerAppends 为本进程追加的 request/header 次数;
|
|
724
|
+
note: '切换真实性验证:headerAppends 为本进程追加的 request/header 次数;stepSwitches 为步骤级升级/降级次数(stepLog 有明细);lastActual 为最近一步真实出网模型',
|
|
649
725
|
}
|
|
650
726
|
},
|
|
651
727
|
})
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-agentic-router",
|
|
3
|
-
"version": "1.
|
|
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
|
|
3
|
+
"version": "1.5.0",
|
|
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, REAL model switching via the session request-header precedence chain, and STEP-LEVEL routing (v1.5.0)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "lhl",
|
package/dsh/package.json
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
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
|
-
}
|
package/dsh/plugin.test.js
DELETED
|
@@ -1,405 +0,0 @@
|
|
|
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.test.js
DELETED
|
@@ -1,218 +0,0 @@
|
|
|
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
|
-
})
|