모델에서 구조화된 데이터 반환하기
모델에서 구조화된 데이터 반환하기 (How to return structured data from a model)
LLM을 쓰다 보면 출력을 특정 스키마에 맞게 받고 싶을 때가 많아요. 가장 흔한 용례는 텍스트에서 데이터를 추출해서 DB에 넣거나 다른 다운스트림 시스템에 넘기는 경우예요. 이 가이드에서는 모델에서 구조화된 출력을 얻는 몇 가지 방법을 다룹니다.
출처: 공식문서
사전 준비
이 가이드를 따라가려면 다음 개념에 익숙해져 있으면 좋아요.
- 채팅 모델 (Chat models)
- 함수/도구 호출 (Function/tool calling)
.with_structured_output() 메서드
구조화된 출력을 얻는 가장 쉽고 확실한 방법이에요. with_structured_output()은 구조화 출력을 위한 네이티브 API(도구/함수 호출이나 JSON mode 같은)를 제공하는 모델들을 위해 구현되어 있고, 내부적으로 그 기능을 활용합니다.
이 메서드는 스키마를 입력으로 받는데, 이 스키마가 원하는 출력 속성들의 이름·타입·설명을 지정해요. 반환되는 것은 모델처럼 생긴 Runnable인데, 문자열이나 메시지 대신 주어진 스키마에 해당하는 객체를 출력한다는 점이 달라요. 스키마는 TypedDict 클래스, JSON Schema, 또는 Pydantic 클래스로 지정할 수 있어요. TypedDict나 JSON Schema를 쓰면 Runnable이 dict를 반환하고, Pydantic 클래스를 쓰면 Pydantic 객체를 반환합니다.
예를 들어, 모델로 하여금 농담을 만들어 setup(도입)과 punchline(결말)을 분리하게 해볼게요.
# | output: false
# | echo: false
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4o", temperature=0)
Pydantic 클래스 사용
모델이 Pydantic 객체를 반환하길 원한다면 원하는 Pydantic 클래스를 넘기면 돼요. Pydantic을 쓰는 핵심 장점은 모델이 생성한 출력이 검증(validate) 된다는 거예요. 필수 필드가 없거나 필드 타입이 틀리면 Pydantic이 오류를 던져 줍니다.
from typing import Optional
from pydantic import BaseModel, Field
# Pydantic
class Joke(BaseModel):
"""Joke to tell user."""
setup: str = Field(description="The setup of the joke")
punchline: str = Field(description="The punchline to the joke")
rating: Optional[int] = Field(
default=None, description="How funny the joke is, from 1 to 10"
)
structured_llm = llm.with_structured_output(Joke)
structured_llm.invoke("Tell me a joke about cats")
팁: Pydantic 클래스의 구조뿐 아니라 클래스 이름, docstring, 파라미터의 이름과 설명도 매우 중요해요. 대부분
with_structured_output이 모델의 함수/도구 호출 API를 사용하는데, 이 정보들이 모두 모델 프롬프트에 추가된다고 생각하면 돼요.
TypedDict 또는 JSON Schema 사용
Pydantic을 쓰고 싶지 않거나, 인자 검증을 명시적으로 원하지 않거나, 모델 출력을 스트리밍하고 싶다면 TypedDict 클래스로 스키마를 정의할 수 있어요. 필드의 기본값과 설명을 지정할 수 있는 Annotated 문법을 선택적으로 쓸 수 있어요. 참고로 기본값은 모델이 생성하지 않아도 자동으로 채워지지 않아요. 모델에 넘기는 스키마를 정의하는 데만 사용됩니다.
요구사항:
- Core:
langchain-core>=0.2.26- 타입 확장:
Annotated와TypedDict는typing대신typing_extensions에서 import 하는 걸 권장해요. Python 버전 간 동작을 일관되게 유지하려면요.
from typing import Optional
from typing_extensions import Annotated, TypedDict
# TypedDict
class Joke(TypedDict):
"""Joke to tell user."""
setup: Annotated[str, ..., "The setup of the joke"]
# Alternatively, we could have specified setup as:
# setup: str # no default, no description
# setup: Annotated[str, ...] # no default, no description
# setup: Annotated[str, "foo"] # default, no description
punchline: Annotated[str, ..., "The punchline of the joke"]
rating: Annotated[Optional[int], None, "How funny the joke is, from 1 to 10"]
structured_llm = llm.with_structured_output(Joke)
structured_llm.invoke("Tell me a joke about cats")
동등하게 JSON Schema dict를 넘길 수도 있어요. 이 방식은 import나 클래스가 필요 없고 각 파라미터가 어떻게 문서화되는지 아주 명확하지만, 조금 장황해지는 단점이 있어요.
json_schema = {
"title": "joke",
"description": "Joke to tell user.",
"type": "object",
"properties": {
"setup": {
"type": "string",
"description": "The setup of the joke",
},
"punchline": {
"type": "string",
"description": "The punchline to the joke",
},
"rating": {
"type": "integer",
"description": "How funny the joke is, from 1 to 10",
"default": None,
},
},
"required": ["setup", "punchline"],
}
structured_llm = llm.with_structured_output(json_schema)
structured_llm.invoke("Tell me a joke about cats")
여러 스키마 중에서 고르기
모델이 여러 스키마 중에서 선택하게 하는 가장 간단한 방법은 Union 타입 속성을 가진 부모 스키마를 만드는 것이에요.
Pydantic 사용
from typing import Union
class Joke(BaseModel):
"""Joke to tell user."""
setup: str = Field(description="The setup of the joke")
punchline: str = Field(description="The punchline to the joke")
rating: Optional[int] = Field(
default=None, description="How funny the joke is, from 1 to 10"
)
class ConversationalResponse(BaseModel):
"""Respond in a conversational manner. Be kind and helpful."""
response: str = Field(description="A conversational response to the user's query")
class FinalResponse(BaseModel):
final_output: Union[Joke, ConversationalResponse]
structured_llm = llm.with_structured_output(FinalResponse)
structured_llm.invoke("Tell me a joke about cats")
structured_llm.invoke("How are you today?")
TypedDict 사용
from typing import Optional, Union
from typing_extensions import Annotated, TypedDict
class Joke(TypedDict):
"""Joke to tell user."""
setup: Annotated[str, ..., "The setup of the joke"]
punchline: Annotated[str, ..., "The punchline of the joke"]
rating: Annotated[Optional[int], None, "How funny the joke is, from 1 to 10"]
class ConversationalResponse(TypedDict):
"""Respond in a conversational manner. Be kind and helpful."""
response: Annotated[str, ..., "A conversational response to the user's query"]
class FinalResponse(TypedDict):
final_output: Union[Joke, ConversationalResponse]
structured_llm = llm.with_structured_output(FinalResponse)
structured_llm.invoke("Tell me a joke about cats")
structured_llm.invoke("How are you today?")
응답은 Pydantic 예시와 동일하게 나와요. 농담을 원하면 Joke, 일상 대화를 원하면 ConversationalResponse를 모델이 선택합니다.
또는 모델이 여러 옵션 중에서 선택하도록 도구 호출(tool calling)을 직접 쓸 수도 있어요. 다만 이 방식은 파싱과 설정이 좀 더 필요하지만, 중첩 스키마를 쓰지 않아서 성능이 더 좋은 경우도 있어요.
스트리밍
출력 타입이 dict일 때(즉 스키마를 TypedDict 클래스나 JSON Schema dict로 지정했을 때) 구조화된 모델의 출력을 스트리밍할 수 있어요.
참고: 산출되는 것은 delta가 아니라 이미 집계된(aggregated) 청크예요.
from typing_extensions import Annotated, TypedDict
# TypedDict
class Joke(TypedDict):
"""Joke to tell user."""
setup: Annotated[str, ..., "The setup of the joke"]
punchline: Annotated[str, ..., "The punchline of the joke"]
rating: Annotated[Optional[int], None, "How funny the joke is, from 1 to 10"]
structured_llm = llm.with_structured_output(Joke)
for chunk in structured_llm.stream("Tell me a joke about cats"):
print(chunk)
Few-shot 프롬프팅
더 복잡한 스키마에서는 프롬프트에 few-shot 예시를 추가하는 게 매우 유용해요. 몇 가지 방법이 있어요.
가장 간단하고 보편적인 방법은 시스템 메시지에 예시를 추가하는 것이에요.
from langchain_core.prompts import ChatPromptTemplate
system = """You are a hilarious comedian. Your specialty is knock-knock jokes. \
Return a joke which has the setup (the response to "Who's there?") and the final punchline (the response to "<setup> who?").
Here are some examples of jokes:
example_user: Tell me a joke about planes
example_assistant: {{"setup": "Why don't planes ever get tired?", "punchline": "Because they have rest wings!", "rating": 2}}
example_user: Tell me another joke about planes
example_assistant: {{"setup": "Cargo", "punchline": "Cargo 'vroom vroom', but planes go 'zoom zoom'!", "rating": 10}}
example_user: Now about caterpillars
example_assistant: {{"setup": "Caterpillar", "punchline": "Caterpillar really slow, but watch me turn into a butterfly and steal the show!", "rating": 5}}"""
prompt = ChatPromptTemplate.from_messages([("system", system), ("human", "{input}")])
few_shot_structured_llm = prompt | structured_llm
few_shot_structured_llm.invoke("what's something funny about woodpeckers")
구조화 출력의 기반이 도구 호출(tool calling)이라면 예시를 명시적인 도구 호출로 넘길 수도 있어요. 사용하는 모델이 도구 호출을 쓰는지는 API 레퍼런스에서 확인할 수 있어요.
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
examples = [
HumanMessage("Tell me a joke about planes", name="example_user"),
AIMessage(
"",
name="example_assistant",
tool_calls=[
{
"name": "joke",
"args": {
"setup": "Why don't planes ever get tired?",
"punchline": "Because they have rest wings!",
"rating": 2,
},
"id": "1",
}
],
),
# Most tool-calling models expect a ToolMessage(s) to follow an AIMessage with tool calls.
ToolMessage("", tool_call_id="1"),
# Some models also expect an AIMessage to follow any ToolMessages,
# so you may need to add an AIMessage here.
HumanMessage("Tell me another joke about planes", name="example_user"),
AIMessage(
"",
name="example_assistant",
tool_calls=[
{
"name": "joke",
"args": {
"setup": "Cargo",
"punchline": "Cargo 'vroom vroom', but planes go 'zoom zoom'!",
"rating": 10,
},
"id": "2",
}
],
),
ToolMessage("", tool_call_id="2"),
HumanMessage("Now about caterpillars", name="example_user"),
AIMessage(
"",
name="example_assistant",
tool_calls=[
{
"name": "joke",
"args": {
"setup": "Caterpillar",
"punchline": "Caterpillar really slow, but watch me turn into a butterfly and steal the show!",
"rating": 5,
},
"id": "3",
}
],
),
ToolMessage("", tool_call_id="3"),
]
system = """You are a hilarious comedian. Your specialty is knock-knock jokes. \
Return a joke which has the setup (the response to "Who's there?") \
and the final punchline (the response to "<setup> who?")."""
prompt = ChatPromptTemplate.from_messages(
[("system", system), ("placeholder", "{examples}"), ("human", "{input}")]
)
few_shot_structured_llm = prompt | structured_llm
few_shot_structured_llm.invoke({"input": "crocodiles", "examples": examples})
도구 호출에서 few-shot 프롬프팅에 대한 더 자세한 내용은 관련 가이드를 참고하세요.
(고급) 구조화 출력 메서드 지정
구조화 출력을 지원하는 수단이 두 개 이상인 모델(도구 호출과 JSON mode를 모두 지원)이라면 method= 인자로 어떤 수단을 쓸지 지정할 수 있어요.
참고(JSON mode): JSON mode를 쓰면 원하는 스키마를 여전히 모델 프롬프트에 지정해야 해요.
with_structured_output에 넘긴 스키마는 모델 출력을 파싱하는 데만 쓰이고, 도구 호출처럼 모델에 전달되지는 않아요.
structured_llm = llm.with_structured_output(None, method="json_schema")
structured_llm.invoke(
"Tell me a joke about cats, respond in JSON with `setup` and `punchline` keys"
)
(고급) 원시 출력 (Raw outputs)
LLM은 특히 스키마가 복잡해질수록 구조화 출력을 완벽하게 만들지 못할 수 있어요. 예외를 던지지 않고 원시 출력을 직접 처리하고 싶으면 include_raw=True를 넘기면 됩니다. 출력 형식이 raw 메시지 출력, parsed 값(성공 시), 그리고 발생한 오류를 담도록 바뀌어요.
structured_llm = llm.with_structured_output(Joke, include_raw=True)
structured_llm.invoke("Tell me a joke about cats")
모델 출력을 직접 프롬프팅하고 파싱하기
모든 모델이 .with_structured_output()을 지원하지는 않아요. 도구 호출이나 JSON mode를 지원하지 않는 모델도 있기 때문이죠. 그런 모델은 모델에 특정 형식을 쓰도록 직접 프롬프팅하고, 출력 파서로 원시 출력에서 구조화된 응답을 추출해야 합니다.
PydanticOutputParser 사용
아래 예시는 내장 PydanticOutputParser를 써서, 주어진 Pydantic 스키마에 맞게 프롬프팅된 채팅 모델의 출력을 파싱해요. format_instructions를 파서의 메서드에서 직접 프롬프트에 추가한 점에 주목하세요.
from typing import List
from langchain_core.output_parsers import PydanticOutputParser
from langchain_core.prompts import ChatPromptTemplate
from pydantic import BaseModel, Field
class Person(BaseModel):
"""Information about a person."""
name: str = Field(..., description="The name of the person")
height_in_meters: float = Field(
..., description="The height of the person expressed in meters."
)
class People(BaseModel):
"""Identifying information about all people in a text."""
people: List[Person]
# Set up a parser
parser = PydanticOutputParser(pydantic_object=People)
# Prompt
prompt = ChatPromptTemplate.from_messages(
[
(
"system",
"Answer the user query. Wrap the output in `json` tags\n{format_instructions}",
),
("human", "{query}"),
]
).partial(format_instructions=parser.get_format_instructions())
모델에 어떤 정보가 전달되는지 볼게요.
query = "Anna is 23 years old and she is 6 feet tall"
print(prompt.invoke({"query": query}).to_string())
이제 호출해 봅니다.
chain = prompt | llm | parser
chain.invoke({"query": query})
구조화 출력을 위한 출력 파서와 프롬프팅 기법에 대해 더 깊이 알고 싶다면 관련 가이드를 참고하세요.
커스텀 파싱
LangChain Expression Language(LCEL)로 커스텀 프롬프트와 파서를 만들어서, 평범한 함수로 모델 출력을 파싱할 수도 있어요.
import json
import re
from typing import List
from langchain_core.messages import AIMessage
from langchain_core.prompts import ChatPromptTemplate
from pydantic import BaseModel, Field
class Person(BaseModel):
"""Information about a person."""
name: str = Field(..., description="The name of the person")
height_in_meters: float = Field(
..., description="The height of the person expressed in meters."
)
class People(BaseModel):
"""Identifying information about all people in a text."""
people: List[Person]
# Prompt
prompt = ChatPromptTemplate.from_messages(
[
(
"system",
"Answer the user query. Output your answer as JSON that "
"matches the given schema: ```json\n{schema}\n```. "
"Make sure to wrap the answer in ```json and ``` tags",
),
("human", "{query}"),
]
).partial(schema=People.model_json_schema())
# Custom parser
def extract_json(message: AIMessage) -> List[dict]:
"""Extracts JSON content from a string where JSON is embedded between ```json and ``` tags.
Parameters:
text (str): The text containing the JSON content.
Returns:
list: A list of extracted JSON strings.
"""
text = message.content
# Define the regular expression pattern to match JSON blocks
pattern = r"```json(.*?)```"
# Find all non-overlapping matches of the pattern in the string
matches = re.findall(pattern, text, re.DOTALL)
# Return the list of matched JSON strings, stripping any leading or trailing whitespace
try:
return [json.loads(match.strip()) for match in matches]
except Exception:
raise ValueError(f"Failed to parse: {message}")
모델에 보내지는 프롬프트는 이렇게 만들어져요.
query = "Anna is 23 years old and she is 6 feet tall"
print(prompt.format_prompt(query=query).to_string())
호출하면 어떤 결과가 나오는지 볼게요.
chain = prompt | llm | extract_json
chain.invoke({"query": query})
추가 도구와 함께 쓰기
구조화 출력과 추가 도구(웹 검색 같은)를 함께 써야 한다면 연산 순서에 주의하세요.
올바른 순서:
# 1. Bind tools first
llm_with_tools = llm.bind_tools([web_search_tool, calculator_tool])
# 2. Apply structured output
structured_llm = llm_with_tools.with_structured_output(MySchema)
잘못된 순서:
# This will fail with "Tool 'MySchema' not found" error
structured_llm = llm.with_structured_output(MySchema)
broken_llm = structured_llm.bind_tools([web_search_tool])
순서가 중요한 이유: with_structured_output()은 내부적으로 도구 호출을 사용해 스키마를 강제해요. 이후에 추가 도구를 bind 하면 도구 해석 시스템에서 충돌이 생깁니다.
완전한 예시:
from pydantic import BaseModel, Field
from langchain_openai import ChatOpenAI
class SearchResult(BaseModel):
"""Structured search result."""
query: str = Field(description="The search query")
findings: str = Field(description="Summary of findings")
# Define tools
search_tool = {
"type": "function",
"function": {
"name": "web_search",
"description": "Search the web for information",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string", "description": "Search query"}},
"required": ["query"],
},
},
}
# Correct approach
llm = ChatOpenAI()
llm_with_search = llm.bind_tools([search_tool])
structured_search_llm = llm_with_search.with_structured_output(SearchResult)
# Now you can use both search and get structured output
result = structured_search_llm.invoke("Search for latest AI research and summarize")
즉 먼저 bind_tools로 도구를 붙인 다음에 with_structured_output을 적용해야 해요. 반대로 하면 "Tool 'MySchema' not found" 같은 오류가 납니다.