동적 실행 엔진라우팅A2A설계

목적별 에이전트를 A2A 워커로 쪼갠 오케스트레이터

RAG, 대용량 첨부 검색, 표 검색을 에이전트마다 따로 만들던 것을, 잘게 나눈 워커를 오케스트레이터에 도구로 쥐여 주는 방식으로 바꿨다. 개발 클러스터 테스트에서 엉뚱한 워커를 고른 적은 없었고, 고칠 곳은 워커 단위로 갈렸다.

2026년 9월 23일13

A2A 를 붙이기 전에는 에이전트 하나에 여러 능력을 주려면 대화 흐름을 유한상태머신(FSM)으로 짰다. 흐름을 그리고, 조건절을 걸고, 파이썬 샌드박스에 로직을 복잡하게 넣어서 정해 둔 루프를 돌리는 방식이다. 이렇게 짜야 답을 잘하는지 저렇게 짜야 잘하는지 테스트를 많이 해야 해서 공수가 들었고, 사용자가 조금만 더 복잡하거나 예상하지 못한 질문을 퍼부으면 길을 잃고 헤매기 쉬웠다. 목적마다 에이전트도 따로 만들었다. RAG 에이전트, 대용량 첨부파일을 검색하는 에이전트, 표를 다루는 RAG 에이전트가 각각 있었다.

A2A 로 바꾸고 개발 클러스터에서 해 보니, 오케스트레이터 하나에 리트리버, 첨부파일 리트리버, 이미지 분석, 웹검색처럼 잘게 나눈 에이전트들을 도구로 쥐여 주는 것으로 그 일을 다 한다. 워커를 붙이거나 고치는 게 모듈 단위라 쉽고, 뭔가 잘못되면 어느 에이전트에서 잘못됐는지 따라가서 거기만 고치면 된다. 지금은 개발 클러스터에서 PoC 를 검증하고 다른 파트들을 개발하는 단계로, 운영 반영 전이다.

이 글은 그 PoC 기간인 9월 18일부터 22일까지의 기록이다. 오케스트레이터 두 개로 보낸 대화가 테스트 기록에 77건 남아 있다.

흐름 대신 카드 한 장

질문매 턴 조회도구 목록 · tool callSendMessage사용자오케스트레이터sandbox code모델 게이트웨이LLM카탈로그Agent Card N장워커들A2A SendMessage

노드에 커서를 올리면 연결과 설명이 나타납니다.

오케스트레이터는 매 턴 카탈로그에서 자기 워커들의 Agent Card 를 받아 LLM 도구로 바꾼다. 도구 설명은 카드 이름. 스킬 설명 사용 판단: when_to_use 예: 예시1 / 예시2 … 한 줄이고, 인자는 처음에는 query 문자열 하나였다. LLM 이 도구를 고르면 그 워커에 A2A SendMessage 를 보내고 결과를 tool result 로 돌려준다. 유한상태머신의 흐름과 조건절이 하던 일을 이제 LLM 이 카드를 읽고 한다. 워커를 새로 붙여도 오케스트레이터는 고칠 게 없고, 다음 턴부터 카탈로그에 잡힌다.

그러니 워커를 서로 구분할 근거는 카드 문구뿐이다. 카드를 등록할 때 스킬 설명이나 when_to_use 가 비어 있거나 예시가 세 개 미만이면 저장을 거절하게 해 둔 것도 그래서였고, 설계 문서에는 이렇게 적어 뒀다.

A2A 의 실질 리스크는 기술이 아니라 카드 품질이다. 품질 미달은 에러가 아니라 "아무도 호출하지 않음"으로 나타난다.

이 기간의 오케스트레이터는 나중에 대화 서비스에 들어갈 도구 루프를 샌드박스 코드로 먼저 짠 것이었다. 모델 게이트웨이를 직접 부르고 대화 서비스를 거치지 않는다. 이 차이는 첫날 밤에 문제가 된다.

첫날, 워커 네 개

