본문 바로가기
AI Agent/Frameworks

LangChain (3) - 출력 구조

by AtoN 2024. 3. 31.

LangChain 구조화 출력

LLM의 기본 출력은 사람이 읽는 자연어입니다. 사용자에게 답변을 보여 주는 용도라면 이것으로 충분하지만, 모델의 결과를 프로그램의 다음 단계에서 사용하려면 이야기가 달라집니다. 분류 결과를 DB에 저장하고, 추출한 값을 API 인자로 넘기고, 점수를 계산에 사용하려면 자유로운 문장보다 필드와 타입이 정해진 데이터 구조가 필요합니다.

가장 단순한 방법은 프롬프트에 "JSON으로 답해"라고 적는 것입니다. 하지만 이것은 모델에게 출력 형식을 자연어로 요청한 것일 뿐입니다. 필드가 빠지거나 타입이 달라지고, JSON 앞뒤에 설명이 붙는 것까지 애플리케이션에서 직접 처리해야 할 수 있습니다.

LangChain의 with_structured_output()은 이보다 한 단계 더 나아갑니다. Pydantic model, TypedDict, JSON Schema 같은 출력 schema를 모델 인터페이스에 전달하고, 그 구조에 맞는 결과를 반환하는 Runnable을 구성합니다. Provider가 native structured output을 지원한다면 그 기능을 사용할 수 있고, 모델과 설정에 따라 tool/function calling 같은 방식으로 구조를 유도할 수도 있습니다.

그래서 중요한 차이는 JSON이라는 문자열 형식과 structured output이라는 계약을 구분하는 것입니다. 전자는 텍스트를 JSON처럼 생성하게 하는 것이고, 후자는 애플리케이션이 기대하는 필드와 타입을 schema로 정의해 모델 호출과 파싱 과정에 연결하는 것입니다.

자유로운 AIMessage가 코드에서 사용할 구조화된 값으로 바뀌는 과정을 살펴보고, Pydantic schema와 with_structured_output()이 각각 어떤 역할을 하는지 봅니다. 이어서 단순 JSON prompting, provider-native structured output, output parser가 무엇이 다른지도 구분합니다.


1장. 왜 텍스트만으로는 부족한가

모델에게 이렇게 요청할 수 있습니다.

이름과 나이를 JSON으로 답하세요.

그런데 모델은 본질적으로 텍스트를 생성하기 때문에 이런 결과가 올 수 있습니다.

물론이죠. 결과는 다음과 같습니다.

{"name": "홍길동", "age": 32}

사람이 읽기에는 문제가 없지만 코드에서는 바로 깨집니다.

import json

json.loads(response.content)
# JSONDecodeError

실패 형태도 다양합니다.

앞뒤에 설명을 붙인다
코드 펜스로 감싼다
필드 이름을 바꾼다
필수 필드를 빼먹는다
타입을 다르게 낸다

문자열 처리로 하나씩 보정할 수는 있지만, 모델 출력 형식이 바뀔 때마다 파싱 로직도 함께 복잡해집니다.

그래서 구조화 출력은 모델이 원하는 구조로 답하게 만드는 것과 그 결과가 실제 스키마에 맞는지 검증하는 것을 함께 봐야 합니다.


2장. 출력 파서 방식

구조화 출력 API가 널리 쓰이기 전에는 프롬프트와 OutputParser를 함께 사용하는 방식이 일반적이었습니다. 이 방식은 지금도 구조화 출력을 직접 지원하지 않는 모델이나 별도 후처리가 필요한 경우에 사용할 수 있습니다.

예를 들어 Pydantic 스키마를 파서에 넘깁니다.

from pydantic import BaseModel, Field
from langchain_core.output_parsers import PydanticOutputParser
from langchain_core.prompts import ChatPromptTemplate

class Person(BaseModel):
    name: str = Field(description="사람의 이름")
    age: int = Field(description="나이")

parser = PydanticOutputParser(pydantic_object=Person)

prompt = ChatPromptTemplate.from_messages([
    ("system", "사용자 문장에서 사람 정보를 추출하세요.\n{format_instructions}"),
    ("human", "{text}"),
]).partial(format_instructions=parser.get_format_instructions())

여기서 파서는 두 가지 일을 합니다.

get_format_instructions()
    모델에게 어떤 형식으로 답해야 하는지 알려 준다

parse()
    나온 문자열을 원하는 구조로 바꾼다

