CPN 한국어 자습서 · 러닝패스 2 / 4 — Building with the Claude API
도구 사용
Fine grained tool calling
도구 호출과 스트리밍을 함께 쓰면, 도구 인자가 만들어지는 동안 큰 지연 뒤에 한꺼번에 텍스트가 쏟아지곤 합니다. 그 이유(API의 JSON 검증 버퍼링)를 알아보고, 그 지연을 없애는 fine-grained tool calling 기능을 켜 봅니다.
Stephen Grider · Anthropic 기술 스태프
이번 영상에서는 도구 사용과 스트리밍을 결합합니다. 앞서 보았듯, 스트리밍을 켜고 Claude에 요청하면 처음 응답을 받은 뒤 여러 이벤트가 연달아 옵니다. 각 이벤트는 사용자에게 보여 줄 만한 텍스트 조각을 담고 있죠. 흔히 살펴야 하는 이벤트가 Content Block Delta입니다.
도구를 스트리밍과 함께 쓰려면 처리해야 할 이벤트가 하나 더 생깁니다 — input JSON 이벤트입니다. 이 객체에는 중요한 속성이 둘 있습니다. 첫째 partial_json — Claude가 도구에 보내려는 인자의 일부를 나타내는 JSON 조각. 둘째 snapshot — 지금까지 받은 모든 partial JSON 조각을 누적해 합친 것입니다.
이를 보여 주려고 003_tool_streaming 노트북을 준비했습니다(이 강의에 첨부). 이 노트북에는 수정된 run_conversation이 들어 있고, 새 chat_stream 함수로 Anthropic API에서 응답 스트림을 엽니다. 그 스트림의 각 chunk를 종류에 따라 처리하는데, input_json chunk를 받으면 지금은 그냥 출력만 합니다. 아래쪽에는 새 도구 save_article과 프롬프트를 준비해 두었습니다. 실행하면 응답이 chunk 단위로 빠르게 들어오고, 도구 호출 인자도 함께 보입니다.
사실 도구 스트리밍은 이게 거의 전부입니다 — 추가 이벤트 하나만 처리하면 됩니다. 다만 한 가지, 아주 특수한 기능을 더 소개하고 싶습니다. 노트북을 다시 돌려 보면 텍스트는 chunk 단위로 잘 나타나는데, 도구 호출 인자는 몇 초 기다려야 하고, 그러다 갑자기 거대한 덩어리가 한꺼번에 나타납니다. 이 큰 지연과 한꺼번에 쏟아지는 동작이 거슬릴 수 있는 경우(정밀한 UI 업데이트나 빠른 후처리)를 위해, 그 이유와 해결 기능을 설명합니다.
먼저 도구를 살짝 손봤습니다. 여전히 학술 논문을 생성하지만 입력 구조가 조금 다릅니다. 예시 입력을 보면 abstract(논문 요약 같은 짧은 문장)와 meta 객체가 있고, meta 안에는 word_count와 review(긴 리뷰)가 들어갑니다. 여기서 짚을 점 — 전체 큰 객체의 최상위 키(top-level key)는 abstract와 meta 둘뿐입니다. meta는 다시 하위 키-값을 가리키지만, 최상위 키는 abstract와 meta입니다. 이 점을 기억해 두세요.
이제 스트리밍 모드에서 도구 인자를 생성할 때 API 내부에서 무슨 일이 벌어지는지 봅니다. 요청이 API에 도착하면 Claude가 도구 입력이 될 JSON을 생성하기 시작합니다. JSON은 한 번에 만들어지지 않고 chunk 단위로 만들어집니다. 그런데 놀랍게도 API는 이 chunk들을 곧장 우리 서버로 돌려보내지 않고 잠시 붙잡아 둡니다. 이유는 Claude가 가끔 유효하지 않은 JSON을 생성할 수 있기 때문입니다. 그래서 API는 유효한 JSON을 보내려고 검증 단계를 거칩니다.
검증은 이렇게 동작합니다. 최상위 키는 abstract와 meta 둘. API는 전체 객체가 끝나길 기다리지 않고, 최상위 키-값 하나가 완성될 때까지 기다립니다. abstract의 경우, 그 값 문자열의 닫는 따옴표가 보일 때까지 기다립니다(예: chunk 4번). 그 닫는 따옴표가 보이면 키-값 하나가 생성된 것이므로, 그 키-값만 따로 떼어 우리가 준 JSON 스키마에 비춰 검증합니다. 유효하면, 원래의 각 chunk들을 그제야 우리 서버로 내려보냅니다. 주의 — 우리는 키-값 하나를 통째로가 아니라 개별 chunk들을 받습니다. 다만 버퍼링됐다가 거의 동시에 도착하는 것입니다.
그다음 meta 최상위 키도 같은 방식 — 전체가 완성될 때까지 기다리고, 검증하고, 개별 chunk들을 내려보냅니다. 그래서 스트리밍을 켜도 chunk들이 API에서 버퍼링되며 큰 멈춤이 생기고, 그러다 한꺼번에 큰 덩어리가 나타나는 것입니다.
이 동작이 괜찮을 수도 있습니다. 하지만 사용자에게 최대한 빨리 업데이트를 보여 주거나, 도구 처리를 최대한 빨리 시작하고 싶다면 이 멈춤이 거슬립니다. 그럴 때 쓰는 기능이 Fine-Grained Tool Calling입니다. 핵심은 단 하나 — JSON 검증 단계를 끕니다. 그러면 API는 chunk 몇 개를 모아 그대로 내려보내, 더 전통적인 스트리밍 출력을 보게 됩니다. 다시 강조하면, fine-grained를 켜면 JSON 검증이 꺼지므로 서버 코드는 유효하지 않은 JSON을 받을 수 있다고 가정하고 적절한 에러 처리를 해야 합니다.
노트북은 이미 fine-grained용으로 준비돼 있습니다. run_conversation 호출에 fine_grained 인자를 더해 켜면, 도구 호출에 이르렀을 때 훨씬 고전적인 스트리밍 — 조금씩 chunk 단위로 — 을 보게 됩니다. 예컨대 word_count 값이 중요하다면, fine-grained 없이는 뒤의 텍스트가 다 생성될 때까지 기다려야 word_count를 얻지만, 켜면 훨씬 빨리 얻습니다.
유효하지 않은 JSON이 나오면 무슨 일이 생길까요? 거의 확실히 invalid JSON을 만들어 내는 프롬프트를 붙이고, force tool call(tool_choice로 save_article을 항상 호출하도록 강제)을 더해 실행합니다. 처음엔 잘 가다가 곧 에러가 납니다. 이 프롬프트는 word_count 값을 undefined로 강제하는데, undefined는 JSON에서 유효한 값이 아닙니다(JSON의 대응값은 null). 그래서 파싱 에러가 나고, undefined 때문이라는 메시지를 보게 됩니다.
fine-grained가 아니었다면? 검증 단계가 동작합니다. 이때 API는 meta 객체를 넣긴 하는데, 객체가 아니라 그 전체를 문자열로 감싸 버립니다. 우리가 준 스키마는 meta가 word_count(number)·review(string)를 가진 객체여야 한다고 했는데, 이제 meta가 객체가 아닌 문자열이 되어 JSON 스펙을 사실상 따르지 않게 됩니다.
정리하면 — 도구 스트리밍 자체는 어렵지 않고, 기존 스트리밍 파이프라인에 쉽게 더할 수 있습니다. 기본 도구 스트리밍에서는 큰 최상위 키-값을 생성할 때 API 검증 단계 때문에 지연이 생깁니다. 그게 문제라면 fine-grained tool streaming을 켜 더 고전적인 스트리밍을 얻되, 검증 단계를 포기하는 것입니다.
이 장에서 배우는 것What you'll learn
약 9분스트리밍 + 도구 → 새 이벤트 input_json(partial_json · snapshot)을 처리
chat_stream + chunk 종류별 분기 — input_json이면 일단 출력
기본 스트리밍은 API가 JSON을 버퍼링·검증 → 큰 지연 뒤 덩어리로 도착
검증 단위는 최상위 키-값(abstract·meta) 하나씩 — 닫는 따옴표를 기다린다
fine-grained tool calling: 검증을 끄고 고전적 chunk 스트리밍을 얻음
대가 — 유효하지 않은 JSON이 올 수 있으니 에러 처리 필수
스트리밍을 켜고 도구를 쓰면 처리할 이벤트가 하나 늘어납니다 — input_json. 여기엔 partial_json(인자 JSON 조각)과 snapshot(지금까지의 누적)이 담깁니다. chat_stream으로 스트림을 열고, chunk 종류에 따라 분기만 더하면 됩니다.
def chat_stream( messages, system=None, temperature=1.0, stop_sequences=[], tools=None, tool_choice=None, betas=[], ): params = { "model": model, "max_tokens": 1000, "messages": messages, "temperature": temperature, "stop_sequences": stop_sequences, } if tool_choice: params["tool_choice"] = tool_choice if tools: params["tools"] = tools if system: params["system"] = system if betas: params["betas"] = betas # 스트리밍 응답을 연다 (beta 네임스페이스) return client.beta.messages.stream(**params)
스트림의 각 chunk를 종류별로 처리합니다. input_json chunk를 받으면 지금은 그냥 출력만 합니다.
def run_conversation(messages, tools=[], tool_choice=None, fine_grained=False): while True: with chat_stream( messages, tools=tools, betas=["fine-grained-tool-streaming-2025-05-14"] if fine_grained else [], tool_choice=tool_choice, ) as stream: for chunk in stream: if chunk.type == "text": print(chunk.text, end="") if chunk.type == "content_block_start": if chunk.content_block.type == "tool_use": print(f'\n>>> Tool Call: "{chunk.content_block.name}"') # -- 새 이벤트: 도구 인자 JSON 조각 -- if chunk.type == "input_json" and chunk.partial_json: print(chunk.partial_json, end="") if chunk.type == "content_block_stop": print("\n") response = stream.get_final_message() add_assistant_message(messages, response) if response.stop_reason != "tool_use": break tool_results = run_tools(response) add_user_message(messages, tool_results) if tool_choice: break return messages
텍스트는 chunk로 잘 흐르는데, 도구 인자는 몇 초 멈췄다가 한꺼번에 쏟아집니다. 이유는 API가 chunk를 곧장 보내지 않고 붙잡아 검증하기 때문입니다 — Claude가 가끔 유효하지 않은 JSON을 만들 수 있으니까요.
from anthropic.types import ToolParam save_article_schema = ToolParam({ "name": "save_article", "description": "Saves a scholarly journal article", "input_schema": { "type": "object", "properties": { "abstract": {"type": "string", "description": "..."}, "meta": { # 최상위 키 (객체) "type": "object", "properties": { "word_count": {"type": "integer"}, "review": {"type": "string"}, }, "required": ["word_count", "review"], }, }, "required": ["abstract", "meta"], # 최상위 키: abstract, meta }, })
검증은 전체 객체가 아니라 최상위 키-값 하나씩 일어납니다. 그래서 큰 키-값일수록 멈춤이 길고, 통과하면 그 키-값의 chunk들이 거의 동시에 쏟아집니다.
API는 전체 객체를 기다리지 않고 최상위 키-값 하나(예: abstract)가 끝날 때까지 — 그 값 문자열의 닫는 따옴표가 보일 때까지 — 기다린 뒤 그 조각만 스키마로 검증합니다. 통과하면 개별 chunk들을 그제야 내려보냅니다(통째로가 아니라 거의 동시에 도착).
최대한 빨리 UI를 갱신하거나 도구 인자를 일찍 받아 처리하고 싶다면, fine-grained tool calling을 켭니다. 핵심은 단 하나 — API의 JSON 검증을 끕니다. 베타 헤더 fine-grained-tool-streaming-2025-05-14를 betas로 넘기면 됩니다.
기본 모드는 abstract·meta가 통째로 멈췄다가 한꺼번에 도착합니다. fine-grained로 바꿔 다시 실행하면 word_count를 훨씬 일찍 볼 수 있습니다.
시연용 모형입니다. 핵심 차이 — 기본은 최상위 키-값 단위로 버퍼링(큰 지연 뒤 덩어리), fine-grained는 검증을 꺼 chunk가 도착하는 즉시 흘러나옵니다.
아래는 유효하지 않은 JSON이 나오는 경우입니다. word_count를 undefined로 강제하는 프롬프트에 tool_choice로 호출을 강제하고 fine-grained를 켜면, 검증이 꺼져 있어 invalid JSON이 그대로 흘러와 파싱 에러가 납니다.
# fine-grained 켜기 + (시연용) 도구 호출 강제 run_conversation( messages, tools=[save_article_schema], fine_grained=True, tool_choice={"type": "tool", "name": "save_article"}, )
>>> Tool Call: "save_article" {"abstract": "...", "meta": {"word_count": undefined ... # fine-grained ON: 검증이 꺼져 invalid JSON이 그대로 흘러옴 # undefined 는 JSON에서 유효하지 않음 (대응값은 null) Error: ... invalid JSON ... "word_count": undefined
fine-grained를 켜면 검증이 꺼지므로, 서버 코드는 유효하지 않은 JSON을 받을 수 있다고 가정하고 적절한 에러 처리를 해야 합니다. (검증이 살아 있으면 API는 invalid한 meta를 객체 대신 문자열로 감싸 스펙을 어기기도 합니다.)
input_json(partial_json·snapshot) 처리만 추가.fine-grained-tool-streaming-2025-05-14 베타로 켜면 검증을 꺼 고전적 chunk 스트리밍.Q1스트리밍 + 도구일 때 추가로 처리할 이벤트는?
도구 인자 JSON 조각이 input_json 이벤트로 옵니다. partial_json은 조각, snapshot은 누적분.
Q2기본 스트리밍에서 도구 인자가 큰 지연 뒤 덩어리로 오는 이유는?
유효한 JSON을 보내려고 키-값 하나가 끝날 때까지 모았다가 검증 후 방출합니다.
Q3fine-grained tool calling이 하는 일은?
검증을 꺼 고전적 스트리밍을 얻는 대신, invalid JSON을 받을 수 있어 에러 처리가 필요합니다.
도구를 직접 만들지 않고 Claude에 내장된 도구도 있습니다. 다음은 파일을 읽고 고치는 텍스트 편집 도구입니다. → 텍스트 편집 도구
전 코스는 계속 무료입니다. 등록하면 이 코스의 남은 76개 레슨을 끝까지 읽을 수 있습니다.
이미 등록하셨다면 그때 쓰신 이메일을 넣어 주세요.