Files
connectai/src/lib/contextManager.ts
T
g1nation 0712014fcb chore: v2.2.73 — ASTRA-DEBUG 로그 레벨 + webview CSP font-src 보강
- ASTRA-DEBUG 정상 흐름 로그를 console.error → logInfo/console.log 로 강등
  (chatHandlers, extension, slashRouter): DevTools에 ERR로 찍히던 오탐 제거
- sidebar webview에 명시적 CSP meta 추가 + font-src에 data: 허용
  (sidebar.html, sidebarProvider._getHtml): VS Code outer iframe이 codicon.ttf를
  data:font/ttf 로 inject하면서 기본 CSP에 막혀 매 prompt 마다 violation
  경고가 찍히던 문제 해소
- 누적된 LM Studio / agent / 컨텍스트 매니저 / 테스트 갱신 동반

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 15:52:19 +09:00

279 lines
13 KiB
TypeScript

/**
* ============================================================
* Context Manager (컨텍스트 한계 관리)
*
* "context length = 132k" 는 "답변을 132k 토큰까지 생성해도 된다" 가 아닙니다.
* 시스템 프롬프트 + 대화 기록 + 입력 문서 + 생성될 답변 + 여유분 ≤ context length
*
* 이 모듈은 요청을 보내기 *전에* 입력 토큰을 추정하고,
* - 동적으로 출력 상한(maxTokens)을 계산하고,
* - 대화 기록이 예산을 넘으면 오래된 메시지를 잘라내고,
* - 그래도 넘으면 시스템 프롬프트의 [CONTEXT] 블록을 마지막 수단으로 줄이고,
* - 생성 종료 사유(stopReason / finish_reason)를 "정상 / 출력한계 / 컨텍스트초과 / 사용자중단"
* 으로 분류해 호출자가 잘린 응답을 감지할 수 있게 합니다.
* ============================================================
*/
export type ChatRole = 'user' | 'assistant' | 'system';
export interface BudgetMessage {
role: ChatRole;
content: string;
/** internal/system bookkeeping messages that should be kept verbatim where possible */
internal?: boolean;
}
export interface ContextLimits {
/** 모델의 context window (프롬프트 + 생성 합산 한계). */
contextLength: number;
/** 한 응답에서 생성할 토큰 수의 상한 (이 값을 넘기지 않음). */
maxOutputTokens: number;
/** 추정 오차를 흡수하기 위한 여유분. */
safetyMargin: number;
/** 출력에 항상 확보해 둘 최소 토큰 수. */
minOutputTokens: number;
}
export const DEFAULT_CONTEXT_LIMITS: ContextLimits = {
contextLength: 32768,
maxOutputTokens: 4096,
safetyMargin: 2048,
minOutputTokens: 512,
};
/** LM Studio `contextOverflowPolicy` 값 — 우리가 예산 계산에 실패했을 때의 안전망. */
export type ContextOverflowPolicy = 'stopAtLimit' | 'truncateMiddle' | 'rollingWindow';
export const DEFAULT_OVERFLOW_POLICY: ContextOverflowPolicy = 'stopAtLimit';
/**
* 텍스트의 토큰 수를 대략 추정합니다.
*
* 정밀한 토크나이저가 없으므로 문자 기반 휴리스틱을 사용합니다:
* - CJK(한/중/일) 글자: ~1.6 토큰/글자 (byte-level BPE 기준 보수적)
* - 그 외(영문/코드/기호): ~0.30 토큰/글자
* 약간 과대평가하는 쪽으로 잡아 컨텍스트 초과를 예방합니다.
*/
export function estimateTokens(text: string): number {
if (!text) return 0;
const cjkChars = (text.match(/[ -〿぀-ヿ㐀-䶿一-鿿가-힯＀-￯]/g) || []).length;
const otherChars = text.length - cjkChars;
return Math.ceil(cjkChars * 1.6 + otherChars * 0.3);
}
/**
* 모델 식별자에서 파라미터 규모(B 단위)를 대략 추출합니다. 모르면 null.
* 예: "qwen2.5-7b" → 7, "llama-3.1-8b-instruct" → 8, "gemma-3n-e2b" / "gemma4:e2b" → 2,
* "phi-3-mini" → null (숫자 없음), "qwen3-30b-a3b" → 30. "4bit" 같은 양자화 표기는 매칭 안 됨.
*/
export function estimateModelParamsB(modelId: string | null | undefined): number | null {
if (!modelId) return null;
const m = String(modelId).match(/(?:^|[-_/:.\s])e?(\d+(?:\.\d+)?)\s*b(?![a-z0-9])/i);
if (!m) return null;
const n = Number(m[1]);
return Number.isFinite(n) && n > 0 && n < 2000 ? n : null;
}
/** role/구분자 등 메시지 1개당 발생하는 고정 오버헤드(대략). */
const PER_MESSAGE_TOKEN_OVERHEAD = 4;
export function estimateMessageTokens(msg: BudgetMessage): number {
return estimateTokens(msg.content || '') + PER_MESSAGE_TOKEN_OVERHEAD;
}
export function estimateMessagesTokens(messages: BudgetMessage[]): number {
return messages.reduce((sum, m) => sum + estimateMessageTokens(m), 0);
}
/**
* 입력 토큰 수가 주어졌을 때 안전하게 생성할 수 있는 출력 토큰 상한을 계산합니다.
*
* available = contextLength - inputTokens - safetyMargin
* maxOutput = clamp(available, minOutputTokens, maxOutputTokens)
*
* available 이 minOutputTokens 보다 작으면 입력이 이미 컨텍스트를 거의 다 먹은 상태이므로
* `tight: true` 와 함께 minOutputTokens 를 그대로 돌려줍니다 (호출자가 추가로 줄여야 함).
*/
export function computeOutputBudget(
inputTokens: number,
limits: ContextLimits = DEFAULT_CONTEXT_LIMITS
): { maxOutputTokens: number; available: number; tight: boolean } {
const { contextLength, maxOutputTokens, safetyMargin, minOutputTokens } = limits;
const available = contextLength - inputTokens - safetyMargin;
if (available <= minOutputTokens) {
return { maxOutputTokens: minOutputTokens, available, tight: true };
}
return {
maxOutputTokens: Math.max(minOutputTokens, Math.min(available, maxOutputTokens)),
available,
tight: false,
};
}
export interface TrimResult<M extends BudgetMessage> {
messages: M[];
/** 잘려나간 메시지 개수 (0 이면 변화 없음). */
droppedCount: number;
/** 잘라낸 뒤의 입력 토큰 추정치. */
tokensAfter: number;
}
/**
* 대화 기록을 토큰 예산 안에 맞춥니다 (sliding window).
*
* 전략:
* 1. 항상 마지막 메시지(보통 현재 사용자 질문)는 유지.
* 2. 최근 메시지부터 역순으로 예산이 허용하는 만큼 채움.
* 3. 하나라도 잘렸으면 맨 앞에 marker 를 끼워 모델이 맥락 누락을 인지하게 함.
* v2.2.69+ — marker 콜백은 droppedCount 뿐 아니라 *잘려나간 메시지 배열* 도 받아
* 단순 count 가 아닌 진짜 요약/맥락을 작성할 수 있다.
*
* 주의: 여기서 잘라내는 것은 *요청에 보낼* 메시지 배열일 뿐, UI에 표시되는 전체 기록은 그대로 둡니다.
*/
export function trimHistoryToBudget<M extends BudgetMessage>(
messages: M[],
budgetTokens: number,
makeMarker: (droppedCount: number, droppedMessages: M[]) => M
): TrimResult<M> {
if (messages.length === 0) {
return { messages, droppedCount: 0, tokensAfter: 0 };
}
const total = estimateMessagesTokens(messages);
if (total <= budgetTokens) {
return { messages, droppedCount: 0, tokensAfter: total };
}
// 최근 메시지부터 역순으로 채움. 최소 1개(마지막 메시지)는 무조건 유지.
const kept: M[] = [];
let used = 0;
for (let i = messages.length - 1; i >= 0; i--) {
const t = estimateMessageTokens(messages[i]);
if (kept.length > 0 && used + t > budgetTokens) {
break;
}
kept.unshift(messages[i]);
used += t;
}
const droppedCount = messages.length - kept.length;
if (droppedCount > 0) {
const droppedMessages = messages.slice(0, droppedCount);
const marker = makeMarker(droppedCount, droppedMessages);
kept.unshift(marker);
used += estimateMessageTokens(marker);
}
return { messages: kept, droppedCount, tokensAfter: used };
}
/** 시스템 프롬프트 안에서 "잘라내도 되는" 보조 컨텍스트 영역의 시작/끝 마커. */
export const CONTEXT_OPEN_MARKER = '\n\n[CONTEXT]\n';
export const CONTEXT_CLOSE_MARKER = '\n[/CONTEXT]\n';
/**
* 시스템 프롬프트가 너무 클 때 마지막 수단으로 `[CONTEXT] … [/CONTEXT]` 사이의 보조 컨텍스트
* (브레인/메모리/열린 파일/RAG 등 — 조립 단계에서 끼워 넣는 데이터)만 잘라냅니다.
* 핵심 지시문(앞부분)과 마무리 지시문(예: negative constraints, agent system prompt — 뒷부분)은
* 절대 건드리지 않습니다. `[/CONTEXT]` 마커가 없으면 `[CONTEXT]` 이후 전체를 trim 대상으로 봅니다.
*
* @param systemPrompt 조립이 끝난 전체 시스템 프롬프트
* @param maxTokens 시스템 프롬프트에 허용할 토큰 상한
*/
export function truncateSystemPromptContext(
systemPrompt: string,
maxTokens: number
): { prompt: string; truncated: boolean } {
if (estimateTokens(systemPrompt) <= maxTokens) {
return { prompt: systemPrompt, truncated: false };
}
const openIdx = systemPrompt.indexOf(CONTEXT_OPEN_MARKER);
if (openIdx < 0) {
// 보조 컨텍스트 영역이 없으면 전체에서 뒤를 잘라낼 수밖에 없음.
const approxChars = Math.max(1000, Math.floor(maxTokens / 0.3));
return {
prompt: systemPrompt.slice(0, approxChars) + '\n\n[…시스템 프롬프트가 컨텍스트 한계로 잘렸습니다…]',
truncated: true,
};
}
const bodyStart = openIdx + CONTEXT_OPEN_MARKER.length;
const closeIdx = systemPrompt.indexOf(CONTEXT_CLOSE_MARKER, bodyStart);
const head = systemPrompt.slice(0, bodyStart); // 지시문 + "[CONTEXT]\n"
const body = closeIdx >= 0 ? systemPrompt.slice(bodyStart, closeIdx) : systemPrompt.slice(bodyStart);
const tail = closeIdx >= 0 ? systemPrompt.slice(closeIdx) : ''; // "[/CONTEXT]" + negative/agent 등
const fixedTokens = estimateTokens(head) + estimateTokens(tail);
const remainForBody = maxTokens - fixedTokens - 64;
if (remainForBody <= 0) {
return {
prompt: head + '[…보조 컨텍스트는 컨텍스트 한계로 모두 생략되었습니다…]' + tail,
truncated: true,
};
}
// CJK 비중에 따라 글자수→토큰 비율이 달라지므로 보수적으로 0.4 토큰/글자로 환산.
const approxChars = Math.floor(remainForBody / 0.4);
const trimmedBody = body.length <= approxChars
? body
: body.slice(0, approxChars) + '\n\n[…이하 보조 컨텍스트는 컨텍스트 한계로 생략됨…]';
return { prompt: head + trimmedBody + tail, truncated: true };
}
export type GenerationStopKind =
| 'complete' // 정상 종료 (EOS / stop string)
| 'output-limit' // maxTokens 도달 — 답변이 중간에 잘림
| 'context-overflow'// 입력+출력이 context window 초과
| 'user-stopped' // 사용자 취소
| 'tool-calls' // 툴 호출로 종료
| 'error' // 모델/런타임 오류
| 'unknown';
/**
* 엔진별 종료 사유 문자열을 공통 분류값으로 정규화합니다.
* - LM Studio SDK: `stats.stopReason` — eosFound / stopStringFound / maxPredictedTokensReached / contextLengthReached / userStopped / toolCalls / failed / modelUnloaded
* - OpenAI 호환 REST: `choices[].finish_reason` — stop / length / tool_calls / content_filter
* - Ollama: `done_reason` — stop / length / load
*/
export function classifyStopReason(raw: string | null | undefined): GenerationStopKind {
if (!raw) return 'unknown';
const r = String(raw).toLowerCase();
if (/(maxpredictedtokensreached|^length$|max_tokens)/.test(r)) return 'output-limit';
if (/(contextlengthreached|context_length|context_overflow|contextoverflow)/.test(r)) return 'context-overflow';
if (/(eosfound|stopstringfound|^stop$|^end$|stop_sequence|content_filter)/.test(r)) return 'complete';
if (/(userstopped|aborted|cancel)/.test(r)) return 'user-stopped';
if (/(toolcalls|tool_calls)/.test(r)) return 'tool-calls';
if (/(failed|error|modelunloaded)/.test(r)) return 'error';
return 'unknown';
}
/**
* 잘린 응답일 때 사용자에게 덧붙일 한 줄 안내. 정상 종료면 빈 문자열.
* (output-limit 은 Astra 가 먼저 자동 이어쓰기를 시도하므로, 이 안내는 그래도 다 못 채웠을 때만 보입니다.
* 그래서 "이어서 작성해줘" 같은 사용자 액션을 요구하지 않습니다.)
*/
export function truncationNotice(kind: GenerationStopKind): string {
switch (kind) {
case 'output-limit':
return '\n\n> ⚠️ 답변이 길어 자동으로 이어 정리했지만 여전히 길이 한계에 닿았습니다. 더 좁은 주제로 나눠 질문하시면 완전한 답변을 받을 수 있어요.';
case 'context-overflow':
return '\n\n> ⚠️ 입력 컨텍스트가 모델의 context window 를 초과했습니다. 대화를 새로 시작하거나(`/newChat`) Settings 에서 `g1nation.contextLength` 를 모델 실제 값으로 맞추고, Brain/Skill 컨텍스트를 줄여보세요.';
case 'error':
return '\n\n> ⚠️ 모델이 비정상 종료했습니다 (컨텍스트 초과 또는 모델 용량 부족 가능). 더 큰 모델로 바꾸거나 컨텍스트를 줄여보세요.';
default:
return '';
}
}
/**
* Some local engines report `maxPredictedTokensReached` even when the visible
* answer is short (for example after an internal retry or SDK stats mismatch).
* Only show the "answer was cut off" notice when the generated answer actually
* consumed most of the output budget.
*/
export function shouldShowTruncationNotice(
kind: GenerationStopKind,
outputTokens: number,
maxOutputTokens: number
): boolean {
if (kind === 'context-overflow' || kind === 'error') return true;
if (kind !== 'output-limit') return false;
const threshold = Math.max(128, Math.floor(maxOutputTokens * 0.85));
return outputTokens >= threshold;
}