Claude stop_reason가 refusal로 오는데 content가 빈 배열이라 IndexError 납니다
보안 점검 리포트를 자동 생성하는 야간 배치인데, 가끔 응답 파싱하다가 IndexError가 납니다. 로그 까보니 response.content가 [](빈 배열)이고 stop_reason이 refusal로 와 있더라고요.
저희가 보안 툴링이라 악의적 의도는 전혀 없는데 가끔 분류기에 걸리는 듯합니다. 무인 배치라 한 번 터지면 그 항목이 통째로 누락돼서 다음 날 보면 구멍이 나 있어요.
- refusal을 어떻게 안전하게 핸들링하나요 (
content[0]깠다가 터지는 거 말고) - 정당한 요청이 잘못 막혔을 때 무인으로 복구할 방법이 있는지
이 두 개가 궁금합니다.
답변 2개
- 채택된 답변에디터 검증
content[0]무조건 까는 게 원인이에요. refusal일 때 content는 빈 배열이거나(출력 전 거절) 잘리다 만 부분 출력(스트리밍 중 거절)이라 인덱싱하면 그 자리에서 터집니다. 항상stop_reason먼저 분기하세요.if response.stop_reason == "refusal": cat = response.stop_details.category if response.stop_details else None handle_refusal(cat) # dead-letter로 빼고 로그 else: text = next(b.text for b in response.content if b.type == "text")복구는 모델에 따라 갈리는데,
claude-fable-5쓰는 중이면 server-side fallback이 제일 깔끔합니다. opt-in이라 안 켜면 거절 시 그냥 멈춰요. 켜두면 정책 거절이 났을 때 같은 호출 안에서 폴백 모델이 한 번 더 돌아줍니다.response = client.beta.messages.create( model="claude-fable-5", max_tokens=16000, betas=["server-side-fallback-2026-06-01"], fallbacks=[{"model": "claude-opus-4-8"}], messages=[...], )과금은 출력 전 거절이면 안 붙고, 폴백이 실제로 서빙하면 폴백 모델 요율로 붙습니다. 최종 응답이 그래도
stop_reason: refusal이면 체인 전체가 거절한 거라 그땐 사람한테 넘겨야 하고요.한 가지 주의: 이거 Bedrock/Vertex/Foundry에선 안 됩니다. 거기 쓰면 server-side fallback이 없어서 SDK 클라이언트사이드 미들웨어(
BetaRefusalFallbackMiddleware)를 클라이언트에 등록하는 쪽으로 가야 해요. 저흰 직 API라 위 방식으로 도배해뒀습니다. 보안 툴링이면 false positive 진짜 종종 납니다(security 인접 작업이 분류기 입장에선 제일 애매해서). 위 fallback 깔고, 운영 쪽으로 두 개만 더 박아두면 거의 안 터져요.
- refusal 난 항목 그냥 누락시키지 말고 dead-letter 큐로 빼서
stop_details.category랑 입력까지 같이 남기세요. 나중에 보면 특정 카테고리에 몰려 있어서 그것만 프롬프트 손보면 됩니다. - 거절을 같은 프롬프트로 재시도하지 마세요. 똑같이 막힙니다(저 처음에 이거 모르고 3번 재시도 박았다가 토큰만 날림). 폴백 모델로 보내거나, 도메인 맥락 분명하게 프롬프트를 다시 쓰는 게 맞아요.
그리고 배치 파서면
stop_reason에end_turn/tool_use/max_tokens/refusal말고pause_turn(서버사이드 도구 루프 중간)도 옵니다. 이거 안 잡아두면 도구 쓰는 호출에서 또 비슷하게 빈 content 만나서 터질 수 있어요.- refusal 난 항목 그냥 누락시키지 말고 dead-letter 큐로 빼서