Add complete Korean-language technical documentation analyzing Claude Code CLI internals, organized in a leveled guide structure (Level 1-3 + Appendix). Level 1 (입문): architecture overview, request lifecycle, key concepts Level 2 (시스템): QueryEngine, Tool system, Command system, Permission system, Agent coordinator, UI/Ink components Level 3 (심화): MCP/LSP integration, IDE bridge, Plugin/Skill system, Context compression, OAuth/Auth, Telemetry Appendix: Korean-English glossary, file map, design patterns Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
19 KiB
QueryEngine: LLM API 엔진 분석
1. 개요
QueryEngine은 Claude Code의 핵심 실행 엔진으로, LLM(Large Language Model, 대형 언어 모델) API 호출, 에이전트 루프(agent loop) 관리, 스트리밍(streaming) 응답 처리를 단일 클래스에서 조율한다. SDK(Software Development Kit) 헤드리스 경로와 REPL(Read-Eval-Print Loop) 인터랙티브 경로 양쪽에서 하나의 대화 세션을 대표하는 객체로 인스턴스화된다.
- 역할: LLM API 호출, 에이전트 루프 관리, 스트리밍 응답 처리의 중앙 엔진
- 위치:
src/QueryEngine.ts(1,295 lines) - 설계 원칙: 대화 하나 당 인스턴스 하나.
submitMessage()호출마다 새 턴(turn)이 시작되며, 메시지 히스토리·파일 캐시·사용량 집계는 턴 간에 유지된다.
의존 관계 다이어그램
graph TD
QE["QueryEngine\n(src/QueryEngine.ts)"]
ASK["ask()\n편의 래퍼"]
QUERY["query()\n(src/query.ts)"]
CLAUDE["services/api/claude.ts\nAnthropics API 클라이언트"]
TOOLS["Tool 시스템\n(src/Tool.ts)"]
COST["cost-tracker.ts\n비용·토큰 집계"]
COMPACT["services/compact/\n컨텍스트 압축"]
FSC["FileStateCache\n파일 상태 캐시"]
SYSPROMPT["utils/queryContext.ts\nfetchSystemPromptParts()"]
TRANSCRIPT["utils/sessionStorage.ts\n트랜스크립트 기록"]
PERM["hooks/useCanUseTool.ts\n권한 관리"]
ASK -->|"new QueryEngine()"| QE
QE -->|"submitMessage() → for await"| QUERY
QUERY -->|"deps.callModel()"| CLAUDE
QUERY -->|"runTools()"| TOOLS
QUERY -->|"autocompact / snip"| COMPACT
QE -->|"fetchSystemPromptParts()"| SYSPROMPT
QE -->|"getTotalCost() / getModelUsage()"| COST
QE -->|"cloneFileStateCache()"| FSC
QE -->|"recordTranscript()"| TRANSCRIPT
QE -->|"wrappedCanUseTool()"| PERM
2. 아키텍처 다이어그램
아래 다이어그램은 하나의 submitMessage() 호출이 완료될 때까지 데이터가 흐르는 경로를 보여준다.
sequenceDiagram
participant Caller as SDK Caller
participant QE as QueryEngine
participant PUI as processUserInput()
participant QF as query() / queryLoop()
participant CM as callModel() (claude.ts)
participant Tools as Tool 실행
participant CT as cost-tracker
Caller->>QE: submitMessage(prompt)
QE->>QE: fetchSystemPromptParts() — 시스템 프롬프트 조립
QE->>PUI: processUserInput(prompt)
PUI-->>QE: messagesFromUserInput, shouldQuery
QE->>QE: recordTranscript() — 사용자 메시지 즉시 저장
QE->>QF: for await query({ messages, systemPrompt, ... })
loop 에이전트 루프 (tool_use → 재질의)
QF->>QF: applyToolResultBudget() — 도구 결과 크기 제한
QF->>QF: autocompact / snip — 필요 시 컨텍스트 압축
QF->>CM: callModel(messages, tools, ...)
CM-->>QF: stream_event / AssistantMessage
QF-->>QE: yield AssistantMessage / StreamEvent
QE->>CT: accumulateUsage()
alt stop_reason == tool_use
QF->>Tools: runTools()
Tools-->>QF: UserMessage (tool_result)
end
end
QF-->>QE: result (Terminal)
QE-->>Caller: yield SDKResultMessage
서브시스템별 역할 요약
| 모듈 | 역할 |
|---|---|
src/QueryEngine.ts |
세션 상태 유지, 턴 오케스트레이션, SDK 메시지 변환 |
src/query.ts |
API 호출 루프, 압축 트리거, 도구 실행 조율 |
src/services/api/claude.ts |
Anthropic 베타 API 클라이언트, 스트리밍 파싱 |
src/cost-tracker.ts |
모델별 토큰·비용 누적 집계 |
src/services/compact/ |
자동 압축, 스닙(snip), 반응형 압축 |
src/utils/fileStateCache.ts |
파일 읽기 캐시, 언두(undo) 스냅샷 |
src/utils/queryContext.ts |
시스템 프롬프트 구성 요소 조회 |
3. 핵심 타입/인터페이스
QueryEngineConfig
QueryEngine 생성자에 전달되는 구성 객체. 대화 생명주기 전체에서 불변으로 유지되는 값과 변경 가능한 콜백의 두 범주로 나뉜다.
export type QueryEngineConfig = {
// -- 실행 환경
cwd: string // 현재 작업 디렉터리
tools: Tools // 사용 가능한 도구 목록
commands: Command[] // 슬래시 커맨드 목록
mcpClients: MCPServerConnection[] // MCP(Model Context Protocol) 클라이언트
agents: AgentDefinition[] // 에이전트 정의 목록
// -- 권한·상태 콜백
canUseTool: CanUseToolFn // 도구 사용 가능 여부 판단 함수
getAppState: () => AppState // 현재 앱 상태 읽기
setAppState: (f: ...) => void // 앱 상태 업데이트
// -- 초기 상태
initialMessages?: Message[] // 재개 시 이전 메시지 히스토리
readFileCache: FileStateCache // 파일 읽기 캐시 (언두용)
// -- 시스템 프롬프트 제어
customSystemPrompt?: string // 기본 프롬프트 전체 교체
appendSystemPrompt?: string // 기본 프롬프트에 추가
// -- 모델 선택
userSpecifiedModel?: string // 사용자 지정 모델 (예: claude-opus-4)
fallbackModel?: string // API 오류 시 폴백 모델
// -- 실행 제한
thinkingConfig?: ThinkingConfig // extended thinking 설정
maxTurns?: number // 최대 에이전트 루프 턴 수
maxBudgetUsd?: number // USD 비용 상한선
taskBudget?: { total: number } // 태스크 토큰 예산 (beta API)
// -- 출력 형식
jsonSchema?: Record<string, unknown> // 구조화 출력 스키마
verbose?: boolean // 상세 로그 활성화
// -- SDK 전용 옵션
replayUserMessages?: boolean // 사용자 메시지 재전송 여부
includePartialMessages?: boolean // 스트림 이벤트 포함 여부
handleElicitation?: ... // MCP URL elicitation 핸들러
setSDKStatus?: (s: SDKStatus) => void // SDK 상태 콜백
abortController?: AbortController // 외부에서 주입 가능한 취소 컨트롤러
orphanedPermission?: OrphanedPermission // 고아(orphaned) 권한 처리
snipReplay?: (msg, store) => ... // HISTORY_SNIP 기능 콜백 (주입식)
}
주요 설계 결정: customSystemPrompt가 존재하면 기본 시스템 프롬프트를 완전히 대체하고, appendSystemPrompt는 기본 프롬프트 뒤에 추가된다. SDK 호출자가 커스텀 프롬프트와 CLAUDE_COWORK_MEMORY_PATH_OVERRIDE를 함께 설정했을 때는 메모리 메카닉스 프롬프트가 자동 주입된다.
QueryEngine 클래스 내부 상태
class QueryEngine {
private config: QueryEngineConfig
private mutableMessages: Message[] // 턴 간 누적 메시지 히스토리
private abortController: AbortController // 인터럽트 신호
private permissionDenials: SDKPermissionDenial[] // 권한 거부 추적
private totalUsage: NonNullableUsage // 누적 토큰 사용량
private hasHandledOrphanedPermission: boolean
private discoveredSkillNames: Set<string> // 턴-스코프 스킬 발견 추적
private loadedNestedMemoryPaths: Set<string>
private readFileState: FileStateCache // 언두 가능한 파일 상태
}
4. 실행 흐름
4.1 설정 조립 (Config Assembly)
submitMessage() 진입 시 config에서 구조 분해로 파라미터를 추출한다. discoveredSkillNames는 각 턴 시작 시 초기화되어 탐색된 스킬이 다음 턴으로 누적되지 않도록 한다. setCwd(cwd)를 호출해 작업 디렉터리를 전역 셸 상태와 동기화한다.
4.2 시스템 프롬프트 구성 (fetchSystemPromptParts)
const {
defaultSystemPrompt,
userContext: baseUserContext,
systemContext,
} = await fetchSystemPromptParts({
tools,
mainLoopModel: initialMainLoopModel,
additionalWorkingDirectories,
mcpClients,
customSystemPrompt: customPrompt,
})
const systemPrompt = asSystemPrompt([
...(customPrompt !== undefined ? [customPrompt] : defaultSystemPrompt),
...(memoryMechanicsPrompt ? [memoryMechanicsPrompt] : []),
...(appendSystemPrompt ? [appendSystemPrompt] : []),
])
fetchSystemPromptParts()는 코디네이터 모드(COORDINATOR_MODE) 기능 플래그가 활성화된 경우 getCoordinatorUserContext()를 통해 userContext를 보강한다. 이 함수는 dead-code elimination(데드코드 제거)을 위해 feature() 조건부 require()로 지연 임포트된다.
4.3 메시지 정규화
processUserInput()이 원시 프롬프트(문자열 또는 ContentBlockParam[])를 처리해 내부 Message 타입으로 변환한다. 슬래시 커맨드가 포함된 경우 이 단계에서 실행된다. 처리 후 messagesFromUserInput이 this.mutableMessages에 추가된다.
처리 결과인 shouldQuery가 false이면(로컬 커맨드만 있는 경우) API 호출 없이 즉시 결과를 반환한다.
4.4 사전 트랜스크립트 기록
// API 응답 전에 사용자 메시지를 즉시 저장
if (persistSession && messagesFromUserInput.length > 0) {
const transcriptPromise = recordTranscript(messages)
if (isBareMode()) {
void transcriptPromise // 베어 모드에서는 비동기로 처리
} else {
await transcriptPromise // 그 외에는 동기 대기
}
}
프로세스가 API 응답 전에 종료되더라도 --resume이 가능하도록 사용자 메시지를 먼저 기록한다. 베어 모드(bare mode, --bare 플래그)에서는 약 4~30ms의 지연을 피하기 위해 fire-and-forget으로 처리한다.
4.5 API 호출 (query() 함수)
for await (const message of query({
messages,
systemPrompt,
userContext,
systemContext,
canUseTool: wrappedCanUseTool,
toolUseContext: processUserInputContext,
fallbackModel,
querySource: 'sdk',
maxTurns,
taskBudget,
})) { ... }
query()는 src/query.ts에 정의된 AsyncGenerator 함수로, 내부 queryLoop()를 호출한다. 루프 내에서 deps.callModel()(실제로는 claude.ts의 Anthropic 베타 API 클라이언트)을 통해 스트리밍 응답을 수신한다.
4.6 스트리밍 응답 처리
query()가 yield하는 메시지 타입별 처리:
| 메시지 타입 | 처리 방식 |
|---|---|
assistant |
mutableMessages에 추가, normalizeMessage() 후 yield |
user |
mutableMessages에 추가, turnCount 증가, yield |
stream_event |
message_start/message_delta/message_stop 이벤트로 토큰 누적 |
progress |
mutableMessages와 트랜스크립트에 인라인 기록 |
attachment |
구조화 출력, max_turns_reached, queued_command 처리 |
system |
압축 경계(compact_boundary), API 오류 재시도, snip 경계 처리 |
tool_use_summary |
SDK로 도구 사용 요약 전달 |
tombstone |
고아 메시지 제거 신호 (스킵) |
4.7 도구 사용 감지 및 실행
query.ts의 queryLoop() 내에서 StreamingToolExecutor 또는 runTools()를 통해 도구를 실행한다. 도구 결과는 UserMessage(tool_result)로 변환되어 다음 API 호출 메시지에 포함된다.
wrappedCanUseTool은 canUseTool을 래핑하여 권한이 거부된 경우 this.permissionDenials에 기록한다. 이 정보는 최종 SDKResultMessage의 permission_denials 필드로 노출된다.
4.8 재질의 루프 (에이전트 루프)
queryLoop()는 needsFollowUp(tool_use 블록 존재 여부)이 true인 한 반복한다. 각 반복에서:
applyToolResultBudget()— 도구 결과 크기를 예산 내로 제한snipModule.snipCompactIfNeeded()— HISTORY_SNIP 플래그 활성 시 스닙 적용- 마이크로컴팩트(microcompact) 적용
autocompact판정 및 실행callModel()호출 → 스트리밍 수신- 도구 사용 블록 감지 → 도구 실행 → tool_result 생성
maxTurns초과 시max_turns_reachedattachment yield 후 반환
4.9 비용 추적 및 토큰 집계
// stream_event 핸들러 내
if (message.event.type === 'message_start') {
currentMessageUsage = updateUsage(EMPTY_USAGE, message.event.message.usage)
}
if (message.event.type === 'message_delta') {
currentMessageUsage = updateUsage(currentMessageUsage, message.event.usage)
}
if (message.event.type === 'message_stop') {
this.totalUsage = accumulateUsage(this.totalUsage, currentMessageUsage)
}
cost-tracker.ts의 addToTotalSessionCost()는 모델별로 토큰(입력/출력/캐시 읽기/캐시 생성)과 USD 비용을 누적 집계한다. 어드바이저(advisor) 모델이 있는 경우 재귀 호출로 어드바이저 사용량도 합산한다. 세션 비용은 saveCurrentSessionCosts()를 통해 프로젝트 설정에 저장되며 재개 시 restoreCostStateForSession()으로 복원된다.
4.10 파일 히스토리 스냅샷
if (fileHistoryEnabled() && persistSession) {
messagesFromUserInput
.filter(messageSelector().selectableUserMessagesFilter)
.forEach(message => {
void fileHistoryMakeSnapshot(...)
})
}
사용자 메시지마다 파일 히스토리 스냅샷을 생성해 언두(undo) 기능을 지원한다. readFileState (FileStateCache)는 엔진 인스턴스 수명 동안 유지되며, ask() 래퍼가 완료 후 setReadFileCache(engine.getReadFileState())를 호출해 상위 컨텍스트로 전파한다.
4.11 압축/컴팩션 트리거
query.ts의 queryLoop() 내에서 매 API 호출 전에 압축 조건을 확인한다:
- 자동 압축 (autocompact): 토큰이 임계값을 초과하면
deps.autocompact()를 호출해 대화를 요약본으로 압축 - 스닙 (HISTORY_SNIP): 특정 메시지 패턴을 감지해 히스토리 일부를 잘라냄
- 반응형 압축 (REACTIVE_COMPACT): API에서
prompt_too_long오류 수신 시 사후 압축 시도 - 컨텍스트 콜랩스 (CONTEXT_COLLAPSE): 컨텍스트 창 초과 전에 단계적 축소
압축 완료 후 compact_boundary 시스템 메시지가 yield되고, QueryEngine은 이를 받아 mutableMessages에서 압축 전 메시지를 GC(garbage collection, 가비지 컬렉션) 해제한다.
5. 주요 설계 결정
5.1 메모이즈드 컨텍스트 (getSystemContext, getUserContext)
fetchSystemPromptParts()는 getSystemContext()와 getUserContext()를 내부적으로 사용한다. 이 함수들은 비용이 높은 파일 시스템 접근(CLAUDE.md, 플러그인, 메모리 디렉터리 등)을 한 번만 수행하고 결과를 메모이즈(memoize)한다. submitMessage() 내에서 매 턴마다 호출하더라도 성능에 영향을 주지 않는다.
5.2 Dead Code Elimination (bun:bundle 기능 플래그)
// 예시: COORDINATOR_MODE 플래그
const getCoordinatorUserContext = feature('COORDINATOR_MODE')
? require('./coordinator/coordinatorMode.js').getCoordinatorUserContext
: () => ({})
feature() 함수는 bun:bundle의 트리 쉐이킹(tree-shaking) 경계 역할을 한다. 플래그가 비활성화된 빌드에서는 해당 모듈 전체가 번들에서 제외된다. QueryEngine.ts와 query.ts 모두 이 패턴을 사용해 HISTORY_SNIP, COORDINATOR_MODE, REACTIVE_COMPACT, CONTEXT_COLLAPSE 등의 실험적 기능을 격리한다.
5.3 순환 의존성 방지를 위한 지연 임포트 (Lazy Imports)
// React/ink를 풀지 않기 위해 지연 임포트
const messageSelector =
(): typeof import('src/components/MessageSelector.js') =>
require('src/components/MessageSelector.js')
MessageSelector.tsx는 React/ink를 의존하므로 최상위 임포트 시 테스트 셔드(shard)의 모듈 초기화 순서를 깨뜨릴 수 있다. require() 지연 임포트로 실제 사용 시점까지 로딩을 미룬다. snipReplay 콜백을 설정으로 주입하는 것도 같은 이유다: 기능 플래그 문자열이 QueryEngine.ts에 직접 포함되면 제외 문자열 검사를 통과하지 못한다.
5.4 FileStateCache와 언두 기능
FileStateCache는 파일 경로를 키로 파일 내용의 과거 상태를 저장한다. ask() 편의 래퍼는 엔진 생성 시 cloneFileStateCache(getReadFileCache())로 독립적인 캐시 복사본을 만들고, 완료 후 setReadFileCache(engine.getReadFileState())로 변경 사항을 상위에 전파한다. 이 구조가 도구 실행이 변경한 파일을 다음 턴에서 정확히 읽을 수 있게 하고, 언두 기능의 기반이 된다.
5.5 도구 결과 예산 및 콘텐츠 교체
applyToolResultBudget()은 누적된 도구 결과의 총 크기가 한 메시지당 예산을 초과하면 오래된 결과를 플레이스홀더로 교체한다. 교체 기록은 recordContentReplacement()를 통해 세션 스토리지에 저장되어 재개 시 복원이 가능하다. 마이크로컴팩트(cached microcompact)는 tool_use_id 기준으로만 동작하므로 콘텐츠 교체와 독립적으로 합성된다.
5.6 고아 권한 처리 (Orphaned Permission)
SDK 호출에서 이전 세션의 미결 권한 요청이 있을 경우, submitMessage() 첫 호출 시 단 한 번 handleOrphanedPermission()이 실행된다. hasHandledOrphanedPermission 플래그로 중복 처리를 방지한다.
5.7 베어 모드와 트랜스크립트 전략
isBareMode()가 true이면 사용자 메시지 트랜스크립트 기록을 fire-and-forget으로 처리한다. 스크립트 호출은 --resume 후 재개가 필요 없으므로 약 4~30ms의 디스크 I/O 지연을 절약한다. 비대화형(non-interactive)이지만 코워크(cowork) 환경에서는 CLAUDE_CODE_EAGER_FLUSH 또는 CLAUDE_CODE_IS_COWORK 환경 변수로 flushSessionStorage()를 강제로 동기 호출한다.
6. ask() 편의 래퍼
export async function* ask({ prompt, cwd, tools, ... }) {
const engine = new QueryEngine({ ... })
try {
yield* engine.submitMessage(prompt, { uuid: promptUuid, isMeta })
} finally {
setReadFileCache(engine.getReadFileState())
}
}
ask()는 단발성(one-shot) 사용을 위한 편의 래퍼다. 내부적으로 QueryEngine을 생성하고 submitMessage()를 위임한다. finally 블록에서 파일 상태 캐시를 항상 상위로 전파한다. HISTORY_SNIP 기능이 활성화된 경우 snipReplay 콜백을 주입해 기능 플래그 문자열이 QueryEngine.ts에 포함되지 않도록 한다.
7. 결과 유형 (SDKResultMessage 서브타입)
| 서브타입 | 원인 |
|---|---|
success |
정상 완료 (end_turn 또는 tool_result 후 응답) |
error_max_turns |
maxTurns 초과 |
error_max_budget_usd |
maxBudgetUsd 초과 |
error_max_structured_output_retries |
구조화 출력 재시도 한도 초과 |
error_during_execution |
비정상 종료 (API 오류, 예기치 않은 stop_reason 등) |
error_during_execution에는 진단 접두사([ede_diagnostic])와 해당 턴 내에서 발생한 인메모리(in-memory) 에러 로그가 errors[]에 포함된다. 워터마크(watermark) 기반으로 이전 턴의 에러가 포함되지 않도록 한다.
8. 관련 문서
- 상위 개요: 요청 생명주기 —
ask()가 어떻게 호출되는지 - 하위 상세: Tool 시스템 — 도구 실행 메커니즘
- 하위 상세: 압축 시스템 —
services/compact/세부 분석