👋 환영합니다! 쥐뿔도 모르는 개발자가 백엔드, 인프라, 트러블슈팅 등의 경험을 공유하고 기록하는 개발 블로그입니다 ✨
[부동산 통합 AI 에이전트 2/2편] LLM과 코드의 역할을 나누기까지

[부동산 통합 AI 에이전트 2/2편] LLM과 코드의 역할을 나누기까지

2026년 11월 13일

전편: [부동산 통합 AI 에이전트 1/2편] AI의 답을 서비스로 이어지게 만들기까지

저번 편에서는 부동산 통합 AI 에이전트를 만들면서 AI의 답을 서비스 기능으로 이어지게 한 과정을 살펴보았습니다.

1편을 읽지 않으신 분들을 위해 짧게 정리하면 이렇습니다. 저희는 기획 변경에 맞춰 부동산 통합 AI 에이전트를 만들어 운영에 반영했습니다. 대화 하나에서 단지를 찾고, 시세를 묻고, 담당 중개사에게 신청하는 것까지 이어지는 기능입니다.

기존 AI 추천은 LLM이 계획을 한 번 세우면 코드가 그대로 실행하는 구조였는데, 이번에는 LLM이 도구를 고르고 결과를 보고 다음 도구를 고르는 Agent Loop로 바꿨습니다. 라우터는 두지 않고 모든 질문을 이 루프 하나로 처리합니다. 화면에서는 숫자를 코드가 만든 카드로 보여주고, 거래 유형이 빠지면 매매로 가정해 결과부터 보여준 뒤 버튼으로 묻습니다. 중개 신청은 사용자가 확인 카드를 누르면 앱과 웹의 기존 신청 흐름으로 진행됩니다.

그런데 AI의 답이 신청이나 기존 화면까지 연결되다 보니, AI가 틀렸을 때의 영향도 화면의 숫자나 실제 신청까지 미칠 수 있었습니다. 이번 글은 그 문제에서 출발해볼까 합니다. LLM에게 맡길 일과 서비스가 책임질 일을 어떤 기준으로 나눴는지, 그 기준을 코드로 어떻게 지켰는지, 그리고 이 구조 때문에 느려진 응답은 어떻게 했는지까지 이야기해보도록 하겠습니다.

어디까지 맡길지 정한 기준

Agent Loop에서는 어떤 도구를 몇 번, 어떤 순서로, 어떤 인자로 부를지 LLM이 정합니다. 그만큼 LLM이 틀릴 수 있는 곳도 많아지죠.

조건에 맞지 않는 단지를 맞다고 하거나, 버튼에 3곳이라고 적어놓고 5곳을 보여주거나, 사용자가 원하지 않았는데 중개 신청을 넣어버리면 사용자에게 실제로 손해가 생기고, 그건 결국 저희 서비스의 문제가 됩니다.

서비스로 이어지게 하려면 사용자가 AI가 보여주는 것을 믿고 다음 행동으로 넘어갈 수 있어야 했습니다. 그래서 저는 LLM이 판단할 여지가 큰 일은 가능한 한 도구 안으로 옮기고, LLM은 저희가 만든 도구를 고르고 인자를 채우는 데까지만 하게 하는 게 맞다고 생각했습니다. 조건에 맞는지, 몇 곳인지, 실행해도 되는지는 도구와 서버에서 정하는 거죠.

이 고민과 관련해서 참고한 자료도 있었습니다. 프롬프트 인젝션 방어 패턴을 정리한 Design Patterns for Securing LLM Agents against Prompt Injections 논문을 보면, 계획을 먼저 세우고 실행하는 구조는 제어 흐름이 고정돼 있어서 인젝션이 일어나도 영향이 도구 인자에 그치지만, Agent Loop에서는 도구 결과로 들어온 텍스트가 다음 행동 선택에 영향을 줄 수 있다고 설명합니다.

지난 AI 추천 구조에서는 흐름이 고정돼 있다는 것 자체가 안전장치였는데, Agent Loop로 가면 그게 없어지게 되니 다른 방법으로 대신해야 했습니다.

그렇다면 무엇을 LLM에게 맡기고 무엇을 코드에 둘까요? 저는 판단 하나하나를 놓고, 틀렸을 때 사용자에게 손해가 생기는지, 되돌릴 수 있는지, 같은 입력에 항상 같은 결과가 나와야 하는지를 봤습니다. 앱과 웹에 이미 있는 화면이나 업무 규칙과 같아야 하는지도 함께 봤습니다.

판단누가이유
사용자가 무엇을 원하는지 해석LLM틀려도 되물으면 되고, 손해가 바로 생기지 않는다
어떤 도구를, 어떤 조건으로 부를지LLM잘못 고르면 결과를 보고 다시 고를 수 있다
언제 멈출지코드응답 시간과 비용은 매번 같은 기준이어야 한다
무엇이 조건에 맞는지, 몇 곳인지코드 (검색 엔진)화면의 숫자는 같은 입력에 같은 결과여야 한다
어떤 단지, 장소, 사용자를 가리키는지서버틀리면 엉뚱한 단지나 다른 사람의 정보로 이어진다
신청 같은 행동을 할지사용자되돌릴 수 없으니 사용자가 확인하고, 실행은 LLM을 거치지 않고 기존 신청 흐름에서 한다
복비 계산, 진행 단계 이름코드복비는 기존 계산기와, 단계 이름은 실제 업무 단계와 같아야 한다
부동산과 관련 있는 질문인지판단은 별도 LLM 호출, 막고 안내하는 건 코드판단은 문맥을 봐야 하지만, 그 결과로 무엇을 할지는 매번 같아야 한다
화면에 보여줄 카드대상이 여럿일 때만 LLM카드 안의 숫자는 코드가 만들고, LLM은 무엇을 보여줄지만 고른다
어떻게 말할지LLM다만 LLM이 볼 수 있는 정보는 코드가 정한다

