에이전트·RAG

AGENT / 37번째 글

도구를 한꺼번에 부를 때

병렬 도구 호출이 무엇을 줄이고 무엇은 못 줄이는지, 병렬로 묶어도 되는 호출과 순서를 지켜야 하는 호출을 가르는 기준, 부분 실패와 결과 순서를 다루는 실행기 설계를 정리합니다.

PALDYN Team9 MIN READ

지난 글에서 호출 하나가 어긋나는 지점들을 봤다. 호출이 안정되고 나면 다음으로 눈에 띄는 것은 속도다. 사용자가 "다음 주 출장 준비 좀 도와줘"라고 하면 날씨·환율·일정 세 가지를 봐야 하는데, 이걸 하나씩 부르면 응답이 눈에 띄게 늦다. 세 조회가 서로를 전혀 참조하지 않는데도 그렇다.

순차 호출에서 실제로 소모되는 것

한 번에 도구 하나만 부르는 루프에서는 도구 대기 시간만 더해지는 게 아니다. 호출과 호출 사이마다 모델이 한 번 더 돌아 "다음에 뭘 부를까"를 정한다. 도구가 세 개면 모델 왕복도 네 번이다.

순차 호출과 병렬 호출의 시간 축

도구 하나가 400ms 걸리고 모델 왕복이 900ms라고 하면, 순차는 대략 900×4 + 400×3 = 4.8초다. 병렬로 묶으면 모델 왕복이 두 번으로 줄고 도구 대기는 가장 느린 하나만 남아 900×2 + 400 = 2.2초가 된다. 절반 이하다. 그리고 이 차이는 도구 개수가 늘수록 벌어진다.

줄어드는 항목을 정확히 적어 두면 이렇다.

T순차=(n+1) M+∑i=1ntiT병렬=2M+max⁡itiT_{\text{순차}} = (n+1)\,M + \sum_{i=1}^{n} t_i \qquad T_{\text{병렬}} = 2M + \max_{i} t_i

MM은 모델 왕복 시간, tit_i는 각 도구의 실행 시간이다. 여기서 읽어야 할 것은 모델 왕복 항이 nn에 비례하다가 상수가 된다는 점이다. 도구가 빠르고 모델이 느린 조합(대부분의 실제 시스템이 그렇다)에서는 이쪽이 더 큰 이득이다.

토큰 비용도 같이 준다. 순차 루프는 매 왕복마다 지금까지의 대화 전체를 다시 보내므로, 왕복 횟수가 줄면 재전송되는 프리픽스도 줄어든다.

묶어도 되는 호출과 안 되는 호출

병렬화의 전제는 하나다. 어떤 호출도 다른 호출의 결과를 인자로 쓰지 않을 것. 이걸 어기면 모델은 아직 존재하지 않는 값을 지어내서 인자에 넣는다. 조용히 틀린 답이 나오는 전형적인 경로다.

상황 병렬 가능 이유
도시 세 곳의 날씨 조회 가능 같은 도구, 독립된 인자
날씨 + 환율 + 일정 가능 서로의 출력을 안 본다
사용자 조회 → 그 사용자의 주문 조회 불가 뒤가 앞의 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가 초당 요청 수에 민감하거나 호출당 과금이 큰 경우도 마찬가지다 — 병렬로 던져 놓고 절반을 안 쓰게 되면 비용만 는다.

병렬 호출이 확실히 값을 하는 자리는 독립된 읽기 도구가 셋 이상 있고, 사용자가 응답을 기다리는 화면이다. 그 조건이 아니면 순차로 두고 다른 걸 최적화하는 편이 낫다.


읽어주셔서 감사합니다. 😊

LATEST

에이전트·RAG의 최신 글

에이전트·RAG2026.08.23

에이전트는 어디서 어긋나기 시작하는가

에이전트가 이상한 답을 낼 때 증상은 마지막에 보이지만 어긋난 자리는 그보다 앞입니다. 한 걸음이 무너지는 여섯 자리를 나누고, 증상에서 원인을 거슬러 찾는 방법과 자리별 처방을 정리합니다.

11 MIN
에이전트·RAG2026.08.23

에이전트가 무엇을 했는지 나중에 알 수 있게 만들기

에이전트는 같은 입력에도 다른 경로로 갑니다. 그래서 로그 몇 줄로는 왜 그렇게 됐는지 복원이 안 됩니다. 트레이스를 어떻게 나누고 구간마다 무엇을 붙이며 어떤 지표를 볼지 정리합니다.

11 MIN
에이전트·RAG2026.08.23

에이전트 비용은 걸음 수보다 빨리 늘어난다

걸음이 두 배면 비용은 두 배가 아니라 서너 배입니다. 왜 그렇게 되는지, 어디서 새는지, 상한을 몇 겹으로 어떻게 거는지와 실제로 효과가 큰 순서대로의 대응을 정리합니다.

13 MIN