3장. LangChain 기본 구조와 LCEL
실습 5 — RunnableParallel 로 요약과 키워드 한 번에 뽑기
손님이 남긴 긴 상담 글 하나를 두 갈래로 나눠, 한쪽은 한 줄 요약을, 다른 쪽은 키워드 목록을 만듭니다. RunnableParallel 로 두 갈래를 한 번에 돌리고, RunnablePassthrough.assign 으로 원래 글을 함께 남겨 상담 접수표를 만듭니다. 마지막으로 stream 으로 요약이 조각조각 나오는 모습을 봅니다. 두 갈래 모두 가짜 모델을 쓰므로 API 키가 없어도 되고, 손님 글은 연습용으로 지어낸 것입니다.
1단계. 두 갈래 체인 만들기
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 로 두 갈래를 한 번에
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단계. 원래 글을 남기고 접수표 만들기
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 으로 조각 받고 저장하기
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는 조각이 들어오는 대로 글을 꺼내 넘깁니다.- 가짜 모델은 질문을 읽지 않으므로 요약과 키워드는 정해 둔 글입니다. 진짜 모델로 바꾸면 손님 글에 따라 달라지고, 두 갈래의 요청 두 번이 동시에 나갑니다.