Multi-Agent 15

Agent 간 데이터 흐름, 명시적으로 설계하기

Agent를 두 개 연결하면 둘 사이에 무엇이 전달되는지 자동으로 명확해진다고 보기 쉽다.

근거 · 교안 p45-p52

Agent 간 데이터 흐름, 명시적으로 설계하기 대표 이미지

1장: 연결만으로는 부족해요, 솔라!

솔라는 노트북 화면에 떠 있는 간단한 다이어그램을 보며 미간을 찌푸렸다. 네모난 상자 두 개가 화살표로 이어져 있었다. [Agent 1] -> [Agent 2]. 그 아래에는 짧은 설명이 한 줄 적혀 있었다.

‘Agent1이 만든 결과(State)를 Agent2가 받아서 작업한다.’

“흠….”

솔라는 의자 등받이에 몸을 기댔다. 너무 간단해서 오히려 이상했다. 마치 마술 같았다.

“첫 번째 에이전트가 뭔가 결과를 만들면, 그냥 그걸 두 번째 에이전트가 받아서 쓴다는 거잖아. 그럼 내가 할 일은 그냥 둘을 연결해 주기만 하면 되는 건가? 똑똑한 비서 두 명을 한 방에 넣어주면, 자기들끼리 알아서 대화하고 일을 처리하는 것처럼?”

솔라는 중얼거리며 손가락으로 허공의 화살표를 덧그렸다. Agent1이 회의록을 요약해서 핵심 문제점을 뽑아내면, 그 ‘핵심 문제점’이라는 정보를 Agent2가 알아서 집어다가 해결책을 만든다. 마치 둘 사이에 보이지 않는 생각의 끈이라도 연결된 것처럼. 모든 정보가 담긴 공용 뇌를 공유하는 것처럼 말이다.

“솔라, 뭘 그렇게 골똘히 봐?”

어느새 다가온 루나가 솔라의 화면을 들여다보며 물었다.

“아, 언니. 멀티 에이전트 시스템을 보고 있었어. 에이전트들을 이렇게 파이프처럼 연결하면, 첫 번째 녀석의 결과물이 다음 녀석에게 그냥 흘러 들어가는 것 같아서. Agent2는 Agent1이 만들어낸 모든 결과물 중에서 자기가 필요한 걸 알아서 쏙쏙 골라 쓸 수 있는 거 아닐까? 똑똑하니까.”

루나는 솔라의 설명을 잠자코 듣더니, 고개를 살짝 갸웃했다.

“정말 그럴까? ‘연결’만 하면 모든 게 자동으로 해결될까? 한번 작은 실험을 해보는 건 어때?”

루나는 빈 코드 에디터를 열었다.

“여기 달리기 선수 두 명이 있다고 상상해 봐. 첫 번째 주자는 ‘고객 불만 이메일’을 읽고 ‘핵심 문제점’이 적힌 쪽지를 찾는 임무를 받았어. 두 번째 주자는 그 쪽지를 받아서 ‘해결 방안’이라는 도착점까지 달려가야 해.”

“응, 좋아. Agent1이 핵심 문제점을 찾고, Agent2가 해결 방안을 만드는 거네.”

솔라는 신나서 키보드를 잡았다.

“내가 한번 만들어 볼게. 간단하잖아.”

솔라는 재빨리 코드를 작성하기 시작했다. 먼저, 긴 이메일에서 핵심 문제점을 한 문장으로 요약하는 문제점_분석_에이전트를 정의했다. 그리고 그 문제점을 바탕으로 구체적인 해결책을 제시하는 해결책_제시_에이전트를 만들었다.

# (개념을 설명하기 위한 가상의 코드입니다)

def 문제점_분석_에이전트(state):
    user_input = state["user_input"]
    # ... 복잡한 분석 로직 ...
    key_point = "서버 접속 지연 문제"
    print(f"Agent 1: 핵심 문제점을 찾았습니다 - {key_point}")
    # state에 결과를 저장... 어떻게? 일단 그냥 둠.
    return {"key_point": key_point}

