[2026 하계 모각코] - Team 하모 6회차

농담곰·2026년 7월 17일

2026.07.17 19:00 - 22:00
Discord 영상통화로 진행

Week 06 — 혼자 다 하던 Agent를 부서로 나누기 (Supervisor + 하위 Agent)

들어가며

5주차까지 만든 걸 돌아보면 이렇다.

1주차: 일정 만들기, 조회, 삭제
2주차: 자연어를 구조화
3주차: SQLite에 저장, 수정, 삭제
4주차: 참고자료 저장/검색 (RAG)
5주차: 외부 대화 검색, 남의 일정 가져오기, 여러 사람 일정 합치기

week05_tools()가 반환하는 도구 개수를 세보면 20개가 넘는다. 1~4주차 도구를 다 물려받고 5주차 도구까지 얹었으니까.

6주차는 이 구조를 바꾼다. 한 agent가 다 하는 대신, supervisor가 요청을 듣고 전문 하위 agent에게 나눠 맡기는 구조로.


왜 나눠야 하나

편의점 알바를 생각해보면 쉽다. 처음엔 계산만 했는데 점점 일이 늘어난다.

계산 + 재고정리 + 택배접수 + 커피머신 관리 + 폐기처리 + 발주...

한 사람이 다 하려니 뭘 먼저 할지 헷갈리고 실수도 늘어난다. LLM도 똑같다. 도구가 20개 넘게 있으면 "이 요청엔 어느 도구를 써야 하지?" 판단이 어려워진다.

나는 이 문제를 계속 겪어왔다.

  • 3주차: personal_list_schedules(1주차, 임시 메모리)와 personal_list_saved_schedules(3주차, DB)를 헷갈려서 엉뚱한 걸 호출했다. 그래서 "저장했는데 조회하니 없다"는 현상이 생겼다.
  • 4주차: 검색 도구를 새로 만들었는데 LLM이 3주차 조회 도구를 골라버렸다.

프롬프트로 "이럴 땐 이걸 써라"고 계속 눌러왔지만, 도구가 늘어날수록 한계가 있었다.


6주차 구조: 작은 회사 만들기

        [supervisor]  ← 접수 담당. 일을 직접 안 하고 넘기기만 함
         /        \
   [Nana]        [Kana]
   개인 담당      외부/그룹 담당

supervisor_tools()를 보면 이 구조의 핵심이 드러난다.

def supervisor_tools() -> list[Any]:
    return [nana_agent, kana_agent]   # 도구가 딱 2개

20개 넘던 선택지가 2개로 줄었다. 식당에서 메뉴 100개 중 고르는 것과 "한식/중식" 둘 중 고르는 것의 차이다.

Nana는 4주차까지의 도구를 그대로 갖는다. 내 일정 CRUD, 할 일·알림 저장, 개인 메모 RAG.

Kana는 5주차 도구 + 6주차 신규 도구를 갖는다.

def kana_tools() -> list[Any]:
    return [
        extract_schedule_request,           # 2주차
        search_previous_conversations,      # 5주차
        load_conversation_messages,         # 5주차
        extract_schedules_from_history,     # 5주차
        list_shared_schedules,              # 5주차
        collect_member_schedules,           # 5주차
        find_common_available_slots,        # 6주차 신규
        decide_final_slot,                  # 6주차 신규
    ]

프롬프트를 세 벌 따로 써야 한다

이게 6주차에서 제일 헷갈렸던 부분이다. 과제 문서에 이렇게 나와 있다.

"하위 에이전트는 supervisor prompt를 공유하지 않으므로 각자 필요한 지시를 스스로 갖고 있어야 합니다."

세 agent는 서로 완전히 다른 존재다. supervisor가 아는 걸 Nana는 모른다. 그래서 각자에게 따로 "너는 이런 사람이야"를 설명해줘야 한다.

def nana_prompt_parts():
    return [*week04_prompt_parts(), NANA_ROLE_PROMPT]   # 4주차 지침 물려받음