원칙은 처음에 정했고, 표의 각 줄은 만들면서 하나씩 채워 나갔습니다. 한 턴의 흐름으로 그려보면 이렇습니다.

  flowchart LR
    U["사용자"] --> LLM["LLM<br/>해석 · 도구 선택 · 답변"]
    LLM -->|도구 호출| D["디스패처<br/>도구 등급 검사"]
    D --> T["도구"]
    SS["로그인 세션"] -.->|사용자 ID| T
    T --> SE["검색 엔진<br/>조건 확인 · 개수"]
    T --> ST["서버 상태<br/>참조 발급 · 선택지 조건 저장"]
    T -->|결과| LLM
    T -->|제안만| C["확인 카드"]
    C -->|사용자가 확인| EX["기존 신청 흐름<br/>(LLM을 거치지 않음)"]

디스패처는 LLM이 고른 도구를 실제로 실행하는 서버 쪽 코드입니다. 모든 도구는 조회, 제안, 확인이 필요한 쓰기 중 하나의 등급을 가지는데, 디스패처는 조회와 제안만 실행하고 쓰기 등급은 실행을 거부합니다.

신청 같은 행동은 “제안” 등급의 도구가 확인 카드에 띄울 제안으로만 저장하고, 실제 신청은 사용자가 확인 카드를 누른 뒤 앱과 웹의 기존 신청 흐름에서 일어납니다. 그래서 지금은 쓰기 등급 도구가 하나도 없습니다. 그런데도 쓰기 등급을 거부하도록 둔 건, 나중에 쓰기 도구가 실수로 추가되더라도 LLM이 바로 실행하지 못하게 하려는 거였습니다. 이 검사를 디스패처 코드에 둔 건 지시문에 적은 금지 규칙이 생각보다 자주 지켜지지 않았기 때문입니다.

“언제 멈출지"도 코드로 정했습니다. 한 턴에서 LLM을 부르는 횟수와 누적 토큰, 경과 시간에 각각 상한을 두고, 같은 도구를 같은 인자로 다시 부르는 경우에도 멈춥니다. 하나라도 걸리면 도구를 쓸 수 없게 한 상태로 한 번 더 불러서 지금까지의 결과로 답하게 합니다. 사용자 입장에서는 지금까지 찾은 결과로라도 답을 받는 게 낫다고 생각했거든요.

버튼에 적힌 숫자와 결과

저는 버튼에 3곳이라고 적었으면 눌렀을 때 3곳이 나와야 하고, “두 번째 단지"라고 하면 화면의 두 번째 단지여야 한다고 생각합니다. 그런데 개수를 세는 시점과 결과를 보여주는 시점 사이에 LLM의 해석이 들어가면 둘이 어긋날 수 있었습니다.

이 문제가 처음 드러난 건 조건 좁히기였습니다. 기획에는 결과가 많으면 조건을 좁히는 객관식 질문을 하고, 선택지마다 남는 단지 수를 보여주자는 요구가 있었습니다.

처음 떠올린 건 지시문에 “결과가 많으면 좁히는 질문을 하라"고 적는 방법이었는데, 문제는 “선택지마다 남는 단지 수"였습니다. LLM은 검색 결과 중 상위 몇 곳만 받기 때문에, 선택지마다 전체에서 몇 곳이 남는지 셀 정보가 애초에 없었습니다.

애플리케이션에서 세는 방법도 검토했지만, 검색 결과에 상한이 있어서 그 이상은 셀 수 없었습니다. 개수와 분포를 세는 건 검색 엔진이 잘하는 일이기도 했고요.

그렇다면 Elasticsearch에서 세면 되지 않을까요? 그런데 세려고 보니 지난 AI 추천 글에서 이야기했던 포스트 필터링이 걸렸습니다.

검색 인덱스는 단지 단위라서 ES는 “이 단지의 평형 범위가 조건과 겹치는지"까지만 보고, “같은 평형에서 가격과 방 개수가 동시에 맞는지"는 검색 후에 애플리케이션에서 다시 걸렀습니다. 조건을 확인하는 곳이 두 곳으로 나뉘어 있었던 거죠. 이 상태로 ES에서 집계를 하면 개수가 실제보다 크게 나옵니다.

결국 조건 확인을 ES 한 곳으로 모았습니다. 평형별 시세를 nested 필드(배열 안의 객체 하나하나를 따로 검색할 수 있게 하는 필드)로 다시 색인하고, 개수와 분포도 ES에서 셉니다.

  flowchart TB
    subgraph Before["이전: 조건 확인이 두 곳에 나뉨"]
        direction LR
        A1["ES<br/>단지의 평형 범위가 겹치는지"] --> B1["애플리케이션<br/>같은 평형에서 가격 · 방까지 맞는지"]
        B1 --> C1["결과<br/>(개수를 셀 수 없음)"]
    end
    subgraph After["이후: ES 한 곳에서 확인"]
        direction LR
        A2["ES (nested)<br/>같은 평형 안에서 모든 조건을 확인"] --> C2["결과 + 개수 · 분포"]
    end

