포스트

LangChain 기초 (3) - Prompts and Output Parsers: 템플릿과 구조화 출력

프롬프트 템플릿에 예시와 대화 이력을 끼우는 방법, 출력 파서와 with_structured_output으로 모델의 답을 검증된 객체로 받는 방법, 구조화 출력이 스트리밍을 끊는 지점을 다룹니다.

LangChain 기초 (3) - Prompts and Output Parsers: 템플릿과 구조화 출력

LangChain 기초 시리즈의 3편입니다. 전체 목차는 0편에 있습니다.

2편에서 부품을 잇는 방법을 봤습니다. 이번 편은 체인의 양끝입니다. 들어가는 쪽을 템플릿으로 정리하고, 나오는 쪽을 문자열이 아니라 검증된 객체로 받습니다.

자리를 비워 두는 템플릿

프롬프트의 뼈대는 고정하고 바뀌는 값만 자리로 둡니다. 문자열 f-string과 다른 점은 자리 이름이 곧 체인의 입력 스키마가 된다는 것입니다.

1
2
3
4
5
6
7
8
9
from langchain_core.prompts import ChatPromptTemplate

prompt = ChatPromptTemplate.from_messages([
    ("system", "너는 온라인 쇼핑몰의 문의 분류기다. 문의를 {labels} 중 하나로 분류한다."),
    ("user", "{text}"),
])

print(prompt.get_input_jsonschema()["properties"].keys())
# dict_keys(['labels', 'text'])

두 자리 중 labels는 매번 같습니다. 이런 값은 미리 채워 둡니다.

1
2
3
4
5
LABELS = ["배송", "환불", "상품문의", "기타"]

prompt = prompt.partial(labels=", ".join(LABELS))
print(prompt.get_input_jsonschema()["properties"].keys())
# dict_keys(['text'])

partial은 자리 일부를 고정한 새 템플릿을 돌려줍니다. 남은 자리만 체인의 입력으로 남으므로, 호출하는 쪽이 신경 쓸 값이 줄어듭니다.

예시를 끼우는 자리

프롬프트 엔지니어링 기초 3편에서 예시를 넣으면 경계에 걸친 문의의 분류가 개선되는 것을 봤습니다. 그때는 예시 문자열을 손으로 이어 붙였습니다. 템플릿에는 예시 전용 부품이 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from langchain_core.prompts import ChatPromptTemplate, FewShotChatMessagePromptTemplate

EXAMPLES = [
    {"text": "송장번호가 조회되지 않아요.", "label": "배송"},
    {"text": "개봉했는데도 환불이 되나요?", "label": "환불"},
    {"text": "재입고 예정이 있는지 궁금합니다.", "label": "상품문의"},
    {"text": "배송이 늦어서 취소하고 환불받고 싶어요.", "label": "환불"},
]

few_shot = FewShotChatMessagePromptTemplate(
    examples=EXAMPLES,
    example_prompt=ChatPromptTemplate.from_messages([
        ("user", "{text}"),
        ("ai", "{label}"),
    ]),
)

prompt = ChatPromptTemplate.from_messages([
    ("system", "너는 온라인 쇼핑몰의 문의 분류기다. 문의를 {labels} 중 하나로 분류한다."),
    few_shot,
    ("user", "{text}"),
]).partial(labels=", ".join(LABELS))

for m in prompt.invoke({"text": "결제 취소했는데 카드 승인이 아직 안 취소됐어요."}).to_messages():
    print(f"{m.type}: {m.text}")
1
2
3
4
5
6
7
system: 너는 온라인 쇼핑몰의 문의 분류기다. 문의를 배송, 환불, 상품문의, 기타 중 하나로 분류한다.
human: 송장번호가 조회되지 않아요.
ai: 배송
human: 개봉했는데도 환불이 되나요?
ai: 환불
...
human: 결제 취소했는데 카드 승인이 아직 안 취소됐어요.

예시가 사용자와 모델이 주고받은 대화로 펼쳐집니다. 예시를 문자열 안에 적는 방식보다 나은 점은 예시 목록만 바꾸면 프롬프트 구조를 건드리지 않는다는 것입니다. 예시를 데이터베이스에서 가져오거나 질문과 비슷한 것만 골라 넣는 식으로 바꾸기 쉬워집니다.