def kana_prompt_parts():
    return [KANA_ROLE_PROMPT, KANA_TOOL_FLOW_PROMPT, KANA_DECISION_FLOW_PROMPT]  # 누적 없음

def week06_prompt_parts():
    return [*week05_prompt_parts(), WEEK06_SUPERVISOR_PROMPT]   # 5주차 지침 + 위임 규칙

kana_prompt_parts만 아무것도 물려받지 않는다. 의도된 설계다. Kana는 완전히 새 캐릭터니까 처음부터 다 써야 한다. 그래서 세 덩어리로 나눠 작성했다.

  • KANA_ROLE_PROMPT — 너는 누구고 뭘 담당해
  • KANA_TOOL_FLOW_PROMPT — 어떤 상황에 어떤 도구를 써
  • KANA_DECISION_FLOW_PROMPT — 회의 시간 잡을 땐 이 순서로 세 단계

심화 과제의 반전: 도구가 계산을 안 해준다

find_common_available_slots는 이름만 보면 "공통 가능 시간을 찾아준다"처럼 보인다. 그런데 안 찾아준다.

과제 문서가 이걸 여러 번 강조한다.

"Python tool이 자동으로 최적 시간을 고르는 것이 아니라, agent가 busy_rows를 근거로 후보와 최종 시간을 직접 골라 argument로 넘기게 만들어야 합니다."
"이 점이 description에 없으면 agent가 tool에 계산을 떠넘깁니다."

실제 흐름은 이렇다.

1) collect_member_schedules → busy_rows 받음
   나    | 7/14 15:00 | 사전 미팅
   민준  | 7/14 15:00 | 운영 회의
   민준  | 7/8  09:30 | 데이터 정리

2) LLM이 직접 계산
   "7월 8일은 민준이 9:30-10:30만 바쁘네. 오전이 좋겠다."

3) find_common_available_slots(candidate_slots=[{date: "2026-07-08", ...}])
   도구: "확인해보니 안 겹치네요, 통과"

4) LLM이 후보 중 하나 선택
   decide_final_slot(final_slot="2026-07-08 09:00-10:00", selected_index=0)
   도구: "기록했습니다"

계산은 LLM이, 검증과 기록은 도구가 한다.

왜 이렇게 만드나

도구가 계산까지 다 해주면 LLM은 결과를 받아쓰는 심부름꾼이 된다. 판단하는 주체가 파이썬 코드가 되어버리니 agent라고 부를 수 없다.

실무적으로도 LLM이 판단해야 할 이유가 있다. 코드는 "겹치지 않는 시간"만 기계적으로 찾지만 LLM은 이런 걸 고려할 수 있다.

  • "점심시간은 피하는 게 좋겠다"
  • "이 사람 일정이 오전에 몰렸으니 오후가 낫겠다"
  • 4주차에 저장한 개인 선호("나는 오전에 집중이 잘 돼")까지 반영

그래서 description을 잘 쓰는 게 심화 과제의 핵심이었다.

FIND_COMMON_AVAILABLE_SLOTS_DESCRIPTION = (
    "여러 사람의 공통 가능 시간 후보를 검증합니다. "
    "이 도구는 후보 시간을 계산해 주지 않습니다. 호출하는 agent가 busy_rows를 직접 읽고 "
    "겹치지 않는 후보를 스스로 골라 candidate_slots로 넘겨야 합니다. "
    ...
    "이 도구의 결과로 답변을 끝내지 말고, 검증된 후보 중 하나를 골라 decide_final_slot을 반드시 이어서 호출하세요."
)

마지막 문장이 중요하다. 이게 없으면 LLM이 "후보를 찾았으니 답변해도 되겠다"고 판단해서 decide_final_slot까지 안 간다.


2겹 trace와 payload 끌어올리기

지금까지 trace는 한 겹이었다.

5주차: collect_member_schedules 호출 → 결과