확인하는 위치를 옮기면 기존 추천 결과가 바뀔 수 있어서, 옮기기 전에 결과가 같은지부터 봤습니다. 기존 방식과 216개 조건으로 비교했을 때 불일치는 0건이었습니다.

좁히기 질문 하나 때문에 검색 인덱스를 다시 만드는 게 과한가 싶기도 했는데, 그래도 화면에 보이는 숫자가 틀리는 걸 그대로 둘 수는 없다고 생각했습니다.

어떤 조건으로 좁힐지도 코드가 정합니다. 후보 조건마다 선택지별 개수를 병렬로 센 뒤, 가장 큰 선택지가 전체에서 차지하는 비율이 가장 작은 조건, 즉 결과를 가장 고르게 나누는 조건을 고릅니다. LLM에게는 질문을 문장으로 다듬는 일만 맡겼습니다.

1
2
3
4
5
// 결과를 줄이는 선택지(0 < n < 전체)가 둘 이상 남는 조건 중, 가장 큰 선택지가 작을수록 잘 나누는 조건이다. (단순화한 코드)
val best = counted
    .map { (dimension, options) -> dimension to options.mapNotNull { (k, n) -> n?.takeIf { it in 1 until total }?.let { k to it } } }
    .filter { (_, options) -> options.size >= 2 }
    .minByOrNull { (_, options) -> options.maxOf { it.second }.toDouble() / total }

개수를 정확히 센 다음에도 남은 문제가 하나 있었습니다. 사용자가 “10년 이내 준공 · 2곳” 버튼을 누르면 LLM은 그 선택을 해석해서 검색 조건을 다시 만듭니다. 이때 조건을 조금이라도 다르게 만들면 버튼에는 2곳인데 결과는 3곳이 나올 수 있습니다.

그래서 선택지마다 검색 조건을 서버에 저장해두고, LLM에게는 선택지의 참조만 넘기기로 했습니다. 사용자가 버튼을 누르면 LLM은 N2 같은 참조만 도구에 넣고, 서버는 저장해둔 조건으로 그대로 검색합니다. 이때 LLM이 다른 조건을 같이 보내도 서버는 쓰지 않습니다.

1
2
3
4
5
6
// 고른 선택지가 있으면 서버가 저장해둔 검색 조건을 그대로 쓴다. 모델이 함께 보낸 다른 조건은 무시한다. (단순화한 코드)
val chosen = input.choiceRef?.let { ref -> session.pendingChoices.firstOrNull { it.ref == ref } }
if (input.choiceRef != null && chosen == null) {
    return ToolResult.error("답을 기다리는 선택지에 없는 참조입니다.")
}
val condition = chosen?.savedCondition ?: input.toCondition()

조건 좁히기. 어떤 조건으로 좁힐지와 남는 단지 수는 코드가 정하고, 버튼을 누르면 적힌 수만큼의 단지가 나옵니다

잠실동 주변을 찾자(반경 검색이라 광진구 단지도 포함됩니다) 준공 시기로 좁히는 질문이 붙었고(왼쪽), “10년 이내 준공 · 2곳"을 고르면 2곳으로 좁혀집니다(오른쪽).

물론 사용자가 “10년 이내인데 그중에 500세대 이상만"처럼 선택지를 조금 바꿔서 말하는 경우에는 이 방식을 쓸 수 없습니다. 버튼 아래에는 “원하는 조건을 직접 입력할 수도 있어요"를 두었습니다. 이렇게 직접 입력한 조건은 저장해둔 조건이 없으니 LLM이 해석해서 검색합니다.

결과가 0건일 때 보여주는 완화안에도 같은 방식을 적용했습니다. 처음에는 완화 버튼이 “가격 상한을 올려서 다시 찾아줘” 같은 문장을 다음 질문으로 보냈는데, 이 문장을 LLM이 해석하면서 버튼을 만들 때와 다른 가격 범위로 검색되는 경우가 있었습니다.

지금은 완화안도 서버가 조건을 하나씩 넓혀 미리 검색해보고, 결과가 있는 완화안만 조건과 함께 저장해둡니다. 버튼을 누르면 저장해둔 조건으로 그대로 검색합니다.

0건일 때의 완화안. 서버가 미리 검색해본 완화안만 개수와 함께 보여주고, 누르면 적힌 수만큼 나옵니다

“그 중에 더 싼 곳은 없어? 15억 이하로"라고 이어서 물으면 앞의 조건(잠실동 주변, 30평대, 10년 이내 준공)을 유지한 채로 찾고, 0곳이면 결과가 있는 완화안만 보여줍니다. “예산을 높여서 찾기 · 1곳"을 누르면 1곳이 나옵니다.

이 방식은 다른 곳에도 적용했습니다. 단지를 가리킬 때 LLM은 실제 ID 대신 서버가 붙인 C1, C2 같은 참조를 쓰고, 장소도 좌표 대신 서버가 발급한 참조로 넘깁니다. 대화에 없는 참조는 거부합니다. 다른 화면으로 보내는 링크도 마찬가지로, LLM이 “복비 계산기로 보내기” 같은 행동을 고르면 주소는 서버가 붙입니다.