문제는 첫 단계가 여전히 프롬프트 지시라는 점입니다. 모델이 형식을 완전히 지키지 않으면 파서에서 오류가 납니다.

그래서 현재 LangChain 문서도 모델이 native structured output을 지원한다면 먼저 그 기능을 사용하는 방향을 권합니다. OutputParser는 사라진 기능이 아니라 구조화 출력 기능이 없는 모델이나 추가 변환이 필요한 경우에 남아 있는 도구입니다.


3장. with_structured_output

현재 가장 단순한 방법은 모델에 스키마를 직접 묶는 것입니다.

from pydantic import BaseModel, Field

class Person(BaseModel):
    name: str = Field(description="사람의 이름")
    age: int = Field(description="나이. 알 수 없으면 -1")
    skills: list[str] = Field(description="보유 기술 목록")

structured_model = model.with_structured_output(Person)

result = structured_model.invoke(
    "홍길동은 32살이고 파이썬과 파이토치를 다룬다."
)

print(result)
# Person(name='홍길동', age=32, skills=['Python', 'PyTorch'])

이제 호출부가 자유로운 JSON 문자열을 직접 파싱하지 않습니다. Person을 넘겼기 때문에 결과도 Person 인스턴스로 받습니다.

중요한 변화는 형식 요구사항이 프롬프트 본문에서 스키마 정의로 이동했다는 것입니다.

class Ticket(BaseModel):
    category: str = Field(description="bug, feature, question 중 하나")
    priority: int = Field(description="1이 가장 급함. 1에서 5 사이")
    summary: str = Field(description="한 문장 요약")

Field(description=...)은 스키마의 설명으로 모델에 전달됩니다. 따라서 타입만 적는 것보다 각 필드가 무엇을 의미하는지 명확하게 쓰는 것이 중요합니다.

with_structured_output()이 반환하는 객체도 Runnable입니다. 그래서 2편의 프롬프트와 연결할 수 있지만, Runnable과 | 조합 자체는 4편에서 따로 다룹니다.

structured_model = model.with_structured_output(Ticket)

여기서는 모델의 출력 인터페이스가 AIMessage에서 원하는 데이터 구조로 바뀐다는 것만 기억하면 됩니다.


4장. 어떤 스키마를 넘길 수 있나

기본 with_structured_output() 인터페이스는 여러 형태의 스키마를 받을 수 있습니다.

스키마 일반적인 반환 형태 런타임 검증
Pydantic 클래스 Pydantic 인스턴스 Pydantic 검증
TypedDict dict Pydantic 검증 없음
JSON Schema dict Pydantic 검증 없음
tool/function schema dict Pydantic 검증 없음

실무에서 타입 검증까지 원한다면 Pydantic이 가장 이해하기 쉽습니다.

from typing import Literal
from pydantic import BaseModel, Field

class Ticket(BaseModel):
    category: Literal["bug", "feature", "question"]
    priority: int = Field(ge=1, le=5)
    summary: str

문자열 설명으로 bug, feature, question 중 하나라고 쓰는 것보다 Literal로 가능한 값을 타입에 직접 넣으면 스키마가 더 명확해집니다.

또 Pydantic은 모델이 만든 값을 애플리케이션에 넘기기 전에 타입과 제약을 확인할 수 있습니다.

priority = 3       통과
priority = "high"  검증 오류
priority = 10      검증 오류

다만 Pydantic을 사용했다는 것과 모델 제공자가 생성 단계에서 스키마를 강제한다는 것은 같은 말이 아닙니다. Pydantic은 LangChain 쪽 결과 검증까지 포함하고, 생성 자체를 얼마나 강하게 제한하는지는 다음 장의 제공자 기능에 달려 있습니다.


5장. 실제로 구조를 만드는 방법은 하나가 아니다

여기서 가장 헷갈리기 쉬운 부분입니다.

with_structured_output()은 공통 인터페이스지만, 내부에서 모든 모델이 같은 방식으로 동작하는 것은 아닙니다.

제공자와 모델 통합에 따라 대표적으로 다음 방식이 사용됩니다.

json_schema
    제공자의 native structured output / JSON Schema 기능 사용

function_calling
    tool/function calling 형식으로 스키마를 전달

json_mode
    JSON 출력 모드를 사용하고 필요한 스키마 지시를 함께 전달

예를 들어 ChatOpenAI와 ChatAnthropic은 둘 다 with_structured_output()을 제공하지만, 지원하는 method와 기본 동작은 서로 다를 수 있습니다.

