쿼리 엔진 & LLM API

QueryEngine.submitMessage()부터 while(true) 쿼리 루프, 스트리밍, 재시도, 컨텍스트 관리까지.

01

큰 그림: 4개 레이어

쿼리 엔진은 사용자 메시지를 받아 LLM 응답을 생성하기까지의 모든 과정을 관장합니다. 네 개의 레이어로 구성됩니다:

02

시퀀스 다이어그램

사용자 메시지부터 최종 응답까지의 전체 흐름을 시퀀스 다이어그램으로 추적합니다.

sequenceDiagram participant User as 사용자 participant QE as QueryEngine participant QL as queryLoop participant QM as queryModel participant API as Anthropic API participant TE as Tool Executor participant SH as stopHooks User->>QE: submitMessage(userInput) QE->>QE: 시스템 프롬프트 구축 QE->>QE: 트랜스크립트 디스크 기록 QE->>QL: query() 호출 loop while(true) QL->>QM: queryModel(messages) QM->>API: callModel + withRetry() API-->>QM: SSE 스트림 QM-->>QL: 어시스턴트 메시지 alt 도구 호출 있음 QL->>TE: 도구 실행 TE-->>QL: 도구 결과 else end_turn QL->>SH: handleStopHooks() SH-->>QL: stop / continue end end QL-->>QE: 최종 응답 QE-->>User: 결과 표시
03

QueryEngine — 대화당 하나의 엔진

QueryEngine은 하나의 대화 세션에 대한 모든 상태를 보유합니다. 핵심 상태로 mutableMessages(대화 히스토리), abortController(취소 제어), totalUsage(누적 토큰 사용량)를 관리합니다.

submitMessage()의 흐름:

  1. 시스템 프롬프트 구축 — 정적 섹션 + 동적 섹션 + CLAUDE.md 주입
  2. 사용자 입력 처리 — 검증, 모델 해석, 메시지 변환
  3. API 호출 전 트랜스크립트를 디스크에 기록 — 프로세스가 중간에 종료되더라도 세션 재개가 가능하도록 보장
  4. query() 호출로 실제 API 통신 시작
// QueryEngine — submitMessage 흐름 (간소화)
async submitMessage(userInput: UserInput) {
  const systemPrompt = await buildSystemPrompt()
  this.mutableMessages.push(userMessage)

  // 프로세스 종료 시에도 세션 재개 가능하도록 미리 기록
  await writeTranscript(this.mutableMessages)

  return await this.query(systemPrompt)
}
주의사항

트랜스크립트를 API 호출 에 기록하는 것은 의도적인 설계입니다. 프로세스가 API 응답 대기 중에 종료되더라도 사용자 입력이 보존되어 --resume으로 세션을 이어갈 수 있습니다.

04

queryLoop() 내부 — while(true) 코어

queryLoop()는 쿼리 엔진의 심장부입니다. while(true) 루프로 동작하며, 각 반복의 결과를 State 타입으로 표현합니다.

State는 두 가지 범주로 분류됩니다:

Continue 전환 — 루프가 계속되는 7가지 이유:

Terminal 종료 조건: end_turn, 예산 소진, 치명적 오류, 사용자 중단 등으로 루프가 종료됩니다.

// query.ts — queryLoop 상태 타입
type State = {
  messages: Message[]
  toolUseContext: ToolUseContext
  autoCompactTracking: AutoCompactTrackingState | undefined
  maxOutputTokensRecoveryCount: number
  hasAttemptedReactiveCompact: boolean
  pendingToolUseSummary: Promise<ToolUseSummaryMessage | null> | undefined
  stopHookActive: boolean | undefined
  turnCount: number
  transition: Continue | undefined   // 다시 루프한 이유
}
// queryLoop — State 타입 (간소화)
type State =
  | { kind: 'continue'; transition: { reason: ContinueReason } }
  | { kind: 'terminal'; result: QueryResult }

