페이지 목차
이 페이지에서 검색
멀티 에이전트
이 페이지에 묻기
멀티 에이전트 · 현재 차시와 목차 본문에서만 찾아 답합니다. 답을 지어내지 않고 해당하는 대목을 그대로 보여 주며, 여기에 없으면 없다고 알려 드립니다.
생성형 AI가 아니라 이 페이지 본문을 찾아 그대로 보여 주는 방식입니다. 외부로 나가는 요청이 없습니다.
MULTI · AGENT
멀티 에이전트
생성 수정
도구 실행·검색·기억·그래프 흐름·전문 에이전트의 협업을 구성하고 권한·평가·사람의 확인으로 통제합니다.
26개 문서 · 2일차 · 17 / 26
16차시 · 멀티턴 대화와 세션 관리
지도 위치 : 2일차 · README 4장의 ④ 기억 층 — 기억 층의 첫 칸, 한 대화 안의 앞말을 이어받는 자리 | 이전: 15차시 검색 품질 개선 | 다음: 17차시 장기 기억 관리 키워드 : 대화 이력, 세션, 컨텍스트 윈도우
이 차시를 다 읽으면
- ⭐ "API는 무상태이고, 기억은 앱이 이력을 들고 매번 다시 보내는 것"을 축 ⑦로 설명할 수 있다.
- 세션을 유와 종차로 정의하고, 루프의
messages(9차시) · 장기 기억(17차시)과 구별할 수 있다. - 세션 저장소를 메모리 ↔ Redis/DB로 비교하고, 서버가 여러 대일 때 무엇이 달라지는지 말할 수 있다.
- 컨텍스트 윈도우를 정의하고, 이력이 길어질 때의 문제와 세 가지 전략(슬라이딩 윈도 · 요약 · 핵심 사실 고정)을 비교할 수 있다.
- ⚠️ 이력을 자를 때
tool_use/tool_result짝을 깨지 않는 법과, 세션 분리 실패가 왜 개인정보 사고인지 설명할 수 있다.
0. 이 차시의 판별 축
| # | 축 | 왼쪽 (0) | 오른쪽 (1) | 이 축으로 갈리는 것 | 큰 축 (README 5장) |
|---|---|---|---|---|---|
| 1 | ⭐ 무상태 ↔ 상태 | LLM API — 지난 요청을 모름 | 앱 — 세션별로 이력을 들고 다시 보냄 | 대화가 "이어지는" 이유 | ⑦ 무상태 ↔ 상태 |
| 2 | 디스크 ↔ 메모리 | Redis · DB에 저장된 세션 | 앱 프로세스 안의 dict | 재시작 · 여러 서버에서 이력이 남는가 | ① 디스크 ↔ 메모리 |
| 3 | 단기 ↔ 장기 기억 | 이번 세션의 대화 | 세션을 넘어 남는 사용자 정보 | 이 차시 ↔ 17차시 | 파생 축 (부모 ①) |
| 4 | 이력 전부 ↔ 줄인 이력 | 처음부터 다 보냄 | 잘라 내거나 요약해 보냄 | 비용 · 지연 ↔ 잊어버림 | 이 차시의 축 |
이 차시의 질문 — 직원이 "경조휴가 며칠이야?"라고 묻고, 이어서 "그거 반차로 나눠 써도 돼?"라고 묻습니다. 사람에게는 당연한 "그거"가, 무상태 API에게는 아무것도 가리키지 않습니다. 9차시에서는 루프 한 번 안에서
messages가 쌓였습니다. 이 차시는 그 쌓임을 요청과 요청 사이로 넓힙니다. README가 축 ⑦의 정점으로 꼽은 자리입니다.
1. ⭐ 축 ⑦의 정점 — API는 기억하지 않는다
1.1 멀티턴은 "다시 보내기"다
앞에서 배운 대로(2차시) LLM API는 무상태입니다. 두 번째 질문을 보낼 때 첫 번째 질문과 답을 함께 보내지 않으면 , 모델은 첫 번째를 모릅니다.
1턴 보냄: [user "경조휴가 며칠이야?"]
받음: assistant "본인 결혼 5일, ... [취업규칙 제24조, p.13]"
2턴 보냄: [user "경조휴가 며칠이야?", assistant "본인 결혼 5일 ...", user "그거 반차로 나눠 써도 돼?"]
받음: assistant "경조휴가는 ... " ← "그거" = 경조휴가임을 앎
history = [] # ⚠️ 이 줄이 이 절의 함정 (2절)
def chat(user_text: str) -> str:
history.append({"role": "user", "content": user_text})
response = client.messages.create(model=MODEL, max_tokens=16000,
system=SYSTEM, messages=history)
history.append({"role": "assistant", "content": response.content})
return "".join(b.text for b in response.content if b.type == "text")
🔍 읽는 포인트 : messages=history — 매번 이력 전체 를 보냅니다. 기억은 모델 안이 아니라 history라는 앱의 변수 안 에 있습니다.
1.2 반례 — 이건 API가 기억하는 것이 아닙니다
| 흔한 말 | 실제로 일어난 일 |
|---|---|
| "Claude 채팅 앱은 앞 대화를 기억하던데?" | 채팅 앱 이 이력을 저장하고 매번 다시 보냄. API 자체는 무상태 |
| "프롬프트 캐싱을 켜면 모델이 기억한다" | 캐싱은 같은 앞부분의 처리 비용을 줄일 뿐 (10차시). 보내지 않은 내용은 모름 |
| "대화 ID를 API에 넘기면 이어진다" | 이력을 보내는 것은 여전히 앱의 일 |
2. 세션 — 누구의 이력인가
2.1 정의와 경계
1.1의 코드에는 큰 함정이 있습니다. history가 하나 뿐이라, 직원 A와 직원 B가 동시에 쓰면 같은 이력에 섞입니다.
세션 (Session) — [앱의 상태 관리 단위]인데, [ 한 사용자의 한 대화를 세션 ID로 묶어, 그 대화의 이력을 요청 사이에 보관한다 ]는 점에서 요청 하나가 끝나면 사라지는 루프의 messages와, 세션을 넘어 남는 장기 기억과 구별된다.
루프의 messages (9차시) | 세션 이력 (이 차시) | 장기 기억 (17차시) | |
|---|---|---|---|
| 범위 | 질문 하나 처리하는 동안 | 대화 하나 (여러 턴) | 대화를 넘어 |
| 언제 사라지나 | 요청이 끝나면 | 세션 만료 · 종료 시 | 정책에 따른 보관 기한 |
| 오피스봇 예 | 잔여 조회 → 규정 확인 | "그거" = 경조휴가 | "이 직원은 개발팀, 한국어 선호" |
2.2 세션 ID와 주인 확인
sessions: dict[str, dict] = {} # 세션 ID → {"owner": 사번, "history": [...]}
def chat(session_id: str, user, text: str) -> str:
s = sessions.get(session_id)
if s is None or s["owner"] != user.employee_id: # ⭐ 이 세션의 주인인가
raise PermissionError("세션 접근 권한 없음")
s["history"].append({"role": "user", "content": text})
...
🔍 읽는 포인트 : 세션 ID만 맞으면 통과시키지 않고, 로그인한 사용자 = 세션 주인 인지 확인합니다. 앞에서 배운 권한 경계(8차시: 신원은 로그인 세션에서, 요청자 = 대상인지 코드가 검사)가 이력에도 그대로 적용됩니다. 세션 ID는 서버가 추측할 수 없는 무작위 값 으로 발급합니다.
3. 세션 저장소 — 메모리 ↔ Redis/DB (축 ①)
| 앱 메모리 (dict) | Redis | DB (테이블) | |
|---|---|---|---|
| 서버 재시작 | ❌ 모든 대화가 사라짐 | 설정에 따라 유지 | ⭕ 유지 |
| 서버 여러 대 | ❌ 다른 서버로 간 요청은 이력을 모름 | ⭕ 모든 서버가 공유 | ⭕ 공유 |
| 만료 (30분 무응답 시 삭제 등) | 직접 구현 | ⭕ 만료 시간 지정이 쉬움 | 정리 작업 필요 |
| 감사 · 분석 | ❌ | 제한적 | ⭕ 조회 · 보관 쉬움 |
| 맞는 곳 | 실습 · 시제품 | 운영 서비스의 진행 중 대화 | 대화 기록을 남겨야 할 때 |
Redis — [데이터 저장소]인데, [ 별도 서버의 메모리에 키-값을 두고 여러 앱 서버가 함께 읽고 쓰며, 항목마다 만료 시간을 줄 수 있다 ]는 점에서 앱 프로세스 안에만 있는 dict와 구별된다.
⚠️ 서버가 두 대가 되는 순간 dict 방식은 조용히 망가집니다. 첫 질문은 1번 서버로, 둘째 질문은 2번 서버로 가면 "그거"를 모릅니다. 에러는 나지 않고 답만 엉뚱해집니다.
⚠️ 저장소에 남는 이력에는 직원의 질문 · 조회 결과가 담깁니다. 보관 기한 · 마스킹 (8차시)을 저장 단계에도 적용하고, 무엇을 얼마나 남길지는 17차시에서 장기 기억과 함께 정합니다.
4. 컨텍스트 윈도우 — 이력이 길어지면
4.1 정의
컨텍스트 윈도우 (Context Window) — [모델의 입력 한도]인데, [ 한 번의 호출에서 다룰 수 있는 토큰(시스템 프롬프트 · 이력 · 도구 · 답)의 상한 ]이라는 점에서 대화를 저장해 두는 세션 저장소의 크기와 구별된다. 최신 Claude 모델은 100만 토큰입니다.
4.2 한도 안이어도 문제다
| 문제 | 왜 |
|---|---|
| 비용 | 턴마다 이력 전체를 다시 보냄. 10턴째는 1~9턴을 또 보냄 → 대화 전체 비용은 턴 수에 비례하는 게 아니라 더 빠르게 늘어남 (10차시 3.3) |
| 지연 | 입력이 길수록 첫 글자가 나오기까지 오래 걸림 |
| 묻힘 | 긴 이력 속에서 앞부분의 중요한 정보가 덜 반영되기도 함 |
| 한도 초과 | 도구 결과 · 검색 청크가 이력에 쌓이면 생각보다 빨리 커짐 |
💡 현재 이력의 크기는 client.messages.count_tokens(model=..., system=..., messages=history)로 미리 셉니다. 다른 회사의 토크나이저로 세면 맞지 않습니다.
4.3 세 가지 전략
| 전략 | 어떻게 | 장점 | 약점 | 오피스봇 |
|---|---|---|---|---|
| 슬라이딩 윈도 | 최근 N턴만 보냄 | 단순, 비용 상한이 분명 | 오래된 내용은 완전히 잊음 | 기본값 (예: 최근 10턴) |
| 요약 | 오래된 턴을 LLM으로 요약해 앞에 둠 | 오래된 맥락을 짧게 유지 | 요약 호출 비용, 요약이 틀리거나 빠뜨릴 수 있음 | 긴 상담 대화 |
| 핵심 사실 고정 | 대화 중 확정된 사실(다루는 휴가 종류, 진행 중 신청)을 별도 칸에 두고 매번 넣음 | 중요한 것은 절대 안 잊음 | 무엇이 핵심인지 정하는 규칙 필요 | 신청 절차 진행 중 |
⭐ 실무에서는 섞어 씁니다 — 최근 N턴 + 오래된 부분 요약 + 핵심 사실 고정. 그리고 어느 전략이든 이력을 바꾸면 앞부분이 달라져 프롬프트 캐시가 깨진다 (10차시 6절)는 점을 비용 계산에 넣습니다. 서버 측에서 긴 대화를 자동 요약해 주는 기능(compaction, 베타)도 있습니다. 직접 요약과의 비교는 17차시에서 다룹니다.
5. ⚠️ 도구 호출 기록까지 담은 이력 자르기
오피스봇의 이력에는 사용자 · 답 텍스트만 있는 게 아닙니다. 9차시 루프의 tool_use · tool_result도 들어 있습니다.
[0] user "다음 주 휴가 쓸 수 있어?" ← 턴 1 시작
[1] assistant tool_use(get_leave_balance) id=t1
[2] user tool_result(t1) "5일"
[3] assistant "네, 5일 남아 ..."
[4] user "경조휴가는 며칠이야?" ← 턴 2 시작
[5] assistant tool_use(search_policy) id=t2
[6] user tool_result(t2) "제24조 ..."
[7] assistant "본인 결혼 5일 ..."
| ❌ 잘못 자름 | 결과 |
|---|---|
| [2]부터 보냄 | 짝 없는 tool_result(t1) — 400 에러 |
| [6]부터 보냄 | 짝 없는 tool_result(t2) — 400 에러 |
| [5]까지만 보냄 | 결과 없는 tool_use(t2) — 400 에러 |
| ⭕ [4]부터 보냄 | 턴 2 전체 — 정상 |
def trim(history: list, max_turns: int) -> list:
starts = [i for i, m in enumerate(history)
if m["role"] == "user" and isinstance(m["content"], str)] # 사람이 쓴 메시지 = 턴 시작
return history if len(starts) <= max_turns else history[starts[-max_turns]:]
🔍 읽는 포인트 : ⭐ 자르는 자리는 턴의 시작 (사람이 직접 쓴 user 메시지)뿐입니다. tool_result도 role은 user이지만 내용이 블록 목록이라 턴 시작으로 치지 않습니다. 이렇게 하면 짝이 항상 함께 남거나 함께 잘립니다.
| 이력에 도구 기록을 | 장점 | 약점 |
|---|---|---|
| 남긴다 | 모델이 "이미 조회한 값"을 알아 같은 도구를 다시 안 부름 | 이력이 빨리 커짐, 자르기 규칙이 까다로움 |
| 턴이 끝나면 최종 답 텍스트만 남긴다 | 이력이 작고 단순 | 다음 턴에 같은 조회를 반복할 수 있음 |
6. ⚠️ 세션 분리 실패 = 개인정보 사고
이력에는 직원의 잔여 연차 · 질문 내용 · 조회 결과가 들어 있습니다. 세션이 섞이면 다른 직원에게 그 내용이 답으로 나갑니다. 모델의 실수가 아니라 앱의 사고 입니다.
| 실패 | 어떻게 생기나 | 막는 법 |
|---|---|---|
| 전역 이력 공유 | 1.1처럼 history 하나 | 세션 ID별 저장 (2.2) |
| 세션 ID 추측 | 1, 2, 3 순번으로 발급 | 무작위 · 충분히 긴 값 |
| 남의 세션 ID로 요청 | 주인 확인 없음 | 로그인 사용자 = 세션 주인 검사 (2.2) |
| 로그아웃 후에도 이력이 남음 | 만료 없음 | 만료 시간 · 로그아웃 시 삭제 |
| 공용 PC에서 이전 사용자의 대화 | 브라우저에 세션이 남음 | 로그아웃 시 세션 무효화 |
⭐ 세션 분리는 부탁이 아니라 강제 의 영역입니다. "이전 사용자의 대화는 언급하지 마"라는 프롬프트는 아무것도 막지 못합니다 — 이력이 섞인 순간 이미 모델 입력에 들어가 있기 때문입니다.
7. 오피스봇 적용 — 세 턴의 대화
| 턴 | 직원 | 이력이 하는 일 | 연결 |
|---|---|---|---|
| 1 | "경조휴가 며칠이야?" | — | 검색 · 근거 답 (14차시) |
| 2 | "그거 반차로 나눠 써도 돼?" | "그거" = 경조휴가. 이력으로 쿼리 재작성 → "경조휴가 분할 사용" | 15차시 5절의 대명사 풀기 회수 |
| 3 | "그럼 다음 주 월요일로 신청해 줘" | 휴가 종류 · 날짜를 이력에서 모음 → 신청 도구 요청 | 쓰기 → 승인 대기 (10차시, 23차시) |
턴 3에서 "신청 대상: 경조휴가, 날짜: 다음 주 월요일"은 핵심 사실 고정 (4.3) 칸에 올려 둡니다. 이력이 잘려도 진행 중인 신청 내용은 잊지 않게 합니다.
8. 2일차 정리와 3일차 예고
| 차시 | 만든 것 | 핵심 축 |
|---|---|---|
| 9 | 스스로 반복하는 에이전트 루프 | ⭐ 누가 흐름을 정하는가, ⑦ |
| 10 | 종료 조건 · 비용 상한 · 인젝션 방어 | 부탁 ↔ 강제, 한 건 ↔ 전체 비용 |
| 11 | 프레임워크 이전 · 트레이싱 · 회귀 테스트 | ② 추상 ↔ 구상, 증거 ↔ 주장 |
| 12 | 문서 로딩 · 조항 단위 청킹 · 메타데이터 | 밖에서 찾는가 ↔ 안을 고치는가, ⑤ |
| 13 | 임베딩 · 벡터 DB · 권한 필터 | ⑧, ① |
| 14 | 근거 · 출처가 붙은 답, "못 찾음" | ⭐ 증거 ↔ 주장 |
| 15 | 측정 · 하이브리드 · 리랭킹 · 쿼리 재작성 | ⑧, ⑤ |
| 16 | 세션별 대화 이력 | ⭐ ⑦ 무상태 ↔ 상태 |
2일차가 끝나면 오피스봇은 스스로 반복하고(행동), 근거를 찾고(지식), 한 대화를 이어 갑니다(기억). 하지만 세션이 끝나면 이력은 사라집니다. 내일 다시 오면 오피스봇은 이 직원이 개발팀이라는 것도, 어제 경조휴가를 물었다는 것도 모릅니다.
앞으로 다시 만납니다 — 17차시에서 세션을 넘어 남는 장기 기억 (요약 메모리 · 사용자 프로필)과 그 민감정보 관리를, 18차시에서 이 차시의 이력과 같은 생각을 그래프의 State · 체크포인트 (세션별 상태 저장 · 재개)로, 23차시에서 턴 3의 승인 대기를 다룹니다.
9. 실습 — 맡기고 검수하기
지시
오피스봇에 세션별 멀티턴 대화를 붙여줘.
- 참고: Claude API 공식 문서(messages, token counting). 확인한 주소를 보고에 적을 것
- 세션: 서버가 무작위 세션 ID 발급, {owner: 로그인 사번, history} 저장. 요청마다 owner == 로그인 사번 검사
- 저장소: 개발은 dict, 설정 하나로 Redis로 바꿀 수 있게. 30분 무응답 시 만료
- 이력 자르기: 최근 10턴 슬라이딩 윈도. 반드시 "사람이 쓴 user 메시지" 위치에서만 자를 것
- 핵심 사실 칸: 진행 중인 신청(휴가 종류·날짜)을 따로 저장해 매 요청 앞에 넣을 것
- 매 요청 전에 count_tokens로 입력 토큰을 재서 로그에 남길 것
- 저장 전 마스킹 적용 (주민번호·전화번호)
테스트: ① 3턴 대화(경조휴가 → 그거 반차로? → 다음 주 월요일 신청)
② 15턴 대화로 자르기 동작, 도구 호출이 섞인 턴에서 잘릴 때 400 에러가 없는지
③ 직원 A 세션 ID로 직원 B 로그인 상태에서 요청
④ 서버 재시작 후 같은 세션 이어 묻기 (dict / Redis 각각)
추측하지 말고 실행 출력 원문을 붙여.
보고 형식: [테스트 | 턴 | 보낸 메시지 수 | 입력 토큰 | 결과 | 에러 여부] 표
받을 증거
- 테스트 ①~④의 표와 요청별 로그 원문
- ②에서 자르기 직전 · 직후의 이력 구조(역할과 블록 종류만)
- ③의 거부 응답 원문
내가 직접 확인할 것
| 볼 것 | 이걸로 아는 것 |
|---|---|
| ⭐ 브라우저 두 개(또는 일반 창 + 시크릿 창)에 다른 직원으로 로그인해 번갈아 질문 — 한쪽 답에 다른 쪽 내용이 섞이는가 | 세션 분리 (2절, 6절) |
| 한쪽 창의 세션 ID를 다른 창에 붙여 넣어 요청 | 주인 확인 (2.2) |
| ①의 턴 2 답이 "그거"를 경조휴가로 이해했는가 | 이력이 다시 보내지는가 (1.1) |
| 15턴 대화의 입력 토큰이 10턴 이후 더 늘지 않는가 | 슬라이딩 윈도 동작 (4.3) |
| 오래된 턴이 잘린 뒤에도 진행 중 신청 내용을 기억하는가 | 핵심 사실 고정 (4.3, 7절) |
| ④에서 dict는 잊고 Redis는 기억하는가 | 축 ① (3절) |
부록 A. 스스로 확인하기
| # | 질문 | 답이 있는 곳 |
|---|---|---|
| 1 | API가 무상태인데 대화가 이어지는 이유는? | 1.1 |
| 2 | "채팅 앱이 기억한다"와 "API가 기억한다"의 차이는? | 1.2 |
| 3 | 세션 · 루프의 messages · 장기 기억은 무엇으로 갈리나? | 2.1 |
| 4 | 세션 ID만 맞으면 통과시키면 안 되는 이유는? | 2.2 |
| 5 | 서버가 두 대가 되면 dict 저장소에 무슨 일이? | 3절 |
| 6 | 컨텍스트 윈도우 안이어도 긴 이력이 문제인 이유는? | 4.2 |
| 7 | 슬라이딩 윈도 · 요약 · 핵심 사실 고정의 약점은? | 4.3 |
| 8 | 이력을 자를 때 400 에러가 나는 경우와 안전한 자리는? | 5절 |
| 9 | 세션 분리 실패가 프롬프트로 막히지 않는 이유는? | 6절 |
부록 B. 자주 하는 실수
| 실수 | 바로잡기 |
|---|---|
| 전역 변수 하나에 이력을 쌓음 | ❌ 모든 사용자가 섞임. 세션 ID별로 |
| 세션 ID를 순번으로 발급 | ❌ 추측 가능. 무작위 값 |
| 세션 ID만 확인하고 주인 확인 생략 | ❌ 로그인 사용자 = 세션 주인 검사 |
| 메시지 개수로 앞에서부터 잘라 냄 | ⚠️ tool_use / tool_result 짝이 깨져 400. 턴 시작에서만 |
| "100만 토큰이니 이력은 다 보내도 된다" | ⚠️ 비용 · 지연 · 묻힘. 줄이는 전략 필요 |
| 운영에서도 dict 저장소 | ⚠️ 재시작 · 서버 여러 대에서 조용히 망가짐 |
| 요약에 맡기고 진행 중 신청 내용을 잃음 | ⭕ 핵심 사실은 따로 고정 |
| 이력을 마스킹 없이 오래 보관 | ⚠️ 보관 기한 · 마스킹 (17차시에서 정책) |
부록 C. NotebookLM 복습 프롬프트
이 16차시 노트만 근거로 6문제를 내줘. 비전공자 기준으로.
"API는 무상태, 앱이 이력을 다시 보낸다(축 ⑦)", "세션과 주인 확인", "컨텍스트 윈도우와 세 가지 줄이기 전략", "tool_use/tool_result 짝을 깨지 않고 자르기"는 반드시 포함해줘.
답은 내가 답한 다음에 채점하고, 틀린 이유를 노트의 절 번호를 근거로 설명해줘.
LangTailor