모델 답을 프로그램이 믿고 쓰려면 어떤 양식이 필요할까요?

은행에서 서류를 쓸 때 백지를 받는 경우는 없습니다. 이름과 계좌 번호와 날짜 칸이 나뉜 양식을 받습니다. 창구 직원이 양식이 같은 서류만 처리할 수 있기 때문입니다.

프로그램도 마찬가지입니다. 문의 내용을 분류해서 담당 큐(대기열)로 보내는 프로그램은 모델의 답에서 "장비"라는 단어를 정확히 읽어야 합니다. 그런데 모델에게 그냥 물으면 답의 모양이 매번 달라집니다. 같은 질문을 세 번 물어 받은 서류를 분류함 투입구에 넣어 봅니다.

문의

양식 없는 쪽의 답은 설명을 위한 예시입니다. 실제 모양은 모델과 질문마다 다릅니다.

양식 그림은 TicketInfo 클래스의 칸을 종이 양식처럼 그린 것입니다. 칸 설명은 모델이 읽는 안내문이고 사람 눈에 보이는 서류와는 다릅니다.

Pydantic 클래스는 빈칸이 있는 양식입니다

Pydantic은 Python 클래스로 데이터의 모양을 적어 두고 값이 그 모양에 맞는지 검사해 주는 패키지입니다. 클래스의 칸마다 이름과 종류(타입)를 적으면 양식이 됩니다. 이 글에서는 문의 하나를 분야와 긴급도와 요약 세 칸으로 나눕니다.

분야 칸의 종류를 str로 두면 "장비"도 "하드웨어"도 "컴퓨터 고장"도 들어갈 수 있어서 읽는 쪽이 곤란해집니다. Literal로 보기를 정해 두면 객관식 문제처럼 그중 하나만 고르게 됩니다. 칸에 붙인 설명(description)도 모델에게 전달되어 칸을 채우는 단서가 됩니다.

schema.pypython
from typing import Literal
from pydantic import BaseModel, Field

# 1 양식: 칸 이름과 칸의 종류를 적는다
class TicketInfo(BaseModel):
    # 2 보기에서 하나만 고르는 칸
    category: Literal["계정", "장비", "소프트웨어", "네트워크", "기타"] = Field(
        description="문의 분야")
    # 3 칸 설명은 모델에게 전달된다
    urgency: Literal["낮음", "보통", "높음"] = Field(description="급한 정도")
    summary: str = Field(description="한 문장 요약")
  1. 클래스가 양식입니다. BaseModel을 이어받은 클래스에 칸이름: 종류를 적으면 됩니다. 이 클래스 하나가 모델에게 보내는 요청과 받은 값을 검사하는 일에 모두 쓰입니다.
  2. 선택지를 닫아 둡니다. Literal[...]은 괄호 안의 값만 허용한다는 뜻입니다. 보기에 없는 값이 오면 검사에서 걸립니다. 보기를 늘리려면 이 목록만 고칩니다.
  3. 칸 설명을 적습니다. Field(description=...)의 글은 양식과 함께 모델에게 전달됩니다. "급한 정도"처럼 짧게 적어도 칸의 뜻이 분명해집니다. 설명을 빼도 동작하지만 모델이 칸을 헷갈릴 수 있습니다.

양식을 모델에게 건네는 길은 두 가지입니다

양식을 만들었으면 모델에게 넘겨야 합니다. 양식 클래스를 그대로 response_format(응답 모양을 정하는 설정)에 넘기면 LangChain이 모델 정보를 보고 길을 자동으로 고릅니다. 스크롤하며 길이 어떻게 갈리는지 보세요.

첫 갈림길

LangChain이 양식을 모델에게 넘기는 방법은 두 가지입니다. 하나는 도구로 변장시키는 ToolStrategy이고 다른 하나는 모델 회사가 직접 제공하는 구조화 출력 기능에 맡기는 ProviderStrategy입니다.

ToolStrategy: 양식을 도구로 변장

