본문으로 건너뛰기
홈
기술
기술 전체
프로그래밍68
컴퓨터 과학63
AI48
웹 개발36
인프라33
데이터31
소프트웨어 공학18
소개
← 목록으로AI › 개발 도구 › LangChain

11. LLM 애플리케이션 개발

목차

LLM 애플리케이션 개발

앞 장들에서 모델 호출, RAG, 메모리, 에이전트 구조, 성능 패턴, 배포와 평가를 각각 살펴봤다. 이제 “사내 규정 문서에 근거해 질문에 답하는 서비스”를 예로 들어 실제 개발 순서를 연결해 보자. 목표는 가장 복잡한 에이전트를 만드는 것이 아니다. 사용자의 질문에 필요한 근거를 찾고, 근거가 없을 때는 답을 만들지 않는 작은 시스템부터 시작한다.

[이번 예제의 완료 조건]

  • 규정에 있는 질문은 답변과 문서 ID를 반환한다.
  • 규정에 없는 질문은 근거 부족을 알린다.
  • 모델이 검색되지 않은 문서 ID를 인용하면 결과를 거절한다.
  • 같은 질문 집합으로 변경 전후를 비교할 수 있다.
flowchart LR
    U[사용자 질문] --> V[입력 검증]
    V --> R[허용된 문서 검색]
    R -->|문서 없음| N[근거 부족 응답]
    R -->|문서 있음| G[답변 생성]
    G --> C[인용 ID 검증]
    C -->|통과| A[답변 + 출처]
    C -->|실패| N
    A --> T[평가·운영 추적]
    N --> T

문서 검색과 인용 검증 사이에 모델 호출이 하나 있다. 이 구조는 순서가 고정돼 있으므로 처음부터 자율적인 도구 선택 에이전트가 필요하지 않다. 요구 사항이 늘어날 때 필요한 경로만 추가한다.

1. 문제와 데이터의 경계 정하기

먼저 질문의 범위를 좁힌다. “모든 회사 업무에 답하기”가 아니라 공개 가능한 인사 규정 문서에 관한 질문만 처리한다. 문서가 가진 작성일과 적용 시작일을 구분해야 개정 전 규정과 현재 규정을 혼동하지 않는다.

항목이 예제의 결정실제 서비스에서 추가할 것
답변 범위승인된 규정 문서만부서·사용자별 열람 권한
문서 식별HR-12 같은 안정적인 ID버전, 발효일, 원본 URL
출처답변에 [문서:HR-12] 표시문장별 근거 위치와 링크
문서가 없을 때추측하지 않고 안내담당자 또는 원본 문서 연결
개인정보입력에 받지 않음로그 마스킹과 보존 정책

2장에서 다룬 문서 로드 → 분할 → 임베딩 → 저장은 문서가 많아질 때 필요하다. 3장에서 다룬 검색은 질문과 관련된 청크를 가져온다. 아래 코드는 그래프의 흐름에 집중하려고 메모리 안의 두 문장을 단순 검색한다. 이 검색 함수는 벡터 검색의 대체 구현이 아니다. 실제 문서에는 검색기와 문서별 권한 필터를 넣어야 한다. LangChain RAG 문서는 인덱싱과 검색을 분리해 설명한다.

2. 가장 작은 그래프 구현

다음 예시는 langgraph, langchain-ollama가 설치돼 있고 OLLAMA_MODEL 환경 변수에 로컬에 설치된 모델 이름이 들어 있다는 전제다. 모델 응답은 실행 환경에 따라 달라진다. 실행 결과를 고정된 정답처럼 해석하지 않는다.

import os
import re
from typing import TypedDict

from langchain_ollama import ChatOllama
from langgraph.graph import END, START, StateGraph

POLICIES = (
    {"id": "HR-12", "text": "휴가는 시작일 3영업일 전까지 신청한다."},
    {"id": "HR-18", "text": "병가 신청 시 증빙 서류를 제출한다."},
)
model = ChatOllama(model=os.environ["OLLAMA_MODEL"], temperature=0)

class State(TypedDict):
    question: str
    documents: list[dict[str, str]]
    answer: str
    citations: list[str]

def retrieve(state: State) -> dict:
    # 작은 실습용 검색. 운영 환경에서는 권한 필터가 있는 검색기로 교체한다.
    words = [word for word in state["question"].split() if len(word) >= 2]
    matches = [
        doc for doc in POLICIES
        if any(word in doc["text"] for word in words)
    ]
    return {"documents": matches}

