dsh-agentic-router 1.0.0 → 1.3.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.en.md +24 -3
- package/README.md +21 -3
- package/dsh/index.js +162 -6
- package/dsh/strategies.js +78 -3
- package/package.json +2 -2
package/README.en.md
CHANGED
|
@@ -40,21 +40,42 @@ dsh plugin --profile web add dsh-agentic-router
|
|
|
40
40
|
| `agentic_router_set_mode` | shadow (default) / active (real swaps) / off (bypass) |
|
|
41
41
|
| `agentic_router_force` | force a model id (active mode only); clear=true to release |
|
|
42
42
|
| `agentic_router_reset` | wipe flywheel learning state, keep mode |
|
|
43
|
+
| `agentic_router_set_prices` | set/query per-provider per-model (or per-tier) prices |
|
|
43
44
|
|
|
44
45
|
## Data flywheel (persistence)
|
|
45
46
|
|
|
46
47
|
Directory: `${DSH_HOME:-~/.dsh}/storages/dsh-agentic-router/` (override with `AGENTIC_ROUTER_HOME`)
|
|
47
48
|
|
|
48
49
|
- `decisions.jsonl` — one line per decision: task type/complexity, all four recommendations, meta pick, actual route, swapped?
|
|
49
|
-
- `rewards.jsonl` — one line per reward with breakdown (tool errors/retries/latency) + feature vector; **replayed on startup to restore learning state
|
|
50
|
+
- `rewards.jsonl` — one line per reward with breakdown (tool errors/retries/latency) + feature vector; **replayed on startup to restore learning state**; human-feedback settlements (`feedback-settle` lines) correct the replayed rewards exactly
|
|
51
|
+
- Human feedback: 45s after a turn closes, the plugin polls `messageFeedback` (the Web UI 👍/👎); a thumbs-down costs −0.5, a thumbs-up adds +0.1, applied as an exact correction to the already-applied reward
|
|
50
52
|
- `policy.json` — mode and forced model (atomic write)
|
|
51
53
|
|
|
52
|
-
Reward: clean close starts at 1; tool failures −0.2 each (cap 0.6), model retries −0.2 each (cap 0.4),
|
|
54
|
+
Reward: clean close starts at 1; tool failures −0.2 each (cap 0.6), model retries −0.2 each (cap 0.4),
|
|
55
|
+
**cost** min(0.5, 20 × turn cost in CNY), latency >30s −0.1 / >90s −0.3; errored turns score 0.
|
|
56
|
+
|
|
57
|
+
**Cost signal (token billing)**: the plugin wraps `llm/stream` and accumulates each model
|
|
58
|
+
call's `usage` chunk (input/output/cache-read/reasoning tokens); providers without usage
|
|
59
|
+
fall back to length-based estimates (flagged `estimated`).
|
|
60
|
+
|
|
61
|
+
**Three-level price resolution** (CNY per million tokens): `provider→model` exact price >
|
|
62
|
+
`provider→tier` price > `*` generic tier > conservative fallback. **Unknown providers
|
|
63
|
+
never inherit DeepSeek pricing** — they use the generic tier and carry a `pricingSource`
|
|
64
|
+
audit field. DeepSeek's official pricing ships as default; add other models at runtime via
|
|
65
|
+
the `agentic_router_set_prices` tool (persisted to policy.json) or at install via
|
|
66
|
+
`config.prices`:
|
|
67
|
+
|
|
68
|
+
```js
|
|
69
|
+
{ 'deepseek-official': { 'deepseek-v4-pro': { in: 1, out: 12 } },
|
|
70
|
+
openai: { 'gpt-x-pro': { in: 15, out: 60 } },
|
|
71
|
+
'*': { fast: { in: 1, out: 4 }, mid: { in: 2, out: 8 }, strong: { in: 3, out: 16 } } }
|
|
72
|
+
```
|
|
53
73
|
|
|
54
74
|
## Known limits
|
|
55
75
|
|
|
56
76
|
- Model tiers are recognized by id keywords (flash→fast, pro→strong); real swaps happen only when more than one tier exists
|
|
57
|
-
-
|
|
77
|
+
- Feedback settles after a 45s window (configurable via `settleDelayMs`); unsettled pending entries are lost on restart; late feedback may be attributed to a later turn
|
|
78
|
+
- Feedback only works where the deployment's Web UI actually writes it (this plugin only reads)
|
|
58
79
|
- LinUCB / k-NN need dozens of turns before they graduate into the meta pool
|
|
59
80
|
|
|
60
81
|
## Development
|
package/README.md
CHANGED
|
@@ -40,16 +40,33 @@ dsh plugin --profile web add dsh-agentic-router
|
|
|
40
40
|
| `agentic_router_set_mode` | 切换 shadow(默认)/ active(真切换)/ off(旁路) |
|
|
41
41
|
| `agentic_router_force` | 强制指定模型 id(active 生效),clear=true 清除 |
|
|
42
42
|
| `agentic_router_reset` | 清空飞轮学习状态,保留模式 |
|
|
43
|
+
| `agentic_router_set_prices` | 配置/查询某 provider 某模型(或档位)的价格 |
|
|
43
44
|
|
|
44
45
|
## 数据飞轮(落盘)
|
|
45
46
|
|
|
46
47
|
目录:`${DSH_HOME:-~/.dsh}/storages/dsh-agentic-router/`(`AGENTIC_ROUTER_HOME` 可覆盖)
|
|
47
48
|
|
|
48
49
|
- `decisions.jsonl` — 每条决策:任务类型/复杂度/四专家推荐/元层选择/实际路由/是否切换
|
|
49
|
-
- `rewards.jsonl` — 每条奖励:明细(工具失败/重试/延迟)+
|
|
50
|
+
- `rewards.jsonl` — 每条奖励:明细(工具失败/重试/延迟)+ 特征向量,**重启后重放恢复学习状态**;人类反馈结算(`feedback-settle` 行)在重放时精确修正
|
|
51
|
+
- 人类反馈:回合结束 45s 后查询 `messageFeedback`(Web UI 的 👍/👎),点踩 −0.5、点赞 +0.1,修正已应用的奖励(UCB 均值/EXP3 权重精确回退)
|
|
50
52
|
- `policy.json` — 模式与强制模型(原子写)
|
|
51
53
|
|
|
52
|
-
奖励公式:干净收尾 1 分起,工具失败 −0.2/次(封顶 0.6)、模型重试 −0.2/次(封顶 0.4
|
|
54
|
+
奖励公式:干净收尾 1 分起,工具失败 −0.2/次(封顶 0.6)、模型重试 −0.2/次(封顶 0.4)、
|
|
55
|
+
**成本** min(0.5, 20×回合成本元)、延迟 >30s −0.1 / >90s −0.3;出错回合记 0。
|
|
56
|
+
|
|
57
|
+
**成本信号(token 计费)**:透传 `llm/stream`,累加每个模型调用的 `usage` chunk
|
|
58
|
+
(输入/输出/缓存读/推理 token);无 usage 的 provider 按文本长度估算(标 `estimated`)。
|
|
59
|
+
|
|
60
|
+
**价格表三级解析**(元/百万 token):`provider→model` 精确价 > `provider→档位` 价 >
|
|
61
|
+
`*` 通用档位 > 保守兜底。**未知 provider 绝不套用 DeepSeek 价格**,用通用档位并标记
|
|
62
|
+
`pricingSource`(审计字段)。默认含 DeepSeek 官方口径,接入其他模型用
|
|
63
|
+
`agentic_router_set_prices` 工具配价(持久化到 policy.json),或安装时传 `config.prices`:
|
|
64
|
+
|
|
65
|
+
```js
|
|
66
|
+
{ 'deepseek-official': { 'deepseek-v4-pro': { in: 1, out: 12 } },
|
|
67
|
+
openai: { 'gpt-x-pro': { in: 15, out: 60 } },
|
|
68
|
+
'*': { fast: { in: 1, out: 4 }, mid: { in: 2, out: 8 }, strong: { in: 3, out: 16 } } }
|
|
69
|
+
```
|
|
53
70
|
|
|
54
71
|
## 分类器(规则先验,<1ms)
|
|
55
72
|
|
|
@@ -59,7 +76,8 @@ dsh plugin --profile web add dsh-agentic-router
|
|
|
59
76
|
## 已知边界
|
|
60
77
|
|
|
61
78
|
- 模型档位靠 id 关键词识别(flash→fast、pro→strong);可用模型多于一档时路由才真正切换
|
|
62
|
-
-
|
|
79
|
+
- 反馈结算窗口 45s(可配置 `settleDelayMs`),窗口内无反馈按代理奖励结算;重启会丢失未结算的 pending 条目;晚到的反馈可能归属到其后的回合
|
|
80
|
+
- 反馈依赖部署的 Web UI 实际产生数据(本插件只读取,不写入)
|
|
63
81
|
- 毕业策略(LinUCB/k-NN)需要数十个回合样本才会进元层池
|
|
64
82
|
|
|
65
83
|
## 开发
|
package/dsh/index.js
CHANGED
|
@@ -15,7 +15,8 @@ import { classify, featuresOf } from './classify.js'
|
|
|
15
15
|
import {
|
|
16
16
|
tierOf, tierTarget, resolveTier,
|
|
17
17
|
recommendRule, recommendUcb, recommendLin, recommendKnn,
|
|
18
|
-
rewardOf, applyReward,
|
|
18
|
+
rewardOf, applyReward, correctReward,
|
|
19
|
+
DEFAULT_PRICES, turnCostYuan, resolvePrice,
|
|
19
20
|
} from './strategies.js'
|
|
20
21
|
import { metaPick, DEFAULT_WEIGHTS } from './meta.js'
|
|
21
22
|
import { Store, defaultHome } from './store.js'
|
|
@@ -25,6 +26,8 @@ export const inject = ['tools']
|
|
|
25
26
|
|
|
26
27
|
const LIN_GRADUATION = 50 // LinUCB 毕业样本线
|
|
27
28
|
const KNN_GRADUATION = 30 // k-NN 毕业经验线
|
|
29
|
+
const SETTLE_DELAY_MS = 45000 // 反馈结算窗口:回合结束 45s 后查询 messageFeedback
|
|
30
|
+
const SWEEP_MS = 15000 // 结算轮询周期
|
|
28
31
|
|
|
29
32
|
function freshState() {
|
|
30
33
|
return {
|
|
@@ -36,6 +39,8 @@ function freshState() {
|
|
|
36
39
|
modelTiers: null,
|
|
37
40
|
turnDecision: {},
|
|
38
41
|
openTurns: new Map(),
|
|
42
|
+
pendingFeedback: [],
|
|
43
|
+
recByTurn: new Map(),
|
|
39
44
|
ucb: { total: 0, arms: {} },
|
|
40
45
|
lin: {
|
|
41
46
|
total: 0,
|
|
@@ -124,24 +129,86 @@ async function refreshModelTiers(llm) {
|
|
|
124
129
|
export function apply(ctx, config = {}) {
|
|
125
130
|
const tools = ctx.get('tools')
|
|
126
131
|
const llm = ctx.get('llm')
|
|
132
|
+
const mf = ctx.get('messageFeedback')
|
|
133
|
+
const timer = ctx.get('timer')
|
|
127
134
|
const store = new Store(config.home || defaultHome())
|
|
135
|
+
const settleDelayMs = typeof config.settleDelayMs === 'number' ? config.settleDelayMs : SETTLE_DELAY_MS
|
|
128
136
|
const policy = store.loadPolicy()
|
|
137
|
+
let prices = (policy.prices && Object.keys(policy.prices).length) ? policy.prices : (config.prices || DEFAULT_PRICES)
|
|
138
|
+
const costWeight = typeof config.costWeight === 'number' ? config.costWeight : 20
|
|
139
|
+
const costCap = typeof config.costCap === 'number' ? config.costCap : 0.5
|
|
129
140
|
|
|
130
141
|
const state = freshState()
|
|
131
142
|
state.mode = policy.mode || config.mode || 'shadow'
|
|
132
143
|
if (typeof policy.forced === 'string') state.forced = policy.forced
|
|
133
144
|
|
|
134
|
-
// 飞轮恢复:重放 rewards.jsonl 重建 UCB/LinUCB/k-NN/EXP3
|
|
145
|
+
// 飞轮恢复:重放 rewards.jsonl 重建 UCB/LinUCB/k-NN/EXP3;
|
|
146
|
+
// feedback-settle 行在基础奖励重放后按 turn 精确修正
|
|
135
147
|
const rewards = store.loadJsonl('rewards.jsonl')
|
|
148
|
+
const byTurn = new Map()
|
|
136
149
|
for (const rec of rewards) {
|
|
137
|
-
if (
|
|
150
|
+
if (rec.type === 'feedback-settle') continue
|
|
151
|
+
if (typeof rec.reward === 'number' && Array.isArray(rec.f)) {
|
|
152
|
+
applyReward(state, rec)
|
|
153
|
+
byTurn.set(rec.turn, rec)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
for (const rec of rewards) {
|
|
157
|
+
if (rec.type !== 'feedback-settle') continue
|
|
158
|
+
const baseRec = byTurn.get(rec.turn)
|
|
159
|
+
if (baseRec) correctReward(state, baseRec, rec.newReward)
|
|
138
160
|
}
|
|
161
|
+
state.recByTurn = byTurn
|
|
139
162
|
state.decisions = store.loadJsonl('decisions.jsonl').slice(-60)
|
|
140
163
|
state.seq = state.decisions.length ? state.decisions[state.decisions.length - 1].seq : 0
|
|
141
164
|
|
|
142
165
|
const log = (msg) => console.log('[agentic-router]', msg)
|
|
143
166
|
if (rewards.length) log('replayed ' + rewards.length + ' rewards; ucb.total=' + state.ucb.total)
|
|
144
167
|
|
|
168
|
+
let mfWarned = false
|
|
169
|
+
// 反馈结算:对已过结算窗口的回合查询 messageFeedback,按人类 👍/👎 修正奖励
|
|
170
|
+
async function settleFeedback() {
|
|
171
|
+
if (!mf || typeof mf.list !== 'function') {
|
|
172
|
+
if (!mfWarned) { log('messageFeedback service absent — 反馈奖励停用'); mfWarned = true }
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
const tNow = now()
|
|
176
|
+
if (tNow == null) return
|
|
177
|
+
const remaining = []
|
|
178
|
+
for (const p of state.pendingFeedback) {
|
|
179
|
+
if (tNow - p.t0 < settleDelayMs) { remaining.push(p); continue }
|
|
180
|
+
try {
|
|
181
|
+
const res = await mf.list({ sessionId: p.sessionId })
|
|
182
|
+
let neg = 0
|
|
183
|
+
let pos = 0
|
|
184
|
+
if (res && res.ok === true && res.value && Array.isArray(res.value.items)) {
|
|
185
|
+
for (const it of res.value.items) {
|
|
186
|
+
if (typeof it.createdAt !== 'number' || it.createdAt < p.t0) continue
|
|
187
|
+
if (it.rating === 'negative') neg += 1
|
|
188
|
+
else if (it.rating === 'positive') pos += 1
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const newReward = Math.max(0, Math.min(1, Math.round((p.appliedReward - 0.5 * neg + 0.1 * pos) * 100) / 100))
|
|
192
|
+
if (newReward !== p.appliedReward) {
|
|
193
|
+
const baseRec = state.recByTurn.get(p.key)
|
|
194
|
+
if (baseRec) correctReward(state, baseRec, newReward)
|
|
195
|
+
store.appendJsonl('rewards.jsonl', {
|
|
196
|
+
type: 'feedback-settle', turn: p.key, sessionId: p.sessionId,
|
|
197
|
+
negative: neg, positive: pos, applied: p.appliedReward, newReward,
|
|
198
|
+
})
|
|
199
|
+
log('feedback settle ' + p.key + ': ' + p.appliedReward + ' -> ' + newReward + ' (neg=' + neg + ' pos=' + pos + ')')
|
|
200
|
+
}
|
|
201
|
+
} catch (e) {
|
|
202
|
+
log('feedback settle failed: ' + String(e))
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
state.pendingFeedback = remaining
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (timer && typeof timer.interval === 'function') {
|
|
209
|
+
timer.interval(() => { settleFeedback().catch((e) => log('settle sweep failed: ' + String(e))) }, SWEEP_MS)
|
|
210
|
+
}
|
|
211
|
+
|
|
145
212
|
// ---- 事件挂载 ----
|
|
146
213
|
ctx.on('agent/pre-step', (payload, next) => {
|
|
147
214
|
try {
|
|
@@ -157,6 +224,7 @@ export function apply(ctx, config = {}) {
|
|
|
157
224
|
const cfg = await next()
|
|
158
225
|
if (!cfg || typeof cfg !== 'object') return cfg
|
|
159
226
|
try {
|
|
227
|
+
if (!timer || typeof timer.interval !== 'function') settleFeedback().catch(() => {})
|
|
160
228
|
const key = fullKey(payload)
|
|
161
229
|
if (state.turnDecision[key]) return cfg
|
|
162
230
|
const pkey = agentKey(payload) + ':' + payload.turn
|
|
@@ -209,6 +277,8 @@ export function apply(ctx, config = {}) {
|
|
|
209
277
|
state.turnDecision[key] = {
|
|
210
278
|
clsType: cls.type, f, recs, meta, chosen: meta.chosen,
|
|
211
279
|
finalized: false, t0: now(), toolErrors: 0, retries: 0,
|
|
280
|
+
currentModel: current, provider, costWeight, costCap,
|
|
281
|
+
estInputTokens: Math.ceil(text.length / 1.5),
|
|
212
282
|
}
|
|
213
283
|
state.openTurns.set(agentKey(payload), key)
|
|
214
284
|
store.appendJsonl('decisions.jsonl', record)
|
|
@@ -242,6 +312,52 @@ export function apply(ctx, config = {}) {
|
|
|
242
312
|
return next()
|
|
243
313
|
})
|
|
244
314
|
|
|
315
|
+
// 成本信号:透传每次流式模型调用,累加 usage chunk 到当前 open turn;
|
|
316
|
+
// 无 usage 的 provider 用文本长度估算(estimated=true)
|
|
317
|
+
ctx.on('llm/stream', function (options, next) {
|
|
318
|
+
return (async function* () {
|
|
319
|
+
let usage = null
|
|
320
|
+
let outTextLen = 0
|
|
321
|
+
try {
|
|
322
|
+
for await (const chunk of next()) {
|
|
323
|
+
if (chunk && chunk.type === 'usage' && chunk.usage) usage = chunk.usage
|
|
324
|
+
if (chunk && chunk.type === 'text-delta' && typeof chunk.text === 'string') outTextLen += chunk.text.length
|
|
325
|
+
yield chunk
|
|
326
|
+
}
|
|
327
|
+
} finally {
|
|
328
|
+
try {
|
|
329
|
+
const sid = options && (options.sessionId || (options.session && options.session.id))
|
|
330
|
+
const key = sid ? state.openTurns.get(sid) : null
|
|
331
|
+
const td = key ? state.turnDecision[key] : null
|
|
332
|
+
if (td && !td.finalized) {
|
|
333
|
+
const u = td.usage || { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, reasoningTokens: 0, estimated: false }
|
|
334
|
+
if (usage && typeof usage.inputTokens === 'number') {
|
|
335
|
+
u.inputTokens += usage.inputTokens
|
|
336
|
+
u.outputTokens += usage.outputTokens || 0
|
|
337
|
+
u.cacheReadTokens += usage.cacheReadTokens || 0
|
|
338
|
+
u.reasoningTokens += usage.reasoningTokens || 0
|
|
339
|
+
u.estimated = false
|
|
340
|
+
} else {
|
|
341
|
+
u.inputTokens += td.estInputTokens || 0
|
|
342
|
+
u.outputTokens += Math.round(outTextLen / 2)
|
|
343
|
+
u.estimated = true
|
|
344
|
+
}
|
|
345
|
+
td.usage = u
|
|
346
|
+
const model = typeof td.currentModel === 'string' ? td.currentModel : ''
|
|
347
|
+
const tier = tierOf(model)
|
|
348
|
+
const provider = (options && typeof options.provider === 'string' && options.provider) || td.provider || ''
|
|
349
|
+
const rp = resolvePrice(provider, model, tier, prices)
|
|
350
|
+
td.costYuan = turnCostYuan(u, provider, model, tier, prices)
|
|
351
|
+
td.pricingSource = rp.source
|
|
352
|
+
log('usage on ' + key + ': in=' + u.inputTokens + ' out=' + u.outputTokens + (u.estimated ? ' (估)' : '') + ' cost=¥' + td.costYuan.toFixed(4) + ' [' + rp.source + ']')
|
|
353
|
+
}
|
|
354
|
+
} catch (e) {
|
|
355
|
+
log('usage hook failed: ' + String(e))
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
})()
|
|
359
|
+
})
|
|
360
|
+
|
|
245
361
|
ctx.on('tools/result', (exec, result) => {
|
|
246
362
|
try {
|
|
247
363
|
if (!isErrorResult(result)) return
|
|
@@ -283,6 +399,15 @@ export function apply(ctx, config = {}) {
|
|
|
283
399
|
}
|
|
284
400
|
applyReward(state, rec)
|
|
285
401
|
store.appendJsonl('rewards.jsonl', rec)
|
|
402
|
+
state.recByTurn.set(key, rec)
|
|
403
|
+
if (state.recByTurn.size > 500) {
|
|
404
|
+
const first = state.recByTurn.keys().next().value
|
|
405
|
+
if (first !== undefined) state.recByTurn.delete(first)
|
|
406
|
+
}
|
|
407
|
+
if (mf && typeof mf.list === 'function' && td.t0 != null) {
|
|
408
|
+
state.pendingFeedback.push({ key, sessionId: agentKey(payload), t0: td.t0, appliedReward: rec.reward })
|
|
409
|
+
if (state.pendingFeedback.length > 200) state.pendingFeedback.shift()
|
|
410
|
+
}
|
|
286
411
|
const ak = agentKey(payload)
|
|
287
412
|
if (state.openTurns.get(ak) === key) state.openTurns.delete(ak)
|
|
288
413
|
log('reward ' + rec.reward + ' ' + tag + ' on ' + key + ' breakdown=' + JSON.stringify(r.breakdown))
|
|
@@ -325,11 +450,14 @@ export function apply(ctx, config = {}) {
|
|
|
325
450
|
forcedModel: state.forced,
|
|
326
451
|
totalDecisions: state.seq,
|
|
327
452
|
modelTiers: state.modelTiers,
|
|
453
|
+
prices,
|
|
328
454
|
flywheel: {
|
|
329
455
|
ucb: state.ucb,
|
|
330
456
|
lin: { total: state.lin.total, tierN: state.lin.tierN, tierW: state.lin.tierW },
|
|
331
457
|
knnPoolSize: state.knn.pool.length,
|
|
332
458
|
exp3: state.exp3,
|
|
459
|
+
pendingFeedback: state.pendingFeedback.length,
|
|
460
|
+
prices,
|
|
333
461
|
recentRewards: store.loadJsonl('rewards.jsonl').slice(-20),
|
|
334
462
|
},
|
|
335
463
|
recent: state.decisions.slice(-20),
|
|
@@ -348,7 +476,7 @@ export function apply(ctx, config = {}) {
|
|
|
348
476
|
isConcurrencySafe: () => true,
|
|
349
477
|
async execute(args) {
|
|
350
478
|
state.mode = args.mode
|
|
351
|
-
store.savePolicy({ mode: state.mode, forced: state.forced })
|
|
479
|
+
store.savePolicy({ mode: state.mode, forced: state.forced, prices })
|
|
352
480
|
return { mode: state.mode }
|
|
353
481
|
},
|
|
354
482
|
})
|
|
@@ -367,17 +495,45 @@ export function apply(ctx, config = {}) {
|
|
|
367
495
|
async execute(args) {
|
|
368
496
|
if (args && args.clear) {
|
|
369
497
|
state.forced = null
|
|
370
|
-
store.savePolicy({ mode: state.mode, forced: null })
|
|
498
|
+
store.savePolicy({ mode: state.mode, forced: null, prices })
|
|
371
499
|
return { forced: null }
|
|
372
500
|
}
|
|
373
501
|
if (args && typeof args.model === 'string') {
|
|
374
502
|
state.forced = args.model
|
|
375
|
-
store.savePolicy({ mode: state.mode, forced: args.model })
|
|
503
|
+
store.savePolicy({ mode: state.mode, forced: args.model, prices })
|
|
376
504
|
return { forced: args.model }
|
|
377
505
|
}
|
|
378
506
|
return { forced: state.forced, error: '需要 model 或 clear=true' }
|
|
379
507
|
},
|
|
380
508
|
})
|
|
509
|
+
register({
|
|
510
|
+
name: 'agentic_router_set_prices',
|
|
511
|
+
description: '配置/查询某 provider 某模型(或档位)的价格(元/百万 tokens,{in, out})。不带 priceIn/priceOut 时返回当前价格表。接入新模型后用它补价格,未知价格按保守值估算并标记 unknown。',
|
|
512
|
+
parameters: {
|
|
513
|
+
type: 'object',
|
|
514
|
+
properties: {
|
|
515
|
+
provider: { type: 'string', description: 'provider id,如 openai、anthropic、deepseek-official' },
|
|
516
|
+
model: { type: 'string', description: '可选:精确模型 id;省略则设置该 provider 的档位价' },
|
|
517
|
+
tier: { type: 'string', enum: ['fast', 'mid', 'strong'], description: '档位(未给 model 时必填)' },
|
|
518
|
+
priceIn: { type: 'number', description: '输入价(元/百万 tokens),负数视为查询模式' },
|
|
519
|
+
priceOut: { type: 'number', description: '输出价(元/百万 tokens),负数视为查询模式' },
|
|
520
|
+
},
|
|
521
|
+
required: ['provider'],
|
|
522
|
+
},
|
|
523
|
+
output: OUT('agentic router 价格表'),
|
|
524
|
+
isConcurrencySafe: () => true,
|
|
525
|
+
async execute(args) {
|
|
526
|
+
if (typeof args.priceIn !== 'number' || typeof args.priceOut !== 'number' || args.priceIn < 0 || args.priceOut < 0) {
|
|
527
|
+
return { prices, note: '查看模式:传 priceIn/priceOut 与 model 或 tier 进行设置' }
|
|
528
|
+
}
|
|
529
|
+
const byProvider = prices[args.provider] || (prices[args.provider] = {})
|
|
530
|
+
if (args.model) byProvider[args.model] = { in: args.priceIn, out: args.priceOut }
|
|
531
|
+
else if (args.tier) byProvider[args.tier] = { in: args.priceIn, out: args.priceOut }
|
|
532
|
+
else return { error: '需要 model 或 tier', prices }
|
|
533
|
+
store.savePolicy({ mode: state.mode, forced: state.forced, prices })
|
|
534
|
+
return { prices, updated: args.model || (args.provider + '/' + args.tier) }
|
|
535
|
+
},
|
|
536
|
+
})
|
|
381
537
|
register({
|
|
382
538
|
name: 'agentic_router_reset',
|
|
383
539
|
description: '清空飞轮学习状态(决策与奖励历史),保留当前模式;用于重新开始学习或清理测试数据。',
|
package/dsh/strategies.js
CHANGED
|
@@ -105,28 +105,76 @@ export function recommendKnn(cls, f, knnState) {
|
|
|
105
105
|
return { tier: best.tier, reason: 'knn k=' + k + ' neighbors score=' + Math.round(best.score * 100) / 100, conf: Math.min(0.8, pool.length / 100) }
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
//
|
|
108
|
+
// ---- 成本信号(token 计费) ----
|
|
109
|
+
// 价格表(元/百万 tokens)三级解析:
|
|
110
|
+
// 1) prices[provider][model] 精确模型价(最高优先)
|
|
111
|
+
// 2) prices[provider][tier] 该 provider 的档位价
|
|
112
|
+
// 3) prices['*'][tier] 通用档位兜底(未知 provider 不套用 DeepSeek 价)
|
|
113
|
+
// 4) CONSERVATIVE 保守值 + 标记 unknown,提醒用户补价格
|
|
114
|
+
export const DEFAULT_PRICES = {
|
|
115
|
+
'deepseek-official': {
|
|
116
|
+
'deepseek-v4-flash': { in: 0.2, out: 2 },
|
|
117
|
+
'deepseek-v4-pro': { in: 1, out: 12 },
|
|
118
|
+
},
|
|
119
|
+
'*': {
|
|
120
|
+
fast: { in: 1, out: 4 },
|
|
121
|
+
mid: { in: 2, out: 8 },
|
|
122
|
+
strong: { in: 3, out: 16 },
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const CONSERVATIVE = { in: 3, out: 16 }
|
|
127
|
+
|
|
128
|
+
// 解析价格并返回来源(透明标注:价格到底从哪来的)
|
|
129
|
+
export function resolvePrice(provider, model, tier, prices) {
|
|
130
|
+
const table = prices || DEFAULT_PRICES
|
|
131
|
+
const byProvider = table[provider]
|
|
132
|
+
if (byProvider && byProvider[model]) return { price: byProvider[model], source: 'provider-model' }
|
|
133
|
+
if (byProvider && byProvider[tier]) return { price: byProvider[tier], source: 'provider-tier' }
|
|
134
|
+
const generic = table['*']
|
|
135
|
+
if (generic && generic[tier]) return { price: generic[tier], source: 'generic-tier' }
|
|
136
|
+
return { price: CONSERVATIVE, source: 'unknown-conservative' }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 单回合成本(元):缓存读计入输入,推理 token 计入输出
|
|
140
|
+
export function turnCostYuan(usage, provider, model, tier, prices) {
|
|
141
|
+
if (!usage) return 0
|
|
142
|
+
const { price } = resolvePrice(provider, model, tier, prices)
|
|
143
|
+
const input = (usage.inputTokens || 0) + (usage.cacheReadTokens || 0)
|
|
144
|
+
const output = (usage.outputTokens || 0) + (usage.reasoningTokens || 0)
|
|
145
|
+
return (price.in * input + price.out * output) / 1e6
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 质量代理奖励:干净收尾 1 分起,扣工具失败(0.2/次,封顶 0.6)、模型重试(0.2/次,封顶 0.4)、
|
|
149
|
+
// 延迟(>90s −0.3, >30s −0.1)、成本(min(cap, weight × 元),默认 weight=20 即每 0.01 元 −0.2)
|
|
109
150
|
export function rewardOf(td, tag, dtMs) {
|
|
110
151
|
if (tag !== 'clean-close') {
|
|
111
|
-
return { reward: 0, breakdown: { base: 0, toolErrors: td.toolErrors || 0, retries: td.retries || 0, dtMs } }
|
|
152
|
+
return { reward: 0, breakdown: { base: 0, toolErrors: td.toolErrors || 0, retries: td.retries || 0, dtMs, costYuan: td.costYuan || 0, costPenalty: 0, pricingSource: td.pricingSource || 'n/a' } }
|
|
112
153
|
}
|
|
113
154
|
let reward = 1
|
|
114
155
|
const toolPenalty = Math.min(0.6, 0.2 * (td.toolErrors || 0))
|
|
115
156
|
const retryPenalty = Math.min(0.4, 0.2 * (td.retries || 0))
|
|
157
|
+
const costCap = typeof td.costCap === 'number' ? td.costCap : 0.5
|
|
158
|
+
const costWeight = typeof td.costWeight === 'number' ? td.costWeight : 20
|
|
159
|
+
const costYuan = typeof td.costYuan === 'number' ? td.costYuan : 0
|
|
160
|
+
const costPenalty = Math.min(costCap, costWeight * costYuan)
|
|
116
161
|
reward -= toolPenalty
|
|
117
162
|
reward -= retryPenalty
|
|
163
|
+
reward -= costPenalty
|
|
118
164
|
if (dtMs != null) {
|
|
119
165
|
if (dtMs > 90000) reward -= 0.3
|
|
120
166
|
else if (dtMs > 30000) reward -= 0.1
|
|
121
167
|
}
|
|
122
168
|
return {
|
|
123
169
|
reward: Math.max(0, Math.round(reward * 100) / 100),
|
|
124
|
-
breakdown: { base: 1, toolErrors: td.toolErrors || 0, toolPenalty, retries: td.retries || 0, retryPenalty, dtMs },
|
|
170
|
+
breakdown: { base: 1, toolErrors: td.toolErrors || 0, toolPenalty, retries: td.retries || 0, retryPenalty, costYuan, costPenalty, dtMs, pricingSource: td.pricingSource || 'n/a' },
|
|
125
171
|
}
|
|
126
172
|
}
|
|
127
173
|
|
|
128
174
|
// 把一条奖励记录应用到飞轮(UCB/LinUCB/k-NN/EXP3),replay 与实时更新共用
|
|
129
175
|
export function applyReward(state, rec) {
|
|
176
|
+
// 记录本次线性更新的学习率,供反馈结算修正精确回退
|
|
177
|
+
rec.linLr = 1 / (1 + (state.lin.tierN[rec.linTier] || 0))
|
|
130
178
|
const armKey = rec.clsType + ':' + rec.ucbTier
|
|
131
179
|
const arm = state.ucb.arms[armKey] || (state.ucb.arms[armKey] = { n: 0, mean: 0 })
|
|
132
180
|
arm.n += 1
|
|
@@ -151,3 +199,30 @@ export function applyReward(state, rec) {
|
|
|
151
199
|
}
|
|
152
200
|
state.exp3.total += 1
|
|
153
201
|
}
|
|
202
|
+
|
|
203
|
+
// 反馈结算修正:把已应用奖励改为 newReward(人类在反馈窗口内点了 👍/👎 之后)
|
|
204
|
+
// UCB 均值修正(n 不变);LinUCB 用原始学习率做精确梯度回退;k-NN 追加修正经验;
|
|
205
|
+
// EXP3 按乘性更新做精确逆修正。
|
|
206
|
+
export function correctReward(state, rec, newReward) {
|
|
207
|
+
const delta = newReward - rec.reward
|
|
208
|
+
if (!delta) return { delta: 0 }
|
|
209
|
+
|
|
210
|
+
const arm = state.ucb.arms[rec.clsType + ':' + rec.ucbTier]
|
|
211
|
+
if (arm && arm.n > 0) arm.mean = arm.mean + delta / arm.n
|
|
212
|
+
|
|
213
|
+
const lw = state.lin.tierW[rec.linTier]
|
|
214
|
+
if (lw && Array.isArray(rec.f)) {
|
|
215
|
+
const lr = typeof rec.linLr === 'number' ? rec.linLr : 1 / (1 + (state.lin.tierN[rec.linTier] || 0))
|
|
216
|
+
for (let i = 0; i < rec.f.length; i++) lw[i] = (lw[i] || 0) + lr * delta * rec.f[i]
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (state.knn && Array.isArray(rec.f)) state.knn.pool.push({ f: rec.f, tier: rec.ucbTier, r: newReward })
|
|
220
|
+
|
|
221
|
+
if (rec.pChosen > 0 && rec.pool && rec.pool.length > 0) {
|
|
222
|
+
state.exp3.weights[rec.chosen] = (state.exp3.weights[rec.chosen] || 0) *
|
|
223
|
+
Math.exp(0.3 * delta / (rec.pChosen * rec.pool.length))
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
rec.reward = newReward
|
|
227
|
+
return { delta }
|
|
228
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-agentic-router",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
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",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"LICENSE"
|
|
34
34
|
],
|
|
35
35
|
"scripts": {
|
|
36
|
-
"test": "node --test
|
|
36
|
+
"test": "node --test"
|
|
37
37
|
},
|
|
38
38
|
"dsh": {
|
|
39
39
|
"bundle": {
|