양식을 도구처럼 위장해 건네면 모델이 도구를 호출하듯 칸을 채웁니다. 요청에는 tools와 tool_choice: any가 실립니다. 받은 값은 라이브러리가 Pydantic으로 검사하고 실패하면 다시 시킵니다.

ProviderStrategy: 회사 기능에 맡김

모델 회사 서버가 모양을 맞춰서 양식에 맞는 JSON 글을 돌려줍니다. 요청에는 output_config의 json_schema가 실립니다. 라이브러리는 받은 JSON을 Pydantic 객체로 바꿉니다.

Claude 5.5는 도구 길이 막혀 있습니다

Anthropic 문서에 따르면 Claude Sonnet 5.5와 Opus 5.5는 tool_choice: any를 받지 않습니다. 그래서 이 모델에서는 자동 선택이 ProviderStrategy가 되어 문제없이 동작합니다.

요청 조각은 같은 양식을 두 방식으로 보낼 때 실제로 나가는 요청을 줄여 옮긴 것입니다.

classify.pypython
# ... model = init_chat_model("anthropic:claude-sonnet-5-5")
# 4 양식을 건네면 알맞은 길을 자동으로 고른다
agent = create_agent(model, tools=[], response_format=TicketInfo)
result = agent.invoke({"messages": [
    {"role": "user", "content": "노트북 화면이 계속 깜빡여서 회의를 못 하고 있어요"}]})
# 5 양식에 맞게 채워진 객체
ticket = result["structured_response"]
print(ticket.category, ticket.urgency)
터미널
$ python classify.py 장비 높음

출력은 예시 답입니다. 코드는 langchain 1.4와 pydantic 2.14 기준입니다.

  1. 양식을 넘기는 한 줄입니다. response_format에 클래스를 그대로 주면 모델에 맞는 길을 LangChain이 고릅니다. 모델이 이미 있다면 앞 글에서 만든 init_chat_model 결과를 그대로 씁니다. create_agent는 모델과 도구를 묶어 Agent를 만드는 LangChain 함수이고 tools=[]는 이번에는 도구 없이 분류만 한다는 뜻입니다.
  2. 결과는 따로 담깁니다. 채워진 양식은 result["structured_response"]에 TicketInfo 객체로 들어 있어서 ticket.category처럼 점으로 칸을 읽습니다. 글 답이 아니라 객체라서 프로그램이 바로 씁니다.
Agent 없이 모델에 양식만 건네는 방법
classify.pypython
structured = model.with_structured_output(
    TicketInfo, method="json_schema")
ticket = structured.invoke("노트북 화면이 계속 깜빡여서 회의를 못 하고 있어요")

모델 객체의 with_structured_output은 양식을 건 새 모델을 만듭니다. Anthropic 모델에서는 method="json_schema"를 직접 지정하는 편이 안전합니다. 기본 방식은 도구를 강제로 호출시키는 방법이라서 Sonnet 5.5에서는 지원하지 않는다는 경고가 나옵니다(langchain-anthropic 1.7.6에서 확인). Pydantic 클래스를 주면 객체가 돌아오고 TypedDict나 JSON Schema를 주면 dict가 돌아오는데 이때는 검사를 직접 해야 합니다.

양식이 모양을 지켜도 범위와 내용은 따로 검사합니다

구조화 출력을 켜면 칸이 모두 채워지고 종류가 맞는 답이 옵니다. 그래도 걸러야 할 일이 남습니다. Anthropic 문서에 따르면 숫자의 최소·최대와 문자열 길이 같은 제약은 양식에 넣을 수 없습니다. 모델이 답을 거절하거나(refusal) 길이 한도에 걸려 답이 잘리면(max_tokens) 양식을 못 따르기도 합니다.

실제로 confidence: float = Field(ge=0, le=1)처럼 범위를 적으면 LangChain은 그 범위를 양식 규칙이 아니라 칸 설명 글에 덧붙여 보냅니다(요청을 직접 확인했습니다). 범위 검사는 받은 뒤 Pydantic이 합니다. 문제 있는 답을 하나 고르면 어느 검문에서 걸리는지 보입니다.

