티스토리 뷰

반응형

LangChain 백엔드 응답을 더 믿을 수 있게 만들기 — structured output과 FastAPI response model을 같이 쓰는 방법

이제 슬슬 이런 문제가 보이기 시작합니다.

백엔드는 답을 잘 합니다.
그런데 프론트엔드 입장에서는 좀 애매해요.

예를 들어 지금까지는 보통 이런 응답을 돌려줬죠.

{
  "answer": "재택근무는 팀 운영 원칙에 따라 주 2회까지 가능하며, 예외는 팀장 승인 후 가능합니다."
}

사람이 보기엔 괜찮습니다.
그런데 서비스는 점점 이런 걸 원합니다.

  • 답변 본문
  • 사용한 tool 목록
  • 출처
  • 신뢰도나 답변 유형
  • 후속 액션 버튼에 쓸 필드

즉, 이제는 그냥 “문장 하나”가 아니라
형태가 있는 응답이 필요해집니다.

LangChain structured output 문서는 create_agent가 structured output을 자동 처리하고, 스키마를 주면 검증된 결과를 structured_response 키에 담아 반환한다고 설명합니다. 또 LangChain v1 문서는 구조화 출력이 메인 루프 안에 통합되어 추가 LLM 호출 비용을 줄였다고 안내해요. FastAPI 쪽은 request body를 Pydantic 모델로 선언하고, response_model을 주면 반환 데이터가 해당 스키마에 맞게 직렬화되고 필드 필터링도 적용된다고 설명합니다. (LangChain 문서)

저는 이 구간이 꽤 중요하다고 생각합니다.
여기서부터 API가 “대충 답하는 서버”에서 “계약이 있는 백엔드”로 바뀌거든요.


왜 structured output이 필요한가

문자열 응답은 빠르게 만들기엔 좋습니다.
하지만 서비스가 커질수록 문제가 생깁니다.

예를 들면 이런 거예요.

  • 프론트가 출처만 따로 보여주고 싶다
  • “정책 안내”인지 “상태 조회”인지 구분해서 UI를 다르게 그리고 싶다
  • 답변에 후속 추천 액션을 붙이고 싶다
  • 로깅이나 평가에서 응답 유형을 정형화해서 보고 싶다

이걸 문자열에서 매번 파싱하려고 하면 금방 지저분해집니다.

LangChain 문서는 structured output의 목적을 JSON 객체나 Pydantic 모델처럼 예측 가능한 형식의 데이터를 얻는 것이라고 설명합니다. FastAPI는 response_model을 사용하면 반환 데이터가 선언한 모델에 맞춰 필터링되고 JSON으로 직렬화된다고 설명해요. (LangChain 문서)

즉, 이 단계의 핵심은 이겁니다.

LLM 응답도 이제는 사람용 문장이 아니라, 시스템이 다루기 좋은 데이터 계약으로 받아야 한다.


오늘 글에서 얻어갈 것

이번 글에서는 아래를 한 번에 잡겠습니다.

  • LangChain agent의 structured output 붙이기
  • FastAPI response_model과 맞물리게 만들기
  • 응답에 answer, used_tools, response_type, sources 같은 필드 넣기
  • 문자열 응답보다 왜 이 방식이 백엔드에 유리한지 이해하기

오늘 만들 응답 형태

이번 글에서는 이런 응답을 목표로 하겠습니다.

{
  "answer": "재택근무는 팀 운영 원칙에 따라 주 2회까지 가능합니다. 예외는 팀장 승인 후 가능합니다.",
  "response_type": "policy_answer",
  "used_tools": ["search_policy_documents"],
  "sources": ["policy-remote-work"]
}

이 정도만 돼도 프론트엔드가 훨씬 편해집니다.

  • response_type으로 아이콘 바꾸기
  • used_tools로 디버깅 표시
  • sources로 출처 링크 표시

LangChain은 create_agent(..., response_format=...)로 이런 구조화 응답을 만들 수 있고, 결과는 structured_response에 들어갑니다. OpenAI 통합 문서도 OpenAI는 native structured output을 지원하고, LangChain agent의 response format으로 활용할 수 있다고 설명합니다. (LangChain 문서)


