콘텐츠로 이동

Context Manager 구현

LLM은 컨텍스트 윈도우가 채워질수록 초반의 지시사항을 점점 덜 따르는 경향이 있습니다. 이를 “instruction fade-out” 이라고 부릅니다. 에이전트가 10턴 이후에 갑자기 다른 언어로 응답하거나, 초반에 설정한 제약을 무시하기 시작한다면 이 현상이 원인일 가능성이 높습니다.

Context Manager는 세 가지 전략으로 이 문제에 대응합니다.

  1. 우선순위 기반 프롬프트 조합 — 중요한 지시사항이 항상 시스템 프롬프트의 상단에 위치
  2. 적응형 컨텍스트 압축 — 히스토리가 길어질 때 요약으로 교체
  3. 이벤트 기반 리마인더 — 특정 시점에 핵심 지시사항을 재주입

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의 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({ ... });
// ...
}