@raolin2025/claude-code-node 2.8.11 → 2.8.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -3
- package/package.json +1 -1
- package/src/__tests__/compact-window.test.js +98 -1
- package/src/__tests__/small-model.test.js +140 -0
- package/src/core/cli.js +10 -0
- package/src/core/compact.js +109 -0
- package/src/core/query-engine.js +105 -13
- package/src/core/small-model.js +243 -0
package/README.md
CHANGED
|
@@ -67,6 +67,8 @@ cc-node --resume session-1747000000000-abc123
|
|
|
67
67
|
| `--resume` | `-r` | 恢复会话 ID | |
|
|
68
68
|
| `--verbose` | `-v` | 详细输出 | `false` |
|
|
69
69
|
| `--no-stream` | | 禁用流式响应 | `false` |
|
|
70
|
+
| `--max-messages` | | 消息条数上限,超过则折叠早期历史为摘要(解决本地小模型"条数过多变傻") | `0`(关闭) |
|
|
71
|
+
| `--small-model` | | 小模型适配模式(强制工具调用 + 敷衍重试 + 意图引导 + 工具精简) | `false` |
|
|
70
72
|
| `--stdio` | | **JSON-RPC 服务器模式**(供桥接层/外部客户端接入,见下) | |
|
|
71
73
|
| `--help` | `-h` | 显示帮助 | |
|
|
72
74
|
|
|
@@ -136,16 +138,25 @@ cc-node 会自动感知当前所用模型的**上下文窗口长度**,并在
|
|
|
136
138
|
|
|
137
139
|
### 自动压缩机制(滑动窗口)
|
|
138
140
|
|
|
139
|
-
上下文**永不超出窗口**,采用"摘要优先 + 滑动窗口裁剪兜底"
|
|
141
|
+
上下文**永不超出窗口**,采用"条数折叠 + 摘要优先 + 滑动窗口裁剪兜底"的多层策略:
|
|
140
142
|
|
|
141
|
-
|
|
143
|
+
**0. 发送前常驻工具结果截断**(v2.8.12):每次发送前(不依赖是否超窗)对**超长工具结果**
|
|
144
|
+
(默认 >6000 字符)做截断,从源头压住"工具结果过程噪音"堆积,避免它们淹没模型对最新
|
|
145
|
+
指令的注意力。
|
|
146
|
+
|
|
147
|
+
**1. 消息条数折叠**(v2.8.12,`--max-messages N` 开启,默认关闭):当上下文**消息条数**
|
|
148
|
+
超过 `N`(如 80)时,把早期历史折叠成一条摘要(保留 `Main goal` / 工具使用 / 关键结果),
|
|
149
|
+
仅保留最近 4 轮完整对话。这解决**本地小模型(如 27B)"token 未超窗但 200+ 条消息却变傻、
|
|
150
|
+
不干活"**的问题——这类模型对"消息条数"比"token 数"更敏感。
|
|
151
|
+
|
|
152
|
+
**2. 摘要式压缩**(信息量更高):每次新消息加入 / 工具结果返回 / 发送前,用**实时 token
|
|
142
153
|
估算**判断是否超窗;超窗时把早期对话压缩为摘要(保留最近 4 轮 + 摘要),目标压到窗口
|
|
143
154
|
的 60%。摘要会**显式标注最早的 `Main goal`(核心任务主线)**,并保留最早的关键发现与
|
|
144
155
|
最新结果,避免例行内容淹没核心任务(防止 AI 压缩后失忆)。
|
|
145
156
|
> **保守触发(v2.8.11)**:由于启发式估算可能比模型真实 token 偏少,压缩**提前到
|
|
146
157
|
> 可用窗口的 85%** 即触发(`compressSafetyFactor`,默认 0.85),给 tokenization 差异
|
|
147
158
|
> 留余量,避免实际请求超过模型窗口(如 `exceed_context_size_error` 400 错误)。
|
|
148
|
-
|
|
159
|
+
**3. 滑动窗口精确裁剪**(兜底,保证永不超窗):摘要压缩后仍超窗时,从**最早的消息**逐条
|
|
149
160
|
挤出,**最新信息始终保留在末尾**,直到总 token ≤ 窗口上限。system 提示永不裁剪;
|
|
150
161
|
**被裁剪的早期历史会压缩成一条摘要 system 保留**(避免 AI 丢失上下文主线);极端情况
|
|
151
162
|
(单条消息超窗)仍保留 system + 最近一条,保证至少能发出请求。
|
|
@@ -166,6 +177,29 @@ cc-node 会自动感知当前所用模型的**上下文窗口长度**,并在
|
|
|
166
177
|
|
|
167
178
|
> 切换模型(`/model`)后会自动重新探测窗口。`/budget` 也会显示当前窗口与 80% 触发阈值。
|
|
168
179
|
|
|
180
|
+
### 🤖 小模型适配模式(--small-model)
|
|
181
|
+
|
|
182
|
+
> 专为 **本地小模型**(如 27B Q3 量化)设计,让编程工具在弱模型下也能可靠工作。
|
|
183
|
+
> 默认关闭,通过 `--small-model` 或 `config.smallModel=true` 开启。
|
|
184
|
+
|
|
185
|
+
小模型的核心弱点是"规划 + 工具调用 + 自我纠错"不稳定——常只回 `Solved by sharing best practices.`
|
|
186
|
+
这类空话、不调工具,或多轮往返后"丢"了目标。该模式把智能从"模型端"转移到"框架端":
|
|
187
|
+
|
|
188
|
+
| 层 | 机制 | 作用 |
|
|
189
|
+
|----|------|------|
|
|
190
|
+
| **A · 兜底** | 强化 system prompt | 明确告诉模型"必须调用工具完成任务,不能只回文字" |
|
|
191
|
+
| **A · 兜底** | 敷衍输出检测 + 重试 | 检测到空话/太短/无工具调用时,追加强引导重试一次 |
|
|
192
|
+
| **A · 兜底** | 工具数量精简 | 按用户指令意图只暴露核心工具子集,降低选择负担 |
|
|
193
|
+
| **B · 替代** | 意图识别 + 引导 | 用规则把"写文档/找文件/跑命令/改代码"映射到明确工具,注入任务引导 |
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
cc-node --api-base http://127.0.0.1:18080/v1 --model ./local-model.gguf \
|
|
197
|
+
--with-notify --small-model --max-messages 80
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
> **配合 `--max-messages N`**:两者互补——`--max-messages` 解决"条数过多",
|
|
201
|
+
> `--small-model` 解决"模型不会自主调工具"。小模型场景建议一起开启。
|
|
202
|
+
|
|
169
203
|
---
|
|
170
204
|
|
|
171
205
|
## 🛠️ 内置工具(10 个)
|
|
@@ -330,6 +364,8 @@ cc-node --api-base http://localhost:11434/v1
|
|
|
330
364
|
"model": "deepseek-chat",
|
|
331
365
|
"maxTurns": 100,
|
|
332
366
|
"maxBudgetTokens": 128000,
|
|
367
|
+
"maxMessages": 80,
|
|
368
|
+
"smallModel": true,
|
|
333
369
|
"permissionMode": "ask",
|
|
334
370
|
"tools": {
|
|
335
371
|
"bash": { "timeout": 120 },
|
|
@@ -345,6 +381,14 @@ cc-node --api-base http://localhost:11434/v1
|
|
|
345
381
|
> **`maxBudgetTokens`**:手动指定的上下文窗口上限(token 数)。
|
|
346
382
|
> 为 `0` 或未设置时,自动探测模型真实窗口;手动指定后优先于自动探测,
|
|
347
383
|
> 等价于 `/window N` 的效果,并持久化保存。
|
|
384
|
+
>
|
|
385
|
+
> **`maxMessages`**:消息条数上限(可选,默认关闭/`0`)。当上下文消息条数超过该值时,
|
|
386
|
+
> 自动折叠早期历史为摘要(保留 Main goal + 最近 4 轮完整对话),解决本地小模型
|
|
387
|
+
> "条数过多、token 不高却变傻"的问题。等价于 `--max-messages N`。
|
|
388
|
+
>
|
|
389
|
+
> **`smallModel`**:小模型适配模式(可选,默认关闭/`false`)。开启后启用强制工具调用
|
|
390
|
+
> 引导、敷衍输出检测重试、意图识别 + 工具精简,让弱模型(如 27B Q3 量化)也能可靠
|
|
391
|
+
> 完成编程任务。等价于 `--small-model`。
|
|
348
392
|
|
|
349
393
|
[](https://www.npmjs.com/package/@raolin2025/claude-code-node) [](https://github.com/bg1avd/claude-code-node) [](https://opensource.org/licenses/MIT)
|
|
350
394
|
---
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raolin2025/claude-code-node",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.13",
|
|
4
4
|
"description": "Node.js AI Code Agent CLI - Zero dependencies, pure JavaScript, security hardened, multi-channel notifications, Telegram & QQ Bot remote programming, rich media upload, multi-account management",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/core/index.js",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { test } from 'node:test'
|
|
13
13
|
import assert from 'node:assert/strict'
|
|
14
|
-
import { trimToWindow, compactMessages } from '../core/compact.js'
|
|
14
|
+
import { trimToWindow, compactMessages, foldHistoryByCount, trimToolResults } from '../core/compact.js'
|
|
15
15
|
import { TokenBudget } from '../core/token-budget.js'
|
|
16
16
|
|
|
17
17
|
/** 构造一个固定窗口的 TokenBudget */
|
|
@@ -187,3 +187,100 @@ test('摘要改进:数字归一化去重,避免例行序号占满摘要', ()
|
|
|
187
187
|
const intentCount = (sm.content.match(/例行检查/g) || []).length
|
|
188
188
|
assert.ok(intentCount <= 2, `例行意图应被去重(实际 ${intentCount} 条)`)
|
|
189
189
|
})
|
|
190
|
+
|
|
191
|
+
// ============================================================
|
|
192
|
+
// foldHistoryByCount — 按消息条数折叠(解决"条数过多、token不高却变傻")
|
|
193
|
+
// ============================================================
|
|
194
|
+
|
|
195
|
+
/** 构造带工具结果的对话:system + n 轮(每轮 user + assistant(tool_calls) + tool + assistant) */
|
|
196
|
+
function makeToolMessages(n, { sysContent = 'SYS' } = {}) {
|
|
197
|
+
const msgs = [{ role: 'system', content: sysContent }]
|
|
198
|
+
for (let i = 0; i < n; i++) {
|
|
199
|
+
msgs.push({ role: 'user', content: `用户任务 ${i}:做点事` })
|
|
200
|
+
msgs.push({ role: 'assistant', content: '', toolCalls: [{ id: `c${i}`, name: 'Bash', input: { command: 'ls' } }] })
|
|
201
|
+
msgs.push({ role: 'tool', tool_call_id: `c${i}`, content: `工具结果 ${i} 的内容` })
|
|
202
|
+
msgs.push({ role: 'assistant', content: `完成 ${i} 的汇报` })
|
|
203
|
+
}
|
|
204
|
+
return msgs
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
test('条数折叠:未超过 maxMessages 时原样保留', () => {
|
|
208
|
+
const msgs = makeToolMessages(5) // 1 + 20 = 21 条
|
|
209
|
+
const r = foldHistoryByCount(msgs, { maxMessages: 80 })
|
|
210
|
+
assert.equal(r.folded, false)
|
|
211
|
+
assert.equal(r.removed, 0)
|
|
212
|
+
assert.equal(r.messages.length, msgs.length)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
test('条数折叠:超过 maxMessages 时把早期历史折叠为摘要', () => {
|
|
216
|
+
const msgs = makeToolMessages(30) // 1 + 120 = 121 条 > 80
|
|
217
|
+
const r = foldHistoryByCount(msgs, { maxMessages: 80, keepRecentTurns: 4 })
|
|
218
|
+
assert.equal(r.folded, true)
|
|
219
|
+
assert.ok(r.removed > 0, '应移除早期消息')
|
|
220
|
+
assert.ok(r.messages.length < msgs.length, '折叠后消息数应减少')
|
|
221
|
+
// 折叠后消息数应显著降低(远低于 121)
|
|
222
|
+
assert.ok(r.messages.length < 60, `折叠后应降到可管理数量(实际 ${r.messages.length})`)
|
|
223
|
+
// 摘要 system 保留
|
|
224
|
+
assert.ok(r.messages.some(m => m.role === 'system' && m.content.includes('Context Summary')),
|
|
225
|
+
'应插入折叠摘要 system')
|
|
226
|
+
// Main goal 保留最早的核心任务
|
|
227
|
+
assert.ok(r.summary.includes('用户任务 0'), '摘要应保留最早的核心任务')
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
test('条数折叠:最新 4 轮完整对话保留在末尾(含工具调用链无空洞)', () => {
|
|
231
|
+
const msgs = makeToolMessages(20) // 1 + 80 = 81 条 > 80
|
|
232
|
+
const r = foldHistoryByCount(msgs, { maxMessages: 80, keepRecentTurns: 4 })
|
|
233
|
+
// 保留最近 4 轮 = 16 条 + system + 摘要 system
|
|
234
|
+
// 检查末尾保留了最新的用户任务
|
|
235
|
+
const lastUser = [...r.messages].reverse().find(m => m.role === 'user')
|
|
236
|
+
assert.ok(lastUser, '应有 user 消息')
|
|
237
|
+
assert.ok(lastUser.content.includes('用户任务 19'), '最新一轮应保留')
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test('条数折叠:system 提示永不折叠', () => {
|
|
241
|
+
const msgs = makeToolMessages(30)
|
|
242
|
+
const r = foldHistoryByCount(msgs, { maxMessages: 80 })
|
|
243
|
+
assert.equal(r.messages[0].role, 'system')
|
|
244
|
+
assert.equal(r.messages[0].content, 'SYS')
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
test('条数折叠:keepRecentTurns=1 时只保留最新一轮', () => {
|
|
248
|
+
const msgs = makeToolMessages(10) // 1 + 40 = 41 条
|
|
249
|
+
const r = foldHistoryByCount(msgs, { maxMessages: 20, keepRecentTurns: 1 })
|
|
250
|
+
assert.equal(r.folded, true)
|
|
251
|
+
// 1(system) + 1(摘要) + 4(最新一轮) = 6
|
|
252
|
+
assert.equal(r.messages.length, 6, `应保留 system+摘要+最新一轮(实际 ${r.messages.length})`)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
// ============================================================
|
|
256
|
+
// trimToolResults — 发送前常驻工具结果截断(不依赖超窗)
|
|
257
|
+
// ============================================================
|
|
258
|
+
|
|
259
|
+
test('工具结果截断:超长工具结果被截断到 maxChars', () => {
|
|
260
|
+
const msgs = [
|
|
261
|
+
{ role: 'tool', tool_call_id: 'a', content: 'x'.repeat(10000) },
|
|
262
|
+
]
|
|
263
|
+
const r = trimToolResults(msgs, 6000)
|
|
264
|
+
assert.ok(r[0].content.length <= 6000 + 50, `截断后长度 ${r[0].content.length} 应 ≤ 6000`)
|
|
265
|
+
assert.ok(r[0].content.includes('truncated'), '应有截断标记')
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
test('工具结果截断:未超长的不受影响', () => {
|
|
269
|
+
const msgs = [
|
|
270
|
+
{ role: 'tool', tool_call_id: 'a', content: 'short' },
|
|
271
|
+
{ role: 'user', content: 'hello' },
|
|
272
|
+
]
|
|
273
|
+
const r = trimToolResults(msgs, 6000)
|
|
274
|
+
assert.equal(r[0].content, 'short')
|
|
275
|
+
assert.equal(r[1].content, 'hello')
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
test('工具结果截断:非 tool 消息与空内容不受影响', () => {
|
|
279
|
+
const msgs = [
|
|
280
|
+
{ role: 'user', content: 'x'.repeat(10000) }, // user 不截断
|
|
281
|
+
{ role: 'tool', tool_call_id: 'a', content: '' },
|
|
282
|
+
]
|
|
283
|
+
const r = trimToolResults(msgs, 100)
|
|
284
|
+
assert.equal(r[0].content.length, 10000, 'user 消息不应被截断')
|
|
285
|
+
assert.equal(r[1].content, '')
|
|
286
|
+
})
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 小模型适配层 (small-model.js) 测试
|
|
3
|
+
*
|
|
4
|
+
* 验证:
|
|
5
|
+
* - 敷衍输出检测(isFillerResponse):空话/太短/命中占位句识别
|
|
6
|
+
* - 意图识别(detectIntent):写文件/找文件/跑命令/改代码等映射
|
|
7
|
+
* - 意图引导(buildIntentGuidance):生成注入的引导文本
|
|
8
|
+
* - 工具精简(selectRelevantTools):按意图筛选核心工具
|
|
9
|
+
* - system prompt 强化(buildSmallModelSystemPrompt)
|
|
10
|
+
*/
|
|
11
|
+
import { test } from 'node:test'
|
|
12
|
+
import assert from 'node:assert/strict'
|
|
13
|
+
import {
|
|
14
|
+
isFillerResponse,
|
|
15
|
+
detectIntent,
|
|
16
|
+
buildIntentGuidance,
|
|
17
|
+
selectRelevantTools,
|
|
18
|
+
buildSmallModelSystemPrompt,
|
|
19
|
+
SMALL_MODEL_SYSTEM_PROMPT,
|
|
20
|
+
isSmallModelEnabled,
|
|
21
|
+
} from '../core/small-model.js'
|
|
22
|
+
|
|
23
|
+
// ---- 敷衍输出检测 ----
|
|
24
|
+
test('敷衍检测:命中空话占位句', () => {
|
|
25
|
+
assert.equal(isFillerResponse({ content: 'Solved by sharing best practices.', toolCalls: [] }), true)
|
|
26
|
+
assert.equal(isFillerResponse({ content: 'Done. This is the kind of mistake that costs real money.', toolCalls: [] }), true)
|
|
27
|
+
assert.equal(isFillerResponse({ content: 'Let me think about this.', toolCalls: [] }), true)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('敷衍检测:空回复/太短', () => {
|
|
31
|
+
assert.equal(isFillerResponse({ content: '', toolCalls: [] }), true)
|
|
32
|
+
assert.equal(isFillerResponse({ content: ' ', toolCalls: [] }), true)
|
|
33
|
+
assert.equal(isFillerResponse({ content: 'ok', toolCalls: [] }), true)
|
|
34
|
+
assert.equal(isFillerResponse({ content: '好的', toolCalls: [] }), true)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('敷衍检测:有工具调用不算敷衍', () => {
|
|
38
|
+
assert.equal(isFillerResponse({ content: 'ok', toolCalls: [{ id: '1' }] }), false)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('敷衍检测:正常实质回答不算敷衍', () => {
|
|
42
|
+
const content = '我已经分析了项目结构,发现3个主要模块需要重构,分别是订单、行情和风控模块,建议先处理订单模块。'
|
|
43
|
+
assert.equal(isFillerResponse({ content, toolCalls: [] }), false)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// ---- 意图识别 ----
|
|
47
|
+
test('意图识别:写文档任务', () => {
|
|
48
|
+
const r = detectIntent('做好开发计划文档')
|
|
49
|
+
assert.ok(r, '应识别到意图')
|
|
50
|
+
assert.ok(r.toolHint.includes('Write'), '应提示用 Write 工具')
|
|
51
|
+
const r2 = detectIntent('把你的计划写入文档')
|
|
52
|
+
assert.ok(r2 && r2.toolHint.includes('Write'))
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('意图识别:找文件任务', () => {
|
|
56
|
+
const r = detectIntent('查找src目录下的文件')
|
|
57
|
+
assert.ok(r && r.toolHint.includes('Glob'))
|
|
58
|
+
assert.ok(r.toolHint.includes('Grep'))
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('意图识别:跑命令任务', () => {
|
|
62
|
+
const r = detectIntent('运行一下测试')
|
|
63
|
+
assert.ok(r && r.toolHint.includes('Bash'))
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('意图识别:改代码任务', () => {
|
|
67
|
+
const r = detectIntent('修复这个bug')
|
|
68
|
+
assert.ok(r && r.toolHint.includes('Edit'))
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('意图识别:联网搜索任务', () => {
|
|
72
|
+
const r = detectIntent('搜索一下最新的量化交易资料')
|
|
73
|
+
assert.ok(r && r.toolHint.includes('WebSearch'))
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('意图识别:无匹配返回 null', () => {
|
|
77
|
+
assert.equal(detectIntent('你好'), null)
|
|
78
|
+
assert.equal(detectIntent(''), null)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
// ---- 意图引导 ----
|
|
82
|
+
test('意图引导:生成注入文本', () => {
|
|
83
|
+
const g = buildIntentGuidance('做好开发计划文档', { enable: true })
|
|
84
|
+
assert.ok(g, '应生成引导')
|
|
85
|
+
assert.ok(g.includes('Write'), '引导应包含工具建议')
|
|
86
|
+
assert.ok(g.includes('任务引导'), '应有引导标记')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('意图引导:未启用或未匹配返回 null', () => {
|
|
90
|
+
assert.equal(buildIntentGuidance('做好开发计划文档', { enable: false }), null)
|
|
91
|
+
assert.equal(buildIntentGuidance('你好', { enable: true }), null)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
// ---- 工具精简 ----
|
|
95
|
+
const fakeTools = [
|
|
96
|
+
{ name: 'Bash', description: 'd' },
|
|
97
|
+
{ name: 'Read', description: 'd' },
|
|
98
|
+
{ name: 'Edit', description: 'd' },
|
|
99
|
+
{ name: 'Write', description: 'd' },
|
|
100
|
+
{ name: 'Glob', description: 'd' },
|
|
101
|
+
{ name: 'Grep', description: 'd' },
|
|
102
|
+
{ name: 'WebSearch', description: 'd' },
|
|
103
|
+
{ name: 'WebFetch', description: 'd' },
|
|
104
|
+
{ name: 'GitTool', description: 'd' },
|
|
105
|
+
{ name: 'NpmPublish', description: 'd' },
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
test('工具精简:按意图保留相关工具', () => {
|
|
109
|
+
const reduced = selectRelevantTools(fakeTools, '做好开发计划文档', { enable: true })
|
|
110
|
+
assert.ok(reduced.length < fakeTools.length, '应精简工具数量')
|
|
111
|
+
assert.ok(reduced.some(t => t.name === 'Write'), '应保留 Write')
|
|
112
|
+
// 不相关的如 NpmPublish 不应保留
|
|
113
|
+
assert.ok(!reduced.some(t => t.name === 'NpmPublish'), '不应保留无关工具')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('工具精简:未启用返回全部', () => {
|
|
117
|
+
assert.equal(selectRelevantTools(fakeTools, 'xx', { enable: false }), fakeTools)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('工具精简:无明确意图时返回核心文件工具', () => {
|
|
121
|
+
const reduced = selectRelevantTools(fakeTools, '你好', { enable: true })
|
|
122
|
+
assert.ok(reduced.length <= 6, '应只保留核心工具')
|
|
123
|
+
assert.ok(reduced.some(t => t.name === 'Bash'))
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// ---- system prompt 强化 ----
|
|
127
|
+
test('system prompt 强化:追加工具使用铁律', () => {
|
|
128
|
+
const base = 'You are cc-node.'
|
|
129
|
+
const enhanced = buildSmallModelSystemPrompt(base)
|
|
130
|
+
assert.ok(enhanced.includes(base), '应保留基础 prompt')
|
|
131
|
+
assert.ok(enhanced.includes('工具调用 Agent'), '应包含工具调用强化引导')
|
|
132
|
+
assert.ok(enhanced.includes(SMALL_MODEL_SYSTEM_PROMPT), '应追加强化 prompt')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
// ---- 开关判断 ----
|
|
136
|
+
test('小模型开关判断', () => {
|
|
137
|
+
assert.equal(isSmallModelEnabled({ smallModel: true }), true)
|
|
138
|
+
assert.equal(isSmallModelEnabled({ smallModel: false }), false)
|
|
139
|
+
assert.equal(isSmallModelEnabled(undefined), false)
|
|
140
|
+
})
|
package/src/core/cli.js
CHANGED
|
@@ -330,6 +330,8 @@ function parseArgs(argv) {
|
|
|
330
330
|
apiBase: 'https://api.deepseek.com/v1',
|
|
331
331
|
resume: null,
|
|
332
332
|
noStream: false,
|
|
333
|
+
maxMessages: 0,
|
|
334
|
+
smallModel: false,
|
|
333
335
|
}
|
|
334
336
|
|
|
335
337
|
let i = 2
|
|
@@ -345,6 +347,8 @@ function parseArgs(argv) {
|
|
|
345
347
|
case '--resume': case '-r': args.resume = argv[++i]; break
|
|
346
348
|
case '--verbose': case '-v': args.verbose = true; break
|
|
347
349
|
case '--no-stream': args.noStream = true; break
|
|
350
|
+
case '--max-messages': args.maxMessages = parseInt(argv[++i], 10); break
|
|
351
|
+
case '--small-model': args.smallModel = true; break
|
|
348
352
|
case '--stdio': args.stdio = true; break
|
|
349
353
|
case '--with-notify': args.withNotify = true; break
|
|
350
354
|
case '--version':
|
|
@@ -365,6 +369,8 @@ Options:
|
|
|
365
369
|
--version Show version
|
|
366
370
|
-v, --verbose Verbose mode
|
|
367
371
|
--no-stream Disable streaming
|
|
372
|
+
--max-messages N Fold history when message count exceeds N (default: 0 = off)
|
|
373
|
+
--small-model Enable small-model adaptation (tool-call enforcement, filler retry, intent guidance)
|
|
368
374
|
--with-notify Start built-in channel listener (Telegram)
|
|
369
375
|
(replaces cc-notify daemon — no external script needed)
|
|
370
376
|
-h, --help Show this help
|
|
@@ -525,6 +531,10 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
525
531
|
costTracker,
|
|
526
532
|
tokenBudget,
|
|
527
533
|
configStore: config,
|
|
534
|
+
// 消息条数上限(折叠早期历史,解决本地小模型"条数过多变傻");0 = 关闭
|
|
535
|
+
maxMessages: cliArgs.maxMessages || config.get('maxMessages') || 0,
|
|
536
|
+
// 小模型适配模式(强制工具调用 + 敷衍重试 + 意图引导 + 工具精简)
|
|
537
|
+
smallModel: cliArgs.smallModel || config.get('smallModel') || false,
|
|
528
538
|
})
|
|
529
539
|
const engine = new QueryEngine(engineConfig)
|
|
530
540
|
|
package/src/core/compact.js
CHANGED
|
@@ -142,6 +142,115 @@ function isConclusionLike(text) {
|
|
|
142
142
|
return CONCLUSION_HINTS.some(k => s.includes(k))
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
/**
|
|
146
|
+
* 发送前常驻工具结果截断 — 不依赖是否超窗
|
|
147
|
+
*
|
|
148
|
+
* 背景:长对话中工具结果(命令回显、git 输出等)会不断累积,即便单条不长,
|
|
149
|
+
* 条数一多也会让模型(尤其本地小模型)"迷失"在过程噪音里。旧逻辑只在 token
|
|
150
|
+
* 超窗时才截断,导致 token 未超窗(如 37%)但 200+ 条消息让模型变傻。
|
|
151
|
+
*
|
|
152
|
+
* 本函数在【每次发送前】都对工具结果做上限截断(默认 6000 字符),
|
|
153
|
+
* 阈值比压缩时的 maxToolResultChars(2000)宽松,避免频繁误截,
|
|
154
|
+
* 同时把"超长噪音"从源头上压住。
|
|
155
|
+
*
|
|
156
|
+
* @param {Array} messages — 消息列表
|
|
157
|
+
* @param {number} [maxChars=6000] — 工具结果保留的最大字符数
|
|
158
|
+
* @returns {Array} 截断后的消息列表(原列表被浅拷贝修改,不影响调用方原始引用结构)
|
|
159
|
+
*/
|
|
160
|
+
export function trimToolResults(messages, maxChars = 6000) {
|
|
161
|
+
return messages.map(msg => {
|
|
162
|
+
if (msg.role === 'tool' && typeof msg.content === 'string' && msg.content.length > maxChars) {
|
|
163
|
+
return {
|
|
164
|
+
...msg,
|
|
165
|
+
content: msg.content.slice(0, maxChars) + `\n[...tool result truncated: kept ${maxChars} chars]`,
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return msg
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 按消息条数折叠历史 — 解决"条数过多、token 不高却变傻"
|
|
174
|
+
*
|
|
175
|
+
* 背景:27B 等本地小模型对"消息条数"比"token 数"更敏感。一个 session 塞进
|
|
176
|
+
* 200+ 条消息(113 条工具结果 + 84 条 assistant + 多个 user),即便 token 只占
|
|
177
|
+
* 窗口 37%,模型也会因为角色切换频繁、工具结果噪音堆积而"迷失"当前指令。
|
|
178
|
+
*
|
|
179
|
+
* 本函数在消息条数超过 maxMessages 时,把早期历史折叠成一条摘要
|
|
180
|
+
* (保留 Main goal + 工具使用 + 关键结果),仅保留最近 keepRecentTurns 轮的
|
|
181
|
+
* 完整对话,把消息条数压到可管理范围,同时不丢主线。
|
|
182
|
+
*
|
|
183
|
+
* @param {Array} messages — 完整消息列表
|
|
184
|
+
* @param {object} options
|
|
185
|
+
* @param {number} [options.maxMessages=80] — 超过此条数即触发折叠
|
|
186
|
+
* @param {number} [options.keepRecentTurns=4] — 保留最近 N 轮完整对话
|
|
187
|
+
* @param {number} [options.maxToolResultChars=2000] — 折叠时工具结果截断长度
|
|
188
|
+
* @returns {{ folded: boolean, messages: Array, removed: number, summary: string|null }}
|
|
189
|
+
*/
|
|
190
|
+
export function foldHistoryByCount(messages, options = {}) {
|
|
191
|
+
const maxMessages = options.maxMessages || 80
|
|
192
|
+
const keepRecentTurns = options.keepRecentTurns ?? 4
|
|
193
|
+
const maxToolResultChars = options.maxToolResultChars || 2000
|
|
194
|
+
|
|
195
|
+
// 未超过条数阈值 → 不折叠
|
|
196
|
+
if (messages.length <= maxMessages) {
|
|
197
|
+
return { folded: false, messages, removed: 0, summary: null }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// 分离 system 提示(首条 system 永不折叠)与普通消息
|
|
201
|
+
const systemMsgs = []
|
|
202
|
+
const body = []
|
|
203
|
+
for (const m of messages) {
|
|
204
|
+
if (m.role === 'system' && systemMsgs.length === 0) {
|
|
205
|
+
systemMsgs.push(m)
|
|
206
|
+
} else {
|
|
207
|
+
body.push(m)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 找到分界点:保留最近 keepRecentTurns 轮
|
|
212
|
+
// 一轮 = user + assistant(+tool_calls) + tool 结果们 + assistant 最终回复
|
|
213
|
+
// 从末尾倒推,数到第 keepRecentTurns 个 user 即分界(splitIndex 指向该轮起点,
|
|
214
|
+
// 使得 recentMessages 恰好包含最近 keepRecentTurns 轮完整对话)
|
|
215
|
+
let turnCount = 0
|
|
216
|
+
let splitIndex = body.length
|
|
217
|
+
for (let i = body.length - 1; i >= 0; i--) {
|
|
218
|
+
if (body[i].role === 'user') {
|
|
219
|
+
turnCount++
|
|
220
|
+
if (turnCount >= keepRecentTurns) {
|
|
221
|
+
splitIndex = i
|
|
222
|
+
break
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// 没有可折叠的早期消息(全都要保留)→ 不折叠
|
|
228
|
+
if (splitIndex <= 0 || splitIndex >= body.length) {
|
|
229
|
+
return { folded: false, messages, removed: 0, summary: null }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const earlyMessages = body.slice(0, splitIndex)
|
|
233
|
+
const recentMessages = body.slice(splitIndex)
|
|
234
|
+
|
|
235
|
+
// 对折叠掉的历史生成摘要(保留 Main goal / 工具 / 关键结果)
|
|
236
|
+
const summary = generateSummary(earlyMessages)
|
|
237
|
+
|
|
238
|
+
// 构建折叠后的消息列表:system + (摘要 system) + 最近 N 轮完整对话
|
|
239
|
+
const folded = [...systemMsgs]
|
|
240
|
+
folded.push({
|
|
241
|
+
role: 'system',
|
|
242
|
+
content: `[Context Summary — ${new Date().toISOString()}]\n${summary}\n[End of Summary — recent conversation follows]`,
|
|
243
|
+
})
|
|
244
|
+
folded.push(...recentMessages)
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
folded: true,
|
|
248
|
+
messages: folded,
|
|
249
|
+
removed: earlyMessages.length,
|
|
250
|
+
summary,
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
145
254
|
/**
|
|
146
255
|
* 从消息列表生成摘要
|
|
147
256
|
*
|
package/src/core/query-engine.js
CHANGED
|
@@ -14,7 +14,15 @@
|
|
|
14
14
|
import crypto from 'crypto'
|
|
15
15
|
import { UserMessage, AssistantMessage, ToolCall, ToolResult, SessionState } from '../types/index.js'
|
|
16
16
|
import { parseStream, parseNonStreamResponse } from './streaming.js'
|
|
17
|
-
import { compactMessages, trimToWindow } from './compact.js'
|
|
17
|
+
import { compactMessages, trimToWindow, foldHistoryByCount, trimToolResults } from './compact.js'
|
|
18
|
+
import {
|
|
19
|
+
isSmallModelEnabled,
|
|
20
|
+
buildSmallModelSystemPrompt,
|
|
21
|
+
isFillerResponse,
|
|
22
|
+
buildIntentGuidance,
|
|
23
|
+
selectRelevantTools,
|
|
24
|
+
RETRY_GUIDANCE,
|
|
25
|
+
} from './small-model.js'
|
|
18
26
|
import { CostTracker } from './cost-tracker.js'
|
|
19
27
|
import { EnhancedPermissionChecker } from '../security/enhanced-permission.js'
|
|
20
28
|
import { isLocalLlmServer, buildAuthHeaders } from '../utils/index.js'
|
|
@@ -34,6 +42,17 @@ export class QueryEngineConfig {
|
|
|
34
42
|
this.maxBudgetTokens = options.maxBudgetTokens || 1_000_000
|
|
35
43
|
// 压缩触发保守系数(0~1):估算到可用窗口的该比例即触发压缩,留出 tokenization 差异余量
|
|
36
44
|
this.compressSafetyFactor = options.compressSafetyFactor ?? 0.85
|
|
45
|
+
// 消息条数上限:超过则强制折叠早期历史(解决本地小模型"条数过多、token不高却变傻")
|
|
46
|
+
// 0 表示不启用条数折叠(仅按 token)
|
|
47
|
+
this.maxMessages = options.maxMessages || 0
|
|
48
|
+
// 发送前常驻工具结果截断长度(字符),防止超长工具结果堆积(0 表示不截断)
|
|
49
|
+
this.maxToolResultChars = options.maxToolResultChars || 6000
|
|
50
|
+
// 小模型适配模式(让弱模型可靠工作):
|
|
51
|
+
// - 强化 system prompt(强制工具调用)
|
|
52
|
+
// - 敷衍输出检测 + 重试
|
|
53
|
+
// - 工具数量精简 + 意图引导
|
|
54
|
+
// 默认关闭,通过 config.smallModel=true 或 --small-model 开启
|
|
55
|
+
this.smallModel = options.smallModel || false
|
|
37
56
|
this.permissionMode = options.permissionMode || 'ask'
|
|
38
57
|
this.verbose = options.verbose || false
|
|
39
58
|
// API 配置 — 通用 OpenAI 兼容协议
|
|
@@ -69,6 +88,8 @@ export class QueryEngine {
|
|
|
69
88
|
this.tokenBudget = this.config.tokenBudget || null
|
|
70
89
|
// 最近一次 processMessage 是否已通过流式回调/直写把正文输出(供调用方避免重复打印 response)
|
|
71
90
|
this.lastStreamed = false
|
|
91
|
+
// 当前正在处理的用户输入(小模型模式用于按意图精简工具集)
|
|
92
|
+
this.currentUserInput = ''
|
|
72
93
|
}
|
|
73
94
|
|
|
74
95
|
/**
|
|
@@ -84,6 +105,7 @@ export class QueryEngine {
|
|
|
84
105
|
this.state.turnCount++
|
|
85
106
|
this.lastStreamed = false // 本轮是否已流式输出正文
|
|
86
107
|
this.abortController = new AbortController()
|
|
108
|
+
this.currentUserInput = userInput // 记录当前用户指令(小模型模式按意图精简工具)
|
|
87
109
|
const userMsg = new UserMessage(userInput, images)
|
|
88
110
|
this.state.messages.push(userMsg)
|
|
89
111
|
|
|
@@ -100,14 +122,17 @@ export class QueryEngine {
|
|
|
100
122
|
}
|
|
101
123
|
|
|
102
124
|
/**
|
|
103
|
-
* 确保上下文 ≤
|
|
125
|
+
* 确保上下文 ≤ 窗口(滑动窗口语义)+ 消息条数可控
|
|
104
126
|
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
127
|
+
* 处理顺序(每层都基于上一层的输出,逐层收敛):
|
|
128
|
+
* 0. 发送前常驻工具结果截断(trimToolResults)—— 不依赖是否超窗,从源头压住
|
|
129
|
+
* 超长工具结果噪音,防止"token 未超窗但工具结果堆积"。
|
|
130
|
+
* 1. 消息条数折叠(foldHistoryByCount)—— 当 state.messages 条数超过
|
|
131
|
+
* maxMessages 时,把早期历史折叠成摘要,保留最近 N 轮完整对话。
|
|
132
|
+
* 解决本地小模型"200+ 条消息、token 仅 37% 却变傻不干活"的核心问题。
|
|
133
|
+
* 2. 估算当前消息总 token(实时估算,反映 state.messages 真实大小);
|
|
134
|
+
* 若未超窗 → 不做 token 层处理;
|
|
108
135
|
* 3. 若超窗 → 先做摘要式压缩(保留最近 N 轮 + 早期摘要,信息量更高)。
|
|
109
|
-
* 注意:这里直接用实时 token 估算判断是否压缩,而非依赖滞后的
|
|
110
|
-
* usagePercent(那会导致"判定超窗但压缩永不触发")。
|
|
111
136
|
* 4. 摘要后仍超窗 → 滑动窗口精确裁剪兜底:从最早完整 user 回合挤出,
|
|
112
137
|
* 并把被裁掉的历史压缩成摘要 system 保留,避免 AI 失忆。
|
|
113
138
|
* 保证最新信息(含刚加入的用户消息)保留在末尾,上下文永不超出窗口。
|
|
@@ -120,6 +145,33 @@ export class QueryEngine {
|
|
|
120
145
|
*/
|
|
121
146
|
_ensureFitWindow() {
|
|
122
147
|
if (!this.tokenBudget) return
|
|
148
|
+
|
|
149
|
+
// ---- 0) 发送前常驻工具结果截断(不依赖超窗)----
|
|
150
|
+
// 避免超长工具结果持续堆积成噪音;阈值宽松(默认 6000),仅在确实过长时截断
|
|
151
|
+
if (this.config.maxToolResultChars > 0) {
|
|
152
|
+
const beforeTrim = this.state.messages.length
|
|
153
|
+
this.state.messages = trimToolResults(this.state.messages, this.config.maxToolResultChars)
|
|
154
|
+
if (this.config.verbose && this.state.messages.length !== beforeTrim) {
|
|
155
|
+
console.error('[compact] 工具结果已按上限截断(常驻)')
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---- 1) 消息条数折叠(解决本地小模型"条数过多变傻")----
|
|
160
|
+
// 不依赖 token 是否超窗:只要消息条数超过 maxMessages,就折叠早期历史为摘要
|
|
161
|
+
if (this.config.maxMessages > 0) {
|
|
162
|
+
const { folded, messages: foldedMsgs, removed, summary } = foldHistoryByCount(this.state.messages, {
|
|
163
|
+
maxMessages: this.config.maxMessages,
|
|
164
|
+
keepRecentTurns: 4,
|
|
165
|
+
})
|
|
166
|
+
if (folded) {
|
|
167
|
+
this.state.messages = foldedMsgs
|
|
168
|
+
if (this.config.verbose) {
|
|
169
|
+
console.error(`[compact] 消息条数 ${removed + foldedMsgs.length} > ${this.config.maxMessages},已折叠早期 ${removed} 条为摘要(保留最近 4 轮)`)
|
|
170
|
+
if (summary) console.error('[compact] 折叠摘要:\n' + summary)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
123
175
|
const maxTokens = this.tokenBudget.maxTokens
|
|
124
176
|
const hardLimit = maxTokens - (this.tokenBudget.reservedForOutput || 0)
|
|
125
177
|
// 保守触发阈值:估算到可用窗口的 85% 就开始压缩,避免估算偏差导致实际超窗
|
|
@@ -128,7 +180,7 @@ export class QueryEngine {
|
|
|
128
180
|
const est = this.tokenBudget.estimateMessages(this.state.messages)
|
|
129
181
|
if (est <= triggerLimit) return
|
|
130
182
|
|
|
131
|
-
//
|
|
183
|
+
// 2) 摘要式压缩优先:用实时估算直接决定是否压缩(不再依赖滞后的 usagePercent)
|
|
132
184
|
// 压缩目标也用保守阈值(而非 hardLimit),确保压缩后实际 token 远离窗口上限
|
|
133
185
|
const summarized = compactMessages(this.state.messages, {
|
|
134
186
|
maxTokens: Math.floor(maxTokens * 0.6),
|
|
@@ -140,7 +192,7 @@ export class QueryEngine {
|
|
|
140
192
|
return
|
|
141
193
|
}
|
|
142
194
|
|
|
143
|
-
//
|
|
195
|
+
// 3) 滑动窗口精确裁剪兜底(摘要仍超窗):裁剪时保留被裁剪历史的摘要
|
|
144
196
|
const { trimmed, messages: trimmedMsgs, removed, summary } = trimToWindow(this.state.messages, {
|
|
145
197
|
tokenBudget: this.tokenBudget,
|
|
146
198
|
maxTokens,
|
|
@@ -165,11 +217,27 @@ export class QueryEngine {
|
|
|
165
217
|
*/
|
|
166
218
|
async _runToolLoop(userMessage) {
|
|
167
219
|
let finalResponse = ''
|
|
220
|
+
const smallModel = isSmallModelEnabled(this.config)
|
|
221
|
+
// 小模型模式:首轮注入意图引导(层 B),帮助模型锁定该用哪些工具
|
|
222
|
+
let intentGuided = false
|
|
223
|
+
// 敷衍重试次数(层 A):模型没调工具只回空话时,追加强引导重试
|
|
224
|
+
let fillerRetries = 0
|
|
225
|
+
const MAX_FILLER_RETRIES = 1
|
|
168
226
|
|
|
169
227
|
for (let turn = 0; turn < this.config.maxTurns; turn++) {
|
|
170
228
|
// 发送前硬校验:工具结果可能已使上下文超窗,确保 ≤ 窗口(摘要优先 + 滑动窗口裁剪兜底)
|
|
171
229
|
this._ensureFitWindow()
|
|
172
230
|
|
|
231
|
+
// 小模型模式:首轮若识别到明确意图,注入意图引导 system 消息
|
|
232
|
+
if (smallModel && !intentGuided && this.state.messages.length > 0) {
|
|
233
|
+
const guidance = buildIntentGuidance(userMessage.content, { enable: true })
|
|
234
|
+
if (guidance) {
|
|
235
|
+
this.state.messages.push({ role: 'system', content: guidance })
|
|
236
|
+
if (this.config.verbose) console.error('[small-model] 已注入意图引导:' + guidance.slice(0, 80) + '...')
|
|
237
|
+
}
|
|
238
|
+
intentGuided = true
|
|
239
|
+
}
|
|
240
|
+
|
|
173
241
|
const requestMessages = this._buildRequest(this.state.messages)
|
|
174
242
|
const response = await this._callLLM(requestMessages, this.state.messages)
|
|
175
243
|
|
|
@@ -177,8 +245,20 @@ export class QueryEngine {
|
|
|
177
245
|
throw new Error('操作已取消')
|
|
178
246
|
}
|
|
179
247
|
|
|
180
|
-
// 没有工具调用 →
|
|
248
|
+
// 没有工具调用 → 潜在最终回复
|
|
181
249
|
if (!response.toolCalls || response.toolCalls.length === 0) {
|
|
250
|
+
// 小模型模式:检测敷衍输出(空话/太短/命中占位句),追加强引导重试
|
|
251
|
+
if (smallModel && fillerRetries < MAX_FILLER_RETRIES && isFillerResponse(response)) {
|
|
252
|
+
fillerRetries++
|
|
253
|
+
if (this.config.verbose) {
|
|
254
|
+
console.error(`[small-model] 检测到敷衍输出(第 ${fillerRetries} 次),追加强引导重试`)
|
|
255
|
+
}
|
|
256
|
+
// 把模型的空话 + 强制工具调用提醒注入上下文,再让模型重新决策
|
|
257
|
+
this.state.messages.push(new AssistantMessage(response.content, [], response.reasoningContent))
|
|
258
|
+
this.state.messages.push({ role: 'user', content: RETRY_GUIDANCE })
|
|
259
|
+
continue // 继续循环,重新请求模型
|
|
260
|
+
}
|
|
261
|
+
|
|
182
262
|
finalResponse = response.content
|
|
183
263
|
this.state.messages.push(new AssistantMessage(response.content, [], response.reasoningContent))
|
|
184
264
|
break
|
|
@@ -222,9 +302,12 @@ export class QueryEngine {
|
|
|
222
302
|
_buildRequest(messages) {
|
|
223
303
|
const request = []
|
|
224
304
|
|
|
225
|
-
//
|
|
305
|
+
// 系统提示(小模型模式:追加强制工具调用引导)
|
|
226
306
|
if (this.config.systemPrompt) {
|
|
227
|
-
|
|
307
|
+
const sysContent = isSmallModelEnabled(this.config)
|
|
308
|
+
? buildSmallModelSystemPrompt(this.config.systemPrompt)
|
|
309
|
+
: this.config.systemPrompt
|
|
310
|
+
request.push({ role: 'system', content: sysContent })
|
|
228
311
|
}
|
|
229
312
|
|
|
230
313
|
// 历史消息 — 转换为 OpenAI 兼容格式
|
|
@@ -355,7 +438,16 @@ export class QueryEngine {
|
|
|
355
438
|
}
|
|
356
439
|
|
|
357
440
|
// 构建工具定义
|
|
358
|
-
|
|
441
|
+
// 小模型模式:按用户指令意图精简工具集,减少模型的选择负担(层 A)
|
|
442
|
+
let effectiveTools = this.config.tools
|
|
443
|
+
if (isSmallModelEnabled(this.config)) {
|
|
444
|
+
const reduced = selectRelevantTools(this.config.tools, this.currentUserInput, { enable: true })
|
|
445
|
+
effectiveTools = reduced
|
|
446
|
+
if (this.config.verbose && reduced.length !== this.config.tools.length) {
|
|
447
|
+
console.error(`[small-model] 按意图精简工具:${this.config.tools.length} → ${reduced.length}(${reduced.map(t => t.name).join(', ')})`)
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
const tools = effectiveTools.map(t => ({
|
|
359
451
|
type: 'function',
|
|
360
452
|
function: { name: t.name, description: t.description, parameters: t.parameters },
|
|
361
453
|
}))
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 小模型适配层 — 让 cc-node 在弱模型(如 27B Q3 量化)下也能可靠工作
|
|
3
|
+
*
|
|
4
|
+
* 背景:
|
|
5
|
+
* 小模型的核心弱点是"规划 + 工具调用 + 自我纠错"不稳定——常出现:
|
|
6
|
+
* - 看不懂指令,只回 "Solved by sharing best practices." 等敷衍空话,不调工具;
|
|
7
|
+
* - 一次请求里塞 16 个工具定义让它选择困难,乱调或漏调;
|
|
8
|
+
* - 多轮工具往返后"丢"了原始目标。
|
|
9
|
+
* 本模块把智能从"模型端"转移到"框架端",提供两层适配:
|
|
10
|
+
*
|
|
11
|
+
* 【层 A — 兜底(防呆)】:
|
|
12
|
+
* - 强化 system prompt:明确告诉模型"必须调用工具完成任务,不能只回文字";
|
|
13
|
+
* - 敷衍输出检测:识别无工具调用 + 无实质内容的空话,返回需要重试的信号;
|
|
14
|
+
* - 工具数量精简:按任务场景只暴露核心工具子集,减少选择负担。
|
|
15
|
+
*
|
|
16
|
+
* 【层 B — 替代(框架接管规划)】:
|
|
17
|
+
* - 意图识别:用关键词规则把常见任务(写文件/找文件/跑命令/查代码)映射到
|
|
18
|
+
* 明确的工具动作,不完全依赖模型自主规划;
|
|
19
|
+
* - 提供一个"计划"数据结构,框架可据此按序执行。
|
|
20
|
+
*
|
|
21
|
+
* 注意:本模块默认【不启用】。通过 config.smallModel=true 或 --small-model 开启。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// ---- 敷衍/空话输出特征(层 A)----
|
|
25
|
+
// 小模型在"没读懂、没调工具"时输出的占位句/空话模式。
|
|
26
|
+
// 命中这些且【本轮无工具调用】时,判定为敷衍输出,触发重试。
|
|
27
|
+
const FILLER_PATTERNS = [
|
|
28
|
+
/^\s*solved/i,
|
|
29
|
+
/^\s*done\b/i,
|
|
30
|
+
/by sharing best practices/i,
|
|
31
|
+
/this is the kind of mistake/i,
|
|
32
|
+
/next action/i,
|
|
33
|
+
/^\s*ok\b/i,
|
|
34
|
+
/^\s*okay\b/i,
|
|
35
|
+
/^\s*got it/i,
|
|
36
|
+
/^\s*understood/i,
|
|
37
|
+
/^\s*no problem/i,
|
|
38
|
+
/^\s*let me think/i,
|
|
39
|
+
/^\s*sure\b/i,
|
|
40
|
+
/^\s*yes\b/i,
|
|
41
|
+
/^\s*right\b/i,
|
|
42
|
+
/^\s*hmm/i,
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
// 有"实质内容"的最小长度(去掉空白/填充后)
|
|
46
|
+
const MIN_SUBSTANTIVE_CHARS = 20
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 判断一次模型回复是否属于"敷衍输出"(无工具调用 + 空话/过短)
|
|
50
|
+
*
|
|
51
|
+
* 用途:小模型模式下,当模型没调工具却回了句空话时,判定为"没干活",
|
|
52
|
+
* 触发框架重试(并在重试时追加强引导)。
|
|
53
|
+
*
|
|
54
|
+
* @param {object} response — _callLLM 的返回 { content, toolCalls, ... }
|
|
55
|
+
* @param {object} [opts]
|
|
56
|
+
* @param {number} [opts.minChars=20] — 判定"实质内容"的最小有效字符数
|
|
57
|
+
* @returns {boolean} true 表示敷衍输出(应重试)
|
|
58
|
+
*/
|
|
59
|
+
export function isFillerResponse(response, opts = {}) {
|
|
60
|
+
const minChars = opts.minChars || MIN_SUBSTANTIVE_CHARS
|
|
61
|
+
// 有工具调用 → 在干活,不是敷衍
|
|
62
|
+
if (response.toolCalls && response.toolCalls.length > 0) return false
|
|
63
|
+
const content = (response.content || '').trim()
|
|
64
|
+
if (!content) return true // 空回复 = 敷衍
|
|
65
|
+
// 去掉空白后的有效字符数
|
|
66
|
+
const substantive = content.replace(/\s+/g, '').replace(/[,。!?,.!?、;;::]/g, '')
|
|
67
|
+
if (substantive.length < minChars) return true // 太短 = 敷衍
|
|
68
|
+
// 命中空话模式
|
|
69
|
+
for (const pat of FILLER_PATTERNS) {
|
|
70
|
+
if (pat.test(content)) return true
|
|
71
|
+
}
|
|
72
|
+
return false
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---- 小模型强化 system prompt(层 A)----
|
|
76
|
+
// 追加到基础 system prompt 之后的强工具引导。对小模型是决定性的:
|
|
77
|
+
// 大模型能"猜"到要调工具,小模型必须被明确告知。
|
|
78
|
+
export const SMALL_MODEL_SYSTEM_PROMPT = `
|
|
79
|
+
## 工具使用铁律(对你至关重要)
|
|
80
|
+
你是一个【工具调用 Agent】,不是一个聊天机器人。用户给你的是一个【任务】,你必须通过调用工具来完成任务,绝对不能只回复文字。
|
|
81
|
+
|
|
82
|
+
遵守以下规则:
|
|
83
|
+
1. **每次收到用户任务,第一反应是"该调用哪个工具",而不是"该怎么用文字回答"。**
|
|
84
|
+
2. 如果任务需要读取/查看/搜索文件,调用 Glob / Grep / Read。
|
|
85
|
+
3. 如果任务需要创建/修改文件,调用 Write / Edit。
|
|
86
|
+
4. 如果任务需要运行命令,调用 Bash。
|
|
87
|
+
5. 如果任务需要联网,调用 WebFetch / WebSearch。
|
|
88
|
+
6. **调用工具时,必须提供完整、正确的参数**(绝对路径、完整内容、具体命令)。
|
|
89
|
+
7. 调用工具后,根据工具返回结果继续,直到任务真正完成。
|
|
90
|
+
8. 除非任务只是简单问答(不需要任何工具),否则【禁止】不调用工具就回复。
|
|
91
|
+
9. 禁止输出 "Solved by..."、"\u201cDone\u201d"、"Let me think" 等空话——这些不是完成任务。
|
|
92
|
+
10. 如果一次要做多件事,一次调用一个工具,逐步推进,不要试图一次性解决。
|
|
93
|
+
|
|
94
|
+
记住:**你的价值在于调用工具把事做完,而不是说漂亮话。**
|
|
95
|
+
`
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 生成小模型模式下的完整 system prompt(基础 prompt + 强化引导)
|
|
99
|
+
* @param {string} basePrompt — 基础 system prompt
|
|
100
|
+
* @returns {string}
|
|
101
|
+
*/
|
|
102
|
+
export function buildSmallModelSystemPrompt(basePrompt) {
|
|
103
|
+
return basePrompt + SMALL_MODEL_SYSTEM_PROMPT
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---- 重试追加强引导(层 A)----
|
|
107
|
+
// 当模型敷衍输出触发重试时,往对话里注入一条"强制工具调用"的用户级提醒,
|
|
108
|
+
// 让模型看到"我刚才敷衍了,现在必须调工具"。
|
|
109
|
+
export const RETRY_GUIDANCE = `[系统提示] 你刚才没有调用任何工具就回复了,这不算完成任务。请立即重新审视任务,调用合适的工具(Write/Edit/Read/Grep/Glob/Bash 等)真正完成任务。不要再输出空话。`
|
|
110
|
+
|
|
111
|
+
// ---- 意图识别 + 框架动作映射(层 B)----
|
|
112
|
+
// 小模型模式下,框架用关键词规则把常见任务映射到明确的工具动作,
|
|
113
|
+
// 作为"计划"注入,引导模型按序执行(而非让模型自由发挥)。
|
|
114
|
+
//
|
|
115
|
+
// 每个意图规则:
|
|
116
|
+
// pattern: 触发正则
|
|
117
|
+
// plan: 建议的工具调用序列(按序执行)
|
|
118
|
+
// note: 给模型的动作说明
|
|
119
|
+
|
|
120
|
+
const INTENT_RULES = [
|
|
121
|
+
{
|
|
122
|
+
// 写文档/文件/计划/代码:覆盖"写/创建/生成/更新/保存/做好...文档/计划"等
|
|
123
|
+
pattern: /(写|创建|生成|更新|保存|做好|做一份|制定|起草|撰写|输出|整理|编写).*(文档|文件|计划|方案|说明|md|readme|代码|函数|模块|接口)/i,
|
|
124
|
+
plan: ['Grep/Glob(先看现状)', 'Write/Edit(写内容)'],
|
|
125
|
+
note: '先搜索确认目标文件是否存在/已有内容,再用 Write 或 Edit 写入。',
|
|
126
|
+
toolHint: 'Write, Edit, Glob, Grep',
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
// 明确提到"文档/文件"但未命中动词 → 归为写文件任务("做...开发计划文档")
|
|
130
|
+
pattern: /(文档|计划文档|开发计划|方案文档|说明文档|md文件).*(写好|完成|制作|做|实现|产出)?$/i,
|
|
131
|
+
plan: ['Write/Edit(写内容)'],
|
|
132
|
+
note: '这是一个要产出文档/计划的写作任务,直接 Write 或 Edit 写入目标文件。',
|
|
133
|
+
toolHint: 'Write, Edit',
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
pattern: /(找|搜索|查|列出|看看|查看|浏览).*(文件|代码|函数|目录|项目|结构)/i,
|
|
137
|
+
plan: ['Glob(找文件)', 'Grep(搜内容)', 'Read(读文件)'],
|
|
138
|
+
note: '用 Glob 找文件路径,用 Grep 搜内容,用 Read 读文件内容。',
|
|
139
|
+
toolHint: 'Glob, Grep, Read',
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
pattern: /(跑|运行|执行|测试|调试|编译|构建).*(命令|脚本|程序|测试|npm|node|python|项目|build)/i,
|
|
143
|
+
plan: ['Bash(运行命令)'],
|
|
144
|
+
note: '用 Bash 运行命令/脚本/测试,并读取输出。',
|
|
145
|
+
toolHint: 'Bash',
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
pattern: /(改|修|修复|编辑|更新|优化|重构).*(文件|代码|bug|问题|错误|逻辑|接口|函数)/i,
|
|
149
|
+
plan: ['Read(读当前内容)', 'Edit(精确修改)'],
|
|
150
|
+
note: '先 Read 查看当前内容,再用 Edit 精确修改指定位置。',
|
|
151
|
+
toolHint: 'Read, Edit',
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
pattern: /(联网|搜索|查一下|网页|网络|资料|信息|最新|资讯)/i,
|
|
155
|
+
plan: ['WebSearch(搜索)', 'WebFetch(抓网页)'],
|
|
156
|
+
note: '用 WebSearch 搜索,用 WebFetch 抓取具体网页内容。',
|
|
157
|
+
toolHint: 'WebSearch, WebFetch',
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
pattern: /(git|提交|push|pull|commit|pr|github|版本)/i,
|
|
161
|
+
plan: ['Bash(git 命令)', 'GitTool(PR 管理)'],
|
|
162
|
+
note: '用 Bash 执行 git 命令,或用 GitTool 管理 GitHub PR。',
|
|
163
|
+
toolHint: 'Bash, GitTool',
|
|
164
|
+
},
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 根据用户指令识别意图,返回框架建议的动作计划
|
|
169
|
+
*
|
|
170
|
+
* @param {string} userInput — 用户指令
|
|
171
|
+
* @returns {{ matched: boolean, intent: string, note: string, toolHint: string } | null}
|
|
172
|
+
*/
|
|
173
|
+
export function detectIntent(userInput) {
|
|
174
|
+
if (!userInput) return null
|
|
175
|
+
for (const rule of INTENT_RULES) {
|
|
176
|
+
if (rule.pattern.test(userInput)) {
|
|
177
|
+
return {
|
|
178
|
+
matched: true,
|
|
179
|
+
intent: rule.plan.join(' → '),
|
|
180
|
+
note: rule.note,
|
|
181
|
+
toolHint: rule.toolHint,
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return null
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 生成注入到消息里的"意图引导 system 提示"(层 B)
|
|
190
|
+
*
|
|
191
|
+
* 当检测到明确意图时,往上下文中插入一条引导,告诉模型该用哪些工具,
|
|
192
|
+
* 降低小模型的决策负担。
|
|
193
|
+
*
|
|
194
|
+
* @param {string} userInput
|
|
195
|
+
* @param {object} [opts]
|
|
196
|
+
* @param {boolean} [opts.enable=true] — 是否启用意图引导
|
|
197
|
+
* @returns {string|null} 引导文本;未匹配或未启用返回 null
|
|
198
|
+
*/
|
|
199
|
+
export function buildIntentGuidance(userInput, opts = {}) {
|
|
200
|
+
if (opts.enable === false) return null
|
|
201
|
+
const intent = detectIntent(userInput)
|
|
202
|
+
if (!intent) return null
|
|
203
|
+
return `[任务引导] 根据用户指令,建议按此思路用工具推进:${intent.intent}。\n说明:${intent.note}\n可用工具:${intent.toolHint}`
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 精简工具列表(层 A)— 小模型一次看太多工具会混乱
|
|
208
|
+
*
|
|
209
|
+
* 从完整工具列表里,按用户指令筛选出最相关的核心工具子集。
|
|
210
|
+
* 这样模型的工具选择负担小,更不容易乱调/漏调。
|
|
211
|
+
*
|
|
212
|
+
* @param {Array} allTools — 完整 ToolDef 列表
|
|
213
|
+
* @param {string} userInput — 用户指令
|
|
214
|
+
* @param {object} [opts]
|
|
215
|
+
* @param {boolean} [opts.enable=true] — 是否启用精简
|
|
216
|
+
* @returns {Array} 精简后的工具列表
|
|
217
|
+
*/
|
|
218
|
+
export function selectRelevantTools(allTools, userInput, opts = {}) {
|
|
219
|
+
if (opts.enable === false) return allTools
|
|
220
|
+
if (!allTools || allTools.length === 0) return allTools
|
|
221
|
+
|
|
222
|
+
const intent = detectIntent(userInput)
|
|
223
|
+
// 未识别到明确意图 → 返回核心工具(不塞满 16 个)
|
|
224
|
+
const coreNames = intent
|
|
225
|
+
? intent.toolHint.split(',').map(s => s.trim())
|
|
226
|
+
: ['Bash', 'Read', 'Edit', 'Write', 'Glob', 'Grep']
|
|
227
|
+
|
|
228
|
+
// 核心工具优先,保留指定工具;若一个都没命中则退回首屏核心集
|
|
229
|
+
const selected = allTools.filter(t => coreNames.includes(t.name))
|
|
230
|
+
if (selected.length === 0) {
|
|
231
|
+
return allTools.filter(t => ['Bash', 'Read', 'Edit', 'Write', 'Glob', 'Grep'].includes(t.name))
|
|
232
|
+
}
|
|
233
|
+
return selected
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 小模型模式开关判断
|
|
238
|
+
* @param {object} engineConfig — QueryEngineConfig 实例
|
|
239
|
+
* @returns {boolean}
|
|
240
|
+
*/
|
|
241
|
+
export function isSmallModelEnabled(engineConfig) {
|
|
242
|
+
return !!(engineConfig && engineConfig.smallModel)
|
|
243
|
+
}
|