c6f8ba95ec
- 新增 context 模块实现 Prune 和 Compaction 压缩策略 - Prune: 将旧工具调用结果替换为占位符 - Compaction: 使用 AI 生成对话摘要 - CLI 提示符显示上下文使用量 [used/available] - 添加 /compact 命令手动压缩对话 - 添加 /context 命令查看上下文详情 - Agent 集成自动压缩 (85%阈值) 和强制压缩功能
197 lines
5.8 KiB
TypeScript
197 lines
5.8 KiB
TypeScript
import { generateText, type ModelMessage, type LanguageModel } from 'ai';
|
|
import { TokenCounter } from './token-counter.js';
|
|
import {
|
|
SUMMARY_MARKER,
|
|
type CompressionConfig,
|
|
DEFAULT_COMPRESSION_CONFIG,
|
|
} from './types.js';
|
|
|
|
/**
|
|
* 摘要生成系统提示词
|
|
*/
|
|
const COMPACTION_SYSTEM_PROMPT = `你是一个专门生成对话摘要的助手。你的任务是将对话历史压缩成一个简洁但信息完整的摘要。
|
|
|
|
摘要应该包含:
|
|
1. 已完成的工作和关键结果
|
|
2. 当前正在进行的任务
|
|
3. 涉及的重要文件和代码
|
|
4. 用户的关键需求和约束
|
|
5. 下一步需要做的事情
|
|
|
|
要求:
|
|
- 保留关键技术细节(文件路径、函数名、配置等)
|
|
- 使用简洁的列表格式
|
|
- 不要遗漏重要信息
|
|
- 使用中文回复`;
|
|
|
|
/**
|
|
* 摘要生成用户提示词
|
|
*/
|
|
const COMPACTION_USER_PROMPT = `请总结上面的对话。这个摘要将是对话继续时唯一可用的上下文,所以要保留关键信息,包括:完成了什么、正在进行的工作、涉及的文件、下一步计划、以及用户的关键需求或约束。要简洁但足够详细,以便工作可以无缝继续。`;
|
|
|
|
/**
|
|
* 检查消息是否为摘要消息
|
|
*/
|
|
export function isSummaryMessage(message: ModelMessage): boolean {
|
|
if (typeof message.content === 'string') {
|
|
return message.content.includes(SUMMARY_MARKER);
|
|
}
|
|
if (Array.isArray(message.content)) {
|
|
return message.content.some(
|
|
(part) =>
|
|
typeof part === 'object' &&
|
|
'text' in part &&
|
|
typeof part.text === 'string' &&
|
|
part.text.includes(SUMMARY_MARKER)
|
|
);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* 创建摘要消息
|
|
*/
|
|
function createSummaryMessage(summary: string): ModelMessage {
|
|
return {
|
|
role: 'assistant',
|
|
content: `${SUMMARY_MARKER}\n## 对话摘要\n\n${summary}\n${SUMMARY_MARKER}`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Compaction 策略:使用 AI 生成对话摘要
|
|
*
|
|
* 逻辑:
|
|
* 1. 将历史消息(排除最近保护的部分)发送给 AI
|
|
* 2. AI 生成摘要
|
|
* 3. 用摘要消息替换旧消息
|
|
* 4. 保留最近的消息不变
|
|
*
|
|
* @param messages 消息数组
|
|
* @param model 语言模型
|
|
* @param config 压缩配置
|
|
* @returns 压缩后的消息数组和释放的 tokens
|
|
*/
|
|
export async function compact(
|
|
messages: ModelMessage[],
|
|
model: LanguageModel,
|
|
config: CompressionConfig = DEFAULT_COMPRESSION_CONFIG
|
|
): Promise<{ messages: ModelMessage[]; freedTokens: number }> {
|
|
const { pruneProtect } = config;
|
|
|
|
// 计算需要保护的消息数量
|
|
let protectedTokens = 0;
|
|
let protectedCount = 0;
|
|
|
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
const tokens = TokenCounter.estimateMessage(messages[i]);
|
|
if (protectedTokens + tokens > pruneProtect) {
|
|
break;
|
|
}
|
|
protectedTokens += tokens;
|
|
protectedCount++;
|
|
}
|
|
|
|
// 确保至少保护最后 2 条消息(除非 pruneProtect 为 0,表示强制压缩模式)
|
|
if (pruneProtect > 0) {
|
|
protectedCount = Math.max(protectedCount, 2);
|
|
} else {
|
|
// 强制压缩模式:至少保护 1 条消息
|
|
protectedCount = Math.max(protectedCount, 1);
|
|
}
|
|
|
|
// 分割消息:需要压缩的部分 vs 保护的部分
|
|
const toCompact = messages.slice(0, messages.length - protectedCount);
|
|
const toKeep = messages.slice(messages.length - protectedCount);
|
|
|
|
// 如果没有需要压缩的消息,直接返回
|
|
if (toCompact.length === 0) {
|
|
return { messages, freedTokens: 0 };
|
|
}
|
|
|
|
// 检查是否已有摘要消息
|
|
const existingSummaryIndex = toCompact.findIndex(isSummaryMessage);
|
|
const messagesForSummary =
|
|
existingSummaryIndex >= 0 ? toCompact.slice(existingSummaryIndex) : toCompact;
|
|
|
|
// 计算压缩前的 tokens
|
|
const beforeTokens = TokenCounter.estimateMessages(toCompact);
|
|
|
|
try {
|
|
// 调用 AI 生成摘要
|
|
const result = await generateText({
|
|
model,
|
|
system: COMPACTION_SYSTEM_PROMPT,
|
|
messages: [
|
|
...messagesForSummary,
|
|
{
|
|
role: 'user',
|
|
content: COMPACTION_USER_PROMPT,
|
|
},
|
|
],
|
|
maxOutputTokens: 2000,
|
|
});
|
|
|
|
const summaryMessage = createSummaryMessage(result.text);
|
|
const afterTokens = TokenCounter.estimateMessage(summaryMessage);
|
|
|
|
// 返回:摘要 + 保护的消息
|
|
return {
|
|
messages: [summaryMessage, ...toKeep],
|
|
freedTokens: beforeTokens - afterTokens,
|
|
};
|
|
} catch (error) {
|
|
console.error('生成摘要失败:', error);
|
|
// 失败时返回原消息
|
|
return { messages, freedTokens: 0 };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 简单压缩:不使用 AI,直接截断旧消息
|
|
* 用于没有模型可用或快速压缩的场景
|
|
*/
|
|
export function simpleCompact(
|
|
messages: ModelMessage[],
|
|
config: CompressionConfig = DEFAULT_COMPRESSION_CONFIG
|
|
): { messages: ModelMessage[]; freedTokens: number } {
|
|
const { pruneProtect } = config;
|
|
|
|
// 计算需要保留的消息
|
|
let keptTokens = 0;
|
|
let keepFromIndex = messages.length;
|
|
|
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
const tokens = TokenCounter.estimateMessage(messages[i]);
|
|
if (keptTokens + tokens > pruneProtect) {
|
|
break;
|
|
}
|
|
keptTokens += tokens;
|
|
keepFromIndex = i;
|
|
}
|
|
|
|
// 确保至少保留最后 N 条消息(强制模式下保留 1 条,否则保留 2 条)
|
|
const minKeep = pruneProtect > 0 ? 2 : 1;
|
|
keepFromIndex = Math.min(keepFromIndex, messages.length - minKeep);
|
|
|
|
const removed = messages.slice(0, keepFromIndex);
|
|
const kept = messages.slice(keepFromIndex);
|
|
|
|
if (removed.length === 0) {
|
|
return { messages, freedTokens: 0 };
|
|
}
|
|
|
|
// 创建简单摘要
|
|
const simpleSummary: ModelMessage = {
|
|
role: 'assistant',
|
|
content: `${SUMMARY_MARKER}\n[对话历史已压缩,共移除 ${removed.length} 条消息]\n${SUMMARY_MARKER}`,
|
|
};
|
|
|
|
const freedTokens = TokenCounter.estimateMessages(removed);
|
|
|
|
return {
|
|
messages: [simpleSummary, ...kept],
|
|
freedTokens,
|
|
};
|
|
}
|