당신의 에이전트들은 괜찮습니다. 문제는 에이전트 간의 핸드오프(Handoff)입니다.
요약
멀티 에이전트 시스템 구축 시 에이전트 자체보다 에이전트 간의 '핸드오프(Handoff)' 관리가 프로덕션 환경의 핵심임을 강조합니다. 스키마 정렬, 실패 전파, 컨텍스트 위생을 통해 안정적인 에이전트 체인을 설계하는 방법을 다룹니다.
핵심 포인트
- 에이전트 간 출력 형식을 맞추는 스키마 정렬이 필수적임
- 한 에이전트의 실패가 전체 체인에 인지되도록 실패 전파 설계 필요
- 컨텍스트 윈도우 내 노이즈 축적을 방지하는 위생 관리가 중요함
- Pydantic 등을 활용한 명시적 계약(Contract) 기반의 데이터 전달 권장
여러분이 보게 될 대부분의 멀티 에이전트 (multi-agent) 데모는 코스튬을 입은 단일 에이전트 (single-agent) 아키텍처에 불과합니다.
그들은 에이전트 A가 무언가를 하고, 그다음 에이전트 B가 다른 무언가를 하는 모습을 보여줍니다. 하지만 그들이 보여주지 않는 것은 에이전트 A의 출력이 에이전트 B가 기대하는 것과 일치하지 않을 때, 또는 핸드오프 (handoff)가 조용히 실패하여 전체 체인이 아무 일도 없었던 것처럼 계속 실행될 때 어떤 일이 발생하는가 하는 점입니다.
저는 올해 프로덕션 환경에서 세 개의 멀티 에이전트 시스템을 출시했습니다. 에이전트 자체는 결코 어려운 부분이 아니었습니다. 핸드오프 (handoff)가 문제였습니다.
실무에서 "핸드오프 (Handoff)"가 실제로 의미하는 것
핸드오프 (handoff)는 단순히 한 에이전트의 출력을 다른 에이전트로 전달하는 것이 아닙니다. 그것은 다음과 같습니다:
- 스키마 정렬 (Schema alignment) — 에이전트 B가 에이전트 A의 출력을 안정적으로 파싱 (parse)할 수 있어야 함
- 실패 전파 (Failure propagation) — 한 에이전트가 실패했을 때, 체인이 이를 인지해야 함
- 컨텍스트 윈도우 위생 (Context window hygiene) — 모든 핸드오프 (handoff)는 노이즈가 축적될 수 있는 기회임
가장 흔한 실수는 에이전트들을 실로 연결된 블랙박스 (black boxes)로 취급하는 것입니다. 에이전트 A에게 프롬프트 (prompt)를 주고, 결과를 얻은 다음, 그것을 에이전트 B에 집어넣습니다. 이것은 작동할 때는 작동하지만, 작동하지 않게 되는 순간 어디서 문제가 생겼는지 알 수 없게 됩니다.
여기 구체적인 예가 있습니다. 일반적인 패턴은 다음과 같습니다: 플래너 에이전트 (planner agent)가 작업을 분해하면, 일련의 워커 에이전트 (worker agents)들이 하위 작업들을 병렬로 실행합니다.
단순한 버전 (The naive version):
# 단순한 핸드오프 (handoff) — 계약(contract)도, 에러 핸들링(error handling)도 없음
planner_output = planner_agent.run(task)
worker_results = [worker.run(subtask) for subtask in planner_output["subtasks"]
...
이 방식은 프로덕션 환경에서 실패할 것입니다. 에이전트들이 나빠서가 아니라, planner_output["subtasks"]가 어떤 실행에서는 리스트 (list)였다가 다음 실행에서는 문자열 (string)이 될 수 있기 때문입니다. 또는 플래너가 {"subtasks": []}를 반환하여 워커들이 조용히 아무것도 하지 않을 수도 있습니다. 혹은 워커가 예외 (exception)를 발생시켰는데 전체 시스템이 이를 그냥 삼켜버릴 수도 있습니다.
명시적 계약 버전 (The explicit contract version):
from pydantic import BaseModel
from typing import Optional
...
이제 무언가 깨지더라도, 어떤 작업의 어떤 에이전트에서 무엇이 잘못되었는지 정확히 알 수 있습니다.
아무도 말하지 않는 세 가지 실패 모드 (Failure Modes)
1. 조용한 잘림 (Silent truncation). 에이전트 A가 2,000개의 토큰을 생성합니다. 에이전트 B의 컨텍스트 윈도우 (Context window)는 128k이지만, 워커 (Worker)에 4k 예산을 설정한 시스템을 실행 중입니다. 출력 결과가 조용히 잘려 나갑니다. 에이전트 B는 부분적인 결과를 처리하고 확신에 찬 헛소리 (Nonsense)를 반환합니다. 해결책: 모든 핸드오프 (Handoff) 시점에 실제 토큰 수를 측정하고, 예산을 초과하면 명시적으로 실패 처리하십시오.
2. 스키마 드리프트 (Schema drift). 플래너 (Planner) 프롬프트가 약간 변경되었습니다. 이제 문단 대신 reasoning을 단일 단어로 반환합니다. 에이전트 B는 reasoning에 대해 문자열 매칭 (String matching)을 수행하고 있었습니다. 해결책: 프롬프트가 아닌, 모든 곳에서 구조화된 출력 (Structured output, 예: Pydantic, JSON schema)을 사용하십시오.
3. 병렬 에이전트 경합 조건 (Parallel agent race conditions). 5개의 워커를 병렬로 실행합니다. 3개는 완료되었고, 2개는 여전히 실행 중입니다. 어그리게이터 (Aggregator)가 처리를 시작합니다. 어그리게이터는 부분적인 결과를 받아 반환합니다. 이는 작은 워크로드로 테스트할 때는 잘 작동하다가, 실제 지연 시간 (Latency)이 발생하는 프로덕션 환경에서 실패하기 때문에 특히 까다롭습니다. 해결책: 배리어 (Barrier, 예: return_exceptions=False를 사용한 asyncio.gather 또는 모든 결과를 기다리거나 빠르게 실패하는 결과 수집기)를 사용하십시오.
실제로 작동하는 최소한의 프로덕션 패턴
세 가지 실패 모드를 모두 겪은 후, 저는 다음과 같은 구조로 정착했습니다:
import asyncio
from dataclasses import dataclass
from enum import Enum
...
이것은 우아하지 않습니다. 장황하고 명시적입니다. 바로 그것이 핵심입니다.
내가 배운 것들
내가 처음 만든 멀티 에이전트 시스템 (Multi-agent system)은 영리해 보였습니다. 동적 라우팅 (Dynamic routing), 컨텍스트 인식 에이전트 선택 (Context-aware agent selection), 그리고 에이전트 이름에 기반한 암시적 핸드오프 (Implicit handoffs)를 사용했습니다. 새벽 3시에 50개의 동시 작업 (Concurrent tasks)으로 실행하기 전까지는 아주 잘 작동했습니다. 하지만 깨어났을 때 마주한 것은 부분적인 결과와 조용한 실패들이 뒤섞인 난장판이었습니다.
두 번째 버전은 투박했지만 정확했습니다. 모든 핸드오프는 타입이 지정된 계약 (Typed contract)이었고, 모든 실패는 명시적이었으며, 모든 에이전트는 격리되어 있었습니다.
세 번째 버전 — 즉 현재 버전 — 은 두 번째 버전의 규율(discipline) 위에 첫 번째 버전의 우아함(elegance)을 쌓아 올린 것입니다. 에이전트들은 여전히 구조화된 출력 (structured output)을 사용합니다. 핸드오프 (handoff)에는 여전히 메타데이터 (metadata)가 포함됩니다. 하지만 실제 에이전트 로직이 깔끔하게 유지될 수 있도록 얇은 프레임워크 (framework) 뒤로 상용구 (boilerplate)를 숨겼습니다.
만약 멀티 에이전트 시스템 (multi-agent systems)을 구축하고 있다면: '보기 흉하지만 정확한 (ugly-correct)' 버전부터 시작하세요. 먼저 프로덕션 (production) 환경에서 틀려보세요. 그런 다음 우아하게 만드세요.
핸드오프 문제는 더 쉬워지지 않습니다. 다만, 더 이상 그 문제에 당황하지 않게 될 뿐입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기