1943 lines
72 KiB
TypeScript
1943 lines
72 KiB
TypeScript
/**
|
||
* Context Manager - 智能上下文管理 (v5.0)
|
||
* 三层策略:滑动窗口 + LLM 摘要压缩 + 记忆注入
|
||
* 支持自动压缩与手动 /compress 触发
|
||
*/
|
||
|
||
import type { OllamaMessage, OllamaStreamChunk, OllamaChatParams } from '../types.js';
|
||
import { logInfo, logWarn, logSuccess, logError } from './log-service.js';
|
||
|
||
// ── R12: 压缩去重 — 内容指纹追踪 ──
|
||
|
||
/** DJB2 哈希函数,用于消息内容指纹 */
|
||
function djb2Hash(str: string): string {
|
||
let hash = 5381;
|
||
for (let i = 0; i < str.length; i++) {
|
||
hash = ((hash << 5) + hash + str.charCodeAt(i)) & 0x7fffffff;
|
||
}
|
||
return hash.toString(36);
|
||
}
|
||
|
||
/** 记录已压缩消息批次的内容指纹,避免重复压缩相同内容 */
|
||
const _compressedContentHashes = new Set<string>();
|
||
const MAX_COMPRESSED_HASHES = 100;
|
||
|
||
/** R12: 计算消息批次的内容指纹 */
|
||
function computeMessagesHash(messages: OllamaMessage[]): string {
|
||
const content = messages.map(m => `${m.role}:${(m.content || '').slice(0, 200)}`).join('|');
|
||
return djb2Hash(content);
|
||
}
|
||
|
||
/** R12: 检查消息批次是否已被压缩过 */
|
||
function isAlreadyCompressed(messages: OllamaMessage[]): boolean {
|
||
if (messages.length === 0) return true;
|
||
const hash = computeMessagesHash(messages);
|
||
return _compressedContentHashes.has(hash);
|
||
}
|
||
|
||
/** R12: 记录已压缩的消息批次指纹 */
|
||
function markAsCompressed(messages: OllamaMessage[]): void {
|
||
const hash = computeMessagesHash(messages);
|
||
_compressedContentHashes.add(hash);
|
||
// LRU 式淘汰:超过上限时移除最早的
|
||
if (_compressedContentHashes.size > MAX_COMPRESSED_HASHES) {
|
||
const firstKey = _compressedContentHashes.values().next().value;
|
||
if (firstKey) _compressedContentHashes.delete(firstKey);
|
||
}
|
||
}
|
||
|
||
// ── R18: 上下文使用率预测 ──
|
||
|
||
/** 上下文使用率趋势数据点 */
|
||
interface TokenUsagePoint {
|
||
turn: number;
|
||
tokens: number;
|
||
numCtx: number;
|
||
timestamp: number;
|
||
}
|
||
|
||
const _tokenUsageTrend: TokenUsagePoint[] = [];
|
||
const MAX_TREND_POINTS = 20;
|
||
let _currentTurn = 0;
|
||
|
||
/** R18: 上下文预警级别 */
|
||
export type ContextWarningLevel = 'safe' | 'notice' | 'warning' | 'critical';
|
||
|
||
/** R18: 上下文预测结果 */
|
||
export interface ContextPrediction {
|
||
level: ContextWarningLevel;
|
||
currentUsage: number; // 0-1
|
||
predictedUsage: number; // 预测下一轮的使用率
|
||
turnsToOverflow: number; // 预计多少轮后溢出(-1 表示不会溢出)
|
||
message: string;
|
||
}
|
||
|
||
/** R18: 记录当前轮次的 token 使用量 */
|
||
export function recordTokenUsage(tokens: number, numCtx: number): void {
|
||
_currentTurn++;
|
||
_tokenUsageTrend.push({
|
||
turn: _currentTurn,
|
||
tokens,
|
||
numCtx,
|
||
timestamp: Date.now(),
|
||
});
|
||
if (_tokenUsageTrend.length > MAX_TREND_POINTS) {
|
||
_tokenUsageTrend.shift();
|
||
}
|
||
}
|
||
|
||
/** R18: 预测上下文溢出风险 */
|
||
export function predictContextOverflow(numCtx: number): ContextPrediction {
|
||
const currentTokens = _tokenUsageTrend.length > 0
|
||
? _tokenUsageTrend[_tokenUsageTrend.length - 1].tokens
|
||
: 0;
|
||
const currentUsage = currentTokens / numCtx;
|
||
|
||
// 至少需要 3 个数据点才能做线性回归预测
|
||
if (_tokenUsageTrend.length < 3) {
|
||
const level: ContextWarningLevel = currentUsage > 0.8 ? 'critical'
|
||
: currentUsage > 0.6 ? 'warning'
|
||
: currentUsage > 0.4 ? 'notice'
|
||
: 'safe';
|
||
return {
|
||
level,
|
||
currentUsage,
|
||
predictedUsage: currentUsage,
|
||
turnsToOverflow: -1,
|
||
message: level === 'safe' ? '' : `当前上下文使用率 ${(currentUsage * 100).toFixed(0)}%`,
|
||
};
|
||
}
|
||
|
||
// 线性回归:y = ax + b,预测未来 token 增长
|
||
const n = _tokenUsageTrend.length;
|
||
const xs = _tokenUsageTrend.map(p => p.turn);
|
||
const ys = _tokenUsageTrend.map(p => p.tokens);
|
||
const xMean = xs.reduce((s, x) => s + x, 0) / n;
|
||
const yMean = ys.reduce((s, y) => s + y, 0) / n;
|
||
let numerator = 0, denominator = 0;
|
||
for (let i = 0; i < n; i++) {
|
||
numerator += (xs[i] - xMean) * (ys[i] - yMean);
|
||
denominator += (xs[i] - xMean) ** 2;
|
||
}
|
||
const slope = denominator !== 0 ? numerator / denominator : 0;
|
||
const intercept = yMean - slope * xMean;
|
||
|
||
// 预测下一轮
|
||
const nextTurn = _currentTurn + 1;
|
||
const predictedTokens = Math.max(0, slope * nextTurn + intercept);
|
||
const predictedUsage = predictedTokens / numCtx;
|
||
|
||
// 计算预计多少轮后溢出
|
||
let turnsToOverflow = -1;
|
||
if (slope > 0) {
|
||
turnsToOverflow = Math.ceil((numCtx - intercept) / slope - _currentTurn);
|
||
if (turnsToOverflow < 0) turnsToOverflow = 0;
|
||
}
|
||
|
||
// 确定预警级别
|
||
const maxUsage = Math.max(currentUsage, predictedUsage);
|
||
let level: ContextWarningLevel;
|
||
let message = '';
|
||
|
||
if (maxUsage > 0.85 || turnsToOverflow === 0) {
|
||
level = 'critical';
|
||
message = `⚠️ 上下文即将溢出!当前 ${currentTokens}/${numCtx} tokens (${(currentUsage * 100).toFixed(0)}%),预计 ${turnsToOverflow} 轮后溢出`;
|
||
} else if (maxUsage > 0.7 || (turnsToOverflow >= 1 && turnsToOverflow <= 3)) {
|
||
level = 'warning';
|
||
message = `⚠️ 上下文使用率较高 (${(currentUsage * 100).toFixed(0)}%),预计 ${turnsToOverflow} 轮后可能溢出,建议压缩`;
|
||
} else if (maxUsage > 0.5) {
|
||
level = 'notice';
|
||
message = `上下文使用率 ${(currentUsage * 100).toFixed(0)}%,趋势正常`;
|
||
} else {
|
||
level = 'safe';
|
||
}
|
||
|
||
return { level, currentUsage, predictedUsage, turnsToOverflow, message };
|
||
}
|
||
|
||
/** R18: 获取 token 使用趋势数据(供调试用) */
|
||
export function getTokenUsageTrend(): TokenUsagePoint[] {
|
||
return [..._tokenUsageTrend];
|
||
}
|
||
|
||
// ── Token 校准系统 ──
|
||
|
||
/** 校准比例:actualTokens / estimatedTokens,基于 Ollama 返回的实际计数动态修正 */
|
||
let _tokenCalibrationRatio = 1.0;
|
||
let _calibrationSamples = 0;
|
||
let _calibrationModel = ''; // C8: 记录校准时的模型名
|
||
const MIN_CALIBRATION_SAMPLES = 3;
|
||
|
||
/**
|
||
* 记录 Ollama 返回的实际 token 计数,用于校准估算器。
|
||
* 在 agent-engine.ts 每轮流式完成后调用。
|
||
* C8: 模型切换时自动重置校准比例,避免不同 tokenizer 导致估算失真
|
||
* @param actualInputTokens Ollama 返回的 prompt_eval_count
|
||
* @param actualOutputTokens Ollama 返回的 eval_count
|
||
* @param estimatedTokens 本轮消息调用 estimateTokens 的合计值
|
||
* @param modelName 当前使用的模型名
|
||
*/
|
||
export function recordActualTokens(actualInputTokens: number, actualOutputTokens: number, estimatedCount: number, modelName?: string): void {
|
||
// C8: 模型切换时重置校准
|
||
if (modelName && modelName !== _calibrationModel) {
|
||
_calibrationModel = modelName;
|
||
_tokenCalibrationRatio = 1.0;
|
||
_calibrationSamples = 0;
|
||
}
|
||
if (actualInputTokens <= 0 || estimatedCount <= 0) return;
|
||
// 仅用 prompt_eval_count(实际输入 token)与估算值对比,
|
||
// 因为 estimatedCount 只估算消息内容(不含输出 token),加入 eval_count 会导致比值虚高
|
||
const sampleRatio = actualInputTokens / Math.max(1, estimatedCount);
|
||
// 指数移动平均,平滑异常值
|
||
const alpha = 0.3;
|
||
_tokenCalibrationRatio = _tokenCalibrationRatio * (1 - alpha) + sampleRatio * alpha;
|
||
_calibrationSamples++;
|
||
}
|
||
|
||
/** 估算 token 数(自动使用校准后的比例) */
|
||
export function estimateTokens(text: string): number {
|
||
if (!text) return 0;
|
||
// 中文按 1.5 字/token,英文按 4 字符/token
|
||
let chineseChars = 0;
|
||
let otherChars = 0;
|
||
for (const ch of text) {
|
||
if (/[\u4e00-\u9fff]/.test(ch)) chineseChars++;
|
||
else otherChars++;
|
||
}
|
||
const raw = Math.ceil(chineseChars / 1.5 + otherChars / 4);
|
||
// 应用校准比例(仅在有足够样本后)
|
||
if (_calibrationSamples >= MIN_CALIBRATION_SAMPLES) {
|
||
return Math.ceil(raw * _tokenCalibrationRatio);
|
||
}
|
||
return raw;
|
||
}
|
||
|
||
/** 获取当前校准比例(供调试用) */
|
||
export function getTokenCalibration(): { ratio: number; samples: number } {
|
||
return { ratio: _tokenCalibrationRatio, samples: _calibrationSamples };
|
||
}
|
||
|
||
/** 自动压缩阈值:当消息 token 占 context window 比例超过此值时触发自动压缩
|
||
* P2 #7 修复:从 0.3 提高到 0.5,避免过于频繁的压缩导致信息丢失
|
||
*/
|
||
export const AUTO_COMPRESS_THRESHOLD = 0.5;
|
||
|
||
/** R14: 自适应压缩阈值 — 根据模型上下文长度动态调整
|
||
* P2 #7 修复:提高各档位阈值,减少不必要的压缩
|
||
*/
|
||
export function getAdaptiveCompressThreshold(numCtx: number): number {
|
||
// 小上下文模型(<8K):更早触发压缩(55%),留余量
|
||
// 中等上下文(8K-32K):标准阈值(50%)
|
||
// 大上下文(>32K):稍晚触发(45%),避免过于频繁压缩
|
||
if (numCtx < 8192) return 0.55;
|
||
if (numCtx > 32768) return 0.45;
|
||
return AUTO_COMPRESS_THRESHOLD;
|
||
}
|
||
|
||
/** R19: 压缩效果指标 */
|
||
export interface CompressionMetrics {
|
||
beforeMessages: number;
|
||
afterMessages: number;
|
||
beforeTokens: number;
|
||
afterTokens: number;
|
||
compressionRatio: number;
|
||
timestamp: number;
|
||
}
|
||
const _compressionHistory: CompressionMetrics[] = [];
|
||
const MAX_COMPRESSION_HISTORY = 20;
|
||
|
||
/** R19: 获取压缩历史指标 */
|
||
export function getCompressionHistory(): CompressionMetrics[] {
|
||
return [..._compressionHistory];
|
||
}
|
||
|
||
/** R19: 获取平均压缩率 */
|
||
export function getAverageCompressionRatio(): number {
|
||
if (_compressionHistory.length === 0) return 1.0;
|
||
const sum = _compressionHistory.reduce((s, m) => s + m.compressionRatio, 0);
|
||
return sum / _compressionHistory.length;
|
||
}
|
||
|
||
/** 压缩后保留首尾消息数 */
|
||
const COMPRESS_KEEP_HEAD = 5;
|
||
const COMPRESS_KEEP_TAIL = 8;
|
||
|
||
// ── 消息重要性评分(纯规则,不调用 LLM)──
|
||
|
||
/** 重要性评分:0-10,越高越应该保留。SOUL.md 和规则记忆始终保留。 */
|
||
export function scoreMessageImportance(msg: OllamaMessage): number {
|
||
let score = 5; // 默认中等
|
||
|
||
// SOUL.md / 日期 / 环境 消息永远不可压缩
|
||
if (msg.content?.includes('[SOUL.md]') || msg.content?.includes('<<<REFERENCE_DATA_START>>>')) return 10;
|
||
if (msg.content?.startsWith('[日期]')) return 10;
|
||
if (msg.content?.startsWith('[环境]')) return 10;
|
||
// 安全规则提示也不可压缩
|
||
if (msg.content?.includes('安全规则')) return 10;
|
||
|
||
// 角色权重
|
||
if (msg.role === 'user') score += 2; // 用户消息最重要
|
||
if (msg.role === 'system') score -= 2; // 系统消息通常可压缩
|
||
|
||
// ephemeral 临时消息 → 最低权重,优先丢弃
|
||
if (msg.ephemeral) return 0;
|
||
|
||
// 已压缩标记 → 中等权重(C6: 提高从 1 到 4,避免快速摘要被立即丢弃)
|
||
if (msg.compressed) score = 4;
|
||
|
||
const content = msg.content || '';
|
||
|
||
// 关键词检测
|
||
const highValuePatterns = [/路径|目录|path|file|config|配置|命令|command|exec/i,
|
||
/错误|error|失败|fail|bug|fix|修复|解决/i,
|
||
/版本|version|API|http|url|端口|port|localhost/i,
|
||
/记住|保存|memory|偏好|偏好|规则|rule/i,
|
||
/完成|done|✓|success|成功|结果|result/i,
|
||
/项目|project|工作空间|workspace|git|repo|仓库/i,
|
||
];
|
||
// 低价值关键词黑名单已删除 — 误伤边界情况(如"好的,我发现了一个 bug")
|
||
// AI 应自行判断消息价值,长度加分机制已足够区分短回复
|
||
|
||
for (const p of highValuePatterns) {
|
||
if (p.test(content)) { score += 1; break; }
|
||
}
|
||
|
||
// 工具调用 → 高价值
|
||
if (msg.tool_calls?.length) score += 2;
|
||
|
||
// R20: 工具结果类型感知 — 不同工具结果的价值不同
|
||
if (msg.role === 'tool' && msg.tool_name) {
|
||
// 写类工具结果:高价值(记录了操作结果)
|
||
if (/write_file|edit_file|delete_file|create_directory|move_file|copy_file/.test(msg.tool_name)) {
|
||
score += 2;
|
||
}
|
||
// 搜索类工具结果:中等价值
|
||
if (/search_files|web_search/.test(msg.tool_name)) {
|
||
score += 1;
|
||
}
|
||
// 读取类工具结果:中等价值
|
||
if (/read_file|list_directory|tree/.test(msg.tool_name)) {
|
||
score += 1;
|
||
}
|
||
}
|
||
|
||
// 长度加分:长消息通常包含更多信息
|
||
if (content.length > 500) score += 1;
|
||
if (content.length > 2000) score += 1;
|
||
|
||
// 图像附件 → 中等价值(大但语义密度低)
|
||
if (msg.images?.length) score -= 1;
|
||
|
||
return Math.max(1, Math.min(10, score));
|
||
}
|
||
|
||
export interface ContextBuildOptions {
|
||
/** 滑动窗口大小(最近 N 条消息完整保留) */
|
||
windowSize?: number;
|
||
/** 每 N 条更早的消息压缩为一段摘要 */
|
||
summaryBatchSize?: number;
|
||
/** 最大 token 数限制 */
|
||
maxTokens?: number;
|
||
/** 系统 prompt 注入的记忆上下文 */
|
||
memoryContext?: string;
|
||
/** 工作空间上下文 */
|
||
workspaceContext?: string;
|
||
}
|
||
|
||
// ── R16: 增量摘要合并工具函数 ──
|
||
|
||
/** R16: 从已压缩消息中提取结构化摘要 */
|
||
function extractStructuredSummary(compressedMsgs: OllamaMessage[]): StructuredSummary {
|
||
const summary: StructuredSummary = {
|
||
topics: [], decisions: [], pendingTasks: [], constraints: [], knowledge: [], toolResults: [],
|
||
};
|
||
for (const msg of compressedMsgs) {
|
||
const content = msg.content || '';
|
||
// 解析结构化摘要中的各部分
|
||
const topicMatch = content.match(/📌 主题:\s*(.+)/);
|
||
if (topicMatch) summary.topics.push(...topicMatch[1].split(';').filter(Boolean));
|
||
const decisionMatch = content.match(/✅ 决策:\s*(.+)/);
|
||
if (decisionMatch) summary.decisions.push(...decisionMatch[1].split(';').filter(Boolean));
|
||
const pendingMatch = content.match(/⏳ 待办:\s*(.+)/);
|
||
if (pendingMatch) summary.pendingTasks.push(...pendingMatch[1].split(';').filter(Boolean));
|
||
const constraintMatch = content.match(/📏 约束:\s*(.+)/);
|
||
if (constraintMatch) summary.constraints.push(...constraintMatch[1].split(';').filter(Boolean));
|
||
const knowledgeMatch = content.match(/🧠 知识点:\s*(.+)/);
|
||
if (knowledgeMatch) summary.knowledge.push(...knowledgeMatch[1].split(';').filter(Boolean));
|
||
const toolMatch = content.match(/🔧 工具结果:\s*(.+)/);
|
||
if (toolMatch) summary.toolResults.push(...toolMatch[1].split(';').filter(Boolean));
|
||
}
|
||
return summary;
|
||
}
|
||
|
||
/** R16: 合并两个结构化摘要,去重并限制条目数 */
|
||
function mergeSummaries(old_: StructuredSummary, new_: StructuredSummary): StructuredSummary {
|
||
const mergeArrays = (oldArr: string[], newArr: string[], max: number): string[] => {
|
||
// 合并、去重、限制数量(新摘要优先)
|
||
const combined = [...new Set([...newArr, ...oldArr])];
|
||
return combined.slice(0, max);
|
||
};
|
||
return {
|
||
topics: mergeArrays(old_.topics, new_.topics, 4),
|
||
decisions: mergeArrays(old_.decisions, new_.decisions, 3),
|
||
pendingTasks: mergeArrays(old_.pendingTasks, new_.pendingTasks, 3),
|
||
constraints: mergeArrays(old_.constraints, new_.constraints, 3),
|
||
knowledge: mergeArrays(old_.knowledge, new_.knowledge, 3),
|
||
toolResults: mergeArrays(old_.toolResults, new_.toolResults, 3),
|
||
};
|
||
}
|
||
|
||
// ── R17: System 消息分区优化 ──
|
||
|
||
/** R17: 判断 system 消息是否为稳定前缀(不会在会话中改变) */
|
||
function isStableSystemMessage(content: string): boolean {
|
||
// SOUL.md、安全规则、日期、环境信息等 — 在整个会话中不会改变
|
||
return content.includes('[SOUL.md]')
|
||
|| content.includes('<<<REFERENCE_DATA_START>>>')
|
||
|| content.startsWith('[日期]')
|
||
|| content.startsWith('[环境]')
|
||
|| content.includes('安全规则')
|
||
|| content.includes('系统提示')
|
||
|| content.includes('You are'); // 通用系统 prompt
|
||
}
|
||
|
||
/** R17: 对 system 消息排序,稳定部分在前,动态部分在后,支持 LLM Prefix Caching */
|
||
function reorderSystemMessagesForPrefixCaching(messages: OllamaMessage[]): void {
|
||
// 找到所有 system 消息
|
||
const systemIndices: number[] = [];
|
||
for (let i = 0; i < messages.length; i++) {
|
||
if (messages[i].role === 'system') systemIndices.push(i);
|
||
}
|
||
if (systemIndices.length <= 1) return;
|
||
|
||
// 提取并分类
|
||
const stableContents: string[] = [];
|
||
const dynamicContents: string[] = [];
|
||
for (const idx of systemIndices) {
|
||
const content = messages[idx].content || '';
|
||
if (isStableSystemMessage(content)) {
|
||
stableContents.push(content);
|
||
} else {
|
||
dynamicContents.push(content);
|
||
}
|
||
}
|
||
|
||
if (stableContents.length === 0 || dynamicContents.length === 0) return;
|
||
|
||
// 合并稳定部分和动态部分
|
||
const stableContent = stableContents.join('\n\n');
|
||
const dynamicContent = dynamicContents.join('\n\n');
|
||
|
||
// 重写第一条 system 消息为稳定部分,其余 system 消息合并为动态部分
|
||
const firstSysIdx = systemIndices[0];
|
||
messages[firstSysIdx].content = stableContent;
|
||
|
||
// 将其余 system 消息合并为一条动态 system 消息,放在最后一条 system 消息位置
|
||
const lastSysIdx = systemIndices[systemIndices.length - 1];
|
||
if (firstSysIdx !== lastSysIdx) {
|
||
messages[lastSysIdx].content = dynamicContent;
|
||
// 删除中间的 system 消息
|
||
const middleSysIndices = systemIndices.slice(1, -1);
|
||
for (let i = middleSysIndices.length - 1; i >= 0; i--) {
|
||
messages.splice(middleSysIndices[i], 1);
|
||
}
|
||
} else {
|
||
// 只有一条 system 消息时,追加动态内容
|
||
messages[firstSysIdx].content = stableContent + '\n\n' + dynamicContent;
|
||
}
|
||
|
||
logInfo(`R17: System 消息分区完成 — 稳定前缀 ${estimateTokens(stableContent)} tokens, 动态部分 ${estimateTokens(dynamicContent)} tokens`);
|
||
}
|
||
|
||
const DEFAULT_OPTIONS: Required<ContextBuildOptions> = {
|
||
windowSize: 40,
|
||
summaryBatchSize: 30,
|
||
maxTokens: 131072,
|
||
memoryContext: '',
|
||
workspaceContext: ''
|
||
};
|
||
|
||
/**
|
||
* 构建发送给模型的 messages(同步,滑动窗口)
|
||
* 三层策略:
|
||
* a. 滑动窗口:最近 N 条消息完整保留
|
||
* b. 更早的消息:每 N 条压缩为一段摘要(快速文本截取)
|
||
* c. 系统 prompt 注入记忆上下文
|
||
*/
|
||
export function buildContext(
|
||
allMessages: OllamaMessage[],
|
||
options: ContextBuildOptions = {}
|
||
): OllamaMessage[] {
|
||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||
const result: OllamaMessage[] = [];
|
||
|
||
// R17: System 消息分区 — 稳定部分在前,动态部分在后,支持 LLM Prefix Caching
|
||
// 稳定部分:已有的 system 消息(SOUL.md、规则等,不随会话变化)
|
||
// 动态部分:memoryContext、workspaceContext(每轮可能变化)
|
||
const existingSystem = allMessages.filter(m => m.role === 'system');
|
||
const stableParts: string[] = [];
|
||
const dynamicParts: string[] = [];
|
||
|
||
for (const sys of existingSystem) {
|
||
if (isStableSystemMessage(sys.content || '')) {
|
||
stableParts.push(sys.content || '');
|
||
} else {
|
||
dynamicParts.push(sys.content || '');
|
||
}
|
||
}
|
||
// memoryContext 和 workspaceContext 是动态的
|
||
if (opts.memoryContext) dynamicParts.push(opts.memoryContext);
|
||
if (opts.workspaceContext) dynamicParts.push(opts.workspaceContext);
|
||
|
||
let systemContent = '';
|
||
// 稳定前缀优先
|
||
if (stableParts.length > 0) systemContent += stableParts.join('\n\n') + '\n\n';
|
||
// 动态部分在后
|
||
if (dynamicParts.length > 0) systemContent += dynamicParts.join('\n\n') + '\n\n';
|
||
|
||
if (systemContent.trim()) {
|
||
result.push({ role: 'system', content: systemContent.trim() });
|
||
}
|
||
|
||
// 非 system 消息
|
||
const nonSystemMessages = allMessages.filter(m => m.role !== 'system');
|
||
|
||
if (nonSystemMessages.length <= opts.windowSize) {
|
||
result.push(...nonSystemMessages);
|
||
return result;
|
||
}
|
||
|
||
// 滑动窗口:最近 N 条
|
||
const recentMessages = nonSystemMessages.slice(-opts.windowSize);
|
||
|
||
// 更早的消息:已压缩标记的保留原样,未压缩的做快速摘要
|
||
const olderMessages = nonSystemMessages.slice(0, -opts.windowSize);
|
||
const compressedMsgs = olderMessages.filter(m => m.compressed);
|
||
const uncompressedMsgs = olderMessages.filter(m => !m.compressed);
|
||
|
||
// 已压缩的消息直接保留
|
||
result.push(...compressedMsgs);
|
||
|
||
// 未压缩的消息做快速摘要
|
||
if (uncompressedMsgs.length > 0) {
|
||
const summaries = summarizeOlderMessages(uncompressedMsgs, opts.summaryBatchSize);
|
||
result.push(...summaries);
|
||
}
|
||
|
||
result.push(...recentMessages);
|
||
|
||
// Token 估算和裁剪
|
||
const trimmed = trimByTokenLimit(result, opts.maxTokens);
|
||
|
||
logInfo(`上下文构建: ${nonSystemMessages.length} 条消息 → ${trimmed.length} 条 (窗口: ${opts.windowSize})`,
|
||
`估算 tokens: ${estimateTokens(trimmed.map(m => m.content).join(''))}`);
|
||
|
||
return trimmed;
|
||
}
|
||
|
||
/**
|
||
* 判断是否需要自动压缩
|
||
* 当总 token 数超过 context window 的 AUTO_COMPRESS_THRESHOLD 比例时返回 true
|
||
* C3: 包含 tool_calls 和 images 的 token 开销
|
||
* R13: tool_calls 开销按实际参数大小估算而非固定 50
|
||
*/
|
||
export function shouldAutoCompress(messages: OllamaMessage[], numCtx: number): boolean {
|
||
let totalTokens = 0;
|
||
for (const m of messages) {
|
||
totalTokens += estimateTokens(m.content || '');
|
||
// R13: tool_calls 开销按实际 JSON 参数大小估算
|
||
if (m.tool_calls?.length) {
|
||
for (const tc of m.tool_calls) {
|
||
const argsSize = JSON.stringify(tc.function.arguments || {}).length;
|
||
totalTokens += estimateTokens(tc.function.name) + Math.ceil(argsSize / 4) + 20; // 20 tokens overhead per call
|
||
}
|
||
}
|
||
if (m.images?.length) totalTokens += m.images.length * 100;
|
||
}
|
||
// R14: 使用自适应压缩阈值
|
||
const threshold = numCtx * getAdaptiveCompressThreshold(numCtx);
|
||
return totalTokens > threshold;
|
||
}
|
||
|
||
// ── 结构化压缩 ──
|
||
|
||
/** 结构化摘要 */
|
||
export interface StructuredSummary {
|
||
/** 讨论的主题 */
|
||
topics: string[];
|
||
/** 已做出的决定 */
|
||
decisions: string[];
|
||
/** 未完成的待办事项 */
|
||
pendingTasks: string[];
|
||
/** 发现的约束/规则 */
|
||
constraints: string[];
|
||
/** 关键知识点(跨会话有价值的信息) */
|
||
knowledge: string[];
|
||
/** 工具调用结果摘要 */
|
||
toolResults: string[];
|
||
}
|
||
|
||
/**
|
||
* LLM 摘要压缩:调用模型对中间消息生成结构化 JSON 摘要。
|
||
* 保留首尾各 keepHead/keepTail 条消息,中间用 JSON 摘要替换。
|
||
* v6.0: 输出结构化 JSON,支持增量合并。
|
||
*
|
||
* @returns 压缩后的消息列表(包含 compressed 标记的摘要消息)
|
||
*/
|
||
export async function compressWithLLM(
|
||
messages: OllamaMessage[],
|
||
api: { chatStream: (params: OllamaChatParams, onChunk: (chunk: OllamaStreamChunk) => void, abortController?: AbortController) => Promise<void> },
|
||
model: string,
|
||
options: {
|
||
keepHead?: number;
|
||
keepTail?: number;
|
||
maxSummaryTokens?: number;
|
||
abortController?: AbortController;
|
||
} = {}
|
||
): Promise<OllamaMessage[]> {
|
||
const keepHead = options.keepHead ?? COMPRESS_KEEP_HEAD;
|
||
const keepTail = options.keepTail ?? COMPRESS_KEEP_TAIL;
|
||
const maxSummaryTokens = options.maxSummaryTokens ?? 500;
|
||
|
||
// C1: 分离 system 和非 system 消息,再细分不可压缩的 system 消息
|
||
const systemMsgs = messages.filter(m => m.role === 'system');
|
||
const nonSystemMsgs = messages.filter(m => m.role !== 'system');
|
||
const incompressibleSysMsgs: OllamaMessage[] = [];
|
||
const compressibleSysMsgs: OllamaMessage[] = [];
|
||
for (const m of systemMsgs) {
|
||
if (scoreMessageImportance(m) >= 10) {
|
||
incompressibleSysMsgs.push(m);
|
||
} else {
|
||
compressibleSysMsgs.push(m);
|
||
}
|
||
}
|
||
|
||
if (nonSystemMsgs.length <= keepHead + keepTail + 2) {
|
||
logInfo('上下文压缩: 消息太少,跳过压缩');
|
||
return messages;
|
||
}
|
||
|
||
const head = nonSystemMsgs.slice(0, keepHead);
|
||
const tail = nonSystemMsgs.slice(-keepTail);
|
||
const middle = nonSystemMsgs.slice(keepHead, nonSystemMsgs.length - keepTail);
|
||
|
||
// P1-C6 修复:head 中最早的 assistant+tool_calls 组在多次压缩后无法清除,导致 token 膨胀。
|
||
// 对 head 中已压缩过的消息(compressed=true),移除其 tool_calls 和后续 tool 消息(保留 content 作为上下文)。
|
||
// 这些旧工具调用的结果已不再需要,但 assistant 的文本内容仍有上下文价值。
|
||
if (head.length > 0) {
|
||
for (let i = 0; i < head.length; i++) {
|
||
const m = head[i];
|
||
if (m.compressed && m.tool_calls?.length) {
|
||
// 移除 tool_calls(降级为纯文本 assistant)
|
||
const newMsg: OllamaMessage = { ...m };
|
||
delete newMsg.tool_calls;
|
||
head[i] = newMsg;
|
||
}
|
||
if (m.compressed && m.role === 'tool') {
|
||
// 旧的 tool 消息标记为空(保留位置但内容为空,避免破坏数组结构)
|
||
// 后续 mergeConsecutiveMessages 会合并这些空消息
|
||
head[i] = { ...m, content: '', compressed: true };
|
||
}
|
||
}
|
||
// 过滤掉 head 中被清空的 tool 消息
|
||
const filteredHead = head.filter(m => !(m.compressed && m.role === 'tool' && !m.content));
|
||
head.length = 0;
|
||
head.push(...filteredHead);
|
||
}
|
||
|
||
// R15: 对话轮次边界保护 — 调整 head/tail 切分点,避免在对话轮次中间切割
|
||
// 如果 head 末尾是带 tool_calls 的 assistant,将后续 tool 消息也纳入 head
|
||
if (head.length > 0 && head[head.length - 1].tool_calls?.length) {
|
||
let extendIdx = 0;
|
||
while (extendIdx < middle.length && middle[extendIdx].role === 'tool') {
|
||
head.push(middle[extendIdx]);
|
||
extendIdx++;
|
||
}
|
||
middle.splice(0, extendIdx);
|
||
}
|
||
// 如果 tail 开头是 tool 消息(无对应 assistant),向前扩展到包含 assistant
|
||
if (tail.length > 0 && tail[0].role === 'tool') {
|
||
let extendBack = middle.length - 1;
|
||
while (extendBack >= 0 && middle[extendBack].role !== 'assistant') {
|
||
extendBack--;
|
||
}
|
||
if (extendBack >= 0) {
|
||
const moved = middle.splice(extendBack);
|
||
tail.unshift(...moved);
|
||
} else {
|
||
// P1-C3 修复:middle 中无 assistant 时,tail[0] 是孤立 tool 消息,
|
||
// Ollama 会拒绝或忽略。将其从 tail 移除(它无对应 assistant.tool_calls)
|
||
tail.shift();
|
||
}
|
||
}
|
||
|
||
// 过滤掉已经压缩过的消息(避免重复压缩)
|
||
const uncompressedMiddle = middle.filter(m => !m.compressed);
|
||
if (uncompressedMiddle.length === 0) {
|
||
logInfo('上下文压缩: 中间消息已全部压缩,跳过');
|
||
return messages;
|
||
}
|
||
|
||
// R12: 压缩去重 — 检查这批消息是否已被压缩过(内容指纹匹配)
|
||
if (isAlreadyCompressed(uncompressedMiddle)) {
|
||
logInfo('R12: 消息批次内容指纹匹配已压缩记录,跳过重复压缩');
|
||
return messages;
|
||
}
|
||
|
||
// 构建对话文本
|
||
const conversationText = uncompressedMiddle.map(m => {
|
||
const role = m.role === 'user' ? '用户' : 'AI';
|
||
let content = m.content || '';
|
||
if (content.length > 2000) content = content.slice(0, 2000) + '...';
|
||
if (m.tool_calls?.length) {
|
||
const toolNames = m.tool_calls.map(t => t.function.name).join(', ');
|
||
content += ` [工具调用: ${toolNames}]`;
|
||
}
|
||
return `${role}: ${content}`;
|
||
}).join('\n');
|
||
|
||
logInfo(`上下文压缩: 开始 LLM 摘要,${uncompressedMiddle.length} 条消息待压缩`);
|
||
|
||
let summaryJson = '';
|
||
try {
|
||
await api.chatStream(
|
||
{
|
||
model,
|
||
messages: [{
|
||
role: 'user',
|
||
content: `请将以下对话摘要为结构化 JSON。保留关键信息,用中文输出。严格按此 JSON 格式返回(不要输出其他内容):\n\n{\n "topics": ["讨论的 2-4 个核心主题"],\n "decisions": ["已做出的决定(最多 3 条)"],\n "pendingTasks": ["尚未完成的任务(最多 3 条)"],\n "constraints": ["发现的约束/规则/偏好(最多 3 条)"],\n "knowledge": ["跨会话有价值的长期知识点(最多 3 条)"],\n "toolResults": ["关键工具调用结果摘要(最多 3 条)"]\n}\n\n对话记录:\n${conversationText}`
|
||
}],
|
||
stream: true,
|
||
think: false,
|
||
options: { num_ctx: 8192, temperature: 0.3 }
|
||
},
|
||
(chunk: OllamaStreamChunk) => {
|
||
if (chunk.message?.content) {
|
||
summaryJson += chunk.message.content;
|
||
}
|
||
},
|
||
options.abortController
|
||
);
|
||
} catch (err) {
|
||
if ((err as Error).name === 'AbortError') {
|
||
logWarn('上下文压缩: LLM 调用被中止');
|
||
return messages;
|
||
}
|
||
logError('上下文压缩: LLM 调用失败', (err as Error).message);
|
||
return messages;
|
||
}
|
||
|
||
if (!summaryJson.trim()) {
|
||
logWarn('上下文压缩: 模型未返回摘要内容');
|
||
return messages;
|
||
}
|
||
|
||
// 解析 JSON 摘要(容错:提取第一个 JSON 块或直接解析)
|
||
let parsed: StructuredSummary;
|
||
try {
|
||
const jsonMatch = summaryJson.match(/\{[\s\S]*"topics"[\s\S]*\}/);
|
||
parsed = jsonMatch ? JSON.parse(jsonMatch[0]) : JSON.parse(summaryJson);
|
||
} catch {
|
||
logWarn('上下文压缩: JSON 解析失败,使用纯文本摘要');
|
||
parsed = { topics: [summaryJson.slice(0, 100)], decisions: [], pendingTasks: [], constraints: [], knowledge: [], toolResults: [] };
|
||
}
|
||
|
||
// R16: 增量摘要合并 — 提取已有压缩摘要并与新摘要合并
|
||
const alreadyCompressedMiddle = middle.filter(m => m.compressed && m.role !== 'system');
|
||
if (alreadyCompressedMiddle.length > 0) {
|
||
const oldSummary = extractStructuredSummary(alreadyCompressedMiddle);
|
||
parsed = mergeSummaries(oldSummary, parsed);
|
||
logInfo(`R16: 合并了 ${alreadyCompressedMiddle.length} 条旧摘要到新摘要`);
|
||
}
|
||
|
||
// 构建结构化摘要消息文本
|
||
const parts: string[] = [];
|
||
if (parsed.topics.length) parts.push(`📌 主题: ${parsed.topics.join(';')}`);
|
||
if (parsed.decisions.length) parts.push(`✅ 决策: ${parsed.decisions.join(';')}`);
|
||
if (parsed.pendingTasks.length) parts.push(`⏳ 待办: ${parsed.pendingTasks.join(';')}`);
|
||
if (parsed.constraints.length) parts.push(`📏 约束: ${parsed.constraints.join(';')}`);
|
||
if (parsed.knowledge.length) parts.push(`🧠 知识点: ${parsed.knowledge.join(';')}`);
|
||
if (parsed.toolResults.length) parts.push(`🔧 工具结果: ${parsed.toolResults.join(';')}`);
|
||
|
||
// R11: 压缩质量验证 — 如果摘要为空或过短,回退到文本摘要
|
||
if (parts.length === 0 || parts.join('').length < 50) {
|
||
logWarn('R11: 压缩摘要质量不足,回退到文本摘要');
|
||
const textSummary = uncompressedMiddle.map(m => {
|
||
const role = m.role === 'user' ? '用户' : 'AI';
|
||
const content = (m.content || '').slice(0, 200);
|
||
return `${role}: ${content}`;
|
||
}).join('\n').slice(0, 1000);
|
||
parts.length = 0;
|
||
parts.push(`📌 主题: ${textSummary}`);
|
||
}
|
||
|
||
// 构建压缩后的摘要消息
|
||
// C5: 使用 user role 而非 system role,避免部分模型拒绝多个 system 消息
|
||
const summaryMsg: OllamaMessage = {
|
||
role: 'user',
|
||
content: `📋 以下是对之前对话的摘要(已压缩 ${uncompressedMiddle.length} 条消息):\n\n${parts.join('\n')}`,
|
||
compressed: true
|
||
};
|
||
|
||
// R16: 保留已压缩的中间消息(system 消息单独处理)+ 新摘要
|
||
// R12: 标记这批消息为已压缩
|
||
markAsCompressed(uncompressedMiddle);
|
||
// P0-C1 修复:原代码保留 nonSystemCompressed(旧摘要),但新摘要已通过 mergeSummaries 合并了旧摘要内容,
|
||
// 保留旧摘要会导致信息重复 + token 累积浪费。正确做法是不保留旧摘要(system 的已在 mergedSystemContent 中合并)
|
||
const alreadyCompressed = middle.filter(m => m.compressed && m.role === 'system');
|
||
|
||
// C1: 合并 system 消息为一条,但保留不可压缩的 system 消息完整内容
|
||
const mergedSystemContent = [
|
||
...incompressibleSysMsgs.map(m => m.content || ''),
|
||
...compressibleSysMsgs.map(m => m.content || ''),
|
||
...alreadyCompressed.filter(m => m.role === 'system').map(m => m.content || ''),
|
||
].filter(Boolean).join('\n\n');
|
||
|
||
// P0-C1 修复:不再保留 nonSystemCompressed,旧摘要内容已合并到 summaryMsg 中
|
||
|
||
const result: OllamaMessage[] = [
|
||
{ role: 'system', content: mergedSystemContent },
|
||
...head,
|
||
summaryMsg,
|
||
...tail
|
||
];
|
||
|
||
// R17: System 消息分区优化 — 将 system 消息按稳定性排序,支持 LLM Prefix Caching
|
||
// 稳定部分(SOUL.md、规则等)放在最前面,动态部分(工作空间、记忆)放在后面
|
||
// 这样 Ollama 可以缓存稳定前缀,只重新处理动态部分
|
||
reorderSystemMessagesForPrefixCaching(result);
|
||
|
||
const beforeTokens = estimateTokens(messages.map(m => m.content || '').join(''));
|
||
const afterTokens = estimateTokens(result.map(m => m.content || '').join(''));
|
||
|
||
// R19: 记录压缩指标
|
||
const metrics: CompressionMetrics = {
|
||
beforeMessages: messages.length,
|
||
afterMessages: result.length,
|
||
beforeTokens,
|
||
afterTokens,
|
||
compressionRatio: beforeTokens > 0 ? afterTokens / beforeTokens : 1.0,
|
||
timestamp: Date.now(),
|
||
};
|
||
_compressionHistory.push(metrics);
|
||
if (_compressionHistory.length > MAX_COMPRESSION_HISTORY) {
|
||
_compressionHistory.shift();
|
||
}
|
||
|
||
logSuccess(`上下文压缩完成: ${messages.length} 条 → ${result.length} 条, tokens: ${beforeTokens} → ${afterTokens} (压缩率: ${(metrics.compressionRatio * 100).toFixed(0)}%)`);
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 将较早的消息每 batchSize 条压缩为一段摘要。
|
||
* v6.0: 重要性高的消息保留更多内容,低价值消息激进截断。
|
||
*/
|
||
function summarizeOlderMessages(messages: OllamaMessage[], batchSize: number): OllamaMessage[] {
|
||
const summaries: OllamaMessage[] = [];
|
||
|
||
for (let i = 0; i < messages.length; i += batchSize) {
|
||
const batch = messages.slice(i, i + batchSize);
|
||
const summary = createQuickSummary(batch);
|
||
// 使用 user role 而非 system role — 摘要是对话历史的延续,用 system 会与系统指令语义混淆
|
||
summaries.push({
|
||
role: 'user',
|
||
content: `【更早的对话摘要(第 ${Math.floor(i / batchSize) + 1} 部分)】\n${summary}`,
|
||
compressed: true
|
||
});
|
||
}
|
||
|
||
return summaries;
|
||
}
|
||
|
||
/**
|
||
* 快速摘要:重要性高的消息保留更多信息,低价值的激进截断
|
||
*/
|
||
function createQuickSummary(messages: OllamaMessage[]): string {
|
||
const parts: string[] = [];
|
||
|
||
for (const msg of messages) {
|
||
const role = msg.role === 'user' ? '用户' : 'AI';
|
||
const content = msg.content || '';
|
||
const importance = scoreMessageImportance(msg);
|
||
|
||
let preview: string;
|
||
if (importance >= 8) {
|
||
// 高价值消息:保留 200 字
|
||
preview = content.length > 200 ? content.slice(0, 200) + '...' : content;
|
||
} else if (importance >= 5) {
|
||
// 中等价值:保留 100 字
|
||
preview = content.length > 100 ? content.slice(0, 100) + '...' : content;
|
||
} else {
|
||
// 低价值:仅保留 40 字或跳过
|
||
if (content.trim().length < 10) continue;
|
||
preview = content.length > 40 ? content.slice(0, 40) + '...' : content;
|
||
}
|
||
|
||
if (preview.trim()) {
|
||
parts.push(`${role}: ${preview}`);
|
||
}
|
||
if (msg.tool_calls?.length) {
|
||
const toolNames = msg.tool_calls.map(t => t.function.name).join(', ');
|
||
parts.push(` [工具: ${toolNames}]`);
|
||
}
|
||
}
|
||
|
||
return parts.join('\n');
|
||
}
|
||
|
||
/**
|
||
* 根据 token 限制裁剪消息。
|
||
* v6.0: 按重要性评分决定保留顺序——重要性低的优先被丢弃。
|
||
* C2: 始终保留最近 N 条消息(时间窗口保护),避免丢失关键上下文
|
||
*/
|
||
function trimByTokenLimit(messages: OllamaMessage[], maxTokens: number): OllamaMessage[] {
|
||
// 分离 system 和非 system 消息
|
||
const systemMsgs = messages.filter(m => m.role === 'system');
|
||
const nonSystemMsgs = messages.filter(m => m.role !== 'system');
|
||
|
||
if (nonSystemMsgs.length <= 4) return messages; // 消息太少不裁剪
|
||
|
||
// C2: 最近 6 条消息始终保留(时间窗口保护)
|
||
const PROTECT_RECENT = 6;
|
||
const recentMsgs = nonSystemMsgs.slice(-PROTECT_RECENT);
|
||
const olderMsgs = nonSystemMsgs.slice(0, -PROTECT_RECENT);
|
||
|
||
// 原子组分组:assistant(带 tool_calls) + 其后续的 tool 消息作为一组
|
||
// 避免裁剪时破坏 assistant.tool_calls 与 tool 结果的配对关系
|
||
interface MsgGroup {
|
||
msgs: OllamaMessage[];
|
||
tokens: number;
|
||
importance: number;
|
||
originalIndex: number;
|
||
}
|
||
|
||
const groups: MsgGroup[] = [];
|
||
let i = 0;
|
||
while (i < olderMsgs.length) {
|
||
const msg = olderMsgs[i];
|
||
if (msg.role === 'assistant' && msg.tool_calls?.length) {
|
||
// 原子组:assistant(带 tool_calls) + 后续连续的 tool 消息
|
||
const groupMsgs: OllamaMessage[] = [msg];
|
||
let tokens = estimateTokens(msg.content || '') +
|
||
(msg.images ? msg.images.length * 100 : 0) +
|
||
(msg.tool_calls ? msg.tool_calls.length * 50 : 0);
|
||
let maxImportance = scoreMessageImportance(msg);
|
||
let j = i + 1;
|
||
while (j < olderMsgs.length && olderMsgs[j].role === 'tool') {
|
||
const toolMsg = olderMsgs[j];
|
||
groupMsgs.push(toolMsg);
|
||
tokens += estimateTokens(toolMsg.content || '');
|
||
maxImportance = Math.max(maxImportance, scoreMessageImportance(toolMsg));
|
||
j++;
|
||
}
|
||
groups.push({ msgs: groupMsgs, tokens, importance: maxImportance, originalIndex: i });
|
||
i = j;
|
||
} else {
|
||
const tokens = estimateTokens(msg.content || '') +
|
||
(msg.images ? msg.images.length * 100 : 0) +
|
||
(msg.tool_calls ? msg.tool_calls.length * 50 : 0);
|
||
groups.push({ msgs: [msg], tokens, importance: scoreMessageImportance(msg), originalIndex: i });
|
||
i++;
|
||
}
|
||
}
|
||
|
||
// system 消息的 token 消耗
|
||
const systemTokens = systemMsgs.reduce((sum, m) => sum + estimateTokens(m.content || ''), 0);
|
||
const recentTokens = recentMsgs.reduce((sum, m) =>
|
||
sum + estimateTokens(m.content || '') +
|
||
(m.images ? m.images.length * 100 : 0) +
|
||
(m.tool_calls ? m.tool_calls.length * 50 : 0), 0);
|
||
const availableTokens = maxTokens - systemTokens - recentTokens;
|
||
|
||
if (availableTokens <= 0) {
|
||
// 连 system + recent 都超了,只保留 system + recent
|
||
return [...systemMsgs, ...recentMsgs];
|
||
}
|
||
|
||
// R94: 按综合评分降序排列(重要性 + 时近性),取能装下的最大数量
|
||
const totalGroups = groups.length;
|
||
groups.forEach((g, idx) => {
|
||
// R94: 时近性因子 — 越靠近最近窗口的消息得分越高(0~2 分加成)
|
||
const recencyRatio = totalGroups > 1 ? idx / (totalGroups - 1) : 1;
|
||
g.importance += Math.round(recencyRatio * 2);
|
||
});
|
||
// 按重要性排序(降序),但保留原始索引用于重建
|
||
const sortedGroups = [...groups].sort((a, b) => b.importance - a.importance);
|
||
|
||
let usedTokens = 0;
|
||
const keptOriginalIndices = new Set<number>();
|
||
for (let i = 0; i < sortedGroups.length; i++) {
|
||
if (usedTokens + sortedGroups[i].tokens > availableTokens && keptOriginalIndices.size >= 2) break;
|
||
usedTokens += sortedGroups[i].tokens;
|
||
keptOriginalIndices.add(sortedGroups[i].originalIndex);
|
||
}
|
||
|
||
// 按原始顺序重建:system → 按重要性保留的旧消息组 → 最近的 protected 消息
|
||
const result: OllamaMessage[] = [...systemMsgs];
|
||
for (const g of groups) {
|
||
if (keptOriginalIndices.has(g.originalIndex)) {
|
||
result.push(...g.msgs);
|
||
}
|
||
}
|
||
result.push(...recentMsgs);
|
||
|
||
return result;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// R91: 上下文压力分级评估 — 三级压力系统指导压缩策略选择
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
export type ContextPressureLevel = 'low' | 'medium' | 'high' | 'critical';
|
||
|
||
export interface ContextPressureInfo {
|
||
level: ContextPressureLevel;
|
||
tokenUsageRatio: number; // 0-1
|
||
messageCount: number;
|
||
recommendedActions: string[]; // 建议的压缩动作
|
||
}
|
||
|
||
/**
|
||
* R91: 评估当前上下文压力等级
|
||
* - low (<30%): 无需压缩
|
||
* - medium (30-50%): 轻量压缩(归档旧工具结果、清理 ephemeral)
|
||
* - high (50-70%): 中等压缩(截断工具结果、合并消息)
|
||
* - critical (>70%): LLM 压缩
|
||
*/
|
||
export function getContextPressureLevel(
|
||
messages: OllamaMessage[],
|
||
numCtx: number,
|
||
): ContextPressureInfo {
|
||
const totalTokens = messages.reduce((sum, m) => {
|
||
let t = estimateTokens(m.content || '');
|
||
if (m.tool_calls?.length) {
|
||
for (const tc of m.tool_calls) {
|
||
const argsSize = JSON.stringify(tc.function.arguments || {}).length;
|
||
t += estimateTokens(tc.function.name) + Math.ceil(argsSize / 4) + 20;
|
||
}
|
||
}
|
||
if (m.images?.length) t += m.images.length * 100;
|
||
return sum + t;
|
||
}, 0);
|
||
|
||
const ratio = numCtx > 0 ? totalTokens / numCtx : 0;
|
||
const msgCount = messages.length;
|
||
const actions: string[] = [];
|
||
|
||
let level: ContextPressureLevel;
|
||
if (ratio > 0.7) {
|
||
level = 'critical';
|
||
actions.push('llm_compress', 'truncate_results', 'compact_old', 'merge_messages', 'clear_ephemeral');
|
||
} else if (ratio > 0.5) {
|
||
level = 'high';
|
||
actions.push('truncate_results', 'compact_old', 'merge_messages');
|
||
} else if (ratio > 0.3) {
|
||
level = 'medium';
|
||
actions.push('compact_old', 'clear_ephemeral');
|
||
} else {
|
||
level = 'low';
|
||
if (msgCount > 60) actions.push('compact_old');
|
||
}
|
||
|
||
return { level, tokenUsageRatio: ratio, messageCount: msgCount, recommendedActions: actions };
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// R93: Token 预算追踪器 — 实时追踪输入/输出 token 与预算比例
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
interface TokenBudgetEntry {
|
||
loop: number;
|
||
inputTokens: number; // prompt_eval_count
|
||
outputTokens: number; // eval_count
|
||
estimatedTokens: number; // 本地估算值
|
||
timestamp: number;
|
||
}
|
||
|
||
const _tokenBudgetHistory: TokenBudgetEntry[] = [];
|
||
const MAX_BUDGET_ENTRIES = 50;
|
||
let _totalInputTokens = 0;
|
||
let _totalOutputTokens = 0;
|
||
let _budgetNumCtx = 131072;
|
||
|
||
/** R93: 设置当前预算的 numCtx */
|
||
export function setTokenBudgetNumCtx(numCtx: number): void {
|
||
_budgetNumCtx = numCtx;
|
||
}
|
||
|
||
/** R93: 记录一轮的 token 消耗 */
|
||
export function recordBudgetUsage(
|
||
loop: number,
|
||
inputTokens: number,
|
||
outputTokens: number,
|
||
estimatedTokens: number,
|
||
): void {
|
||
_tokenBudgetHistory.push({
|
||
loop, inputTokens, outputTokens, estimatedTokens, timestamp: Date.now(),
|
||
});
|
||
if (_tokenBudgetHistory.length > MAX_BUDGET_ENTRIES) {
|
||
_tokenBudgetHistory.shift();
|
||
}
|
||
_totalInputTokens += inputTokens;
|
||
_totalOutputTokens += outputTokens;
|
||
}
|
||
|
||
/** R93: 获取 Token 预算使用情况 */
|
||
export interface TokenBudgetStatus {
|
||
totalInput: number;
|
||
totalOutput: number;
|
||
totalSpent: number;
|
||
avgInputPerLoop: number;
|
||
avgOutputPerLoop: number;
|
||
budgetNumCtx: number;
|
||
currentLoopInput: number;
|
||
budgetUtilization: number; // 当前轮输入占预算比例 0-1
|
||
trend: 'increasing' | 'stable' | 'decreasing';
|
||
history: TokenBudgetEntry[];
|
||
}
|
||
|
||
/** R93: 获取当前 Token 预算状态 */
|
||
export function getTokenBudgetStatus(): TokenBudgetStatus {
|
||
const history = [..._tokenBudgetHistory];
|
||
const currentLoop = history.length > 0 ? history[history.length - 1] : null;
|
||
|
||
// 计算趋势
|
||
let trend: 'increasing' | 'stable' | 'decreasing' = 'stable';
|
||
if (history.length >= 3) {
|
||
const recent = history.slice(-3);
|
||
const avg = recent.reduce((s, e) => s + e.inputTokens, 0) / recent.length;
|
||
const oldest = recent[0].inputTokens;
|
||
if (avg > oldest * 1.15) trend = 'increasing';
|
||
else if (avg < oldest * 0.85) trend = 'decreasing';
|
||
}
|
||
|
||
const avgInput = history.length > 0
|
||
? Math.round(_totalInputTokens / history.length)
|
||
: 0;
|
||
const avgOutput = history.length > 0
|
||
? Math.round(_totalOutputTokens / history.length)
|
||
: 0;
|
||
|
||
return {
|
||
totalInput: _totalInputTokens,
|
||
totalOutput: _totalOutputTokens,
|
||
totalSpent: _totalInputTokens + _totalOutputTokens,
|
||
avgInputPerLoop: avgInput,
|
||
avgOutputPerLoop: avgOutput,
|
||
budgetNumCtx: _budgetNumCtx,
|
||
currentLoopInput: currentLoop?.inputTokens || 0,
|
||
budgetUtilization: _budgetNumCtx > 0 && currentLoop
|
||
? currentLoop.inputTokens / _budgetNumCtx
|
||
: 0,
|
||
trend,
|
||
history,
|
||
};
|
||
}
|
||
|
||
/** R93: 重置 Token 预算追踪 */
|
||
export function resetTokenBudget(): void {
|
||
_tokenBudgetHistory.length = 0;
|
||
_totalInputTokens = 0;
|
||
_totalOutputTokens = 0;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// R96: 消息角色压缩 — 合并连续相同角色消息,减少消息条数开销
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
/**
|
||
* R96: 合并连续相同角色的非工具消息
|
||
* 规则:
|
||
* - 连续的 user 消息合并为一条(用分隔符连接)
|
||
* - 连续的 assistant 消息合并为一条(保留 tool_calls)
|
||
* - tool 消息不合并(每条对应一个 tool_call)
|
||
* - system 消息不合并(已有 R17 处理)
|
||
* - ephemeral 临时消息不合并
|
||
* - compressed 消息不合并
|
||
*/
|
||
export function mergeConsecutiveMessages(messages: OllamaMessage[]): OllamaMessage[] {
|
||
if (messages.length <= 2) return messages;
|
||
|
||
const result: OllamaMessage[] = [];
|
||
let mergedCount = 0;
|
||
|
||
for (let i = 0; i < messages.length; i++) {
|
||
const msg = messages[i];
|
||
const last = result[result.length - 1];
|
||
|
||
// 不合并的情况
|
||
if (
|
||
!last ||
|
||
msg.role === 'tool' ||
|
||
msg.role === 'system' ||
|
||
msg.ephemeral ||
|
||
msg.compressed ||
|
||
last.role !== msg.role ||
|
||
last.ephemeral ||
|
||
last.compressed ||
|
||
msg.tool_calls?.length || // 有工具调用的 assistant 不合并
|
||
last.tool_calls?.length
|
||
) {
|
||
result.push(msg);
|
||
continue;
|
||
}
|
||
|
||
// 合并连续相同角色消息
|
||
// 限制合并后内容不超过 3000 字符,避免合并后过长
|
||
const combinedContent = (last.content || '') + '\n\n' + (msg.content || '');
|
||
if (combinedContent.length > 3000) {
|
||
result.push(msg);
|
||
continue;
|
||
}
|
||
|
||
result[result.length - 1] = {
|
||
...last,
|
||
content: combinedContent,
|
||
};
|
||
mergedCount++;
|
||
}
|
||
|
||
if (mergedCount > 0) {
|
||
logInfo(`R96: 消息角色压缩 — 合并了 ${mergedCount} 条连续同角色消息 (${messages.length} → ${result.length})`);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// R98: 压缩触发阈值优化 — 结合趋势预测动态调整
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
/**
|
||
* R98: 获取结合趋势的压缩触发阈值
|
||
* 如果 token 使用趋势在快速增长,提前触发压缩
|
||
* 如果趋势稳定或下降,延后压缩
|
||
*/
|
||
export function getTrendAwareCompressThreshold(
|
||
numCtx: number,
|
||
messages: OllamaMessage[],
|
||
): { shouldCompress: boolean; reason: string; urgency: 'low' | 'medium' | 'high' } {
|
||
const baseThreshold = getAdaptiveCompressThreshold(numCtx);
|
||
const currentTokens = messages.reduce((sum, m) => {
|
||
let t = estimateTokens(m.content || '');
|
||
if (m.tool_calls?.length) {
|
||
for (const tc of m.tool_calls) {
|
||
const argsSize = JSON.stringify(tc.function.arguments || {}).length;
|
||
t += estimateTokens(tc.function.name) + Math.ceil(argsSize / 4) + 20;
|
||
}
|
||
}
|
||
if (m.images?.length) t += m.images.length * 100;
|
||
return sum + t;
|
||
}, 0);
|
||
|
||
const usageRatio = numCtx > 0 ? currentTokens / numCtx : 0;
|
||
const prediction = predictContextOverflow(numCtx);
|
||
|
||
// 紧急情况:预测即将溢出
|
||
if (prediction.level === 'critical' || (prediction.level === 'warning' && prediction.turnsToOverflow <= 2)) {
|
||
return {
|
||
shouldCompress: true,
|
||
reason: `趋势预测触发: ${prediction.message}`,
|
||
urgency: 'high',
|
||
};
|
||
}
|
||
|
||
// 趋势加速增长 + 使用率超过基础阈值
|
||
if (prediction.turnsToOverflow > 0 && prediction.turnsToOverflow <= 5 && usageRatio > baseThreshold * 0.8) {
|
||
return {
|
||
shouldCompress: true,
|
||
reason: `趋势加速: ${prediction.turnsToOverflow} 轮后可能溢出,当前使用率 ${(usageRatio * 100).toFixed(0)}%`,
|
||
urgency: 'medium',
|
||
};
|
||
}
|
||
|
||
// 标准阈值触发
|
||
if (usageRatio > baseThreshold) {
|
||
return {
|
||
shouldCompress: true,
|
||
reason: `标准阈值触发: 使用率 ${(usageRatio * 100).toFixed(0)}% > 阈值 ${(baseThreshold * 100).toFixed(0)}%`,
|
||
urgency: usageRatio > 0.6 ? 'high' : 'medium',
|
||
};
|
||
}
|
||
|
||
// 消息条数硬阈值
|
||
const msgThreshold = getIncrementalCompressThresholdMessages(numCtx);
|
||
if (messages.length >= msgThreshold) {
|
||
return {
|
||
shouldCompress: true,
|
||
reason: `消息条数触发: ${messages.length} >= ${msgThreshold}`,
|
||
urgency: 'low',
|
||
};
|
||
}
|
||
|
||
return { shouldCompress: false, reason: '', urgency: 'low' };
|
||
}
|
||
|
||
/** R98: 消息条数阈值(独立函数,供 engine 复用) */
|
||
function getIncrementalCompressThresholdMessages(numCtx: number): number {
|
||
const tokenThreshold = Math.floor(numCtx * 0.3);
|
||
return Math.max(20, Math.min(120, Math.floor(tokenThreshold / 100)));
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// R100: Token 使用统计报告 — 生成详细消耗分析
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
export interface TokenReport {
|
||
generatedAt: number;
|
||
session: {
|
||
totalInputTokens: number;
|
||
totalOutputTokens: number;
|
||
totalTokens: number;
|
||
loopCount: number;
|
||
avgInputPerLoop: number;
|
||
avgOutputPerLoop: number;
|
||
};
|
||
budget: {
|
||
numCtx: number;
|
||
currentUtilization: number;
|
||
peakUtilization: number;
|
||
trend: 'increasing' | 'stable' | 'decreasing';
|
||
};
|
||
compression: {
|
||
historyCount: number;
|
||
avgCompressionRatio: number;
|
||
lastCompressionRatio: number | null;
|
||
};
|
||
warnings: string[];
|
||
}
|
||
|
||
/**
|
||
* R100: 生成 Token 使用统计报告
|
||
*/
|
||
export function generateTokenReport(numCtx: number): TokenReport {
|
||
const budget = getTokenBudgetStatus();
|
||
const compressionHistory = getCompressionHistory();
|
||
|
||
// 计算峰值利用率
|
||
let peakUtilization = 0;
|
||
for (const entry of budget.history) {
|
||
const util = numCtx > 0 ? entry.inputTokens / numCtx : 0;
|
||
if (util > peakUtilization) peakUtilization = util;
|
||
}
|
||
|
||
const warnings: string[] = [];
|
||
if (budget.budgetUtilization > 0.7) {
|
||
warnings.push(`当前轮 token 使用率过高: ${(budget.budgetUtilization * 100).toFixed(0)}%`);
|
||
}
|
||
if (budget.trend === 'increasing' && budget.avgInputPerLoop > numCtx * 0.3) {
|
||
warnings.push(`token 消耗趋势上升,平均每轮 ${budget.avgInputPerLoop} tokens`);
|
||
}
|
||
if (compressionHistory.length > 0) {
|
||
const avgRatio = getAverageCompressionRatio();
|
||
if (avgRatio > 0.8) {
|
||
warnings.push(`压缩效率偏低: 平均压缩率 ${(avgRatio * 100).toFixed(0)}% (越低越好)`);
|
||
}
|
||
}
|
||
|
||
return {
|
||
generatedAt: Date.now(),
|
||
session: {
|
||
totalInputTokens: budget.totalInput,
|
||
totalOutputTokens: budget.totalOutput,
|
||
totalTokens: budget.totalSpent,
|
||
loopCount: budget.history.length,
|
||
avgInputPerLoop: budget.avgInputPerLoop,
|
||
avgOutputPerLoop: budget.avgOutputPerLoop,
|
||
},
|
||
budget: {
|
||
numCtx,
|
||
currentUtilization: budget.budgetUtilization,
|
||
peakUtilization,
|
||
trend: budget.trend,
|
||
},
|
||
compression: {
|
||
historyCount: compressionHistory.length,
|
||
avgCompressionRatio: getAverageCompressionRatio(),
|
||
lastCompressionRatio: compressionHistory.length > 0
|
||
? compressionHistory[compressionHistory.length - 1].compressionRatio
|
||
: null,
|
||
},
|
||
warnings,
|
||
};
|
||
}
|
||
|
||
/** R100: 格式化 Token 报告为可读字符串 */
|
||
export function formatTokenReport(report: TokenReport): string {
|
||
const lines: string[] = [
|
||
`Token Usage Report (${new Date(report.generatedAt).toLocaleTimeString()})`,
|
||
`${'─'.repeat(50)}`,
|
||
`Session:`,
|
||
` Total Input: ${report.session.totalInputTokens.toLocaleString()} tokens`,
|
||
` Total Output: ${report.session.totalOutputTokens.toLocaleString()} tokens`,
|
||
` Total Spent: ${report.session.totalTokens.toLocaleString()} tokens`,
|
||
` Loops: ${report.session.loopCount}`,
|
||
` Avg In/Loop: ${report.session.avgInputPerLoop.toLocaleString()} tokens`,
|
||
` Avg Out/Loop: ${report.session.avgOutputPerLoop.toLocaleString()} tokens`,
|
||
`Budget:`,
|
||
` numCtx: ${report.budget.numCtx.toLocaleString()}`,
|
||
` Current Usage: ${(report.budget.currentUtilization * 100).toFixed(1)}%`,
|
||
` Peak Usage: ${(report.budget.peakUtilization * 100).toFixed(1)}%`,
|
||
` Trend: ${report.budget.trend}`,
|
||
`Compression:`,
|
||
` History Count: ${report.compression.historyCount}`,
|
||
` Avg Ratio: ${(report.compression.avgCompressionRatio * 100).toFixed(0)}%`,
|
||
` Last Ratio: ${report.compression.lastCompressionRatio !== null ? (report.compression.lastCompressionRatio * 100).toFixed(0) + '%' : 'N/A'}`,
|
||
];
|
||
if (report.warnings.length > 0) {
|
||
lines.push(`Warnings:`);
|
||
for (const w of report.warnings) {
|
||
lines.push(` ⚠️ ${w}`);
|
||
}
|
||
}
|
||
return lines.join('\n');
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// R111: 压缩策略自适应选择 — 根据上下文特征选择快速压缩或 LLM 压缩
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
export type CompressionStrategy = 'skip' | 'fast' | 'medium' | 'llm';
|
||
|
||
export interface CompressionDecision {
|
||
strategy: CompressionStrategy;
|
||
reason: string;
|
||
estimatedSavings: number; // 预估节省 token 数
|
||
}
|
||
|
||
/** R111: 根据上下文压力和消息特征选择最优压缩策略 */
|
||
export function chooseCompressionStrategy(
|
||
messages: OllamaMessage[],
|
||
numCtx: number,
|
||
pressureLevel: string
|
||
): CompressionDecision {
|
||
const totalTokens = estimateTokens(messages.map(m => m.content || '').join(''));
|
||
const usageRatio = numCtx > 0 ? totalTokens / numCtx : 0;
|
||
|
||
// R120: 如果上下文压力很低且消息不多,跳过压缩
|
||
if (pressureLevel === 'low' && messages.length < 30) {
|
||
return {
|
||
strategy: 'skip',
|
||
reason: `上下文压力低 (${messages.length} 条消息, ${(usageRatio * 100).toFixed(0)}%),无需压缩`,
|
||
estimatedSavings: 0,
|
||
};
|
||
}
|
||
|
||
// 统计工具结果消息占比
|
||
const toolMsgs = messages.filter(m => m.role === 'tool');
|
||
const toolRatio = messages.length > 0 ? toolMsgs.length / messages.length : 0;
|
||
|
||
// 如果工具结果占比高,使用快速压缩(截断+归档)
|
||
if (toolRatio > 0.4 && pressureLevel !== 'critical') {
|
||
const savings = Math.floor(totalTokens * 0.3);
|
||
return {
|
||
strategy: 'fast',
|
||
reason: `工具结果占比高 (${(toolRatio * 100).toFixed(0)}%),使用快速截断压缩`,
|
||
estimatedSavings: savings,
|
||
};
|
||
}
|
||
|
||
// 中等压力:中等压缩(消息合并+旧消息裁剪)
|
||
if (pressureLevel === 'medium' || pressureLevel === 'high') {
|
||
const savings = Math.floor(totalTokens * 0.4);
|
||
return {
|
||
strategy: 'medium',
|
||
reason: `中等压力 (${pressureLevel}),使用消息合并+裁剪`,
|
||
estimatedSavings: savings,
|
||
};
|
||
}
|
||
|
||
// 关键压力或高使用率:使用 LLM 摘要压缩
|
||
if (pressureLevel === 'critical' || usageRatio > 0.75) {
|
||
const savings = Math.floor(totalTokens * 0.6);
|
||
return {
|
||
strategy: 'llm',
|
||
reason: `高压力 (${pressureLevel}, ${(usageRatio * 100).toFixed(0)}%),使用 LLM 摘要压缩`,
|
||
estimatedSavings: savings,
|
||
};
|
||
}
|
||
|
||
// 默认:快速压缩
|
||
return {
|
||
strategy: 'fast',
|
||
reason: '默认快速压缩',
|
||
estimatedSavings: Math.floor(totalTokens * 0.2),
|
||
};
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// R115: 上下文水印 — 标记不可压缩的关键信息
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
/** 水印标记:带有此标记的消息在压缩时会被保留 */
|
||
const WATERMARK_PREFIX = '[PRESERVE]';
|
||
const _watermarkedIndices = new Set<number>();
|
||
|
||
/** R115: 标记消息为不可压缩 */
|
||
export function watermarkMessage(index: number): void {
|
||
_watermarkedIndices.add(index);
|
||
}
|
||
|
||
/** R115: 检查消息是否被水印保护 */
|
||
export function isWatermarked(index: number): boolean {
|
||
return _watermarkedIndices.has(index);
|
||
}
|
||
|
||
/** R115: 自动为关键消息添加水印 */
|
||
export function autoWatermarkCritical(messages: OllamaMessage[]): number[] {
|
||
const protectedIndices: number[] = [];
|
||
|
||
for (let i = 0; i < messages.length; i++) {
|
||
const msg = messages[i];
|
||
const content = msg.content || '';
|
||
|
||
// 系统消息始终保护
|
||
if (msg.role === 'system') {
|
||
watermarkMessage(i);
|
||
protectedIndices.push(i);
|
||
continue;
|
||
}
|
||
|
||
// 包含错误信息的用户消息保护
|
||
if (msg.role === 'user' && (content.includes('错误') || content.includes('error') || content.includes('失败'))) {
|
||
watermarkMessage(i);
|
||
protectedIndices.push(i);
|
||
continue;
|
||
}
|
||
|
||
// 最近 5 条消息保护
|
||
if (i >= messages.length - 5) {
|
||
watermarkMessage(i);
|
||
protectedIndices.push(i);
|
||
}
|
||
}
|
||
|
||
return protectedIndices;
|
||
}
|
||
|
||
/** R115: 清除水印 */
|
||
export function clearWatermarks(): void {
|
||
_watermarkedIndices.clear();
|
||
}
|
||
|
||
/** R115: 获取受保护的消息索引列表 */
|
||
export function getWatermarkedIndices(): number[] {
|
||
return Array.from(_watermarkedIndices).sort((a, b) => a - b);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// R120: 上下文压缩跳过逻辑 — 不值得压缩时跳过
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
/** R120: 判断是否应该跳过压缩 */
|
||
export function shouldSkipCompression(
|
||
messages: OllamaMessage[],
|
||
numCtx: number,
|
||
recentCompressionRatio: number
|
||
): { skip: boolean; reason: string } {
|
||
const totalTokens = estimateTokens(messages.map(m => m.content || '').join(''));
|
||
const usageRatio = numCtx > 0 ? totalTokens / numCtx : 0;
|
||
|
||
// 如果使用率很低,跳过
|
||
if (usageRatio < 0.2) {
|
||
return { skip: true, reason: `上下文使用率极低 (${(usageRatio * 100).toFixed(0)}%),无需压缩` };
|
||
}
|
||
|
||
// 如果消息数太少,跳过
|
||
if (messages.length < 10) {
|
||
return { skip: true, reason: `消息数过少 (${messages.length} 条),无需压缩` };
|
||
}
|
||
|
||
// 如果最近压缩收益很低(压缩比 < 10%),跳过
|
||
if (recentCompressionRatio > 0.9) {
|
||
return { skip: true, reason: `最近压缩收益低 (压缩比 ${(recentCompressionRatio * 100).toFixed(0)}%),跳过` };
|
||
}
|
||
|
||
// 如果大部分消息已经被归档/压缩过,跳过
|
||
const archivedCount = messages.filter(m =>
|
||
m.content?.includes('[工具结果已归档]') || m.content?.includes('[PRESERVE]')
|
||
).length;
|
||
if (archivedCount / messages.length > 0.6) {
|
||
return { skip: true, reason: `大部分消息已归档 (${(archivedCount / messages.length * 100).toFixed(0)}%),跳过` };
|
||
}
|
||
|
||
return { skip: false, reason: '' };
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// R121: 滑动窗口自适应大小 — 根据上下文压力动态调整窗口大小
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
/** R121: 根据上下文压力获取自适应滑动窗口大小 */
|
||
export function getAdaptiveWindowSize(
|
||
totalMessages: number,
|
||
pressureLevel: string,
|
||
numCtx: number
|
||
): { keepRecent: number; keepSystem: number; reason: string } {
|
||
const baseWindow = Math.min(totalMessages, 40);
|
||
|
||
switch (pressureLevel) {
|
||
case 'critical':
|
||
return {
|
||
keepRecent: Math.min(baseWindow, 15),
|
||
keepSystem: 2,
|
||
reason: '关键压力:保留最近 15 条 + 系统 2 条',
|
||
};
|
||
case 'high':
|
||
return {
|
||
keepRecent: Math.min(baseWindow, 25),
|
||
keepSystem: 3,
|
||
reason: '高压力:保留最近 25 条 + 系统 3 条',
|
||
};
|
||
case 'medium':
|
||
return {
|
||
keepRecent: Math.min(baseWindow, 35),
|
||
keepSystem: 5,
|
||
reason: '中等压力:保留最近 35 条 + 系统 5 条',
|
||
};
|
||
case 'low':
|
||
default:
|
||
return {
|
||
keepRecent: Math.min(baseWindow, 50),
|
||
keepSystem: 5,
|
||
reason: '低压力:保留最近 50 条 + 系统 5 条',
|
||
};
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// R122: Token 趋势分析 — 深度分析 token 使用趋势用于预测性压缩
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
export interface TrendAnalysis {
|
||
trend: 'increasing' | 'decreasing' | 'stable';
|
||
avgGrowthRate: number; // 每轮平均 token 增长量
|
||
projectedOverflow: number; // 预计几轮后溢出(-1=不会)
|
||
recommendedAction: string;
|
||
confidence: number; // 0-1
|
||
}
|
||
|
||
/** R122: 分析 token 使用趋势 */
|
||
export function analyzeTokenTrend(numCtx: number): TrendAnalysis {
|
||
if (_tokenUsageTrend.length < 3) {
|
||
return {
|
||
trend: 'stable',
|
||
avgGrowthRate: 0,
|
||
projectedOverflow: -1,
|
||
recommendedAction: '数据不足,暂不推荐操作',
|
||
confidence: 0,
|
||
};
|
||
}
|
||
|
||
const points = _tokenUsageTrend;
|
||
const n = points.length;
|
||
|
||
// 计算平均增长率
|
||
let totalGrowth = 0;
|
||
let growthCount = 0;
|
||
for (let i = 1; i < n; i++) {
|
||
const growth = points[i].tokens - points[i - 1].tokens;
|
||
totalGrowth += growth;
|
||
growthCount++;
|
||
}
|
||
const avgGrowthRate = growthCount > 0 ? totalGrowth / growthCount : 0;
|
||
|
||
// 线性回归确定趋势
|
||
const xs = points.map(p => p.turn);
|
||
const ys = points.map(p => p.tokens);
|
||
const xMean = xs.reduce((s, x) => s + x, 0) / n;
|
||
const yMean = ys.reduce((s, y) => s + y, 0) / n;
|
||
let num = 0, den = 0;
|
||
for (let i = 0; i < n; i++) {
|
||
num += (xs[i] - xMean) * (ys[i] - yMean);
|
||
den += (xs[i] - xMean) ** 2;
|
||
}
|
||
const slope = den !== 0 ? num / den : 0;
|
||
|
||
// 判断趋势
|
||
let trend: TrendAnalysis['trend'];
|
||
if (slope > 100) trend = 'increasing';
|
||
else if (slope < -50) trend = 'decreasing';
|
||
else trend = 'stable';
|
||
|
||
// 预测溢出
|
||
let projectedOverflow = -1;
|
||
if (slope > 0) {
|
||
const currentTokens = points[n - 1].tokens;
|
||
const remaining = numCtx - currentTokens;
|
||
projectedOverflow = Math.ceil(remaining / slope);
|
||
if (projectedOverflow < 0) projectedOverflow = 0;
|
||
}
|
||
|
||
// 推荐操作
|
||
let recommendedAction = '';
|
||
if (trend === 'increasing' && projectedOverflow >= 0 && projectedOverflow <= 5) {
|
||
recommendedAction = `⚠️ 预计 ${projectedOverflow} 轮后上下文溢出,建议立即压缩`;
|
||
} else if (trend === 'increasing' && projectedOverflow > 5 && projectedOverflow <= 10) {
|
||
recommendedAction = `建议在接下来 2-3 轮内进行压缩(${projectedOverflow} 轮后溢出)`;
|
||
} else if (trend === 'stable') {
|
||
recommendedAction = 'Token 使用趋势稳定,无需额外操作';
|
||
} else if (trend === 'decreasing') {
|
||
recommendedAction = 'Token 使用量在下降,压缩策略生效';
|
||
}
|
||
|
||
// 置信度:基于数据点数量和趋势一致性
|
||
let confidence = Math.min(1, n / 10);
|
||
if (trend === 'stable') confidence *= 0.7;
|
||
|
||
return {
|
||
trend,
|
||
avgGrowthRate: Math.round(avgGrowthRate),
|
||
projectedOverflow,
|
||
recommendedAction,
|
||
confidence,
|
||
};
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// R123: 会话摘要持久化 — 跨会话引用
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
export interface SessionSummary {
|
||
id: string;
|
||
createdAt: number;
|
||
goal: string;
|
||
summary: string;
|
||
toolsUsed: string[];
|
||
keyFindings: string[];
|
||
tokenUsage: number;
|
||
}
|
||
|
||
const SESSION_SUMMARY_KEY = 'metona_session_summaries';
|
||
const MAX_SESSION_SUMMARIES = 10;
|
||
|
||
/** R123: 保存会话摘要到 localStorage */
|
||
export function saveSessionSummary(summary: SessionSummary): void {
|
||
try {
|
||
const existing = loadSessionSummaries();
|
||
existing.unshift(summary);
|
||
if (existing.length > MAX_SESSION_SUMMARIES) {
|
||
existing.length = MAX_SESSION_SUMMARIES;
|
||
}
|
||
localStorage.setItem(SESSION_SUMMARY_KEY, JSON.stringify(existing));
|
||
logInfo(`R123: 会话摘要已保存 (${summary.id})`);
|
||
} catch (err) {
|
||
logWarn(`R123: 保存会话摘要失败: ${(err as Error).message}`);
|
||
}
|
||
}
|
||
|
||
/** R123: 加载所有会话摘要 */
|
||
export function loadSessionSummaries(): SessionSummary[] {
|
||
try {
|
||
const raw = localStorage.getItem(SESSION_SUMMARY_KEY);
|
||
if (!raw) return [];
|
||
return JSON.parse(raw) as SessionSummary[];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/** R123: 生成当前会话摘要 */
|
||
export function generateSessionSummary(
|
||
goal: string,
|
||
messages: OllamaMessage[],
|
||
toolRecords: Array<{ name: string }>,
|
||
totalTokens: number
|
||
): SessionSummary {
|
||
const toolsUsed = [...new Set(toolRecords.map(t => t.name))];
|
||
const assistantMessages = messages.filter(m => m.role === 'assistant');
|
||
const lastAssistant = assistantMessages[assistantMessages.length - 1];
|
||
|
||
return {
|
||
id: `session_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||
createdAt: Date.now(),
|
||
goal: goal.slice(0, 200),
|
||
summary: (lastAssistant?.content || '').slice(0, 500),
|
||
toolsUsed,
|
||
keyFindings: [],
|
||
tokenUsage: totalTokens,
|
||
};
|
||
}
|
||
|
||
/** R123: 格式化历史会话摘要供注入 */
|
||
export function formatSessionSummariesForContext(summaries: SessionSummary[]): string {
|
||
if (summaries.length === 0) return '';
|
||
const lines = ['[历史会话参考]', ''];
|
||
for (const s of summaries.slice(0, 3)) {
|
||
const date = new Date(s.createdAt).toLocaleDateString();
|
||
lines.push(`- ${date}: 目标="${s.goal.slice(0, 60)}..." | 工具=[${s.toolsUsed.join(', ')}] | 结果=${s.summary.slice(0, 100)}...`);
|
||
}
|
||
return lines.join('\n');
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// R125: Agent 状态检查点 — 保存和恢复 Agent 状态
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
export interface AgentCheckpoint {
|
||
id: string;
|
||
timestamp: number;
|
||
loopCount: number;
|
||
state: string;
|
||
messagesSnapshot: OllamaMessage[];
|
||
toolRecordsCount: number;
|
||
goal: string;
|
||
}
|
||
|
||
const _checkpoints: AgentCheckpoint[] = [];
|
||
const MAX_CHECKPOINTS = 5;
|
||
|
||
/** R125: 创建状态检查点 */
|
||
export function createCheckpoint(
|
||
loopCount: number,
|
||
agentState: string,
|
||
messages: OllamaMessage[],
|
||
toolRecordsCount: number,
|
||
goal: string
|
||
): AgentCheckpoint {
|
||
const checkpoint: AgentCheckpoint = {
|
||
id: `cp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||
timestamp: Date.now(),
|
||
loopCount,
|
||
state: agentState,
|
||
messagesSnapshot: messages.map(m => ({ ...m })),
|
||
toolRecordsCount,
|
||
goal,
|
||
};
|
||
|
||
_checkpoints.push(checkpoint);
|
||
if (_checkpoints.length > MAX_CHECKPOINTS) {
|
||
_checkpoints.shift();
|
||
}
|
||
|
||
logInfo(`R125: 检查点已创建 (loop=${loopCount}, state=${agentState})`);
|
||
return checkpoint;
|
||
}
|
||
|
||
/** R125: 获取最近的检查点 */
|
||
export function getLatestCheckpoint(): AgentCheckpoint | null {
|
||
return _checkpoints.length > 0 ? _checkpoints[_checkpoints.length - 1] : null;
|
||
}
|
||
|
||
/** R125: 恢复到指定检查点 */
|
||
export function restoreCheckpoint(id: string): AgentCheckpoint | null {
|
||
const cp = _checkpoints.find(c => c.id === id);
|
||
if (!cp) {
|
||
logWarn(`R125: 检查点 ${id} 不存在`);
|
||
return null;
|
||
}
|
||
logInfo(`R125: 恢复到检查点 ${id} (loop=${cp.loopCount})`);
|
||
return cp;
|
||
}
|
||
|
||
/** R125: 获取所有检查点 */
|
||
export function getAllCheckpoints(): AgentCheckpoint[] {
|
||
return [..._checkpoints];
|
||
}
|
||
|
||
/** R125: 清除所有检查点 */
|
||
export function clearCheckpoints(): void {
|
||
_checkpoints.length = 0;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// R126: 上下文预算分配 — 按消息类型分配上下文 token 预算
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
export interface ContextBudgetAllocation {
|
||
system: number; // 系统消息预算
|
||
user: number; // 用户消息预算
|
||
assistant: number; // 助手消息预算
|
||
tool: number; // 工具结果预算
|
||
memory: number; // 记忆注入预算
|
||
total: number; // 总预算
|
||
}
|
||
|
||
/** R126: 默认预算分配比例 */
|
||
const DEFAULT_BUDGET_RATIOS = {
|
||
system: 0.05, // 5%
|
||
user: 0.15, // 15%
|
||
assistant: 0.25, // 25%
|
||
tool: 0.45, // 45%
|
||
memory: 0.10, // 10%
|
||
};
|
||
|
||
/** R126: 根据消息分布动态调整预算分配 */
|
||
export function allocateContextBudget(
|
||
messages: OllamaMessage[],
|
||
numCtx: number
|
||
): ContextBudgetAllocation {
|
||
const total = numCtx;
|
||
|
||
// 统计各类型消息当前占比
|
||
const counts = { system: 0, user: 0, assistant: 0, tool: 0 };
|
||
let memorySize = 0;
|
||
|
||
for (const msg of messages) {
|
||
if (msg.role in counts) {
|
||
counts[msg.role as keyof typeof counts]++;
|
||
}
|
||
if (msg.content?.includes('[记忆注入]')) {
|
||
memorySize += estimateTokens(msg.content);
|
||
}
|
||
}
|
||
|
||
const totalMsgs = messages.length || 1;
|
||
|
||
// 动态调整:如果工具结果占比过高,增加工具预算
|
||
const toolRatio = counts.tool / totalMsgs;
|
||
const ratios = { ...DEFAULT_BUDGET_RATIOS };
|
||
|
||
if (toolRatio > 0.5) {
|
||
// 工具结果过多,从助手预算中转移一部分给工具
|
||
const shift = Math.min(0.1, (toolRatio - 0.5) * 0.3);
|
||
ratios.assistant -= shift;
|
||
ratios.tool += shift;
|
||
}
|
||
|
||
// 如果记忆注入很大,增加记忆预算
|
||
if (memorySize > numCtx * 0.1) {
|
||
const shift = Math.min(0.05, (memorySize / numCtx - 0.1) * 0.2);
|
||
ratios.tool -= shift;
|
||
ratios.memory += shift;
|
||
}
|
||
|
||
return {
|
||
system: Math.floor(total * ratios.system),
|
||
user: Math.floor(total * ratios.user),
|
||
assistant: Math.floor(total * ratios.assistant),
|
||
tool: Math.floor(total * ratios.tool),
|
||
memory: Math.floor(total * ratios.memory),
|
||
total,
|
||
};
|
||
}
|
||
|
||
/** R126: 检查消息是否超出预算 */
|
||
export function checkBudgetOverflow(
|
||
messages: OllamaMessage[],
|
||
budget: ContextBudgetAllocation
|
||
): { role: string; current: number; budget: number; overflow: number }[] {
|
||
const tokensByRole: Record<string, number> = {};
|
||
for (const msg of messages) {
|
||
tokensByRole[msg.role] = (tokensByRole[msg.role] || 0) + estimateTokens(msg.content || '');
|
||
}
|
||
|
||
const overflows: { role: string; current: number; budget: number; overflow: number }[] = [];
|
||
const budgetMap: Record<string, number> = {
|
||
system: budget.system,
|
||
user: budget.user,
|
||
assistant: budget.assistant,
|
||
tool: budget.tool,
|
||
};
|
||
|
||
for (const [role, current] of Object.entries(tokensByRole)) {
|
||
const bud = budgetMap[role] || Infinity;
|
||
if (current > bud) {
|
||
overflows.push({ role, current, budget: bud, overflow: current - bud });
|
||
}
|
||
}
|
||
|
||
return overflows;
|
||
}
|