이렇게 LLM에게 실제 값 대신 서버가 발급한 참조를 넘기게 한 건, LLM이 값을 직접 만들면 그 값이 실행할 때마다 달라질 수 있기 때문이었습니다. 참조만 다루게 하면 LLM이 틀리더라도 “다른 참조를 고르는 것” 정도로 범위가 좁아지고, 그런 실수는 화면에 바로 드러나서 확인하기도 쉬웠습니다.

앞에서 이야기한 인젝션 문제도 여기서 일부 줄어듭니다. 버튼으로 고른 조건에서는 LLM이 넘길 수 있는 게 대화 안에서 발급된 참조뿐이기 때문이죠. 물론 이것만으로 다 막을 수 있는 건 아니라서, 되돌릴 수 없는 일에 사용자 확인을 거치게 한 것과 함께 보고 있습니다.

다른 사용자의 정보

다른 사용자의 정보를 어떻게 막을지도 처음부터 고민했던 부분입니다.

기존 서비스에서 “내 관심 단지”, “내 신청 내역” 같은 조회는 로그인한 사용자가 자기 화면에서 호출하는 구조였습니다. 그런데 에이전트에서는 LLM이 도구의 인자를 만듭니다. 사용자가 대화로 “다른 사람 신청 내역도 보여줘"라고 하거나 다른 사용자의 식별자처럼 보이는 값을 입력하면, LLM이 그 값을 도구 인자에 넣을 수 있죠.

그래서 저는 LLM이 만든 인자도 사용자 입력처럼 그대로 믿지 않기로 했습니다. 사용자 ID처럼 LLM이 바꿀 수 없어야 하는 값은 대화에서 받지 않고, 도구를 실행할 때 서버가 로그인 정보로 채워 넣습니다. 대화 세션이나 현재 상태도 같은 방식으로 채웁니다.

도구 스키마에는 사용자 식별자 필드를 아예 두지 않았고, 그런 필드가 생기지 않았는지 검사하는 테스트도 두었습니다. 개인 데이터를 다루는 도구는 로그인한 사용자 범위 안에서만 조회합니다.

대화 세션도 같은 기준으로 처리했습니다. 다른 사용자의 대화 세션으로 요청이 들어오면 세션이 있는지조차 알려주지 않고 없는 세션처럼 응답합니다. 앞에서 이야기한 C1 같은 참조도 세션 안에서만 의미가 있어서, 다른 세션의 참조를 가져와도 아무것도 가리키지 않습니다.

LLM이 볼 필요가 없는 개인정보는 처음부터 넘기지 않았습니다. 예를 들어 중개사 연락처나 실명, 내부 식별자 같은 것들이죠. 화면에서도 중개사는 “가락동 담당중개사"처럼 동네 이름으로만 안내합니다.

이것도 생각보다 꼼꼼하게 봐야 했습니다. 등기분석 결과를 설명하는 기능을 붙일 때 소유자 이름 필드는 빼고 넘겼는데, 개발 중에 결과를 확인해보니 설명 문장 안에도 이름이 들어 있을 수 있었습니다. 지금은 필드를 빼는 것과 별도로 문장 안의 이름까지 가린 뒤 넘깁니다.

부동산과 관련 없는 질문

범용 LLM을 붙였으니 부동산과 상관없는 질문에도 답할 수 있습니다. 처음에는 지시문에 “부동산 외 질문에는 정중히 거절해"라고 적어두면 될 거라고 생각했는데, 그렇게 적어둔 상태에서도 게임이나 요리 질문에 답하고 있었습니다.

이걸 가볍게 넘길 수 없었던 건, 그 답도 결국 저희 서비스가 한 답이 되기 때문입니다. 틀리거나 부적절해도 책임은 서비스에 있고, 한 턴에 LLM을 여러 번 부르는 구조라 다른 용도로 쓰이면 그 비용도 저희가 냅니다.

결국 본 루프와 별개로 부동산 관련 여부만 판별하는 짧은 LLM 호출을 두고, 관련 없는 질문이면 본 루프를 멈추고 정해진 안내 문구로 답합니다.

고민했던 건 “이사 가는 날 짐 싸는 팁"처럼 부동산과 걸쳐 있는 질문이었습니다. 저는 부동산 질문을 잘못 막는 쪽이 더 문제라고 생각했습니다. 멀쩡한 부동산 질문에 답할 수 없다고 하면 사용자 입장에서는 오류와 구분하기 어렵거든요. 애매한 질문은 막지 않는 쪽으로 정했고, 본인의 서비스 이용 정보를 묻는 질문도 허용 범위에 넣었습니다.

부동산과 관련 없는 질문에는 정해진 안내로 답합니다

“오늘 저녁 메뉴 추천해줘"에는 답하지 않고, 할 수 있는 일을 안내합니다.

그런데 이걸 붙이고 나니 판별이 끝난 뒤에 본 루프가 시작돼서, 판별에 걸리는 시간(평균 1.1초)만큼 모든 질문의 첫 반응이 늦어졌습니다. 관련 없는 질문을 거르려고 모든 사용자가 1초를 더 기다리는 건 맞지 않다고 생각했습니다.

