시리즈 이전 편: [Part 4] 프롬프트 캐싱 분석
목차
- 같은 Claude인데, 왜 매번 다르게 대답할까
- buildEffectiveSystemPrompt() — 우선순위 체계
- 동적 섹션들 — 매 세션마다 달라지는 프롬프트 조각
- 환경 정보 — computeSimpleEnvInfo()
- 언어 설정 — getLanguageSection()
- 출력 스타일 — getOutputStyleSection()
- MCP 지시 — getMcpInstructionsSection()
- 메모리 통합 — loadMemoryPrompt()
- 피처 플래그 — 프롬프트 분기의 핵심
- 그래서 뭐가 달라지나
같은 Claude인데, 왜 매번 다르게 대답할까
같은 Claude Code를 쓰는데 누구는 한국어로 답변을 받고, 누구는 MCP 서버 안내가 붙고, 누구는 프로액티브 모드로 자율 에이전트처럼 동작합니다. 모델이 달라서가 아닙니다. 시스템 프롬프트를 매 세션마다 "조립"하기 때문입니다.
이번 편에서는 Claude Code가 프롬프트를 동적으로 구성하는 내부 구조를 소스 레벨에서 뜯어봅니다.
buildEffectiveSystemPrompt() — 우선순위 체계
프롬프트가 여러 출처에서 올 수 있으니, 누가 이기느냐를 정해야 합니다. src/utils/systemPrompt.ts에 정의된 buildEffectiveSystemPrompt()가 그 심판입니다.
// src/utils/systemPrompt.ts
export function buildEffectiveSystemPrompt({
mainThreadAgentDefinition,
toolUseContext,
customSystemPrompt,
defaultSystemPrompt,
appendSystemPrompt,
overrideSystemPrompt,
}: { ... }): SystemPrompt {
if (overrideSystemPrompt) {
return asSystemPrompt([overrideSystemPrompt])
}
// ... coordinator, agent, custom, default 순으로 폴백
}
우선순위는 명확합니다. Override > Coordinator > Agent > Custom > Default 순서이고, appendSystemPrompt는 override를 제외한 모든 경우에 끝에 붙습니다. override가 설정되면 다른 건 전부 무시됩니다. loop 모드 같은 특수 상황에서 프롬프트를 완전히 교체할 때 쓰입니다.
Coordinator 모드가 활성화되면 getCoordinatorSystemPrompt()가 default를 대체합니다. Agent가 지정되면 Agent 프롬프트가 default를 대체하는데, 프로액티브 모드에서는 예외적으로 default에 "추가"됩니다.
동적 섹션들 — 매 세션마다 달라지는 프롬프트 조각
src/constants/prompts.ts에서 실제 default 프롬프트를 조립할 때, 정적 영역과 동적 영역이 SYSTEM_PROMPT_DYNAMIC_BOUNDARY로 구분됩니다.
// src/constants/prompts.ts (560행 부근)
return [
// --- Static content (cacheable) ---
getSimpleIntroSection(outputStyleConfig),
getSimpleSystemSection(),
// ... 생략 ...
// === BOUNDARY MARKER ===
...(shouldUseGlobalCacheScope()
? [SYSTEM_PROMPT_DYNAMIC_BOUNDARY] : []),
// --- Dynamic content (registry-managed) ---
...resolvedDynamicSections,
].filter(s => s !== null)
경계선 위쪽은 프롬프트 캐싱 대상이 되는 정적 콘텐츠, 아래쪽은 매 세션 조건에 따라 달라지는 동적 콘텐츠입니다. Part 4에서 다뤘던 캐싱이 여기서 연결됩니다.
환경 정보 — computeSimpleEnvInfo()
운영체제, 셸, git 여부, 모델명, 지식 컷오프까지 런타임에 수집합니다.
// src/constants/prompts.ts (651행)
export async function computeSimpleEnvInfo(
modelId: string,
additionalWorkingDirectories?: string[],
): Promise<string> {
const [isGit, unameSR] = await Promise.all([
getIsGit(), getUnameSR(),
])
// 모델명, 플랫폼, 셸, OS 버전 등을 조합
}
Windows에서 쓰면 Platform: win32가 들어가고, Mac에서 쓰면 darwin이 들어갑니다. Claude가 OS별로 다른 명령어를 제안하는 이유가 여기 있습니다.
언어 설정 — getLanguageSection()
function getLanguageSection(
languagePreference: string | undefined,
): string | null {
if (!languagePreference) return null
return `# Language
Always respond in ${languagePreference}.`
}
설정에서 language를 지정하면 해당 언어로 응답하라는 지시가 프롬프트에 주입됩니다. 기술 용어는 원문 그대로 유지하라는 조건도 함께 들어갑니다.
출력 스타일 — getOutputStyleSection()
function getOutputStyleSection(
outputStyleConfig: OutputStyleConfig | null,
): string | null {
if (outputStyleConfig === null) return null
return `# Output Style: ${outputStyleConfig.name}
${outputStyleConfig.prompt}`
}
사용자가 커스텀 출력 스타일을 설정하면 프롬프트에 # Output Style 섹션이 추가됩니다. 설정하지 않으면 null을 반환해서 프롬프트에 아예 포함되지 않습니다.
MCP 지시 — getMcpInstructionsSection()
MCP 서버가 연결되어 있으면 해당 서버의 사용법이 프롬프트에 주입됩니다. 서버가 없으면 null입니다. 흥미로운 점은 이 섹션이 DANGEROUS_uncachedSystemPromptSection으로 등록된다는 것입니다. MCP 서버는 턴 사이에 연결/해제될 수 있어서 캐싱하면 안 되기 때문입니다.
메모리 통합 — loadMemoryPrompt()
src/memdir/memdir.ts의 loadMemoryPrompt()는 MEMORY.md 파일을 읽어 프롬프트에 주입합니다.
// src/memdir/memdir.ts (419행)
export async function loadMemoryPrompt():
Promise<string | null> {
const autoEnabled = isAutoMemoryEnabled()
if (feature('KAIROS') && autoEnabled
&& getKairosActive()) {
return buildAssistantDailyLogPrompt(skipIndex)
}
// TEAMMEM -> 팀 메모리 결합
// autoEnabled -> 개인 메모리 로드
}
KAIROS 모드에서는 일일 로그 형식으로, TEAMMEM 피처가 켜져 있으면 팀 메모리와 개인 메모리를 결합해서 반환합니다. MEMORY.md는 200줄, 25KB 제한이 있고(MAX_ENTRYPOINT_LINES = 200, MAX_ENTRYPOINT_BYTES = 25_000), 초과하면 경고와 함께 잘립니다.
피처 플래그 — 프롬프트 분기의 핵심
소스 전반에 걸쳐 feature('PROACTIVE'), feature('KAIROS'), feature('TEAMMEM') 같은 빌드타임 피처 플래그가 프롬프트 구성을 분기합니다.
// src/utils/systemPrompt.ts (18행)
const proactiveModule =
feature('PROACTIVE') || feature('KAIROS')
? require('../proactive/index.js')
: null
feature() 호출은 Bun 번들러의 dead code elimination과 연동됩니다. 피처가 꺼진 빌드에서는 관련 모듈이 아예 번들에 포함되지 않습니다. PROACTIVE 모드가 활성화되면 프롬프트 첫 줄부터 완전히 달라집니다. "소프트웨어 엔지니어링 작업을 돕는다"가 "자율 에이전트로서 유용한 작업을 한다"로 바뀝니다.
그래서 뭐가 달라지나
Claude Code의 프롬프트는 정적 템플릿이 아닙니다. 매 세션마다 다음 조건들이 조합됩니다.
- 실행 환경: OS, 셸, git 여부, 모델 ID
- 사용자 설정: 언어, 출력 스타일, 커스텀 프롬프트
- 연결 상태: MCP 서버 목록, 활성화된 도구 셋
- 메모리: MEMORY.md 내용, 팀 메모리 여부
- 피처 플래그: PROACTIVE, KAIROS, TEAMMEM, COORDINATOR_MODE
- 모드: 에이전트 정의, loop 모드, 코워크 모드
이 조건들의 조합이 매번 다른 시스템 프롬프트를 만들어내고, 같은 질문에도 다른 행동을 이끌어냅니다. 정적인 부분은 캐싱하고 동적인 부분만 매번 재계산하는 경계선(SYSTEM_PROMPT_DYNAMIC_BOUNDARY)을 두어 성능과 유연성을 동시에 잡았습니다.
결국 Claude Code가 "똑똑하게" 느껴지는 이유의 상당 부분은, 모델 자체의 능력이 아니라 컨텍스트에 맞게 프롬프트를 정밀하게 조립하는 이 엔지니어링에 있습니다.
다음 편 예고: [Claude Code 해부학 Part 6] 클로드 코드(Claude Code) 도구 시스템 분석 — AI에게 손발을 달아주는 40개 Tool
'AI 코딩 에이전트' 카테고리의 다른 글
| Claude Code QueryEngine 분석: AI와 대화하는 코드의 정체 (해부학 Part 8) (0) | 2026.04.01 |
|---|---|
| Claude Code 도구 시스템 분석: AI에게 손발을 달아주는 40개 Tool (해부학 Part 6) (0) | 2026.04.01 |
| Claude Code 프롬프트 캐싱 분석: API 비용을 90% 줄이는 구조 (해부학 Part 4) (0) | 2026.04.01 |
| Claude Code 시스템 프롬프트 분석: 915줄은 어떻게 조립되는가 (해부학 Part 3) (0) | 2026.04.01 |
| Claude Code 부팅 과정 분석: 실행 후 0.5초 안에 벌어지는 일들 (해부학 Part 2) (0) | 2026.04.01 |