def 해결책_제시_에이전트(state):
    # Agent 1의 결과를 어떻게 가져오지?
    # state에 있겠지?
    key_point = state["key_point"]
    print(f"Agent 2: '{key_point}'에 대한 해결책을 생성합니다.")
    # ... 해결책 생성 로직 ...
    action_item = "서버 증설 및 코드 최적화"
    return {"action_item": action_item}

# 두 에이전트를 연결
# workflow.add_node("agent1", 문제점_분석_에이전트)
# workflow.add_node("agent2", 해결책_제시_에이전트)
# workflow.add_edge("agent1", "agent2")

“자, 이렇게 두 에이전트를 만들고, 워크플로우에 추가해서 둘을 연결했어.”

솔라는 자신만만하게 말했다.

문제점_분석_에이전트가 실행되고 나면 그 결과가 state 어딘가에 남아 있을 테고, 그걸 다음 차례인 해결책_제시_에이전트가 자연스럽게 가져다 쓰면 되겠지.”

“한번 실행해 볼까?”

루나가 조용히 말했다. 솔라는 실행 버튼을 눌렀다. 잠시 후, 화면에 메시지가 출력되었다.

Agent 1: 핵심 문제점을 찾았습니다 - 서버 접속 지연 문제
...
ERROR: KeyError: 'key_point'

“어?”

솔라의 눈이 동그래졌다. 첫 번째 에이전트는 성공적으로 실행됐지만, 두 번째 에이전트에서 에러가 발생했다. ‘key_point’라는 키를 찾을 수 없다는 뜻이었다.

“실패했어… 왜? Agent1이 ‘key_point’를 만들었잖아. 그리고 둘은 분명히 연결되어 있는데. Agent2는 왜 그걸 못 찾는 거지?”

솔라는 당황해서 에러 메시지와 코드를 번갈아 쳐다봤다. 분명히 같은 state를 공유할 것이라고 믿었는데, 현실은 달랐다. 기대했던 마법은 일어나지 않았다.

루나는 솔라의 어깨를 가볍게 두드렸다.

“솔라, 두 사무실 사이에 공압관을 설치했다고 상상해 봐.”

“공압관?”

“응. 이쪽 사무실에서 저쪽으로 서류를 보낼 수 있는 관 말이야. 관을 설치하는 행위가 바로 우리가 방금 한 ‘에이전트 연결’이야. 길이 만들어진 거지. 하지만 그렇다고 해서 내 책상 위 서류가 저절로 관을 타고 반대편으로 날아가진 않잖아.”

솔라는 루나의 비유를 곱씹었다.

“누군가 서류를 캡슐에 넣어서, 관에 집어넣고, ‘보내기’ 버튼을 눌러야 해. 그리고 반대편 사람은 캡슐이 도착했다는 걸 알고, 그걸 열어서 서류를 꺼내야 하지.”

그제야 솔라의 얼굴에 깨달음이 스쳤다.

“아! 연결은 말 그대로 길만 터주는 거구나! 데이터가 자동으로 오가는 게 아니라! Agent1이 결과를 만들었으면, 그걸 state라는 캡슐의 특정 칸에 ‘명시적으로’ 넣어야 하고, Agent2는 state 캡슐의 바로 그 칸을 ‘명시적으로’ 열어봐야 했던 거야.”

솔라는 다시 코드를 봤다. Agent1은 key_point를 반환했지만, 그게 state의 다음 단계로 넘어가는 공식적인 통로에 담기지 않았다. Agent2는 텅 빈 state만 전달받고 key_point를 찾으려 하니 실패할 수밖에 없었다. 연결만으로는 충분하지 않았다. 데이터를 실어 나를 명확한 채널과, 그 채널을 사용하겠다는 명시적인 약속이 필요했다.

“맞아. 그 공압관 시스템 전체가 State인 셈이야. 에이전트들은 그저 관을 통해 캡슐을 주고받을 수 있는 권한을 얻은 거고. 한 에이전트가 무슨 생각을 하는지 다른 에이전트가 마음대로 들여다볼 수는 없어.”

솔라는 고개를 끄덕였다. 자동 정보 공유라는 막연한 기대가 깨지는 순간이었다. 에이전트들을 연결하는 것은 시작에 불과했다. 진짜 중요한 것은 그들 사이에 어떤 정보를, 어떤 이름으로, 어떻게 주고받을지 정하는 것이었다.

