LangChain 챗봇3장 · LangChain 기본 구조와 LCEL18 / 21← 이전목차다음 →MetaContents

3장. LangChain 기본 구조와 LCEL

실습 5 — RunnableParallel 로 요약과 키워드 한 번에 뽑기

손님이 남긴 긴 상담 글 하나를 두 갈래로 나눠, 한쪽은 한 줄 요약을, 다른 쪽은 키워드 목록을 만듭니다. RunnableParallel 로 두 갈래를 한 번에 돌리고, RunnablePassthrough.assign 으로 원래 글을 함께 남겨 상담 접수표를 만듭니다. 마지막으로 stream 으로 요약이 조각조각 나오는 모습을 봅니다. 두 갈래 모두 가짜 모델을 쓰므로 API 키가 없어도 되고, 손님 글은 연습용으로 지어낸 것입니다.

1단계. 두 갈래 체인 만들기

Python
from langchain_core.language_models.fake_chat_models import FakeListChatModel
from langchain_core.output_parsers import CommaSeparatedListOutputParser, StrOutputParser
from langchain_core.prompts import ChatPromptTemplate

MESSAGE = "다음 달 셋째 주에 강아지 한 마리랑 제주도 2박 3일 가고 싶어요. 숙소는 바다가 보이면 좋겠고, 렌터카도 필요해요."

# 갈래 1: 한 줄 요약. 가짜 모델이 정해 둔 요약을 돌려준다
summary_prompt = ChatPromptTemplate.from_messages([("system", "손님 글을 한 문장으로 요약한다."), ("human", "{message}")])
summary_chain = summary_prompt | FakeListChatModel(responses=["강아지와 함께 다음 달 제주도 2박 3일, 바다 전망 숙소와 렌터카를 원합니다."]) | StrOutputParser()

# 갈래 2: 키워드. 목록 파서의 형식 지시문을 시스템 메시지에 붙인다
list_parser = CommaSeparatedListOutputParser()
keyword_prompt = ChatPromptTemplate.from_messages([
    ("system", "손님 글에서 키워드를 다섯 개 뽑는다. {format}"),
    ("human", "{message}"),
]).partial(format=list_parser.get_format_instructions())
keyword_chain = keyword_prompt | FakeListChatModel(responses=["제주도, 2박 3일, 반려견, 바다 전망, 렌터카"]) | list_parser

print("요약:", summary_chain.invoke({"message": MESSAGE}))
print("키워드:", keyword_chain.invoke({"message": MESSAGE}))
출력
요약: 강아지와 함께 다음 달 제주도 2박 3일, 바다 전망 숙소와 렌터카를 원합니다.
키워드: ['제주도', '2박 3일', '반려견', '바다 전망', '렌터카']

코드가 의미하는 것 끝의 파서만 다른 두 체인을 만듭니다. summary_chain 은 StrOutputParser 로 글을, keyword_chain 은 CommaSeparatedListOutputParser 로 목록을 돌려주고, partial(format=list_parser.get_format_instructions()) 로 파서가 읽을 꼴을 모델에게 미리 일러 둡니다. 같은 손님 글에서 쓰임이 다른 결과 두 가지를 뽑는 준비입니다.

2단계. RunnableParallel 로 두 갈래를 한 번에

Python
from langchain_core.runnables import RunnableParallel

# 같은 입력 {"message": …} 가 두 갈래에 똑같이 들어가고, 결과는 사전 하나로 모인다
both = RunnableParallel(summary=summary_chain, keywords=keyword_chain)
result = both.invoke({"message": MESSAGE})

print(type(result).__name__, list(result))
print("요약:", result["summary"])
print("키워드:", result["keywords"], "→", len(result["keywords"]), "개")
출력
dict ['summary', 'keywords']
요약: 강아지와 함께 다음 달 제주도 2박 3일, 바다 전망 숙소와 렌터카를 원합니다.
키워드: ['제주도', '2박 3일', '반려견', '바다 전망', '렌터카'] → 5 개

코드가 의미하는 것 RunnableParallel(summary=…, keywords=…) 는 같은 입력을 두 갈래에 똑같이 넣고, 이름표를 열쇠로 결과를 사전 하나에 모읍니다. 상담 접수 화면처럼 한 글에서 여러 정보를 뽑을 때, 체인을 따로따로 부르지 않고 한 번에 부르는 방법입니다.

3단계. 원래 글을 남기고 접수표 만들기

Python
from langchain_core.runnables import RunnableLambda, RunnablePassthrough


def make_ticket(d):
    """두 갈래의 결과와 원래 글을 상담 접수표 사전으로 정리한다."""
    return {
        "원래 글자 수": len(d["message"]),
        "요약": d["summary"],
        "키워드": d["keywords"],
        # 키워드에 반려동물 낱말이 있으면 반려동물 담당에게 넘긴다
        "담당": "반려동물" if any(k in ["반려견", "강아지", "고양이"] for k in d["keywords"]) else "일반",
    }


# assign: 입력 사전의 message 는 그대로 두고 summary, keywords 를 옆에 붙인다
# 그다음 RunnableLambda 로 접수표를 만든다
ticket_chain = RunnablePassthrough.assign(summary=summary_chain, keywords=keyword_chain) | RunnableLambda(make_ticket)
ticket = ticket_chain.invoke({"message": MESSAGE})

for key, value in ticket.items():
    print(f"{key}: {value}")
출력
원래 글자 수: 66
요약: 강아지와 함께 다음 달 제주도 2박 3일, 바다 전망 숙소와 렌터카를 원합니다.
키워드: ['제주도', '2박 3일', '반려견', '바다 전망', '렌터카']
담당: 반려동물

