feat: v1.1.0 — 上下文引擎重构 + 技能系统升级 + SOUL.md 支持
- 版本号更新: 1.0.0 → 1.1.0,全量同步 package.json/lock/README/docs/UI - 上下文长度自动检测: 切换模型时从 model_info 读取实际 context_length - SOUL.md 支持: 每次发送自动扫描工作空间 SOUL.md,注入为首条 system 消息且不可压缩 - 系统提示词卡片: AI 回复顶部可折叠展示实际发送给模型的完整 system prompt - Token 校准: 利用 Ollama 返回的实际计数动态修正估算器(EMA) - 消息重要性评分: 纯规则打分,trim/summarize 按重要性而非时间顺序 - 结构化压缩: LLM 压缩输出 JSON 格式(topics/decisions/pendingTasks/constraints) - 技能系统 v1.1: 语义匹配(embedding) + 参数自优化 + 未使用衰减 + 技能链合并 - 修复: 100+ TypeScript strict 模式编译错误清零
This commit is contained in:
@@ -7,7 +7,31 @@
|
||||
import type { OllamaMessage, OllamaStreamChunk } from '../types.js';
|
||||
import { logInfo, logWarn, logSuccess, logError } from './log-service.js';
|
||||
|
||||
/** 粗略估算 token 数 */
|
||||
// ── Token 校准系统 ──
|
||||
|
||||
/** 校准比例:actualTokens / estimatedTokens,基于 Ollama 返回的实际计数动态修正 */
|
||||
let _tokenCalibrationRatio = 1.0;
|
||||
let _calibrationSamples = 0;
|
||||
const MIN_CALIBRATION_SAMPLES = 3;
|
||||
|
||||
/**
|
||||
* 记录 Ollama 返回的实际 token 计数,用于校准估算器。
|
||||
* 在 agent-engine.ts 每轮流式完成后调用。
|
||||
* @param actualInputTokens Ollama 返回的 prompt_eval_count
|
||||
* @param actualOutputTokens Ollama 返回的 eval_count
|
||||
* @param estimatedTokens 本轮消息调用 estimateTokens 的合计值
|
||||
*/
|
||||
export function recordActualTokens(actualInputTokens: number, actualOutputTokens: number, estimatedCount: number): void {
|
||||
if (actualInputTokens <= 0 || estimatedCount <= 0) return;
|
||||
const actualTotal = actualInputTokens + actualOutputTokens;
|
||||
const sampleRatio = actualTotal / 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
|
||||
@@ -17,7 +41,17 @@ export function estimateTokens(text: string): number {
|
||||
if (/[\u4e00-\u9fff]/.test(ch)) chineseChars++;
|
||||
else otherChars++;
|
||||
}
|
||||
return Math.ceil(chineseChars / 1.5 + otherChars / 4);
|
||||
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 比例超过此值时触发自动压缩 */
|
||||
@@ -27,6 +61,57 @@ export const AUTO_COMPRESS_THRESHOLD = 0.5;
|
||||
const COMPRESS_KEEP_HEAD = 2;
|
||||
const COMPRESS_KEEP_TAIL = 2;
|
||||
|
||||
// ── 消息重要性评分(纯规则,不调用 LLM)──
|
||||
|
||||
/** 重要性评分:0-10,越高越应该保留。SOUL.md 和规则记忆始终保留。 */
|
||||
export function scoreMessageImportance(msg: OllamaMessage): number {
|
||||
let score = 5; // 默认中等
|
||||
|
||||
// SOUL.md 消息永远不可压缩
|
||||
if (msg.content?.startsWith('[SOUL.md]')) return 10;
|
||||
|
||||
// 角色权重
|
||||
if (msg.role === 'user') score += 2; // 用户消息最重要
|
||||
if (msg.role === 'system') score -= 2; // 系统消息通常可压缩
|
||||
|
||||
// 已压缩标记 → 低权重(避免压缩产物堆积)
|
||||
if (msg.compressed) score = 1;
|
||||
|
||||
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;
|
||||
@@ -127,9 +212,28 @@ export function shouldAutoCompress(messages: OllamaMessage[], numCtx: number): b
|
||||
return totalTokens > threshold;
|
||||
}
|
||||
|
||||
// ── 结构化压缩 ──
|
||||
|
||||
/** 结构化摘要 */
|
||||
export interface StructuredSummary {
|
||||
/** 讨论的主题 */
|
||||
topics: string[];
|
||||
/** 已做出的决定 */
|
||||
decisions: string[];
|
||||
/** 未完成的待办事项 */
|
||||
pendingTasks: string[];
|
||||
/** 发现的约束/规则 */
|
||||
constraints: string[];
|
||||
/** 关键知识点(跨会话有价值的信息) */
|
||||
knowledge: string[];
|
||||
/** 工具调用结果摘要 */
|
||||
toolResults: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* LLM 摘要压缩:调用模型对中间消息生成摘要
|
||||
* 保留首尾各 keepHead/keepTail 条消息,中间用 LLM 摘要替换
|
||||
* LLM 摘要压缩:调用模型对中间消息生成结构化 JSON 摘要。
|
||||
* 保留首尾各 keepHead/keepTail 条消息,中间用 JSON 摘要替换。
|
||||
* v6.0: 输出结构化 JSON,支持增量合并。
|
||||
*
|
||||
* @returns 压缩后的消息列表(包含 compressed 标记的摘要消息)
|
||||
*/
|
||||
@@ -182,14 +286,14 @@ export async function compressWithLLM(
|
||||
|
||||
logInfo(`上下文压缩: 开始 LLM 摘要,${uncompressedMiddle.length} 条消息待压缩`);
|
||||
|
||||
let summary = '';
|
||||
let summaryJson = '';
|
||||
try {
|
||||
await api.chatStream(
|
||||
{
|
||||
model,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: `请将以下对话摘要为一段简洁的上下文总结。保留关键信息:讨论的主题、得出的结论、用户的偏好、未完成的任务、关键工具调用结果。用中文输出,不超过 ${maxSummaryTokens} 字。\n\n对话记录:\n${conversationText}`
|
||||
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,
|
||||
@@ -197,7 +301,7 @@ export async function compressWithLLM(
|
||||
},
|
||||
(chunk: OllamaStreamChunk) => {
|
||||
if (chunk.message?.content) {
|
||||
summary += chunk.message.content;
|
||||
summaryJson += chunk.message.content;
|
||||
}
|
||||
},
|
||||
options.abortController
|
||||
@@ -211,15 +315,34 @@ export async function compressWithLLM(
|
||||
return messages;
|
||||
}
|
||||
|
||||
if (!summary.trim()) {
|
||||
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(';')}`);
|
||||
|
||||
// 构建压缩后的摘要消息
|
||||
const summaryMsg: OllamaMessage = {
|
||||
role: 'system',
|
||||
content: `📋 以下是对之前对话的摘要(已压缩 ${uncompressedMiddle.length} 条消息):\n\n${summary}`,
|
||||
content: `📋 以下是对之前对话的摘要(已压缩 ${uncompressedMiddle.length} 条消息):\n\n${parts.join('\n')}`,
|
||||
compressed: true
|
||||
};
|
||||
|
||||
@@ -243,7 +366,8 @@ export async function compressWithLLM(
|
||||
}
|
||||
|
||||
/**
|
||||
* 将较早的消息每 batchSize 条压缩为一段摘要(快速文本截取,不调用 LLM)
|
||||
* 将较早的消息每 batchSize 条压缩为一段摘要。
|
||||
* v6.0: 重要性高的消息保留更多内容,低价值消息激进截断。
|
||||
*/
|
||||
function summarizeOlderMessages(messages: OllamaMessage[], batchSize: number): OllamaMessage[] {
|
||||
const summaries: OllamaMessage[] = [];
|
||||
@@ -262,7 +386,7 @@ function summarizeOlderMessages(messages: OllamaMessage[], batchSize: number): O
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速摘要(不调用模型,直接提取关键信息)
|
||||
* 快速摘要:重要性高的消息保留更多信息,低价值的激进截断
|
||||
*/
|
||||
function createQuickSummary(messages: OllamaMessage[]): string {
|
||||
const parts: string[] = [];
|
||||
@@ -270,7 +394,21 @@ function createQuickSummary(messages: OllamaMessage[]): string {
|
||||
for (const msg of messages) {
|
||||
const role = msg.role === 'user' ? '用户' : 'AI';
|
||||
const content = msg.content || '';
|
||||
const preview = content.length > 100 ? content.slice(0, 100) + '...' : 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}`);
|
||||
}
|
||||
@@ -284,31 +422,46 @@ function createQuickSummary(messages: OllamaMessage[]): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 token 限制裁剪消息
|
||||
* 根据 token 限制裁剪消息。
|
||||
* v6.0: 按重要性评分决定保留顺序——重要性低的优先被丢弃。
|
||||
*/
|
||||
function trimByTokenLimit(messages: OllamaMessage[], maxTokens: number): OllamaMessage[] {
|
||||
let totalTokens = 0;
|
||||
const result: OllamaMessage[] = [];
|
||||
// 分离 system 和非 system 消息
|
||||
const systemMsgs = messages.filter(m => m.role === 'system');
|
||||
const nonSystemMsgs = messages.filter(m => m.role !== 'system');
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
const msgTokens = estimateTokens(msg.content || '') +
|
||||
(msg.images ? msg.images.length * 100 : 0) +
|
||||
(msg.tool_calls ? msg.tool_calls.length * 50 : 0);
|
||||
if (nonSystemMsgs.length <= 4) return messages; // 消息太少不裁剪
|
||||
|
||||
// system 消息始终保留
|
||||
if (msg.role === 'system') {
|
||||
totalTokens += msgTokens;
|
||||
result.unshift(msg);
|
||||
continue;
|
||||
}
|
||||
// 计算每条消息的 token + 重要性
|
||||
const scored = nonSystemMsgs.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),
|
||||
}));
|
||||
|
||||
if (totalTokens + msgTokens > maxTokens && result.length > 2) {
|
||||
break;
|
||||
}
|
||||
// system 消息的 token 消耗
|
||||
const systemTokens = systemMsgs.reduce((sum, m) => sum + estimateTokens(m.content || ''), 0);
|
||||
const availableTokens = maxTokens - systemTokens;
|
||||
|
||||
totalTokens += msgTokens;
|
||||
result.unshift(msg);
|
||||
// 按重要性降序排列,取能装下的最大数量
|
||||
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 → 按重要性保留的非system
|
||||
const result: OllamaMessage[] = [...systemMsgs];
|
||||
// 使用原始顺序而非重要性顺序,保持对话的时间线
|
||||
const keptSet = new Set(scored.filter((_, i) => kept.has(i)).map(s => s.msg));
|
||||
for (const m of nonSystemMsgs) {
|
||||
if (keptSet.has(m)) result.push(m);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
Reference in New Issue
Block a user