def generate(state: State) -> dict:
    if not state["documents"]:
        return {"answer": "관련 규정 문서를 찾지 못했습니다."}

    context = "\n".join(
        f"[문서:{doc['id']}] {doc['text']}" for doc in state["documents"]
    )
    response = model.invoke([
        ("system", "제공된 문서만 근거로 답하세요. 사용한 문서 ID를 "
                   "[문서:ID] 형식으로 인용하세요. 근거가 없으면 모른다고 답하세요."),
        ("user", f"질문: {state['question']}\n\n문서:\n{context}"),
    ])
    return {"answer": str(response.content)}

def verify(state: State) -> dict:
    if not state["documents"]:
        return {"citations": []}

    cited = re.findall(r"\[문서:([^\]]+)\]", state["answer"])
    allowed = {doc["id"] for doc in state["documents"]}
    if not cited or any(doc_id not in allowed for doc_id in cited):
        return {
            "answer": "답변의 근거 문서를 확인할 수 없습니다.",
            "citations": [],
        }
    return {"citations": list(dict.fromkeys(cited))}

builder = StateGraph(State)
builder.add_node("retrieve", retrieve)
builder.add_node("generate", generate)
builder.add_node("verify", verify)
builder.add_edge(START, "retrieve")
builder.add_edge("retrieve", "generate")
builder.add_edge("generate", "verify")
builder.add_edge("verify", END)
graph = builder.compile()

def ask(question: str) -> dict:
    cleaned = question.strip()
    if not cleaned or len(cleaned) > 500:
        raise ValueError("질문은 1자 이상 500자 이하로 입력하세요.")
    result = graph.invoke({
        "question": cleaned, "documents": [], "answer": "", "citations": [],
    })
    return {"answer": result["answer"], "citations": result["citations"]}

print(ask("휴가 신청 기한은?"))

retrieve가 찾은 문서만 generate에 전달되고, verify는 모델이 인용한 ID가 그 목록 안에 있는지 확인한다. 문서가 없으면 모델을 호출하지 않는다. 이 방식은 존재하지 않는 인용 ID를 걸러내지만, 인용한 문장의 내용이 문서와 일치하는지까지 보증하지는 않는다. 10장의 근거 충실성 평가가 추가로 필요하다.

또한 여기서는 str.split()으로 띄어쓰기 기준 검색을 한다. “휴가를 언제 올리나요?”와 “연차 신청은?”처럼 같은 뜻의 질문을 연결하지 못할 수 있다. 실제 서비스에서는 검색 데이터와 질문 예제를 살펴보고 2~3장의 청크·임베딩·검색 기법을 적용한다.

3. 기능을 추가하는 순서

처음 만든 그래프가 작으면 결함의 위치를 파악하기 쉽다. 다음 요구가 생겼을 때 필요한 구성요소를 추가한다.

  1. 문서가 많아짐: 문서 로더와 인덱스를 추가하고, 문서 ID·버전·접근 범위를 메타데이터로 관리한다.
  2. 질문 유형이 달라짐: 정책 질문과 개인 데이터 질문을 라우팅한다. 개인 데이터 경로는 인증과 권한 검사를 독립적으로 수행한다.
  3. 대화를 이어가야 함: 체크포인터를 붙이고 사용자와 스레드의 소유권을 연결한다. 이전 대화가 현재 질문의 근거를 대신하지 않도록 검색은 다시 수행한다.
  4. 쓰기 작업이 필요함: 승인 흐름과 멱등 처리를 먼저 설계한 뒤 도구를 연결한다.
  5. 정확도가 부족함: 실패 사례를 검색·생성·검증 단계로 나누고 8장의 패턴 중 필요한 것만 적용한다.
flowchart TD
    B[단순 RAG 그래프] --> Q{새 요구 사항}
    Q -->|문서 증가| I[인덱스·검색 개선]
    Q -->|다른 질문 종류| R[라우터 추가]
    Q -->|대화 연속성| M[체크포인터 추가]
    Q -->|외부 작업| T[권한·승인 후 도구 추가]
    I --> E[동일 데이터셋 평가]
    R --> E
    M --> E
    T --> E

어느 경로를 선택해도 기존 평가 데이터셋을 다시 실행한다. 기능을 더해 기존의 간단한 질문 답변이 나빠지지 않았는지 확인하기 위해서다.

4. 사용자 화면과 API 계약

사용자는 모델 내부 메시지보다 답, 출처, 처리 상태를 원한다. API가 반환할 최소 구조를 먼저 정하면 UI와 그래프를 독립적으로 바꿀 수 있다.