6주차는 이렇게 된다.

supervisor trace
  └─ kana_agent 호출
       └─ Kana 내부 trace
            └─ collect_member_schedules → find_common_available_slots → decide_final_slot

두 겹 안쪽에서 무슨 일이 있었는지 supervisor 레벨에서도 보여야 하니, inner_tool_names로 끌어올린다.

그리고 kana_agent에는 한 덩어리가 더 있다.

    for event in events:
        content = event.get("content")
        if isinstance(content, str):
            try:
                content = json.loads(content)
            except (json.JSONDecodeError, ValueError):
                continue
        if not isinstance(content, dict):
            continue
        if "final_slot" in content:
            final_slot_payload = content

Kana가 안에서 decide_final_slot을 호출했으면 그 결과에 최종 회의 시간이 들어있다. 그걸 trace를 뒤져서 찾아내 끌어올린다.

왜냐면 supervisor가 최종 답변을 만들 때 "회의 시간이 언제로 정해졌나"를 알아야 하니까. Kana의 답변 문장에도 들어있지만, 문장은 LLM이 만든 거라 부정확할 수 있다. 구조화된 payload를 직접 올리는 게 정확하다. 5주차에서 배운 "정보를 흘리지 마라"와 같은 원칙이다.


삽질: 위임이 엉뚱한 곳으로 갔다

메인 과제를 다 구현하고 테스트했다.

"내 일정 보여줘"
→ supervisor_selected_agent: "nana_agent"     ✅ 정상
→ inner_tool_names: ["personal_list_saved_schedules"]

여기까지는 좋았다. 그런데 다음 테스트에서 문제가 났다.

"7월 7일부터 17일까지 민준이랑 회의 시간 잡아줘"
→ supervisor_selected_agent: "nana_agent"     ❌ Kana여야 하는데
→ inner_tool_names: []                        ❌ 도구가 아예 안 불림

그룹 조율 요청인데 개인 담당 Nana로 갔다. Nana는 자기 담당이 아니라 도구도 못 쓰고 "회의 제목과 날짜를 알려주시겠어요?"라고 되묻기만 했다.

원인은 프롬프트였다

과제 문서에 이런 조언이 있었다.

"위임이 엉뚱한 agent로 가면 tool 구현이 아니라 prompt의 판단 기준을 먼저 고칩니다."

내가 쓴 supervisor 프롬프트를 다시 봤다.

"외부 멤버의 과거 대화 검색, 다른 사람 일정 조회, 여러 사람의 공통 가능 시간 찾기와
 최종 회의 시간 결정은 kana_agent에 위임한다."

"공통 가능 시간 찾기"라고 썼는데, 사용자는 "회의 시간 잡아줘"라고 했다. LLM이 "잡아줘"를 일정 생성으로 해석해서 Nana(일정 생성 담당)로 보낸 것이다.

기능 이름 위주로 적어서, 사용자가 실제로 쓰는 표현과 연결되지 않았다. LLM 입장에서 어떤 문장이 들어올지를 먼저 생각하지 않은 게 문제였다.

거기에 하나 더. week06_prompt_parts()week05_prompt_parts()를 물려받으니 1~5주차 지침이 다 들어있다. 그중엔 "일정 요청이 오면 personal_create_schedule을 써라" 같은 1주차 지침도 있다. supervisor는 그 도구가 없는데도 그 지침 때문에 "이건 개인 일정 업무"라는 인상을 받을 수 있었다.

해결

세 가지를 고쳤다.

