콘텐츠로 이동

Lazy Discovery와 Progressive Disclosure

에이전트 시스템에서 사용 가능한 도구가 수십 개에서 수백 개로 늘어나면, 모든 도구를 컨텍스트에 포함시키는 것은 현실적이지 않습니다.

도구 하나의 정의(이름 + 설명 + 파라미터 스키마)는 평균 50~200 토큰을 차지합니다. 100개 도구 = 최대 20,000 토큰 소모입니다. 128K 컨텍스트의 15%가 도구 목록에 낭비됩니다.

전체 컨텍스트: 128,000 토큰
도구 100개 × 150토큰 = 15,000 토큰 (11.7% 소모)
실제 태스크에 사용 가능: 113,000 토큰
현재 태스크가 실제로 사용하는 도구: 평균 3~5개
낭비되는 도구 정의: 95~97개 × 150토큰 ≈ 14,250 토큰

Lazy Discovery는 필요한 도구만 적시에 컨텍스트에 로드하는 전략입니다.

구분Eager LoadingLazy Loading
도구 로드 시점세션 시작 시 전부필요할 때 검색하여 로드
컨텍스트 사용전체 도구 목록관련 도구만
발견 지연없음검색 1회 지연
적합한 규모도구 10개 이하도구 20개 이상
캐시 친화성낮음 (도구 변경 시 캐시 파괴)높음 (시스템 프롬프트 안정)

MCP의 listTools는 전체 도구 목록을 반환합니다. Lazy Discovery는 이 목록을 모두 로드하지 않고, 현재 태스크와 관련성이 높은 도구만 검색합니다.

interface ToolMetadata {
name: string;
description: string;
keywords: string[]; // 도구 등록 시 부여하는 키워드
category: string;
}
function scoreToolRelevance(
tool: ToolMetadata,
query: string
): number {
const queryTokens = tokenize(query.toLowerCase());
let score = 0;
for (const token of queryTokens) {
// 이름 일치: 높은 가중치
if (tool.name.toLowerCase().includes(token)) score += 3;
// 키워드 일치: 중간 가중치
if (tool.keywords.some((k) => k.toLowerCase().includes(token))) score += 2;
// 설명 일치: 낮은 가중치
if (tool.description.toLowerCase().includes(token)) score += 1;
}
return score;
}
async function discoverTools(
allTools: ToolMetadata[],
taskDescription: string,
topK: number = 5
): Promise<ToolMetadata[]> {
const scored = allTools.map((tool) => ({
tool,
score: scoreToolRelevance(tool, taskDescription),
}));
return scored
.filter(({ score }) => score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, topK)
.map(({ tool }) => tool);
}

태스크가 진행되면서 필요한 도구가 달라집니다. 처음부터 모든 도구를 제공하는 대신, 단계별로 필요한 도구를 추가 제공합니다.

class ProgressiveToolDisclosure {
private loadedTools = new Set<string>();
async getToolsForStep(
stepDescription: string,
allTools: ToolMetadata[]
): Promise<LoadedTool[]> {
// 이미 로드된 도구 유지 + 새로 관련된 도구 추가
const relevant = await discoverTools(allTools, stepDescription, 5);
const newTools = relevant.filter(
(t) => !this.loadedTools.has(t.name)
);
// 새 도구를 로드된 목록에 추가
for (const tool of newTools) {
this.loadedTools.add(tool.name);
}
// 현재까지 로드된 모든 도구 반환
return allTools
.filter((t) => this.loadedTools.has(t.name))
.map((t) => loadFullToolDefinition(t.name));
}
}

실제 사용 흐름:

스텝 1: "파일 읽기" → read_file, list_directory 로드 (2개)
스텝 2: "코드 분석" → analyze_code, run_tests 추가 (4개)
스텝 3: "수정 사항 저장" → write_file 추가 (5개)
vs Eager Loading: 전 스텝에서 100개 전부 로드

도구가 많을 때 카테고리 기반 네임스페이스로 구조화하면 검색 효율이 높아집니다.

const toolNamespaces = {
'file.*': ['file.read', 'file.write', 'file.delete', 'file.list'],
'git.*': ['git.commit', 'git.push', 'git.diff', 'git.log'],
'web.*': ['web.search', 'web.fetch', 'web.screenshot'],
'code.*': ['code.lint', 'code.format', 'code.test', 'code.analyze'],
};
// 네임스페이스 매칭으로 관련 도구 그룹 빠르게 발견
function getNamespaceTools(query: string): string[] {
const matchedNamespaces = Object.entries(toolNamespaces)
.filter(([ns]) => queryMatchesNamespace(query, ns))
.flatMap(([, tools]) => tools);
return matchedNamespaces;
}

로짓 마스킹으로 도구 비활성화

섹션 제목: “로짓 마스킹으로 도구 비활성화”

Manus에서 사용하는 고급 기법입니다. KV-Cache를 보존하기 위해 도구를 컨텍스트에서 제거하는 대신, 디코딩 단계에서 특정 도구 호출 토큰의 로짓을 마스킹합니다.

일반적 비활성화 방법:
시스템 프롬프트에서 도구 제거
→ 캐시 무효화 → 재계산 비용 발생
로짓 마스킹 방법:
시스템 프롬프트는 그대로 유지 (캐시 보존)
→ 디코딩 시 비활성 도구 토큰의 확률을 -∞으로 설정
→ 모델이 해당 도구를 선택할 수 없게 됨
# 개념적 구현 (추론 엔진 레벨)
def apply_tool_mask(logits, disabled_tools, tokenizer):
for tool_name in disabled_tools:
# 도구 호출 시작 토큰 시퀀스 찾기
tool_tokens = tokenizer.encode(f'"{tool_name}"')
for token_id in tool_tokens:
logits[token_id] = float('-inf') # 선택 불가
return logits
상황권장 전략
도구 수 < 20Eager Loading, 단순하게 유지
도구 수 20~100키워드 점수 기반 Lazy Discovery
도구 수 > 100네임스페이스 + 점진적 공개 조합
자체 호스팅 모델로짓 마스킹으로 KV-Cache 보존
API 기반 서비스컨텍스트에서 도구 제거 방식

Lazy Discovery는 필요한 도구만 적시에 로드하여 컨텍스트 예산을 절약합니다. 키워드 점수 기반 검색으로 태스크와 관련된 상위 K개 도구를 선별하고, 점진적 공개로 태스크 단계마다 필요한 도구를 추가합니다. 자체 호스팅 환경에서는 로짓 마스킹으로 KV-Cache를 보존하면서 도구를 비활성화할 수 있습니다.