병렬 도구 호출과 도구 스트리밍(Parallel Tool Calls and Streaming with Tools)

독립적인 날씨 조회 세 개를 직렬로 실행하면 왕복이 세 번 발생합니다. 병렬로 실행하면 전체 시간은 가장 느린 단일 호출 수준으로 줄어듭니다. 이제 최전선의 모든 공급자(provider)는 한 차례(turn) 안에서 여러 도구 호출을 동시에 내보냅니다. 이득은 분명하지만 그 뒤를 받치는 배관(plumbing)은 미묘합니다. 이 강의에서는 두 축을 모두 다룹니다. 병렬 분산 호출(fan-out)과 스트리밍 인자(arguments) 재조립이며, 특히 식별자 연결(id correlation) 함정에 초점을 둡니다.

유형: Build 언어: Python (표준 라이브러리, 스레드 풀(thread pool) + 스트리밍 하니스(streaming harness)) 선수 지식: Phase 13 · 02 (함수 호출(function calling) 심화) 예상 시간: 약 75분

학습 목표

  • parallel_tool_calls: true가 왜 존재하고 언제 비활성화해야 하는지 설명합니다.
  • 병렬 분산 호출 중 스트리밍으로 도착하는 인자 조각(chunk)을 올바른 도구 호출 식별자(tool-call id)와 연결합니다.
  • 부분 arguments 문자열을 너무 일찍 파싱하지 않고 완전한 JSON으로 재조립합니다.
  • 세 도시 날씨 벤치마크(benchmark)를 실행해 순차 실행과 병렬 실행의 지연 시간 차이를 확인합니다.

문제

병렬 호출이 없으면 "Bengaluru, Tokyo, Zurich 날씨가 어때?"에 답하는 에이전트는 다음처럼 동작합니다.

user -> LLM
LLM -> call get_weather(Bengaluru)
host -> executor 실행, 결과 반환
LLM -> call get_weather(Tokyo)
host -> executor 실행, 결과 반환
LLM -> call get_weather(Zurich)
host -> executor 실행, 결과 반환
LLM -> 최종 텍스트 답변

LLM 왕복이 세 번이고, 각 왕복마다 실행기(executor) 지연(latency)까지 더해집니다. 이상적인 실측 시간(wall-clock)보다 대략 4배 느린 셈입니다.

병렬 호출을 쓰면 다음처럼 됩니다.

user -> LLM
LLM -> call get_weather(Bengaluru); call get_weather(Tokyo); call get_weather(Zurich)
host -> 세 executor를 동시에 실행하고, 세 결과를 반환
LLM -> 최종 텍스트 답변

LLM 왕복은 한 번입니다. 실행기 시간은 세 호출의 합이 아니라 최댓값입니다. OpenAI, Anthropic, Gemini의 실제 운영 환경 벤치마크에서는 분산 호출 작업 부하(workload)의 실측 시간이 60~70% 줄어든다고 보고됩니다.

대가는 호출 결과를 짝지어 주는 식별자 연결의 복잡도입니다. 세 호출이 순서 없이 끝나면 결과는 반드시 일치하는 tool_call_id를 포함해야 모델이 줄을 맞출 수 있습니다. 결과가 스트리밍될 때는 부분적으로 도착한 인자 조각을 완전한 JSON으로 조립한 뒤 실행해야 합니다. Gemini 3가 호출마다 고유한 식별자(unique id)를 추가한 배경 중 하나도, 같은 도구를 두 번 병렬 호출했을 때 두 호출을 구분할 수 없었던 현실적 문제를 해결하기 위함이었습니다.

사전 테스트

2문제 · 이 강의를 시작하기 전에 얼마나 알고 있는지 확인해보세요

1.병렬 스트리밍 도구 호출가 해결하는 핵심 과제는?

2.병렬 스트리밍 도구 호출 이전의 주요 한계는?

0/2 답변 완료

개념

병렬 활성화

  • OpenAI. parallel_tool_calls: true가 기본으로 켜져 있습니다. false로 설정하면 직렬 호출을 강제합니다.
  • Anthropic. disable_parallel_tool_use: false로 병렬을 사용합니다. Claude 3.5 이상에서는 기본값입니다. true로 설정하면 직렬입니다.
  • Gemini. 항상 병렬이 가능합니다. tool_config.function_calling_config.mode = "AUTO"로 두면 모델이 스스로 결정합니다.

