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 출력 모드를 이용하는 방식 |
참고자료
- LangChain Reference,
BaseChatModel.with_structured_output - LangChain Reference,
ChatOpenAI.with_structured_output - LangChain Reference,
ChatAnthropic.with_structured_output - LangChain Reference, Output parsers
- LangChain Reference,
JsonOutputParser - LangChain Reference,
PydanticOutputParser - Pydantic 공식 문서
- LangChain PyPI
- langchain-core PyPI
- LangChain 공식 문서, Structured output
- LangChain 공식 문서, Agents
'AI Agent > Frameworks' 카테고리의 다른 글
| LangGraph (2) - State, Node, Edge (0) | 2024.08.05 |
|---|---|
| LangChain (4) - 조합하기 (0) | 2024.05.30 |
| LangGraph (1) - 왜 그래프인가 (0) | 2024.05.10 |
| LangChain (2) - 프롬프트 넣기 (0) | 2024.02.22 |
| LangChain (1) - 패키지 구조와 첫 호출 (0) | 2024.01.18 |
댓글