A2A 멀티에이전트 · MVP

구현 확인서

지시하신 구조가 실제로 그렇게 동작하는지 직접 확인하실 수 있도록 정리했습니다. 아래의 모든 출력과 수치는 실제로 실행해서 얻은 것이며, 예시나 추정치가 아닙니다.

2026-09-13 실측 · 회귀 테스트 185건 전부 통과

01요구사항 대비 구현 내역

요구사항구현 방식검증
관리자 ↔ 검증자 통신 차단발신 측 서비스 디스커버리 제한 + 수신 측 인가 검사 (2단)6.2
검증자 권한 우위판정 Artifact 무결성 보장 — 중계자 편집·상태 승격 불가6.3
데모 도메인: SW 개발함수 구현 → 정적 검사 6종 + 샌드박스 실행 검증6.1
비용 발생 억제추론 백엔드 추상화 — 기본값이 무호출 구현체7
CLI 선행, GUI 후속오케스트레이션 로직 단일화, 두 UI는 어댑터6.4
Windows · LinuxLinux 상시 가동. 전송 계층은 표준 라이브러리8

기술 스택

에이전트 간 통신
A2A 표준 프로토콜
a2a-sdk 1.1.2
에이전트 내부 제어
LangGraph 상태 기계
langgraph 1.2.11
추론 백엔드
교체형 Brain 인터페이스
langchain-core 1.6.3
전송 계층
Python 표준 라이브러리
http.server · urllib

두 축을 분리한 것이 이 구현의 핵심 설계 판단입니다 — 에이전트 사이는 A2A, 에이전트 안쪽은 LangGraph. 근거는 4장에 적었습니다.

02MVP 적용 범위

먼저 이번 MVP가 무엇을 처리하고 무엇을 처리하지 않는지 밝힙니다.

이번 MVP가 처리하는 작업은 "단일 Python 함수 생성" 한 가지입니다. 관리자는 실무자의 Agent Card에서 implement_function 스킬을 조회해 라우팅하고, 실무자는 review_code 스킬을 가진 검증자를 찾습니다. 현재 등록된 스킬이 이 둘뿐이라 파이프라인은 함수 생성·검증만 수행합니다.

범위를 벗어난 지시를 넣으면

보고서 작성을 요청한 실제 결과입니다.

console.cli
[사람]     회사 매출 보고서를 작성해줘
[검증자]    반려
         · [docstring] docstring 이 없는 함수: handle_request, ...
[검증자]    통과

────────────────────────────────────────────────
  def handle_request(data):
      """회사 매출 보고서를 작성해줘"""
      ...
────────────────────────────────────────────────

보고서가 아니라 함수를 만듭니다. 지시를 거부하지 않고 함수 생성으로 처리합니다. 의도된 동작이며, 도메인을 확장하기 전까지는 이 상태입니다.

이 제약은 골격이 아니라 스킬 등록의 문제입니다. 새 작업 유형을 추가하려면 에이전트가 Agent Card에 스킬을 선언하고 상위 config에 한 줄 넣으면 되며, 호출하는 쪽 코드는 고치지 않습니다 (9장).

지시가 모호할 때

무엇을 만들지 판단할 수 없으면 사람에게 되묻고 대기합니다. LangGraph의 interrupt()로 구현했습니다.

console.cli
[사람]     abc
[관리자]    되물음 'abc' 만으로는 무엇을 만들지 알 수 없습니다.
           어떤 기능의 함수인지 한 문장으로 알려 주세요.

작업이 실패로 끝나는 것이 아니라 INPUT_REQUIRED 상태로 멈춰 있다가, 같은 taskId로 답이 오면 중단 지점부터 이어서 실행합니다.

03구조

배치 · 통신 방향
사람 (CLI 또는 웹 화면)
     │
     ↓  지시 / 최종 결재
관리자 Agent  :8001    수신 [console]     위임 → worker
     │
     ↓
실무자 Agent  :8002    수신 [manager]     위임 → reviewer
     │
     ↓  재작업 루프 (상한 3회)
검증자 Agent  :8003    수신 [worker]      끝단 — 위임 대상 없음