첫 오케스트레이터에는 웹검색, 코딩, 질의 전처리, 이미지생성 워커를 붙였다. "오늘 삼성전자 주가 알려줘"를 보내니 LLM 은 웹검색을 골랐는데 처음에는 답이 비어서 왔다. 워커 결과를 LLM 에 돌려주는 게이트웨이 호출이 400 이었고(tool_result 앞에 짝이 되는 tool_use 가 없었다), 그걸 고치자 이번엔 샌드박스는 답을 흘렸는데 화면에는 한 글자도 안 남았다. 그것까지 고치고 나서야 261,000원이 나왔다.

"오늘 삼성전자 주가를 웹에서 찾고, 그 종가에서 15% 오르면 얼마인지 코드로 계산해줘"도 보냈다. LLM 은 웹검색 다음에 코딩 워커를 불렀는데 답이 사라졌다. 실행 엔진이 호출자의 요청 ID 를 워커에게 그대로 넘겼고, 대화 서비스가 그 ID 로 스트림을 잡다 보니 워커가 끝나는 순간 오케스트레이터의 스트림까지 닫혔다. 채널 키를 요청 ID 로 잡았다가 생긴 문제와 뿌리가 같은 문제로, 이 엔진에서는 요청 ID 를 그대로 물려줬다가 이미 겪은 적이 있었다. 워커마다 새 ID 를 발급하게 고친 뒤 300,150원이 나왔다. 웹검색에서 받은 261,000원을 LLM 이 코딩 워커에게 보낼 질문에 스스로 넣은 결과다.

병렬 호출도 확인했다. 주가를 알려 주고 그와 별개로 고래 그림을 그려 달라고 하니 두 워커가 한 라운드에 같이 불렸다. 웹검색 23.6초, 이미지생성 41.7초였고 전체는 42.5초였다. 비교할 65.3초는 두 값을 더한 것이지 실제로 순서대로 돌려 본 값이 아니고, 완료 시각은 3초 간격으로 폴링해서 잡았으니 최대 3초 늦을 수 있다.

그날 밤 멀티턴에서 처음으로 워커를 안 불렀다. 1턴에 주가를 받고 2턴에 "방금 알려준 그 주가에서 15% 오르면 얼마야? 코드로 계산해줘"라고 하니, 코딩 워커를 부르지 않고 주가가 얼마냐고 되물었다. 에이전트는 처음에 워커 쪽 contextId 가 매 턴 초기화되는 것으로 봤다가, 확인해 보고 오케스트레이터 자신에게 대화 이력이 없는 것으로 정정했다. 샌드박스가 게이트웨이를 직접 부르니 대화 서비스가 평소에 붙여 주던 이력이 없었던 것이다. 그 사이 나는 워커들도 이력을 받는지 물었다.

하위에이전트들이 히스토리를 받아서 처리하나? (…) 안쓰면 파이썬 샌드박스코드로 history 를 따로 기존 다른 시나리처럼 구현해뒀어야 이전 맥락 기억할텐데

에이전트가 워커별로 확인해 보고 그 지적이 맞다고 했다. 오케스트레이터에 이력 조회를 넣은 뒤로는 비슷한 2턴 질문(15%, 20%, 30%, 25%)이 모두 코딩 워커로 갔다. 카드와 프롬프트는 건드리지 않았다.

사내 문서 검색을 워커로 나누기

9월 21일에는 사내 문서 검색용 오케스트레이터를 새로 만들었다. 기존에 RAG 에이전트 하나가 하던 일을 워커로 나눠 붙이는 시험이었다. 리트리버, 인덱스 카탈로그, 웹검색 세 개로 시작해서 다음 날까지 일곱 개로 늘었다.

인덱스 카탈로그는 사내 벡터 인덱스 가운데 질문에 맞을 것을 골라 주는 워커다. 인덱스가 수백 개라 어느 걸 봐야 할지 모르니, 인덱스 목록을 가져오는 에이전트를 따로 두면 되겠다고 생각해서 만들었다. 처음에는 LLM 이 붙어 있어서 인덱스를 최대 다섯 개 골라 줬고, 리트리버는 그 이름을 받아 검색했다.

처음 드러난 차이는 출처였다. 기존 RAG 단일 에이전트들은 답과 함께 출처를 보여 주는데 이 오케스트레이터는 출처를 안 보여 줬다. 리트리버가 돌려준 패시지 id 를 오케스트레이터가 화면용 번호로 다시 매기고 있었고, 실제 id 를 그대로 쓰게 해서 맞췄다.

