Skip to content

Commit 72a638c

Browse files
committed
Version 1.0.3 is expected to be released.
1 parent 3a07976 commit 72a638c

12 files changed

Lines changed: 493 additions & 29 deletions

File tree

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,4 +67,6 @@ SCHEDULE.md
6767
tree.md
6868
tree.pdf
6969

70-
_engine/
70+
_engine/
71+
72+
.qdrant-initialized

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22

33
## zh
44

5-
- 提供了在线访问Agent的功能
5+
- 提供了在线访问Agent的功能.
6+
- 优化了上下文压缩.
67

78
## en
89

910
- Provides online access to the Agent.
11+
- Optimized context compression.
1012

1113

1214
# V1.0.2

lib/core/agent/agent_context.dart

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import 'hooks/memory_recall_hook.dart';
3232
import 'hooks/memory_store_hook.dart';
3333
import 'middleware/logging_middleware.dart';
3434
import 'middleware/permission_middleware.dart';
35+
import 'transformers/context_compactor.dart';
3536

3637
/// Agent 执行上下文 — 封装一次对话所需的全部运行时资源
3738
///
@@ -272,7 +273,17 @@ class AgentContextBuilder {
272273
systemPromptPrefix: systemPromptPrefix,
273274
skillsSection: skillsSection,
274275
messages: existingMessages,
275-
messagePipeline: const MessagePipeline(), // 默认空管线,零开销透传
276+
messagePipeline: MessagePipeline([
277+
if (effectiveStore && memoryManager != null && memoryCollection != null)
278+
ContextCompactor(
279+
llm: llm,
280+
memoryManager: memoryManager,
281+
memoryCollection: memoryCollection,
282+
useQdrant: useQdrant,
283+
// contextWindow 为 0 时,ContextCompactor 用 128K 兜底
284+
contextWindow: modelCard.contextWindow,
285+
),
286+
])
276287
);
277288
}
278289

Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
import 'dart:math';
2+
3+
import '../../llm/llm_client.dart';
4+
import '../../logger/app_logger.dart';
5+
import '../../memory/memory_manager.dart';
6+
import '../message_transformer.dart';
7+
8+
/// 上下文压缩变换器
9+
///
10+
/// 当消息列表的估算 token 数超过阈值时,自动触发压缩:
11+
/// 1. 将即将丢弃的早期消息沉淀到长期记忆(Qdrant/SQLite)
12+
/// 2. 调用 LLM 对早期消息生成摘要
13+
/// 3. 用摘要替换原始消息,保留最近 N 轮完整对话
14+
///
15+
/// 特色:压缩前的"沉淀"步骤确保信息不会真正丢失——
16+
/// 后续对话如果语义相关,MemoryRecallHook 能从长期记忆中召回。
17+
class ContextCompactor extends MessageTransformer {
18+
/// 用于生成摘要的 LLM(复用当前对话模型或指定轻量模型)
19+
final LlmClient llm;
20+
21+
/// 长期记忆管理器(可选,有则执行沉淀)
22+
final MemoryManager? memoryManager;
23+
24+
/// 记忆 collection 名称
25+
final String? memoryCollection;
26+
27+
/// 是否使用 Qdrant 向量存储
28+
final bool useQdrant;
29+
30+
/// 触发压缩的 token 阈值。
31+
/// 当 > 0 时使用该固定值;当 == 0 时表示由 [contextWindow] 动态计算。
32+
final int tokenThreshold;
33+
34+
/// 模型上下文窗口大小 (token)。用于动态计算压缩阈值。
35+
/// 0 表示未知——此时退回保守默认值。
36+
final int contextWindow;
37+
38+
/// 兜底默认上下文窗口 (128K),用于 contextWindow 未知时计算阈值。
39+
/// 当今主流模型最低都有 128K,安全兜底。
40+
static const int _defaultContextWindow = 128000;
41+
42+
/// 基于模型 context window 计算动态阈值的比例。
43+
/// 触发压缩 = contextWindow * ratio。
44+
/// 预留 40% 给 system prompt + 工具定义 + 保真区 + LLM 回复空间。
45+
static const double _thresholdRatio = 0.6;
46+
47+
/// 动态计算出的实际阈值
48+
int get effectiveThreshold {
49+
if (tokenThreshold > 0) return tokenThreshold;
50+
final ctx = contextWindow > 0 ? contextWindow : _defaultContextWindow;
51+
return (ctx * _thresholdRatio).toInt();
52+
}
53+
54+
/// 压缩后保留最近多少轮对话(一轮 = user + assistant)
55+
final int keepRecentTurns;
56+
57+
/// 摘要的目标最大 token 数
58+
final int summaryMaxTokens;
59+
60+
/// 是否已在当前 pipeline 调用中执行过压缩(防止同一轮重复压缩)
61+
bool _compactedThisRound = false;
62+
63+
ContextCompactor({
64+
required this.llm,
65+
this.memoryManager,
66+
this.memoryCollection,
67+
this.useQdrant = false,
68+
this.tokenThreshold = 0,
69+
this.contextWindow = 0,
70+
this.keepRecentTurns = 6,
71+
this.summaryMaxTokens = 800,
72+
});
73+
74+
@override
75+
String get name => 'ContextCompactor';
76+
77+
@override
78+
bool shouldActivate(List<Map<String, dynamic>> messages) {
79+
// 消息太少不需要压缩
80+
if (messages.length < (keepRecentTurns * 2 + 3)) return false;
81+
// 估算 token 超阈值才激活
82+
final threshold = effectiveThreshold;
83+
final tokens = _estimateTokens(messages);
84+
if (tokens > threshold && !_compactedThisRound) {
85+
AppLogger.instance.log(
86+
'[Compactor] 触发压缩: 估算 $tokens tokens > 阈值 $threshold'
87+
'${contextWindow > 0 ? " (contextWindow=$contextWindow, ratio=$_thresholdRatio)" : " (默认阈值)"}',
88+
);
89+
return true;
90+
}
91+
return false;
92+
}
93+
94+
@override
95+
Future<List<Map<String, dynamic>>> transform(
96+
List<Map<String, dynamic>> messages,
97+
) async {
98+
_compactedThisRound = true;
99+
100+
// ─── 分区 ───
101+
// system (第一条) | 可压缩区 | 保真区 (最近 N 轮)
102+
final system = messages.first;
103+
final recentCount = _findRecentBoundary(messages);
104+
final compressible = messages.sublist(1, messages.length - recentCount);
105+
final recent = messages.sublist(messages.length - recentCount);
106+
107+
if (compressible.isEmpty) {
108+
AppLogger.instance.log('[Compactor] 可压缩区为空,跳过');
109+
return messages;
110+
}
111+
112+
AppLogger.instance.log(
113+
'[Compactor] 分区: system=1, 压缩区=${compressible.length}条, '
114+
'保真区=${recent.length}条',
115+
);
116+
117+
// ─── 第一步:沉淀到长期记忆 ───
118+
if (memoryManager != null && memoryCollection != null) {
119+
await _sinkToMemory(compressible);
120+
}
121+
122+
// ─── 第二步:生成摘要 ───
123+
final summary = await _summarize(compressible);
124+
125+
// ─── 第三步:重组消息列表 ───
126+
final result = <Map<String, dynamic>>[
127+
system,
128+
{
129+
'role': 'system',
130+
'content': '[以下是之前对话的摘要,供你参考上下文]\n$summary',
131+
},
132+
...recent,
133+
];
134+
135+
final newTokens = _estimateTokens(result);
136+
AppLogger.instance.log(
137+
'[Compactor] 压缩完成: ${messages.length}条→${result.length}条, '
138+
'估算 token: $newTokens',
139+
);
140+
141+
return result;
142+
}
143+
144+
/// 重置轮次标记(每次新消息开始时由外部重置)
145+
void resetRound() => _compactedThisRound = false;
146+
147+
// ─── 私有方法 ─────────────────────────────────────────────
148+
149+
/// 估算消息列表的 token 数(粗略:中文 ~1.5 token/字,英文 ~0.75 token/词)
150+
int _estimateTokens(List<Map<String, dynamic>> messages) {
151+
int total = 0;
152+
for (final msg in messages) {
153+
final content = msg['content'];
154+
if (content is String) {
155+
total += _estimateStringTokens(content);
156+
} else if (content is List) {
157+
// Multimodal content parts
158+
for (final part in content) {
159+
if (part is Map && part['type'] == 'text') {
160+
total += _estimateStringTokens(part['text'] as String? ?? '');
161+
}
162+
}
163+
}
164+
// tool_calls 参数也算 token
165+
if (msg['tool_calls'] is List) {
166+
for (final tc in msg['tool_calls'] as List) {
167+
final args = tc['function']?['arguments'] as String? ?? '';
168+
total += _estimateStringTokens(args);
169+
}
170+
}
171+
total += 4; // 每条消息的元数据开销 (role, separators)
172+
}
173+
return total;
174+
}
175+
176+
int _estimateStringTokens(String text) {
177+
if (text.isEmpty) return 0;
178+
// 混合语言估算:统计中文字符占比,加权计算
179+
int cjk = 0;
180+
for (final c in text.runes) {
181+
if (c >= 0x4E00 && c <= 0x9FFF) cjk++;
182+
}
183+
final ratio = cjk / max(1, text.length);
184+
// 中文 ~1.5 token/字, 英文 ~0.25 token/字符 (≈4 chars/token)
185+
return ((text.length * (ratio * 1.5 + (1 - ratio) * 0.25)) + 1).toInt();
186+
}
187+
188+
/// 找到保真区的起始位置:保留最近 keepRecentTurns 轮完整对话
189+
/// 一轮 = user + assistant(可能包含中间的 tool 消息)
190+
int _findRecentBoundary(List<Map<String, dynamic>> messages) {
191+
int turnsFound = 0;
192+
int idx = messages.length - 1;
193+
194+
while (idx > 0 && turnsFound < keepRecentTurns) {
195+
final role = messages[idx]['role'] as String?;
196+
if (role == 'user') turnsFound++;
197+
idx--;
198+
}
199+
200+
// idx+1 是保真区的起始位置,确保不包含 system (index 0)
201+
final boundary = messages.length - (idx + 1);
202+
return min(boundary, messages.length - 1); // 至少保留 system
203+
}
204+
205+
/// 将即将被压缩的消息沉淀到长期记忆
206+
///
207+
/// 每 3 轮对话为一组,调用 LLM 提取值得记忆的信息并存储。
208+
/// 这确保了即使摘要被再次压缩,重要信息仍可通过语义检索召回。
209+
Future<void> _sinkToMemory(List<Map<String, dynamic>> messages) async {
210+
AppLogger.instance.log(
211+
'[Compactor] 开始记忆沉淀: ${messages.length}条消息',
212+
);
213+
214+
// 按 6 条为一组(约 3 轮对话)批量处理
215+
const batchSize = 6;
216+
int stored = 0;
217+
218+
for (int i = 0; i < messages.length; i += batchSize) {
219+
final batch = messages.sublist(i, min(i + batchSize, messages.length));
220+
final text = _messagesToText(batch);
221+
if (text.length < 50) continue; // 太短跳过
222+
223+
try {
224+
final extractPrompt = [
225+
{
226+
'role': 'system',
227+
'content':
228+
'你是一个记忆提取器。分析下面的对话片段,提取所有值得长期记住的信息'
229+
'(如:用户偏好、技术决策、项目约定、重要结论、配置信息、关键事实)。\n\n'
230+
'如果有多条值得记忆的信息,每条一行输出,保持简洁(方便日后语义检索)。\n'
231+
'如果没有值得记住的信息(普通闲聊、一次性问答),只输出: SKIP',
232+
},
233+
{'role': 'user', 'content': text},
234+
];
235+
236+
final response = await llm.chat(extractPrompt);
237+
final result = response.content?.trim() ?? '';
238+
239+
if (result.isEmpty || result.toUpperCase().startsWith('SKIP')) continue;
240+
241+
// 每条记忆单独存储(方便精确召回)
242+
final memories = result.split('\n').where((l) => l.trim().isNotEmpty);
243+
for (final memory in memories) {
244+
final cleanMemory = memory.replaceFirst(RegExp(r'^[-•]\s*'), '');
245+
if (cleanMemory.length < 10) continue;
246+
await memoryManager!.store(
247+
text: cleanMemory,
248+
collectionName: memoryCollection!,
249+
useQdrant: useQdrant,
250+
metadata: {
251+
'source': 'compaction_sink',
252+
'batch_index': i ~/ batchSize,
253+
},
254+
);
255+
stored++;
256+
}
257+
} catch (e) {
258+
AppLogger.instance.log('[Compactor] 沉淀批次失败: $e');
259+
}
260+
}
261+
262+
AppLogger.instance.log('[Compactor] 沉淀完成: 存入 $stored 条记忆');
263+
}
264+
265+
/// 生成对话摘要
266+
Future<String> _summarize(List<Map<String, dynamic>> messages) async {
267+
final text = _messagesToText(messages);
268+
269+
AppLogger.instance.log(
270+
'[Compactor] 生成摘要: 原文 ${text.length} 字符',
271+
);
272+
273+
try {
274+
final response = await llm.chat([
275+
{
276+
'role': 'system',
277+
'content':
278+
'你是一个对话摘要生成器。将下面的对话历史压缩为一段简洁的摘要。\n\n'
279+
'要求:\n'
280+
'- 保留关键信息:用户的需求、做出的决策、重要结论\n'
281+
'- 保留技术细节:涉及的文件、函数、配置、错误信息\n'
282+
'- 保留进度状态:已完成什么、正在进行什么、待办事项\n'
283+
'- 使用简洁的要点格式\n'
284+
'- 控制在 400 字以内\n'
285+
'- 直接输出摘要内容,不要前缀或解释',
286+
},
287+
{'role': 'user', 'content': text},
288+
]);
289+
290+
final summary = response.content?.trim() ?? '';
291+
AppLogger.instance.log(
292+
'[Compactor] 摘要生成完成: ${summary.length} 字符',
293+
);
294+
return summary.isEmpty ? _fallbackSummary(messages) : summary;
295+
} catch (e) {
296+
AppLogger.instance.log('[Compactor] 摘要生成失败: $e, 使用降级方案');
297+
return _fallbackSummary(messages);
298+
}
299+
}
300+
301+
/// 降级摘要:LLM 调用失败时,取每轮对话的首 50 字符
302+
String _fallbackSummary(List<Map<String, dynamic>> messages) {
303+
final lines = <String>[];
304+
for (final msg in messages) {
305+
final role = msg['role'] as String? ?? '';
306+
if (role == 'user' || role == 'assistant') {
307+
final content = msg['content'];
308+
final text = content is String ? content : '';
309+
if (text.isNotEmpty) {
310+
final preview = text.length > 80 ? '${text.substring(0, 80)}...' : text;
311+
lines.add('[$role] $preview');
312+
}
313+
}
314+
}
315+
return lines.take(20).join('\n');
316+
}
317+
318+
/// 将消息列表格式化为可读文本(供 LLM 阅读)
319+
String _messagesToText(List<Map<String, dynamic>> messages) {
320+
final buffer = StringBuffer();
321+
for (final msg in messages) {
322+
final role = msg['role'] as String? ?? 'unknown';
323+
final content = msg['content'];
324+
String text = '';
325+
if (content is String) {
326+
text = content;
327+
} else if (content is List) {
328+
text = (content)
329+
.where((p) => p is Map && p['type'] == 'text')
330+
.map((p) => p['text'] as String? ?? '')
331+
.join(' ');
332+
}
333+
if (text.isEmpty) continue;
334+
// 截断过长的单条消息(避免摘要输入过大)
335+
if (text.length > 500) text = '${text.substring(0, 500)}...';
336+
buffer.writeln('[$role]: $text');
337+
}
338+
return buffer.toString();
339+
}
340+
}

0 commit comments

Comments
 (0)