에이전트 셋은 각각 별개의 프로세스입니다. 한 프로그램 안의 함수가 아닙니다. 같은 프로세스에 넣으면 관리자 코드가 검증자 함수를 그냥 호출할 수 있어 "영향 범위 최소화"를 지킬 수 없습니다.

각자 자기 것만 안다

전체 배치를 아는 파일은 없습니다. 에이전트마다 config.yaml 하나씩만 읽고, 그 안에는 세 가지만 들어 있습니다.

  • 자기 이름과 포트
  • accepts_from — 누구의 요청을 받을지
  • delegates — 누구를 부를 수 있는지 (이름과 주소)

관리자의 config.yaml에는 검증자 주소가 아예 없습니다. 부르지 않기로 약속한 것이 아니라, 주소를 얻을 방법이 없습니다.

04기술 선택의 근거

확장하기 좋게 만들어 달라고 하신 요구가 이 두 선택을 결정했습니다.

왜 A2A 표준을 썼는가

에이전트 간 통신을 자체 규약으로 만들지 않고 A2A 표준 프로토콜을 그대로 채택했습니다. a2a-sdk 1.1.2의 공식 타입을 씁니다.

확장 계획에 직결되기 때문입니다. 끝단에 외부 전문가나 타사 에이전트를 붙이겠다고 하셨는데, 자체 규약이면 상대방이 우리 규약을 배워야 합니다. 표준을 쓰면 A2A를 말하는 쪽은 누구든 그대로 붙습니다.

요소구현
Agent Card/.well-known/agent-card.json — 사양 1.0 및 v0.3 경로 양쪽 응답
메서드SendMessage · GetTask (v0.3 표기 message/send · tasks/get 도 수신)
전송JSON-RPC 2.0 over HTTP
상태submitted · working · input-required · completed · failed · rejected

능력 발견이 실제로 동작합니다. 관리자는 실무자 이름을 코드에 갖고 있지 않습니다. 상대 Agent Card를 조회해 implement_function 스킬을 선언한 쪽을 고릅니다. 새 에이전트를 config에 넣고 그쪽이 스킬을 선언하면, 호출하는 코드를 고치지 않아도 찾아집니다.

Agent Card 조회
$ curl http://127.0.0.1:8001/.well-known/agent-card.json

{"name": "manager",
 "description": "사람의 지시를 받아 실무자에게 위임하고 결과를 보고한다",
 "supportedInterfaces": [{"url": "http://127.0.0.1:8001",
                          "protocolBinding": "JSONRPC",
                          "protocolVersion": "1.0"}],
 "capabilities": {"streaming": false, "pushNotifications": false}, ...}
AI 추가 개발을 위한 조치

SDK의 protobuf 타입은 a2a_core/types.py 파사드 밖으로 나가지 않습니다. 에이전트 코드는 평범한 dataclass만 봅니다 — AI에게 추가 개발을 맡기실 때 protobuf를 몰라도 되도록 했습니다.

왜 LangGraph 를 썼는가

에이전트 내부의 작업 흐름은 LangGraph 상태 기계로 만들었습니다 (1.2.11).

if 문으로 짜도 지금은 돕니다. 그러나 재작업 루프·사람 개입·중단 후 재개가 얽히면 곧 흐름을 따라갈 수 없게 됩니다. 상용화까지 가신다고 하셨으므로 그래프로 두었습니다. 흐름이 그림과 코드에서 같은 모양입니다.

관리자 — 사람 개입 지점이 있는 흐름
START → 지시 해석 → 실무자 선택 ─┬─ 찾음 ──▶ 위임 → END
             │                  └─ 못 찾음 ──▶ END (FAILED)
             └─ 모호하면 interrupt → 사람에게 되묻기
실무자 — 재작업 루프가 조건부 엣지 하나로 강제된다
START → 초안 작성 → 검증 요청 ─┬─ 통과 ──────────▶ END
             ▲                └─ 반려 ─┬─ 여유 있음 ─┐
             └───────────────────────┘              │
                                      └─ 상한 소진 ─▶ END (반려)
검증자 — 검사마다 노드 하나
START → 문법 → 함수 존재 → docstring → 이름 규약
      → 예외 처리 → 미완성 표시 → 실행 검사 → END

