LLM 호출을 둘러싼 지루한 계층
요약
LLM 애플리케이션 구축 시 모델 자체보다 호출을 둘러싼 인프라 계층의 중요성을 다룹니다. 타임아웃 설정 오류와 잘못된 재시도 전략이 시스템 안정성과 비용에 미치는 영향을 설명합니다.
핵심 포인트
- 적절한 타임아웃 설정은 워커 점유와 비용 낭비를 방지함
- 인프라(Nginx 등)보다 애플리케이션 타임아웃을 더 짧게 설정해야 함
- 에러 코드(429, 500, 400)에 따라 차별화된 재시도 전략이 필요함
- 잘못된 재시도 로직은 불필요한 API 호출 비용을 발생시킴
올해 제가 배운 대부분은 흥미로운 부분들을 구축하면서 얻은 것이었습니다. 검색 (Retrieval), 프롬프트 (Prompts), 에이전트 루프 (Agent loop) 같은 것들 말이죠. 생각하기 즐거운 요소들입니다.
그러다 제 프로젝트 중 하나 앞에 FastAPI 엔드포인트를 두고 친구에게 보여주었는데, 그가 약 4분 만에 그것을 망가뜨리는 것을 지켜보게 되었습니다. 악의적인 의도는 아니었습니다. 그는 그저 길고 이상한 질문을 던졌고, 요청은 그가 탭을 닫을 때까지 계속 회전하며 대기 상태로 머물러 있었습니다.
그때 저는 모델이 실제 작업의 3분의 1 정도에 불과할지도 모른다는 사실을 깨달았습니다. 나머지는 그것을 둘러싸고 있는 것들인데, 이는 지루하고 구축하는 과정이 흥미롭지 않기 때문에 아무도 이에 대해 튜토리얼을 쓰지 않습니다.
이것이 제가 최종적으로 도달한 결론입니다. 저는 여전히 학생이므로, 이를 대규모로 운영해 본 사람의 조언이라기보다는 메모로 취급해 주세요. 아마 제가 틀린 부분도 있을 것입니다.
호출에는 기본적으로 타임아웃 (Timeout)이 없으며 이는 문제입니다
이것이 저를 처음으로 괴롭혔던 문제입니다.
저는 어딘가에 합리적인 기본값이 있을 것이라고 가정했습니다. 클라이언트에 따라 어느 정도 존재하기는 하지만, 매우 길거나 사실상 없는 경우가 많습니다. 그래서 제공업체 (Provider)가 느려지면 요청이 실패하지 않습니다. 그저 기다릴 뿐입니다. 그리고 기다리는 동안 다른 일을 할 수 없는 워커 (Worker)를 붙잡고 있게 됩니다.
response = await litellm.acompletion(
model="deepseek/deepseek-chat",
messages=messages,
...
처음에는 30초가 공격적이라고 느껴졌지만, 실제로 p95를 측정해 보니 합당한 작업 중 15초보다 오래 걸리는 것은 없다는 것을 깨달았습니다. 호출이 30초에 달한다면 이미 무언가 잘못된 것이며, 더 오래 기다리는 것은 도움이 되지 않습니다.
한동안 저를 혼란스럽게 했던 한 가지는 이것입니다: 만약 여러분이 자체적인 타임아웃을 가진 무언가(nginx, 클라우드 로드 밸런서 (Load balancer), API 게이트웨이 (API gateway)) 뒤에 있고, 여러분의 타임아웃이 그것보다 길다면, 최악의 상황을 맞이하게 됩니다. 사용자는 504 에러를 받게 되지만, 여러분의 호출은 계속 실행되며 아무도 보지 못할 답변을 위해 계속 비용을 발생시킵니다. 여러분의 타임아웃이 더 짧아야 합니다.
재시도 (Retries)는 절반만 맞추기 쉽습니다
모두가 지수 백오프 (Exponential Backoff)를 사용하여 재시도하라고 말합니다. 그 부분은 괜찮습니다. 대부분의 라이브러리가 이를 대신 처리해 주니까요.
제가 생각하지 못했던 점은 서로 다른 에러가 서로 다른 의미를 갖는다는 것이었습니다.
429 에러는 속도를 줄이라는 뜻이며, 너무 빠르게 요청하고 있으니 백오프 (Backoff)를 하는 것이 정확한 대응입니다. 500 에러는 제공자 (Provider) 측에 문제가 생겼음을 의미하므로 재시도가 합리적입니다. 하지만 400 에러는 요청 형식이 잘못되었음을 의미하며, 이를 재시도하는 것은 그 사실을 알아내는 대가로 비용을 지불하면서 매번 동일한 에러를 반복해서 생성할 뿐입니다.
response = await litellm.acompletion(
model="deepseek/deepseek-chat",
messages=messages,
...
다섯 번이 아니라 두 번만 재시도하세요. 한때 재시도 루프 (Retry loop)와 유효성 검사 실패 (Validation failure)가 서로 맞물려, 제가 알아차리기 전까지 동일한 요청이 대략 12번 정도 나가는 버그가 있었습니다. 제 테스트 코퍼스 (Corpus)가 매우 작았고 비용도 몇 푼 안 되었기에 극적인 일은 일어나지 않았지만, 실제 워크로드 (Workload)에서 같은 형태의 버그가 발생한다면 누군가에게 청구서를 설명해야 하는 상황에 직면하게 될 것입니다.
또한, 재시도를 모두 소진하고 포기한 요청들을 로그 (Log)에 남기는 것도 가치가 있습니다. 사용자에게 드러나는 에러만 로그를 남기기 쉽지만, 세 번의 시도 끝에 조용히 실패한 것들이야말로 여러분이 반드시 알아야 하는 것들입니다.
양 끝단 모두에 토큰 상한선이 필요합니다
저는 max_tokens에 대해 알고 있었습니다. 모두가 max_tokens에 대해 알고 있죠. 그것은 돌아오는 응답의 범위를 제한합니다.
입력 측면에 대해 제대로 생각하는 데는 더 오랜 시간이 걸렸습니다. RAG (Retrieval-Augmented Generation)에서는 실제로 그곳에 위험이 존재합니다. 검색된 청크 (Chunks)를 프롬프트 (Prompt)에 집어넣는데, 그 청크들이 무엇을 포함하고 있는지 여러분이 완전히 제어할 수 없기 때문입니다. 제 문서 대부분은 정상적이었습니다. 하지만 그중 하나는 엄청나게 컸습니다. 그것은 다른 모든 것과 동일한 코드 경로를 거쳤고, 일반적인 요청 비용의 대략 10배를 발생시켰습니다. 저는 전혀 다른 이유로 호출당 비용을 들여다보고 있었기에 겨우 찾아낼 수 있었습니다.
그래서 이제 저는 요청을 보낸 후가 아니라, 보내기 전에 토큰을 계산합니다.
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
...
조잡하며, 관련성(relevance)보다는 위치(position)를 기준으로 청크(chunk)를 삭제하기 때문에 이상적이지는 않습니다. 하지만 실제로 존재하는 조잡한 상한선이, 추가할 계획만 세우고 있는 우아한 상한선보다 낫습니다.
폴백(Fallback)은 생각보다 더 중요합니다
제공업체(Providers)는 다운됩니다. 자주 있는 일은 아니지만 발생하며, 그런 일이 생기면 기다리는 것 외에는 할 수 있는 게 없습니다. 이는 데모 중에 발견하기에는 매우 좋지 않은 상황입니다.
LiteLLM은 이 과정을 진정으로 쉽게 만들어 주며, 이것이 제가 LiteLLM을 사용하는 주된 이유 중 하나입니다:
response = await litellm.acompletion(
model="deepseek/deepseek-chat",
messages=messages,
...
폴백(Fallback) 모델이 반드시 똑같이 좋을 필요는 없습니다. 이 부분이 제가 처음에 오해했던 지점입니다. 약간 더 나쁜 답변을 받는 것과 아예 답변을 받지 못하는 것 사이에는 큰 차이가 있으며, 사용자들은 전자에 대해 훨씬 더 관대합니다.
하지만 실제로 테스트는 꼭 해보세요. 저는 한동안 모델 이름이 잘못 설정되어 있어, 만약 폴백이 작동했다면 실패했을 설정을 해두었습니다. 그 경로를 실행해 본 적이 없었기 때문입니다. 저는 의도적으로 쓰레기 같은(garbage) 기본 모델 이름을 넣고 어떤 일이 일어나는지 확인함으로써 이 문제를 발견했습니다. 이 작업에는 2분밖에 걸리지 않았으며, 저는 즉시 이 작업을 수행했어야 했습니다.
검증(Validation)은 무언가 고장 났음을 알려줄 뿐, 어떻게 해야 할지는 알려주지 않습니다
Pydantic은 훌륭합니다. 원하는 형태(shape)를 정의하면, 모델이 다른 것을 반환했을 때 깔끔한 에러를 받을 수 있습니다.
from pydantic import BaseModel
class Answer(BaseModel):
...
저를 곤란하게 했던 점은 에러를 잡는 것(catching the error)이 결정의 절반에 불과하다는 것이었습니다. 그다음 단계로 무엇을 할지 여전히 선택해야 하는데, 저는 한동안 아무것도 선택하지 않았고, 이는 기본적으로 "사용자에게 500 에러를 던지는 것"을 선택한 셈이 되었습니다.
제가 결국 고민하게 된 옵션들은 다음과 같습니다:
검증 에러(validation error)를 다시 프롬프트(prompt)에 넣어 한 번 재시도(Retry)하는 것입니다. 이는 작은 스키마(schema) 오류의 경우 놀라울 정도로 자주 작동합니다. 하지만 추가적인 호출과 몇 초의 시간이 더 소요되므로 공짜는 아닙니다.
더 단순한 것으로 폴백(Fall back)하는 것입니다. 구조화된 버전(structured version)이 계속 실패한다면, 응답 자체를 잃어버리는 대신 구조를 포기하고 일반 텍스트(plain text)를 취하는 방식입니다.
실제 메시지와 함께 적절히 실패하는 것이 중요합니다. 때로는 이것이 옳습니다. 하지만 '죄송해요, 문제가 발생했어요(sorry, something went wrong)'라는 문구는 스택 트레이스(stack trace)보다 훨씬 낫고, 세 단계 위에서 무언가를 망가뜨리는 빈 객체를 조용히 반환하는 것보다도 좋습니다.
이 모든 것이 일반적인 상황에서는 옳지 않습니다. 핵심은 의도적으로 하나를 선택하는 것입니다.
대시보드에서 알려주는 지출 상한선(A spend ceiling, because dashboards tell you afterwards)
제가 다시 시작한다면 가장 먼저 추가할 부분입니다.
프로바이더의 대시보드는 좋습니다. 하지만 그것들은 회고적(retrospective)입니다. 언제 돈을 썼는지가 아니라, 돈을 쓰고 난 후에 얼마를 썼는지 알려주며, 만약 밤새 무언가가 루프(loop)한다면 아침에야 알게 됩니다.
그래서 저는 프로세스 내부에 카운터(counter)를 유지합니다:
class SpendGuard:
def __init__(self, ceiling_usd):
self.ceiling = ceiling_usd
...
이건 20줄짜리 코드이고 순진한 방식입니다. 프로세스가 재시작되면 초기화되고, Redis와 같은 공유 공간에 카운터를 옮기지 않는 한 여러 워커(worker)에 걸쳐 도움이 되지 않습니다. 하지만 이것은 '무제한(unbounded)'을 '유한(bounded)'으로 바꾸어 주며, 그것이 실제로 중요한 부분입니다. 제 논문 실험 전체가 약 4파운드의 컴퓨팅 자원으로 진행되었는데, 버그가 이를 400파운드로 만들 수 없다는 것을 알게 되자 훨씬 자유롭게 반복할 수 있었습니다.
만약 호스팅된 프로바이더를 사용한다면, 그들의 콘솔(console)에서도 하드 비딩 제한(hard billing limit)을 설정하세요. 만약을 대비하는 것입니다(Belt and braces).
제가 다르게 할 것들
솔직히 말해서, 저는 흥미로운 부분을 작성하기 전에 이 모든 것을 먼저 작성할 것입니다.
여기에 추가한 모든 항목들은 무언가에 놀란 후에 추가된 것이었기 때문에, 각각의 요소들이 결정(decision)이라기보다는 작은 패닉(panic)처럼 느껴졌습니다. 코드가 많지는 않습니다. 타임아웃(timeout), 재시도 횟수 제한(retry cap), 토큰 상한선 두 개, 폴백(fallback), 유효성 검사 분기(validation branch), 그리고 카운터입니다. 처음부터 구현하는 데 한 시간이 걸릴지 모르지만, 각각을 개별적으로 어려운 방식으로 찾는 것보다는 훨씬 빠릅니다.
모델은 모두가 이야기하는 부분입니다. 그 주변의 구조물들이 이 시스템이 실제 사용자 접촉에서 살아남을지 여부를 결정합니다.
저보다 더 진행된 단계에 계시다면, 제가 여기서 잘못한 부분이 있다면 정말 알고 싶습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기