“그럼… 이 캡슐에 어떤 서류를 넣을지, 서류에 어떤 제목을 붙일지 미리 약속해야겠네. 안 그러면 Agent1은 ‘주요 문제’라는 이름으로 서류를 보냈는데, Agent2는 ‘핵심 포인트’라는 서류함만 뒤지고 있을 테니까.”

솔라의 머릿속에 새로운 질문이 떠올랐다.

“어떻게 하면 그 약속, 즉 주고받을 데이터의 형식을 깔끔하게 정의할 수 있을까? 마치 두 사람이 서명을 하기 전에 계약서를 작성하는 것처럼 말이야.”

2장: State는 단순한 메모장이 아니라 계약서예요!

솔라는 지난번의 실패를 곱씹으며 화면에 새 파일을 열었다. 이전의 깨달음은 명확했다. 에이전트들을 연결하는 것만으로는 부족하며, 데이터를 주고받을 명시적인 약속이 필요하다. 솔라는 지난번 대화의 마지막에 떠올렸던 ‘계약서’라는 단어에 꽂혀 있었다.

“그래, 계약서. Agent1과 Agent2가 서로 오해하지 않도록, 어떤 정보를 주고받을지 미리 정해두는 거야.”

솔라는 키보드 위에서 손가락을 분주하게 움직였다. 그녀의 생각은 간단했다. 파이썬의 딕셔너리(dictionary)를 사용하면 되지 않을까? 필요한 키(key)들을 미리 주석으로 적어두면, 그게 바로 약속이 될 수 있을 터였다.

# -- State 계약서 초안 (솔라 작성) --
# user_input: 사용자의 원본 입력 (str)
# key_point: Agent1이 추출한 핵심 문제점 (str)
# action_item: Agent2가 생성할 해결 방안 (str)

# 초기 State는 이렇게 생겼겠지?
app_state = {
    "user_input": "우리 앱을 쓸 때마다 서버 접속이 너무 느리다고 고객센터에 불만이 접수됐습니다."
}

솔라는 자신이 만든 초안을 보며 만족스러운 미소를 지었다.

“언니, 이거 봐봐! 내가 우리 에이전트들을 위한 데이터 계약서 초안을 한번 써봤어. 주고받을 정보의 종류를 이렇게 주석으로 딱 정해두는 거지. user_input, key_point, action_item. 깔끔하지 않아? 이 딕셔너리 하나만 있으면, 에이전트들이 자유롭게 필요한 정보를 읽고 쓸 수 있을 거야.”

솔라의 목소리에는 자신감이 넘쳤다. 그녀에게 State는 모든 에이전트가 함께 사용하는 커다란 메모장, 즉 전역 딕셔너리처럼 느껴졌다.

루나는 솔라의 화면을 조용히 들여다보았다. 그리고는 솔라의 코드 옆에 새로운 창을 하나 더 열었다.

“좋은 시작이야, 솔라. ‘어떤 정보를 주고받을지’에 대한 합의는 분명히 담겨 있네. 그런데, 계약서가 왜 중요할까? 단순히 내용을 알려주는 것 이상의 역할이 있지 않을까?”

루나는 질문을 던지며 솔라가 작성한 딕셔너리를 살짝 수정했다.

# Agent1이 실수로 오타를 냈다면?
app_state["key_pont"] = "서버 접속 지연 문제" # 'key_point'가 아니라 'key_pont'

# 혹은 Agent1이 계약에 없는 내용을 마음대로 추가했다면?
app_state["agent1_internal_memo"] = "분석하는데 5초 걸림"

“만약 첫 번째 에이전트 개발자가 깜빡하고 key_point 대신 key_pont라고 오타를 냈다고 상상해 봐. 두 번째 에이전트는 key_point라는 서랍만 계속 열어보려 할 텐데, 그 서랍은 텅 비어있겠지. 지난번과 똑같은 에러가 발생할 거야.”

루나가 덧붙였다.

“혹은, 첫 번째 에이전트가 디버깅 용도로 agent1_internal_memo 같은, 계약에 없는 정보를 마구 추가하면 어떨까? 우리의 공유 메모장은 금세 지저분해지고, 누구도 이 안에 무엇이 들었는지 확신할 수 없게 될 거야. 이런 메모장을 ‘계약서’라고 부를 수 있을까?”

