Files
ai-terminal-assistant/src/context/compaction.ts
T
kurihada c6f8ba95ec feat: 添加对话压缩功能和上下文使用情况显示
- 新增 context 模块实现 Prune 和 Compaction 压缩策略
- Prune: 将旧工具调用结果替换为占位符
- Compaction: 使用 AI 生成对话摘要
- CLI 提示符显示上下文使用量 [used/available]
- 添加 /compact 命令手动压缩对话
- 添加 /context 命令查看上下文详情
- Agent 集成自动压缩 (85%阈值) 和强制压缩功能
2025-12-11 10:59:43 +08:00

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,
};
}