검문 결과는 Anthropic 구조화 출력 문서와 Pydantic 검사 결과를 정리한 예시입니다. ToolStrategy를 쓰면 1겹이 없어서 보기에 없는 값도 2겹에서 걸립니다.

ToolStrategy를 쓸 때의 흐름입니다. 에러 문구는 실제로 만들어지는 메시지를 줄여 옮긴 것입니다.

검증에 실패하면 에러를 모델에게 돌려주고 다시 시킵니다

ToolStrategy에는 handle_errors 옵션이 있습니다. 기본값 True에서는 검사에 실패하면 에러 내용을 ToolMessage(도구 결과를 담는 메시지)에 담아 모델에게 돌려주고 다시 채우게 합니다. 도구가 실패했을 때 오류를 결과로 돌려주던 방식과 같습니다.

에러 문장에 무엇이 틀렸는지 들어 있어서 모델이 대개 한 번에 고칩니다. 값을 문자열이나 예외 종류로 바꾸면 재시도 문구와 대상을 정할 수 있고 False면 재시도 없이 에러를 그대로 던집니다. 몇 번까지 재시도하는지는 확인하지 못했습니다. 한도가 필요하면 Agent의 반복 한도로 막습니다.

retry.pypython
from langchain.agents.structured_output import ToolStrategy

# 6 검사에 실패하면 에러를 돌려주고 다시 시킨다
agent = create_agent(
    model, tools=[],
    response_format=ToolStrategy(TicketInfo, handle_errors=True))
result = agent.invoke({"messages": [
    {"role": "user", "content": "노트북 화면이 계속 깜빡여서 회의를 못 하고 있어요"}]})
print(result["structured_response"])
print("모델 호출", sum(m.type == "ai" for m in result["messages"]), "번")
터미널
$ python retry.py category='장비' urgency='높음' summary='노트북 화면이 깜빡임' 모델 호출 2 번

출력은 예시입니다. 첫 응답이 보기에 없는 값이었던 경우를 가정했습니다. ToolStrategy가 자동으로 고르는 길이 아닌 모델에서 보이도록 모델은 openai:gpt-5.5로 바꿨고 코드는 langchain 1.4 기준입니다.

  1. ToolStrategy로 직접 지정합니다. 클래스를 그대로 넘겼을 때 자동으로 고르는 방식 대신 도구 방식을 고르는 한 줄입니다. handle_errors=True는 기본값이라 적지 않아도 같습니다. 검사에 실패한 첫 호출과 고쳐 낸 두 번째 호출이 모두 result["messages"]에 남아서 호출 횟수를 셀 수 있습니다.

구조화 출력의 한 바퀴를 한 장으로 보면

지금까지 본 장면을 이어 붙이면 길 하나가 됩니다. 질문이 들어가고 양식이 길을 고르고 모델이 칸을 채워 돌아오면 두 겹의 검문을 지나 프로그램이 객체를 받습니다. 어느 정거장에서 걸려도 에러 쪽지가 모델에게 돌아가거나 프로그램이 직접 처리합니다.

정거장이 늘어나는 만큼 걸릴 곳도 늘어납니다. 그래서 양식을 쓸 때는 검문과 재시도까지 한 묶음으로 설계해야 합니다.

양식은 한 번 정해서 계속 씁니다

양식은 모양을 지켜 줍니다. 값이 옳은지는 검사가 지킵니다.

Pydantic 클래스를 양식으로 쓰면 보내는 요청과 받은 값의 검사가 한 곳에 모입니다. 보기를 늘리거나 칸을 더할 때도 클래스만 고치면 됩니다. 다만 모델이 틀린 분류를 자신 있게 내놓는 일은 양식으로 막을 수 없어서 평가가 따로 필요합니다. 다음 글에서는 모델이 쓸 도구를 잘 설계하는 법을 알아보겠습니다.

참고 자료

출처 7개 펼치기