프로젝트 구조

지난 글 구조를 그대로 이어갑니다.

project/
├─ app/
│  ├─ main.py
│  ├─ schemas.py
│  ├─ agent.py
│  └─ tools.py
├─ requirements.txt
└─ .env

이번 글에서 바뀌는 핵심은 두 파일입니다.

  • schemas.py
  • agent.py

requirements.txt

structured output은 LangChain v1에서 기본 agent 흐름 안에 잘 녹아 있습니다. FastAPI와 LangChain 쪽은 버전 고정을 해두는 편이 실전에서 안전합니다. FastAPI도 실제 앱에서는 version pinning을 권장합니다. (LangChain 문서)

fastapi==0.112.0
uvicorn[standard]==0.30.6
langchain==1.0.3
langchain-openai==1.0.1
langgraph==1.0.2
pydantic==2.11.7
python-dotenv==1.0.1

1. 응답 스키마를 먼저 잡자

app/schemas.py

from typing import Literal
from pydantic import BaseModel, Field


class AskRequest(BaseModel):
    session_id: str = Field(description="대화 세션 ID. LangChain thread_id로 사용됨")
    question: str = Field(description="사용자 질문")


class AssistantStructuredResponse(BaseModel):
    answer: str = Field(description="사용자에게 보여줄 최종 답변")
    response_type: Literal["policy_answer", "request_status", "summary", "unknown"] = Field(
        description="응답 유형"
    )
    used_tools: list[str] = Field(
        default_factory=list,
        description="에이전트가 사용한 tool 이름 목록"
    )
    sources: list[str] = Field(
        default_factory=list,
        description="참고한 출처 또는 문서 소스 목록"
    )


class AskResponse(BaseModel):
    answer: str = Field(description="사용자에게 보여줄 최종 답변")
    response_type: Literal["policy_answer", "request_status", "summary", "unknown"] = Field(
        description="응답 유형"
    )
    used_tools: list[str] = Field(
        default_factory=list,
        description="에이전트가 사용한 tool 이름 목록"
    )
    sources: list[str] = Field(
        default_factory=list,
        description="참고한 출처 또는 문서 소스 목록"
    )

FastAPI는 request body와 response model에 Pydantic 모델을 쓰는 방식을 기본으로 설명하고, response model을 선언하면 반환 필드가 필터링되고 JSON 스키마 문서화도 자동으로 붙습니다. (FastAPI)

여기서 AssistantStructuredResponse와 AskResponse를 따로 둔 이유는 단순합니다.

  • 하나는 LangChain agent의 구조화 응답 스키마
  • 하나는 FastAPI가 외부에 노출할 응답 스키마

지금은 거의 같지만, 나중엔 분리해두는 편이 편합니다.


2. tools.py는 지난 글 그대로 써도 된다

반응형

이번 글의 핵심은 tool이 아니라 응답 형식입니다.
그래서 tool은 지난 글 버전을 그대로 씁니다.

app/tools.py

import json
from langchain.tools import tool


@tool
def search_policy_documents(query: str) -> str:
    """Search internal policy documents by query.

    Use this tool when the user asks about company policy, rules, benefits,
    leave, expense, onboarding, or internal process documentation.
    """
    docs = [
        {
            "title": "연차 사용 정책",
            "content": "정규직 직원은 연차를 사내 인사 시스템에서 신청할 수 있으며, 팀 리더 승인 후 확정된다.",
            "source": "policy-annual-leave",
        },
        {
            "title": "출장비 정산 정책",
            "content": "출장비는 결제 영수증과 함께 경비 시스템에 등록해야 하며, 귀사 후 7일 이내 정산이 원칙이다.",
            "source": "policy-expense-travel",
        },
        {
            "title": "재택근무 정책",
            "content": "재택근무는 팀 운영 원칙에 따라 주 2회까지 가능하며, 예외는 팀장 승인 후 가능하다.",
            "source": "policy-remote-work",
        },
    ]

    query_lower = query.lower()
    matched = []

    for doc in docs:
        haystack = f"{doc['title']} {doc['content']}".lower()
        if any(keyword in haystack for keyword in query_lower.split()):
            matched.append(doc)

    if not matched:
        return json.dumps(
            {
                "found": False,
                "results": [],
                "message": "관련 정책 문서를 찾지 못했습니다.",
            },
            ensure_ascii=False,
        )

    return json.dumps(
        {
            "found": True,
            "results": matched[:2],
        },
        ensure_ascii=False,
    )