다음은 사내 문서 내용과 서울 날씨를 같이 물은 질문이었다. 인덱스 카탈로그가 맞는 인덱스를 못 찾자 오케스트레이터는 리트리버를 부르지 않은 채 "사내 문서에서 확인하지 못했습니다"라고 답했다. 카탈로그 카드에는 "어느 인덱스를 봐야 할지 모를 때 리트리버보다 먼저 씁니다"라고 적혀 있었다. 카드대로 먼저 불렀고, 빈손이 오자 거기서 멈춘 것이다. 에이전트가 오케스트레이터 프롬프트에 카탈로그가 못 찾았다고 해도 리트리버를 인덱스 없이라도 불러 보라는 문장을 넣었고, 바로 다음 질문부터 정답이 나왔다. 카드는 그대로 뒀으니 이때부터 카드와 프롬프트가 서로 반대 방향을 가리키게 됐다.

그날 오후에는 워커 역할을 계속 다시 나눴다. 리트리버는 검색 엔진에서 패시지만 가져오는 워커라고 생각하고 있었는데, 확인해 보니 인덱스를 안 정하면 기본 인덱스만 뒤지게 되어 있었다. 범용 워커라면 인덱스를 필수로 받아야 한다고 봤고 그렇게 바꿨다. 그러자 카드와 실제 동작이 어긋났다. 카드에는 여전히 "인덱스를 지정하지 않으면 기본 인덱스만 검색"이라고 적혀 있었고, 오케스트레이터는 리트리버를 인덱스 없이 불렀다. 프롬프트가 query 에 JSON 문자열을 넣으라고 했는데 LLM 이 평문을 보낸 탓도 있었다. 이건 인덱스 이름을 도구 인자로 따로 빼고 JSON 은 코드가 조립하게 해서 잡았다.

기존 첨부 에이전트는 워커가 되지 못했다

첨부 파일을 물으면 오케스트레이터는 목적별로 따로 만들어 둔 대용량 첨부 분석 에이전트를 불렀다. 문서를 map-reduce 로 나눠 읽는 에이전트다. 그런데 돌아온 답은 "읽을 수 있는 첨부 문서가 없습니다" 한 문장이었다. 그 에이전트의 카드에는 이렇게 적혀 있었다.

호출자가 파일을 넘겨줄 수 없는 상황(A2A 경유)에서는 부르지 마세요 — 빈손으로 돌아옵니다.

카드가 부르지 말라고 한 워커를 부른 셈이다. 다만 이 경고는 이미 낡은 문구였다. 그 에이전트에는 A2A 로 입력을 받는 기능이 먼저 배포돼 있었는데 카드를 다시 등록하지 않았다. 빈손으로 돌아온 이유는 오케스트레이터 쪽에 있었다. 도구 인자가 query 하나뿐이라 파일을 넘길 방법이 없었다. 이어서 보낸 질문에서는 흐름도를 그려 달라고 했는데 이미지생성 워커도 부르지 않았다. 그 결과를 보고 물었다.

지금 최대한 중복기능없이 마이크로하게 워커에이전트들을 구성하려는데, 맵리듀스 에이전트를 쓰는게 맞나? 아니면 저 에이전트를 다시 분해해서 더 작은 에이전드들로 분리하는게 맞을까?

바로 뒤에 화면을 다시 보니 출처 칸에 잡문이 섞여 있어서 "원문출처에 이상한 말이 달려있네"라고 했다. 그 에이전트가 자기 출력을 오케스트레이터 화면에 직접 발행하고 있었고, 새는 원인은 에이전트가 찾았다. 혼자서 끝까지 답을 내도록 만든 에이전트라 남의 도구로 불리는 자리에서는 하는 일이 너무 많았던 것 같다. 나는 더 작은 쪽을 골랐다. 첨부 파일에서 관련 구절만 원문 그대로 돌려주는 워커를 새로 만들고, 카드에 파일을 함께 넘겨야 동작한다고 적고, 파일을 넘기는 인자를 도구에 추가했다. 기존 에이전트는 목록에서 뺐다. 그 뒤로 첨부 인용과 첨부 내용을 흐름도로 그리는 요청이 정답으로 나왔다.

