LLM에이전트

LLM 에이전트의 상태 설계: 비선형 대화 흐름에서의 맥락 보존

2026. 08. 05

KT 유선가입 상용 에이전트에서 사용자가 대화 도중 다른 주제로 이탈했다 돌아오면 맥락이 사라졌습니다. 단계를 코드에 고정하는 대신 단일 상태 객체와 슬롯 충족으로 흐름을 재구성한 과정입니다.

프로젝트 배경

소속
휴트리온 · AX·DX사업부 AI개발팀 (Client: KT / KT ds)
프로젝트
MyK K-Intelligence 2차 · 유선가입 AI 에이전트
기간
2026.02 ~ 2026.06 (상용 오픈·운영 이관 완료)
담당
유선 도메인 AI 백엔드 3인 전담 · 설계~운영 이관 전 주기

KT의 대화형 AI 플랫폼 K-Intelligence 위에서, 인터넷·TV 상품을 자연어 대화만으로 탐색하고 최종 가입까지 완결하는 상용 에이전트를 구축한 프로젝트입니다. 전체 120명+ 규모 사업에서 유선 상품 도메인의 AI 백엔드를 3인이 전담했으며, 아키텍처 설계부터 외부 API 연동, QA, 상용 오픈, 운영 이관까지 전 주기를 담당했습니다. 여러 도메인 에이전트가 함께 동작하는 멀티 에이전트 환경이라, 사용자가 대화 도중 다른 에이전트로 이탈했다 돌아오는 상황이 일상적으로 발생했습니다.

LLM에이전트상태관리오케스트레이션SlotFilling아키텍처

1. 배경: 대화만으로 가입을 완결시켜야 하는 요구

KT 유선가입 에이전트는 대화만으로 가입 절차를 끝까지 완료시켜야 했습니다. 상품을 고르고, 필요한 정보를 몇 단계에 걸쳐 받고, 확인한 뒤 최종 주문까지 이어지는 흐름입니다.

기획서상의 대화는 아름다웠습니다.

사용자: 이거 신청하고 싶어요
  봇  : 어떤 걸로 하시겠어요?
사용자: A로 할게요
  봇  : 언제로 해드릴까요?
사용자: 다음 주 화요일이요
  봇  : 확인되었습니다

실제 사용자의 발화는 달랐습니다.

사용자: 이거 신청하고 싶어요
  봇  : 어떤 걸로 하시겠어요?
사용자: 근데 요금이 어떻게 되죠?     ← 다른 주제로 이탈
  봇  : (다른 에이전트로 넘어감)
사용자: 아 됐고, 아까 그거 A로 다음 주 화요일에 해주세요
                                    ← 복귀 + 두 단계 동시 입력

이 단일 발화에서 기존 구조의 한계가 드러났습니다.

2. 문제 정의: 복귀 시 맥락 소실

증상은 세 가지 형태로 나타났습니다.

첫째, 이탈 후 복귀하면 맥락이 사라졌습니다. 사용자가 다른 주제를 묻고 돌아오면 진행 단계가 초기화됐습니다. 이미 고른 항목을 다시 물었습니다.

둘째, 단계를 건너뛰면 처리하지 못했습니다. 시스템은 1단계 다음에 2단계를 기대하는데 사용자가 3단계 정보를 먼저 말하면, 그 정보를 버리고 다시 2단계를 물었습니다.

셋째, 한 문장에 여러 의도가 섞이면 하나만 처리했습니다. "A로 다음 주 화요일에"는 두 개의 정보인데 앞의 것만 반영되고 뒤의 것은 사라졌습니다.

공통점은 명확했습니다. 사용자는 이미 말했는데 시스템이 기억하지 못하니, 같은 걸 또 물었습니다. 그리고 사용자는 그 지점에서 대화를 그만뒀습니다.

3. 기술적 난점

근본 원인은 LLM이 무상태(stateless) 라는 점입니다. LLM은 이번 턴에 넣어준 것만 압니다. 이전에 무슨 얘기가 오갔는지, 지금 몇 단계인지 스스로 알지 못합니다.

