스트리밍 거절 처리하기
스트리밍 거절 처리하기 (Handle streaming refusals)
Claude 4 모델부터 스트리밍 응답에서 정책 위반 가능성을 처리하는 스트리밍 분류기가 개입하면 API가 **stop_reason: "refusal"**을 반환해요. 이 안전 기능은 실시간 스트리밍 중에도 콘텐츠 규정 준수를 유지하는 데 도움을 줘요. 거절을 감지하고 대체 모델로 재시도하는 방법을 함께 살펴볼게요.
출처: 문서
본문
Claude 4 모델부터 Claude API의 스트리밍 응답은 스트리밍 분류기가 잠재적 정책 위반을 처리하기 위해 개입할 때 **stop_reason: "refusal"**을 반환해요. 이 안전 기능은 실시간 스트리밍 중 콘텐츠 규정 준수를 유지하는 데 도움이 돼요.
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_details는 stop_reason과 함께 message_delta 이벤트로 도착해요.
거절 시 stop_details 객체는 항상 존재하지만, 예를 들어 거절이 명명된 카테고리에 매핑되지 않는 경우 category와 explanation 필드는 null일 수 있어요. category와 explanation이 채워져 있다고 가정하지 말고 stop_reason이나 stop_details.type을 기준으로 분기하고, null일 때는 자체적인 사용자용 메시지를 제공하세요.
거절 후 컨텍스트 재설정
**stop_reason: refusal**을 받으면 계속 진행하기 전에 대화 컨텍스트를 반드시 재설정해야 해요. 거절을 유발한 턴을 제거하거나 다시 표현하거나, 대화 기록을 완전히 지울 수 있어요. 재설정 없이 계속하려고 하면 계속 거절이 발생할 거예요.
Claude가 어떤 출력도 생성하기 전에 거절이 도착하면 Claude API에서는 그 요청에 대해 과금되지 않으며, 그 응답의 usage 수치는 정보용일 뿐이에요. Claude가 출력을 생성한 후 거절이 발생하면 그 요청에 과금돼요.
구현 가이드
애플리케이션에서 스트리밍 거절을 감지하고 처리하는 방법이에요:
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을 기준으로 분기하세요.