그렇다면 판별과 본 루프를 동시에 시작하면 어떨까요? 이때 판별 결과가 나오기 전에 일어나면 안 되는 건 사용자에게 답변이 보이는 것과 도구가 실행되는 것이었습니다.

그래서 본 루프는 판별과 함께 시작하되, 판별이 끝날 때까지 답변은 화면에 보내지 않고 모아두고 도구 실행은 판별 결과를 기다리도록 바꿨습니다. 부동산 질문이면 모아둔 답변부터 보내고 이어서 진행하고, 관련 없는 질문이면 본 루프를 취소하고 본 루프가 만든 내용은 이력에 남기지 않습니다.

  sequenceDiagram
    participant U as 사용자
    participant G as 주제 판별
    participant L as 본 루프
    U->>G: 질문
    U->>L: 질문 (동시에 시작)
    L->>L: LLM 호출, 답변은 모아두기
    Note over L: 화면 출력과 도구 실행은<br/>판별 결과를 기다림
    alt 부동산 관련
        G-->>L: 통과
        L-->>U: 모아둔 답변부터 보내고 이어서 진행
    else 관련 없는 질문
        G-->>L: 취소 (본 루프 결과는 남기지 않음)
        G-->>U: 정해진 안내 문구
    end

관련 없는 질문이면 판별이 끝나기 전에 본 루프가 부른 LLM 호출만큼 비용이 더 듭니다. 하지만 도구는 판별을 기다리니 낭비되는 건 호출 한 번 정도였고, 그 대신 모든 질문의 첫 반응을 앞당길 수 있어서 이쪽을 택했습니다.

대화 이력과 상태

대화 상태는 처음부터 저희가 관리해야 한다고 생각했습니다.

OpenAI의 Responses API는 대화를 공급자 서버에 저장해두고 이어 쓰는 기능을 제공합니다. 물론 이걸 쓰면 구현은 꽤 간단해집니다. 하지만 그러면 대화의 원본이 저희 밖에 있게 됩니다. 저장 기간도, 개인정보를 언제 지울지도 공급자 정책을 따라야 하고, 이어 쓸 때마다 앞의 대화가 다시 과금되니 비용이 줄어드는 것도 아니었습니다. 무엇보다 “왜 이런 결과가 나왔어요?“라는 질문을 받았을 때 볼 원본 이력을 저희 쪽에 두고 싶었습니다.

결국 공급자 저장은 끄고 대화 이력과 세션 상태를 저희 DB에 두었습니다. 대신 공급자가 해주던 일을 직접 해야 했습니다. 공급자 저장을 끄면 추론 모델이 이전 턴의 추론을 이어받지 못하기 때문에, 도구 호출과 함께 받은 추론 항목을 암호화된 형태로 저장했다가 다음 요청에 다시 보냅니다.

매 턴 LLM에게 무엇을 보낼지도 저희가 정해야 했습니다. “두 번째 단지 실거래가 어때?”, “거기 주변에 지하철역 있어?” 같은 질문에 맞는 도구를 고르려면 LLM이 앞의 대화를 알아야 합니다. 그렇다고 대화 전체를 매번 그대로 보내면 토큰이 계속 늘어나고, 오래된 도구 결과 때문에 LLM이 잘못 판단하는 경우도 생깁니다. 매 턴 보내는 문맥은 아래처럼 나눠서 조립했습니다.

  flowchart LR
    subgraph Fixed["매 턴 같음 (캐시됨)"]
        I["지시문"] --> TD["도구 정의"]
    end
    subgraph History["대화 이력"]
        OLD["오래된 턴<br/>질문과 답변만"] --> RECENT["최근 턴<br/>도구 호출 · 결과까지 원문"]
    end
    subgraph State["매 턴 새로 만듦"]
        SS["세션 상태<br/>확정된 위치 · 결과 목록 · 답을 기다리는 선택지<br/>(모두 참조로)"]
    end
    Fixed --> History --> State

세션 상태에는 지금까지 대화에서 확정된 위치와 검색 결과, 답을 기다리는 선택지를 앞에서 이야기한 참조로 정리해서 붙입니다. 덕분에 “두 번째 단지"나 “거기” 같은 말을 LLM이 해석할 때 근거가 분명해졌습니다. “두 번째 단지 담당 중개사와 이야기하고 싶어"처럼 앞의 결과를 가리키며 서비스로 넘어가는 요청도 마찬가지로 정확한 단지로 이어집니다.

LLM이 도구를 잘못 부르는 경우도 있었습니다. 없는 참조를 넣거나, 필요한 인자를 빼먹거나, 대화에 없는 단지를 가리키는 경우들이었죠. Agent Loop에서는 LLM이 도구 결과를 보고 다시 고를 수 있기 때문에, 잘못된 호출도 예외로 끊지 않고 이유를 도구 결과로 돌려주는 게 맞다고 생각했습니다. 지금은 LLM이 이 결과를 보고 인자를 고치거나 다른 도구를 골라 다시 부릅니다.

판단 근거도 남겼습니다. Agent Loop에서는 판단이 여러 번의 LLM 호출과 도구 호출에 흩어지기 때문에, 요청 단위가 아니라 실행 한 번의 전체 과정을 기록 단위로 잡았습니다. 도구 호출에는 LLM에게 보내지 않는 검색 후보와 순위도 같이 남겼습니다.

