2026.07.03 19:00 - 22:00
Discord 영상통화로 진행
1주차에서는 LLM에게 tool을 쥐어줘서 "치과 예약 잡아줘" 같은 말을 바로 실행하게 만들었다. 2주차의 목표는 그 앞 단계를 다듬는 것이다. 사용자의 자유로운 문장을 앱이 바로 쓸 수 있는 정해진 형식의 데이터로 변환하는 방법을 배운다.
한 줄로 말하면: 말 → 바로 실행(1주차) 에서 말 → 구조화 → 실행(2주차) 으로, 중간에 검증 단계를 하나 추가하는 것.
LLM에게 "내일 3시에 회의 잡아줘"라고 하면 보통 이렇게 자유 문장으로 답한다.
"네, 내일 오후 3시에 회의를 잡았습니다!"
그런데 이 문장을 앱 DB에 저장하려면 코드가 날짜가 뭔지, 시간이 뭔지 알 수가 없다. 그래서 이렇게 정해진 형식으로 받아야 한다.
{
"kind": "personal_schedule",
"title": "회의",
"date": "2026-07-09",
"start_time": "15:00"
}
이러면 코드가 schedule["date"]처럼 값을 바로 꺼내 쓸 수 있다. 이게 structured output이고, 그 형식을 정의하는 도구가 Pydantic이다.
파이썬에서 데이터 형식을 정의하고 검증하는 라이브러리다. "이 데이터는 이런 필드가 있고 이런 타입이어야 한다"는 틀을 클래스로 만든다.
class ScheduleCreate(BaseModel):
title: str # 반드시 문자열
date: str
attendees: list[str]
타입이 안 맞으면 자동으로 에러를 내서, 잘못된 데이터가 코드 깊숙이 들어가는 걸 막아준다. response_format에 이 클래스를 넘기면 LLM이 자유 문장 대신 이 형식에 맞춰서 답하도록 강제된다.
사용자 요청이 어떤 종류인지 먼저 분류하는 라벨이다.
RequestKind = Literal["personal_schedule", "group_schedule", "todo", "reminder", "unknown"]
| kind | 예시 |
|---|---|
| personal_schedule | "내일 치과 예약" |
| group_schedule | "팀원들이랑 회의" |
| todo | "보고서 제출해야 함" |
| reminder | "발표 30분 전에 알려줘" |
| unknown | 애매해서 분류가 안 되는 요청 |
kind를 먼저 보고, 그 종류에 맞는 세부 필드를 읽는 구조다. unknown은 실패가 아니라, 애매한 요청을 억지로 분류하지 않고 안전하게 처리하는 방식이다.
LangChain의 create_agent에서 response_format으로 구조화된 출력을 받을 때, 그 결과를 만들어내는 내부 전략(strategy)이 나뉜다. 겉으로는 똑같이 구조화된 결과가 나오지만, 내부 동작 방식이 달라서 알아둘 필요가 있다.
create_agent(..., response_format=...)에 넣을 수 있는 값은 세 가지다.
response_format=StructuredRequest) : LangChain이 모델 성능에 맞춰 위 두 전략 중 자동 선택 (AutoStrategy 방식)스키마 타입을 직접 전달하면, LangChain은 모델과 provider가 네이티브 structured output을 지원하는 경우(OpenAI, Anthropic, xAI 등) ProviderStrategy를, 그 외 모델에는 ToolStrategy를 자동으로 선택한다.
모델 자체가 제공하는 structured output 기능을 우선 사용하는 방식이다.
toolStrategy가 function calling을 써서 구조화된 출력을 뽑아내는 것과 달리, providerStrategy는 provider의 네이티브 structured output 기능을 활용해서 더 효율적이고 안정적으로 스키마를 강제한다. ProviderStrategy는 내부적으로 context free grammar(CFG)나 finite state machine(FSM) 방식으로 스키마 제약을 컴파일해서 적용한다.
모델이 네이티브 기능을 지원한다면 이게 권장되는 방식이다. 다만 모델이 structured output을 지원하지 않으면 자동으로 tool calling 전략으로 폴백(fallback)된다.
LangChain 자체에서 동작하는 tool call 기반의 구조화 방식이다.
스키마를 function tool로 변환해서, 모델이 그 함수를 호출하며 스키마에 맞는 인자를 채우게 하고, agent가 그 tool call에서 구조화된 출력을 추출·검증한다. 이 방식은 서로 다른 모델 provider 간 호환성이 가장 높다 — function calling을 지원하는 대부분의 모델에서 동작하기 때문이다.
즉 모델의 네이티브 기능에 의존하지 않고, LangChain 레벨에서 "구조화 = 특정 tool 호출"로 처리하는 방식이다.
ToolStrategy는 tool call 형태로 동작하기 때문에, 구조화 과정이 "어떤 tool을 어떤 인자로 호출했는지"라는 명시적인 이벤트로 trace에 남는다. 디버깅이나 로깅 관점에서 구조화 흐름을 추적하기 좋다.
이게 핵심이다. ProviderStrategy는 모델 내부의 구조화 엔진(CFG/FSM)이, ToolStrategy는 LangChain의 tool call 파서가 결과를 만든다. 처리 경로 자체가 다르기 때문에, 완전히 동일한 프롬프트와 스키마를 줘도 두 전략이 서로 다르게 파싱하거나 다른 결과를 낼 수 있다.
그래서 어떤 전략으로 동작하는지 모르면 "왜 같은 코드인데 결과가 다르지?" 하는 혼란이 생긴다. 동작을 확실히 고정하고 싶으면 전략을 명시적으로 지정하는 게 안전하다.
from langchain.agents.structured_output import ToolStrategy, ProviderStrategy
# 방법 1: 스키마 직접 전달 → LangChain이 자동 선택
response_format=StructuredRequest
# 방법 2: ToolStrategy로 명시 고정
response_format=ToolStrategy(StructuredRequest)
# 방법 3: ProviderStrategy로 명시 고정
response_format=ProviderStrategy(StructuredRequest)
실무에서 알려진 이슈가 하나 있다. 일반 tool 호출과 structured output을 같이 쓸 때 두 전략 모두 문제가 생길 수 있다. 예를 들어 ToolStrategy에서는 실제 tool 호출과 "구조화용 가짜 tool 호출"이 충돌해서 recursion limit에 걸리거나 실제 tool이 제대로 실행되지 않는 경우가 보고된 적이 있다. (LangChain 버전에 따라 동작이 달라질 수 있으니, tool과 structured output을 함께 쓰는 구조라면 실제 동작을 trace로 꼭 확인하는 게 좋다.)
| ProviderStrategy | ToolStrategy | |
|---|---|---|
| 방식 | 모델 네이티브 structured output | LangChain tool call 기반 |
| 내부 동작 | CFG/FSM으로 스키마 강제 | 스키마를 tool로 변환해 호출 |
| 호환성 | 지원 모델만 (OpenAI, Anthropic 등) | function calling 되는 대부분의 모델 |
| 효율/안정성 | 더 효율적·안정적 | 호환성이 넓음 |
| trace 로깅 | tool call로 안 남음 | tool call 이벤트로 명시적으로 남음 |
스키마만 직접 넘기면 LangChain이 알아서 골라주지만, 동작을 확실히 통제하거나 두 전략의 파싱 차이를 피하려면 명시적으로 지정하는 게 안전하다.
참고: 이 내용은 LangChain 1.x 기준이며, 세부 API와 동작은 버전마다 바뀔 수 있다. 정확한 최신 정보는 LangChain 공식 structured output 문서에서 확인하는 것을 권장한다.
자연어 한 문장을 구조화한 결과를 담는 틀이다. kind만 필수이고, 나머지는 모를 수 있으니 대부분 None이나 빈 리스트가 기본값이다.
class StructuredRequest(BaseModel):
kind: RequestKind = Field(description="요청 종류")
title: str | None = Field(default=None, description="일정/할 일 제목")
date: str | None = Field(default=None, description="YYYY-MM-DD, 사용자가 직접 말한 경우에만")
start_time: str | None = Field(default=None, description="HH:MM, 직접 말한 경우에만 (지어내지 말 것)")
end_time: str | None = Field(default=None, description="HH:MM, 직접 말한 경우에만")
members: list[str] = Field(default_factory=list, description="참석자")
priority: str | None = Field(default=None, description="우선순위")
reason: str | None = Field(default=None, description="판단 근거")
original_text: str = Field(default="", description="원문 그대로 보존")
StructuredRequest를 여러 개 담는 묶음이다. "회의 잡고 보고서도 추가해줘"처럼 한 문장에 요청이 여러 개일 때도 처리할 수 있게, 요청이 하나뿐이어도 리스트 형태를 유지한다.
class StructuredRequestBatch(BaseModel):
requests: list[StructuredRequest] = Field(default_factory=list)
base_date: str = Field(default_factory=current_app_date_iso)
base_date는 "내일", "다음 주" 같은 상대 날짜를 실제 날짜로 계산할 때 기준이 되는 오늘 날짜다.
response_format=StructuredRequestBatch가 1주차와의 결정적 차이다. 이게 있어야 LLM 답변이 자유 문장이 아니라 구조화된 객체로 나온다.
def build_week02_agent():
return create_agent(
model=chat_model(),
tools=week02_tools(),
response_format=StructuredRequestBatch, # 핵심
system_prompt=week02_system_prompt(),
)
"다음 주 화요일 오후 3시에 철수랑 회의 잡아줘"를 입력하면:
StructuredRequestBatch(
requests=[
StructuredRequest(
kind='personal_schedule',
title='회의',
date='2026-07-14', // "다음 주 화요일"이 실제 날짜로 변환
start_time='15:00', // "오후 3시"가 24시간 형식으로
members=['철수'], // 참석자 추출
original_text='다음 주 화요일 오후 3시에 철수랑 회의 잡아줘' // 원문 보존
)
],
base_date='2026-07-08'
)
자유로운 한국어 문장이 정확히 필드가 채워진 객체로 변환됐다.
리뷰에서 지적받은 문제였다. "다음에 언제 밥 한번 먹자"처럼 날짜/시간이 없는 애매한 입력에서도 LLM이 값을 임의로 채웠다.
"내일 회의 잡아줘" (시간을 안 말했는데)
→ start_time='09:00' // LLM이 지어냄
→ end_time='18:00' // 회의 길이를 추측해서 채움
원래는 None이 나와야 맞다.
처음엔 프롬프트에 "확실하지 않으면 None으로 둬라"만 추가했는데 한 번에 해결되지 않았다. LLM은 "빈 문자열('')이나 '미정'"처럼 계속 다른 방식으로 값을 채웠다. 결국 두 가지를 모두 강화했다.
프롬프트에 구체적으로 반복 명시:
"시간이 명시되지 않으면 start_time과 end_time을 반드시 None으로 둔다. 임의의 시간을 절대 지어내지 마라.",
"빈 문자열('')이나 '미정' 같은 값을 넣지 말고 반드시 None으로 둔다.",
Field description에도 조건 추가 (description은 단순 주석이 아니라 LLM이 읽는 스키마 힌트이므로):
start_time: str | None = Field(default=None,
description="HH:MM 형식. 사용자가 시작 시간을 직접 말한 경우에만 채우고, 없으면 None (임의로 지어내지 말 것)")
이렇게 프롬프트와 스키마 양쪽에서 조건을 명시하니 대부분의 케이스에서 None이 정확히 나왔다.
교훈: LLM은 "빈칸을 채우려는" 성향이 강하다. "확실할 때만"처럼 애매한 표현은 "회의는 보통 1시간이니 확실하다"는 식으로 자기 편한 대로 해석해버린다. "사용자가 직접 말하지 않았으면 반드시 None"처럼 판단 기준을 구체적으로 줘야 한다.
2주차는 1주차를 대체하는 게 아니라 앞에 붙는 구조다.
1주차만: 말 → tool 실행
1주차 + 2주차: 말 → 구조화/검증 → tool 실행
1주차 코드는 그대로 살아있고, 2주차가 그 앞단을 더 안정적으로 만든다. 요청 종류가 여러 개이고 정확성이 중요할수록 이 구조화 단계가 중요해진다.
가장 인상적이었던 건 코드 로직보다 프롬프트와 Field description을 얼마나 명확하게 쓰느냐가 결과를 좌우한다는 점이었다. 사람한테 말하듯 대충 지시하면 LLM은 자기 식대로 해석해버린다. "이렇게 애매하게 쓰면 LLM이 이렇게 엉뚱하게 받아들이는구나"를 직접 겪어본 게 이번 주의 가장 큰 수확이었다.