대화 이력을 끼우는 자리

턴 수가 정해지지 않은 이력을 넣으려면 자리 하나에 메시지 여러 개가 들어가야 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from langchain_core.prompts import MessagesPlaceholder

chat_prompt = ChatPromptTemplate.from_messages([
    ("system", "너는 온라인 쇼핑몰의 고객 지원 담당자다."),
    MessagesPlaceholder("history"),
    ("user", "{question}"),
])

messages = chat_prompt.invoke({
    "history": [
        ("user", "지난주에 주문한 이어폰 언제 오나요?"),
        ("ai", "8월 3일에 배송 완료된 것으로 확인됩니다."),
    ],
    "question": "그럼 환불은 언제까지 가능한가요?",
}).to_messages()

print(len(messages))   # 4

("user", "{question}")은 문자열 하나가 들어가는 자리이고, MessagesPlaceholder("history")는 메시지 리스트가 통째로 들어가는 자리입니다. 이력을 누가 보관하고 언제 잘라 낼지는 이 부품이 정해 주지 않습니다. 그 문제는 6편에서 다룹니다.

답을 문자열로 받는 경우

2편에서 쓴 StrOutputParserAIMessage에서 텍스트만 꺼냅니다. 분류처럼 라벨 하나만 필요할 때는 이걸로 충분합니다.

1
2
3
4
5
6
7
8
from langchain.chat_models import init_chat_model
from langchain_core.output_parsers import StrOutputParser

model = init_chat_model("anthropic:claude-sonnet-5", max_tokens=256)
classifier = prompt | model | StrOutputParser()

print(classifier.invoke({"text": "결제 취소했는데 카드 승인이 아직 안 취소됐어요."}))
# 환불

문제는 모델이 항상 라벨만 답한다는 보장이 없다는 것입니다. “환불입니다”나 “이 문의는 환불 카테고리에 해당합니다”가 나오면 뒷단계가 깨집니다. 프롬프트 엔지니어링 기초 4편에서 json.loads가 깨지고 정규식으로 중괄호를 찾는 코드를 붙이게 되는 과정을 다뤘습니다.

답을 객체로 받는 경우

스키마를 모델에 알려 주고 검증된 객체로 돌려받습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from typing import Literal
from pydantic import BaseModel, Field

class Classification(BaseModel):
    """고객 문의 분류 결과."""
    label: Literal["배송", "환불", "상품문의", "기타"] = Field(description="문의 카테고리")
    confidence: float = Field(description="0에서 1 사이의 확신도")
    reason: str = Field(description="그 카테고리로 판단한 근거 한 문장")

structured_model = model.with_structured_output(Classification)
classifier = prompt | structured_model

result = classifier.invoke({"text": "배송이 늦어서 취소하고 환불받고 싶어요."})
print(type(result).__name__, result.label, result.confidence)
# Classification 환불 0.9
print(result.reason)
# 배송 지연을 언급했지만 고객이 요청한 것은 주문 취소와 환불이다.

with_structured_output은 모델을 감싼 새 Runnable을 돌려줍니다. 파서가 체인 끝에 따로 붙는 것이 아니라 모델 자체가 객체를 내놓는 부품으로 바뀝니다. 내부적으로는 제공자의 도구 호출이나 JSON 스키마 기능을 써서 형식을 강제합니다.

세 가지가 한꺼번에 해결됩니다. 클래스의 docstring과 Fielddescription이 모델에게 가는 설명이 되고, Literal이 라벨 오타를 막고, Pydantic이 타입을 검증합니다. 스키마를 따로 작성해 프롬프트에 붙이는 과정이 사라집니다.

원본 응답도 함께 필요하면 옵션을 켭니다. 토큰 사용량을 세거나 파싱 실패를 로그로 남길 때 씁니다.

1
2
3
4
5
6
raw_model = model.with_structured_output(Classification, include_raw=True)
out = raw_model.invoke(prompt.invoke({"text": "재입고 예정이 있는지 궁금합니다."}))

print(out["parsed"].label)                     # 상품문의
print(out["raw"].usage_metadata)               # {'input_tokens': ..., 'output_tokens': ...}
print(out["parsing_error"])                    # None

