스트리밍 거절 처리하기

스트리밍 거절 처리하기 (Handle streaming refusals)

Claude 4 모델부터 스트리밍 응답에서 정책 위반 가능성을 처리하는 스트리밍 분류기가 개입하면 API가 **stop_reason: "refusal"**을 반환해요. 이 안전 기능은 실시간 스트리밍 중에도 콘텐츠 규정 준수를 유지하는 데 도움을 줘요. 거절을 감지하고 대체 모델로 재시도하는 방법을 함께 살펴볼게요.

출처: 문서

본문

Claude 4 모델부터 Claude API의 스트리밍 응답은 스트리밍 분류기가 잠재적 정책 위반을 처리하기 위해 개입할 때 **stop_reason: "refusal"**을 반환해요. 이 안전 기능은 실시간 스트리밍 중 콘텐츠 규정 준수를 유지하는 데 도움이 돼요.

이 페이지는 스트리밍 응답에서 거절이 어떻게 나타나는지 다뤄요. 모든 `stop_reason` 값과 처리 방법은 [Stop reasons and fallback](https://platform.claude.com/docs/en/build-with-claude/handling-stop-reasons)을 참고하세요. 거절된 요청을 다른 Claude 모델에 재시도하려면 [Refusals and fallback](https://platform.claude.com/docs/en/build-with-claude/refusals-and-fallback)을 참고하세요.

API 응답 형식

스트리밍 분류기가 Anthropic의 정책을 위반하는 콘텐츠를 감지하면 API는 다음 응답을 반환해요:

{
  "role": "assistant",
  "content": [
    {
      "type": "text",
      "text": "Hello.."
    }
  ],
  "stop_reason": "refusal",
  "stop_details": {
    "type": "refusal",
    "category": "cyber",
    "explanation": "This request was declined because it could enable cyber harm."
  }
}

이벤트 스트림에서 stop_detailsstop_reason과 함께 message_delta 이벤트로 도착해요.

스트리밍 분류기의 `refusal` 응답에는 `category`와 사람이 읽을 수 있는 `explanation`을 담은 `stop_details` 객체가 포함되며, 이를 사용자에게 표시할 수 있어요. 전체 응답 형태와 사용 가능한 카테고리는 [Refusals and fallback](https://platform.claude.com/docs/en/build-with-claude/refusals-and-fallback#refusal-response)을 참고하세요.

거절 시 stop_details 객체는 항상 존재하지만, 예를 들어 거절이 명명된 카테고리에 매핑되지 않는 경우 categoryexplanation 필드는 null일 수 있어요. categoryexplanation이 채워져 있다고 가정하지 말고 stop_reason이나 stop_details.type을 기준으로 분기하고, null일 때는 자체적인 사용자용 메시지를 제공하세요.

거절 후 컨텍스트 재설정

**stop_reason: refusal**을 받으면 계속 진행하기 전에 대화 컨텍스트를 반드시 재설정해야 해요. 거절을 유발한 턴을 제거하거나 다시 표현하거나, 대화 기록을 완전히 지울 수 있어요. 재설정 없이 계속하려고 하면 계속 거절이 발생할 거예요.

응답이 거절된 경우에도 응답에는 사용량 지표가 계속 제공돼요.

Claude가 어떤 출력도 생성하기 전에 거절이 도착하면 Claude API에서는 그 요청에 대해 과금되지 않으며, 그 응답의 usage 수치는 정보용일 뿐이에요. Claude가 출력을 생성한 후 거절이 발생하면 그 요청에 과금돼요.

컨텍스트 재설정만이 복구 방법은 아니에요. 거절된 요청을 다른 Claude 모델에 재시도할 수도 있고, [Refusals and fallback](https://platform.claude.com/docs/en/build-with-claude/refusals-and-fallback) 페이지에서 서버 측 폴백, SDK 미들웨어, 수동 재시도로 설정하는 방법을 보여줘요.

구현 가이드

애플리케이션에서 스트리밍 거절을 감지하고 처리하는 방법이에요:

```bash cURL # Stream request and check for refusal response=$(curl -N https://api.anthropic.com/v1/messages \ -H "anthropic-version: 2023-06-01" \ -H "content-type: application/json" \ -H "x-api-key: $ANTHR...KEY" \ -d '{ "model": "claude-opus-5-5", "messages": [{"role": "user", "content": "Hello"}], "max_tokens": 1024, "stream": true }')

Check for refusal in the stream

if echo "$response" | grep -q '"stop_reason":"refusal"'; then echo "Response refused - resetting conversation context" # Reset your conversation state here fi


```python Python
client = anthropic.Anthropic()
messages = []


def reset_conversation():
    """Reset conversation context after refusal"""
    global messages
    messages = []
    print("Conversation reset due to refusal")


try:
    with client.messages.stream(
        max_tokens=1024,
        messages=messages + [{"role": "user", "content": "Hello"}],
        model="claude-opus-5-5",
    ) as stream:
        for event in stream:
            # Check for refusal in message delta
            if event.type == "message_delta":
                if event.delta.stop_reason == "refusal":
                    reset_conversation()
                    break
except Exception as e:
    print(f"Error: {e}")
const client = new Anthropic();
let messages: Anthropic.MessageParam[] = [];

function resetConversation() {
  // Reset conversation context after refusal
  messages = [];
  console.log("Conversation reset due to refusal");
}

try {
  const stream = await client.messages.stream({
    messages: [...messages, { role: "user", content: "Hello" }],
    model: "claude-opus-5-5",
    max_tokens: 1024
  });

  for await (const event of stream) {
    // Check for refusal in message delta
    if (event.type === "message_delta" && event.delta.stop_reason === "refusal") {
      resetConversation();
      break;
    }
  }
} catch (error) {
  console.error("Error:", error);
}
List<Message> messages = new();
AnthropicClient client = new();

var parameters = new MessageCreateParams
{
    Model = Model.ClaudeOpus5_5,
    MaxTokens = 1024,
    Messages = [new() { Role = Role.User, Content = "Hello" }]
};

try
{
    await foreach (var streamEvent in client.Messages.CreateStreaming(parameters))
    {
        if (
            streamEvent.TryPickDelta(out var deltaEvent)
            && deltaEvent.Delta.StopReason == StopReason.Refusal
        )
        {
            ResetConversation();
            break;
        }
    }
}
catch (Exception e)
{
    Console.WriteLine($"Error: {e.Message}");
}

void ResetConversation()
{
    messages.Clear();
    Console.WriteLine("Conversation reset due to refusal");
}
var messages []anthropic.MessageParam

func resetConversation() {
	messages = []anthropic.MessageParam{}
	fmt.Println("Conversation reset due to refusal")
}
// ...
	client := anthropic.NewClient()

	stream := client.Messages.NewStreaming(context.TODO(), anthropic.MessageNewParams{
		Model:     anthropic.ModelClaudeOpus5_5,
		MaxTokens: 1024,
		Messages: []anthropic.MessageParam{
			anthropic.NewUserMessage(anthropic.NewTextBlock("Hello")),
		},
	})

streamLoop:
	for stream.Next() {
		event := stream.Current()
		switch eventVariant := event.AsAny().(type) {
		case anthropic.MessageDeltaEvent:
			if eventVariant.Delta.StopReason == anthropic.StopReasonRefusal {
				resetConversation()
				break streamLoop
			}
		}
	}

	if err := stream.Err(); err != nil {
		log.Fatal(err)
	}
import com.anthropic.core.http.StreamResponse;
import com.anthropic.models.messages.RawMessageStreamEvent;
import com.anthropic.models.messages.StopReason;
// ...

List<MessageParam> messages = new ArrayList<>();

void main() {
    AnthropicClient client = AnthropicOkHttpClient.fromEnv();

    MessageCreateParams params = MessageCreateParams.builder()
        .model(Model.CLAUDE_OPUS_5_5)
        .maxTokens(1024L)
        .addUserMessage("Hello")
        .build();

    try (StreamResponse<RawMessageStreamEvent> stream = client.messages().createStreaming(params)) {
        stream.stream().forEach(event -> {
            event.messageDelta().ifPresent(deltaEvent -> {
                deltaEvent.delta().stopReason().ifPresent(stopReason -> {
                    if (stopReason.equals(StopReason.REFUSAL)) {
                        resetConversation();
                    }
                });
            });
        });
    } catch (Exception e) {
        System.err.println("Error: " + e.getMessage());
    }
}

void resetConversation() {
    messages.clear();
    IO.println("Conversation reset due to refusal");
}
$client = new Client();
$messages = [];

function resetConversation(&$messages) {
    $messages = [];
    echo "Conversation reset due to refusal\n";
}

try {
    $stream = $client->messages->createStream(
        maxTokens: 1024,
        messages: [
            ['role' => 'user', 'content' => 'Hello']
        ],
        model: 'claude-opus-5-5',
    );

    foreach ($stream as $event) {
        if ($event->type === 'message_delta' && $event->delta->stopReason === 'refusal') {
            resetConversation($messages);
            break;
        }
    }
} catch (Exception $e) {
    echo "Error: " . $e->getMessage() . "\n";
}
client = Anthropic::Client.new
messages = []

def reset_conversation(messages)
  messages.clear
  puts "Conversation reset due to refusal"
end

begin
  stream = client.messages.stream(
    model: :"claude-opus-5-5",
    max_tokens: 1024,
    messages: [{ role: "user", content: "Hello" }]
  )

  stream.each do |event|
    if event.type == :message_delta && event.delta.stop_reason == :refusal
      reset_conversation(messages)
      break
    end
  end
rescue => e
  puts "Error: #{e.message}"
end

현재 거절 유형

API는 현재 거절을 세 가지 방식으로 처리해요:

거절 유형 응답 형식 발생 시점
스트리밍 분류기 거절 stop_reason: refusal 콘텐츠가 정책을 위반하는 스트리밍 중
API 입력 및 저작권 검증 400 오류 코드 입력이 검증 검사를 통과하지 못할 때
모델 생성 거절 표준 텍스트 응답 모델 자체가 거절할 때

모범 사례

  • 거절 모니터링: 오류 처리에 stop_reason: refusal 확인을 포함하세요
  • 자동 재설정: 거절이 감지되면 자동 컨텍스트 재설정을 구현하세요
  • 다른 모델로 폴백: 서버 측 폴백이나 SDK 미들웨어를 구성해 거절된 요청이 사용자에게 거절을 표시하는 대신 다른 Claude 모델에서 재시도되도록 하세요
  • 수동 재시도 시 폴백 크레딧 사용: 재시도를 직접 구축한다면 거절의 폴백 크레딧 토큰을 전달해 재시도가 프롬프트 캐시 비용을 두 번 지불하지 않게 하세요
  • 사용자 정의 메시지 제공: 거절 발생 시 더 나은 UX를 위해 사용자 친화적인 메시지를 만드세요
  • 거절 패턴 추적: 거절 빈도를 모니터링해 프롬프트의 잠재적 문제를 파악하세요

마이그레이션 참고 사항

이 기능이 처음 출시될 때 거절 처리를 구축했거나 기존 통합에 추가 중이라면 다음을 확인하세요:

  • 거절은 오류가 아니라 응답이에요. 거절은 성공적인 HTTP 200 응답으로 stop_reason: "refusal"과 함께 도착해요. 그래서 오류율만 기반으로 한 모니터링은 이를 드러내지 못해요. 거절을 별도의 신호로 추적하세요.
  • 거절에는 구조화된 세부 정보가 포함돼요. 모든 모델에서 거절에는 거절 배후의 정책 카테고리를 식별하는 stop_details 객체도 포함돼요. 전체 응답 형태는 Refusals and fallback을 참고하세요.
  • 다른 모델에 재시도하세요. 거절된 요청을 같은 모델에 다시 보내면 보통 또 거절이 돼요. 컨텍스트만 재설정하는 대신 서버 측 폴백, SDK 미들웨어, 수동 재시도로 폴백 모델에 재시도하고, 직접 재시도를 구축할 때는 폴백 크레딧을 사용하세요.
  • 배치 결과에서 거절 확인: Message Batch의 거절된 요청은 오류 결과가 아니라 stop_reason: "refusal"이 있는 성공 결과로 반환돼요.
  • stop_reason을 중심으로 처리를 중앙화하세요. API는 거절 처리를 계속 stop_reason: "refusal"을 중심으로 통합하고 있으므로, 모델별 동작이 아니라 stop reason을 기준으로 분기하세요.

다음 단계

서버 측 또는 클라이언트에서 거절된 요청을 다른 Claude 모델에 재시도하세요. 모든 `stop_reason` 값과 처리 방법. 응답을 스트리밍하고 도착하는 `message_delta` 이벤트에서 `stop_reason`을 읽으세요. Claude의 교차 언어 기능으로 여러 언어의 사용자에게 서비스하세요.

더 알아보기 (Learn more)