@tool
def get_request_status(request_id: str) -> str:
    """Get the status of an internal request or ticket by request ID.

    Use this tool when the user asks for the latest state of a request,
    ticket, approval, or internal work item.
    """
    mock_requests = {
        "1024": {
            "request_id": "1024",
            "status": "승인 대기",
            "owner": "총무팀",
            "summary": "노트북 교체 요청",
        },
        "2048": {
            "request_id": "2048",
            "status": "처리 완료",
            "owner": "IT 지원팀",
            "summary": "사내 VPN 권한 부여",
        },
        "3001": {
            "request_id": "3001",
            "status": "진행 중",
            "owner": "재무팀",
            "summary": "출장비 정산 검토",
        },
    }

    data = mock_requests.get(request_id)
    if not data:
        return json.dumps(
            {
                "found": False,
                "request_id": request_id,
                "message": "해당 요청 ID를 찾지 못했습니다.",
            },
            ensure_ascii=False,
        )

    return json.dumps(
        {
            "found": True,
            "request": data,
        },
        ensure_ascii=False,
    )


@tool
def summarize_for_employee(text: str) -> str:
    """Summarize internal information for an employee in short, clear Korean.

    Use this tool when the user explicitly asks for a short summary
    or when multiple pieces of information need to be condensed.
    """
    short = text.strip().replace("\n", " ")
    if len(short) > 180:
        short = short[:180] + "..."
    return f"요약: {short}"

LangChain tools 문서는 tools가 agents에 의해 사용되며, 이름·설명·인자 정보가 모델이 언제 tool을 써야 하는지 판단하는 데 중요하다고 설명합니다. (LangChain 문서)


3. 핵심: agent에 response_format 붙이기

app/agent.py

import os
from functools import lru_cache

from dotenv import load_dotenv
from langchain.agents import create_agent
from langgraph.checkpoint.memory import InMemorySaver

from .schemas import AssistantStructuredResponse
from .tools import (
    get_request_status,
    search_policy_documents,
    summarize_for_employee,
)

load_dotenv()


@lru_cache(maxsize=1)
def get_agent():
    api_key = os.getenv("OPENAI_API_KEY")
    if not api_key:
        raise ValueError("OPENAI_API_KEY 환경변수가 설정되어 있지 않습니다.")

    checkpointer = InMemorySaver()

    agent = create_agent(
        model="openai:gpt-4o-mini",
        tools=[
            search_policy_documents,
            get_request_status,
            summarize_for_employee,
        ],
        system_prompt=(
            "너는 사내 업무 도우미다. "
            "정책/규정/제도 질문은 정책 검색 tool을 사용해라. "
            "요청/티켓/상태 확인 질문은 요청 상태 조회 tool을 사용해라. "
            "사용자가 요약을 원하거나 정보가 길면 요약 tool을 사용해라. "
            "같은 thread 안에서는 이전 대화 문맥을 참고해라. "
            "문서나 tool 결과에 없는 내용은 추측하지 마라. "
            "최종 응답은 반드시 주어진 structured response schema에 맞춰라."
        ),
        checkpointer=checkpointer,
        response_format=AssistantStructuredResponse,
    )
    return agent


def ask_assistant(session_id: str, question: str) -> AssistantStructuredResponse:
    agent = get_agent()

    result = agent.invoke(
        {
            "messages": [
                {
                    "role": "user",
                    "content": question,
                }
            ]
        },
        {
            "configurable": {
                "thread_id": session_id,
            }
        },
    )

    structured = result["structured_response"]
    return structured