워커가 이렇게 갖춰지고 나서 세 가지를 한꺼번에 시켜 봤다. 첨부한 문서의 한 항목에서 단계 수를 찾고, 사내 문서에서 유한상태머신의 구성요소가 무엇인지 찾고, 오늘 서울 날씨를 웹에서 확인한 다음, 마지막으로 그 단계 수에 7을 곱한 값을 코드로 계산해 달라는 질문이었다.

첫 라운드에 첨부 인용, 인덱스 카탈로그, 웹검색이 동시에 불렸고, 이어서 리트리버와 코딩이 불렸다. 워커 다섯 종류가 한 질문에서 돌았고 29.5초 뒤에 네 답(4, 상태·전이·인텐트, 날씨, 28)이 출처 9건과 함께 나왔다. 예전 방식이었다면 첨부, 사내 문서, 웹이 각각 다른 에이전트의 일이었을 질문이다.

워커 하나씩 고치기

그 직후, 사내 문서에서 검색 점수 공식을 찾아 코드로 계산하고 그림도 그려 달라고 했더니 오케스트레이터가 인덱스 카탈로그만 되풀이해 부르다가 리트리버도 코딩도 이미지생성도 부르지 않고 끝났다. 에이전트가 찾은 원인은 인덱스 이름에 그 검색 서비스 이름이 안 들어가 있다는 것이었다. 이름으로 고르는 LLM 이 매번 빈손으로 돌아온 것이다. 카탈로그와 오케스트레이터의 프롬프트를 먼저 고쳤는데 다시 보낸 질문도 똑같이 실패했고, 카탈로그의 쿼리를 고치고 나서야 넘어갔다. 그걸 보고 물었다.

그러면 인덱스 검색에이전트도 그냥 인덱스만 전체 돌려주는게 낫지않아? 마이크로하게 역할을 본다면 굳이 llm안붙이고?

카탈로그에서 LLM 을 뺐다. 인덱스 목록 219개를 그대로 돌려주고 고르는 일은 오케스트레이터가 한다. 그 뒤 사내 인덱스에서 아파트 분양 공고문을 찾아 달라는 질문에는 오케스트레이터가 219개 목록에서 인덱스를 스스로 골라 답했다. 대신 30분쯤 뒤에 개발 중에 만들어 둔 시험용 인덱스, 이름부터 "동해물과 백두산이" 같은 것을 골라 거기서 출처 10건을 가져왔다. 카탈로그가 시험용 인덱스를 거르게 해서 79개로 줄였다.

워커는 맞게 골랐는데 인자가 잘못된 경우도 있었다. 채팅 화면에서 보낸 질문의 웹검색이 "2024년 6월 기준"으로 나가 있어서 물었다.

지금 보면 2024년 6월기준으로 왜 검색을 하지?

주요 시중은행 주택담보대출 금리를 비교해 달라는 질문으로 다시 보내 보니, 오케스트레이터가 웹검색 워커에 넘긴 질의에 "2024년 9월 21일 기준"이 들어가 있었다. 현재 시각을 프롬프트 뒤쪽에 넣는 것으로는 안 고쳐졌고, 시스템 프롬프트 맨 앞으로 옮기고 연도를 코드로 교정하게 해서 잡았다. 에이전트는 이걸 모델의 학습 시점 문제로 봤는데 어느 모델인지를 헛짚어서, 2024년이 나온 건 에이전트 기본 모델 쪽이었다고 나중에 내가 바로잡았다.

같은 질문에 결과가 다르게 나온 적도 있다. 사내 문서에서 올해 노벨문학상 수상자를 찾아 달라고 했더니 리트리버에서 근거가 없자 웹검색까지 갔는데, 같은 질문을 다시 보내자 이번에는 리트리버에서 멈춰 엉뚱한 패시지로 답했다.

그러면 오케스트레이터가 rag 작업이 들어온경우에, 리트리버 검색에 정보가 없으면 다른걸 시도해보나? 그건 오케스트레이터의 비결정성인가?

