P0: 取消80%上下文使用率硬性限制3轮,改为触发紧急压缩; P0: compressWithLLM预处理截断500→2000字符; P0: handleThinking API异常重试机制(2次指数退避); P1: 增量压缩改为按token使用率30%触发而非消息条数; P1: COMPRESS_KEEP_HEAD/TAIL从2/2调整为5/8; P1: 看门狗从30分钟延长到60分钟; P1: 流式超时改为动态计算(120s+0.5s/1K tokens,上限10分钟); P1: buildContext windowSize从20调整为40; P2: smartTruncate改为token感知,仅在压力>50%时触发; P2: 工具消息硬限制从40条改为token感知(80条/压力大40条); P2: 移除60%软收敛催促(避免误导模型提前结束); AUTO_COMPRESS_THRESHOLD从0.5调整为0.3
527 lines
19 KiB
TypeScript
527 lines
19 KiB
TypeScript
/**
|
||
* Context Manager - 智能上下文管理 (v5.0)
|
||
* 三层策略:滑动窗口 + LLM 摘要压缩 + 记忆注入
|
||
* 支持自动压缩与手动 /compress 触发
|
||
*/
|
||
|
||
import type { OllamaMessage, OllamaStreamChunk } from '../types.js';
|
||
import { logInfo, logWarn, logSuccess, logError } from './log-service.js';
|
||
|
||
// ── 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 比例超过此值时触发自动压缩 */
|
||
export const AUTO_COMPRESS_THRESHOLD = 0.3;
|
||
|
||
/** 压缩后保留首尾消息数 */
|
||
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,
|
||
];
|
||
const lowValuePatterns = [/好的|明白|ok|知道了|嗯|哦|好/i,
|
||
/继续|请|帮我|可以吗/i,
|
||
/谢谢|感谢|不客气/i,
|
||
];
|
||
|
||
for (const p of highValuePatterns) {
|
||
if (p.test(content)) { score += 1; break; }
|
||
}
|
||
for (const p of lowValuePatterns) {
|
||
if (p.test(content) && content.length < 100) { score -= 2; break; }
|
||
}
|
||
|
||
// 工具调用 → 高价值
|
||
if (msg.tool_calls?.length) score += 2;
|
||
|
||
// 长度加分:长消息通常包含更多信息
|
||
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;
|
||
}
|
||
|
||
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[] = [];
|
||
|
||
// 系统 prompt
|
||
let systemContent = '';
|
||
if (opts.memoryContext) {
|
||
systemContent += opts.memoryContext + '\n\n';
|
||
}
|
||
if (opts.workspaceContext) {
|
||
systemContent += opts.workspaceContext + '\n\n';
|
||
}
|
||
|
||
// 提取已有的 system 消息
|
||
const existingSystem = allMessages.filter(m => m.role === 'system');
|
||
for (const sys of existingSystem) {
|
||
systemContent += sys.content + '\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 开销
|
||
*/
|
||
export function shouldAutoCompress(messages: OllamaMessage[], numCtx: number): boolean {
|
||
let totalTokens = 0;
|
||
for (const m of messages) {
|
||
totalTokens += estimateTokens(m.content || '');
|
||
// C3: tool_calls 和 images 也消耗大量 token
|
||
if (m.tool_calls?.length) totalTokens += m.tool_calls.length * 50;
|
||
if (m.images?.length) totalTokens += m.images.length * 100;
|
||
}
|
||
const threshold = numCtx * AUTO_COMPRESS_THRESHOLD;
|
||
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: Record<string, unknown>, onChunk: (chunk: OllamaStreamChunk) => void, ac?: 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);
|
||
|
||
// 过滤掉已经压缩过的消息(避免重复压缩)
|
||
const uncompressedMiddle = middle.filter(m => !m.compressed);
|
||
if (uncompressedMiddle.length === 0) {
|
||
logInfo('上下文压缩: 中间消息已全部压缩,跳过');
|
||
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: [] };
|
||
}
|
||
|
||
// 构建结构化摘要消息文本
|
||
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(';')}`);
|
||
|
||
// 构建压缩后的摘要消息
|
||
// C5: 使用 user role 而非 system role,避免部分模型拒绝多个 system 消息
|
||
const summaryMsg: OllamaMessage = {
|
||
role: 'user',
|
||
content: `📋 以下是对之前对话的摘要(已压缩 ${uncompressedMiddle.length} 条消息):\n\n${parts.join('\n')}`,
|
||
compressed: true
|
||
};
|
||
|
||
// 保留已压缩的中间消息 + 新摘要
|
||
const alreadyCompressed = middle.filter(m => m.compressed);
|
||
|
||
// 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');
|
||
|
||
const nonSystemCompressed = alreadyCompressed.filter(m => m.role !== 'system');
|
||
|
||
const result: OllamaMessage[] = [
|
||
{ role: 'system', content: mergedSystemContent },
|
||
...head,
|
||
...nonSystemCompressed,
|
||
summaryMsg,
|
||
...tail
|
||
];
|
||
|
||
const beforeTokens = estimateTokens(messages.map(m => m.content || '').join(''));
|
||
const afterTokens = estimateTokens(result.map(m => m.content || '').join(''));
|
||
|
||
logSuccess(`上下文压缩完成: ${messages.length} 条 → ${result.length} 条, tokens: ${beforeTokens} → ${afterTokens}`);
|
||
|
||
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);
|
||
summaries.push({
|
||
role: 'system',
|
||
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);
|
||
|
||
// 计算每条消息的 token + 重要性
|
||
const scored = olderMsgs.map(m => ({
|
||
msg: m,
|
||
tokens: estimateTokens(m.content || '') +
|
||
(m.images ? m.images.length * 100 : 0) +
|
||
(m.tool_calls ? m.tool_calls.length * 50 : 0),
|
||
importance: scoreMessageImportance(m),
|
||
}));
|
||
|
||
// 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];
|
||
}
|
||
|
||
// 按重要性降序排列,取能装下的最大数量
|
||
scored.sort((a, b) => b.importance - a.importance);
|
||
|
||
let usedTokens = 0;
|
||
const kept = new Set<number>(); // 保留的索引
|
||
for (let i = 0; i < scored.length; i++) {
|
||
if (usedTokens + scored[i].tokens > availableTokens && kept.size >= 2) break;
|
||
usedTokens += scored[i].tokens;
|
||
kept.add(i);
|
||
}
|
||
|
||
// 按原始顺序重建:system → 按重要性保留的旧消息 → 最近的 protected 消息
|
||
const result: OllamaMessage[] = [...systemMsgs];
|
||
const keptSet = new Set(scored.filter((_, i) => kept.has(i)).map(s => s.msg));
|
||
for (const m of olderMsgs) {
|
||
if (keptSet.has(m)) result.push(m);
|
||
}
|
||
result.push(...recentMsgs);
|
||
|
||
return result;
|
||
}
|