LangChain structured output 문서는 create_agent가 structured output을 자동 처리하고, 사용자가 스키마를 주면 검증된 결과가 structured_response에 담긴다고 설명합니다. LangChain v1 릴리스 문서는 이 구조화 출력이 메인 루프 안으로 들어가서 추가 비용을 줄였다고 설명해요. 또 LangChain models 및 ChatOpenAI 통합 문서는 OpenAI가 native structured output을 지원하며 agent response format에도 활용할 수 있다고 안내합니다. (LangChain 문서)

이게 이번 글의 핵심입니다.

예전에는:

  • 최종 메시지 문자열 하나 꺼내기

이제는:

  • 검증된 구조화 응답 객체 꺼내기

이 차이가 꽤 큽니다.


4. FastAPI에서 response model과 연결하기

app/main.py

from fastapi import FastAPI, HTTPException

from .agent import ask_assistant
from .schemas import AskRequest, AskResponse

app = FastAPI(
    title="Internal Assistant API",
    version="0.3.0",
    description="LangChain structured output이 붙은 사내 도우미 백엔드 예제",
)


@app.get("/health")
def health():
    return {"status": "ok"}


@app.post("/assistant/ask", response_model=AskResponse)
def ask(request: AskRequest) -> AskResponse:
    try:
        structured = ask_assistant(
            session_id=request.session_id,
            question=request.question,
        )

        return AskResponse(
            answer=structured.answer,
            response_type=structured.response_type,
            used_tools=structured.used_tools,
            sources=structured.sources,
        )
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

FastAPI는 response_model을 사용하면 반환 데이터를 해당 모델에 맞춰 직렬화하고, 선언되지 않은 필드는 걸러낸다고 설명합니다. response model을 쓰는 편이 직접 JSONResponse를 다루는 것보다 직렬화와 문서화 측면에서 더 유리하다고도 설명해요. (FastAPI)

즉 지금 구조는 두 단계의 계약을 가집니다.

  • LangChain 내부: AssistantStructuredResponse
  • 외부 API 계약: AskResponse

이렇게 해두면 나중에 내부 필드가 조금 바뀌어도, 외부 API는 안정적으로 유지하기 쉽습니다.


5. 실행해보자

uvicorn app.main:app --reload

질문 예시 1:

curl -X POST "http://127.0.0.1:8000/assistant/ask" \
  -H "Content-Type: application/json" \
  -d '{
    "session_id": "ops-session-1",
    "question": "재택근무 정책 알려줘."
  }'

예상 응답 예시:

{
  "answer": "재택근무는 팀 운영 원칙에 따라 주 2회까지 가능하며, 예외는 팀장 승인 후 가능합니다.",
  "response_type": "policy_answer",
  "used_tools": ["search_policy_documents"],
  "sources": ["policy-remote-work"]
}

질문 예시 2:

curl -X POST "http://127.0.0.1:8000/assistant/ask" \
  -H "Content-Type: application/json" \
  -d '{
    "session_id": "ops-session-1",
    "question": "요청 ID 2048 상태 알려줘."
  }'

예상 응답 예시:

{
  "answer": "요청 ID 2048은 처리 완료 상태이며, IT 지원팀이 담당한 사내 VPN 권한 부여 요청입니다.",
  "response_type": "request_status",
  "used_tools": ["get_request_status"],
  "sources": []
}

이제 프론트엔드는 단순 문자열이 아니라
UI에 바로 꽂을 수 있는 구조화 데이터를 받습니다.


6. 왜 이 방식이 FastAPI 백엔드에 특히 잘 맞을까

이유는 간단합니다.

FastAPI는 원래 typed API를 만들기 좋고,
LangChain structured output은 LLM 응답을 typed object처럼 다루게 해줍니다.

즉, 양쪽이 잘 맞물립니다.

  • FastAPI: 요청/응답 계약
  • LangChain: 내부 agent 응답 계약