// reason 필드 = 이전 반복이 종료 대신 계속을 결정한 이유
05

스트리밍 & API 레이어

Anthropic API와의 통신은 SSE(Server-Sent Events) 스트리밍으로 이루어집니다. 스트림 청크가 도착하는 대로 재구성되어 사용자에게 실시간으로 표시됩니다.

주요 메커니즘:

06

컨텍스트 관리 & 자동 압축

대화가 길어지면 컨텍스트 윈도우를 초과할 수 있습니다. 쿼리 엔진은 5단계 파이프라인으로 컨텍스트를 자동 관리합니다:

  1. applyToolResultBudget — 도구 결과의 크기를 예산 내로 잘라냄
  2. snipCompact — 오래된 메시지의 도구 결과를 요약으로 대체
  3. microcompact — 미세 압축: 공백 제거, 반복 패턴 축소
  4. contextCollapse — 컨텍스트 붕괴: 대화의 큰 블록을 요약으로 대체
  5. autoCompact — 자동 압축: LLM을 사용하여 전체 대화를 요약

이 단계들은 점진적으로 적용됩니다. 가벼운 방법부터 시도하고, 그래도 부족하면 더 공격적인 압축을 수행합니다.

Deep Dive

딥 다이브 — withRetry(): 재시도 전략의 모든 것

withRetry()는 API 호출 실패 시 지수 백오프(exponential backoff)를 적용하여 재시도합니다. 단순한 재시도를 넘어 여러 특수 상황을 처리합니다:

  • 529 처리: 과부하 응답(HTTP 529)은 포그라운드 프로세스에서만 재시도합니다. 백그라운드 프로세스(CI, 배치 작업)는 즉시 실패합니다.
  • Opus 폴백: 3회 연속 529 응답 후 더 가벼운 모델로 자동 폴백을 트리거합니다.
  • OAuth 401 갱신: 인증 토큰 만료 시 자동으로 토큰을 갱신하고 재시도합니다.
  • 영구 모드: 최대 30분까지 재시도를 계속합니다(캡 적용).
  • ECONNRESET 처리: 네트워크 연결 리셋 시 별도 로직으로 재시도합니다.
// services/api/withRetry.ts — 백오프 공식
export function getRetryDelay(
  attempt: number,
  retryAfterHeader?: string | null,
  maxDelayMs = 32000,
): number {
  if (retryAfterHeader) {
    const seconds = parseInt(retryAfterHeader, 10)
    if (!isNaN(seconds)) return seconds * 1000
  }
  const baseDelay = Math.min(
    BASE_DELAY_MS * Math.pow(2, attempt - 1),
    maxDelayMs,
  )
  const jitter = Math.random() * 0.25 * baseDelay
  return baseDelay + jitter
}
07

스톱 훅

handleStopHooks()는 모델이 end_turn을 반환했을 때 실행되며, 세 가지 카테고리의 훅을 처리합니다:

Fire-and-Forget 백그라운드 작업: 스톱 훅 처리 후 다음 작업들이 백그라운드에서 실행됩니다:

주의사항

마지막 어시스턴트 메시지가 API 오류인 경우 스톱 훅을 건너뜁니다. 이유: 차단 훅이 재시도를 요청하면 재오류가 발생하고, 다시 스톱 훅이 실행되어 무한 루프에 빠질 수 있기 때문입니다.

08

토큰 예산

SDK 경로에서는 토큰 예산이 설정되어 에이전트의 자동 계속을 관리합니다.

이 메커니즘은 모델이 동일한 패턴을 반복하며 토큰을 낭비하는 것을 방지합니다.

09

핵심 요약