{
  "status": "answered",
  "answer": "휴가는 시작일 3영업일 전까지 신청합니다.",
  "citations": [
    {"id": "HR-12", "title": "휴가 규정", "url": "/policies/HR-12"}
  ]
}

이는 원하는 API 형식의 예시이며 위 Python 코드의 실제 출력이라고 주장하는 것은 아니다. url은 서버가 문서 ID를 조회해 허용된 원본 경로로 채워야 한다. 모델이 만들어 낸 URL을 그대로 링크로 보여주지 않는다. 자료가 없을 때는 status: "no_evidence"처럼 별도 상태를 사용하면 UI가 답변 실패와 서버 장애를 구분할 수 있다.

5. 배포 전에 확인할 사례

[최소 평가 목록]

  • 근거 문서가 있는 질문: 올바른 문서를 찾고 정확한 조건을 답하는가?
  • 말만 바꾼 질문: 동일한 규정을 찾는가?
  • 근거가 없는 질문: 모델이 숫자와 날짜를 추측하지 않는가?
  • 오래된 규정: 적용 시점을 잘못 섞지 않는가?
  • 잘못된 인용: 검색되지 않은 ID를 결과에서 제거하는가?
  • 다른 사용자의 자료: 접근 권한이 없는 문서를 검색·출력하지 않는가?
  • 외부 도구 실패: 중복 실행이나 정보 노출 없이 오류를 처리하는가?

이 사례를 10장의 오프라인 데이터셋으로 저장하고, 9장의 배포 전 검증을 통과한 버전만 운영에 반영한다. 운영 중에는 추적에서 실패 단계를 찾아 다시 데이터셋에 넣는다.

6. 두 문장 검색기를 실제 문서 검색기로 바꾸는 순서

위 retrieve는 이해를 위한 함수다. 질문을 공백으로 나눠 각 단어가 본문에 포함되는지 볼 뿐이어서, “휴가 신청 기한”의 신청 때문에 병가 문서도 찾을 수 있다. 또 “연차를 언제 올려야 해?”처럼 동의어를 쓰면 휴가 문서를 놓칠 수 있다. 이런 실패를 확인하지 않고 임베딩 모델만 바꾸면 원인이 해결됐는지 알기 어렵다.

실제 문서에는 최소한 다음 필드가 필요하다.

필드예시이유
문서 IDHR-12인용과 원문 링크를 안정적으로 연결
버전·적용일v2, 2026-01-01구 규정과 현행 규정을 분리
청크 IDHR-12:v2:p3답변의 어느 부분이 근거인지 추적
접근 범위all-employees검색 전에 권한을 적용
본문과 원본 위치문단, URL사용자가 원문을 직접 확인

인덱싱 단계에서는 원본 파일을 파싱하고 문서 ID·버전·접근 범위를 붙인 다음 청크로 나눠 임베딩한다. 요청 단계에서는 인증된 사용자에 허용된 문서만 검색하고, 현재 질문에 맞는 청크를 상위 몇 개 고른다. 이어서 선택된 청크만 모델에 전달한다. 문서 접근 범위를 모델에게 “보지 마”라고 지시해서 해결할 수 없다. 검색기 또는 데이터 조회 계층에서 필터를 강제해야 한다(LangChain RAG 가이드).

검색 결과가 좋은지 판단하려면 10장의 평가 행에 기대 문서 ID를 함께 저장한다. 예를 들어 “휴가 신청 기한”이 HR-12:v2를 찾지 못하면 검색을 고치고, 찾았는데 3일이라고 답하면 생성·검증을 고친다. 인덱스를 바꿀 때 이전 버전과 같은 질문 집합으로 검색 적중과 답변을 각각 비교한다.

7. verify가 통과해도 틀릴 수 있는 경우

현재 verify는 정규식으로 [문서:ID]를 뽑아 검색된 ID 집합에 있는지 검사한다. 따라서 모델이 “휴가는 5일 전에 신청한다 [문서:HR-12]”라고 답해도 ID가 허용 목록에 있으면 통과한다. 반대로 모델이 정확히 답했지만 [문서: HR-12]처럼 공백을 넣으면 실패한다. 형식 검사와 의미 검사는 별개다.