도구 사이에 순서 의존성이 있다면 병렬을 끕니다. 예를 들어 create_file 다음에 write_file을 호출해야 하는 경우입니다. 한 호출의 출력이 다른 호출의 입력이 되거나, 요청 제한기(rate limiter)가 분산 호출량을 감당하지 못할 때도 병렬을 비활성화합니다.

식별자 연결(Id correlation)

모델이 내보내는 모든 호출에는 id가 있습니다. 호스트가 반환하는 모든 결과는 같은 id를 포함해야 합니다. 그렇지 않으면 결과의 짝이 모호해집니다.

  • OpenAI. 각 tool 역할 메시지의 tool_call_id.
  • Anthropic.tool_result 블록(block)의 tool_use_id.
  • Gemini.functionResponseid. Gemini 3 이상에서 적용됩니다. Gemini 2는 이름(name)으로 매칭했는데, 같은 이름을 가진 병렬 호출에서는 이 방식이 깨졌습니다.

호출을 동시에 실행하기

호스트는 각 호출의 실행기를 별도 스레드(thread), 코루틴(coroutine), 또는 원격 작업자(remote worker)에서 실행합니다. 가장 단순한 하니스는 스레드 풀을 씁니다. 운영 환경에서는 asyncio.gather나 구조적 동시성(structured concurrency)을 사용합니다. 완료 순서는 예측할 수 없습니다. 호출을 식별하는 단서는 오직 id뿐입니다.

흔한 버그는 결과를 완료 순서가 아니라 호출 목록 순서로 답하는 것입니다. 모델은 보통 tool_call_id만 확인하기 때문에 그래도 동작합니다. 그러나 결과가 빠지거나 중복되면, 순서가 어긋난 채로 제출하는 방식은 디버깅을 어렵게 만듭니다. 명시적 id와 함께 완료 순서로 답하는 편이 더 낫습니다.

스트리밍 도구 호출

모델이 스트리밍하면 arguments가 조각으로 도착합니다. 병렬 호출 세 개가 있으면 세 호출의 조각 스트림이 동일한 연결(wire) 위에서 서로 섞여 흐릅니다. id마다 하나의 누적기(accumulator)가 필요합니다.

공급자별 형식(shape)은 다음과 같습니다.

  • OpenAI. 각 조각은 choices[0].delta.tool_calls[i].function.arguments의 부분 문자열입니다. 조각에는 호출 목록 안에서의 위치를 나타내는 index가 있습니다. index별로 누적하고, id가 처음 나타날 때 읽으며, finish_reason = "tool_calls"가 오면 JSON을 파싱합니다.
  • Anthropic. 스트림 이벤트는 message_start로 시작하며, tool_use 타입의 content_block_start가 블록마다 한 번씩 나오고 id, name, 빈 input을 포함합니다. content_block_delta 이벤트는 input_json_delta 조각을 담습니다. content_block_stop이 각 블록을 닫습니다.
  • Gemini. Gemini 3 이상의 streamFunctionCallArgumentsfunctionCallId와 함께 조각을 내보내므로 호출이 섞여도 깔끔하게 처리됩니다. Gemini 3 이전 버전에서는 스트리밍이 완성된 호출 하나씩 돌아왔습니다.

부분 JSON과 너무 일찍 파싱하는 함정(parse-early trap)

