툴을 하나 만들었다. Claude가 주제를 받으면 write_article 툴을 호출하면서 body에 글 본문을 통째로 넣어 넘기는 구조였다. 그런데 스트리밍으로 받는데도 화면이 한참 비어 있었다. 본문이 수천 토큰이다 보니, 그게 다 생성되고 나서야 한꺼번에 툭 떨어졌다.
이유를 찾아보니 기본 동작이 그랬다. Claude는 툴 입력(tool_use의 input)을 완전한 JSON으로 다 만든 뒤 검증하고 나서 내보낸다. 텍스트 응답은 토큰 단위로 흘려주면서, 툴 인자만 버퍼링을 한 번 거친다. 인자가 짧으면 티가 안 나는데, 긴 본문을 인자에 담으면 그 시간이 고스란히 빈 화면으로 남는다.
베타 헤더 하나면 버퍼링이 사라진다
anthropic-beta: fine-grained-tool-streaming-2025-05-14 헤더를 붙이면 Claude가 툴 입력 JSON을 생성되는 대로 그대로 흘려준다. 검증을 건너뛰기 때문에 첫 조각이 바로 날아온다.
import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic();
const stream = await client.messages.create(
{
model: 'claude-sonnet-4-5',
max_tokens: 8192,
stream: true,
tools: [
{
name: 'write_article',
description: '주제를 받아 블로그 글 초안을 작성한다',
input_schema: {
type: 'object',
properties: {
title: { type: 'string' },
body: { type: 'string' },
},
required: ['title', 'body'],
},
},
],
tool_choice: { type: 'tool', name: 'write_article' },
messages: [{ role: 'user', content: 'Next.js App Router 캐싱에 대한 글 써줘' }],
},
{
headers: { 'anthropic-beta': 'fine-grained-tool-streaming-2025-05-14' },
},
);
let jsonBuffer = '';
for await (const event of stream) {
if (
event.type === 'content_block_delta' &&
event.delta.type === 'input_json_delta'
) {
jsonBuffer += event.delta.partial_json;
process.stdout.write(event.delta.partial_json); // 오는 대로 바로 화면에
}
}
이러면 body에 글이 채워지는 과정이 실시간으로 보인다. 긴 결과를 뱉는 툴일수록 체감 차이가 크다.
대신 책임은 내가 진다
편한 만큼 주의할 게 있다. 버퍼링과 검증을 건너뛴다는 건, 중간 조각이 깨진 JSON일 수 있다는 뜻이다. 그래서 오는 족족 JSON.parse를 돌리면 터진다. 조각은 화면에 흘리기만 하고, 파싱은 스트림이 끝난 뒤 한 번만 한다.
const toolInput = JSON.parse(jsonBuffer);
한 가지 더. max_tokens에 걸려 응답이 중간에 잘리면 JSON도 반쪽만 온다. 평소엔 서버가 잡아주던 걸 이제 내가 막아야 하니, 마지막 message_delta의 stop_reason이 max_tokens인지 확인하고, 그러면 파싱을 건너뛰거나 재시도하는 가드를 꼭 넣자.
정리하면, 긴 인자를 담는 툴에서 첫 응답 지연이 거슬린다면 이 베타가 답이다. 다만 "JSON 검증은 서버가 알아서" 해주던 안전장치가 사라지므로, 끝에서 한 번만 파싱 + stop_reason 체크, 이 두 가지를 세트로 챙기면 된다.