“음… 그건 계약서라기보다는 그냥 막 쓰는 포스트잇 모음집 같네.”

솔라는 루나의 지적에 미간을 찌푸렸다. 주석으로 약속을 정하는 것은 아무런 강제성도, 보호 기능도 없었다. 언제든 깨질 수 있는 너무나 허술한 약속이었다.

“맞아. 그래서 우리에겐 단순한 메모장이 아니라, 정해진 양식과 규칙을 강제하는 진짜 ‘계약서’가 필요해.”

루나는 솔라의 코드 옆에 새로운 코드를 작성하기 시작했다.

from typing import TypedDict, List

class AgentState(TypedDict):
    user_input: str
    key_point: str
    action_item: str

“이게 바로 TypedDict를 이용해 만든 우리만의 State 계약서야. 이 계약서는 세 개의 필드(user_input, key_point, action_item)만 존재하며, 각 필드에는 문자열(str)만 들어갈 수 있다고 명시하고 있어.”

솔라는 두 코드를 나란히 비교해 보았다. 왼쪽은 단순한 딕셔너리와 주석. 오른쪽은 class 키워드와 타입 힌트가 붙은 엄격한 구조체.

“이제 이 계약서를 가지고 아까의 실수를 다시 해볼까?”

루나는 AgentState 계약서를 사용하는 코드를 보여주었다. IDE는 코드를 실행하기도 전에, 문제가 있는 부분에 즉시 빨간 밑줄을 그었다.

# AgentState 계약서를 사용하는 state
state: AgentState = {"user_input": "...", "key_point": "", "action_item": ""}

# Agent1이 실수로 오타를 냈을 때
state["key_pont"] = "서버 접속 지연 문제"
#      ^~~~~~~~
#      IDE 경고: "AgentState"에는 "key_pont" 필드가 없습니다. "key_point"를 의도했나요?

# Agent1이 계약에 없는 내용을 추가하려고 할 때
state["agent1_internal_memo"] = "분석하는데 5초 걸림"
#      ^~~~~~~~~~~~~~~~~~~~~~
#      IDE 경고: "AgentState"에는 "agent1_internal_memo" 필드가 없습니다.

“와…!”

솔라의 입에서 짧은 탄성이 터져 나왔다. 코드를 실행해서 에러를 마주하는 것이 아니라, 코드를 작성하는 단계에서부터 계약 위반을 잡아주고 있었다.

“이건… 단순한 메모장이 아니네. 이건 마치 설계도 같아. 에이전트들이 데이터를 담기 전에, 그 데이터가 어떤 모양이어야 하는지를 정확하게 알려주는 설계도. 이 설계도에 없는 칸을 만들거나, 다른 재료를 넣으려고 하면 바로 경고를 주는 거야.”

솔라는 깨달았다. State는 모든 정보가 뒤섞여 흘러 다니는 거대한 강이 아니었다. 그것은 각 에이전트가 무엇을 넣고 무엇을 가져갈지 명확하게 정의된, 잘 짜인 채널들의 집합이었다. 그리고 그 채널의 구조를 정의하는 것이 바로 이 TypedDict와 같은 ‘스키마 계약’의 역할이었다. 자동 정보 공유라는 막연한 기대가 무너진 자리에, ‘명시적인 계약을 통한 제어’라는 훨씬 더 강력하고 안정적인 개념이 자리 잡기 시작했다.

솔라는 방금 전 자신이 짰던 딕셔너리 코드를 망설임 없이 지웠다. 그리고 TypedDict로 정의된 AgentState를 바라보았다. 훨씬 더 믿음직스러웠다.

“좋아, 이제 우리 에이전트들은 절대 길을 잃거나 서로를 오해할 일이 없겠어. 이 완벽한 계약서가 있으니까.”

솔라는 만족스럽게 중얼거렸다. 그러다 문득 새로운 궁금증이 고개를 들었다.

“그런데 언니, 이 멋진 계약서가 있다는 건 알겠어. 그런데… 에이전트들은 이 계약서의 어느 필드를 읽고, 어느 필드에 자기 결과를 써야 하는지 어떻게 아는 거지? Agent1은 key_point에 써야 하고, Agent2는 key_point를 읽어서 action_item을 채워야 한다는 걸, 코드로 어떻게 명령하는 거야?”

