[클로드 코드 소스 읽기] 클로드 코드의 기본 동작 루프를 알아보기 .
클로드 코드가 자신의 동작을 어떻게 계획하고 수행하는가에 대한 소스 분석
클로드 코드의 기본 동작 루프를 알아보기
소개
Claude Code는 CLI 입력창 한 개로 자연어 명령을 받는다.
"안녕"이라는 인사는 모델 호출 한 번으로 끝나고,
"xx 모듈을 분석해줘" 같은 분석 요청은 파일 읽기·검색이 여러 번 일어난 뒤 마지막 호출에서 요약 텍스트가 돌아온다.
같은 진입점에서 클로드 코드가 다음 행위를 어떻게 선택하는지, src/query.ts를 거시적으로 분석한다.
자료가 유출 코드 기반이라, 코드 원본을 그대로 옮기기보다 의미와 예시 위주로 설명한다.
요약
cli 를 통해 들어온 자연어 요청은 query.ts 파일의 로직에 의해 처리된다.
로직의 요약은 다음과 같다.
-
요청은 단일 루프에서 처리된다.
루프 1회 반복에서 모델 호출 1회 수행하고, 그 결과에 따라 루프를 지속할지 결정한다. -
한 요청의 루프 안에서 늘어나는 컨텍스트는 별도로 압축하지 않는다.
캐시 prefix를 깨지 않기 위함인 것으로 보인다 . -
System prompt가 크다. 기본 900줄 base에 동적으로 섹션이 추가되면 더 커진다.
모델의 성능자체에 크게 기대고 있는 구조이다
- 시스템 프롬프트 900줄 이상
- 한 작업에서 누적되는 이전 turn 결과 (도구 호출이력과 결과 등)을 모두 user prompt 에 누적해서 포함 .
1. 단일 루프 구조
queryLoop의 본문은 단 하나의 while (true) 블록이다.
계획/실행과 관계없는 검증·회복(컴팩션, 모델 fallback, max-token 회복, stop-hook blocking, token budget, abort, max_turns)을 모두 빼고 나면 이 정도만 남는다.
while (true) {
for await (msg of callModel({ messages, systemPrompt, tools })) {
if (msg.type === 'assistant') {
assistantMessages.push(msg)
msg.message.content
.filter(c => c.type === 'tool_use')
.forEach(b => toolUseBlocks.push(b))
}
}
if (toolUseBlocks.length === 0) {
return { reason: 'completed' }
}
for await (update of runTools(toolUseBlocks, ...)) {
if (update.message) toolResults.push(update.message)
}
messages = [...messagesForQuery, ...assistantMessages, ...toolResults]
}
한 바퀴가 모델 호출 한 번에 대응한다.
assistant 메시지 안의 tool_use 블록이 0개면 종료, 1개 이상이면 runTools로 다음 루프를 실행한다.
2. 안녕 / 분석 시뮬레이션
루프 내 llm 호출에 따라 다음 동작이 분기된다.
2-1. "안녕"
iteration 1회. 모델이 응답에 tool_use를 안 담는다. runTools 호출은 일어나지 않는다.
[iter 1]
callModel(messages=[user("안녕")])
→ assistant: { content: [{ type: 'text', text: '안녕하세요...' }] }
toolUseBlocks = [] → return completed
sequenceDiagram
participant U as User
participant L as queryLoop
participant M as Model
U->>L: "안녕"
L->>M: callModel(messages, systemPrompt, tools)
M-->>L: assistant { text: "안녕하세요..." }
Note over L: tool_use=0 → return completed
2-2. "xx 모듈을 분석해줘"
iteration N회. 모델이 매 응답마다 tool_use 블록을 담고, 호스트가 결과를 messages에 누적해 다시 부른다. 마지막 호출에서 모델이 tool_use를 비우면 종료.
[iter 1]
callModel
→ assistant: text + tool_use(Bash "ls -R xx") + tool_use(Read "xx/README.md")
runTools → tool_result(파일 목록), tool_result(README 내용)
[iter 2]
callModel(messages = iter1 + tool_results)
→ assistant: text + tool_use(Read "xx/src/index.ts")
runTools → tool_result(파일 내용)
[iter 3..N]
같은 패턴으로 Read / Grep 반복
[iter last]
callModel
→ assistant: text("분석 요약: ...") # tool_use 없음
toolUseBlocks = [] → return completed
sequenceDiagram
participant U as User
participant L as queryLoop
participant M as Model
participant T as runTools
U->>L: "xx 모듈을 분석해줘"
loop 모델 응답에 tool_use가 들어 있는 동안
L->>M: callModel(messages, systemPrompt, tools)
M-->>L: assistant: text + tool_use[]
Note over L: needsFollowUp = true
L->>T: runTools(tool_use[])
T-->>L: tool_results
Note over L: messages += assistant + tool_results
end
L->>M: callModel(messages, systemPrompt, tools)
M-->>L: assistant: text only (tool_use 없음)
Note over L: !needsFollowUp → return completed
L-->>U: 종료
iteration마다 같은 형태의 callModel을 호출한다. user_prompt dp 각 turn의 작업 내용을 user prompt 에 append 하면서 전송한다.
이 결과 sys prompt+ user prompt를 재차 보낼때, 항상 캐싱의 이득을 볼 수 있다.
이 부분의 설명은 아래에 4. LLM 호출과 캐싱 에 추가 설명이 있다.
모델에서의 응답이 더이상의 tool 호출을 요구하지 않을 경우, 루프를 종료한다 .
도구 결과를 레퍼런스로 저장하는 경우
도구 호출 한 번이 user 메시지 한 개로 messages에 누적되며, iteration이 N번 돌면 그만큼 컨텍스트가 같이 자란다. 결과 크기가 임계(Grep 20K, default 50K 등)를 넘으면 본문은 디스크에 저장되고 메시지엔 프리뷰와 파일 경로만 남는데, 자동으로 매번 가져오지는 않고 모델이 필요할 때 다시 호출하는 식으로 재탐색이 일어난다.
3. 모델이 보는 도구 카탈로그
매 callModel 호출에 동일한 tools 배열이 전달된다. 모델이 보는 건 도구의 name, description, inputSchema 세 가지다.
| 이름 | 목적 |
|---|---|
Bash | 셸 명령 실행 |
Glob | 파일 이름·패턴 검색 |
Grep | 파일 내용 검색 (ripgrep 기반) |
Read | 파일 읽기 (텍스트/이미지/PDF/노트북) |
Edit | 파일 내 정확한 문자열 치환 |
Write | 파일 생성/덮어쓰기 |
LSP | 코드 인텔리전스 (정의/참조/심볼/hover) |
Agent | 서브에이전트로 위임 |
WebFetch | URL 가져와 보조 모델로 처리 |
WebSearch | 웹 검색 |
TodoWrite | 진행 중 작업 목록 관리 |
도구 선택의 가이드는 호스트 코드가 아니라 각 도구의 description 안에 박혀 있다.
- (Grep) "검색 작업에는 항상 Grep을 사용하라. grep이나 rg를 Bash 명령으로 직접 호출하지 마라."
- (Glob) "여러 라운드의 globbing과 grepping이 필요한 개방형 검색일 때는 Agent 도구를 대신 사용하라."
- (Read) "이 도구는 파일만 읽을 수 있고 디렉터리는 읽을 수 없다. 디렉터리는 Bash 도구의 ls 명령을 사용하라."
- (Edit) "편집 전에는 대화 중 Read 도구를 최소 한 번 사용하라."
- (WebFetch) "GitHub URL은 Bash의 gh CLI를 우선 사용하라."
4. LLM 호출과 캐싱
LLM 호출은 크게 두 단계로 나뉜다.
- 입력 토큰 시퀀스를 전처리하는 단계
- (1) 결과를 바탕으로 토큰을 하나씩 생성하는 단계
자연어 입력을 전처리하는 과정은 입력과 결과가 시퀀스 관계이기 때문에 prefix(앞부분 일치)에 대한 캐싱이 가능하다.
시작부터 일치하는 부분이 있으면 처음부터 다시 계산하지 않고 캐시된 결과를 가져오며, 새로 추가된 부분만 그 뒤에 append하여 처리한다.
참고: 위는 간추린 설명이고, 실제 메커니즘은 NVIDIA 기술 블로그가 깊이 있게 다룬다.
- 메커니즘 (어려운 쪽): Mastering LLM Techniques: Inference Optimization (NVIDIA)
- 사용 가이드 (쉬운 쪽): Prompt caching (OpenAI)
cc의 루프 구조가 이와 맞물린다. 매 iteration 호출은 sys_prompt + tools + 이전 messages + 새 추가의 형태인데, 이전 user message 부분을 유지한 채로 새롭게 호출된 tool 호출과 결과를 append하는 식으로 진행한다. 그래서 새롭게 추가되는 부분 이외에는 토큰 시퀀스를 처리하는 단계에서 캐시 히트가 된다. 단, 이 절약은 비용·시간 관점에 한정된다. 모델이 보는 컨텍스트 길이 자체는 그대로 자라므로, "작업 스코프를 잘게 나누는 것이 유효" 추측은 비용 절약과 별개로 여전히 유효한 이유가 된다.
다음 글 예고
- 단일 루프의 시스템 프롬프트 분석
- 단일 작업에서의 컨텍스트 크기 관리