dsh-agentic-router 1.0.0 → 1.4.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 +51 -21
- package/README.md +71 -23
- package/docs/DESIGN.md +3 -1
- package/docs/MAINTAINING.md +74 -0
- package/dsh/index.js +410 -77
- package/dsh/strategies.js +78 -3
- package/package.json +3 -3
package/README.en.md
CHANGED
|
@@ -4,14 +4,16 @@
|
|
|
4
4
|
|
|
5
5
|
DeepSeek Harness (DSH) plugin: a **learning agentic router with a data flywheel**.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
At the start of every turn (`agent/inbox/claimed`) it routes the request to the most appropriate model tier by task type and complexity, asks four experts in parallel and lets an EXP3 meta-selector decide who to trust. Every turn is scored with **quality-proxy signals** (tool failures, model retries, cost, latency) and the reward feeds back into the strategies — the router gets better as it runs. Every decision and reward is persisted locally: auditable and replayable.
|
|
8
|
+
|
|
9
|
+
**Real switching since v1.4.0**: v1.3.0 and earlier swapped models by returning a replaced config from the `agent/request` waterfall, but DSH's built-in model-selection listener overrides that value, so the swap never took effect. v1.4.0 instead appends a `request/header` event (reason: `router`) to the session log at turn start, going through DSH's official model-selection precedence chain (in-process selection → latest logged session `request/header` → default model).
|
|
8
10
|
|
|
9
11
|
## Architecture
|
|
10
12
|
|
|
11
13
|
```
|
|
12
|
-
user input → classify (rules, <1ms) → 4 expert recommendations → EXP3 meta-pick →
|
|
13
|
-
|
|
14
|
-
UCB/LinUCB/k-NN/EXP3 updates ← quality-proxy reward ← turn end (tool errors/retries/latency)
|
|
14
|
+
user input → classify (rules, <1ms) → 4 expert recommendations → EXP3 meta-pick → request/header append (real switch)
|
|
15
|
+
↑ │
|
|
16
|
+
UCB/LinUCB/k-NN/EXP3 updates ← quality-proxy reward ← turn end (tool errors/retries/cost/latency)
|
|
15
17
|
```
|
|
16
18
|
|
|
17
19
|
| Expert | Algorithm | Status |
|
|
@@ -30,7 +32,26 @@ Design rationale and roadmap: [docs/DESIGN.md](./docs/DESIGN.md). The implementa
|
|
|
30
32
|
dsh plugin --profile web add dsh-agentic-router
|
|
31
33
|
```
|
|
32
34
|
|
|
33
|
-
> `--profile` is mandatory. Restart the
|
|
35
|
+
> `--profile` is mandatory. Restart the web profile after installing so the tool schemas join prompt assembly.
|
|
36
|
+
|
|
37
|
+
### Switch to real routing (active)
|
|
38
|
+
|
|
39
|
+
Default mode is **shadow** (records and learns, never swaps). To enable real switching, the easiest way is: after restarting, tell the agent in any DSH session:
|
|
40
|
+
|
|
41
|
+
> Switch the agentic router to active mode
|
|
42
|
+
|
|
43
|
+
The agent calls `agentic_router_set_mode` (mode=active) and persists it to
|
|
44
|
+
`~/.dsh/storages/dsh-agentic-router/policy.json` — it stays active across restarts.
|
|
45
|
+
|
|
46
|
+
Alternatively, start active on first load by adding config in the profile's `cordis.patch.yml`:
|
|
47
|
+
|
|
48
|
+
```yaml
|
|
49
|
+
- id: agentic-router
|
|
50
|
+
config:
|
|
51
|
+
mode: active
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
(Pick one; once a mode has been saved via the tool, `policy.json` takes precedence over `config.mode`.)
|
|
34
55
|
|
|
35
56
|
## Tools
|
|
36
57
|
|
|
@@ -40,36 +61,45 @@ dsh plugin --profile web add dsh-agentic-router
|
|
|
40
61
|
| `agentic_router_set_mode` | shadow (default) / active (real swaps) / off (bypass) |
|
|
41
62
|
| `agentic_router_force` | force a model id (active mode only); clear=true to release |
|
|
42
63
|
| `agentic_router_reset` | wipe flywheel learning state, keep mode |
|
|
64
|
+
| `agentic_router_set_prices` | set/query per-provider per-model (or per-tier) prices |
|
|
43
65
|
|
|
44
66
|
## Data flywheel (persistence)
|
|
45
67
|
|
|
46
68
|
Directory: `${DSH_HOME:-~/.dsh}/storages/dsh-agentic-router/` (override with `AGENTIC_ROUTER_HOME`)
|
|
47
69
|
|
|
48
70
|
- `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
|
|
71
|
+
- `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
|
|
72
|
+
- 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
73
|
- `policy.json` — mode and forced model (atomic write)
|
|
51
74
|
|
|
52
|
-
Reward: clean close starts at 1; tool failures −0.2 each (cap 0.6), model retries −0.2 each (cap 0.4),
|
|
53
|
-
|
|
54
|
-
## Known limits
|
|
75
|
+
Reward: clean close starts at 1; tool failures −0.2 each (cap 0.6), model retries −0.2 each (cap 0.4),
|
|
76
|
+
**cost** min(0.5, 20 × turn cost in CNY), latency >30s −0.1 / >90s −0.3; errored turns score 0.
|
|
55
77
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
78
|
+
**Cost signal (token billing)**: the plugin wraps `llm/stream` and accumulates each model
|
|
79
|
+
call's `usage` chunk (input/output/cache-read/reasoning tokens); providers without usage
|
|
80
|
+
fall back to length-based estimates (flagged `estimated`).
|
|
59
81
|
|
|
60
|
-
|
|
82
|
+
**Three-level price resolution** (CNY per million tokens): `provider→model` exact price >
|
|
83
|
+
`provider→tier` price > `*` generic tier > conservative fallback. **Unknown providers
|
|
84
|
+
never inherit DeepSeek pricing** — they use the generic tier and carry a `pricingSource`
|
|
85
|
+
audit field. DeepSeek's official pricing ships as default; add other models at runtime via
|
|
86
|
+
the `agentic_router_set_prices` tool (persisted to policy.json) or at install via
|
|
87
|
+
`config.prices`:
|
|
61
88
|
|
|
62
|
-
```
|
|
63
|
-
|
|
89
|
+
```js
|
|
90
|
+
{ 'deepseek-official': { 'deepseek-v4-pro': { in: 1, out: 12 } },
|
|
91
|
+
openai: { 'gpt-x-pro': { in: 15, out: 60 } },
|
|
92
|
+
'*': { fast: { in: 1, out: 4 }, mid: { in: 2, out: 8 }, strong: { in: 3, out: 16 } } }
|
|
64
93
|
```
|
|
65
94
|
|
|
66
|
-
##
|
|
95
|
+
## Known limits
|
|
96
|
+
|
|
97
|
+
- Model tiers are recognized by id keywords (flash→fast, pro→strong); real swaps happen only when more than one tier exists
|
|
98
|
+
- 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
|
|
99
|
+
- Feedback only works where the deployment's Web UI actually writes it (this plugin only reads)
|
|
100
|
+
- LinUCB / k-NN need dozens of turns before they graduate into the meta pool
|
|
67
101
|
|
|
68
|
-
|
|
69
|
-
npm publish # requires npm account
|
|
70
|
-
git push origin main # after making the GitHub repo public
|
|
71
|
-
# directory listing: add the dsh-plugin topic, or submit at dshmarketplace.dev/submit
|
|
72
|
-
```
|
|
102
|
+
**Development, testing and release flow (maintainers): see [docs/MAINTAINING.md](./docs/MAINTAINING.md).**
|
|
73
103
|
|
|
74
104
|
## License
|
|
75
105
|
|
package/README.md
CHANGED
|
@@ -4,14 +4,22 @@
|
|
|
4
4
|
|
|
5
5
|
DeepSeek Harness(DSH)插件:**学习型智能路由(agentic router)+ 数据飞轮**。
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
每回合开始时(`agent/inbox/claimed`)按任务类型与复杂度把请求路由到最合适的模型档位;
|
|
8
|
+
四个专家并行推荐、EXP3 元选择器决定听谁的;每回合按**质量代理信号**
|
|
9
|
+
(工具失败、模型重试、成本、延迟)计算奖励并回传,路由策略随使用越学越准。
|
|
10
|
+
全部决策与奖励落盘,可审计、可重放。
|
|
11
|
+
|
|
12
|
+
**v1.4.0 起切换真实生效**:v1.3.0 及之前通过 `agent/request` 返回替换后的配置切模型,
|
|
13
|
+
但该返回值会被 DSH 内置的模型选择监听器强制改回,实际不生效;v1.4.0 改为在回合开始
|
|
14
|
+
向会话日志追加 `request/header` 事件(reason: `router`),走 DSH 官方模型选择优先级链
|
|
15
|
+
(本进程选择 → 会话日志最新 header → 默认模型)真实切换。
|
|
8
16
|
|
|
9
17
|
## 架构
|
|
10
18
|
|
|
11
19
|
```
|
|
12
|
-
用户输入 → 分类(规则,<1ms) → 四专家推荐 → EXP3 元选择 →
|
|
20
|
+
用户输入 → 分类(规则,<1ms) → 四专家推荐 → EXP3 元选择 → request/header 追加(真实切换)
|
|
13
21
|
↑ │
|
|
14
|
-
UCB/LinUCB/k-NN/EXP3 更新 ← 质量代理奖励 ← 回合收尾(
|
|
22
|
+
UCB/LinUCB/k-NN/EXP3 更新 ← 质量代理奖励 ← 回合收尾(工具失败/重试/成本/延迟)
|
|
15
23
|
```
|
|
16
24
|
|
|
17
25
|
| 专家 | 算法 | 状态 |
|
|
@@ -22,7 +30,7 @@ DeepSeek Harness(DSH)插件:**学习型智能路由(agentic router)+
|
|
|
22
30
|
| k-NN | 经验池最近邻(k=5)聚合 | 影子,30 经验毕业 |
|
|
23
31
|
| EXP3 | 元选择器,重要性加权更新 | 常驻,冷启动偏 rule(权重 2.5) |
|
|
24
32
|
|
|
25
|
-
设计细节与路线图见 [docs/DESIGN.md](./docs/DESIGN.md)
|
|
33
|
+
设计细节与路线图见 [docs/DESIGN.md](./docs/DESIGN.md)。
|
|
26
34
|
|
|
27
35
|
## 安装
|
|
28
36
|
|
|
@@ -30,7 +38,43 @@ DeepSeek Harness(DSH)插件:**学习型智能路由(agentic router)+
|
|
|
30
38
|
dsh plugin --profile web add dsh-agentic-router
|
|
31
39
|
```
|
|
32
40
|
|
|
33
|
-
> `--profile`
|
|
41
|
+
> `--profile` 必填。安装后重启 Web,工具 schema 才会进入 prompt 组装。
|
|
42
|
+
|
|
43
|
+
### 切换到真实切换(active)
|
|
44
|
+
|
|
45
|
+
默认是 **shadow 模式**(只记录与学习,不切换模型)。切到真实切换,最省事的方式:重启后在任意 DSH 会话里对 agent 说:
|
|
46
|
+
|
|
47
|
+
> 把 agentic router 切成 active 模式
|
|
48
|
+
|
|
49
|
+
agent 会调用 `agentic_router_set_mode`(mode=active),并把模式持久化到
|
|
50
|
+
`~/.dsh/storages/dsh-agentic-router/policy.json`——**重启后依然 active**,无需每次设置。
|
|
51
|
+
|
|
52
|
+
也可以不走会话、首次启动即 active:在 profile 的 `cordis.patch.yml` 里给
|
|
53
|
+
`agentic-router` 条目加配置:
|
|
54
|
+
|
|
55
|
+
```yaml
|
|
56
|
+
- id: agentic-router
|
|
57
|
+
config:
|
|
58
|
+
mode: active
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
(两种方式选其一;一旦用工具存过模式,`policy.json` 的取值优先于 `config.mode`。)
|
|
62
|
+
|
|
63
|
+
## 切换机制(v1.4.0)
|
|
64
|
+
|
|
65
|
+
DSH 的 agent 每步请求最终由模型选择逻辑决定,读取优先级为:
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
本进程选择 → 会话日志最新 request/header → 默认模型
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- 直接改 `agent/request` waterfall 的返回值:会被 `dsh-agent` 自带监听器强制改回「选定模型」——无效(v1.3.0 及之前的缺陷)
|
|
72
|
+
- 改 `llm/stream`:agent-loop 请求到达时已深度冻结,无法改写
|
|
73
|
+
|
|
74
|
+
v1.4.0 的正确做法:在 `agent/inbox/claimed`(回合开始、prompt 组装之前)向会话日志追加
|
|
75
|
+
`request/header` 事件,下一轮组装自然读到新模型。`agent/request` 只做只读观测
|
|
76
|
+
(记录每步真实出网模型);插件中途热加载时由 `llm/stream` 在循环自身 header 落盘后补一次应用。
|
|
77
|
+
切到 fast/mid 档会去掉 `reasoningEffort`,让目标模型走自身默认 effort。
|
|
34
78
|
|
|
35
79
|
## 工具
|
|
36
80
|
|
|
@@ -40,16 +84,33 @@ dsh plugin --profile web add dsh-agentic-router
|
|
|
40
84
|
| `agentic_router_set_mode` | 切换 shadow(默认)/ active(真切换)/ off(旁路) |
|
|
41
85
|
| `agentic_router_force` | 强制指定模型 id(active 生效),clear=true 清除 |
|
|
42
86
|
| `agentic_router_reset` | 清空飞轮学习状态,保留模式 |
|
|
87
|
+
| `agentic_router_set_prices` | 配置/查询某 provider 某模型(或档位)的价格 |
|
|
43
88
|
|
|
44
89
|
## 数据飞轮(落盘)
|
|
45
90
|
|
|
46
91
|
目录:`${DSH_HOME:-~/.dsh}/storages/dsh-agentic-router/`(`AGENTIC_ROUTER_HOME` 可覆盖)
|
|
47
92
|
|
|
48
93
|
- `decisions.jsonl` — 每条决策:任务类型/复杂度/四专家推荐/元层选择/实际路由/是否切换
|
|
49
|
-
- `rewards.jsonl` — 每条奖励:明细(工具失败/重试/延迟)+
|
|
94
|
+
- `rewards.jsonl` — 每条奖励:明细(工具失败/重试/延迟)+ 特征向量,**重启后重放恢复学习状态**;人类反馈结算(`feedback-settle` 行)在重放时精确修正
|
|
95
|
+
- 人类反馈:回合结束 45s 后查询 `messageFeedback`(Web UI 的 👍/👎),点踩 −0.5、点赞 +0.1,修正已应用的奖励(UCB 均值/EXP3 权重精确回退)
|
|
50
96
|
- `policy.json` — 模式与强制模型(原子写)
|
|
51
97
|
|
|
52
|
-
奖励公式:干净收尾 1 分起,工具失败 −0.2/次(封顶 0.6)、模型重试 −0.2/次(封顶 0.4
|
|
98
|
+
奖励公式:干净收尾 1 分起,工具失败 −0.2/次(封顶 0.6)、模型重试 −0.2/次(封顶 0.4)、
|
|
99
|
+
**成本** min(0.5, 20×回合成本元)、延迟 >30s −0.1 / >90s −0.3;出错回合记 0。
|
|
100
|
+
|
|
101
|
+
**成本信号(token 计费)**:透传 `llm/stream`,累加每个模型调用的 `usage` chunk
|
|
102
|
+
(输入/输出/缓存读/推理 token);无 usage 的 provider 按文本长度估算(标 `estimated`)。
|
|
103
|
+
|
|
104
|
+
**价格表三级解析**(元/百万 token):`provider→model` 精确价 > `provider→档位` 价 >
|
|
105
|
+
`*` 通用档位 > 保守兜底。**未知 provider 绝不套用 DeepSeek 价格**,用通用档位并标记
|
|
106
|
+
`pricingSource`(审计字段)。默认含 DeepSeek 官方口径,接入其他模型用
|
|
107
|
+
`agentic_router_set_prices` 工具配价(持久化到 policy.json),或安装时传 `config.prices`:
|
|
108
|
+
|
|
109
|
+
```js
|
|
110
|
+
{ 'deepseek-official': { 'deepseek-v4-pro': { in: 1, out: 12 } },
|
|
111
|
+
openai: { 'gpt-x-pro': { in: 15, out: 60 } },
|
|
112
|
+
'*': { fast: { in: 1, out: 4 }, mid: { in: 2, out: 8 }, strong: { in: 3, out: 16 } } }
|
|
113
|
+
```
|
|
53
114
|
|
|
54
115
|
## 分类器(规则先验,<1ms)
|
|
55
116
|
|
|
@@ -59,24 +120,11 @@ dsh plugin --profile web add dsh-agentic-router
|
|
|
59
120
|
## 已知边界
|
|
60
121
|
|
|
61
122
|
- 模型档位靠 id 关键词识别(flash→fast、pro→strong);可用模型多于一档时路由才真正切换
|
|
62
|
-
-
|
|
123
|
+
- 反馈结算窗口 45s(可配置 `settleDelayMs`),窗口内无反馈按代理奖励结算;重启会丢失未结算的 pending 条目;晚到的反馈可能归属到其后的回合
|
|
124
|
+
- 反馈依赖部署的 Web UI 实际产生数据(本插件只读取,不写入)
|
|
63
125
|
- 毕业策略(LinUCB/k-NN)需要数十个回合样本才会进元层池
|
|
64
126
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
```sh
|
|
68
|
-
npm test # node --test,零依赖
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
回归用例覆盖实测缺陷:qa 祈使句、mid 档回退、UCB 冷启动轮转、agent 键隔离、奖励 JSON 合法性、飞轮重放恢复等。
|
|
72
|
-
|
|
73
|
-
## 发布
|
|
74
|
-
|
|
75
|
-
```sh
|
|
76
|
-
npm publish # 需 npm 账号
|
|
77
|
-
git push origin main # GitHub 仓库公开后
|
|
78
|
-
# 目录站:给仓库加 dsh-plugin topic 自动收录,或到 dshmarketplace.dev/submit 提交
|
|
79
|
-
```
|
|
127
|
+
**开发、测试与发布流程(维护者)见 [docs/MAINTAINING.md](./docs/MAINTAINING.md)。**
|
|
80
128
|
|
|
81
129
|
## License
|
|
82
130
|
|
package/docs/DESIGN.md
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
> 目标:在 DeepSeek Harness 里做一个**学习型智能路由**插件——监听用户输入,按任务类型与复杂度把请求路由到最合适的模型 / 子 agent / 工具组合;每次路由的**决策与结果全部落盘**,形成数据飞轮,让路由判断随使用越来越准、越来越快。
|
|
4
4
|
|
|
5
|
+
> ⚠️ **v1.4.0 机制修正**:下文 1.1 的 `agent/request` 返回替换配置方案已废弃——实测该返回值会被 `dsh-agent` 内置的模型选择监听器强制改回「选定模型」,不生效。v1.4.0 改为在 `agent/inbox/claimed` 向会话日志追加 `request/header`(reason: `router`),走官方模型选择优先级链真实切换;`agent/request` 降级为只读观测,`llm/stream` 提供中途热加载的兜底应用。实现见 `dsh/index.js` 顶部注释与 README「切换机制」一节。
|
|
6
|
+
|
|
5
7
|
## 0. 一句话架构
|
|
6
8
|
|
|
7
9
|
```
|
|
8
|
-
用户输入 → 特征提取(快) → 路由器决策 →
|
|
10
|
+
用户输入 → 特征提取(快) → 路由器决策 → request/header 追加(真实切换) / 派发子 agent
|
|
9
11
|
↑ ↓
|
|
10
12
|
策略(规则表+学习权重) ← 评分与在线学习 ← 结果回传(质量/成本/延迟落盘)
|
|
11
13
|
```
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# 维护者手册
|
|
2
|
+
|
|
3
|
+
> 读者:本仓库的维护者/贡献者。安装使用者请看根目录 README。
|
|
4
|
+
> 原则:**main 同时是 `github:` 安装源与目录站文档源,不要直接推 main;npm 版本不可变、可精确回滚,是正式发布门禁。**
|
|
5
|
+
|
|
6
|
+
## 一次性设置
|
|
7
|
+
|
|
8
|
+
### 1. GitHub 分支保护
|
|
9
|
+
|
|
10
|
+
仓库 Settings → Branches → Add branch protection rule:
|
|
11
|
+
|
|
12
|
+
- Branch name pattern:`main`
|
|
13
|
+
- ✅ Require a pull request before merging
|
|
14
|
+
- ✅ Require status checks to pass before merging → 选 `ci`
|
|
15
|
+
- ✅ Require linear history(可选,保持历史干净)
|
|
16
|
+
|
|
17
|
+
### 2. CI 发布令牌
|
|
18
|
+
|
|
19
|
+
`.github/workflows/release.yml` 在 `v*` tag 上自动测试并 `npm publish`,需要一个 npm 令牌:
|
|
20
|
+
|
|
21
|
+
1. npmjs.com → Access Tokens → Generate New Token → **Granular Access Token**
|
|
22
|
+
2. 权限选本包 **Read and write**;Token 类型必须选 **Automation**(可绕过 2FA,官方为 CI 设计)
|
|
23
|
+
3. GitHub 仓库 Settings → Secrets and variables → Actions → New repository secret
|
|
24
|
+
- Name:`NPM_TOKEN`
|
|
25
|
+
- Value:`npm_xxxxxxxx...`
|
|
26
|
+
|
|
27
|
+
### 3. 目录站收录
|
|
28
|
+
|
|
29
|
+
仓库 About → Topics 添加 `dsh-plugin`(目录站周期同步自动收录);也可主动到
|
|
30
|
+
<https://dshmarketplace.dev/submit> 提交仓库 URL(人工审核)。
|
|
31
|
+
|
|
32
|
+
## 开发与测试
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
npm test # node --test,零依赖
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
回归用例覆盖实测缺陷:qa 祈使句、mid 档回退、UCB 冷启动轮转、agent 键隔离、
|
|
39
|
+
奖励 JSON 合法性、飞轮重放恢复、反馈结算时序、多 provider 价格解析等。
|
|
40
|
+
|
|
41
|
+
背景:核心逻辑先在真实运行时的动态原型上逐阶段验证(P0 监听落盘 → P1 真实切换 →
|
|
42
|
+
P2 三策略飞轮 → P3 质量/成本/反馈奖励),共发现并修复 8 个实测缺陷后才沉淀为本仓库,
|
|
43
|
+
每个缺陷都对应一条回归测试。改动后请先本地 `npm test` 全绿再开 PR。
|
|
44
|
+
|
|
45
|
+
## 日常发布流程
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
# 1. 功能分支开发(不要直接在 main 提交)
|
|
49
|
+
git checkout -b feat/xxx
|
|
50
|
+
git push -u origin feat/xxx # 开 PR,ci 检查绿了再合并
|
|
51
|
+
|
|
52
|
+
# 2. 合并到 main 后发布
|
|
53
|
+
git checkout main && git pull
|
|
54
|
+
npm version patch # bump 版本 + 自动打 vX.Y.Z tag + 提交
|
|
55
|
+
git push origin main --follow-tags # 推送后 CI 自动跑测试并发布 npm
|
|
56
|
+
|
|
57
|
+
# 重大变更用 minor(npm version minor),不兼容用 major
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## 发布回滚
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
# npm:版本不可变,出问题直接回滚 tag 指向旧版本
|
|
64
|
+
npm dist-tag add dsh-agentic-router@1.2.0 latest
|
|
65
|
+
|
|
66
|
+
# 源码:直接 revert 再走一遍发布流程
|
|
67
|
+
git revert <坏提交> && npm version patch && git push origin main --follow-tags
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## 版本号约定(SemVer)
|
|
71
|
+
|
|
72
|
+
- **patch**:bug 修复、文档、测试(用户无感知)
|
|
73
|
+
- **minor**:新功能(如新增工具、新奖励信号)
|
|
74
|
+
- **major**:破坏性变更(工具改名、数据格式不兼容)
|
package/dsh/index.js
CHANGED
|
@@ -1,21 +1,31 @@
|
|
|
1
1
|
// dsh-agentic-router —— DeepSeek Harness 插件(Host 侧)
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// 学习型智能路由(v1.4.0 修复版):按任务类型与复杂度在模型档位(fast/mid/strong)
|
|
4
|
+
// 之间路由;四个专家(规则表 / UCB / LinUCB / k-NN)并行推荐,EXP3 元选择器决定
|
|
5
|
+
// 听谁的;回合结束时按质量代理信号(工具失败 + 模型重试 + 成本 + 延迟)计算奖励
|
|
6
|
+
// 并回传飞轮;人类 👍/👎 反馈在结算窗口后修正奖励。
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
8
|
+
// ── 切换机制(v1.4.0 修复)────────────────────────────────────────────
|
|
9
|
+
// v1.3.0 及之前通过 `agent/request` waterfall 返回替换后的 LlmCallConfig 来切模型,
|
|
10
|
+
// 但该返回值会被 dsh-agent 内置的模型选择监听器强制改回「选定模型」,实际不生效。
|
|
11
|
+
// 正确做法是走 dsh 官方的模型选择优先级链:
|
|
12
|
+
//
|
|
13
|
+
// 本进程选择 → 会话日志最新 request/header → 默认模型
|
|
14
|
+
//
|
|
15
|
+
// 在 `agent/inbox/claimed`(回合开始、prompt 组装之前)向会话日志追加一条
|
|
16
|
+
// `request/header` 事件(reason: 'router'),下一轮组装就会读到新模型,真正生效。
|
|
17
|
+
// `agent/request` 只做只读观测(记录每步实际出网模型);插件中途热加载时,
|
|
18
|
+
// `llm/stream` 在循环自身 header 落盘后补一次应用(deferred apply)。
|
|
19
|
+
//
|
|
20
|
+
// 其余架构依据 docs/architecture.md;所有监听经 ctx.on 注册,随插件卸载自动回收。
|
|
12
21
|
// 飞轮数据落盘 ${DSH_HOME:-~/.dsh}/storages/dsh-agentic-router/,重启后重放恢复。
|
|
13
22
|
|
|
14
23
|
import { classify, featuresOf } from './classify.js'
|
|
15
24
|
import {
|
|
16
25
|
tierOf, tierTarget, resolveTier,
|
|
17
26
|
recommendRule, recommendUcb, recommendLin, recommendKnn,
|
|
18
|
-
rewardOf, applyReward,
|
|
27
|
+
rewardOf, applyReward, correctReward,
|
|
28
|
+
DEFAULT_PRICES, turnCostYuan, resolvePrice,
|
|
19
29
|
} from './strategies.js'
|
|
20
30
|
import { metaPick, DEFAULT_WEIGHTS } from './meta.js'
|
|
21
31
|
import { Store, defaultHome } from './store.js'
|
|
@@ -25,6 +35,8 @@ export const inject = ['tools']
|
|
|
25
35
|
|
|
26
36
|
const LIN_GRADUATION = 50 // LinUCB 毕业样本线
|
|
27
37
|
const KNN_GRADUATION = 30 // k-NN 毕业经验线
|
|
38
|
+
const SETTLE_DELAY_MS = 45000 // 反馈结算窗口:回合结束 45s 后查询 messageFeedback
|
|
39
|
+
const SWEEP_MS = 15000 // 结算轮询周期
|
|
28
40
|
|
|
29
41
|
function freshState() {
|
|
30
42
|
return {
|
|
@@ -36,6 +48,10 @@ function freshState() {
|
|
|
36
48
|
modelTiers: null,
|
|
37
49
|
turnDecision: {},
|
|
38
50
|
openTurns: new Map(),
|
|
51
|
+
pendingFeedback: [],
|
|
52
|
+
recByTurn: new Map(),
|
|
53
|
+
headerAppends: 0,
|
|
54
|
+
lastActual: null,
|
|
39
55
|
ucb: { total: 0, arms: {} },
|
|
40
56
|
lin: {
|
|
41
57
|
total: 0,
|
|
@@ -82,6 +98,20 @@ function textOfMessages(messages) {
|
|
|
82
98
|
return parts.join('\n').trim()
|
|
83
99
|
}
|
|
84
100
|
|
|
101
|
+
function extractText(message) {
|
|
102
|
+
if (message == null) return ''
|
|
103
|
+
if (typeof message.text === 'string') return message.text
|
|
104
|
+
const blocks = message.content
|
|
105
|
+
if (Array.isArray(blocks)) {
|
|
106
|
+
let out = ''
|
|
107
|
+
for (const b of blocks) {
|
|
108
|
+
if (b && typeof b === 'object' && b.type === 'text' && typeof b.text === 'string') out += b.text + '\n'
|
|
109
|
+
}
|
|
110
|
+
return out
|
|
111
|
+
}
|
|
112
|
+
return ''
|
|
113
|
+
}
|
|
114
|
+
|
|
85
115
|
function isErrorResult(r) {
|
|
86
116
|
if (r == null || typeof r !== 'object') return false
|
|
87
117
|
if (r.ok === false || r.success === false) return true
|
|
@@ -105,6 +135,57 @@ function execAgentKey(exec) {
|
|
|
105
135
|
return null
|
|
106
136
|
}
|
|
107
137
|
|
|
138
|
+
// 会话句柄:agent 的 session(inbox / request 路径)
|
|
139
|
+
function sessionOf(payload) {
|
|
140
|
+
try {
|
|
141
|
+
const a = payload && payload.agent
|
|
142
|
+
const s = a && a.session
|
|
143
|
+
if (s && typeof s.append === 'function' && typeof s.requestHeader === 'function' && typeof s.id === 'string') return s
|
|
144
|
+
} catch {}
|
|
145
|
+
return null
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function headerConfig(session) {
|
|
149
|
+
try {
|
|
150
|
+
const h = session.requestHeader()
|
|
151
|
+
if (h && h.config && typeof h.config === 'object') return h.config
|
|
152
|
+
} catch {}
|
|
153
|
+
return null
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function currentModel(session) {
|
|
157
|
+
const c = headerConfig(session)
|
|
158
|
+
return c && typeof c.model === 'string' ? c.model : null
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function currentProvider(session) {
|
|
162
|
+
const c = headerConfig(session)
|
|
163
|
+
return c && typeof c.provider === 'string' ? c.provider : ''
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 核心切换:向会话日志追加 request/header(reason: 'router'),
|
|
167
|
+
// 下一轮 prompt 组装即读到新模型。fast/mid 档去掉 reasoningEffort,
|
|
168
|
+
// 让目标模型走自身默认 effort(避免 flash 不支持 max 而整步失败)。
|
|
169
|
+
function appendRouterHeader(session, targetModel) {
|
|
170
|
+
try {
|
|
171
|
+
const last = session.requestHeader()
|
|
172
|
+
const header = {}
|
|
173
|
+
if (last && typeof last === 'object') {
|
|
174
|
+
for (const k of Object.keys(last)) header[k] = last[k]
|
|
175
|
+
}
|
|
176
|
+
const prevConfig = header.config && typeof header.config === 'object' ? header.config : {}
|
|
177
|
+
const config = {}
|
|
178
|
+
for (const k of Object.keys(prevConfig)) config[k] = prevConfig[k]
|
|
179
|
+
config.model = targetModel
|
|
180
|
+
if (tierOf(targetModel) !== 'strong') delete config.reasoningEffort
|
|
181
|
+
header.config = config
|
|
182
|
+
session.append('request/header', { header, reason: 'router' })
|
|
183
|
+
return true
|
|
184
|
+
} catch (e) {
|
|
185
|
+
return false
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
108
189
|
async function refreshModelTiers(llm) {
|
|
109
190
|
if (!llm || typeof llm.listProviders !== 'function') return null
|
|
110
191
|
const providers = await llm.listProviders()
|
|
@@ -124,25 +205,197 @@ async function refreshModelTiers(llm) {
|
|
|
124
205
|
export function apply(ctx, config = {}) {
|
|
125
206
|
const tools = ctx.get('tools')
|
|
126
207
|
const llm = ctx.get('llm')
|
|
208
|
+
const mf = ctx.get('messageFeedback')
|
|
209
|
+
const timer = ctx.get('timer')
|
|
127
210
|
const store = new Store(config.home || defaultHome())
|
|
211
|
+
const settleDelayMs = typeof config.settleDelayMs === 'number' ? config.settleDelayMs : SETTLE_DELAY_MS
|
|
128
212
|
const policy = store.loadPolicy()
|
|
213
|
+
let prices = (policy.prices && Object.keys(policy.prices).length) ? policy.prices : (config.prices || DEFAULT_PRICES)
|
|
214
|
+
const costWeight = typeof config.costWeight === 'number' ? config.costWeight : 20
|
|
215
|
+
const costCap = typeof config.costCap === 'number' ? config.costCap : 0.5
|
|
129
216
|
|
|
130
217
|
const state = freshState()
|
|
131
218
|
state.mode = policy.mode || config.mode || 'shadow'
|
|
132
219
|
if (typeof policy.forced === 'string') state.forced = policy.forced
|
|
133
220
|
|
|
134
|
-
// 飞轮恢复:重放 rewards.jsonl 重建 UCB/LinUCB/k-NN/EXP3
|
|
221
|
+
// 飞轮恢复:重放 rewards.jsonl 重建 UCB/LinUCB/k-NN/EXP3;
|
|
222
|
+
// feedback-settle 行在基础奖励重放后按 turn 精确修正
|
|
135
223
|
const rewards = store.loadJsonl('rewards.jsonl')
|
|
224
|
+
const byTurn = new Map()
|
|
225
|
+
for (const rec of rewards) {
|
|
226
|
+
if (rec.type === 'feedback-settle') continue
|
|
227
|
+
if (typeof rec.reward === 'number' && Array.isArray(rec.f)) {
|
|
228
|
+
applyReward(state, rec)
|
|
229
|
+
byTurn.set(rec.turn, rec)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
136
232
|
for (const rec of rewards) {
|
|
137
|
-
if (
|
|
233
|
+
if (rec.type !== 'feedback-settle') continue
|
|
234
|
+
const baseRec = byTurn.get(rec.turn)
|
|
235
|
+
if (baseRec) correctReward(state, baseRec, rec.newReward)
|
|
138
236
|
}
|
|
237
|
+
state.recByTurn = byTurn
|
|
139
238
|
state.decisions = store.loadJsonl('decisions.jsonl').slice(-60)
|
|
140
239
|
state.seq = state.decisions.length ? state.decisions[state.decisions.length - 1].seq : 0
|
|
141
240
|
|
|
142
241
|
const log = (msg) => console.log('[agentic-router]', msg)
|
|
143
242
|
if (rewards.length) log('replayed ' + rewards.length + ' rewards; ucb.total=' + state.ucb.total)
|
|
144
243
|
|
|
244
|
+
let mfWarned = false
|
|
245
|
+
// 反馈结算:对已过结算窗口的回合查询 messageFeedback,按人类 👍/👎 修正奖励
|
|
246
|
+
async function settleFeedback() {
|
|
247
|
+
if (!mf || typeof mf.list !== 'function') {
|
|
248
|
+
if (!mfWarned) { log('messageFeedback service absent — 反馈奖励停用'); mfWarned = true }
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
const tNow = now()
|
|
252
|
+
if (tNow == null) return
|
|
253
|
+
const remaining = []
|
|
254
|
+
for (const p of state.pendingFeedback) {
|
|
255
|
+
if (tNow - p.t0 < settleDelayMs) { remaining.push(p); continue }
|
|
256
|
+
try {
|
|
257
|
+
const res = await mf.list({ sessionId: p.sessionId })
|
|
258
|
+
let neg = 0
|
|
259
|
+
let pos = 0
|
|
260
|
+
if (res && res.ok === true && res.value && Array.isArray(res.value.items)) {
|
|
261
|
+
for (const it of res.value.items) {
|
|
262
|
+
if (typeof it.createdAt !== 'number' || it.createdAt < p.t0) continue
|
|
263
|
+
if (it.rating === 'negative') neg += 1
|
|
264
|
+
else if (it.rating === 'positive') pos += 1
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const newReward = Math.max(0, Math.min(1, Math.round((p.appliedReward - 0.5 * neg + 0.1 * pos) * 100) / 100))
|
|
268
|
+
if (newReward !== p.appliedReward) {
|
|
269
|
+
const baseRec = state.recByTurn.get(p.key)
|
|
270
|
+
if (baseRec) correctReward(state, baseRec, newReward)
|
|
271
|
+
store.appendJsonl('rewards.jsonl', {
|
|
272
|
+
type: 'feedback-settle', turn: p.key, sessionId: p.sessionId,
|
|
273
|
+
negative: neg, positive: pos, applied: p.appliedReward, newReward,
|
|
274
|
+
})
|
|
275
|
+
log('feedback settle ' + p.key + ': ' + p.appliedReward + ' -> ' + newReward + ' (neg=' + neg + ' pos=' + pos + ')')
|
|
276
|
+
}
|
|
277
|
+
} catch (e) {
|
|
278
|
+
log('feedback settle failed: ' + String(e))
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
state.pendingFeedback = remaining
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (timer && typeof timer.interval === 'function') {
|
|
285
|
+
timer.interval(() => { settleFeedback().catch((e) => log('settle sweep failed: ' + String(e))) }, SWEEP_MS)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ---- 决策流水线(inbox 与 request 兜底共用) ----
|
|
289
|
+
// 返回 { key, td, record, target };已决策过本回合则返回 null。
|
|
290
|
+
// 档位表异步加载;决策前等待就绪(tiers-not-ready 会拿不到 target)。
|
|
291
|
+
async function decideAndRecord(payload, text, current, provider, source) {
|
|
292
|
+
if (!state.modelTiers) {
|
|
293
|
+
try { await tiersReady } catch (e) { /* keep null */ }
|
|
294
|
+
}
|
|
295
|
+
const key = fullKey(payload)
|
|
296
|
+
if (state.turnDecision[key]) return null
|
|
297
|
+
const cls = classify(text)
|
|
298
|
+
const f = featuresOf(cls, text)
|
|
299
|
+
const recs = {
|
|
300
|
+
rule: recommendRule(cls),
|
|
301
|
+
ucb: recommendUcb(cls, state.ucb),
|
|
302
|
+
lin: recommendLin(cls, f, state.lin),
|
|
303
|
+
knn: recommendKnn(cls, f, state.knn),
|
|
304
|
+
}
|
|
305
|
+
if (state.forced) recs.rule = { tier: 'forced', reason: 'forced', conf: 1 }
|
|
306
|
+
const exp3 = {
|
|
307
|
+
...state.exp3,
|
|
308
|
+
linGraduated: state.lin.total >= LIN_GRADUATION,
|
|
309
|
+
knnGraduated: state.knn.pool.length >= KNN_GRADUATION,
|
|
310
|
+
}
|
|
311
|
+
const meta = metaPick(recs, exp3)
|
|
312
|
+
const chosenRec = meta.byName[meta.chosen] || recs.rule
|
|
313
|
+
const resolved = state.forced
|
|
314
|
+
? { target: state.forced, used: 'forced', note: 'forced' }
|
|
315
|
+
: resolveTier(chosenRec.tier, provider, state.modelTiers)
|
|
316
|
+
const target = resolved.target
|
|
317
|
+
state.seq += 1
|
|
318
|
+
const td = {
|
|
319
|
+
clsType: cls.type, f, recs, meta, chosen: meta.chosen,
|
|
320
|
+
finalized: false, t0: now(), toolErrors: 0, retries: 0,
|
|
321
|
+
currentModel: current, provider, costWeight, costCap,
|
|
322
|
+
estInputTokens: Math.ceil(text.length / 1.5),
|
|
323
|
+
target, applied: false, needsApply: false, actualModel: current,
|
|
324
|
+
}
|
|
325
|
+
const record = {
|
|
326
|
+
seq: state.seq,
|
|
327
|
+
turn: key,
|
|
328
|
+
mode: state.mode,
|
|
329
|
+
taskType: cls.type,
|
|
330
|
+
complexity: cls.complexity,
|
|
331
|
+
inputPreview: text.slice(0, 100),
|
|
332
|
+
currentModel: current,
|
|
333
|
+
recs: {
|
|
334
|
+
rule: recs.rule.tier + '(' + recs.rule.conf + ')',
|
|
335
|
+
ucb: recs.ucb.tier + '(' + recs.ucb.conf + ')',
|
|
336
|
+
lin: recs.lin.tier + '(' + recs.lin.conf + ')',
|
|
337
|
+
knn: recs.knn.tier + '(' + recs.knn.conf + ')',
|
|
338
|
+
},
|
|
339
|
+
metaChosen: meta.chosen,
|
|
340
|
+
metaProbs: meta.ps,
|
|
341
|
+
metaPool: meta.pool,
|
|
342
|
+
resolvedTier: resolved.used + (resolved.note ? ' [' + resolved.note + ']' : ''),
|
|
343
|
+
targetModel: state.mode === 'active' ? target : null,
|
|
344
|
+
applied: false,
|
|
345
|
+
appliedAt: source,
|
|
346
|
+
swapped: false,
|
|
347
|
+
}
|
|
348
|
+
td.rec = record
|
|
349
|
+
state.turnDecision[key] = td
|
|
350
|
+
state.openTurns.set(agentKey(payload), key)
|
|
351
|
+
return { key, td, record, target }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// 决策记录落盘(在 applyDecision 之后调用,保证 applied/swapped 已是最终值)
|
|
355
|
+
function commitRecord(record) {
|
|
356
|
+
store.appendJsonl('decisions.jsonl', record)
|
|
357
|
+
state.decisions.push(record)
|
|
358
|
+
if (state.decisions.length > 200) state.decisions.shift()
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// 应用切换:active 模式且目标与当前不同 → 追加 request/header。
|
|
362
|
+
// 返回是否真实追加成功。
|
|
363
|
+
function applyDecision(session, key, td, record, target) {
|
|
364
|
+
if (state.mode !== 'active' || !target) return false
|
|
365
|
+
const base = td.actualModel || td.currentModel
|
|
366
|
+
if (base && target === base) return false
|
|
367
|
+
const ok = session ? appendRouterHeader(session, target) : false
|
|
368
|
+
td.applied = ok
|
|
369
|
+
if (record) { record.applied = ok; record.swapped = ok }
|
|
370
|
+
if (ok) {
|
|
371
|
+
state.headerAppends++
|
|
372
|
+
log('SWAP -> ' + target + ' | ' + td.chosen + ' | ' + (td.recs && td.recs[td.chosen] ? td.recs[td.chosen].reason : ''))
|
|
373
|
+
}
|
|
374
|
+
return ok
|
|
375
|
+
}
|
|
376
|
+
|
|
145
377
|
// ---- 事件挂载 ----
|
|
378
|
+
// 回合开始(prompt 组装之前):捕获用户消息并决策,active 模式立即应用切换。
|
|
379
|
+
// 这是主路径——零延迟,回合第一步就按目标模型执行。
|
|
380
|
+
ctx.on('agent/inbox/claimed', async (payload) => {
|
|
381
|
+
try {
|
|
382
|
+
const session = sessionOf(payload)
|
|
383
|
+
const text = extractText(payload && payload.message)
|
|
384
|
+
if (!text) return
|
|
385
|
+
const current = session ? currentModel(session) : null
|
|
386
|
+
const provider = session ? currentProvider(session) : ''
|
|
387
|
+
const r = await decideAndRecord(payload, text, current, provider, 'inbox')
|
|
388
|
+
if (!r) return
|
|
389
|
+
applyDecision(session, r.key, r.td, r.record, r.target)
|
|
390
|
+
commitRecord(r.record)
|
|
391
|
+
log('decision #' + state.seq + ' type=' + r.td.clsType + ' c=' + r.td.f[1] +
|
|
392
|
+
' meta=' + r.td.chosen + ' tier=' + r.record.resolvedTier +
|
|
393
|
+
' applied=' + r.td.applied + ' target=' + (r.target || '-'))
|
|
394
|
+
} catch (e) {
|
|
395
|
+
log('inbox decide failed: ' + String(e))
|
|
396
|
+
}
|
|
397
|
+
})
|
|
398
|
+
|
|
146
399
|
ctx.on('agent/pre-step', (payload, next) => {
|
|
147
400
|
try {
|
|
148
401
|
const text = textOfMessages(payload && payload.messages)
|
|
@@ -153,76 +406,35 @@ export function apply(ctx, config = {}) {
|
|
|
153
406
|
return next()
|
|
154
407
|
})
|
|
155
408
|
|
|
409
|
+
// 只读观测 + 兜底决策。观测:记录每步真实出网模型(供 stats 验证切换生效)。
|
|
410
|
+
// 兜底:inbox 未触发(插件中途热加载)时,在首个请求用 pending 文本决策,
|
|
411
|
+
// 但不再替换配置——改由 llm/stream 在循环 header 落盘后补应用。
|
|
156
412
|
ctx.on('agent/request', async (payload, next) => {
|
|
157
413
|
const cfg = await next()
|
|
158
414
|
if (!cfg || typeof cfg !== 'object') return cfg
|
|
159
415
|
try {
|
|
416
|
+
if (!timer || typeof timer.interval !== 'function') settleFeedback().catch(() => {})
|
|
160
417
|
const key = fullKey(payload)
|
|
161
|
-
|
|
418
|
+
const model = typeof cfg.model === 'string' ? cfg.model : null
|
|
419
|
+
const td = state.turnDecision[key]
|
|
420
|
+
if (td) {
|
|
421
|
+
if (model) {
|
|
422
|
+
td.actualModel = model
|
|
423
|
+
state.lastActual = { model, turn: payload.turn, step: payload.step, key }
|
|
424
|
+
}
|
|
425
|
+
return cfg
|
|
426
|
+
}
|
|
162
427
|
const pkey = agentKey(payload) + ':' + payload.turn
|
|
163
428
|
const text = state.pending.get(pkey) || ''
|
|
164
429
|
state.pending.delete(pkey)
|
|
165
|
-
const cls = classify(text)
|
|
166
|
-
const f = featuresOf(cls, text)
|
|
167
|
-
const current = typeof cfg.model === 'string' ? cfg.model : '(unknown)'
|
|
168
430
|
const provider = typeof cfg.provider === 'string' ? cfg.provider : ''
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
if (state.forced) recs.rule = { tier: 'forced', reason: 'forced', conf: 1 }
|
|
176
|
-
const exp3 = {
|
|
177
|
-
...state.exp3,
|
|
178
|
-
linGraduated: state.lin.total >= LIN_GRADUATION,
|
|
179
|
-
knnGraduated: state.knn.pool.length >= KNN_GRADUATION,
|
|
431
|
+
const r = await decideAndRecord(payload, text, model, provider, 'request')
|
|
432
|
+
if (r) {
|
|
433
|
+
r.td.needsApply = state.mode === 'active' && r.target && r.target !== r.td.currentModel
|
|
434
|
+
commitRecord(r.record)
|
|
435
|
+
if (r.td.needsApply) log('decision #' + state.seq + ' -> apply deferred to llm/stream')
|
|
436
|
+
else log('decision #' + state.seq + ' type=' + r.td.clsType + ' meta=' + r.td.chosen + ' tier=' + r.record.resolvedTier)
|
|
180
437
|
}
|
|
181
|
-
const meta = metaPick(recs, exp3)
|
|
182
|
-
const chosenRec = meta.byName[meta.chosen] || recs.rule
|
|
183
|
-
const resolved = state.forced
|
|
184
|
-
? { target: state.forced, used: 'forced', note: 'forced' }
|
|
185
|
-
: resolveTier(chosenRec.tier, provider, state.modelTiers)
|
|
186
|
-
const target = resolved.target
|
|
187
|
-
state.seq += 1
|
|
188
|
-
const record = {
|
|
189
|
-
seq: state.seq,
|
|
190
|
-
turn: key,
|
|
191
|
-
mode: state.mode,
|
|
192
|
-
taskType: cls.type,
|
|
193
|
-
complexity: cls.complexity,
|
|
194
|
-
inputPreview: text.slice(0, 100),
|
|
195
|
-
currentModel: current,
|
|
196
|
-
recs: {
|
|
197
|
-
rule: recs.rule.tier + '(' + recs.rule.conf + ')',
|
|
198
|
-
ucb: recs.ucb.tier + '(' + recs.ucb.conf + ')',
|
|
199
|
-
lin: recs.lin.tier + '(' + recs.lin.conf + ')',
|
|
200
|
-
knn: recs.knn.tier + '(' + recs.knn.conf + ')',
|
|
201
|
-
},
|
|
202
|
-
metaChosen: meta.chosen,
|
|
203
|
-
metaProbs: meta.ps,
|
|
204
|
-
metaPool: meta.pool,
|
|
205
|
-
resolvedTier: resolved.used + (resolved.note ? ' [' + resolved.note + ']' : ''),
|
|
206
|
-
targetModel: state.mode === 'active' ? target : null,
|
|
207
|
-
swapped: false,
|
|
208
|
-
}
|
|
209
|
-
state.turnDecision[key] = {
|
|
210
|
-
clsType: cls.type, f, recs, meta, chosen: meta.chosen,
|
|
211
|
-
finalized: false, t0: now(), toolErrors: 0, retries: 0,
|
|
212
|
-
}
|
|
213
|
-
state.openTurns.set(agentKey(payload), key)
|
|
214
|
-
store.appendJsonl('decisions.jsonl', record)
|
|
215
|
-
if (state.mode === 'active' && target && target !== current) {
|
|
216
|
-
record.swapped = true
|
|
217
|
-
state.decisions.push(record)
|
|
218
|
-
if (state.decisions.length > 200) state.decisions.shift()
|
|
219
|
-
log('SWAP -> ' + target + ' | ' + meta.chosen + ' | ' + chosenRec.reason + ' | ' + resolved.note)
|
|
220
|
-
return { ...cfg, model: target }
|
|
221
|
-
}
|
|
222
|
-
state.decisions.push(record)
|
|
223
|
-
if (state.decisions.length > 200) state.decisions.shift()
|
|
224
|
-
log('decision #' + state.seq + ' type=' + cls.type + ' c=' + cls.complexity +
|
|
225
|
-
' meta=' + meta.chosen + ' tier=' + resolved.used + ' recs=' + JSON.stringify(record.recs))
|
|
226
438
|
} catch (e) {
|
|
227
439
|
log('decide failed: ' + String(e))
|
|
228
440
|
}
|
|
@@ -242,6 +454,62 @@ export function apply(ctx, config = {}) {
|
|
|
242
454
|
return next()
|
|
243
455
|
})
|
|
244
456
|
|
|
457
|
+
// 成本信号:透传每次流式模型调用,累加 usage chunk 到当前 open turn;
|
|
458
|
+
// 无 usage 的 provider 用文本长度估算(estimated=true)。
|
|
459
|
+
// 同时:request 路径的兜底决策在这里补应用切换(循环自身 header 已落盘,无竞态)。
|
|
460
|
+
ctx.on('llm/stream', function (options, next) {
|
|
461
|
+
return (async function* () {
|
|
462
|
+
let usage = null
|
|
463
|
+
let outTextLen = 0
|
|
464
|
+
try {
|
|
465
|
+
for await (const chunk of next()) {
|
|
466
|
+
if (chunk && chunk.type === 'usage' && chunk.usage) usage = chunk.usage
|
|
467
|
+
if (chunk && chunk.type === 'text-delta' && typeof chunk.text === 'string') outTextLen += chunk.text.length
|
|
468
|
+
yield chunk
|
|
469
|
+
}
|
|
470
|
+
} finally {
|
|
471
|
+
try {
|
|
472
|
+
const sid = options && (options.sessionId || (options.session && options.session.id))
|
|
473
|
+
const key = sid ? state.openTurns.get(sid) : null
|
|
474
|
+
const td = key ? state.turnDecision[key] : null
|
|
475
|
+
if (td && !td.finalized) {
|
|
476
|
+
// 兜底应用:request 路径决策未应用时,在 header 落盘后补切换
|
|
477
|
+
if (td.needsApply && !td.applied) {
|
|
478
|
+
let session = null
|
|
479
|
+
try {
|
|
480
|
+
const sessions = ctx.get('sessions')
|
|
481
|
+
if (sessions && typeof sessions.get === 'function') session = sessions.get(sid) || null
|
|
482
|
+
} catch (e) { session = null }
|
|
483
|
+
if (session) applyDecision(session, key, td, td.rec, td.target)
|
|
484
|
+
}
|
|
485
|
+
const u = td.usage || { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, reasoningTokens: 0, estimated: false }
|
|
486
|
+
const model = (options && typeof options.model === 'string' && options.model) || td.currentModel || ''
|
|
487
|
+
const tier = tierOf(model)
|
|
488
|
+
const provider = (options && typeof options.provider === 'string' && options.provider) || td.provider || ''
|
|
489
|
+
if (usage && typeof usage.inputTokens === 'number') {
|
|
490
|
+
u.inputTokens += usage.inputTokens
|
|
491
|
+
u.outputTokens += usage.outputTokens || 0
|
|
492
|
+
u.cacheReadTokens += usage.cacheReadTokens || 0
|
|
493
|
+
u.reasoningTokens += usage.reasoningTokens || 0
|
|
494
|
+
u.estimated = false
|
|
495
|
+
} else {
|
|
496
|
+
u.inputTokens += td.estInputTokens || 0
|
|
497
|
+
u.outputTokens += Math.round(outTextLen / 2)
|
|
498
|
+
u.estimated = true
|
|
499
|
+
}
|
|
500
|
+
td.usage = u
|
|
501
|
+
const rp = resolvePrice(provider, model, tier, prices)
|
|
502
|
+
td.costYuan = turnCostYuan(u, provider, model, tier, prices)
|
|
503
|
+
td.pricingSource = rp.source
|
|
504
|
+
log('usage on ' + key + ': in=' + u.inputTokens + ' out=' + u.outputTokens + (u.estimated ? ' (估)' : '') + ' cost=¥' + td.costYuan.toFixed(4) + ' [' + rp.source + ']')
|
|
505
|
+
}
|
|
506
|
+
} catch (e) {
|
|
507
|
+
log('usage hook failed: ' + String(e))
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
})()
|
|
511
|
+
})
|
|
512
|
+
|
|
245
513
|
ctx.on('tools/result', (exec, result) => {
|
|
246
514
|
try {
|
|
247
515
|
if (!isErrorResult(result)) return
|
|
@@ -283,6 +551,15 @@ export function apply(ctx, config = {}) {
|
|
|
283
551
|
}
|
|
284
552
|
applyReward(state, rec)
|
|
285
553
|
store.appendJsonl('rewards.jsonl', rec)
|
|
554
|
+
state.recByTurn.set(key, rec)
|
|
555
|
+
if (state.recByTurn.size > 500) {
|
|
556
|
+
const first = state.recByTurn.keys().next().value
|
|
557
|
+
if (first !== undefined) state.recByTurn.delete(first)
|
|
558
|
+
}
|
|
559
|
+
if (mf && typeof mf.list === 'function' && td.t0 != null) {
|
|
560
|
+
state.pendingFeedback.push({ key, sessionId: agentKey(payload), t0: td.t0, appliedReward: rec.reward })
|
|
561
|
+
if (state.pendingFeedback.length > 200) state.pendingFeedback.shift()
|
|
562
|
+
}
|
|
286
563
|
const ak = agentKey(payload)
|
|
287
564
|
if (state.openTurns.get(ak) === key) state.openTurns.delete(ak)
|
|
288
565
|
log('reward ' + rec.reward + ' ' + tag + ' on ' + key + ' breakdown=' + JSON.stringify(r.breakdown))
|
|
@@ -294,10 +571,12 @@ export function apply(ctx, config = {}) {
|
|
|
294
571
|
ctx.on('agent/turn-stopping', (payload) => finalize(payload, 'clean-close'))
|
|
295
572
|
ctx.on('agent/error', (payload) => finalize(payload, 'error'))
|
|
296
573
|
|
|
297
|
-
|
|
574
|
+
// 档位表异步加载;decideAndRecord 在决策前 await 此 promise
|
|
575
|
+
const tiersReady = refreshModelTiers(llm)
|
|
576
|
+
tiersReady.then((t) => {
|
|
298
577
|
state.modelTiers = t
|
|
299
578
|
log('model tiers: ' + JSON.stringify(t))
|
|
300
|
-
})
|
|
579
|
+
}).catch((e) => log('model tiers refresh failed: ' + String(e)))
|
|
301
580
|
|
|
302
581
|
// ---- 工具 ----
|
|
303
582
|
const OUT = (label) => ({
|
|
@@ -315,7 +594,7 @@ export function apply(ctx, config = {}) {
|
|
|
315
594
|
|
|
316
595
|
register({
|
|
317
596
|
name: 'agentic_router_stats',
|
|
318
|
-
description: '查看 agentic router(UCB/LinUCB/k-NN 三策略 + EXP3 元选择器 +
|
|
597
|
+
description: '查看 agentic router(UCB/LinUCB/k-NN 三策略 + EXP3 元选择器 + 质量代理奖励)的决策记录、策略权重、真实切换与飞轮学习状态。',
|
|
319
598
|
parameters: { type: 'object', properties: {} },
|
|
320
599
|
output: OUT('agentic router 状态'),
|
|
321
600
|
isConcurrencySafe: () => true,
|
|
@@ -325,14 +604,21 @@ export function apply(ctx, config = {}) {
|
|
|
325
604
|
forcedModel: state.forced,
|
|
326
605
|
totalDecisions: state.seq,
|
|
327
606
|
modelTiers: state.modelTiers,
|
|
607
|
+
prices,
|
|
608
|
+
mechanism: 'request/header append (agent/inbox/claimed)',
|
|
609
|
+
lastActual: state.lastActual,
|
|
610
|
+
headerAppends: state.headerAppends,
|
|
328
611
|
flywheel: {
|
|
329
612
|
ucb: state.ucb,
|
|
330
613
|
lin: { total: state.lin.total, tierN: state.lin.tierN, tierW: state.lin.tierW },
|
|
331
614
|
knnPoolSize: state.knn.pool.length,
|
|
332
615
|
exp3: state.exp3,
|
|
616
|
+
pendingFeedback: state.pendingFeedback.length,
|
|
617
|
+
prices,
|
|
333
618
|
recentRewards: store.loadJsonl('rewards.jsonl').slice(-20),
|
|
334
619
|
},
|
|
335
620
|
recent: state.decisions.slice(-20),
|
|
621
|
+
note: '切换真实性验证:headerAppends 为本进程追加的 request/header 次数;lastActual 为最近一步真实出网模型;decisions[].applied=true 且 swapped=true 即真实切换成功',
|
|
336
622
|
}
|
|
337
623
|
},
|
|
338
624
|
})
|
|
@@ -348,7 +634,7 @@ export function apply(ctx, config = {}) {
|
|
|
348
634
|
isConcurrencySafe: () => true,
|
|
349
635
|
async execute(args) {
|
|
350
636
|
state.mode = args.mode
|
|
351
|
-
store.savePolicy({ mode: state.mode, forced: state.forced })
|
|
637
|
+
store.savePolicy({ mode: state.mode, forced: state.forced, prices })
|
|
352
638
|
return { mode: state.mode }
|
|
353
639
|
},
|
|
354
640
|
})
|
|
@@ -367,17 +653,62 @@ export function apply(ctx, config = {}) {
|
|
|
367
653
|
async execute(args) {
|
|
368
654
|
if (args && args.clear) {
|
|
369
655
|
state.forced = null
|
|
370
|
-
store.savePolicy({ mode: state.mode, forced: null })
|
|
656
|
+
store.savePolicy({ mode: state.mode, forced: null, prices })
|
|
371
657
|
return { forced: null }
|
|
372
658
|
}
|
|
373
659
|
if (args && typeof args.model === 'string') {
|
|
374
660
|
state.forced = args.model
|
|
375
|
-
store.savePolicy({ mode: state.mode, forced: args.model })
|
|
661
|
+
store.savePolicy({ mode: state.mode, forced: args.model, prices })
|
|
662
|
+
// 立即对存活会话应用(下一请求生效)
|
|
663
|
+
if (state.mode === 'active') {
|
|
664
|
+
try {
|
|
665
|
+
const agents = ctx.get('agents')
|
|
666
|
+
if (agents && typeof agents.list === 'function') {
|
|
667
|
+
for (const agent of agents.list()) {
|
|
668
|
+
try {
|
|
669
|
+
const session = sessionOf({ agent })
|
|
670
|
+
if (session && currentModel(session) !== args.model) {
|
|
671
|
+
const ok = appendRouterHeader(session, args.model)
|
|
672
|
+
if (ok) state.headerAppends++
|
|
673
|
+
}
|
|
674
|
+
} catch (e) { /* per-agent best effort */ }
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
} catch (e) { /* best effort */ }
|
|
678
|
+
}
|
|
376
679
|
return { forced: args.model }
|
|
377
680
|
}
|
|
378
681
|
return { forced: state.forced, error: '需要 model 或 clear=true' }
|
|
379
682
|
},
|
|
380
683
|
})
|
|
684
|
+
register({
|
|
685
|
+
name: 'agentic_router_set_prices',
|
|
686
|
+
description: '配置/查询某 provider 某模型(或档位)的价格(元/百万 tokens,{in, out})。不带 priceIn/priceOut 时返回当前价格表。接入新模型后用它补价格,未知价格按保守值估算并标记 unknown。',
|
|
687
|
+
parameters: {
|
|
688
|
+
type: 'object',
|
|
689
|
+
properties: {
|
|
690
|
+
provider: { type: 'string', description: 'provider id,如 openai、anthropic、deepseek-official' },
|
|
691
|
+
model: { type: 'string', description: '可选:精确模型 id;省略则设置该 provider 的档位价' },
|
|
692
|
+
tier: { type: 'string', enum: ['fast', 'mid', 'strong'], description: '档位(未给 model 时必填)' },
|
|
693
|
+
priceIn: { type: 'number', description: '输入价(元/百万 tokens),负数视为查询模式' },
|
|
694
|
+
priceOut: { type: 'number', description: '输出价(元/百万 tokens),负数视为查询模式' },
|
|
695
|
+
},
|
|
696
|
+
required: ['provider'],
|
|
697
|
+
},
|
|
698
|
+
output: OUT('agentic router 价格表'),
|
|
699
|
+
isConcurrencySafe: () => true,
|
|
700
|
+
async execute(args) {
|
|
701
|
+
if (typeof args.priceIn !== 'number' || typeof args.priceOut !== 'number' || args.priceIn < 0 || args.priceOut < 0) {
|
|
702
|
+
return { prices, note: '查看模式:传 priceIn/priceOut 与 model 或 tier 进行设置' }
|
|
703
|
+
}
|
|
704
|
+
const byProvider = prices[args.provider] || (prices[args.provider] = {})
|
|
705
|
+
if (args.model) byProvider[args.model] = { in: args.priceIn, out: args.priceOut }
|
|
706
|
+
else if (args.tier) byProvider[args.tier] = { in: args.priceIn, out: args.priceOut }
|
|
707
|
+
else return { error: '需要 model 或 tier', prices }
|
|
708
|
+
store.savePolicy({ mode: state.mode, forced: state.forced, prices })
|
|
709
|
+
return { prices, updated: args.model || (args.provider + '/' + args.tier) }
|
|
710
|
+
},
|
|
711
|
+
})
|
|
381
712
|
register({
|
|
382
713
|
name: 'agentic_router_reset',
|
|
383
714
|
description: '清空飞轮学习状态(决策与奖励历史),保留当前模式;用于重新开始学习或清理测试数据。',
|
|
@@ -396,6 +727,8 @@ export function apply(ctx, config = {}) {
|
|
|
396
727
|
state.exp3 = { total: 0, weights: { ...DEFAULT_WEIGHTS } }
|
|
397
728
|
state.seq = 0
|
|
398
729
|
state.decisions = []
|
|
730
|
+
state.headerAppends = 0
|
|
731
|
+
state.lastActual = null
|
|
399
732
|
return { reset: true, home: store.home }
|
|
400
733
|
},
|
|
401
734
|
})
|
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,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.4.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 and REAL model switching via the session request-header precedence chain (v1.4.0)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "lhl",
|
|
@@ -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": {
|