따라서 이렇게 외우는 편이 정확합니다.

with_structured_output()은 구조화 출력을 위한 LangChain의 공통 인터페이스이고, 실제 제어 방식은 모델 제공자의 기능에 따라 달라진다.

일부 통합에서는 strict=True 같은 옵션으로 스키마 준수를 더 강하게 요구할 수도 있습니다.

structured = model.with_structured_output(
    Ticket,
    strict=True,
)

하지만 strict, method 지원 여부와 의미는 제공자별 구현을 확인해야 합니다.

그리고 function/tool calling 자체의 동작 원리, 실제 함수를 실행하고 결과를 다시 모델에 돌려주는 루프는 이 편의 범위를 넘습니다. 그 부분은 Native Tool Calling 편에서 따로 다룹니다.


6장. 실패를 어떻게 다룰까

가장 단순하게 사용할 때는 파싱된 결과만 받습니다.

structured = model.with_structured_output(Person)
result = structured.invoke(text)

이때 구조화 결과를 만들거나 검증하는 과정에서 오류가 나면 예외가 호출부로 전달됩니다.

원본 응답까지 함께 보고 싶다면 include_raw=True를 사용할 수 있습니다.

structured = model.with_structured_output(Person, include_raw=True)
res = structured.invoke(text)

res["raw"]
res["parsed"]
res["parsing_error"]

의미는 다음과 같습니다.

키 내용
raw 원본 AIMessage
parsed 성공적으로 구조화된 결과
parsing_error 파싱·검증 과정에서 발생한 오류

include_raw=True가 항상 더 좋은 기본값은 아닙니다. 단순한 애플리케이션에서는 결과 객체만 받는 편이 코드가 간단합니다.

반대로 다음 상황에서는 유용합니다.

구조화 출력 실패 원인을 로그로 남겨야 할 때
fallback 경로를 직접 제어할 때
모델이나 스키마 변경 전후의 실패율을 비교할 때
원본 응답을 디버깅해야 할 때

즉 include_raw는 운영과 디버깅을 위한 선택지로 보는 편이 좋습니다.


7장. 에이전트에서는 response_format을 쓴다

모델 하나의 출력을 구조화할 때는 with_structured_output()이 가장 직접적입니다. 도구를 사용하는 에이전트에서는 같은 목적을 create_agent()의 response_format으로 지정할 수 있습니다.

from pydantic import BaseModel
from langchain.agents import create_agent

class Answer(BaseModel):
    summary: str
    confidence: float

agent = create_agent(
    model="openai:gpt-5.5",
    tools=tools,
    response_format=Answer,
)

result = agent.invoke({
    "messages": [{"role": "user", "content": "이 문서를 요약해줘"}]
})

answer = result["structured_response"]

스키마 타입을 직접 넘기면 LangChain이 모델과 제공자의 기능에 따라 provider-native structured output을 쓸지, tool calling 기반 전략을 쓸지 선택합니다. 필요하면 ProviderStrategy나 ToolStrategy를 명시할 수도 있습니다.

이 구분은 모델 호출과 에이전트 호출의 차이입니다. 모델에서는 with_structured_output(), 에이전트에서는 response_format을 기준으로 보면 이해하기 쉽습니다.


8장. 스트리밍은 모델마다 다르다

구조화 출력은 스트리밍과 궁합이 나쁘다고 단정하면 안 됩니다.

일부 모델 통합은 구조화된 결과를 스트리밍할 수 있고, JsonOutputParser 역시 부분 JSON을 점진적으로 파싱할 수 있습니다. 반면 어떤 통합은 완성된 구조가 만들어진 뒤에야 유효한 객체를 내보내는 편이 자연스럽습니다.

그래서 중요한 것은 구조화 출력이면 스트리밍이 안 된다는 규칙이 아니라, 사용하는 모델 통합이 어떤 chunk를 반환하는지 확인하는 것입니다.

일반 대화
    텍스트 chunk를 바로 화면에 표시

구조화 추출
    부분 구조가 실제 애플리케이션에서 의미가 있는지 먼저 판단

예를 들어 사용자에게 문장을 보여 주는 채팅은 토큰 단위 스트리밍이 자연스럽지만, DB에 넣을 Ticket 객체는 완성된 결과를 기다리는 편이 단순할 수 있습니다.


9장. 스키마를 설계하는 법

구조화 출력의 품질은 모델만큼 스키마 설계의 영향을 받습니다.

필드 이름을 의미 있게 쓴다

