지난 글에서 호출 하나가 어긋나는 지점들을 봤다. 호출이 안정되고 나면 다음으로 눈에 띄는 것은 속도다. 사용자가 "다음 주 출장 준비 좀 도와줘"라고 하면 날씨·환율·일정 세 가지를 봐야 하는데, 이걸 하나씩 부르면 응답이 눈에 띄게 늦다. 세 조회가 서로를 전혀 참조하지 않는데도 그렇다.
순차 호출에서 실제로 소모되는 것
한 번에 도구 하나만 부르는 루프에서는 도구 대기 시간만 더해지는 게 아니다. 호출과 호출 사이마다 모델이 한 번 더 돌아 "다음에 뭘 부를까"를 정한다. 도구가 세 개면 모델 왕복도 네 번이다.
도구 하나가 400ms 걸리고 모델 왕복이 900ms라고 하면, 순차는 대략 900×4 + 400×3 = 4.8초다. 병렬로 묶으면 모델 왕복이 두 번으로 줄고 도구 대기는 가장 느린 하나만 남아 900×2 + 400 = 2.2초가 된다. 절반 이하다. 그리고 이 차이는 도구 개수가 늘수록 벌어진다.
줄어드는 항목을 정확히 적어 두면 이렇다.
은 모델 왕복 시간, 는 각 도구의 실행 시간이다. 여기서 읽어야 할 것은 모델 왕복 항이 에 비례하다가 상수가 된다는 점이다. 도구가 빠르고 모델이 느린 조합(대부분의 실제 시스템이 그렇다)에서는 이쪽이 더 큰 이득이다.
토큰 비용도 같이 준다. 순차 루프는 매 왕복마다 지금까지의 대화 전체를 다시 보내므로, 왕복 횟수가 줄면 재전송되는 프리픽스도 줄어든다.
묶어도 되는 호출과 안 되는 호출
병렬화의 전제는 하나다. 어떤 호출도 다른 호출의 결과를 인자로 쓰지 않을 것. 이걸 어기면 모델은 아직 존재하지 않는 값을 지어내서 인자에 넣는다. 조용히 틀린 답이 나오는 전형적인 경로다.
| 상황 | 병렬 가능 | 이유 |
|---|---|---|
| 도시 세 곳의 날씨 조회 | 가능 | 같은 도구, 독립된 인자 |
| 날씨 + 환율 + 일정 | 가능 | 서로의 출력을 안 본다 |
| 사용자 조회 → 그 사용자의 주문 조회 | 불가 | 뒤가 앞의 user_id를 쓴다 |
| 재고 확인 → 주문 생성 | 불가 | 순서 자체가 의미다 |
| 결제 승인 + 포인트 적립 | 불가 | 앞이 실패하면 뒤를 하면 안 된다 |
마지막 줄이 미묘하다. 데이터 의존성은 없지만 실패 의존성이 있다. 병렬로 던지면 결제가 실패해도 적립은 이미 나가 있다. 이런 조합은 데이터가 독립이어도 순차로 두어야 한다.
실무에서 쓰기 좋은 판단 기준은 이것이다 — 읽기끼리는 묶고, 쓰기는 묶지 않는다. 조회성 도구는 부작용이 없어 중복 실행이나 순서 뒤바뀜이 문제가 되지 않는다. 상태를 바꾸는 도구는 반대다.
실행기가 책임져야 하는 것들
모델이 한 응답에 도구 호출 세 개를 담아 보내면, 그다음은 전부 우리 코드의 일이다. 세 가지를 반드시 처리해야 한다.
import asyncio
WRITE_TOOLS = {"create_order", "cancel_order", "send_email"}
async def run_tool_calls(calls, timeout=8.0):
# 1) 상태를 바꾸는 도구가 섞여 있으면 병렬로 돌리지 않는다.
if any(c.name in WRITE_TOOLS for c in calls):
return [await run_one(c, timeout) for c in calls]
results = await asyncio.gather(
*(run_one(c, timeout) for c in calls),
return_exceptions=True, # 2) 하나가 터져도 나머지를 살린다
)
# 3) 순서는 호출 순서 그대로 유지한다. 완료 순서가 아니다.
return [to_tool_result(c, r) for c, r in zip(calls, results)]
async def run_one(call, timeout):
async with asyncio.timeout(timeout):
return await TOOLS[call.name](**call.arguments)
세 주석이 각각 실제 사고에 대응한다.
부분 실패. return_exceptions=True가 없으면 세 조회 중 하나가 타임아웃 났을 때 나머지 두 개의 성공 결과까지 버려진다. 모델은 셋 다 실패한 것으로 알고 전부 다시 부른다. 실패한 하나만 error로 표시해 돌려주면 모델은 그 하나만 재시도하거나, 나머지 둘로 답할 수 있는지 판단한다.
결과 순서. 완료된 순서대로 결과를 붙이면 모델이 받은 tool_call_id와 내용이 어긋난다. 대부분의 API는 호출 id로 짝을 맞추므로 조용히 틀리지는 않지만, id 없이 순서로만 맞추는 코드를 직접 짰다면 여기서 답이 뒤바뀐다. 완료 순서가 아니라 요청 순서로 정렬한다.
동시성 상한. 위 코드에는 없지만 실제로는 필요하다. 모델이 한 번에 열두 개를 부르는 일이 있고, 그게 그대로 외부 API로 나가면 레이트리밋에 걸린다. asyncio.Semaphore로 4~6개 정도로 묶어 두는 편이 안전하다.
병렬로 부르게 만드는 쪽
모델이 병렬 호출을 지원해도 실제로 묶어 부를지는 별개다. 잘 안 묶는 경우, 원인은 보통 셋 중 하나다.
첫째, 도구 설명문이 순서를 암시한다. "사용자를 조회한 뒤 주문을 조회합니다" 같은 문장이 다른 도구의 설명에 들어 있으면 모델은 전체를 순차 작업으로 읽는다.
둘째, 도구가 지나치게 잘게 쪼개져 있다. get_weather_temp, get_weather_humidity처럼 나눠 두면 모델은 이걸 한 덩어리 작업으로 인식해 순서대로 부른다. 같이 쓰이는 값은 한 도구가 함께 반환하는 편이 낫다.
셋째, 시스템 프롬프트가 단계적 사고를 강하게 요구한다. "한 번에 하나씩 신중하게 진행하세요" 같은 지시는 병렬 호출과 정면으로 부딪친다.
반대로 밀어 주는 것은 간단하다. 시스템 프롬프트에 한 문장이면 된다 — "서로의 결과가 필요하지 않은 조회는 한 번에 함께 요청한다." 다만 이 문장을 넣기 전에 실행기가 부분 실패와 동시성 상한을 처리하는지부터 확인해야 한다. 준비 안 된 실행기에 병렬 호출을 밀어 넣으면 지연 시간 대신 장애가 줄어든다.
언제 하지 말아야 하는가
도구가 한두 개뿐이면 이득이 거의 없다. 모델 왕복이 한 번 줄 뿐인데 실행기 복잡도는 확실히 는다. 외부 API가 초당 요청 수에 민감하거나 호출당 과금이 큰 경우도 마찬가지다 — 병렬로 던져 놓고 절반을 안 쓰게 되면 비용만 는다.
병렬 호출이 확실히 값을 하는 자리는 독립된 읽기 도구가 셋 이상 있고, 사용자가 응답을 기다리는 화면이다. 그 조건이 아니면 순차로 두고 다른 걸 최적화하는 편이 낫다.
읽어주셔서 감사합니다. 😊