WEEK06_SUPERVISOR_PROMPT = (
    "[Week 6] 너는 Nana와 Kana를 관리하는 supervisor다. 이 지시는 이전 주차의 모든 지시보다 우선한다. "
    "이전 주차 프롬프트에 나온 일정 도구 이름들은 하위 에이전트가 쓰는 것이고, 너는 그 도구를 직접 호출할 수 없다. "
    ...
    "예를 들어 '민준이랑 회의 시간 잡아줘', '팀원들이랑 언제 만날까', '철수랑 일정 맞춰줘', "
    "'다음 주에 다 같이 되는 시간 찾아줘' 같은 요청이다. "
    ...
    "판단 기준: 시간을 '정해야' 하면 kana_agent, 시간이 '이미 정해져' 있으면 nana_agent다."
)
  1. 누적된 이전 주차 지침을 무력화하는 문장 추가
  2. 추상적 기능명 대신 실제 사용자 발화를 예시로 나열
  3. 두 부서를 가르는 기준을 한 문장으로 요약

Nana 프롬프트도 보강했다. 자기 담당이 아닌 요청에 되묻지 말고 짧게 담당을 알리도록. 되물으면 supervisor가 그 답을 사용자에게 전달해서 잘못된 부서에서 대화가 이어지기 때문이다.

수정 후

"7월 7일부터 17일까지 민준이랑 회의 시간 잡아줘"
→ supervisor_selected_agent: "kana_agent"     ✅
→ inner_tool_names: [
     "extract_schedule_request",
     "collect_member_schedules",
     "find_common_available_slots",
     "decide_final_slot"
   ]                                          ✅ 3단계 체인 완성
→ final_slot_payload: {"final_slot": "2026-07-07 09:00-10:00", ...}

그리고 LLM이 직접 계산했다는 증거도 trace에 남았다.

"candidate_slots": [
  {"date": "2026-07-07", "start_time": "09:00", "reason": "7월 7일은 두 분 모두 일정 없음"},
  {"date": "2026-07-10", "start_time": "09:00", "reason": "7월 10일 오전 두 분 모두 가능"},
  ...
],
"llm_reason": "민준님과 나의 바쁜 시간을 고려해 7월 7일, 10일, 13일 오전 중 1시간씩 가능한 후보를 골랐습니다."

민준이 바쁜 7/8, 7/9, 7/14와 내가 바쁜 7/14, 7/17을 다 피해서 7/7, 7/10, 7/13을 골랐다. 도구가 아니라 LLM이 판단한 결과다.


멘토 리뷰로 발견한 경계 케이스 두 개

동작은 되는데, 규칙에 구멍이 있었다.

1) 참석자는 있지만 시간이 이미 정해진 요청

"2026년 8월 20일 오후 3시부터 4시까지 민준과 프로젝트 킥오프 일정으로 저장해줘"

내 프롬프트의 두 규칙이 이 요청에서 충돌한다.

"다른 사람과 시간을 맞추는 요청이 조금이라도 포함되면 kana_agent"
   → 민준이 있으니 Kana?

"날짜와 시간이 이미 정해져 있고 저장만 하면 되는 요청은 nana_agent"
   → 시간이 확정이니 Nana?

정답은 Nana다. 시간이 확정됐으니 조율할 게 없고 저장만 하면 된다. Kana가 끼어들면 불필요한 조율 단계가 생긴다.

판단 기준을 "참석자 유무"에서 "시간 확정 여부"로 통일했다.

2) 날짜 범위 없는 조율 요청

"민준과 회의 시간을 잡아줘"

내 Kana 프롬프트는 "회의 시간을 잡아달라는 요청에는 세 단계를 반드시 이어서 수행한다"고만 쓰여 있었다. 날짜 범위가 없는 경우를 전혀 다루지 않았다.

collect_member_schedulesdate_from, date_to는 필수 인자다. 사용자가 안 주면 LLM이 임의로 채운다. 그러면 사용자가 의도하지 않은 기간에서 회의가 확정된다.

멘토님이 던진 질문이 핵심이었다.

"날짜 범위가 주어지지 않은 상태에서 AI Agent가 임의로 회의를 잡는게 적절할까요?"

적절하지 않다. 일정 조율은 틀리면 실제로 약속이 깨진다. 그래서 도구를 호출하지 않고 되묻도록 프롬프트를 고쳤다.