그렇다고 대화 기록을 통째로 넣는 방식은 답이 아니었습니다. 턴이 쌓일수록 컨텍스트가 길어져 비용과 지연이 늘고, 무엇보다 길어진 히스토리 속에서 모델이 "지금 확정된 값"을 잘못 읽는 일이 생겼습니다. 사용자가 중간에 마음을 바꿔 A에서 B로 변경했는데, 히스토리에 A가 여러 번 등장하면 모델이 A를 고르는 식입니다.

초기 구조에는 더 깊은 문제도 있었습니다. 추론과 정책과 상태가 전부 하나의 큰 프롬프트 안에 섞여 있었습니다. 그래서 정책이 하나 바뀌면 프롬프트 전체를 고쳐야 했고, 고치고 나면 무엇이 깨졌는지 알 수 없어 전체를 다시 검증해야 했습니다. 배포가 계속 밀렸습니다.

4. 대안 검토

1안. 히스토리를 더 잘 넣는다

요약해서 넣거나, 최근 N턴만 넣는 방식입니다.

  • 장점: 구조를 바꾸지 않아도 됩니다.
  • 단점: 근본 해결이 아닙니다. 요약 과정에서 정보가 유실되고, 요약 자체를 LLM이 하니 여기서도 오류가 납니다. "확정된 값"을 자연어 속에 두는 한 같은 문반복됩니다.

2안. 단계별 상태 머신을 코드로 고정합니다

1단계 → 2단계 → 3단계 순서를 코드에 명시하는 전통적인 방식입니다.

  • 장점: 예측 가능하고 디버깅이 쉽습니다.
  • 단점: 비선형 입력을 감당하지 못합니다. 건너뛰기와 복귀를 지원하려면 상태 전이를 전부 나열해야 하는데, 단계가 늘수록 경우의 수가 폭발합니다. 예외를 하나 추가할 때마다 분기가 늘어납니다.

3안. 상태를 단일 객체로 분리하고, 순서가 아니라 충족 여부로 흐름을 정합니다

대화에 필요한 모든 정보를 하나의 JSON 객체에 모으고, "다음에 뭘 할지"를 빈 슬롯이 무엇인지로 결정하는 방식입니다.

  • 장점: 순서에 의존하지 않아 건너뛰기와 복귀가 같은 로직으로 처리됩니다.
  • 단점: 상태 스키마를 처음에 잘 설계해야 하고, 상태를 다루는 계층을 직접 만들어야 합니다.

5. 설계 결정 및 근거

3안을 골랐습니다. 이유는 하나입니다. 1안과 2안은 "사용자가 예상대로 행동합니다"는 전제를 유지한 채 예외를 덧붙이는 방식인데, 제 문제는 그 전제 자체가 틀렸다는 것이었기 때문입니다.

상태의 단일 객체 응집

대화 진행에 필요한 모든 것을 단일 JSON 객체에 응집시켰습니다.

  • 현재 단계와 직전 단계
  • 라우터가 내린 판정 결과
  • 사용자가 채운 슬롯 값들
  • 다른 주제를 경유한 이력

매 턴 이 사이클을 반복합니다.

① 상태 파싱 (복원)  →  ② 입력 반영 (갱신)  →  ③ 다음 단계 결정 (라우팅)

핵심은 확정된 값을 자연어 히스토리가 아니라 구조화된 슬롯에 둔다는 것입니다. 사용자가 A에서 B로 바꾸면 슬롯 값이 B로 덮어써집니다. 히스토리에 A가 몇 번 등장했든 상관없습니다. 지금 유효한 값은 언제나 슬롯 하나뿐입니다.

단계 순서의 외부화

가장 중요한 결정이었습니다. 단계 순서를 코드에 고정하지 않고, 어떤 슬롯이 비어 있는가만 보고 다음 행동을 정하게 했습니다.

이 구조에서 예상하지 못한 효과가 관측됐습니다. 건너뛰기와 복귀가 별도 처리 없이 동작했습니다.

  • 사용자가 3단계 정보를 먼저 말함 → 그 슬롯이 채워짐 → 남은 빈 슬롯을 물음
  • 사용자가 이탈했습니다 복귀 → 슬롯은 그대로 있음 → 남은 빈 슬롯을 물음

두 경우가 시스템 입장에서는 완전히 같은 상황입니다. "빈 슬롯을 찾아 묻습니다"는 규칙 하나로 둘 다 처리됩니다. 예외 분기를 늘리는 대신 예외가 예외가 아니게 만든 것입니다.

