Context Manager 구현
컨텍스트 관리가 어려운 이유
섹션 제목: “컨텍스트 관리가 어려운 이유”LLM은 컨텍스트 윈도우가 채워질수록 초반의 지시사항을 점점 덜 따르는 경향이 있습니다. 이를 “instruction fade-out” 이라고 부릅니다. 에이전트가 10턴 이후에 갑자기 다른 언어로 응답하거나, 초반에 설정한 제약을 무시하기 시작한다면 이 현상이 원인일 가능성이 높습니다.
Context Manager는 세 가지 전략으로 이 문제에 대응합니다.
- 우선순위 기반 프롬프트 조합 — 중요한 지시사항이 항상 시스템 프롬프트의 상단에 위치
- 적응형 컨텍스트 압축 — 히스토리가 길어질 때 요약으로 교체
- 이벤트 기반 리마인더 — 특정 시점에 핵심 지시사항을 재주입
PromptComposer: 우선순위 기반 조합
섹션 제목: “PromptComposer: 우선순위 기반 조합”interface PromptSection { id: string; priority: number; // 높을수록 상단에 배치 (0~100) content: string; required: boolean; // true이면 압축 시에도 제거 불가}
class PromptComposer { private sections: PromptSection[] = [];
add(section: PromptSection): void { this.sections.push(section); }
remove(id: string): void { this.sections = this.sections.filter(s => s.id !== id); }
compose(options?: { suffix?: string; base?: string }): string { if (options?.base) { // SubAgentSpec의 override: 기본 섹션 무시 return [options.base, options.suffix].filter(Boolean).join('\n\n'); }
const sorted = [...this.sections].sort((a, b) => b.priority - a.priority); const parts = sorted.map(s => s.content);
if (options?.suffix) parts.push(options.suffix);
return parts.join('\n\n---\n\n'); }
// 토큰 예산 초과 시 낮은 우선순위 섹션 제거 composeWithBudget(maxTokens: number): string { const sorted = [...this.sections].sort((a, b) => b.priority - a.priority); const selected: PromptSection[] = []; let tokenCount = 0;
for (const section of sorted) { const sectionTokens = estimateTokens(section.content); if (tokenCount + sectionTokens <= maxTokens || section.required) { selected.push(section); tokenCount += sectionTokens; } }
return selected.map(s => s.content).join('\n\n---\n\n'); }}표준 섹션 우선순위 체계
섹션 제목: “표준 섹션 우선순위 체계”// 권장 우선순위 기준const PROMPT_PRIORITIES = { SAFETY_CONSTRAINTS: 100, // 절대 제거 불가 (required: true) ROLE_DEFINITION: 90, // 에이전트의 핵심 역할 TASK_CONTEXT: 80, // 현재 작업 컨텍스트 TOOL_GUIDANCE: 70, // 툴 사용 지침 OUTPUT_FORMAT: 60, // 출력 형식 요구사항 EXAMPLES: 40, // Few-shot 예시 (압축 시 우선 제거) BACKGROUND_INFO: 20, // 배경 정보 (압축 시 제거)} as const;
// 실제 섹션 등록 예시composer.add({ id: 'safety', priority: PROMPT_PRIORITIES.SAFETY_CONSTRAINTS, required: true, content: `## 안전 제약사항- /etc, /sys, /proc 디렉토리는 절대 수정하지 마세요- 사용자 홈 디렉토리 외부 파일 삭제는 금지합니다- 네트워크 요청은 허용된 도메인에만 허용합니다 `.trim(),});
composer.add({ id: 'examples', priority: PROMPT_PRIORITIES.EXAMPLES, required: false, content: fewShotExamples, // 컨텍스트 압박 시 제거됨});컨텍스트 압축 엔진
섹션 제목: “컨텍스트 압축 엔진”interface CompactionConfig { triggerThreshold: number; // 이 비율 초과 시 압축 시작 (예: 0.75) targetRatio: number; // 압축 후 목표 비율 (예: 0.5) keepLastN: number; // 최근 N개 메시지는 항상 보존}
class ContextCompactor { constructor( private llmClient: LLMClient, private config: CompactionConfig, ) {}
needsCompaction(history: Message[], maxTokens: number): boolean { const usedTokens = estimateTokens(history); return usedTokens / maxTokens > this.config.triggerThreshold; }
async compact(history: Message[]): Promise<Message[]> { const keepCount = this.config.keepLastN; const toSummarize = history.slice(0, -keepCount); const toKeep = history.slice(-keepCount);
if (toSummarize.length === 0) return history;
const summary = await this.summarize(toSummarize);
return [ { role: 'user', content: `[이전 대화 요약]\n${summary}` }, ...toKeep, ]; }
private async summarize(messages: Message[]): Promise<string> { const prompt = `다음 대화를 핵심 결정사항과 완료된 작업 중심으로 간결하게 요약하세요:\n\n${ messages.map(m => `${m.role}: ${m.content}`).join('\n') }`;
const response = await this.llmClient.complete({ system: '대화 요약 전문가입니다. 중요한 결정과 상태 변화에 집중하세요.', messages: [{ role: 'user', content: prompt }], });
return response.content; }}이벤트 기반 리마인더
섹션 제목: “이벤트 기반 리마인더”특정 조건이 충족될 때 핵심 지시사항을 대화 히스토리에 재주입합니다.
interface ReminderRule { id: string; trigger: ReminderTrigger; message: string; maxRepeats?: number; // 최대 반복 횟수 (기본: 무제한)}
type ReminderTrigger = | { type: 'every_n_turns'; n: number } | { type: 'token_threshold'; threshold: number } | { type: 'tool_call'; toolName: string } | { type: 'keyword_in_response'; keyword: string };
class EventReminder { private repeatCounts = new Map<string, number>();
constructor(private rules: ReminderRule[]) {}
getReminders(context: AgentContext): string[] { return this.rules .filter(rule => this.shouldTrigger(rule, context)) .map(rule => rule.message); }
private shouldTrigger(rule: ReminderRule, ctx: AgentContext): boolean { const repeatCount = this.repeatCounts.get(rule.id) ?? 0; if (rule.maxRepeats !== undefined && repeatCount >= rule.maxRepeats) return false;
const { trigger } = rule; switch (trigger.type) { case 'every_n_turns': return ctx.turnCount % trigger.n === 0; case 'token_threshold': return ctx.usedTokens > trigger.threshold; case 'tool_call': return ctx.lastToolCall === trigger.toolName; case 'keyword_in_response': return ctx.lastResponse?.includes(trigger.keyword) ?? false; } }
recordTriggered(ruleId: string): void { this.repeatCounts.set(ruleId, (this.repeatCounts.get(ruleId) ?? 0) + 1); }}실제 리마인더 규칙 예시
섹션 제목: “실제 리마인더 규칙 예시”const reminders = new EventReminder([ { id: 'language-reminder', trigger: { type: 'every_n_turns', n: 10 }, message: '[시스템 리마인더] 반드시 한국어로 응답하세요.', maxRepeats: 5, }, { id: 'safety-reminder', trigger: { type: 'tool_call', toolName: 'run_command' }, message: '[시스템 리마인더] 명령 실행 전 의도치 않은 부작용이 없는지 확인하세요.', }, { id: 'compaction-notice', trigger: { type: 'token_threshold', threshold: 150_000 }, message: '[시스템 리마인더] 컨텍스트가 압축되었습니다. 초기 지시사항을 다시 확인하세요.', maxRepeats: 1, },]);MainAgent와 통합
섹션 제목: “MainAgent와 통합”// MainAgent의 run() 루프에 Context Manager 통합while (turns < maxTurns) { turns++;
// 압축 필요 여부 확인 if (compactor.needsCompaction(this.conversationHistory, maxContextTokens)) { this.conversationHistory = await compactor.compact(this.conversationHistory); }
// 리마인더 주입 const reminders = eventReminder.getReminders({ turnCount: turns, usedTokens, lastToolCall }); if (reminders.length > 0) { this.conversationHistory.push({ role: 'user', content: reminders.join('\n'), }); }
const response = await this.deps.llmClient.complete({ ... }); // ...}