AI 호출이 실패해도 서비스가 멈추지 않게 만들기
AI API는 느려지기도 하고, 거절하기도 하고, 답이 중간에 잘리기도 해요. 실패를 종류별로 나누고 각각 어떻게 대응했는지 정리했어요.
서비스에 AI를 붙이면 새로운 종류의 실패를 만나게 돼요. 데이터베이스는 대개 되거나 안 되거나 둘 중 하나인데, AI 호출은 실패하는 방식이 훨씬 다양해요. 혼잡해서 거절당하기도 하고, 한참 걸리다가 끊기기도 하고, 답은 왔는데 중간에 잘려 있기도 해요.
WhoIsLoafing은 분석 한 번에 AI를 여러 번 불러요. 그중 하나가 실패했다고 전체가 멈추면 안 돼요. 실패를 어떻게 나눠서 다루는지 정리했어요.
다시 해 볼 만한 실패와 아닌 실패
가장 먼저 한 일은 실패를 두 종류로 나누는 것이에요.
function isRetryable(err: unknown): boolean {
if (err instanceof Anthropic.RateLimitError || err instanceof Anthropic.InternalServerError) return true
if (err instanceof Anthropic.APIConnectionError) return true
if (err instanceof Anthropic.APIError) return err.status === 409 || (err.status ?? 0) >= 500
return err instanceof SyntaxError || err instanceof z.ZodError || err instanceof EmptyResponseError
}
| 실패 | 다시 시도 | 이유 |
|---|---|---|
| 호출 제한, 혼잡 | 해요 | 잠깐 기다리면 풀려요 |
| 서버 오류, 연결 끊김 | 해요 | 일시적인 경우가 많아요 |
| 답이 잘렸거나 형식이 맞지 않음 | 해요 | 다시 하면 제대로 올 수 있어요 |
| 키가 틀림, 요청이 잘못됨 | 안 해요 | 몇 번을 해도 같은 결과예요 |
| AI가 답하기를 거절함 | 안 해요 | 같은 자료로는 다시 거절해요 |
다시 해도 소용없는 실패를 계속 시도하면 사용자는 이유도 모른 채 오래 기다리게 돼요. 이런 실패는 바로 포기하고 다음 단계로 넘어가요.
두 겹으로 다시 시도해요
다시 시도는 두 겹이에요.
안쪽은 SDK가 해 줘요. 클라이언트를 만들 때 다시 시도할 횟수와 제한 시간을 정해 둬요.
new Anthropic({ apiKey: key, maxRetries: 2, timeout: 90_000 })
혼잡이나 호출 제한 응답이 오면 SDK가 간격을 늘려 가며 두 번까지 알아서 다시 불러요.
바깥은 직접 만들었어요. SDK가 다 실패했거나, 답은 왔는데 쓸 수 없는 경우를 위한 것이에요.
const ROUNDS = 2
const RETRY_DELAY_MS = 4000
for (let round = 1; round <= ROUNDS; round++) {
try {
return await generate(model, system, user, schema, meta)
} catch (err) {
if (!isRetryable(err)) throw err
lastError = err
}
if (round < ROUNDS) await sleep(RETRY_DELAY_MS)
}
throw lastError
4초를 쉬었다가 한 번 더 해 봐요. 바로 다시 부르지 않는 이유는, 혼잡해서 실패했다면 1초 뒤에도 혼잡할 가능성이 높기 때문이에요.
실패했을 때 더 작은 모델로 바꿔서 시도하는 방법도 있지만 쓰지 않았어요. 모델이 바뀌면 글의 품질과 말투가 달라져서, 같은 화면 안에서 말풍선마다 결이 달라져요. 품질이 흔들리는 것보다 그 항목을 건너뛰는 편이 낫다고 판단했어요.
답이 왔다고 성공은 아니에요
상태 코드가 200이어도 쓸 수 없는 답이 있어요. 응답이 끝난 이유를 꼭 확인해요.
if (response.stop_reason === 'refusal') {
throw new Error('AI가 이 요청에 답하지 않았어요.')
}
if (response.stop_reason === 'max_tokens' || !response.parsed_output) {
throw new EmptyResponseError('AI 응답이 비어 있거나 중간에 잘렸어요.')
}
특히 조심할 것이 출력 길이 한도예요. 모델이 답을 쓰기 전에 생각하는 데 쓰는 토큰도 이 한도에 포함돼요. 한도를 작게 잡으면 생각만 하다가 정작 답은 쓰지 못하고 끝나요. 그래서 실제 답의 길이보다 훨씬 넉넉하게 잡아 둬요.
응답에서 글을 꺼낼 때도 "첫 번째 블록"이라고 가정하지 않아요. 응답이 생각 블록으로 시작할 수 있어서, 순서가 아니라 종류로 찾아요.
const text = response.content.find((block) => block.type === 'text')?.text
기다릴 시간에 끝을 정해요
숫자를 풀어서 설명하는 해설은 있으면 좋지만 없어도 분석은 성립해요. 이런 호출에는 기다릴 시간에 끝을 정해 뒀어요. 25초 안에 답이 오지 않으면 미리 준비해 둔 기본 문장으로 대신해요.
사용자 입장에서는 조금 덜 다듬어진 문장을 보는 것이 한참 기다리다 오류를 보는 것보다 나아요. 무엇이 꼭 필요한 결과이고 무엇이 있으면 좋은 결과인지 나누는 것이 먼저예요.
| 호출 | 실패하면 |
|---|---|
| 레포 한 문장 정의 | 레포 설명과 사용 언어로 만든 기본 문장으로 소개해요 |
| 참여자의 기능과 코드 스타일 | 그 참여자의 결과만 비워 두고 나머지는 계속해요 |
| 숫자를 풀어 주는 해설 | 기본 문장으로 대신하거나 건너뛰어요 |
한 가지 더 챙긴 것이 있어요. 분석 결과는 하루 동안 캐시해 두는데, AI 해설이 빠진 결과는 캐시하지 않아요. 덜 완성된 결과가 하루 종일 다시 나오는 일을 막으려는 것이에요. 다음에 같은 레포를 분석하면 처음부터 다시 시도해요.
AI가 아예 없어도 돌아가게
API 키가 설정되어 있지 않으면 AI를 부르는 단계를 통째로 건너뛰고 수치만 보여줘요.
키 없이도 개발 서버를 띄울 수 있어서 편한 처리인데, 설계에도 좋은 기준이 돼요. "AI 없이 무엇이 남는가"를 계속 묻게 되기 때문이에요. 커밋 수, 라인 수, 기여도, 활동 시간대 같은 숫자는 전부 코드로 계산하고, AI는 그 위에 설명을 얹는 역할만 해요. AI가 빠져도 뼈대가 남아요.
협업 방식을 정리해 주는 기능에서 "우리 팀에 적용하는 순서"를 AI가 아니라 계산한 값에서 규칙으로 만드는 것도 같은 이유예요. 브랜치 이름에 이슈 번호를 쓰는지, PR 템플릿이 있는지는 코드로 확인할 수 있는 사실이라, 굳이 AI의 판단에 맡길 필요가 없어요.
실패도 기록해요
AI를 한 번 부를 때마다 기록을 한 줄 남겨요. 성공한 호출만이 아니라 실패한 호출과 다시 시도한 호출도 전부예요.
- 어떤 목적의 호출인지, 어떤 모델인지
- 성공했는지, 실패했다면 어떤 오류인지
- 보낸 지시문과 받은 답
- 입력과 출력 토큰 수, 걸린 시간
기록이 있어야 "어제 저녁에 분석이 느렸다"는 말을 들었을 때 무슨 일이 있었는지 확인할 수 있어요. 비용도 여기서 보여요. 어떤 호출이 토큰을 많이 쓰는지 알아야 줄일 곳을 찾을 수 있어요.
다만 기록 때문에 분석이 느려지면 안 돼요. 기록은 기다리지 않고 뒤에서 보내고, 기록 자체가 실패해도 분석은 계속돼요. 부가 기능이 본래 기능을 막지 않게 하는 것이 원칙이에요.
사용자에게는 무엇을 할 수 있는지 알려줘요
끝내 실패했을 때 보여주는 문구도 중요해요. "오류가 발생했습니다"는 사용자에게 아무것도 알려 주지 않아요.
- 다시 하면 될 만한 실패에는 다시 시도하기 버튼을 붙여요. 누르면 같은 레포를 같은 설정으로 이어서 분석해요.
- 호출 제한에 걸렸다면 언제쯤 풀리는지 알려줘요.
- 레포를 찾을 수 없다면 다시 시도하기와 함께 다른 레포 분석하기를 보여줘요.
오류 문구는 무슨 일이 일어났는지보다 이제 무엇을 하면 되는지를 말해야 해요.
정리
- 실패를 다시 해 볼 만한 것과 아닌 것으로 나눠요.
- 다시 시도에는 간격과 끝을 정해요.
- 상태 코드가 아니라 응답이 끝난 이유까지 확인해요.
- 꼭 필요한 결과와 있으면 좋은 결과를 나누고, 뒤쪽에는 대신할 것을 준비해요.
- AI 없이도 남는 뼈대를 먼저 만들어요.