3장: 계약서대로 읽고 써야죠, 솔라!

솔라의 손가락이 키보드 위에서 잠시 멈췄다. 화면에는 지난번에 루나와 함께 정의했던 AgentState 계약서가 깔끔하게 자리 잡고 있었다.

from typing import TypedDict

class AgentState(TypedDict):
    user_input: str
    key_point: str
    action_item: str

이 계약서는 에이전트들이 주고받을 데이터의 구조를 완벽하게 정의해주었다. 마치 잘 설계된 서류 양식 같았다. 하지만 솔라는 이제 그 서류 양식을 실제로 채워 넣을 담당자, 즉 에이전트 함수를 작성해야 하는 단계에서 막막함을 느꼈다.

그녀는 문제점_분석_에이전트의 함수 정의를 입력했다.

def 문제점_분석_에이전트(state: AgentState):
    # ???

물음표 자리에서 커서가 깜빡였다. ‘State 계약서가 있으니, 이 함수는 이제 어떻게 동작해야 하지?’ 솔라의 머릿속은 복잡했다.

“흠… state 객체 전체를 인자로 받는 건 알겠어. 그럼 이 안에서 user_input을 꺼내 분석한 다음, 알아서 key_point라는 결과물을 만들면 되는 건가? 똑똑한 에이전트니까, 자기가 만든 결과가 계약서의 어느 필드에 해당하는지 알고 알아서 착착 넣어주지 않을까?”

솔라는 중얼거리며 가상의 코드를 상상했다. 에이전트가 그저 return "서버 접속 지연 문제" 라고 결과만 휙 던져주면, 시스템이 그걸 받아서 AgentStatekey_point 필드에 마법처럼 채워주는 그림. 편리하긴 하겠지만, 정말 그럴 리가 없다는 것을 지난번의 경험으로 알고 있었다.

“언니, 이 계약서를 사용하는 건 좋은데, 막상 에이전트 코드를 짜려니까 헷갈려. Agent1이 key_point를 만들고, Agent2는 그걸 받아서 action_item을 만들어야 하잖아. 이걸 코드로 어떻게 명령해야 하는 거지?”

루나는 솔라의 화면을 보더니, 빈 서류 양식이 인쇄된 종이 한 장과 펜 두 자루를 가져왔다. 서류 상단에는 ‘업무 처리서’라고 적혀 있고, ‘원본 내용’, ‘핵심 문제’, ‘조치 사항’이라는 빈칸이 있었다.

“솔라, 이게 우리 AgentState 계약서라고 생각해 봐. 그리고 너는 첫 번째 담당자, 나는 두 번째 담당자야.”

루나는 펜 한 자루를 솔라에게 건넸다.

“네 임무는 ‘원본 내용’ 칸을 읽고, 분석해서 ‘핵심 문제’ 칸을 채우는 거야. 자, 한번 해봐.”

솔라는 잠시 망설이다가 펜을 들었다. 그녀는 ‘원본 내용’ 칸에 적힌 가상의 문장을 읽는 척하고는, ‘핵심 문제’라고 적힌 빈칸에 ‘서버 접속 지연’이라고 또박또박 적었다.

“됐어. 내 임무는 끝.”

“좋아. 그럼 이제 어떻게 할 거지?”

루나가 물었다.

“어떻게 하냐니? 이제 언니 차례잖아. 언니가 이 서류를 받아서 ‘핵심 문제’ 칸을 읽고, ‘조치 사항’ 칸을 채워야지.”

“바로 그거야, 솔라.”

루나는 솔라가 내민 서류를 받으며 말했다.

“네가 방금 한 행동을 그대로 코드로 옮기면 돼. 너는 서류(state)를 받아서, 특정 칸(user_input)을 읽고, 네가 맡은 다른 칸(key_point)에 썼어. 그리고 서류 전체를 나에게 넘겼지. 나도 마찬가지로 서류를 받아서, 네가 채운 칸(key_point)을 읽고, 내 담당 칸(action_item)에 거야. 에이전트도 똑같아. state라는 서류 뭉치를 받아서, 정해진 칸을 명시적으로 읽고 쓰는 거지. 알아서 해주는 건 아무것도 없어.”

