fix: proactive context compression for LM Studio small models - compress BEFORE fetch not after error

This commit is contained in:
2026-05-07 15:57:48 +09:00
parent faf3060ae7
commit d9a2ebfedd
7 changed files with 86 additions and 19 deletions
+70 -3
View File
@@ -2022,16 +2022,83 @@ export class AgentExecutor {
for (const candidateModel of modelCandidates) {
for (const variant of messageVariants) {
// 실제 전송할 메시지 (n_ctx 재시도 시 수정됨)
// 실제 전송할 메시지
let finalMessages = variant.messages;
// ── LM Studio 선제적 컨텍스트 압축 ──
// 소형 모델(4B 등)은 GPU 메모리 부족으로 n_ctx가 설정값보다 크게 줄어들 수 있고,
// 이때 LM Studio는 에러 대신 200 OK + 빈 스트림을 반환하여 재시도 불가.
// 따라서 전송 전에 선제적으로 메시지를 n_ctx에 맞게 압축합니다.
if (engine === 'lmstudio') {
const totalCharsRaw = finalMessages.reduce((acc, m) => acc + String(m.content || '').length, 0);
const estimatedTokensRaw = Math.ceil(totalCharsRaw / 4);
const LM_CTX_SAFE_LIMIT = 3500; // 4096 n_ctx 기준 안전 마진
if (estimatedTokensRaw > LM_CTX_SAFE_LIMIT) {
logInfo('LM Studio proactive compression triggered.', {
estimatedTokens: estimatedTokensRaw,
limit: LM_CTX_SAFE_LIMIT,
originalMessageCount: finalMessages.length
});
// 1. system 메시지에서 [CONTEXT] 이후 부분을 우선 제거
const sysIdx = finalMessages.findIndex(m => m.role === 'system');
if (sysIdx >= 0) {
const sysContent = String(finalMessages[sysIdx].content || '');
const contextSplit = sysContent.indexOf('[CONTEXT]');
if (contextSplit > 0) {
// [CONTEXT] 이전까지만 유지 (기본 시스템 프롬프트 + 핵심 지시)
const trimmedSys = sysContent.slice(0, contextSplit).trimEnd();
finalMessages = finalMessages.map((m, i) =>
i === sysIdx ? { ...m, content: trimmedSys + '\n[Context omitted: model context limit]' } : m
);
}
}
// 2. 그래도 크면 시스템 프롬프트를 max 글자로 강제 잘라냄
const afterTrimChars = finalMessages.reduce((acc, m) => acc + String(m.content || '').length, 0);
const afterTrimTokens = Math.ceil(afterTrimChars / 4);
if (afterTrimTokens > LM_CTX_SAFE_LIMIT && sysIdx >= 0) {
// 유저 메시지 토큰 계산
const nonSysTokens = finalMessages
.filter((_, i) => i !== sysIdx)
.reduce((acc, m) => acc + String(m.content || '').length, 0) / 4;
const maxSysChars = Math.max(2000, (LM_CTX_SAFE_LIMIT - Math.ceil(nonSysTokens) - 512)) * 4;
const sysContent = String(finalMessages[sysIdx].content || '');
if (sysContent.length > maxSysChars) {
finalMessages = finalMessages.map((m, i) =>
i === sysIdx ? { ...m, content: sysContent.slice(0, maxSysChars) + '\n[Truncated for model context limit]' } : m
);
}
}
// 3. 히스토리 메시지 정리: system + 마지막 user만 유지
const finalCheck = finalMessages.reduce((acc, m) => acc + String(m.content || '').length, 0) / 4;
if (finalCheck > LM_CTX_SAFE_LIMIT) {
const sysMsg = finalMessages.find(m => m.role === 'system');
const lastUserMsg = [...finalMessages].reverse().find(m => m.role === 'user');
finalMessages = [
...(sysMsg ? [sysMsg] : []),
...(lastUserMsg ? [lastUserMsg] : [])
];
}
logInfo('LM Studio compression result.', {
originalTokens: estimatedTokensRaw,
compressedTokens: Math.ceil(finalMessages.reduce((a, m) => a + String(m.content || '').length, 0) / 4),
messageCount: finalMessages.length
});
}
}
const totalChars = finalMessages.reduce((acc, m) => acc + String(m.content || '').length, 0);
const estimatedTokens = Math.ceil(totalChars / 4);
const streamBody = {
model: candidateModel,
messages: finalMessages,
messages: finalMessages.map(m => ({ role: m.role, content: m.content })),
stream: true,
...(engine === 'lmstudio'
? { max_tokens: 4096, temperature }
? { max_tokens: Math.min(4096, Math.max(256, 3500 - estimatedTokens)), temperature }
: { options: { num_ctx: 32768, num_predict: 4096, temperature } }),
};
logInfo('AI streaming request started.', {