검사 규칙을 늘리려면 목록에 함수를 추가하면 노드가 자동으로 생깁니다.

LangGraph 로 얻은 것 셋

  • 중단·재개. 체크포인터의 스레드 키를 A2A taskId로 맞췄습니다. 되물음으로 멈춘 작업에 같은 taskId로 답을 보내면 그 노드부터 이어 돕니다. 처음부터 다시 실행하지 않습니다.
  • 상한이 구조로 강제됩니다. 재작업 횟수는 조건부 엣지가 판정하므로, 에이전트가 이를 우회할 경로가 없습니다.
  • LLM 없이 동작합니다. LangGraph는 상태 기계 라이브러리로만 씁니다. 무료 모드에서도 그래프는 그대로 돕니다. 외부 추적 서비스 호출도 없습니다.

05실행 방법

설치 · 기동 · 실행
# 1. 설치 — 다섯 프로젝트를 한 번에 (Python 3.13 이상)
$ python deploy/setup_dev.py

# 2. 에이전트 3종 기동
$ python deploy/run_all.py
────────────────────────────────────────────────────────
  reviewer   127.0.0.1:8003  수신 ['worker'] (끝단)
  worker     127.0.0.1:8002  수신 ['manager'] → reviewer
  manager    127.0.0.1:8001  수신 ['console'] → worker
────────────────────────────────────────────────────────

# 3-a. CLI 로 지시 (지시 없이 실행하면 대화 모드)
$ python -m console.cli "로그인 검증 함수를 만들어줘"

# 3-b. 웹 화면 → http://127.0.0.1:8080
$ python -m console.web_server

06확인 항목

6.1 한 번의 작업이 끝까지 도는지

console.cli — 실제 출력
$ python -m console.cli "로그인 검증 함수를 만들어줘"

A2A 콘솔 · 관리자 manager · http://127.0.0.1:8001

[사람]     로그인 검증 함수를 만들어줘
[검증자]    반려
         · [docstring] docstring 이 없는 함수: authenticate, test_authenticate
[검증자]    통과
[관리자]    결과 제출

────────────────────────────────────────────────────────
  def authenticate(user_id, password):
      """로그인 검증 함수를 만들어줘"""
      if user_id is None:
          raise ValueError("user_id 가 필요하다")
      return True


  def test_authenticate():
      """authenticate 이 참을 돌려주는지 확인한다."""
      assert authenticate("user_id", "password") is True
────────────────────────────────────────────────────────
  재작업 1회 · 검증 2회 · 두뇌 rule · 호출 3회 · 비용 0원
재작업 루프가 실제로 돈다 첫 산출물이 docstring 누락으로 반려되고, 고쳐서 두 번째에 통과합니다. 기본 두뇌가 첫 시도에서 일부러 docstring을 빼도록 되어 있습니다 — 루프를 매번 보실 수 있게 한 것입니다.
확인
비용 0원 기본 두뇌(rule)는 외부 API를 한 번도 부르지 않습니다. 키가 없어도, 인터넷이 끊겨도 돕니다.
확인
누적 사용량 상시 표시 마지막 줄에 호출 횟수와 원화가 항상 찍힙니다.
확인

6.2 격리가 실제로 막는지

가장 중요한 확인입니다. 에이전트를 띄운 상태에서 아래를 그대로 실행해 보십시오.

관리자인 척하고 검증자를 직접 호출
$ curl -X POST http://127.0.0.1:8003/ -H "Content-Type: application/json" \
    -d '{"jsonrpc":"2.0","id":"t1","method":"SendMessage","params":{"message":
        {"role":"ROLE_USER","content":[{"text":"검사해줘"}],"messageId":"m1",
         "metadata":{"issued_by":"manager"}}}}'

{"jsonrpc": "2.0", "id": "t1", "error": {"code": -32003,
 "message": "'reviewer' 는 'manager' 의 요청을 받지 않는다. 허용: ['worker']"}}
사람이 실무자를 직접 호출
$ curl -X POST http://127.0.0.1:8002/ ... "issued_by":"human" ...