"'다음 주', '8월 10일부터 14일까지'처럼 기간을 특정할 수 있는 표현이 없으면 "
"날짜 범위를 임의로 정하지 말고 도구를 호출하지 않는다. "
"대신 사용자에게 어느 기간에 회의를 잡을지 되묻고, 예시로 '다음 주' 또는 "
"'8월 10일부터 14일까지' 같은 형식을 함께 안내한다."

"도구를 호출하지 않는다"를 명시한 게 포인트다. "되물어라"만 쓰면 LLM이 도구를 부르면서 동시에 되묻는 경우가 생긴다.

이건 2주차에서 배운 것과 같은 문제였다. 당시 "확실하지 않은 값은 억지로 만들지 말고 None으로 두라"는 지적을 받았는데, 그때는 start_time을 지어내는 문제였고 이번은 날짜 범위를 지어내는 문제다. 스케일만 커졌다.


5주차 코드도 고쳐야 했다

6주차가 collect_member_schedules의 rows를 busy_rows로 그대로 쓰기 때문에, 5주차에 있던 버그를 먼저 잡아야 했다. 공지로 두 가지 버그가 공유됐다.

버그 1: 저장된 내 일정을 kind="personal_schedule"로만 읽어서 그룹 일정이 빠졌다. 그러면 이미 잡아둔 회의 시간이 "빈 시간"으로 추천된다. (내 코드는 원래 kind 필터를 안 걸어서 이건 해당 없었다.)

버그 2: 앱 DB와 공유 저장소가 같은 일정을 다르게 다듬어서 중복이 안 걸러졌다.

필드앱 DB공유 저장소
제목원문 그대로소괄호 제거
end_time"미정" → "18:00""미정" 그대로

"팀 회의 (온라인)" 하나를 저장해도 rows에는 "팀 회의 (온라인)""팀 회의"가 각각 남는다.

그래서 (member_name, date, start_time, 소괄호 제거한 제목)을 키로 dedupe하도록 고쳤다. end_time은 앱 DB 경로에서 이미 바뀐 값을 되돌릴 수 없으니 키에서 뺐다.


배운 것

나누는 것은 절반이고, 나머지 절반은 프롬프트다

도구를 부서로 나눠도 프롬프트가 부실하면 LLM이 엉뚱한 곳으로 보낸다. 3주차엔 도구 이름이 비슷해서 헷갈렸고, 4주차엔 목적이 비슷한 도구(조회 vs 검색) 사이에서 헷갈렸고, 6주차엔 그 혼동이 "어느 부서로 보낼지"라는 더 큰 단위로 올라왔다.

매번 결론은 같았다. 판단 기준을 프롬프트에 구체적으로, 예시와 함께 써야 한다는 것.

기능 이름이 아니라 사용자 발화로 써야 한다

"공통 가능 시간 찾기"라고 쓰면 LLM은 그 기능이 뭔지는 알지만, "회의 시간 잡아줘"가 그 기능인지는 모른다. 예상되는 사용자 발화를 먼저 적어보고 그걸 기준으로 규칙을 세우는 게 낫다.

규칙을 쓸 때 "이 조건이 없으면?"을 함께 봐야 한다

멘토 리뷰로 지적받은 두 케이스가 모두 이 문제였다. "참석자가 있으면 Kana"라고 썼을 때 "참석자가 있는데 시간도 정해진 경우"를 생각하지 않았고, "세 단계를 수행한다"고 썼을 때 "날짜 범위가 없는 경우"를 생각하지 않았다.

trace 없이는 알 수 없다

위임이 잘못된 것도 답변만 봐서는 몰랐다. supervisor_selected_agentinner_tool_names를 보고서야 원인을 잡았다. 2겹 구조가 되면서 이게 더 중요해졌다. 어느 층에서 문제가 생겼는지 구분해야 하니까.

profile
둥글게살자

0개의 댓글