예를 들어 “강남역 근처 30평대 매매"를 물었을 때 AI가 “강남역 후보가 두 곳 있는데 어느 쪽인가요?“라고 되물은 적이 있습니다. 기록을 열어보니 같은 이름, 같은 좌표의 강남역이 노선별로 두 번 색인되어 있었고, LLM은 그 둘을 다른 장소로 보고 되물은 거였습니다. 기록 덕분에 프롬프트가 아니라 색인 데이터 문제라는 걸 바로 찾을 수 있었습니다.

지시문과 도구 설명

권한은 코드로 정했어도, LLM이 어떻게 말하고 어떤 도구를 고를지는 결국 지시문과 도구 설명으로 알려줘야 했습니다. 그런데 지시문이 생각대로 동작하지 않는 경우가 여러 번 있었고, 그러다 보니 몇 가지 경향이 보이더라고요.

예를 들어 담당 중개사와 이야기할 수 있냐고 물었더니 AI가 “앱에서 매칭을 신청해 주세요"라고 답한 적이 있습니다. 지시문에는 대화에서 바로 이어주라고 적어뒀는데도요. 원인을 찾아보니 담당 중개사를 찾는 도구의 결과 문구가 “앱의 단지 화면에서 매칭을 신청할 수 있습니다"였습니다. LLM은 이 도구 결과 문구를 따라 답한 거였습니다.

복비 할인 기준도 비슷했습니다. 할인에는 거래금액 기준이 있는데, LLM이 이 기준을 빼먹거나 다르게 말하는 경우가 있었습니다. 지시문에 기준을 적어봤지만 달라지지 않았고, 복비 계산 도구의 결과에 기준 금액을 항상 담아 보낸 뒤에야 해결됐습니다.

왜 이런 일이 생겼을까요? 실험으로 잰 건 아니지만, 두 사례 모두 지시문을 고쳤을 때는 그대로였고 도구 설명이나 도구 결과 문구를 고쳤을 때 해결됐습니다. 저는 LLM이 판단하는 시점 바로 앞에 있는 텍스트가 도구 설명과 도구 결과이기 때문이지 않나 싶습니다.

이걸 알고 나서 몇 가지를 바꿨습니다. 먼저 도구 결과 문구를 사용자에게 보여줄 문장이라고 생각하고 쓰기 시작했습니다. 저는 백엔드 입장에서 도구 결과를 그냥 데이터로 생각했는데, 도구 결과 문구가 답변의 말투와 내용에 그대로 반영되고 있었거든요.

도구 설명에는 무엇을 하는지뿐 아니라 언제 쓰지 말아야 하는지도 적었습니다. 처음에는 장소 검색, 단지 검색, 단지 상세, 실거래가, 주변 시설 정도의 도구 5개로 시작했는데, 통합 AI 에이전트를 목표로 하다 보니 지금은 30개까지 늘었거든요.

도구가 늘면서 비슷한 도구 사이에서 잘못 고르는 경우가 생겼는데, OpenAI의 에이전트 가이드에도 도구가 많을 때의 문제는 개수보다 도구 사이의 유사성에서 생긴다는 내용이 있었습니다. 중개 신청 제안은 “여기 사고 싶어"처럼 거래 의사를 밝혔을 때 쓰고 “맘에 들어"처럼 관심만 보였을 때는 쓰지 않는다는 식으로, 비슷한 도구 사이의 기준을 설명에 적어뒀습니다.

1
2
3
중개 신청 제안 도구 설명 (단순화한 예시)
- 이럴 때 씁니다: "여기 사고 싶어", "방문해서 볼 수 있어?"처럼 거래 의사를 분명히 밝혔을 때
- 이럴 때는 쓰지 않습니다: "맘에 들어"처럼 관심만 보였을 때 (이때는 권유 도구를 쓰고, 알림을 먼저 권할지는 서버가 정함)

지난 AI 추천 글에서 도구 경계를 나누는 기준으로 “LLM이 이 도구의 역할을 한 문장으로 설명할 수 있는가"를 적었는데, 이번에는 “이 도구를 쓰지 말아야 할 때를 한 문장으로 설명할 수 있는가"도 같이 보게 됐습니다.

사용자에게 보이면 안 되는 정보는 “말하지 마"라고 적지 않고, LLM이 볼 수 있는 곳에서 아예 뺐습니다. 금지 문장은 지켜지지 않았을 때 원인을 찾기도 어려웠거든요.

그리고 지시문으로 지켜지지 않는 규칙은 코드로 옮겼습니다. 예를 들어 좁히기 질문을 낸 응답에서는 다른 선택지 버튼을 또 내지 말라고 지시문에 적어뒀는데, 테스트해보니 버튼을 또 만드는 경우가 꽤 많았습니다. 같은 응답에 버튼 묶음이 두 개 생기면 사용자는 어느 쪽을 눌러야 할지 알 수 없죠. 좁히기 질문이 이미 나간 응답에서 LLM이 선택지 버튼을 또 만들면, 서버가 그 버튼을 버리고 좁히기 질문을 남깁니다.

1편에서 이야기한 권유 규칙도 마찬가지였습니다. 같은 단지에 중개를 두 번 권하지 않거나, 담당 중개사가 없는 단지에는 권하지 않는 규칙도 지시문에 적어둔 것만으로는 잘 지켜지지 않아서 권유 도구 안에서 서버가 확인합니다.