{"jsonrpc": "2.0", "id": "t2", "error": {"code": -32003,
 "message": "'worker' 는 'human' 의 요청을 받지 않는다. 허용: ['manager']"}}

거부되고, 작업 자체가 만들어지지 않습니다. 요청을 받아 처리하다 중간에 멈추는 것이 아니라 문 앞에서 돌려보냅니다.

막는 지점없으면
호출자 쪽관리자 config에 검증자 주소가 없음
수신자 쪽검증자가 worker 외의 발신자를 거부주소를 알아내면 뚫림
알려드릴 것 — 현재 설정의 한계

지금 설정은 발신자 이름을 자기 신고로 받습니다. 위 curl 예시처럼 issued_by를 다른 이름으로 적으면 그 이름으로 취급됩니다.

로컬 데모(127.0.0.1 고정)에서는 충분하지만, 외부에 열린 환경에서는 config.yamlauth.token을 채워야 실제 차단이 됩니다. 자리는 만들어 두었고 값만 넣으면 동작합니다. 이번 범위에서 인증은 제외라고 하셨기에 비워 둔 상태입니다.

6.3 검증 결과가 편집되지 않는지

검증자의 판정문은 실무자를 거쳐 관리자로 올라갑니다. 실무자가 자기에게 불리한 반려를 요약하거나 눌러버리면 관리자는 알 수 없으므로, 실무자는 검증 결과를 손대지 않고 원문 그대로 첨부합니다. 상태를 통과로 올리는 것도 불가능합니다.

6.1의 출력에서 [검증자] 반려 아래에 붙은 [docstring] docstring 이 없는 함수: ...가 검증자가 쓴 문장 그대로입니다.

검증자가 하는 검사

종류항목
정적 검사 6종문법 · 함수 존재 · docstring · 이름 규칙(snake_case) · 빈 except 금지 · TODO 잔존 금지
실행 검사격리된 별도 프로세스에서 test_ 함수 실행. 5초 초과 시 중단

검사 항목과 시간 제한은 검증자 config.yaml에서 조절합니다.

6.4 CLI 와 GUI 가 같은 로직을 쓰는지

"CLI 먼저, 그 위에 GUI" 를 지킨 방식
console/orchestrator.py    ← 로직은 전부 여기
    ├── cli.py             ← 껍데기
    └── web_server.py      ← 껍데기

화면 쪽에는 판단이 없습니다. 회귀 테스트에 이 사실을 검사하는 항목이 들어 있습니다 — web_server가 에이전트를 직접 부르면 테스트가 실패합니다.

tests/test_web.py
[OK] web_server 는 orchestrator 를 import 만 한다
     (로직 없음: A2AClient 직접 호출 없음)

GUI를 나중에 걷어내도 CLI는 그대로 돌고, 반대도 마찬가지입니다.

6.5 회귀 테스트

tests/run_all.py
$ python tests/run_all.py

합계: 통과 185 · 실패 0

pytest 같은 외부 도구가 필요 없습니다. 표준 라이브러리만으로 돕니다. 네 묶음(공통 라이브러리 · 에이전트 · 콘솔 · 웹)으로 나뉘어 있습니다.

07비용

무료로 쓰실 때 (기본값)

아무 설정도 하지 않으면 BRAIN=rule입니다. API 호출 0건, 0원. 키가 없어도 돌고, 잔액이 떨어질 일도 없습니다. 위 6.1의 실행이 이 상태였습니다.

실제 모델을 붙이실 때

.envBRAIN=openai와 키를 넣으면 GPT-5 nano를 씁니다. 2회 호출 실측 ₩0.26입니다 (2026-09-13).

키가 없거나 패키지가 없으면 자동으로 무료 모드로 내려갑니다. 데모가 죽지 않습니다.

키가 없을 때
[brain] openai 를 쓸 수 없어 rule 로 돌린다 — OPENAI_API_KEY 가 없다

비용이 새지 않게 하는 장치

전부 config.yaml 값이라 파일에서 바로 고치실 수 있습니다.

항목기본값하는 일
max_rework3재작업 반복 상한
max_llm_calls실무자 4 · 관리자 2에이전트별 호출 상한
max_depth5위임 깊이 상한
순환 감지A→B→C→A 를 감지해 중단