arguments가 완성되기 전에는 파싱할 수 없습니다. {"city": "Beng 같은 부분 JSON은 유효하지 않으며 예외를 발생시킵니다. 올바른 관문(gate)은 공급자가 보내는 호출 종료 신호입니다. OpenAI는 finish_reason = "tool_calls", Anthropic은 content_block_stop, Gemini는 스트림 종료 이벤트가 그 역할을 합니다. 그때에 한해 json.loads를 시도합니다. 더 견고한 방법은 구조가 완성될 때마다 이벤트를 내보내는 점진적 JSON 파서(incremental JSON parser)를 사용하는 것입니다. OpenAI 스트리밍 가이드는 실시간 "thinking" 표시기 같은 사용자 경험에 이런 방식을 권장합니다. 중괄호 개수 세기(brace counting)는 따옴표로 감싼 문자열이나 이스케이프된 내용 안의 중괄호 때문에 잘못된 양성 신호(false positive)를 만들 수 있으므로 완전성 검사로 사용하면 안 됩니다. 비공식적인 디버그 휴리스틱(heuristic) 정도로만 활용합니다.

완료 순서가 어긋난 응답(Out-of-order completion)

call_A: 빠른 API, 먼저 반환
call_B: 느린 API, 두 번째 반환
call_C: 중간 API, 세 번째 반환

호스트 응답은 여전히 id를 명시해야 합니다.

[{role: "tool", tool_call_id: "call_A", content: ...},
 {role: "tool", tool_call_id: "call_B", content: ...},
 {role: "tool", tool_call_id: "call_C", content: ...}]

OpenAI나 Anthropic에서는 응답 내부의 순서가 정확성(correctness)에 영향을 주지 않습니다. Gemini도 id만 맞으면 어떤 순서든 받아들입니다.

벤치마크: 순차와 병렬

code/main.py의 하니스는 400, 600, 800ms 지연을 가진 실행기 세 개를 모의(simulation)합니다. 순차 실행은 총 1800ms가 걸리고, 병렬 실행은 max(400, 600, 800) = 800ms가 걸립니다. 차이는 비례가 아니라 일정한 상수 폭이므로, 도구 수가 늘수록 절약 효과가 커집니다.

현실적인 주의 사항도 있습니다. 병렬 호출은 후방(downstream) API에 부하를 줍니다. 요청 제한이 있는 서비스에 10갈래 분산 호출을 보내면 실패합니다. Phase 13 · 17은 게이트웨이 수준의 역압(backpressure)을 다룹니다. 재시도 의미론(retry semantics)은 이후 단계에서 다룰 계획입니다.

스트리밍 분산 호출의 실측 시간

모델 자체가 스트리밍하면 모든 호출이 끝날 때까지 기다리지 않고, 어떤 호출의 인자가 완성되는 즉시 실행을 시작할 수 있습니다. OpenAI가 문서화한 최적화이지만 모든 SDK가 이를 노출하지는 않습니다. 이 강의의 하니스는 이 최적화를 수행합니다. 모의 스트림이 완전한 인자 객체를 내놓는 즉시 호스트가 해당 호출을 시작합니다.

사용해보기

code/main.py는 두 부분으로 구성됩니다. 첫 부분은 concurrent.futures.ThreadPoolExecutor를 사용해 세 개의 모의 날씨 호출을 순차와 병렬로 실행하고 실측 시간을 출력합니다. 두 번째 부분은 가짜 스트리밍 응답을 재생합니다. 세 병렬 호출의 arguments 조각이 하나의 스트림 위에서 섞여 도착하고, StreamAccumulator가 id별로 재조립합니다. LLM도 네트워크도 없이 재조립 논리(logic)만 다룹니다.

확인할 지점은 다음과 같습니다.

  • 순차 측정 시간은 약 1.8초입니다. 동일한 가짜 지연 조건에서 병렬 측정 시간은 약 0.8초입니다.
  • 누적기는 id별 버퍼(buffer)를 사용하며, 각 호출의 JSON이 완성될 때만 파싱하므로 조각이 섞여 도착해도 안전하게 처리합니다.
  • 실행기는 모든 스트림이 끝난 뒤가 아니라, 특정 id의 인자가 완성되는 즉시 시작됩니다.

산출물 만들기

이 강의는 outputs/skill-parallel-call-safety-check.md를 만듭니다. 도구 레지스트리(tool registry)가 주어지면 이 스킬(skill)은 어떤 도구가 병렬화에 안전한지, 어떤 도구가 순서 의존성을 가지는지, 어떤 도구가 후방 요청 제한(downstream rate limit)을 압도할 수 있는지 감사(audit)하고, 도구별 parallel_safe 플래그가 포함된 수정된 레지스트리를 반환합니다.

연습문제

  1. code/main.py를 실행하고 모의 지연 값을 바꿔 봅니다. 병렬 대 순차의 비율이 대략 max/sum에 가까운지 확인합니다. 실제 실행은 스레드 스케줄링, 직렬화(serialization), 하니스 오버헤드 때문에 이상값에서 조금 벗어납니다. 어떤 지연 분포(distribution)에서는 병렬화 효과가 미미해질까요?

  2. 누적기를 확장하여 "호출이 스트림 도중에 취소된" 경우를 처리합니다. 해당 버퍼를 버리고 cancelled 이벤트를 출력합니다. 어떤 공급자가 이 경우를 명시적으로 문서화하는지 확인해 봅니다. Anthropic의 content_block_stop 의미론과 OpenAI의 finish_reason: "length" 동작을 확인해 보세요.

  3. 스레드 풀을 asyncio.gather로 바꾸고 두 방식을 벤치마크합니다. 실행기가 실제 입출력 작업을 수행할 때만 비동기(async)의 낮은 문맥 교환 비용으로 작은 이득을 얻을 수 있습니다.

  4. 병렬화하면 안 되는 두 도구를 고릅니다. 예를 들어 create_file 다음에 write_file을 호출하는 경우입니다. 레지스트리에 ordering_dependency 그래프(graph)를 추가하고, 그 그래프를 기준으로 분산 호출을 차단(gate)합니다. 이것은 의존성을 인식하는 스케줄링의 최소 장치이며, 이후 에이전트 엔지니어링 단계에서 정식화(formalize)됩니다.

  5. OpenAI의 parallel-function-calling 절과 Anthropic의 disable_parallel_tool_use 문서를 읽고, Anthropic이 병렬 비활성화를 권장하는 현실적 도구 유형 하나를 찾아냅니다. 힌트: 같은 자원(resource)에 대한 결과가 누적되는 변경(consequential mutation)입니다.

핵심 용어

용어흔한 설명실제 의미
병렬 도구 호출(Parallel tool calls)"한 차례에 분산 호출"모델이 단일 어시스턴트 메시지 안에서 여러 도구 호출을 동시에 내보내는 것
parallel_tool_calls"OpenAI 플래그"다중 호출 방출을 켜거나 끄는 설정
disable_parallel_tool_use"Anthropic의 역(inverse) 플래그"옵트아웃(opt-out) 플래그. 기본값은 병렬 활성화
도구 호출 식별자(Tool call id)"결과를 짝짓는 손잡이"결과 메시지가 반드시 그대로 되돌려 줘야 하는 호출별 식별자
누적기(Accumulator)"스트림 버퍼"부분 arguments 조각을 id별로 모으는 문자열 버퍼
완료 순서 어긋남(Out-of-order completion)"빠른 것이 먼저 끝남"병렬 호출 완료 순서는 예측할 수 없으며 id가 결과를 연결해 주는 접착제 역할을 한다
의존성 그래프(Dependency graph)"순서 제약"어떤 도구의 출력이 다른 도구의 입력이 되는 관계. 병렬화할 수 없다
너무 일찍 파싱하는 함정(Parse-early trap)"JSON.parse 폭발"완성되지 않은 arguments 문자열을 파싱하려는 실수
streamFunctionCallArguments"Gemini 3 기능"호출별 고유 식별자가 붙은 스트리밍 인자 조각
완료 순서 응답(Completion-order reply)"모두 기다리지 않기"결과가 도착하는 순서대로 id를 붙여 반환하는 방식

더 읽을거리

실습 코드

이 강의의 실습 코드 1개

main
Code

산출물

이 강의에서 생성된 프롬프트, 스킬, 코드 산출물 1개

parallel-call-safety-check

Audit a tool registry for safe parallelization. Mark each tool parallel_safe, note ordering dependencies, and flag downstream rate-limit risk.

Skill

확인 문제

3문제 · 모두 맞추면 완료 표시가 가능합니다

1.프로덕션에서 병렬 스트리밍 도구 호출의 가장 중요한 설계 원칙은?

2.병렬 스트리밍 도구 호출가 올바른 선택이 아닌 경우는?

3.병렬 스트리밍 도구 호출는 AI 생태계에 어떻게 들어맞나요?

0/3 답변 완료

추가 문제 풀기

AI가 강의 내용을 바탕으로 새로운 문제를 생성합니다