[Codepresso 러너톤 도전기] Week 2. 러너블(Runnable)과 프롬프트
본 게시물은 테디노트의 <랭체인LangChain 노트> - LangChain 한국어 튜토리얼🇰🇷를 참고하고 있습니다.
Runnable
chain은 오직 클래스 객체로 정의된 변수(또는 생성자)만을 연결할 수 있다. 따라서, input()
과 같은 메서드를 연결하는 것이 불가능하다.
그렇지만 체인에 결과 문자열을 전처리하거나, 사용자의 입력을 받거나, 혹은 다른 기능을 수행하는 모듈을 연결해야 할 상황이 존재할 것이다. 특히 그 체인이 챗봇 체인인 경우엔 더욱 그렇다.
Runnable은 바로 이런 경우에 활용할 수 있다. 체인 과정에 메서드나 함수를 넣어야 하는 경우, 혹은 입력값을 받아와야 하는 경우 함수나 메서드를 Runnable 클래스 객체로 정의하도록 하여 체인에 연결할 수 있게 한다.
자주 쓰이는 Runnable 클래스로는 RunnablePathThrough
, RunnableWithMessageHistory
, RunnableLambda
가 있다.
RunnablePathThrough
week1 에서도 등장한 클래스이다. RunnablePathThrough
는 입력값을 전달하는 클래스이다. 랭체인 업데이트 이전 chain.invoke()
에서 input 변수가 1개인 상황에서도 딕셔너리 형태로 값을 전달해야 했는데, RunnablePathThrough
를 사용함으로써 .invoke
에 바로 입력값을 전달할 수 있었다.
RunnableWithMessageHistory
챗봇은 방금 나눴던 대화 내용을 모두 기억하고 있어야 하고, 이전 대화 내용을 참고해 대답할 수 있어야 한다. 그러나 단순히 .invoke
를 사용하는 것으로는 불가능하며, chain에 이것을 가능하게 하는 함수들을 연결하여 구현하는 방법은 너무 어렵고 복잡하다. 이때 사용할 수 있는 클래스가 바로 RunnableWithMessageHistory
이다.
RunnableWithMessageHistory는 기본적으로 프롬프트에 과거 대화 내역을 삽입하는 방식이다. 따라서 가장 먼저 대화 내용을 "ID" : [ChatMessageHistory] 쌍으로 저장할 수 있는 딕셔너리 타입의 store
를 정의해야 한다.
store = {}
그리고, store
로부터 session id를 사용해 대화 내용을 가져오고, 만일 존재하지 않는다면 새로운 ChatMessageHistory
객체를 생성하는 함수가 필요하다.
def get_session_history(session_id):
if session_id not in store:
store[session_id] = ChatMessageHistory()
return store[session_id]
프롬프트를 구성할 때에는, MessagePlaceHolder
를 사용해 대화 내역이 삽입될 지점을 정의한다.
prompt = ChatPromptTemplate.from_message(
[
("system", "당신은 k-pop 아이돌에 대한 질문에 댑을 주는 챗봇이고, 주어진 컨텍스트(context)를 참고하여 답변을 생성합니다."),
MessagePlaceHolder(variable_name="chat_histroy"),
("human", "다음의 컨텍스트를 참고하여 주어진 질문에 대한 답을 하시오.\n\n<context>{context}</context>\n\n<question>{question}</question>")
]
)
chain
은 다음과 같이 구성한다. 프롬프트의 변수들이 포맷팅 될 수 있도록 딕셔너리 형태로 변수명과 값들을 정의하는 부분이 제일 첫번째에 오게 된다.
chain = {
"context" : itemgetter("question") | retreiver, # 입력된 질의를 바탕으로 검색을 수행해 context 생성
"question" : itemgetter("question"),
"chat_history":itemgetter("chat_history")}|prompt|llm|StrOutputParser()
RunnableWithMessageHistory
는 별도의 체인 클래스로 사용된다. 따라서, 앞서 정의한 체인과 chat history search 함수, 그리고 입력 메시지에 대한 키 값과 메시지 키 값을 정의하여 RunnableWithMessageHistory chain을 생성한다.
chat_histroy_chat = RunnableWithMessageHistory(chain, get_session_history,
input_message_key="question", history_message_key="chat_history")
이렇게 생성된 체인은 일반적인 체인과 마찬가지로 .invoke
, .stream
등의 메서드로 채팅을 실행시킬 수 있다. 그리고 store
객체에 저장된 대화 내용을 확인할 수도 있다.
chat_history_chat.invoke({"question" : input("User Question : ")})
RunnableLambda
RunnableLambda
는 체인에서 사용자 정의 함수나 메서드를 실행시킨 후, 그 결과를 입력변수로 전달할 때 사용한다. RunnableLambda(<함수 객체>)
로 사용할 수 있다. RunnableLambda의 구체적인 사용 예시는 프롬프트 챕터에서 설명하도록 하겠다.
Prompt
Few-Shot Prompt는 질문-답변 예시를 제공하는 기법이다. 예시에 갯수에 따라, 예시를 한 개도 주지 않고 오직 지시문으로만 구성하는 경우를 Zero-Shot, 예시를 한 개만 제공하는 경우를 One-Shot, 여러 개의 예시문을 제공하는 경우는 Few-Shot 이라고 한다. Few Shot Prompt의 경우 FewShotPromptTemplate
클래스를 활용할 수 있다.
Few Shot Prompt Template
Few Shot Prompt Template 객체는 여러 개의 질문-답변 리스트와 질문 답변을 포맷팅 할 프롬프트, 그리고 생성형 ai에게 질문 할 내용의 문장 형식(suffix)를 매개 변수로 구성된다.
우선 예시 QA 세트를 생성한다. QA 세트는 { Question : 질의어, Answer : 답변구성 } 으로 구성된 딕셔너리들의 리스트로 구성한다. 답변은 질의어에 대한 답변을 여러 단계에 걸쳐 생성하도록(추가질문-중간답변 형식으로) 예시를 작성한다.
Example Prompts
examples = [
{
"question" : "Heart2Heart와 아이브의 멤버 수가 같나요?",
"answer" : """이 질문에 추가 질문이 필요한가요: 예.
추가 질문 : Heart2Heart의 멤버 수는 몇 명인가요?
중간 답변 : Heart2Heart의 멤버 수는 8명입니다.
추가 질문 : 아이브의 멤버 수는 몇 명인가요?
중간 답변 : 아이브의 멤버 수는 6명입니다. 따라서 두 그룹의 멤버 수가 다릅니다.
최종 답변은 : 아니오.
"""
},
{
"question" : "(여자)아이들의 민니와 같은 국가 출신인 여자 아이돌은 누구인가요?",
"answer" : """이 질문에 추가 질문이 필요한가요: 예.
추가 질문 : (여자)아이들의 민니는 어느 국가 출신인가요?
중간 답변 : (여자)아이들의 민니는 태국 출신입니다.
추가 질문 : 태국 출신의 아이돌로는 누가 있나요?
중간 답변 : 태국 출신의 아이돌로는 2PM의 닉쿤과 블랙핑크의 리사가 있습니다.
추가 질문 : 2PM과 블랙핑크 중 여자 아이돌 그룹은 무엇인가요?
중간 답변 : 여자 아이돌 그룹은 블랙 핑크입니다.
최종 답변은 : 블랙핑크의 리사"""
},
{
"question" : "2024-2025년에 SM, JYP, YG, HYBE에서 데뷔한 아이돌 그룹 중 멤버 전원이 한국인인 그룹이 있나요?",
"answer" : """이 질문에 추가 질문이 필요한가요: 예.
추가 질문 : 2024-2025년에 SM, JYP, YG, HYBE 소속의 데뷔 아이돌 그룹은?
중간 답변 : IILIT, Babymonster, Hearts2Hearts,
추가 질문 : IILIT 멤버들의 국적은?
중간 답변 : 한국, 일본
추가 질문 : Babymonster 멤버들의 국적은?
중간 답변 : 한국, 일본
추가 질문 : Hearts2Hearts 멤버들의 국적은?
중간 답변 : 한국, 호주, 인도네시아
최종 답변은 : 2024-2025년에 데뷔한 SM, JYP, HYBE, YG 소속 아이돌 그룹 중 멤버 전원이 한국인인 그룹은 없습니다."""
}
]
그 다음에는 예시 프롬프트들을 PromptTemplate
클래스 형식으로 생성할 수 있도록 example template을 정의한다.
example_template = PromptTemplate.from_template(
"Question : \n{question}\nAnswer:\n{answer}"
)
예시문과 예시문 구성을 위한 템플릿 정의가 끝났으면, FewShotPromptTemplate
클래스로 체인에 사용할 프롬프트를 정의한다. suffix
는 입력 질문에 대한 형식을 정의한다.
few_shot_prompt = FewShotPromptTemplate(
examples=examples,
example_template=example_template,
suffix="Question:\n{question}\nAnswer:",
input_variables=[question]
)
chain = few_shot_prompt|llm|StrOutputParser()
chat = chain.stream({"question" : "팀 내 막내가 2008년 생인 아이돌 그룹은?"})
Example Selector
예제가 너무 많고, 예제의 내용이나 주제가 다양한 경우에는 example selector를 활용하여, 참고할 예시 몇 개만 고를 수 있다. 예제문도 임베딩시켜 벡터스토어에 저장해야 하므로, 벡터 db 객체와 임베딩 모델이 미리 정의되어 있어야 한다.
example_selector = SemanticSimilarityExampleSelector.from_examples(
example, # 예시문 리스트
EmbeddingModel, # 임베딩 모델 객체
VectorStoreObj, # 앞서 정의한 벡터 스토어 객체
k=1 # 생성할 예시 개수
)
selected_example = example_selector.select_example({"question" : question})
챗봇 환경에서 이것을 활용하고자 한다면, 1. example sample을 선정하여 리턴하는 함수를 정의하고, 2. 이 함수를 Runnable 객체로 정의하여 체인에서 사용해야 한다. 이때 앞서 언급한 RunnableLambda
가 사용된다.
# example_selector를 사용자 함수로 정의한다.
def get_few_shot_prompt(question):
chosen_examples = example_selector.select_examples({"question":question})
prompt_tempolate = FewShotPromptTemplate(
examples=chosen_examples,
example_prompt=example_prompt,
suffix="Question: {input}\nAnswer:\n",
input_variables =["input"]
)
return [prompt_template.invoke({"input" : question}).text] # 선정된 예시문을 리스트 형식으로 받아온다.
prompt = ChatPromptTemplate.from_messages(
[
("system", "너는 한국의 k-pop 아이돌에 대한 질의 응답을 수행하는 챗봇이야."),
MessagePlaceHolder(variable_name="chat_history"), # history 관련
MessagePlaceHolder(variable_name="examples"),
("human", "다음의 컨텍스트를 참고하여 주어진 질문에 대한 답을 하시오\n\n<context>{context}</context>\n\n<question>{question}</question>")
]
)
chain = {
"context" : itemgetter("question")|retriever,
"examples" : itemgetter("question")|RunnableLambda(get_few_shot_prompt),
"question" :itemgetter("question"),
"chat_history" : itemgetter("chat_history")}|prompt|llm|StrOutputParser()
하지만, 매우 간단하게 FewShotChatMessagePromptTemplate
객체를 프롬프트로 정의하여 사용하는 방법도 있다.
#few-shot chat message prompt 정의
few_shot_prompt = FewShotChatMessagePromptTemplate(
example_sampler = example_sampler,
example_prompt = example_prompt,
)
# chain 할 최종 프롬프트 정의
final_prompt = ChatPromptTemplate.from_messages(
[
("system", "너는 K-POP 아이돌에 대한 질의 응답을 수행하는 챗봇이야."),
few_shot_prompt,
("human", "Question:\n{question}")
]
)
example selector는 단순 question-answer 예시보다, intruction-input-answer 예시에 유용하다.
Instruction-Input 예시
instruction_examples = [
{
"instruction" : "다음의 내용을 더 사무적인 형식의 이메일로 바꿔줘",
"input" : """
내일 회의 오찬 장소가 201관 1층 식당에서 101관 11층 식당으로 변경됐습니다. 회의실 입장 시 식권이 제공되니, 식권을 지참하여 방문해주세요.
""",
"answer" : """
이메일 제목 : [회의명]회의 오찬 장소가 변경되었음을 공지합니다.
이메일 내용 :
안녕하세요. 회의 주최사 OOOO의 담당자 OOO입니다.
내일 (3/7) 진행될 회의의 오찬 장소가 201관 1층 식당에서 101관 11층 식당으로 변경되었음을 공지합니다.
회의실 입장 시 식권을 지급하며, 해당 식권을 지참하여 식사하실 수 있습니다.
내일 뵙도록 하겠습니다.
감사합니다.
"""
},
{
"instruction" : "다음의 내용으로 고객에게 보낼 사과문을 작성해줘,
"input" : """
실수로 오배송 해서 죄송합니다. 사죄의 의미로 고객님 계정으로 2000포인트를 지급하도록 하겠습니다. 재발 방지를 위해 모니터링을 강화하겠습니다.
""",
"answer" : """
OOO 고객님, 오배송 문제로 붎편을 겪게 해서 죄송합니다.
본사에서는 재발 방지를 위해 모니터링 절차를 강화하도록 하겠습니다.
다시 한번 깊은 사죄의 말씀을 드리며, 이번 오배송에 대한 보상으로 2000point를 고객님 계정으로 지금하도록 하겠습니다.
고객님의 피드백을 통해 더 나은 OOO이 될 수 있도록 하겠습니다. 감사합니다.
"""
}
]
example prompt와 chain에 최종적으로 들어갈 프롬프트는 다음과 같이 구성할 수 있다.
instruction_example_prompt = ChatPromptTemplate.from_messages(
[
("human", "Instruction:{instruction}\nInput:{input}"),
("ai", "Answer : {answer}")
]
)
instruction_few_shot_prompt = FewShotChatMessagesPromptTemplate(
example_selector=example_sampler,# instruction 입력에 맞는 다른 sampler를 사용하는 것을 추천
example_prompt=instruction_example_prompt
)
final_prompt = ChatPromptTemplate.from_messages(
[
("system", "너는 사무 업무를 보조하는 챗봇이야."),
instruction_few_shot_prompt,
("human", "Instruction:{instruction}\nInput:{input}")
]
)
위 코드의 주석으로 달아 놓았듯이, instruction 의 유형에 따라 예시문을 선택해야 하므로 기존의 단순 유사도 기반 example selector는 비효율적이다.
따라서, 개인이 직접 example selector를 만들어서 사용하는 것이 좋다. 아래의 예시에서는 CustomExampleSelector
클래스를 새로 정의하여 instruction 의 유사도만을 비교하여 예시를 선택하는 example selector 객체를 만들었다. 여기를 참고하면 더 정확하고 자세한 설명을 확인할 수 있다.
# 부모 클래스로 BaseExampleSelector 클래스를 상속받아야 한다.
## 그렇게 하지 않으면 FewShotChatMessagePromptTemplate 에서 인식이 안됨
from langchain_core.example_selectors.base import BaseExampleSelector
class CustomExampleSelector(BaseExampleSelector):
def __init__(self, example_list):
self.example_list=example_list # 예시문 리스트를 받아온다
# 예시문 리스트에서 지시문만 따로 추려온다
self.instructions=[{"instruction" : x['instruction']}for x in example_list]
# 지시문 리스트에서 입력된 지시문과 가장 유사한 문장을 찾도록 한다.
self.instruction_selector = SemanticSimilarityExampleSelector.from_examples(
self.instructions, # 지시문 리스트의 유사도만 비교한다.
bge_m3, # 임베딩 모델
chroma, # 벡터 스토어
k=2 # 두 개만 가져오도록 한다.
)
# !! 필수 정의 !!
def select_examples(self, context):
return self.get_instruction_sample(context)
# !! 필수 정의 !!
def add_example(self, example):
self.example_list.append(example)
# 가장 유사도가 높은 지시문을 가진 예시 딕셔너리를 리턴하도록 한다.
# input은 {"instruction" : , "input" :} 꼴의 딕셔너리
def get_instruction_sample(self, context):
ex_instruction_list=self.instruction_selector.select_examples({"instruction":context['instruction']})
ex_sam_list = []
for searched in ex_instruction_list:
for ex in self.example_list:
if searched["instruction"] == ex["instruction"]:
ex_sam_list.append(ex)
return ex_sam_list
일반적인 example selector를 쓰는 것과 동일한 방식으로 chain에 들어갈 프롬프트를 정의하면 된다.
instruction_selector = CustomExampleSelector(instrucion_list)
instruction_few_shot_prompt = FewShotChatMessagePromptTemplate(
example_selector=instruction_selector,# instruction 입력에 맞는 다른 sampler를 사용하는 것을 추천
example_prompt=instruction_example_prompt
)
final_prompt = ChatPromptTemplate.from_messages(
[
("system", "너는 사무 업무를 보조하는 챗봇이야."),
instruction_few_shot_prompt,
("human", "Instruction:{instruction}\nInput:{input}")
]
)
.invoke
를 사용할 때는 {"instruction" : val, "input" : val} 형식으로 넣어준다.
few_shot_chain = final_prompt|qwen|StrOutputParser()
answer = few_shot_chain.invoke({
"instruction" : "교수님께 보낼 이메일",
"input" : "교수님 제가 오늘 코로나에 걸려서 내일 출석 못할것같아요"
})
print(answer)
이렇게 하면 다음과 같은 답변을 얻을 수 있을 것이다.
Answer :
이메일 제목: 오늘 코로나 감염으로 인한 내일 수업 결석 신고
안녕하세요, 교수님. OOOO 교수님께 건강 관련 사항을 말씀드립니다.
오늘 코로나에 감염되어 내일 수업에 참석하지 못할 것으로 예상됩니다. 혹시 수업 자료나 강의 내용을 제공해주실 수 있으신지 알려주시면 감사하겠습니다.
기저질환은 없으나, 감염 되었음을 확인한 후 안전을 위해 자가격리를 실시하겠습니다. 건강 상태가 개선될 경우 즉시 교수님께 연락드리겠습니다.
감사합니다.
감사합니다.
[당신의 이름] 드림
조금 번역체긴 하지만 뭔가 그럴싸한게 나오긴 했다.