v0.15.0: Agent ReAct Loop 核心引擎深度审计与生产级增强 (R1-R50)

核心引擎健壮性(R1-R10)、上下文管理优化(R11-R20)、工具安全与验证(R21-R30)、UI渲染性能(R31-R40)、基础设施与监控(R41-R50)、版本号升级
This commit is contained in:
thzxx
2026-07-11 20:56:48 +08:00
parent e04320d454
commit cb2ce48eb7
15 changed files with 2012 additions and 170 deletions
+420 -20
View File
@@ -7,6 +7,159 @@
import type { OllamaMessage, OllamaStreamChunk } 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 返回的实际计数动态修正 */
@@ -67,6 +220,40 @@ export function getTokenCalibration(): { ratio: number; samples: number } {
/** 自动压缩阈值:当消息 token 占 context window 比例超过此值时触发自动压缩 */
export const AUTO_COMPRESS_THRESHOLD = 0.3;
/** R14: 自适应压缩阈值 — 根据模型上下文长度动态调整 */
export function getAdaptiveCompressThreshold(numCtx: number): number {
// 小上下文模型(<8K):更早触发压缩(40%),留更多余量
// 中等上下文(8K-32K):标准阈值(30%)
// 大上下文(>32K):稍晚触发(25%),避免过于频繁压缩
if (numCtx < 8192) return 0.4;
if (numCtx > 32768) return 0.25;
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;
@@ -104,9 +291,11 @@ export function scoreMessageImportance(msg: OllamaMessage): number {
/完成|done|✓|success|成功|结果|result/i,
/项目|project|工作空间|workspace|git|repo|仓库/i,
];
const lowValuePatterns = [/好的|明白|ok|知道了|嗯|哦|好/i,
/继续|请|帮我|可以吗/i,
/谢谢|感谢|不客气/i,
// R10: 修复低价值模式误报 — 移除单字“好”(匹配几乎所有中文文本)
// 仅匹配明确的短语回复,且仅对短消息(<100字)生效
const lowValuePatterns = [/^(好的|明白|ok|知道了|嗯|哦|好[的呀吧]|收到)$/i,
/^(继续|请继续|帮帮我|可以吗|行吗|好的谢谢)$/i,
/^(谢谢|感谢|不客气|多谢|thanks?)$/i,
];
for (const p of highValuePatterns) {
@@ -119,6 +308,26 @@ export function scoreMessageImportance(msg: OllamaMessage): number {
// 工具调用 → 高价值
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 (/datetime|random|uuid|hash/.test(msg.tool_name)) {
score -= 2;
}
}
// 长度加分:长消息通常包含更多信息
if (content.length > 500) score += 1;
if (content.length > 2000) score += 1;
@@ -142,6 +351,111 @@ export interface ContextBuildOptions {
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,
@@ -164,20 +478,29 @@ export function buildContext(
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 消息
// 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) {
systemContent += sys.content + '\n';
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() });
@@ -223,16 +546,23 @@ export function buildContext(
* 判断是否需要自动压缩
* 当总 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 || '');
// C3: tool_calls 和 images 也消耗大量 token
if (m.tool_calls?.length) totalTokens += m.tool_calls.length * 50;
// 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;
}
const threshold = numCtx * AUTO_COMPRESS_THRESHOLD;
// R14: 使用自适应压缩阈值
const threshold = numCtx * getAdaptiveCompressThreshold(numCtx);
return totalTokens > threshold;
}
@@ -298,6 +628,28 @@ export async function compressWithLLM(
const tail = nonSystemMsgs.slice(-keepTail);
const middle = nonSystemMsgs.slice(keepHead, nonSystemMsgs.length - keepTail);
// 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);
}
}
// 过滤掉已经压缩过的消息(避免重复压缩)
const uncompressedMiddle = middle.filter(m => !m.compressed);
if (uncompressedMiddle.length === 0) {
@@ -305,6 +657,12 @@ export async function compressWithLLM(
return messages;
}
// R12: 压缩去重 — 检查这批消息是否已被压缩过(内容指纹匹配)
if (isAlreadyCompressed(uncompressedMiddle)) {
logInfo('R12: 消息批次内容指纹匹配已压缩记录,跳过重复压缩');
return messages;
}
// 构建对话文本
const conversationText = uncompressedMiddle.map(m => {
const role = m.role === 'user' ? '用户' : 'AI';
@@ -363,6 +721,14 @@ export async function compressWithLLM(
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('')}`);
@@ -372,6 +738,18 @@ export async function compressWithLLM(
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 = {
@@ -380,8 +758,11 @@ export async function compressWithLLM(
compressed: true
};
// 保留已压缩的中间消息 + 新摘要
const alreadyCompressed = middle.filter(m => m.compressed);
// R16: 保留已压缩的中间消息system 消息单独处理)+ 新摘要
// R12: 标记这批消息为已压缩
markAsCompressed(uncompressedMiddle);
// R16: 已压缩的旧摘要已被合并到新摘要中,不再保留它们
const alreadyCompressed = middle.filter(m => m.compressed && m.role === 'system');
// C1: 合并 system 消息为一条,但保留不可压缩的 system 消息完整内容
const mergedSystemContent = [
@@ -400,10 +781,29 @@ export async function compressWithLLM(
...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(''));
logSuccess(`上下文压缩完成: ${messages.length} 条 → ${result.length} 条, tokens: ${beforeTokens}${afterTokens}`);
// 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;
}