Anthropic Messages, Chat Completions, Responses: 선택·변환 방법과 호환성 한계
HTTP 200이 반환되어도 Agent가 호환된다는 뜻은 아닙니다. 세 프로토콜의 완전한 도구 왕복을 통해 실제 차이와 마이그레이션, 테스트, 장애 진단 방법을 설명합니다.
목차
같은 Agent에서 endpoint와 필드 이름만 바꾼 뒤 요청이 계속 200을 반환하더라도, 도구가 실행되지 않거나 JSON이 Schema를 위반하거나 멀티턴 문맥이 갑자기 사라질 수 있습니다. 이는 대개 모델이 “덜 똑똑해진” 문제가 아니라, 애플리케이션이 서로 다른 세 프로토콜을 하나의 인터페이스처럼 취급했기 때문입니다.
마이그레이션 성공 여부는 최소한 세 단계로 나눠 확인해야 합니다.
- 형식이 수용되는가: 서버가 요청을 파싱하고 성공 상태를 반환합니다.
- 동작이 동등한가: 도구가 호출되고 결과가 올바르게 반환되며 스트림이 끝까지 완료되고 멀티턴 문맥이 이어집니다.
- 기능이 보존되는가: strict Schema, 네이티브 추론 상태, 호스팅 도구, 구조화 출력 등이 조용히 무시되거나 축소되지 않습니다.
HTTP 200은 첫 번째 단계만 증명합니다. 한 덩어리의 텍스트만 생성하는 요청이라면 단순 변환으로 충분할 때도 있습니다. 하지만 Agent, 도구 호출, 스트리밍 인자, 멀티턴 상태, 추론 모델이 들어가면 전체 상호작용 경로를 검증해야 합니다.
먼저 결론: 모델 이름이 아니라 클라이언트와 필요한 기능에 맞춰 프로토콜을 선택한다
| 상황 | 더 적합한 시작점 | 이유 |
|---|---|---|
기존 애플리케이션이 OpenAI SDK와 messages를 안정적으로 사용 중 | Chat Completions | 변경이 가장 적고 기존 메시지·도구 루프를 유지할 수 있음 |
| 새 OpenAI Agent에 호스팅 도구, 타입이 있는 Items, 서버 상태 이어가기가 필요 | Responses | OpenAI가 현재 신규 프로젝트에 권장하며 Agent 기능이 더 풍부함 |
| Claude Code, Claude 네이티브 애플리케이션 또는 Claude 고유 기능에 의존 | Anthropic Messages | 콘텐츠 블록, 도구 결과, thinking 등이 Anthropic의 네이티브 계약을 따름 |
| 자체 gateway 또는 멀티모델 라우터 | upstream 프로토콜별로 독립 어댑터 유지 | 하나의 “범용 JSON”으로 모든 네이티브 기능을 손실 없이 표현할 수 없음 |
OpenAI는 여전히 Chat Completions를 지원하므로, 안정적으로 운영되는 시스템을 “새 API가 나왔다”는 이유만으로 즉시 다시 작성할 필요는 없습니다. 신규 프로젝트이거나 Responses 네이티브 기능이 필요할 때 마이그레이션하는 편이 합리적입니다. Anthropic Messages도 OpenAI 인터페이스에서 필드 이름만 messages로 바꾼 것이 아닙니다. 콘텐츠 블록, 도구 결과 전달, 스트림 이벤트, 상태 규칙이 각각 독립된 계약을 이룹니다.
세 API의 핵심 차이
이 글에서 Completions는 Chat Completions를 뜻하며, 예전 /v1/completions endpoint를 의미하지 않습니다.
| 항목 | OpenAI Chat Completions | OpenAI Responses | Anthropic Messages |
|---|---|---|---|
| Endpoint | /v1/chat/completions | /v1/responses | /v1/messages |
| 주요 입력 | messages | input Items, 단순 메시지 입력도 가능 | messages, 보통 최상위 system을 별도로 사용 |
| 주요 출력 | choices[].message | output[]의 타입이 있는 Items | content[]의 콘텐츠 블록 |
| 도구 정의 | tools[].function | tools[]에 name, parameters를 직접 배치 | tools[]에서 input_schema 사용 |
| 도구 인자 | function.arguments, JSON 문자열 | arguments, JSON 문자열 | tool_use.input, JSON 객체 |
| 호출 연결 ID | tool_calls[].id | call_id | tool_use.id |
| 도구 결과 반환 | role: "tool" + tool_call_id | function_call_output + call_id | user 메시지의 tool_result + tool_use_id |
| 멀티턴 상태 | 애플리케이션이 메시지 이력을 재전송 | Items 재전송, previous_response_id 또는 Conversations | 애플리케이션이 메시지와 콘텐츠 블록 재전송 |
| 최종 구조화 출력 | response_format | text.format | output_config.format |
| 스트리밍 | choices[].delta | 타입이 있는 Responses 이벤트 | message/content block 이벤트 |
표만 보면 필드 이름 차이처럼 보이지만 실제 오류는 두 번째 요청에서 가장 많이 발생합니다. 모델이 도구 호출을 만든 뒤 애플리케이션은 어떻게 실행하고, 어떤 ID를 보존하며, 어떤 role과 순서로 결과를 돌려줘야 할까요? 아래에서는 부작용이 없는 같은 작업을 세 프로토콜에서 끝까지 수행합니다.
공통 예제: 테스트 요금제 조회
사용자 질문은 다음과 같습니다.
team요금제를 조회하고 포함량 초과 후 사용량 기반 과금이 가능한지 알려 주세요.
도구 이름은 get_plan_info입니다. 고정된 로컬 데이터만 읽고 외부 부작용이 없으므로 프로토콜 마이그레이션 테스트에 적합합니다.
아래 요금제 데이터는 교육용 합성 데이터입니다. OpenAI, Anthropic 또는 BetterToken의 실제 요금제, 가격, 권한을 나타내지 않습니다. 세 요청·응답 흐름은 프로토콜 구조를 설명하기 위한 예제이며 실제 API 실행 기록이 아닙니다.
애플리케이션 측 도구는 프로토콜과 무관한 함수로 작성할 수 있습니다.
from __future__ import annotations
import json
from typing import Any
PLAN_FIXTURES: dict[str, dict[str, Any]] = {
"team": {
"plan_code": "team",
"display_name": "Team",
"billing_mode": "usage_based",
"included_requests": 10_000,
"overage_allowed": True,
"source_version": "fixture-2026-09-01",
}
}
def execute_tool(name: str, raw_arguments: str | dict[str, Any]) -> str:
"""교육용 읽기 전용 도구를 실행하고 모델에 바로 전달할 수 있는 JSON 문자열을 반환한다."""
if isinstance(raw_arguments, str):
arguments = json.loads(raw_arguments)
elif isinstance(raw_arguments, dict):
arguments = raw_arguments
else:
raise TypeError("도구 인자는 JSON 문자열 또는 객체여야 합니다")
if name != "get_plan_info":
raise ValueError(f"알 수 없는 도구: {name}")
if set(arguments) != {"plan_code"}:
raise ValueError("get_plan_info는 plan_code만 허용합니다")
plan_code = arguments["plan_code"]
if not isinstance(plan_code, str):
raise TypeError("plan_code는 문자열이어야 합니다")
plan = PLAN_FIXTURES.get(plan_code)
if plan is None:
return json.dumps(
{"ok": False, "error": "plan_not_found", "plan_code": plan_code},
ensure_ascii=False,
)
return json.dumps({"ok": True, "data": plan}, ensure_ascii=False)
요청에서 strict Schema를 켰더라도 애플리케이션의 자체 입력 검증은 유지해야 합니다. strict 모드는 모델이 생성하는 도구 인자를 제한할 뿐이며 권한, enum 유효성, 멱등성, 보안 검사를 대신하지 않습니다.
Chat Completions: 완전한 도구 왕복
첫 번째 요청: 모델이 도구 호출을 생성하게 하기
아래 예제는 프로토콜 설명을 위해 OpenAI 공식 endpoint를 사용합니다. 호환 서비스에 연결할 때는 해당 제공자의 문서에 따라 Base URL, 인증 방식, Model ID를 바꾸세요.
curl https://api.openai.com/v1/chat/completions \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "YOUR_OPENAI_MODEL",
"messages": [
{
"role": "system",
"content": "당신은 요금제 안내 도우미입니다. 도구가 반환한 데이터만 근거로 답하고 추측하지 마세요."
},
{
"role": "user",
"content": "team 요금제를 조회하고 포함량 초과 후 사용량 기반 과금이 가능한지 알려 주세요."
}
],
"tools": [
{
"type": "function",
"function": {
"name": "get_plan_info",
"description": "요금제 코드로 고정 테스트 데이터 조회",
"strict": true,
"parameters": {
"type": "object",
"properties": {
"plan_code": {
"type": "string",
"enum": ["team"]
}
},
"required": ["plan_code"],
"additionalProperties": false
}
}
}
],
"tool_choice": "required",
"parallel_tool_calls": false
}'
애플리케이션은 assistant 메시지의 tool_calls를 읽어야 합니다. 아래 응답은 다음 단계에 필요한 필드만 남긴 예입니다.
{
"choices": [
{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_plan_001",
"type": "function",
"function": {
"name": "get_plan_info",
"arguments": "{\"plan_code\":\"team\"}"
}
}
]
},
"finish_reason": "tool_calls"
}
]
}
잃으면 안 되는 값은 두 가지입니다.
tool_calls[0].id: 두 번째 요청에서tool_call_id로 그대로 돌려줘야 합니다.function.arguments: JSON 문자열입니다. 먼저 파싱한 뒤 자체 Schema와 비즈니스 검증을 수행합니다.
도구를 실행합니다.
tool_result = execute_tool(
"get_plan_info",
"{\"plan_code\":\"team\"}",
)
두 번째 요청: 도구 결과를 모델에 반환
Chat Completions에서는 첫 응답의 도구 호출이 들어 있는 assistant 메시지를 이력에 보존하고, 그 뒤에 role: "tool" 결과 메시지를 추가해야 합니다.
curl https://api.openai.com/v1/chat/completions \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "YOUR_OPENAI_MODEL",
"messages": [
{
"role": "system",
"content": "당신은 요금제 안내 도우미입니다. 도구가 반환한 데이터만 근거로 답하고 추측하지 마세요."
},
{
"role": "user",
"content": "team 요금제를 조회하고 포함량 초과 후 사용량 기반 과금이 가능한지 알려 주세요."
},
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_plan_001",
"type": "function",
"function": {
"name": "get_plan_info",
"arguments": "{\"plan_code\":\"team\"}"
}
}
]
},
{
"role": "tool",
"tool_call_id": "call_plan_001",
"content": "{\"ok\":true,\"data\":{\"plan_code\":\"team\",\"display_name\":\"Team\",\"billing_mode\":\"usage_based\",\"included_requests\":10000,\"overage_allowed\":true,\"source_version\":\"fixture-2026-09-01\"}}"
}
],
"tools": [
{
"type": "function",
"function": {
"name": "get_plan_info",
"description": "요금제 코드로 고정 테스트 데이터 조회",
"strict": true,
"parameters": {
"type": "object",
"properties": {
"plan_code": {
"type": "string",
"enum": ["team"]
}
},
"required": ["plan_code"],
"additionalProperties": false
}
}
}
]
}'
대표적인 최종 메시지는 다음과 같습니다.
{
"choices": [
{
"message": {
"role": "assistant",
"content": "Team 요금제는 포함량 초과 후 사용량 기반 과금을 지원합니다. 테스트 데이터에는 요청 10,000건이 포함되어 있으며 overage_allowed는 true입니다."
},
"finish_reason": "stop"
}
]
}
어댑터가 첫 user 메시지만 변환하고 assistant의 tool_calls를 보존하지 않거나 잘못된 ID를 tool_call_id에 넣으면, 두 번째 요청은 더 이상 같은 도구 호출의 연속이 아닙니다.
Responses: 완전한 도구 왕복
Responses는 메시지, 추론, 도구 호출, 도구 결과를 서로 다른 Item type으로 표현합니다. output[0]을 항상 최종 텍스트로 간주하지 말고 각 Item의 type에 따라 분기 처리해야 합니다.
첫 번째 요청: 모델이 function_call Item을 반환하게 하기
curl https://api.openai.com/v1/responses \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "YOUR_OPENAI_MODEL",
"instructions": "당신은 요금제 안내 도우미입니다. 도구가 반환한 데이터만 근거로 답하고 추측하지 마세요.",
"input": "team 요금제를 조회하고 포함량 초과 후 사용량 기반 과금이 가능한지 알려 주세요.",
"tools": [
{
"type": "function",
"name": "get_plan_info",
"description": "요금제 코드로 고정 테스트 데이터 조회",
"strict": true,
"parameters": {
"type": "object",
"properties": {
"plan_code": {
"type": "string",
"enum": ["team"]
}
},
"required": ["plan_code"],
"additionalProperties": false
}
}
],
"tool_choice": "required",
"parallel_tool_calls": false,
"store": false
}'
대표적인 도구 호출 Item은 다음과 같습니다.
{
"id": "resp_plan_001",
"object": "response",
"output": [
{
"type": "function_call",
"id": "fc_plan_001",
"call_id": "call_plan_001",
"name": "get_plan_info",
"arguments": "{\"plan_code\":\"team\"}",
"status": "completed"
}
]
}
도구 결과 연결에는 call_id를 사용합니다. id: "fc_plan_001"은 Item 자체의 ID이므로 call_id 대신 쓰면 안 됩니다.
도구를 실행합니다.
tool_result = execute_tool(
"get_plan_info",
"{\"plan_code\":\"team\"}",
)
두 번째 요청: function_call_output 반환
아래는 Items를 수동으로 재전송하는 무상태 방식이므로 instructions, 원래 사용자 입력, 도구 호출, 도구 결과를 다시 제공합니다.
curl https://api.openai.com/v1/responses \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "YOUR_OPENAI_MODEL",
"instructions": "당신은 요금제 안내 도우미입니다. 도구가 반환한 데이터만 근거로 답하고 추측하지 마세요.",
"input": [
{
"role": "user",
"content": "team 요금제를 조회하고 포함량 초과 후 사용량 기반 과금이 가능한지 알려 주세요."
},
{
"type": "function_call",
"call_id": "call_plan_001",
"name": "get_plan_info",
"arguments": "{\"plan_code\":\"team\"}"
},
{
"type": "function_call_output",
"call_id": "call_plan_001",
"output": "{\"ok\":true,\"data\":{\"plan_code\":\"team\",\"display_name\":\"Team\",\"billing_mode\":\"usage_based\",\"included_requests\":10000,\"overage_allowed\":true,\"source_version\":\"fixture-2026-09-01\"}}"
}
],
"tools": [
{
"type": "function",
"name": "get_plan_info",
"description": "요금제 코드로 고정 테스트 데이터 조회",
"strict": true,
"parameters": {
"type": "object",
"properties": {
"plan_code": {
"type": "string",
"enum": ["team"]
}
},
"required": ["plan_code"],
"additionalProperties": false
}
}
],
"store": false
}'
대표적인 최종 출력 Item은 다음과 같습니다.
{
"id": "resp_plan_002",
"object": "response",
"output": [
{
"type": "message",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "Team 요금제는 포함량 초과 후 사용량 기반 과금을 지원합니다. 테스트 데이터에는 요청 10,000건이 포함되어 있으며 overage_allowed는 true입니다."
}
]
}
]
}
서버 측 상태 이어가기를 선택한다면 첫 응답 저장을 허용한 뒤 두 번째 요청에서 다음 형식을 사용할 수 있습니다.
{
"model": "YOUR_OPENAI_MODEL",
"previous_response_id": "resp_plan_001",
"input": [
{
"type": "function_call_output",
"call_id": "call_plan_001",
"output": "{\"ok\":true,\"data\":{\"plan_code\":\"team\",\"overage_allowed\":true}}"
}
]
}
previous_response_id는 해당 응답을 만든 upstream 서비스에 속합니다. 다른 제공자에게 넘겨 이어갈 수 없습니다. 과거 입력이 무료가 되는 것도 아닙니다. OpenAI의 현재 문서에는 체인의 이전 input token도 계속 input으로 과금된다고 명시되어 있습니다.
응답에 reasoning Item이 포함되면 무상태 재전송에서도 문서 요구에 따라 해당 Item을 보존해야 합니다. “통일된 형식”을 만들기 위해 버린 뒤 추론 문맥이 동등하다고 주장할 수 없습니다.
Anthropic Messages: 완전한 도구 왕복
Messages는 도구 호출을 assistant 콘텐츠의 tool_use block으로 나타내고, 결과는 다음 user 메시지의 tool_result block으로 반환합니다. 도구 인자는 이미 객체이므로 아직 파싱하지 않은 JSON 문자열이 아닙니다.
첫 번째 요청: Claude가 tool_use를 반환하게 하기
curl https://api.anthropic.com/v1/messages \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d '{
"model": "YOUR_CLAUDE_MODEL",
"max_tokens": 512,
"system": "당신은 요금제 안내 도우미입니다. 도구가 반환한 데이터만 근거로 답하고 추측하지 마세요.",
"messages": [
{
"role": "user",
"content": "team 요금제를 조회하고 포함량 초과 후 사용량 기반 과금이 가능한지 알려 주세요."
}
],
"tools": [
{
"name": "get_plan_info",
"description": "요금제 코드로 고정 테스트 데이터 조회",
"strict": true,
"input_schema": {
"type": "object",
"properties": {
"plan_code": {
"type": "string",
"enum": ["team"]
}
},
"required": ["plan_code"],
"additionalProperties": false
}
}
],
"tool_choice": {
"type": "tool",
"name": "get_plan_info"
}
}'
특정 도구 강제 지정은 선택한 모델과 설정의 지원 여부에 달려 있습니다. 대상 모델이 지원하지 않으면 auto를 사용하고 애플리케이션에서 실제로 도구 호출이 반환되었는지 확인하세요.
대표적인 응답은 다음과 같습니다.
{
"id": "msg_plan_001",
"type": "message",
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "toolu_plan_001",
"name": "get_plan_info",
"input": {
"plan_code": "team"
}
}
],
"stop_reason": "tool_use"
}
input 객체는 그대로 도구 실행 함수에 전달할 수 있습니다.
tool_result = execute_tool(
"get_plan_info",
{"plan_code": "team"},
)
두 번째 요청: 바로 다음 user 메시지에 tool_result 배치
curl https://api.anthropic.com/v1/messages \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d '{
"model": "YOUR_CLAUDE_MODEL",
"max_tokens": 512,
"system": "당신은 요금제 안내 도우미입니다. 도구가 반환한 데이터만 근거로 답하고 추측하지 마세요.",
"messages": [
{
"role": "user",
"content": "team 요금제를 조회하고 포함량 초과 후 사용량 기반 과금이 가능한지 알려 주세요."
},
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "toolu_plan_001",
"name": "get_plan_info",
"input": {
"plan_code": "team"
}
}
]
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_plan_001",
"content": "{\"ok\":true,\"data\":{\"plan_code\":\"team\",\"display_name\":\"Team\",\"billing_mode\":\"usage_based\",\"included_requests\":10000,\"overage_allowed\":true,\"source_version\":\"fixture-2026-09-01\"}}"
}
]
}
],
"tools": [
{
"name": "get_plan_info",
"description": "요금제 코드로 고정 테스트 데이터 조회",
"strict": true,
"input_schema": {
"type": "object",
"properties": {
"plan_code": {
"type": "string",
"enum": ["team"]
}
},
"required": ["plan_code"],
"additionalProperties": false
}
}
]
}'
대표적인 최종 응답은 다음과 같습니다.
{
"id": "msg_plan_002",
"type": "message",
"role": "assistant",
"content": [
{
"type": "text",
"text": "Team 요금제는 포함량 초과 후 사용량 기반 과금을 지원합니다. 테스트 데이터에는 요청 10,000건이 포함되어 있으며 overage_allowed는 true입니다."
}
],
"stop_reason": "end_turn"
}
Messages는 순서 요구가 명확합니다. tool_result는 대응하는 tool_use를 포함한 assistant 메시지 바로 다음에 와야 합니다. 한 assistant 턴에서 클라이언트 측 도구 호출이 여러 개 생성되면, 다음 user 메시지에서 모든 결과 블록을 반환하고 각각 tool_use_id로 연결해야 합니다. 같은 user 메시지에 일반 텍스트도 넣는다면 도구 결과 블록을 텍스트보다 앞에 둡니다.
직접 매핑할 수 있는 것과 반드시 손실이 생기는 변환
| 기능 | 변환 판단 | 올바른 처리 방법 |
|---|---|---|
| 일반 사용자 텍스트 | 보통 직접 매핑 가능 | 보이는 문자열만 복사하지 말고 텍스트, 순서, 멀티모달 타입을 유지 |
| 기본 함수 Schema | 형태를 바꿔 매핑 가능 | function.parameters, Responses parameters, Messages input_schema 사이를 변환하고 지원되는 JSON Schema 부분집합을 재검증 |
| 도구 인자 | 타입 변환 필요 | 두 OpenAI 인터페이스는 보통 JSON 문자열, Messages는 객체를 반환. 비즈니스 코드 전에 정규화·파싱·검증 |
| 도구 호출 ID | 의미는 보존하되 namespace 재사용 금지 | 내부 canonical call ID와 upstream 원본 ID를 함께 저장하고 각 프로토콜 필드로 반환 |
| 병렬 도구 호출 | 지원 가능하지만 배열 위치로 연결 금지 | tool_call_id, call_id, tool_use_id로 각 결과를 연결 |
| system/developer 지시 | 손실 가능 | 전역, 대화 단계, 단일 턴 범위를 구분하고 대상 프로토콜이 원래 범위를 표현하지 못하면 명시적으로 축소하거나 거부 |
| 최종 구조화 출력 | 필드를 기계적으로 교환할 수 없음 | Chat은 response_format, Responses는 text.format, Messages는 output_config.format 사용 |
| 스트리밍 도구 인자 | 프로토콜별 parser 필요 | 이벤트와 호출 ID별로 조각을 누적하고 완료 이벤트 후 JSON 파싱 |
| 서버 측 멀티턴 상태 | 범용 동등물 없음 | previous_response_id 같은 상태 ID는 원 upstream에 묶임. upstream 변경 시 보이는 문맥을 재전송하거나 sticky routing 사용 |
| thinking/reasoning 상태 | 대개 무손실 변환 불가 | opaque Item, thinking block, 서명, 암호화 콘텐츠를 네이티브 요구대로 보존하고 직접 만들어내지 않음 |
| 호스팅 도구 | 직접 대응물이 없는 경우가 많음 | web search, file search, computer use, server tools 등의 지원과 대체 경로를 개별 선언 |
| 복수 후보 생성 | 동등물이 없을 수 있음 | Chat Completions의 n이 Responses에 직접 매핑된다고 가정하지 말고 애플리케이션에서 여러 번 요청하거나 제품 동작 변경 |
따라서 gateway의 가장 안정적인 내부 추상화는 모든 필드를 하나의 거대한 객체로 합치는 방식이 아닙니다. 메시지, 지시 범위, 도구 정의, 도구 호출, 결과, 상태 핸들, 스트림 이벤트, 불투명한 네이티브 상태를 각각 모델링해야 합니다. 표현할 수 없는 기능은 필드를 조용히 삭제하지 말고 “지원하지 않음” 또는 “손실 변환” 상태로 명시합니다.
strict, response_format, text.format은 서로 다른 문제를 해결한다
마이그레이션에서 가장 흔한 혼동 중 하나는 “도구 인자가 유효함”과 “최종 답변이 지정 JSON 형태임”을 하나의 기능으로 보는 것입니다.
| 목표 | Chat Completions | Responses | Anthropic Messages |
|---|---|---|---|
| 도구 호출 인자 제한 | tools[].function.strict | tools[].strict | tools[].strict |
| 모델 최종 출력 제한 | response_format | text.format | output_config.format |
도구의 strict는 모델이 함수를 호출하는 방식을 제한합니다. 최종 구조화 출력은 사용자에게 반환되는 내용을 제한합니다. Agent는 둘 다 필요할 수 있습니다. strict 인자로 도구를 호출한 뒤 고정 JSON Schema로 최종 결과를 반환하는 경우입니다.
OpenAI의 현재 문서에는 놓치기 쉬운 기본 동작 차이도 있습니다.
- Chat Completions의 함수 호출은 기본적으로 비엄격 모드입니다.
- Responses에서
strict를 생략하면 서비스가 Schema를 strict 모드로 정규화하려고 합니다. 호환되지 않으면 비엄격 모드로 돌아가고 파싱된 도구 정의에strict: false가 표시될 수 있습니다.
의도를 명확히 하고 인터페이스별 기본값에 의존하지 않으려면 production 요청에서 strict: true 또는 strict: false를 명시하세요. strict Schema는 추가 속성 금지, 모든 필수 필드 지정 같은 관련 조건도 충족해야 합니다.
더 중요한 점은 호환 레이어가 필드를 받아들이면서 제약을 실행하지 않을 수 있다는 것입니다. Anthropic의 공식 OpenAI SDK compatibility 문서에는 이 특정 레이어에서 function strict, response_format, reasoning_effort 등이 무시되며, 대부분의 미지원 필드도 오류를 내지 않는다고 적혀 있습니다. 따라서 요청이 200을 반환해도 Schema나 추론 설정이 실제로 적용되지 않았을 수 있습니다.
이는 Anthropic 네이티브 Messages에 대응 기능이 없다는 뜻이 아닙니다. 네이티브 Messages는 strict 도구 입력을 지원하고 최종 JSON 제한에는 output_config.format을 사용합니다. 장애 진단의 첫 질문은 “네이티브 Messages를 호출하는가, OpenAI 호환 레이어를 호출하는가”여야 합니다.
system, developer, 지시 범위는 문자열 연결만으로 보존할 수 없다
OpenAI 스타일 인터페이스는 메시지 이력 안에서 여러 role을 허용하고 Responses에는 instructions도 있습니다. Anthropic Messages는 오랫동안 최상위 system을 사용해 왔습니다. 2026년 9월 기준 일부 현행 모델은 대화 중간의 role: "system"도 지원하지만 모든 모델이 그런 것은 아니며, 삽입 위치와 도구 호출 순서에도 제약이 있습니다.
동시에 Anthropic의 OpenAI SDK compatibility 레이어는 대화의 system/developer 메시지를 모아 줄바꿈으로 합친 뒤 시작 부분의 하나의 system prompt로 올립니다. 요청을 사용할 수 있게 해 주지만 원래의 시간 순서와 범위가 바뀝니다. 8번째 턴부터만 적용되어야 할 developer 지시가 처음으로 이동하면 앞선 7개 턴의 의미에도 영향을 줄 수 있습니다.
더 안전한 어댑터는 애플리케이션 내부에서 먼저 세 범위를 구분합니다.
- 전역 지시: 전체 대화에 적용됩니다.
- 대화 단계 지시: 특정 턴부터 계속 적용됩니다.
- 단일 턴 지시: 현재 작업에만 적용됩니다.
대상 프로토콜이 같은 범위를 표현할 수 있을 때만 매핑합니다. 표현할 수 없다면 지원 모델로 라우팅을 고정하거나, 지시를 축소하고 차이를 기록하거나, 마이그레이션을 거부하는 명시적 전략을 선택해야 합니다. 조용한 문자열 연결은 코드가 간단하지만 “요청은 성공했는데 동작이 달라졌다”는 문제를 가장 쉽게 만듭니다.
스트리밍은 텍스트 token 연결이 아니라 상태 머신으로 파싱한다
세 인터페이스 모두 스트리밍을 지원하지만 이벤트는 동등하지 않습니다.
- Chat Completions는 보통
choices[].delta에서 텍스트와tool_calls조각을 모읍니다. - Responses는
response.output_text.delta,response.function_call_arguments.delta,response.function_call_arguments.done,response.completed,error같은 타입이 있는 이벤트를 사용합니다. - Messages는
message_start,content_block_start,content_block_delta,content_block_stop,message_delta,message_stop을 사용하며 도구 인자는input_json_delta.partial_json을 통해 조각으로 도착합니다.
도구 인자는 다음처럼 나뉠 수 있습니다.
{"plan_
code":"te
am"}
각 조각은 단독으로 유효한 JSON이 아닙니다. 호출 ID 또는 콘텐츠 블록 index별로 누적한 뒤 해당 인자의 완료 이벤트를 받고 파싱해야 합니다.
from __future__ import annotations
import json
from collections import defaultdict
from typing import Any
class ToolArgumentAssembler:
def __init__(self) -> None:
self._buffers: dict[str, list[str]] = defaultdict(list)
def add_delta(self, call_id: str, fragment: str) -> None:
self._buffers[call_id].append(fragment)
def finish(self, call_id: str) -> dict[str, Any]:
if call_id not in self._buffers:
raise KeyError(f"알 수 없는 call_id: {call_id}")
raw = "".join(self._buffers.pop(call_id))
value = json.loads(raw)
if not isinstance(value, dict):
raise TypeError("도구 인자는 객체로 디코딩되어야 합니다")
return value
def discard(self, call_id: str) -> None:
self._buffers.pop(call_id, None)
어댑터는 명확한 최종 상태도 기록해야 합니다.
created -> receiving -> completed
\-> failed
\-> disconnected
disconnected는 completed가 아닙니다. Anthropic Messages는 HTTP 연결이 이미 성공한 뒤에도 스트림 안의 event: error로 오류를 전달할 수 있고, Responses도 독립적인 오류 이벤트가 있습니다. 초기 HTTP status만 보거나 연결 종료를 자연스러운 완료로 간주하면 도구 인자나 최종 답변이 잘릴 수 있습니다.
이벤트 parser는 알 수 없는 이벤트 type도 허용해야 합니다. 현재 지원 기능에 영향을 주지 않는 이벤트는 기록하고 건너뛰어, 서버에 새 이벤트가 추가될 때마다 전체 클라이언트가 중단되지 않도록 합니다.
멀티턴 상태와 추론 상태는 만들어낼 수 없다
Chat Completions와 전통적인 Messages 흐름은 보통 애플리케이션이 이력을 재전송합니다. Responses는 previous_response_id 또는 Conversations로 서버 측 상태를 유지할 수도 있습니다. 세 프로토콜이 말하는 “이전 턴”은 같은 것이 아닙니다.
gateway가 상태 ID를 받았을 때 올바른 전략은 세 가지뿐입니다.
- Sticky routing: 이후 요청도 상태를 만든 같은 upstream으로 보냅니다.
- 완전 재전송: 합법적으로 재전송할 수 있는 메시지, 도구 호출, 결과, 네이티브 상태를 모두 다시 보냅니다.
- 명시적 거부: 대상 upstream이 이어갈 수 없으면 진단 가능한 오류를 반환하고 클라이언트가 대화를 다시 시작하게 합니다.
OpenAI의 previous_response_id를 Anthropic에 그대로 보내서는 안 되며, gateway 내부 대화 ID를 다른 제공자가 이해할 수 있는 상태 핸들처럼 가장해서도 안 됩니다.
추론 상태도 필드 이름 변경으로 해결되지 않습니다.
- 무상태 또는 특정 데이터 보존 환경에서 Responses는 후속 요청에 다시 포함해야 하는 암호화 reasoning Item을 반환할 수 있습니다.
- Anthropic thinking 흐름에는 thinking block, 서명, 기타 불투명한 상태가 포함될 수 있습니다. 도구 사용과 멀티턴 대화에서는 네이티브 문서의 요구에 따라 보존해야 합니다.
- 2026년 9월 기준 Anthropic의 수동
thinking.type: "enabled"와budget_tokens조합은 4.6 세대 모델에서 deprecated되었고 4.7 이상에서는 거부됩니다. 더 새로운 모델은 adaptive thinking과 해당 effort 제어를 사용합니다.
따라서 OpenAI reasoning_effort와 Anthropic budget_tokens를 영구적으로 같다고 보는 규칙은 만들 수 없습니다. 올바른 기능 설명에는 대상 모델, 현재 thinking 모드, 미지원 시 축소 전략이 포함되어야 합니다.
병렬 도구 호출: 배열 순서가 아니라 ID로 연결
모델은 한 턴에 여러 도구를 요청할 수 있습니다. 실행 시간이 다르면 결과 순서도 호출 순서와 다를 수 있습니다. 어댑터는 다음과 같은 관계를 유지해야 합니다.
canonical_call_id
-> provider
-> provider_call_id
-> tool_name
-> validated_arguments
-> execution_status
-> result
결과를 반환할 때는 다음과 같이 처리합니다.
- Chat Completions는 결과마다
role: "tool"메시지를 만들고 대응하는tool_call_id를 넣습니다. - Responses는 결과마다
function_call_outputItem을 만들고 대응하는call_id를 넣습니다. - Messages는 바로 다음 user 턴에 대응하는
tool_resultblocks를 넣고 각각tool_use_id를 지정합니다.
마이그레이션 테스트에서는 먼저 parallel_tool_calls: false로 단일 도구 경로를 완성한 뒤 병렬 실행을 켜세요. production에서 이메일 전송, 결제, 리소스 생성처럼 부작용이 있는 도구는 멱등 키도 필요합니다. 네트워크 retry, 스트림 끊김, upstream replay 때문에 같은 의미의 호출이 다시 도착할 수 있으므로 모델이 생성한 문장만 보고 이미 실행됐는지 판단하면 안 됩니다.
HTTP 200만으로 호환성을 검증할 수 없는 이유
의미 있는 마이그레이션 테스트는 최소한 다음 경로를 포함해야 합니다.
| 테스트 | 통과 기준 |
|---|---|
| 일반 텍스트 | 내용이 읽을 수 있고 system/developer 범위가 예상대로 동작 |
| 단일 도구 호출 | 도구명, 인자, 호출 ID, 결과, 최종 답변이 완전한 왕복을 구성 |
| 병렬 도구 호출 | 각 결과가 ID로 정확히 연결되고 혼선이나 누락이 없음 |
| 스트리밍 도구 인자 | 조각이 완전히 조립되고 완료 후 JSON 파싱 가능 |
| strict 도구 Schema | 잘못된 필드와 타입이 예상대로 거부되거나 축소가 명시됨 |
| 최종 구조화 출력 | 단지 “JSON처럼 보이는” 것이 아니라 지정 Schema를 충족 |
| 도구 실행 오류 | 모델이 구조화 오류를 받고 무한 반복하거나 성공을 꾸며내지 않음 |
| 멀티턴 이어가기 | 두 번째 턴이 첫 번째 턴의 사실을 참조하고 상태 전환 규칙이 명확 |
| reasoning/thinking | 지원을 선언한 모드가 실제로 동작하고 네이티브 상태가 삭제·조작되지 않음 |
| 스트림 내부 오류와 끊김 | 클라이언트가 완료, 실패, 연결 중단을 구분 |
| 통제된 API 오류 | error type, request ID, retry 정책을 진단 가능 |
고정 입력과 고정 도구 fixture를 사용하고 각 프로토콜별로 다음을 기록하세요.
- 최종 비즈니스 결과가 동등한지.
- 도구 호출과 결과 반환이 완전한지.
- P50, P95 latency.
- input, output, cache 관련 usage.
- error type, request ID, 최종 상태.
- 어떤 기능이 명시적으로 축소되었는지.
API Key, 전체 민감 prompt, 사용자 비공개 출력을 로그에 남기지 마세요. 오류 로그에는 최소한 HTTP status, upstream error type/code, 짧은 오류 메시지, request ID, endpoint, 프로토콜, Model ID, 스트림 최종 상태를 보존해야 합니다. 그렇지 않으면 model_not_found, 권한 부족, 경로 비호환이 모두 원인 불명의 400으로 합쳐질 수 있습니다.
증상별 진단: Agent가 어디서 망가졌는가
| 증상 | 흔한 원인 | 확인과 수정 |
|---|---|---|
200은 반환되지만 모델이 도구를 호출하지 않음 | 도구 정의 누락, tool_choice 무시, 모델이 도구 미지원, prompt 부족 | 최종 outbound 요청을 출력하고 대상 모델과 호환 레이어를 확인. 테스트에서는 읽기 전용 도구 하나만 제공하고 호출을 강제하거나 명시적으로 요청 |
| 모델이 도구 호출을 반환했지만 애플리케이션이 실행하지 않음 | message.content만 보는 등 이전 필드를 읽음 | 프로토콜에 맞춰 tool_calls, function_call Item, tool_use block을 읽음 |
| 도구 인자 JSON 파싱 실패 | 스트리밍 조각을 완전한 JSON으로 봄, 객체를 문자열처럼 다시 파싱 | 인자 완료 이벤트를 기다리고 먼저 문자열인지 객체인지 확인 |
strict인데도 추가 필드가 나옴 | 호환 레이어가 조용히 무시, Schema가 strict 조건 미충족, 네이티브 endpoint가 아님 | 최종 endpoint와 문서를 확인하고 strict를 명시. 의도적으로 Schema를 위반하는 회귀 테스트 추가 |
| 두 번째 요청에서 도구 결과가 없다고 나옴 | 호출 ID 불일치, 첫 assistant/tool Item 미보존 | upstream 호출과 ID를 그대로 저장하고 프로토콜이 요구하는 위치에 바로 반환 |
Messages가 tool_use ids ... without tool_result 반환 | tool_result가 호출 직후가 아니거나 앞에 일반 텍스트 삽입 | 모든 대응 tool_result를 다음 user 메시지에 넣고 선택적 텍스트보다 앞에 배치 |
| 스트리밍이 멈추거나 인자가 절반만 옴 | 텍스트 종료만 기다리고 도구 인자·오류 최종 상태를 처리하지 않음 | 프로토콜별 이벤트 상태 머신을 구현하고 completed, failed, error, disconnected 구분 |
| 두 번째 턴이 첫 번째를 잊음 | 이력, 호출, result Items 누락 또는 previous_response_id가 다른 upstream 소유 | 전체 보이는 문맥을 재전송하거나 sticky routing 유지. 제공자 사이 상태 ID 전달 금지 |
| 인터페이스 변경 후 system 지시가 너무 일찍 적용 | 호환 레이어가 중간 system/developer를 시작 부분으로 올림 | 지시 범위를 모델링하고 무손실 매핑이 불가능하면 명시적으로 축소하거나 네이티브 프로토콜 유지 |
| 도구가 두 번 실행됨 | 요청 retry, 끊김 후 replay, 멱등 제어 없음 | 테스트는 읽기 전용 도구 사용. production 부작용 도구는 canonical call ID로 멱등 키 생성 |
| 최종 내용은 JSON인데 필드가 가끔 빠짐 | prompt에서 “JSON으로 답해”라고만 하고 구조화 출력 미사용 | 해당 인터페이스의 response_format, text.format, output_config.format을 사용하고 애플리케이션에서 다시 검증 |
더 안전한 마이그레이션 절차
- 클라이언트가 실제 보내는 프로토콜을 확인합니다. 모델 이름으로 추측하지 말고 전체 endpoint, SDK method, 요청 최상위 필드, 스트림 이벤트 type을 기록합니다.
- 유지해야 할 동작을 나열합니다. 최소한 도구, 병렬 호출, strict Schema, 최종 구조화 출력, 멀티턴 상태, 스트리밍, thinking/reasoning을 포함합니다.
- 네이티브 프로토콜을 우선합니다. 네이티브 Messages 또는 Responses로 구현 가능한 기능은 추가 호환 레이어를 피합니다.
- 변환 기능 매트릭스를 만듭니다. 각 기능을 완전 지원, 손실 지원, 미지원으로 표시하고 caller가 결과를 볼 수 있게 합니다.
- 부작용 없는 fixture로 완전한 두 요청 왕복을 실행합니다. “한 요청에서 텍스트가 온다”에서 끝내지 말고 도구를 실행해 결과를 돌려줍니다.
- 그다음 병렬, 스트리밍, 오류 경로를 테스트합니다. 정상 경로가 통과한 뒤 부작용 도구와 실제 트래픽을 엽니다.
- 트래픽을 단계적으로 늘리고 지표를 비교합니다. HTTP 성공률만 보지 말고 정확성, latency, usage, 오류, 도구 중복 실행을 함께 봅니다.
BetterToken에서 맞는 연결 지점 선택
BetterToken은 클라이언트별로 다른 연결 경로를 제공합니다. 실제 프로토콜은 클라이언트가 사용하는 wire contract로 결정됩니다.
- Chat Completions: 전체 요청 URL은
https://www.bettertoken.ai/v1/chat/completions입니다. 경로를 자동으로 붙이는 SDK나 도구에서는 Base URL을 보통https://www.bettertoken.ai/v1로 설정합니다. Chat Completions API reference를 참고하세요. - Codex / Responses: 현재 Codex 문서는
base_url = "https://www.bettertoken.ai/v1"와wire_api = "responses"를 사용하며 Codex가/responses를 추가합니다. Codex 연결 안내를 참고하세요. - Claude Code / Messages: 현재 문서는
ANTHROPIC_BASE_URL=https://bettertoken.ai를 사용하고 Base URL 뒤에/v1을 붙이지 않습니다. 클라이언트가/v1/messages를 추가합니다. Claude Code 연결 안내를 참고하세요.
같은 Dashboard, API Key, 모델 이름을 사용해도 세 프로토콜이 하나의 형식으로 바뀌지 않습니다. 기존 도구는 그 도구가 요구하는 프로토콜을 선택하세요. 자체 Agent는 이 글의 완전한 왕복과 검수 매트릭스를 기준으로 필요한 기능을 확인해야 합니다.
자주 묻는 질문
OpenAI-compatible은 OpenAI API를 완전히 복제했다는 뜻인가요?
아닙니다. 보통 일부 endpoints와 데이터 구조를 OpenAI 스타일 클라이언트에서 호출할 수 있다는 뜻입니다. 모델, 파라미터, 스트림 이벤트, 도구, 구조화 출력, 호스팅 도구, 오류 의미는 각각 확인해야 합니다.
Base URL과 API Key만 바꾸면 되나요?
같은 wire contract를 사용하는 단순 텍스트 요청이라면 가능할 때도 있습니다. 도구 Agent는 도구 정의, 두 번째 요청의 결과 반환, 스트림 이벤트, strict Schema, 상태, 오류를 계속 검증해야 합니다. 클라이언트가 Responses를 기대하면 /chat/completions만으로는 부족하고, Messages를 기대하면 OpenAI 스타일 endpoint가 자동으로 변환해 주지 않습니다.
하나의 범용 어댑터로 세 프로토콜을 모두 변환할 수 있나요?
일반 텍스트와 함수 도구 루프 일부는 처리할 수 있지만 전체 기능을 무손실 지원한다고 주장해서는 안 됩니다. provider-managed state, 호스팅 도구, 불투명한 thinking/reasoning 상태, 일부 system 범위, 모델 고유 기능에는 범용 동등물이 없는 경우가 많습니다. 어댑터는 기능 매트릭스와 축소 정보를 공개해야 합니다.
단위 테스트는 통과하는데 실제 Agent는 왜 실패하나요?
많은 테스트가 첫 모델 응답만 모의하고 두 번째 요청의 도구 결과 반환, 병렬 호출, 스트리밍 조각, 상태 이어가기를 확인하지 않습니다. 테스트를 “사용자 요청 → 모델 도구 호출 → 애플리케이션 실행 → 도구 결과 반환 → 최종 답변”까지 확장해야 실제 프로토콜 문제를 발견할 수 있습니다.
마이그레이션할 때 Chat Completions와 Responses 중 무엇을 먼저 바꿔야 하나요?
안정적인 Chat Completions 애플리케이션은 계속 운영하면서 비즈니스 가치에 따라 기능별로 옮길 수 있습니다. 새 OpenAI Agent이거나 타입이 있는 Items, 호스팅 도구, Responses 상태 기능이 명확히 필요하다면 처음부터 Responses를 사용하는 편이 적합합니다. 기준은 인터페이스 이름의 신구가 아니라 필요한 기능과 마이그레이션 비용입니다.