원인은 에이전트 스스로 짚었다. 인덱스 카탈로그를 반복해서 부르던 문제를 막으려고 넣은 "같은 도구에 같은 질의를 반복하지 마세요" 규칙이 재검색까지 막고 있었다. 근거가 부족할 때는 질의를 바꾸거나 쪼개서 다시 찾되 상한을 두는 규칙을 넣고, 워커가 빈손으로 돌아오면 코드가 tool result 끝에 "근거 없음"을 붙이게 했다. 그 뒤의 질문에서 오케스트레이터가 질의를 둘로 쪼개 다시 검색했다.

다음 날에는 오케스트레이터 안에 도구로 붙어 있던 이미지 보기를 워커로 따로 뺐다. 워커는 이렇게 해서 일곱 개가 됐다.

어디를 고쳤나

테스트 기록 77건 가운데 엉뚱한 워커를 고른 적은 없었다. 워커를 고르고 부르는 쪽에서 난 실패는 여덟 건이고, 대부분 불러야 할 워커를 안 불렀거나 워커는 맞게 골랐는데 인자가 잘못된 경우였다. 답이 비거나 사라진 실패는 이것과 별개로 더 있었다.

증상종류고친 곳
멀티턴에서 앞 결과를 잊고 되물음안 부름오케스트레이터 대화 이력
카탈로그가 빈손이자 "없다"고 답함안 부름프롬프트
리트리버를 인덱스 없이 부름인자도구 인자
파일을 넘기지 못한 채 워커를 부름카드가 말린 워커 호출새 워커 + 파일 인자
카탈로그만 되풀이하다 포기안 부름카탈로그 워커
질의에 2024년 날짜인자프롬프트 + 코드
시험용 인덱스를 고름인자카탈로그 워커
같은 질문에 재검색을 안 함안 부름프롬프트 + 코드
여덟 사례를 잡은 곳 · 8
워커를 바꿈337.5%
프롬프트·코드337.5%
도구 인자112.5%
대화 이력112.5%
기존 카드 문구00.0%

막대에 커서를 올리면 내용이 나타납니다.

고친 곳이 워커 하나, 또는 프롬프트 한 곳으로 갈렸다. 인덱스 카탈로그가 말썽이면 카탈로그만, 첨부가 안 되면 첨부 워커만 봤다. 유한상태머신으로 짠 에이전트에서는 한 흐름 안에 다 엮여 있어서 이렇게 떼어 보기가 어려웠다.

설계 때 가장 걱정한 카드 품질은 이 기간의 기록에서는 문제가 되지 않았다. 카드 문구는 전부 에이전트가 초안을 써서 등록했고, 나는 빈 에이전트를 만들어 id 를 넘기고 어떤 워커를 둘지를 정했다. 22일 기준으로 카드 네 장이 실제 동작과 어긋나 있다. 리트리버와 인덱스 카탈로그 카드는 인덱스를 필수로 바꾸고 LLM 을 빼기 전의 설명 그대로이고, 나머지 둘은 출처 번호 매기는 방식과 바뀌기 전 워커 이름이다. 이 넷 때문에 엉뚱한 워커를 고른 기록은 없다. 리트리버 카드가 어긋나 있던 건 오케스트레이터가 리트리버를 인덱스 없이 부른 원인 중 하나였다.

개발 클러스터에서 확인한 범위

엉뚱한 워커를 안 고른 건 워커 수와 역할 덕이 큰 것 같다. 워커는 많아야 일곱 개였고, 역할이 겹치지 않게 계속 쪼갰다. 웹검색과 리트리버, 첨부 인용과 이미지 보기처럼 할 일이 분명히 갈리면 LLM 이 그중에서 고르는 건 어렵지 않은 것 같다. 여덟 건은 대부분 고른 다음에 나왔다. 빈손으로 돌아온 뒤 다시 부를지, 무엇을 넘길지에서였다.

이 결과를 그대로 믿기 어려운 이유도 있다. 개발 클러스터에서 보낸 대화이고, 카드는 전부 같은 양식을 지켜 에이전트가 썼다. 그리고 이 기간의 오케스트레이터는 대화 서비스가 아니라 샌드박스 코드에서 돌았다. 다음 단계에서 이 루프를 대화 서비스의 도구 루프로 옮기면 이력은 대화 서비스가 붙여 주니 첫날 밤처럼 앞 턴의 결과를 잊는 일은 저절로 없어질 것 같지만, 그건 옮겨 봐야 안다.