선입력 정보의 버퍼 처리

아직 도달하지 않은 단계의 정보를 미리 말하는 경우가 많았습니다. 이런 값은 버리지 않고 look-ahead 버퍼에 임시로 담아뒀다가, 해당 단계에 도달하면 자동으로 꺼내 쓰도록 했습니다. 사용자 입장에서는 한 번 말한 걸 다시 말하지 않아도 됩니다.

한 문장에 여러 의도가 섞인 경우도 같은 원리입니다. 명시적인 선행 의도를 먼저 처리하고 나머지는 버퍼에 보관했다가 순차적으로 소비합니다.

추론 계층과 비즈니스 로직의 분리

단일 LLM이 전부 판단하던 구조를 여러 단계로 쪼갰습니다. 상태 확인 → 주제 분류 → 처리 주체 선택 → 유형 세분화 → 답변 생성 순입니다.

답변을 만드는 마지막 단계에는 확정된 상태 값만 넣었습니다. 이것이 환각 대책의 핵심이었습니다. 답변 생성 노드가 원본 데이터에 접근할 수 없으니, 확정되지 않은 것을 지어낼 방법 자체가 없습니다. 프롬프트로 "지어내지 마"라고 지시하는 대신, 구조적으로 불가능하게 만든 것입니다.

부수 효과로 운영이 편해졌습니다. 정책이 바뀌면 백엔드 코드만, 문구가 바뀌면 프롬프트만 고치면 됩니다. 서로를 건드리지 않으니 전체 회귀 검증 없이 당일 배포가 가능해졌습니다.

6. 실험 결과

측정 항목개선 전개선 후
답변 성공률78% (단일턴)98%+ (단일·멀티턴 평균)
평균 입력 토큰76.59k27.97k (63.4%↓)
최대 입력 토큰92.83k67.43k (27.3%↓)
평균 응답 토큰기준19.1%↓

토큰이 줄어든 건 상태 설계와 함께 진행한 별도 작업 덕입니다. 매 턴 모든 데이터를 불러오던 것을 현재 단계에 필요한 것만 선별해서 호출하도록 바꾸고, 응답에서 불필요한 필드를 제거해 경량 포맷으로 직렬화했습니다. 상태 객체가 있으니 "지금 무엇이 필요한지"를 알 수 있어서 가능했던 최적화입니다.

최대 입력 토큰이 27.3% 줄어든 것도 의미가 있었습니다. 멀티턴이 길게 이어질 때 컨텍스트 한도를 넘겨 발생하던 오버플로우 오류가 사라졌습니다.

7. 결론 및 시사점

에이전트에서 가장 어려운 건 프롬프트가 아니라 상태 설계였습니다. 초기에는 이 문제를 프롬프트로 풀려고 했습니다. "이전 대화를 참고해서 단계를 파악하라"고 아무리 정교하게 써도, 모델이 매번 다르게 해석하니 안정적일 수가 없었습니다. 모델에게 추론시키던 것을 구조로 옮기고 나서야 결과가 일정해졌습니다.

예외를 처리하지 말고, 예외가 아니게 만드는 게 낫습니다. 건너뛰기와 복귀를 각각의 예외 케이스로 다뤘다면 분기가 계속 늘었을 것입니다. "빈 슬롯을 찾는다"는 규칙 하나로 바꾸니 둘 다 정상 경로가 됐습니다. 예외가 많아진다면 그건 대개 추상화 수준이 잘못됐다는 신호라고 생각하게 됐습니다.

LLM에게 무엇을 맡기지 않을지 정하는 게 중요합니다. LLM의 역할을 "의도를 분류하는 것"으로 좁혔습니다. 값을 비교하고 유효성을 따지는 일은 코드가, 사고로 직결되는 판단은 규칙이 맡았습니다. 자동화가 잘 맞는 영역과 확정성이 필요한 영역을 나누는 것이, 모델을 더 잘 쓰는 방법보다 결과에 큰 영향을 줬습니다.

그래서 기능 구현보다 상태 흐름과 예외 경로를 먼저 설계하게 됐습니다. 어떤 값이 언제 확정되고 언제까지 유지되며 어디서 파기되는지를 먼저 그리고 나면, 나머지 구현은 그 위에 얹는 일이 됩니다.