코드가 의미하는 것 RunnablePassthrough.assign 은 원래 손님 글(message)을 지우지 않고 두 결과를 옆에 붙이고, RunnableLambda(make_ticket) 은 그 사전을 받아 파이썬 규칙으로 담당을 정합니다. 모델이 할 일(요약, 키워드)과 코드가 할 일(글자 수, 담당 고르기)을 한 체인 안에 나눠 놓은 것입니다.

4단계. stream 으로 조각 받고 저장하기

Python
import json
from pathlib import Path

# stream: 요약이 다 될 때까지 기다리지 않고 조각이 생기는 대로 받는다
pieces = []
for chunk in summary_chain.stream({"message": MESSAGE}):
    pieces.append(chunk)
print("조각 수:", len(pieces))
print("앞의 다섯 조각:", pieces[:5])
# 조각을 모두 이어 붙이면 invoke 의 결과와 같다
print("이어 붙인 글:", "".join(pieces))

# 접수표를 json 으로 남긴다. ensure_ascii=False: 한글을 그대로 적는다
Path("out_ch03_ticket.json").write_text(json.dumps(ticket, ensure_ascii=False, indent=2), encoding="utf-8")
print("저장: out_ch03_ticket.json")
출력
조각 수: 44
앞의 다섯 조각: ['강', '아', '지', '와', ' ']
이어 붙인 글: 강아지와 함께 다음 달 제주도 2박 3일, 바다 전망 숙소와 렌터카를 원합니다.
저장: out_ch03_ticket.json

코드가 의미하는 것 summary_chain.stream(...) 은 요약을 다 기다리지 않고 조각이 생기는 대로 받는 손잡이로, 챗봇 화면에 답이 타자 치듯 나오게 하는 바탕입니다. "".join(pieces) 가 invoke 의 결과와 같으므로 받는 방식만 다를 뿐 답은 같습니다. json.dumps(..., ensure_ascii=False) 로 남긴 접수표는 다른 프로그램이 읽어 쓸 수 있는 꼴입니다.

이 실습이 뜻하는 것

이 실습은 큰 식당에서 주문서를 복사해 국 담당과 반찬 담당에게 동시에 넘기고, 원본은 계산대로 보내는 일과 같습니다. 두 갈래 벨트를 따로 세워 시험하고(1단계), 한 번에 돌리고(2단계), 원본과 함께 쟁반에 모아 접수표를 만들고(3단계), 요리가 나오는 대로 받아 봅니다(4단계). 여기서 두 갈래 벨트가 summary_chain 과 keyword_chain, 복사기가 RunnableParallel, 원본을 넘기는 손이 RunnablePassthrough.assign 입니다. 비유와 다른 점은, 이 실습의 두 담당(가짜 모델)은 주문서를 읽지 않고 정해 둔 요리를 낸다는 것입니다.

단계출력뜻
1. 두 갈래요약은 글, 키워드는 목록 5개끝의 파서가 다르니 같은 손님 글에서 꼴이 다른 결과가 나왔습니다
2. 한 번에dict ['summary', 'keywords']RunnableParallel 의 이름표가 그대로 사전의 열쇠가 됐습니다
3. 접수표원래 글자 수 66, 담당 반려동물assign 이 원래 글을 남겨 두어 글자 수를 셀 수 있었고, 키워드 "반려견"으로 담당이 정해졌습니다
4. stream조각 44개, 이어 붙이면 같은 글가짜 모델이 한 글자씩 보내 44자짜리 요약이 조각 44개가 됐고, 이어 붙이면 invoke 의 결과와 같습니다

한 줄로 줄이면, 체인을 한 줄로만 잇지 않고 갈래로 나누고 다시 모으는 법을 익힌 실습입니다. 3단계의 make_ticket 처럼 모델의 결과를 받아 파이썬으로 판단하는 부품을 끝에 붙이면, 체인이 "요약하고, 키워드를 뽑고, 담당을 정하는" 작은 업무 흐름이 됩니다. 4단계의 stream 은 7장에서 Streamlit 화면에 답이 타자 치듯 나오게 할 때 다시 씁니다. 진짜 모델로 바꾸면 두 갈래의 요청이 동시에 나가므로, 요금은 두 번 들고 기다리는 시간은 오래 걸리는 갈래 하나만큼입니다.

코드 설명

  • keyword_prompt 의 {format} 빈칸에 list_parser.get_format_instructions() 를 partial 로 미리 채웠습니다. 파서가 읽을 수 있는 꼴(쉼표로 나눈 목록)을 모델에게 알려 주는 흔한 방법입니다.
  • RunnableParallel(summary=…, keywords=…) 의 이름표가 결과 사전의 열쇠가 됩니다. 갈래마다 끝의 파서가 달라서, 요약은 글로 키워드는 목록으로 돌아왔습니다.
  • RunnablePassthrough.assign(summary=…, keywords=…) 는 입력 사전의 message 를 지우지 않고 두 결과를 옆에 붙입니다. 그래서 다음 부품인 make_ticket 이 원래 글과 두 결과를 함께 쓸 수 있습니다.
  • summary_chain.stream(...) 은 파서까지 이은 체인의 stream 이라 조각이 AIMessageChunk 가 아닌 글 조각으로 나옵니다. StrOutputParser 는 조각이 들어오는 대로 글을 꺼내 넘깁니다.
  • 가짜 모델은 질문을 읽지 않으므로 요약과 키워드는 정해 둔 글입니다. 진짜 모델로 바꾸면 손님 글에 따라 달라지고, 두 갈래의 요청 두 번이 동시에 나갑니다.
← 이전 절3장 · 실습 4 — 다양한 프롬프트 견주기다음 절 →3장 · 프로젝트 1 — 여행지 소개 체인