핵심 포인트

  • 쿼리 엔진은 4개 레이어로 구성됩니다: submitMessage()queryLoop()queryModel/callModel → 스톱 훅 & 토큰 예산
  • submitMessage()는 API 호출 전에 트랜스크립트를 디스크에 기록하여 프로세스 종료 시에도 세션 재개가 가능합니다
  • queryLoop()State.transition.reason 필드는 이전 반복이 종료 대신 계속을 결정한 이유를 기록합니다
  • withRetry()는 지수 백오프, 529 포그라운드 전용 재시도, 3회 연속 529 후 Opus 폴백, OAuth 갱신, 30분 캡 영구 모드를 지원합니다
  • 컨텍스트 관리는 5단계 점진적 파이프라인으로 가벼운 압축부터 LLM 기반 자동 압축까지 수행합니다
  • 스톱 훅은 3가지 카테고리(Stop Hooks, TaskCompleted, TeammateIdle) + Fire-and-Forget 백그라운드 작업으로 구성됩니다
  • 마지막 어시스턴트 메시지가 API 오류이면 스톱 훅을 건너뛰어 차단 훅 → 재오류 → 무한 루프를 방지합니다
  • 토큰 예산의 체감 감소 감지는 3회 이상 연속 500토큰 미만 델타 시 조기 중단을 트리거합니다
10

지식 확인

퀴즈 — 5문제

Q1. submitMessage()가 API 호출 전 트랜스크립트를 디스크에 쓰는 주된 이유는?

  • A) 중복 쓰기를 방지하기 위해
  • B) 프로세스 종료 시에도 세션 재개가 가능하도록
  • C) 프롬프트 캐싱을 최적화하기 위해
  • D) REPL에 즉시 표시하기 위해
API 호출 전에 트랜스크립트를 기록하면, 프로세스가 API 응답 대기 중에 종료되더라도 사용자 입력이 보존됩니다. --resume으로 세션을 이어갈 수 있습니다.

Q2. 3회 연속 529 응답 후 withRetry()가 발생시키는 동작은?

  • A) CannotRetryError를 던짐
  • B) APIConnectionError를 던짐
  • C) 더 가벼운 모델로 자동 폴백을 트리거
  • D) 일반 Error를 던짐
3회 연속 HTTP 529(과부하) 응답이 발생하면 withRetry()는 현재 모델 대신 더 가벼운 모델로 폴백을 트리거합니다. 이는 서버 과부하 상황에서 사용자 경험을 유지하기 위한 전략입니다.

Q3. Statetransition.reason 필드가 나타내는 것은?

  • A) 현재 반복이 시작된 이유
  • B) API가 반환한 정지 이유
  • C) 이전 반복이 종료 대신 계속을 결정한 이유
  • D) 예외의 카테고리
transition.reason은 이전 반복이 왜 terminal이 아닌 continue를 선택했는지를 기록합니다. 7가지 Continue 이유(max_output_tokens_escalate, reactive_compact_retry 등) 중 하나가 됩니다.

Q4. checkTokenBudget()이 체감 감소 조기 중단을 트리거하는 조건은?

  • A) 예산 100% 소진
  • B) 3회 이상 연속 후 양쪽 델타가 500토큰 미만
  • C) end_turn 2회 연속
  • D) max_turns 초과
3회 이상 연속으로 입력+출력 양쪽 델타가 500토큰 미만이면, 모델이 의미 있는 진전 없이 토큰을 소비하고 있다고 판단하여 조기 중단을 트리거합니다.

Q5. 마지막 어시스턴트 메시지가 API 오류일 때 스톱 훅을 건너뛰는 이유는?

  • A) 메시지에 접근할 수 없기 때문
  • B) 토큰을 절약하기 위해
  • C) 차단 훅이 재시도를 요청하면 재오류가 발생하여 무한 루프에 빠지는 것을 방지
  • D) REPL 전용 기능이기 때문
API 오류 상태에서 스톱 훅을 실행하면, 차단 훅이 "계속"을 반환할 경우 다시 API를 호출하고, 같은 오류가 발생하고, 다시 스톱 훅이 실행되는 무한 루프에 빠질 수 있습니다.
0 / 5