이렇게 되면 좋은 점이 많아요.

첫째, 프론트엔드가 편하다

문자열 파싱을 안 해도 됩니다.

둘째, 백엔드 테스트가 편하다

필드 단위로 assert하기 쉽습니다.

셋째, 관측과 로깅이 편하다

response_type, used_tools, sources를 바로 기록할 수 있습니다.

넷째, 후속 기능 확장이 쉽다

나중에 confidence, next_actions, citations 같은 필드를 추가하기 쉬워집니다.

LangChain structured output은 예측 가능한 형식을 목표로 하고, FastAPI response model은 반환 데이터 필터링과 JSON 스키마 문서화를 보장해줍니다. 이 둘의 결합이 백엔드에 특히 잘 맞는 이유가 바로 여기에 있어요. (LangChain 문서)


7. structured_response가 있는데 왜 또 AskResponse로 감싸나

이 질문이 꽤 좋습니다.

사실 지금처럼 내부 스키마와 외부 스키마가 거의 같으면
그냥 하나만 써도 됩니다.

그런데 저는 분리해두는 쪽을 더 추천합니다.
이유는 나중에 차이가 나기 때문이에요.

예를 들어 나중엔 내부적으로 이런 필드를 쓸 수 있습니다.

  • raw_tool_trace
  • confidence_score
  • debug_reason
  • intermediate_summary

그런데 외부 API에는 이런 걸 다 주고 싶지 않을 수 있죠.

FastAPI response model은 반환 데이터를 선언한 모델 기준으로 필터링해주기 때문에, 내부와 외부 모델을 분리해두면 이런 경계가 더 깔끔해집니다. (FastAPI)

즉:

  • 내부 계약: agent 작업용
  • 외부 계약: API 소비자용

이 분리가 실전에서 꽤 오래 갑니다.


8. 자주 하는 실수

이 구간에서 진짜 많이 나오는 실수들입니다.

실수 1. structured output 없이 문자열을 억지로 파싱한다

예전엔 많이 했지만, 지금은 굳이 그렇게 안 가도 됩니다. LangChain도 output parsing failure 문서에서 가능하면 parser보다 structured output이나 tool calling 같은 더 안정적인 방식 사용을 권합니다. (LangChain 문서)

실수 2. 내부 스키마와 외부 응답 모델을 무조건 하나로 묶는다

작을 땐 괜찮지만, 커지면 경계가 흐려집니다.

실수 3. response model을 안 쓴다

그러면 반환 필드가 통제되지 않고, API 계약이 느슨해집니다. FastAPI는 response model을 통한 필터링과 문서화를 장점으로 내세웁니다. (FastAPI)

실수 4. 필드를 너무 많이 넣는다

처음엔 단순하게 가는 게 좋습니다.
answer, response_type, used_tools, sources 정도면 충분합니다.


9. 지금 단계에서 제일 좋은 확장 방향

지금 구조를 바탕으로 하면 다음 확장이 꽤 자연스럽습니다.

1) sources를 진짜 citation처럼 강화하기

문서 검색 tool에서 source 뿐 아니라 title, page, chunk_id까지 넣을 수 있습니다.

2) used_tools를 자동 trace로 바꾸기

지금은 agent가 최종 구조화 응답에 넣게 했지만, 나중엔 middleware나 observability 쪽과 연결할 수 있습니다.

3) next_actions 추가하기

예를 들어:

  • “정산 시스템 바로가기”
  • “정책 문서 링크 보기”
  • “요청 상세 페이지 열기”

4) 실패 응답도 구조화하기

예:

  • response_type="unknown"
  • sources=[]
  • used_tools=[]

이런 식으로 가면 프론트가 훨씬 안정적으로 처리할 수 있습니다.


10. 여기서부터 진짜 백엔드 느낌이 난다

저는 개인적으로 이 구간이 되게 좋습니다.