0을 넣으면 해당 제한이 풀립니다. 상용 단계에서 쓰실 수 있습니다. 사용량은 사슬을 타고 올라와 관리자에서 합산되므로, 화면에 보이는 숫자가 전체 합계입니다.

08원격 데모 서버

설치 없이 바로 보실 수 있도록 리눅스 서버에 올려 두었습니다.

http://49.247.137.13:8080 접속 계정과 비밀번호는 별도로 전달드립니다
  • 에이전트 3종이 systemd 서비스로 상시 가동 중입니다.
  • 이 서버의 두뇌는 openai로 설정되어 있어 실제 모델이 붙은 상태를 보실 수 있습니다.

09확장하는 방법

하려는 것해야 하는 일
같은 종류를 하나 더
실무자 2번
config.yaml을 복사해 이름과 포트만 바꾸고, 관리자 delegates에 한 줄 추가. 코드는 건드리지 않습니다.
새로운 종류의 에이전트 프로젝트 폴더 하나를 만들고, 부를 쪽 delegates와 받을 쪽 accepts_from에 이름을 넣습니다.
끝단에 사람이나
외부 에이전트
검증자처럼 A2A 주소를 갖는 참여자로 등록합니다. 위임 깊이 상한과 순환 감지가 이미 들어 있어 사슬이 길어져도 무한히 도는 일은 없습니다.

세 경우 모두 A2A 표준과 Agent Card 능력 발견 덕분에 성립합니다. 호출하는 쪽은 상대의 이름이 아니라 스킬을 찾기 때문입니다.

지금 범위 밖인 것

  • 인증 (auth.token 자리는 만들어 두었고 값만 비어 있습니다)
  • 클라우드 배포 · DB 영속화 (지금은 메모리에만 기록됩니다)
  • 1:N 라우팅 정책 — 구조는 1:N을 받아들이지만 MVP는 각 1개씩입니다
  • 스트리밍 (A2A 사양에는 있으나 이번 범위 밖)
  • 모바일 전용 화면

10확인해 주실 것

직접 돌려 보시고 아래가 기대와 다르면 알려 주십시오.

재작업 루프가 도는 모습 6.1 — 반려 후 고쳐서 통과하는 흐름
확인 요청
관리자가 검증자를 못 부르는 것 6.2 — curl 로 직접 시도해 보십시오
확인 요청
검증 판정 기준 지금은 문법 · docstring · 이름 규칙 · 테스트 실행입니다. 더 볼 것이 있으면 검사 항목을 늘립니다
확인 요청
화면에서 보고 싶은 정보 더 필요한 것이 있는지
확인 요청

별첨화면 구성

웹 콘솔을 실제로 돌려 캡처한 화면입니다. 8장의 데모 서버에서 같은 화면을 보실 수 있습니다.

A2A 콘솔 — 왼쪽 실행 목록, 가운데 런 트리와 진행 로그, 오른쪽 산출물 상세로 구성된 3단 화면
작업 하나가 끝난 직후. 가운데 런 트리에 관리자 → 실무자 → 검증자 계층이 그대로 나타납니다. 실무자에 재작업 1회, 검증자 #1 에 반려, 검증자 #2 에 통과가 붙어 재작업 루프가 눈에 보입니다. 각 줄의 소요 시간과 비용(₩0)이 함께 표시되고, 오른쪽에는 최종 산출물과 사람이 누를 승인 · 반려 버튼이 있습니다.
검증자 #1 노드를 선택해 판정 탭을 연 화면. 반려 사유가 원문 그대로 표시된다
반려한 검증자를 눌러 판정을 편 것. 검사 7개 · 테스트 1개 실행 아래에 반려 사유가 검증자가 쓴 문장 그대로 올라옵니다 — 실무자가 요약하거나 고치지 않는다는 것을 화면에서 확인하실 수 있습니다 (6.3). 상단 막대의 세션 ₩0 · 호출 3 · 토큰 0/0 은 무료 모드로 돌린 결과입니다.

화면은 진행 상황이 실시간으로 갱신됩니다 (SSE). 새로고침하지 않아도 검증 판정이 나오는 대로 트리에 붙습니다.