LLM 호출을 안정적으로 만들기: Retry, Semaphore, Cache, 그리고 Batch
요약
LLM API 호출 시 발생할 수 있는 오류와 속도 제한 문제를 해결하기 위한 신뢰성 스택 구축 방법을 소개합니다. 지수 백오프를 적용한 재시도 메커니즘과 동시성 제어를 위한 세마포어 활용법을 다룹니다.
핵심 포인트
- 지수 백오프를 적용한 재시도로 일시적인 API 오류 대응
- 세마포어를 통한 동시 호출 수 제한으로 Rate Limit 방지
- 계층별 신뢰성 스택 구축을 통한 안정적인 LLM 통합
TestSmith가 --llm 옵션으로 테스트를 생성할 때, 처리되는 모든 소스 파일의 모든 공개 멤버(public member)에 대해 LLM을 호출합니다. 20개의 파일이 있고 각 파일에 5개의 공개 함수가 있다면, 한 번의 실행에 최대 100번의 API 호출이 발생한다는 의미입니다. 이는 문제가 발생할 수 있는 접점이 매우 넓다는 것을 뜻합니다. 우리가 계층별로 구축한 신뢰성 스택(reliability stack)을 소개합니다.
계층 1: 지수 백오프(Exponential Backoff)를 적용한 재시도(Retry)
LLM API는 일시적으로 실패할 수 있습니다. 속도 제한(Rate limits), 타임아웃(timeouts), 간헐적인 5xx 응답 등은 기다렸다가 재시도하면 모두 복구 가능합니다. 우리는 Provider를 감싸는 재시도 미들웨어(retry middleware)를 구축했습니다:
type RetryProvider struct {
inner Provider
maxRetries int
}
func (r *RetryProvider) Complete(ctx context.Context, req CompletionRequest) (CompletionResponse, error) {
var lastErr error
for attempt := 0; attempt < r.maxRetries; attempt++ {
if attempt > 0 {
wait := time.Duration(math.Pow(2, float64(attempt))) * 100 * time.Millisecond
select {
case <-time.After(wait):
case <-ctx.Done():
return CompletionResponse{}, ctx.Err()
}
}
resp, err := r.inner.Complete(ctx, req)
if err == nil {
return resp, nil
}
lastErr = err
}
return CompletionResponse{}, fmt.Errorf("after %d attempts: %w", r.maxRetries, lastErr)
}
MaxRetryAttempts의 기본값은 3입니다. 지수 백오프(exponential backoff)를 적용하면: 1차 시도는 즉시 실행되고, 2차 시도는 200ms를 대기하며, 3차 시도는 400ms를 대기합니다. 호출당 최악의 경우 총 대기 시간은 1초 미만이며, 이는 백그라운드 도구로서 수용 가능한 지연 시간(latency)입니다.
계층 2: 동시성 제어를 위한 세마포어(Semaphore)
최대 100번의 호출을 수행해야 하므로, 고루틴 팬아웃(goroutine fan-out)이 명확한 접근 방식입니다. 하지만 100개의 동시 요청으로 LLM API를 호출하면 즉시 속도 제한(rate limiting)이 발생합니다. 세마포어(semaphore)는 진행 중인(in-flight) 호출 수를 제한합니다:
type SemaphoreProvider struct {
inner Provider
sem chan struct{}
}
func NewSemaphoreProvider(inner Provider, maxConcurrent int) *SemaphoreProvider {
return &SemaphoreProvider{
inner: inner,
sem: make(chan struct{}, maxConcurrent),
}
}
func (s *SemaphoreProvider) Complete(ctx context."
Context, req CompletionRequest) (CompletionResponse, error) { select { case s.sem <- struct{}{}: defer func() { <-s.sem }() case <-ctx.Done(): return CompletionResponse{}, ctx.Err() } return s.inner.Complete(ctx, req) }
MaxConcurrentCalls의 기본값은 5입니다. 각 재시도(retry) 시도는 자신만의 세마포어(semaphore) 슬롯을 획득합니다. 이 점이 중요합니다. 만약 재시도 로직이 시도 사이의 대기 시간 동안 슬롯을 점유하고 있다면, 다른 고루틴(goroutine)들이 불필요하게 차단될 것입니다. 재시도 래퍼(retry wrapper)가 바깥쪽 레이어이며, 세마포어는 안쪽 레이어입니다. 팩토리(factory)에 의해 조립된 미들웨어 스택은 다음과 같습니다: retry → semaphore → raw provider
Layer 3: 결과 캐시 (Result Cache)
많은 테스트 생성 실행이 동일한 파일에 반복적으로 접근합니다. watch 모드는 그 극단적인 사례입니다. 동일한 소스 코드에 대해 LLM을 두 번 호출하는 것은 낭비입니다. 콘텐츠 주소 지정 방식의 캐시(content-addressed cache)를 통해 이를 방지할 수 있습니다:
type ResultCache struct {
mu sync.RWMutex
entries map[string][]BodyGenResult
hits int
misses int
}
func cacheKey(req BodyGenRequest) string {
h := sha256.New()
fmt.Fprintf(h, "%s \n %s \n %s \n %s", req.Language, req.MemberName, req.SourceCode, req.Framework.Name)
return hex.EncodeToString(h[:])
}
키는 언어(language), 멤버 이름(member name), 소스 코드(source code), 그리고 프레임워크(framework)의 SHA-256 해시값입니다. 소스 파일이 변경되면 해시가 변경되어 캐시 미스(cache miss)가 발생하며, 변경된 코드에 대해 항상 신선한 결과를 얻을 수 있습니다. 실행 후, `--verbose`를 사용하면 캐시 통계가 출력됩니다:
LLM cache — hits: 12 misses: 8 entries: 8
Layer 4: 배치 생성 (Batch Generation)
팬아웃(fan-out) 방식은 공개 멤버(public member)당 한 번의 API 호출을 수행합니다. 함수가 10개 있는 파일의 경우, 10번의 호출이 발생합니다. 배치 생성(Batch generation)은 이를 하나로 통합합니다:
func (g *LLMBodyGenerator) GenerateBatchBodies(ctx context.Context, reqs []BodyGenRequest) ([]BodyGenResult, error) {
prompt := buildBatchPrompt(reqs)
resp, err := g.provider.Complete(ctx, CompletionRequest {
SystemPrompt: batchSystemPrompt,
UserPrompt: prompt,
Model: g.model,
MaxTokens: g.
maxTokens * len ( reqs ), // 요청 수에 따라 스케일링됨
Temperature : g . temperature ,
ResponseFormat : "json_object" ,
// 구조화된 출력 (structured output)
})
// ... }
우리는 구조화된 출력 (structured output)을 얻기 위해 OpenAI의 `response_format: {"type": "json_object"}`를 사용합니다. 모델은 각 멤버당 하나의 항목을 포함하는 JSON 봉투 (envelope)를 반환합니다:
{ "tests" : [ { "name" : "ProcessPayment" , "code" : "func TestProcessPayment(t *testing.T) { ... }
수치상 결과
실제 사례로, 40개의 소스 파일과 각 파일당 평균 6개의 공개 함수(public functions)가 있는 중간 규모의 Go 프로젝트의 경우:
- Batch 미사용 시: 240번의 API 호출, 5개의 동시 실행(concurrent) 기준 약 4분 소요
- Batch 사용 시: 40번의 API 호출(파일당 1회), 약 45초 소요
- Warm Cache(따뜻한 캐시) 상태에서의 두 번째 실행: 변경되지 않은 파일에 대해 거의 즉각적인 결과 반환
캐시(Cache)와 Batch 생성을 함께 사용하면,
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기