왜냐면 여기서부터 AI 기능이 더 이상 “문장 생성”으로만 안 보이거든요.
이제는 정말 백엔드 컴포넌트처럼 보입니다.

  • 입력 모델이 있고
  • 내부 처리 계약이 있고
  • 출력 모델이 있고
  • 필드 단위 의미가 있고
  • 다른 시스템이 신뢰할 수 있는 형태로 노출됩니다

FastAPI가 원래 이런 typed API 설계에 강하고, LangChain structured output이 그 철학과 잘 맞습니다. 그러니까 둘을 붙였을 때 서비스 구조가 꽤 빨리 안정화되는 거예요. (FastAPI)


오늘 글에서 꼭 가져가야 할 한 문장

오늘 내용을 한 줄로 줄이면 이겁니다.

LangChain structured output은 LLM 응답을 검증 가능한 데이터 계약으로 바꾸고, FastAPI response model은 그 계약을 외부 API 스키마로 안정적으로 노출하게 해준다.

이 감각이 생기면
이제 “AI가 답한다”보다
“AI 기능을 시스템 계약으로 제공한다”는 말이 더 자연스럽게 느껴질 겁니다.


마무리

처음 AI 백엔드를 만들면,
답이 잘 나오면 된다고 생각하기 쉽습니다.

근데 실제 서비스는 거기서 끝나지 않더라고요.

프론트는 필드를 원하고,
로그는 구조를 원하고,
평가는 타입을 원하고,
운영은 예측 가능성을 원합니다.

그러니까 결국 필요한 건
“그럴듯한 문장”이 아니라
형태가 있는 응답이었습니다.

저는 structured output을 붙이고 나서
비로소 “아, 이게 진짜 백엔드 같다”는 느낌이 났어요.

그전엔 AI가 그냥 말을 잘하는 느낌이었다면,
그 뒤부턴 그래도 계약이 있는 시스템처럼 보였거든요.

그 차이가 꽤 큽니다.


다음 글 예고

다음 글에서는
LangChain 백엔드 응답을 스트리밍으로 바꾸기 — FastAPI에서 실시간 답변과 agent 진행 상황을 어떻게 흘려보낼까
로 이어가겠습니다.

이제부터는 답을 한 번에 던지는 API가 아니라,
사용자가 “지금 검색 중인지, 요약 중인지, 답변 중인지”를 느낄 수 있는 실시간 UX로 넘어가게 될 거예요.

출처

  • LangChain Structured Output 문서: create_agent는 structured output을 자동 처리하며, 검증된 결과를 structured_response 키에 담아 반환한다고 설명합니다. (LangChain 문서)
  • LangChain v1 릴리스 문서: structured output이 메인 루프에 통합되어 추가 LLM 호출 비용을 줄였다고 설명합니다. (LangChain 문서)
  • LangChain Models 문서: create_agent에서 structured output 전략은 모델의 native structured output 지원 여부에 따라 추론될 수 있다고 설명합니다. (LangChain 문서)
  • LangChain ChatOpenAI 통합 문서: OpenAI는 native structured output을 지원하며, 개별 모델 호출이나 LangChain agent response format에서 활용할 수 있다고 설명합니다. (LangChain 문서)
  • FastAPI Request Body 문서: request body는 Pydantic 모델로 선언하는 방식을 기본으로 안내합니다. (FastAPI)
  • FastAPI Response Model 문서: 반환 데이터는 선언한 response model에 맞춰 필터링되고 직렬화된다고 설명합니다. (FastAPI)
  • FastAPI Custom Response / Return Directly 문서: response model을 쓰면 Pydantic 기반 JSON 직렬화를 사용하며, 직접 JSONResponse를 다루는 것보다 일관된 방식으로 쓸 수 있다고 설명합니다. (FastAPI)
  • LangChain Output Parsing Failure 문서: 가능하면 output parser보다 structured output이나 tool calling 같은 더 안정적인 방식을 고려하라고 권장합니다. (LangChain 문서)

LangChain, Structured Output, FastAPI, Response Model, create_agent, Pydantic, 생성형AI, 백엔드개발, LangChain Backend, 주니어개발자

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/05   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
글 보관함
반응형