# 좋지 않음
class Result(BaseModel):
    a: str
    b: int

# 더 명확함
class Ticket(BaseModel):
    category: str
    priority: int

필드 이름 자체가 모델이 이해하는 정보입니다.

description은 의미를 설명한다

priority: int = Field(
    ge=1,
    le=5,
    description="고객 업무에 미치는 영향. 1이 가장 긴급함",
)

단순히 정수라고 반복하기보다 판단 기준을 적는 편이 유용합니다.

가능한 값은 타입으로 제한한다

from typing import Literal

category: Literal["bug", "feature", "question"]

가능한 값이 정해져 있다면 자연어 설명보다 스키마 자체에 넣습니다.

없는 값을 억지로 만들게 하지 않는다

assignee: str | None = None

원문에 없는 정보까지 반드시 채워야 하는 스키마를 만들면 모델이 값을 추측할 가능성이 커집니다.

너무 큰 스키마는 나눈다

필드가 수십 개이고 중첩이 깊으면 모델이 동시에 지켜야 할 조건도 많아집니다. 하나의 거대한 스키마보다 작업 단위로 나누는 편이 유지보수와 실패 분석에 유리한 경우가 많습니다.

temperature=0을 형식 보장 장치로 생각하지 않는다

낮은 temperature가 값의 일관성에 도움이 될 수는 있지만 스키마 준수를 보장하는 기능은 아닙니다. 형식 안정성은 structured output 기능과 스키마 검증으로 확보해야 합니다.


10장. 정리

2편까지는 입력 쪽을 만들었습니다.

사용자 입력
    ↓
ChatPromptTemplate
    ↓
ChatModel

이번 편에서는 출력 쪽을 붙였습니다.

사용자 입력
    ↓
ChatPromptTemplate
    ↓
ChatModel.with_structured_output(Schema)
    ↓
Pydantic / dict

핵심은 세 가지입니다.

첫째, JSON을 프롬프트로 요청하는 것과 구조화 출력은 다릅니다. 프롬프트 + OutputParser 방식은 여전히 사용할 수 있지만, 모델이 structured output을 지원한다면 with_structured_output()이 더 직접적인 인터페이스입니다.

둘째, with_structured_output()의 내부 구현은 하나가 아닙니다. native JSON Schema를 쓸 수도 있고 function calling이나 JSON mode를 쓸 수도 있습니다. 실제 강제 수준과 지원 옵션은 제공자와 모델에 따라 달라집니다.

셋째, 구조화 출력에서도 검증은 중요합니다. Pydantic을 사용하면 반환값을 애플리케이션에 넘기기 전에 타입과 제약을 검사할 수 있습니다.

이제 입력과 출력 양쪽 컴포넌트가 준비됐습니다.

다음 편에서는 이들을 따로 호출하지 않고 prompt | model | parser처럼 하나의 실행 흐름으로 조합하는 Runnable과 LCEL을 다룹니다.


용어 정리

용어 한 줄 뜻
구조화 출력 모델 결과를 미리 정의한 스키마 형태로 받는 것
OutputParser 모델이 낸 텍스트를 문자열, JSON, Pydantic 등 원하는 형태로 변환하는 컴포넌트
PydanticOutputParser 텍스트 출력을 Pydantic 모델로 파싱하고 검증하는 OutputParser
get_format_instructions() 모델이 따라야 할 출력 형식을 프롬프트에 넣기 위한 설명을 만드는 메서드
with_structured_output() ChatModel에 스키마를 묶어 구조화된 결과를 반환하는 Runnable을 만드는 메서드
Pydantic 타입과 제약을 정의하고 런타임에 값을 검증하는 데이터 모델 라이브러리
TypedDict 딕셔너리의 키와 타입을 정적으로 표현하는 타입
JSON Schema JSON 데이터의 필드와 타입, 제약을 표현하는 표준 스키마
Field(description=...) Pydantic 필드의 의미를 스키마에 담는 설명
Literal 허용되는 값을 타입 수준에서 제한하는 파이썬 타입
include_raw 원본 메시지, 파싱 결과, 파싱 오류를 함께 받는 옵션
strict 지원하는 모델 통합에서 스키마 준수를 더 엄격하게 요구하는 옵션
json_schema 제공자의 native JSON Schema 기반 structured output을 사용하는 방식
function_calling tool/function calling 인터페이스를 이용해 구조를 생성하는 방식
json_mode 모델의 JSON 출력 모드를 이용하는 방식

참고자료

댓글