한 단계 더 나아가려면 모델 출력 자체를 answer와 citations 필드를 가진 구조화된 값으로 요청하고, 서버에서 스키마와 인용 목록을 확인한다. 형식이 안정되더라도 날짜·조건이 문서에 실제로 있는지 확인하는 평가는 남는다. 금액이나 기한처럼 틀리면 큰 문제가 되는 필드는 가능하면 문서에서 구조화해 추출하고, 코드로 비교할 수 있는 규칙을 둔다.

다음은 모델 없이 검증 노드만 확인하는 결정적인 사례다. verify가 제공하는 보장과 제공하지 않는 보장을 분명히 보여준다.

docs = [{"id": "HR-12", "text": "휴가는 시작일 3영업일 전까지 신청한다."}]

known = verify({
    "question": "휴가 신청 기한은?",
    "documents": docs,
    "answer": "3영업일 전입니다 [문서:HR-12]",
    "citations": [],
})
unknown = verify({
    "question": "휴가 신청 기한은?",
    "documents": docs,
    "answer": "3영업일 전입니다 [문서:HR-99]",
    "citations": [],
})
assert known["citations"] == ["HR-12"]
assert unknown["citations"] == []

known이 통과했다는 뜻은 인용 ID가 허용 목록에 있다는 것뿐이다. 답변 내용이 틀린 상태의 [문서:HR-12]도 같은 검사를 통과한다. 그러므로 API에서는 verified_citation 같은 과장된 상태명 대신 citation_id_valid와 content_reviewed를 구분해도 좋다.

8. API 계약으로 결과를 옮기기

위 ask()는 answer와 문자열 ID 목록만 반환한다. 4장의 API 예시에 있는 status, title, url은 별도 서버 계층에서 채워야 한다. 모델이 생성한 URL을 그대로 보여주지 말고, 서버의 문서 메타데이터에서 ID에 해당하는 원본 링크를 조회한다. 그 문서에 현재 사용자가 접근할 수 있는지 다시 확인한다.

그래프 결과API 상태화면 처리
인용된 문서가 있고 검증 통과answered답변과 서버가 만든 출처 링크 표시
검색 문서가 없음no_evidence“근거를 찾지 못함” 안내
인용 ID가 검색 결과 밖invalid_citation모델 답을 숨기고 재질문 또는 문의 안내
검색기·모델 예외temporary_error재시도 가능한 오류 안내, 요청 ID 기록

no_evidence와 temporary_error를 합치면 사용자는 “회사 규정이 없는 것인지, 서비스가 고장난 것인지” 알 수 없다. 내부 예외 메시지와 비밀값은 응답에 포함하지 않되, 로그에서는 단계와 요청 ID를 남긴다. 대화를 이어가기 위해 체크포인터를 붙일 때도 사용자별 스레드 소유권 확인과 현재 문서 권한 필터를 별도로 유지한다. 이전 대화가 답의 근거를 대체해서는 안 된다.

9. 처음부터 끝까지 한 사례를 따라가기

“휴가 신청 기한은?”이 들어오면 ask()가 공백과 길이를 검사한다. retrieve는 관련 문서를 반환하고, generate는 그 문서 텍스트와 질문을 모델에 보낸다. verify는 답변의 인용 ID가 검색 결과에 있었는지 확인한다. API 계층은 ID를 현재 허용된 문서 메타데이터로 변환해 출처 링크를 만든다. 마지막으로 운영 추적에는 단계별 지연, 문서 ID·버전, 상태 코드를 남긴다.

이 중 어느 단계라도 실패하면 최종 답으로 표시하지 않는다. 검색 실패는 인덱스를, 답변 내용의 오류는 생성·평가를, 잘못된 인용은 검증을, 권한 문제는 검색 전 필터와 API 소유권 검사를 고친다. 이런 분류를 10장의 평가 데이터와 연결해야 다음 변경이 실제로 도움이 됐는지 확인할 수 있다.

10. 전체 흐름 정리

1장의 모델과 프롬프트는 답을 만드는 부품이다. 23장의 RAG는 답의 근거를 가져오는 부품이다. 47장의 LangGraph와 에이전트 패턴은 상태와 실행 순서를 다루는 부품이다. 8장의 성능 패턴은 관찰된 실패를 줄이는 선택지이고, 9~10장의 배포·평가는 여러 사용자가 안전하게 쓰며 개선할 수 있게 하는 절차다.

처음에는 한 질문을 정확하게 처리하는 작은 그래프로 시작한다. 실패한 질문과 근거를 기록하고, 필요한 단계만 추가하면 구조가 커져도 각 단계의 역할을 설명하고 검증할 수 있다.

참고 문서

같은 카테고리의 글