나빠지지는 않았는지

구조를 바꾸면서 기존 기능보다 나빠지지 않았는지 확인하고 싶었습니다.

먼저 기존 AI 추천에서 쓰던 예시 질문 16개를 양쪽에 똑같이 넣고, 검색 조건이 의도대로 잡혔는지 비교했습니다. 처음에는 16개 중 7개만 의도한 조건이 잡혔는데, 하나씩 보니 대부분은 조건을 잡기 전에 되물어서 생긴 차이였습니다. 처음 지시문에 되물어야 하는 경우를 너무 넓게 적어둬서, 조건이 충분한데도 묻고 있었던 거죠. 지시문을 고친 뒤에는 16개 중 15개가 맞았습니다.

다만 지시문을 고칠 때 이 16개를 보면서 고쳤으니, 이 숫자만으로 나아졌다고 말하기는 어려웠습니다. 고칠 때 보지 않은 질문으로도 따로 확인했습니다.

확인 방법결과
기존 추천 예시 질문 16개 (지시문 수정 전 → 후)7개 → 15개 의도대로
기획 시나리오 48개로 직접 대화적절 27 / 개선 필요 14 / 당시 기능 없음 7
기능을 바꿀 때마다 기존 기능 86개를 두 번씩이전과 같은지 회귀 확인
아무것도 바꾸지 않고 같은 질문 27개를 두 번19개 자동으로 같음 / 8개 사람이 확인

기능이 없던 7개는 통근 시간, 초품아, 갭 투자 금액, 급매처럼 당시 데이터나 도구가 없던 조건들이었습니다.

16개 질문을 비교하면서 기존 경로의 한계가 보이기도 했습니다. “서울아산병원 근처 전세"를 물었더니 기존 추천은 이름이 비슷한 다른 지역의 병원을 잡아 김포의 단지를 추천했고, 새 구조는 장소 후보를 보고 문맥에 맞는 서울아산병원을 골라 송파의 단지를 추천했습니다.

평가에서 가장 어려웠던 건 같은 질문도 실행할 때마다 답이 조금씩 달라진다는 점이었습니다. 한 번 돌려서 맞았다고 끝낼 수가 없었죠.

그래서 이후 지시문을 바꿀 때마다 비교할 수 있도록, 아무것도 바꾸지 않고 같은 질문을 두 번 돌렸을 때 결과가 얼마나 달라지는지부터 쟀습니다. 표의 마지막 줄이 그 결과로, 기획 시나리오와 기존 추천 예시에서 27개 질문을 골라 돌렸습니다. 원래 이 정도는 흔들린다는 기준선이 있어야, 지시문을 바꾼 뒤의 차이가 개선인지 그냥 흔들림인지 구분할 수 있다고 생각했습니다.

비교도 점수를 매기는 대신, 기존 답변과 바꾼 답변을 어느 쪽인지 모르게 나란히 놓고 사람이 더 나은 쪽을 고르는 방식으로 하고 있습니다. LLM에게 점수를 매기게 하는 방법도 검토했지만, 판정하는 쪽도 같은 방식으로 흔들리는 상태에서는 그 점수를 기준으로 삼기 어렵다고 생각했습니다.

느려진 응답 시간

앞의 16개 질문 비교에서 응답 시간 중앙값은 기존 4.9초, 새 구조 7.4초로 응답이 느려졌습니다.

어느 정도는 예상했던 부분입니다. 다음 도구를 고르는 일을 LLM에게 맡긴 만큼 LLM 호출이 여러 번 일어날 수밖에 없고, 지난 AI 추천 글에서 Agent Loop를 선택하지 않았던 이유 중 하나도 이것이었습니다. 라우터 없이 정형 질문까지 루프 하나로 처리하게 된 것도 영향이 있었고요.

그래도 이번에는 느려지는 걸 감수하기로 했습니다. 기존 구조는 이어지는 질문을 이해하지 못했고, 결과를 보고 되묻거나 조건을 바꿀 수도 없었으니까요.

느려진 만큼을 줄이려고 먼저 어디서 시간이 쓰이는지부터 쟀습니다. 개발 서버에서 2주 동안 쌓인 1,458턴을 LLM 호출 횟수별로 나눠보니 이랬습니다.

  xychart-beta
    title "LLM 호출 횟수별 한 턴 응답 시간 (개발 서버 2주)"
    x-axis ["1회", "2회", "3회", "4회"]
    y-axis "초" 0 --> 12
    bar [3.0, 4.7, 7.5, 10.1]

호출이 한 번 늘 때마다 응답 시간이 1.7~2.8초씩 늘었고, LLM을 세 번 부른 턴에서는 응답 시간의 91%가 LLM을 기다리는 시간이었습니다. 그래서 LLM을 기다리는 시간을 줄이거나, 기다리는 동안 먼저 보여줄 수 있는 걸 앞당기는 쪽에 집중했습니다.

먼저 결과가 보이기까지의 시간을 줄였습니다. 초기 버전은 답변이 다 끝난 뒤에 카드를 붙였는데, 답변이 끝나기 전에 카드를 그리게 바꾸면서 결과가 보이기까지 3초 정도 빨라졌습니다. 주제 판별을 본 루프와 동시에 돌린 것도 같은 목적이었습니다. 사용자가 체감하기에는 처음 뭔가 보이기까지의 시간이 중요하다고 생각해서, 기록에도 첫 글자가 나오기까지의 시간을 따로 남기고 있습니다.