그제야 솔라의 머릿속에 안개가 걷히는 듯했다. State는 마법 상자가 아니었다. 그냥 순서대로 돌려보는 하나의 문서 파일이었다. 각 에이전트는 그 파일을 열어서 자기 담당 부분을 채우고 저장한 뒤, 다음 주자에게 넘겨주는 것과 같았다.

“아하! 그럼 코드를 이렇게 짜야 하는구나!”

솔라는 다시 키보드를 잡았다. 이번에는 망설임이 없었다.

# (개념을 설명하기 위한 가상의 코드입니다)

# Agent 1: 문제점 분석가
def 문제점_분석_에이전트(state: AgentState):
    print("Agent 1: 업무 처리서를 받았습니다.")
    # 1. 계약서의 'user_input' 필드를 명시적으로 '읽는다'.
    user_input = state["user_input"]
    
    # 2. 자신의 임무를 수행한다.
    key_point = f"'{user_input[:10]}...'에 대한 핵심 문제점은 '서버 접속 지연'입니다."

    # 3. 계약서의 'key_point' 필드에 결과를 명시적으로 '쓴다'.
    state["key_point"] = key_point
    print("Agent 1: '핵심 문제' 칸을 채웠습니다.")

    # 4. 수정된 업무 처리서 전체를 다음 에이전트에게 전달한다.
    return state

# Agent 2: 해결책 설계자
def 해결책_제시_에이전트(state: AgentState):
    print("Agent 2: 수정된 업무 처리서를 받았습니다.")
    # 1. 계약서의 'key_point' 필드를 명시적으로 '읽는다'.
    key_point = state["key_point"]

    # 2. 자신의 임무를 수행한다.
    action_item = f"'{key_point}' 문제 해결을 위해 서버 증설 및 코드 최적화가 필요합니다."
    
    # 3. 계약서의 'action_item' 필드에 결과를 명시적으로 '쓴다'.
    state["action_item"] = action_item
    print("Agent 2: '조치 사항' 칸을 채웠습니다.")

    # 4. 최종 업무 처리서를 반환한다.
    return state

솔라는 두 에이전트 함수를 완성하고 실행했다. 화면에는 에이전트들이 각자의 임무를 수행하며 state의 특정 필드를 읽고 쓰는 과정이 선명하게 나타났다.

Agent 1: 업무 처리서를 받았습니다.
Agent 1: '핵심 문제' 칸을 채웠습니다.
Agent 2: 수정된 업무 처리서를 받았습니다.
Agent 2: '조치 사항' 칸을 채웠습니다.

최종적으로 출력된 state를 확인하자, key_pointaction_item 필드가 각 에이전트가 만든 결과물로 완벽하게 채워져 있었다.

솔라는 비로소 깨달았다. ‘State는 user_input, key_point, action_item을 전달한다’는 문장의 진짜 의미를. ‘전달한다’는 것은 자동적인 흐름이 아니었다. 그것은 Agent1key_point 필드에 값을 쓰고, Agent2가 그 key_point 필드의 값을 읽는 명시적인 행위의 연속이었던 것이다. 데이터 흐름의 모든 단계는 개발자가 직접 코드로 제어하는 것이었다.

“이제 알겠어. 에이전트를 연결하고, 데이터 계약서를 만드는 것까지가 시스템의 판을 짜는 거라면, 각 에이전트 함수 안에서 어떤 필드를 읽고 쓸지 정하는 건 배우들의 동선을 짜는 것과 같구나. 이 동선이 명확해야 엉뚱한 곳에서 헤매지 않는 거고.”

솔라는 자신이 만든 두 개의 에이전트 함수를 뿌듯하게 바라봤다. 이제 그녀는 두 에이전트가 어떤 데이터를, 어떤 순서로, 어떻게 주고받는지 완벽하게 제어할 수 있었다. 막연한 자동화에 대한 기대를 버리자, 오히려 훨씬 더 강력한 통제력을 얻게 된 것이다. 이제 솔라는 두 에이전트가 협력하는 작은 시스템을 자신 있게 설계하고 구현할 수 있었다.