posts within custom post type all share the same content in the front-end

You are querying the entire posts of doctors type, and setting the number of posts per page to one. This will obviously always return the same post.

You need to either set the id of the post, or navigate through the loop to find a match. If you are calling this function in single post page, to filter the results based on post id, you should use the function this way:

function doctor_banner_shortcode($doctor_id) {
$args = array(
    'posts_per_page'    => 1,
    'post_type'     => 'doctors',
    'post_status'       => 'publish',
    'p'        => $doctor_id
);
$doctors_query = new WP_Query( $args );
if ( $doctors_query->have_posts() ) :
    while ( $doctors_query->have_posts() ) :
        $doctors_query->the_post();

        $dr_name = get_field( 'practitioner_name' );
        $dr_degree = get_field( 'practitioner_title' );
        $dr_bio = get_field( 'practitioner_short_bio' );
        $dr_cv = get_field( 'practitioner_cv' );

        $html_out="<h1>Dr. " . $dr_name . '</h1>
                     <h3">' . $dr_degree . '</h3>
                     <p>' . $dr_bio . '</p>
                     <a href="' . $dr_cv . '">' . 'Read CV' . '</a>';
    endwhile;
else : // No results
    $html_out = "No Doctors Found.";
endif;
wp_reset_query();
return $html_out;
}

add_shortcode( 'doctor_banner', 'doctor_banner_shortcode' );

Then you can call your function in the single.php template in this way :
function doctor_banner_shortcode($post->ID)

If you want to call the function manually in some other page, you should set the ID while calling the function.