또 프롬프트 캐시가 깨지지 않도록 문맥 조립 순서를 고정했습니다. Agent Loop는 한 턴에 LLM을 여러 번 부르고 그때마다 지시문과 도구 정의를 다시 보냅니다. 앞의 그림처럼 바뀌지 않는 부분을 앞에 두고 매 턴 바뀌는 세션 상태를 뒤에 붙이니, 같은 2주 동안 입력 토큰의 95%가 캐시에 적중했습니다. 도구 정의 순서 하나만 바뀌어도 캐시가 깨지기 때문에, 도구 정의 순서도 코드에서 정해두었습니다.

제한 시간이 적용되지 않던 도구를 고쳤습니다. 도구마다 실행 시간 제한을 걸어뒀는데, 검색 하나가 그 몇 배가 걸려도 끊기지 않았습니다. 코루틴의 withTimeout은 코루틴이 중단되는 지점에서만 취소를 확인하는데, 도구 대부분이 ES나 HTTP를 호출하는 블로킹 코드라 중단 지점이 없었던 거죠. 그래서 도구는 별도 스코프에서 실행하고, 루프는 결과를 기다리는 쪽에만 제한 시간을 걸도록 나눴습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
/**
 * 도구를 별도 스코프에서 실행하고 기다리는 쪽에만 제한 시간을 건다.
 * 기다림을 끊으면 루프는 시간 초과 결과를 받고 바로 다음 판단으로 넘어가고, 늦게 끝난 결과는 버린다.
 */
private suspend fun withWallClockTimeout(block: suspend () -> ToolResult): ToolResult =
    withContext(Dispatchers.IO) {
        val running = toolScope.async { block() }
        try {
            withTimeout(toolTimeout) { running.await() }
        } finally {
            running.cancel()
        }
    }

덕분에 도구 하나가 느려도 제한 시간이 지나면 루프는 다음 판단으로 넘어갑니다.

응답 한 번의 시간과 함께, 원하는 결과를 얻기까지 걸리는 전체 시간도 보려고 했습니다. 되묻기가 한 번 생기면 사용자는 대화 한 턴, 수 초를 더 쓰게 되니까요. 그래서 불필요한 되묻기를 줄이는 것도 응답 시간 작업에 넣었습니다. 결과를 먼저 보여주게 바꾼 것이나 0건일 때 서버가 완화안을 미리 검색해 결과가 있는 것만 버튼으로 보여주게 한 것도 같은 이유였습니다.

개발 서버 2주 기준 응답 시간 중앙값은 5.3초, 상위 10%는 9.5초였습니다. 앞의 16개 질문 비교와는 표본이 달라 바로 비교할 수는 없어서, 같은 질문으로 다시 재보려고 합니다.

마치며

지난 AI 추천 글 마지막에 “다음에 비슷한 기능을 만들 때는 처음부터 ‘LLM이 할 일’과 ‘코드가 할 일’의 경계를 먼저 정의하고 시작할 것 같다"고 적었는데, 이번에는 실제로 그 경계를 먼저 정하고 시작해봤습니다. 판단이 애매할 때마다 처음에 정한 기준으로 돌아가서 정했던 것 같습니다.

1편의 목표를 지키려다 보니 판단 하나하나를 LLM, 코드, 사용자 중 누구에게 맡길지 정하는 데 시간이 꽤 들었고, LLM에게 맡기지 않은 만큼 저희가 직접 해야 하는 일도 늘었습니다. 확인 버튼을 한 번 더 눌러야 하는 것이나 다시 만든 검색 인덱스, 직접 관리하는 대화 이력 같은 것들이었죠. 그래도 이 비용들은 대부분 미리 예상할 수 있었고, 저희 팀 규모에서도 감당할 수 있는 정도였다고 생각합니다.

아직 남은 것도 많습니다. 평가는 여전히 사람이 직접 보는 부분이 많아서 판정을 어디까지 자동으로 맡길 수 있을지 계속 고민하고 있고, 응답 속도도 여러 작업을 했지만 같은 질문으로 비교했을 때 기존보다 느렸던 만큼 더 줄여야 합니다. 사용자 확인을 거쳐 실행하는 기능이 늘어나면 이번에 정한 기준들도 그때마다 다시 점검해야 할 것 같습니다.

참고로 이번 작업 중에 AI 모듈은 별도 서비스로 분리했습니다. AI는 한 요청이 수 초에서 수십 초씩 걸려서 다른 기능과 부하 특성이 많이 다릅니다. AI 쪽 문제가 다른 기능까지 번지지 않게 하려는 결정이었습니다. 이 과정은 모듈러 모놀리식 전환기에 이어서 따로 정리해볼 생각입니다.

부동산 통합 AI 에이전트라는 목표에 비하면 이번 작업은 첫 단계라고 생각합니다. 서비스에 LLM을 붙이면서 비슷한 고민을 하고 계신 분들께 조금이나마 도움이 되었으면 좋겠습니다.

긴 글 읽어주셔서 감사합니다.


참고 자료

관련 글

참고 문헌