include_raw=True를 주면 예외를 던지는 대신 parsing_error에 담아 돌려줍니다. 배치로 100건을 돌릴 때 한 건 때문에 전체가 멈추지 않게 하려면 이쪽이 낫습니다.

구조화 출력이 스트리밍을 끊는다

2편에서 말한 “전체 출력이 모여야 결과를 낼 수 있는 부품”이 여기 있습니다.

1
2
3
4
5
6
7
for piece in (prompt | model | StrOutputParser()).stream({"text": "송장번호가 조회되지 않아요."}):
    print(piece, end="|")
# 배|송|

for piece in (prompt | structured_model).stream({"text": "송장번호가 조회되지 않아요."}):
    print(piece)
# label='배송' confidence=0.95 reason='송장 조회 문제는 배송 관련 문의다.'

두 번째는 조각이 하나만 나옵니다. Pydantic 객체를 만들려면 JSON이 끝까지 와야 하기 때문에, 완성된 객체 하나를 마지막에 한 번 내보냅니다. 사용자에게 답을 흘려 보여 줘야 하는 화면에서 구조화 출력을 쓰면 체감 속도가 사라집니다.

답변 본문은 흘리고 메타데이터만 구조로 받고 싶다면 체인을 둘로 나눕니다. 본문 생성은 StrOutputParser로 스트리밍하고, 분류나 출처 추출은 별도 체인으로 돌립니다. 하나의 체인이 둘 다 하게 만들 수는 없습니다.

부분 JSON이라도 흘려받아야 한다면 JsonOutputParser가 있습니다. 미완성 JSON을 그때까지의 dict로 파싱해 내보내지만, Pydantic 검증은 받지 못합니다.

1
from langchain_core.output_parsers import JsonOutputParser

함정

중괄호는 이스케이프해야 합니다. 템플릿 문자열 안의 {}는 자리 표시로 해석됩니다. 프롬프트에 JSON 예시를 넣으려면 ``로 적어야 합니다. 이 오류는 체인을 만들 때가 아니라 invoke 할 때 “missing variable” 형태로 나오므로 원인을 찾기 어렵습니다.

사용자 입력에는 자리 표시가 없어야 합니다. 고객 문의 본문에 {가 들어 있어도 그건 ("user", "{text}") 자리에 값으로 들어가는 것이라 안전합니다. 위험한 것은 사용자 입력으로 템플릿 문자열 자체를 조립하는 경우입니다.

Fielddescription은 주석이 아닙니다. 모델에게 실제로 전달되는 설명입니다. 비워 두면 필드 이름만 보고 판단하므로, confidence 같은 이름은 무엇을 기준으로 한 값인지 적어 줘야 일관된 값이 나옵니다.

with_structured_output은 도구 호출 자리를 씁니다. 제공자에 따라 내부적으로 도구를 하나 만들어 강제 호출하는 방식이라, 같은 호출에서 다른 도구도 쓰게 하려면 충돌합니다. 도구와 구조화 출력을 함께 써야 하는 경우는 6편에서 봅니다.

정리

  • 템플릿의 자리 이름이 체인의 입력 스키마가 되고, partial로 고정하면 남은 자리만 입력으로 남는다
  • FewShotChatMessagePromptTemplate은 예시를 주고받은 대화로 펼치고, MessagesPlaceholder는 길이가 정해지지 않은 이력을 한 자리에 받는다
  • with_structured_output은 파서를 붙이는 것이 아니라 모델을 객체를 내놓는 부품으로 바꾼다
  • Pydantic의 docstring과 description이 모델에게 가는 설명이고, Literal이 값의 범위를 막는다
  • 구조화 출력은 스트리밍을 끊는다. 흘려 보여 줄 본문과 구조로 받을 메타데이터는 체인을 나눈다

다음 편에서 검색 부품으로 넘어갑니다. RAG 시리즈에서 numpy로 계산하고 Chroma 클라이언트를 직접 다뤘던 코드를 로더와 스플리터와 벡터스토어로 바꿔 끼우고, chunk가 어떻게 달라지는지 나란히 봅니다.

다음 글: LangChain 기초 (4) - Retrieval Components: 로더, 스플리터, 벡터스토어, 리트리버

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.