태그 보관물: LLM

LangChain: 대화를 위한 ConversationChain

LLMChain

앞에서 LangChain의 LLMChain을 살펴봤습니다. PromptTemplate, LLM, OutputParser를 연결해서 편리하게 사용할 수 있는 기능이었죠. 그림으로 표현해봅시다.

  • 사용자가 질문을 던집니다.
  • 질문은 PromptTemplate을 통과하며 최종 프롬프트가 됩니다.
  • 최종 프롬프트는 LLM(완성형 또는 대화형)에 전달되고, LLM에서 Output을 출력합니다.
  • 출력은 OutputParser를 거쳐 최종 응답이 되고, 사용자에게 전달됩니다

이 Chain은 한 번 입력을 받아서 한 번 출력하는 형태의 Chain입니다. LLMChain은 여러 개를 연결해서 더 복잡한 구조의 Chain을 만들 수 있지만, 기본적으로 대화용은 아닙니다. 챗봇과 같이 대화에 사용할 수 있는 Chain으로 ConversationChain이 있습니다.

ConversationChain

ChatGPT와 달리 REST API로 이용하는 LLM에는 대화를 기억하는 기능이 없습니다. 따라서 이전 대화 내용을 바탕으로 계속 대화를 이어가기 위해서는 대화하고 있는 내용을 저장해뒀다가 매번 프롬프트를 만들 때 추가해줘야 합니다. 그래야 LLM이 지금까지의 대화 내용을 기억하고 있는 것처럼 대답할 수 있죠. 이렇게 이전 대화를 기억하도록 하는 LangChain의 기능을 Memory라고 했습니다. 대화를 위한 ConversationChain을 만들 때는 이 Memory가 필요합니다. 그림으로 보면 다음과 같습니다.

  • 사용자가 질문을 던집니다.
  • 질문은 Memory에 저장된 이전 대화 내용과 함께 PromptTemplate에 전달되고, PromptTemplate를 거쳐 최종 프롬프트가 됩니다.
  • 최종 프롬프트는 대화형 LLM에 전달되고, 대화형 LLM에서 Output을 출력합니다.
  • Output은 사용자의 Query와 함께 Memory에 저장되고, OutputParser에도 전달됩니다.
  • OutputParser를 거쳐 최종 응답이 사용자에게 전달됩니다.

코드를 보겠습니다.

from langchain.chat_models import ChatOpenAI
from langchain.chains import ConversationChain
from langchain.memory import ConversationBufferMemory

llm = ChatOpenAI(temperature=0.0)

memory = ConversationBufferMemory()

conversation = ConversationChain(
    llm=llm, 
    memory = memory
)
conversation.predict(input="안녕, 나는 토니 스타크라고 해.")

### 결과(응답)
'안녕, 토니 스타크! 반가워요. 저는 OpenAI의 인공지능입니다. 무엇을 도와드릴까요?'

위 코드에서는 간단한 대화를 위해 PromptTemplate과 OutputParser를 생략했습니다. 이 경우 프롬프트에는 대화를 위한 기본 SystemMessage, 대화를 계속해 나가면서 누적된 대화 내용, 그리고 사용자의 새로운 질문이 들어가게 됩니다. OutputParser가 없으니 출력은 LLM의 대답이 그대로 문자열로 나옵니다.

LangChain: Memory로 대화 내용 저장하기

LangChain에는 LLM과 대화시 대화 내용을 저장하기 위한 Memory 기능이 있습니다. OpenAI API와 같은 REST API는 상태를 저장하지 않습니다(stateless). 따라서 API 실행시 이전 대화 내용을 전달하지 않고 새로운 메시지만 던지면 처음 받는 질문으로 판단하고 대답하게 됩니다. 대화를 이어가기 위해서는 지난 대화 내용을 프롬프트에 포함해 전달하면서 새로운 메시지를 추가해야 합니다. 지난 대화 내용을 저장할 때 사용하는 것이 바로 Memory입니다.

그런데, 대화가 길어지면 문제가 생깁니다. API를 통해 한 번에 입출력할 수 있는 토큰 수가 정해져 있죠. 지난 대화가 길면 토큰 수를 넘어가서 답변을 듣지 못하게 됩니다. 물론 API 사용료도 더 많이 나가죠. 그래서 LangChain의 Memory 중에는 지난 대화 내용을 줄이는 Memory도 있습니다. 기본적인 메모리들을 몇 가지 살펴보겠습니다.

  • ConversationBufferMemory
  • ConversationBufferWindowMemory
  • ConversationTokenBufferMemory
  • ConversationSummaryBufferMemory

ConversationBufferMemory

ConversationBufferMemory는 지난 대화 내용을 그대로 저장하는 메모리입니다.

from langchain.chat_models import ChatOpenAI
from langchain.chains import ConversationChain
from langchain.memory import ConversationBufferMemory

llm = ChatOpenAI(temperature=0.0)

memory = ConversationBufferMemory()

conversation = ConversationChain(
    llm=llm, 
    memory = memory
)
conversation.predict(input="안녕, 나는 토니 스타크라고 해.")

### 결과(응답)
'안녕, 토니 스타크! 반가워요. 저는 OpenAI의 인공지능입니다. 무엇을 도와드릴까요?'

코드에서 사용한 ConversationChain은 한 번 응답을 받는 LLMChain과 달리 여러 번 대화를 주고 받을 때 사용하는 Chain입니다. Chain을 생성할 때 memory 인자에 LangChain의 Memory 객체를 입력하는 방식으로 Memory를 사용합니다. Chain 내부에서는 사용자가 input 메시지를 입력하면 메모리에 메시지를 추가한 후 LLM에 전달하고, LLM이 응답하면 메모리에 응답을 추가한 후 사용자에게 응답을 반환합니다. 대화를 이어나가겠습니다.

conversation.predict(input="내 인공지능 비서 이름이 뭐지?")
# 응답: '당신의 인공지능 비서의 이름은 "지니"입니다. 어떤 일을 도와드릴까요?'
conversation.predict(input="내 이름이 뭐였지?")
# 응답: '당신의 이름은 "토니 스타크"입니다.'

이렇게 이전 대화 내용을 기억하며 대화를 이어나갈 수 있습니다. 메모리에 저장된 내용은 다음과 같이 확인할 수 있습니다.

print(memory.buffer)

### 결과
Human: 안녕, 나는 토니 스타크라고 해.
AI: 안녕, 토니 스타크! 반가워요. 저는 OpenAI의 인공지능입니다. 무엇을 도와드릴까요?
Human: 내 인공지능 비서 이름이 뭐지?
AI: 당신의 인공지능 비서의 이름은 "지니"입니다. 어떤 일을 도와드릴까요?
Human: 내 이름이 뭐였지?
AI: 당신의 이름은 "토니 스타크"입니다.

load_memory_variables를 이용할 수도 있습니다.

memory.load_memory_variables({})

### 결과
{'history': 'Human: 안녕, 나는 토니 스타크라고 해.\nAI: 안녕, 토니 스타크! 반가워요. 저는 OpenAI의 인공지능입니다. 무엇을 도와드릴까요?\nHuman: 내 인공지능 비서 이름이 뭐지?\nAI: 당신의 인공지능 비서의 이름은 "지니"입니다. 어떤 일을 도와드릴까요?\nHuman: 내 이름이 뭐였지?\nAI: 당신의 이름은 "토니 스타크"입니다.'}

save_context를 이용해 메모리에 직접 내용을 추가할 수도 있습니다.

memory = ConversationBufferMemory()
memory.save_context({"input": "안녕, 나는 토니 스타크라고 해."}, 
                    {"output": "안녕하세요, 토니 스타크님! 반갑습니다."})
print(memory.buffer)

### 결과
Human: 안녕, 나는 토니 스타크라고 해.
AI: 안녕하세요, 토니 스타크님! 반갑습니다.

더 추가해볼까요?

memory.save_context({"input": "내 비서 이름이 뭐지?"}, 
                    {"output": "JARVIS입니다."})
memory.load_memory_variables({})

### 결과
{'history': 'Human: 안녕, 나는 토니 스타크라고 해.\nAI: 안녕하세요, 토니 스타크님! 반갑습니다.\nHuman: 내 비서 이름이 뭐지?\nAI: JARVIS입니다.'}

ConversationBufferWindowMemory

ConversationBufferWindowMemory는 윈도우 크기 k를 지정하면 최근 k개의 대화만 기억하고 이전 대화는 삭제합니다. 여기서 1개의 대화는 한 번 주고 받는 대화를 말합니다.

from langchain.memory import ConversationBufferWindowMemory
memory = ConversationBufferWindowMemory(k=1) 
memory.save_context({"input": "안녕, 나는 토니 스타크라고 해."},
                    {"output": "안녕하세요, 토니 스타크님! 반갑습니다."})
memory.save_context({"input": "내 비서 이름이 뭐지?"},
                    {"output": "JARVIS입니다."})
memory.load_memory_variables({})

### 결과
{'history': 'Human: 내 비서 이름이 뭐지?\nAI: JARVIS입니다.'}

k=1로 했더니 마지막 대화만 기억하고 그 앞의 대화는 잊어버렸습니다.

memory = ConversationBufferWindowMemory(k=1)
conversation = ConversationChain(
    llm=llm, 
    memory = memory
)
conversation.predict(input="안녕, 나는 토니 스타크라고 해.")
# 응답: '안녕, 토니 스타크! 반가워요. 저는 OpenAI의 인공지능입니다. 무엇을 도와드릴까요?'
conversation.predict(input="내 인공지능 비서 이름은 뭐지?")
# 응답: '당신의 인공지능 비서의 이름은 "제이비"입니다.'
conversation.predict(input="내 이름이 뭐라고?")
# 응답: '죄송합니다, 저는 당신의 이름을 알 수 없습니다.'

이전 대화를 잊어버렸으니 이전 대화에 있던 정보를 물으면 답할 수가 없습니다. 보통 대화에서는 k=1보다 큰 값을 사용합니다.

ConversationTokenBufferMemory

ConversationTokenBufferMemory는 기억되는 토큰 수를 제한합니다. 최대 토큰 수를 넘지 않는 범위에서 최근 대화들을 기억합니다.

from langchain.memory import ConversationTokenBufferMemory
memory = ConversationTokenBufferMemory(llm=llm, max_token_limit=50)
memory.save_context({"input": "안녕, 나는 토니 스타크라고 해."},
                    {"output": "안녕, 토니 스타크! 반가워요. 저는 OpenAI의 인공지능입니다. 무엇을 도와드릴까요?"})
memory.save_context({"input": "내 인공지능 비서 이름은 뭐지?"},
                    {"output": "당신의 인공지능 비서의 이름은 JARVIS입니다."})
memory.save_context({"input": "내 이름이 뭐라고?"}, 
                    {"output": "당신의 이름은 '토니 스타크'입니다."})
memory.load_memory_variables({})

### 결과
{'history': "Human: 내 이름이 뭐라고?\nAI: 당신의 이름은 '토니 스타크'입니다."}

최대 토큰 수를 50으로 작게 잡았기 때문에 마지막 대화밖에 안 남았습니다.

ConversationSummaryBufferMemory

ConversationSummaryMemory는 이전 대화 내용 그대로 기억하지 않고 요약해서 기억합니다. 요약은 LLM이 하기 때문에 Memory 생성시 LLM을 지정해줍니다. ConversationSummaryBufferMemory는 ConversationSummaryMemory의 확장판이라고 볼 수 있습니다. 이전 대화를 요약해 기억하면서 최근 대화는 최대 토큰 범위 내에서 유지합니다. 이를 위해 최대 토큰 길이도 지정해줍니다. 코드에서는 ConversationSummaryBufferMemory를 사용하겠습니다.

from langchain.memory import ConversationSummaryBufferMemory

schedule = "내일 아침 8시에는 퓨리랑 미팅이 있습니다. \
어벤져스와 가디언즈 오브 갤럭시 팀의 관계에 대해 상의할 예정입니다. \
12시에는 페퍼와 점심식사가 있습니다. 치즈버거를 먹기로 했습니다. \
3시에는 해피와 스파이더맨 교육 계획 회의가 있습니다 \
5시에는 아크 원자로 개선 버전 공개 행사가 있습니다. \
간단한 소개 자료를 준비해 놓았습니다."

memory = ConversationSummaryBufferMemory(llm=llm, max_token_limit=100)
memory.save_context({"input": "자비스"}, {"output": "네, 말씀하세요."})
memory.save_context({"input": "자기 전에 확인할게 있어."},
                    {"output": "무엇을 확인해드릴까요?"})
memory.save_context({"input": "내일 일정에 대해 알려줘."}, 
                    {"output": f"{schedule}"})
memory.load_memory_variables({})

### 결과
{'history': 'System: The human asks the AI to check something before they go to bed. The AI asks what they would like to check. The human asks the AI to inform them about their schedule for tomorrow. The AI informs the human that they have a meeting with Fury at 8 am to discuss the relationship between the Avengers and the Guardians of the Galaxy teams. At 12 pm, they have a lunch with Pepper, where they will be having a cheeseburger. At 3 pm, they have a meeting with Happy and Spiderman to discuss educational plans. At 5 pm, there is a public event for the unveiling of an improved version of the Arc Reactor, and the AI has prepared a brief introduction material.'}

대화 내용을 요약해서 기억하는 것을 볼 수 있습니다. 대화를 이어가면 메모리 내용이 어떻게 달라지는가 봅시다.

conversation = ConversationChain(
    llm=llm, 
    memory = memory,
    verbose=True
)
conversation.predict(input="행사 전 4시에 닥터 스트레인지와 약속을 추가해줘.")
# 응답: '행사 전 4시에 닥터 스트레인지와 약속을 추가했습니다.'
memory.load_memory_variables({})

### 결과
{'history': 'System: The human asks the AI to check something before they go to bed. The AI asks what they would like to check. The human asks the AI to inform them about their schedule for tomorrow. The AI informs the human that they have a meeting with Fury at 8 am to discuss the relationship between the Avengers and the Guardians of the Galaxy teams. At 12 pm, they have a lunch with Pepper, where they will be having a cheeseburger. At 3 pm, they have a meeting with Happy and Spiderman to discuss educational plans. At 5 pm, there is a public event for the unveiling of an improved version of the Arc Reactor, and the AI has prepared a brief introduction material.\nHuman: 행사 전 4시에 닥터 스트레인지와 약속을 추가해줘.\nAI: 행사 전 4시에 닥터 스트레인지와 약속을 추가했습니다.'}

앞의 내용은 요약되어 있지만 최근 대화는 그대로 들어가 있습니다. 대화를 더 이어가봅시다.

conversation.predict(input="내일 행사 참석 인원은 100명 정도야.")
# 응답: '내일 행사에는 약 100명 정도의 참석자가 있을 것으로 예상됩니다.'
memory.load_memory_variables({})

### 결과
{'history': 'System: The human asks the AI to check something before they go to bed. The AI asks what they would like to check. The human asks the AI to inform them about their schedule for tomorrow. The AI informs the human that they have a meeting with Fury at 8 am to discuss the relationship between the Avengers and the Guardians of the Galaxy teams. At 12 pm, they have a lunch with Pepper, where they will be having a cheeseburger. At 3 pm, they have a meeting with Happy and Spiderman to discuss educational plans. At 5 pm, there is a public event for the unveiling of an improved version of the Arc Reactor, and the AI has prepared a brief introduction material. The human requests to add a meeting with Doctor Strange at 4 pm before the event.\nAI: 행사 전 4시에 닥터 스트레인지와 약속을 추가했습니다.\nHuman: 내일 행사 참석 인원은 100명 정도야.\nAI: 내일 행사에는 약 100명 정도의 참석자가 있을 것으로 예상됩니다.'}

앞에서 그대로 들어가 있던 대화의 일부가 요약에 포함된 것을 볼 수 있습니다.

conversation.predict(input="내일 원자로 소개할 때 뭐라고 시작할까?")
# 응답
'내일 원자로 소개할 때, "안녕하세요 여러분, 오늘 저희는 개선된 아크 리액터의 공개 행사에 참석하게 되어 매우 기쁩니다. 이번 개발은 이전 버전에 비해 더욱 효율적이고 안전한 에너지 공급을 제공합니다. 이 아크 리액터는 우리의 노력과 협력으로 만들어진 결과물이며, 앞으로의 미래에 대한 희망과 발전을 상징합니다. 이제부터 자세한 소개를 시작하도록 하겠습니다."라고 시작하시면 좋을 것 같습니다.'

마지막 대화가 길었습니다. 토큰 수 제한이 있기 때문에 마지막에 주고 받은 대화가 길어지면 다음과 같이 마지막 대화까지 요약에 포함될 수도 있습니다.

memory.load_memory_variables({})

### 결과
{'history': 'System: The human asks the AI to check their schedule for tomorrow. The AI informs the human that they have a meeting with Fury at 8 am, a lunch with Pepper at 12 pm, a meeting with Happy and Spiderman at 3 pm, and a public event for the unveiling of an improved version of the Arc Reactor at 5 pm. The human requests to add a meeting with Doctor Strange at 4 pm before the event. The AI adds the meeting to the schedule. The human mentions that there will be around 100 attendees at the event. The AI estimates that there will be about 100 attendees. The human asks for suggestions on how to start the introduction for the Arc Reactor unveiling. The AI suggests starting with "Hello everyone, we are delighted to have you all here today for the unveiling of the improved Arc Reactor. This development provides more efficient and safe energy supply compared to the previous version. It is a result of our efforts and collaboration, symbolizing hope and progress for the future. Now, let\'s begin with a detailed introduction."'}

이전 대화 내용을 요약하는 메모리의 경우 요약을 위해 내부적으로 LLM 호출이 추가된다는 점을 기억합시다.

(추가) OpenAI Thread

2023년 11월 6일 OpenAI에서 새로운 API들을 발표했습니다. 그중 Assistants API의 Thread가 있는데, Thread에 Message를 추가하면 OpenAI 서버에 대화 내용을 저장해주고, 프롬프트에 이전 대화 내용을 넣는 과정도 알아서 진행해줍니다. 참고: OpenAI Assistants API로 대화 내용 저장하기

LangChain: LLMChain으로 PromptTemplate, LLM, OutputParser 연결하기

앞에서 LangChain의 거대 언어 모델 유형으로 문장 완성을 위한 LLMs와 대화를 위한 ChatModels가 있다는 것을 확인했습니다. 프롬프트 작성을 위한 PromptTemplate들도 봤고, 출력 형식 지정을 위한 OutputParser도 봤습니다.

LangChain의 Chain 기능은 여러 기능들을 연결해서(chain) 편리하게 이용할 수 있는 기능입니다. 이 기능을 이용하면 사용자의 입력을 받아 PromptTemplate을 이용해 프롬프트를 만들고 LLM에 전달한 후 OutputParser를 이용해 결과를 반환하는 하나의 Chain을 만들 수 있습니다. 필요하면 Chain들끼리 연결하는 것도 가능합니다. 여러 Chain들 중 가장 기본이 되는 Chain은 LLMChain입니다.

LLMChain: PromptTemplate + LLM

예제를 위해 먼저 LLM과 프롬프트 템플릿을 만들었습니다.

from langchain.chat_models import ChatOpenAI
from langchain.prompts import PromptTemplate

llm = ChatOpenAI(temperature=0.9)

prompt = PromptTemplate(
    input_variables=["what"],
    template="{what} 이름 세 개를 추천해줘.",
)

위에서 ChatOpenAI (gpt-3.5-turbo)를 사용했는데, 문장 완성 모델인 OpenAI (text-davinci-003)를 사용해도 됩니다. 여기서는 성능이 더 좋아서 ChatOpenAI를 선택했습니다. 이제 둘을 LLMChain으로 연결합니다.

from langchain.chains import LLMChain
chain = LLMChain(llm=llm, prompt=prompt)

print(chain.run("새로 만든 거대 언어 모델"))

### 결과
1. GigaMinds
2. MegaLinguo
3. SuperVerse

LLMChain 실행 방법

이렇게 LLMChain으로 llmprompt를 연결한 후 chain.run으로 한 번에 실행할 수 있습니다. 참고로, chain을 실행하는 방법에는 여러 가지가 있습니다.

chain.run("새로 만든 거대 언어 모델")
chain.run({"what":"새로 만든 거대 언어 모델"})
chain.predict(what="새로 만든 거대 언어 모델")

위 세 가지 방법은 앞의 결과처럼 문자열을 반환합니다.

chain("새로 만든 거대 언어 모델")

### 결과
{'what': '새로 만든 거대 언어 모델',
 'text': '1. 인지어 (CogniLang)\n2. 심대어 (MenteLingua)\n3. 우주어 (CosmoLexis)'}

위 경우에는 딕셔너리를 반환했고, 결과는 ‘text’에 들어 있습니다.

input_list = [
    {"what":"새로 만든 거대 언어 모델"},
    {"what":"새로 만든 그림 생성 AI"}
]
chain.apply(input_list)

### 결과
[{'text': '1. "Aether"\n2. "Luminex"\n3. "Omnilingua"'},
 {'text': '1. PaintPal\n2. CreativeCanvas\n3. ArtGenius'}]

리스트를 만들어 입력했고, 결과를 담은 딕셔너리들의 리스트를 받았습니다. LLM으로 ChatOpenAI를 사용했지만, 기본적으로 LLMChain은 대화를 이어가는 기능이 아닙니다. 위에서 응답 결과는 input_list 내에 있는 각각의 질문에 대한 대답입니다. 대화를 위해서는 ConversationChain이 별도로 존재합니다.

chain.generate(input_list)

### 결과
LLMResult(
generations=[
[ChatGeneration(text='1. 바나나AI\n2. 루나\n3. 메가클라우드', generation_info={'finish_reason': 'stop'}, message=AIMessage(content='1. 바나나AI\n2. 루나\n3. 메가클라우드', additional_kwargs={}, example=False))], 
[ChatGeneration(text='1. CreARTist\n2. AImagination\n3. ArtiVision', generation_info={'finish_reason': 'stop'}, message=AIMessage(content='1. CreARTist\n2. AImagination\n3. ArtiVision', additional_kwargs={}, example=False))]
],
llm_output={'token_usage': {'prompt_tokens': 62, 'completion_tokens': 40, 'total_tokens': 102}, 'model_name': 'gpt-3.5-turbo'}, 
run=[RunInfo(run_id=UUID('144c8d77-c1a6-48d3-a65a-983db734b6e9')), RunInfo(run_id=UUID('939a315a-b71c-48f1-9814-0abf6bfca23a'))]
)

generate를 사용하면 LLMResult 객체를 반환하는데, 여기에는 토큰수와 같은 추가 정보가 들어가 있습니다.

PromptTemplate에 변수를 여러 개 넣는 것도 가능하죠.

prompt = PromptTemplate(
    input_variables=["desc", "what"],
    template="{desc} {what} 이름 세 개를 추천해줘.",
)
chain2 = LLMChain(llm=llm, prompt=prompt)
print(chain2.run({
    'desc': "한국적인",
    'what': "거대 언어 모델"
    }))

PromptTemplate 대신 ChatPromptTemplate을 사용하는 것도 가능합니다.

from langchain.chat_models import ChatOpenAI
from langchain.prompts.chat import (
    ChatPromptTemplate,
    HumanMessagePromptTemplate,
)
human_message_prompt = HumanMessagePromptTemplate(
        prompt=PromptTemplate(
            template="{what} 이름 세 개를 추천해줘.",
            input_variables=["what"],
        )
    )
chat_prompt_template = ChatPromptTemplate.from_messages([human_message_prompt])
chat = ChatOpenAI(temperature=0.9)

chain = LLMChain(llm=chat, prompt=chat_prompt_template)
print(chain.run("새로 만든 거대 언어 모델"))

LLMChain: PromptTemplate + LLM + OutputParser

OutputParser도 chain으로 연결할 수 있습니다. 여기서는 CommaSeparatedListOutputParser를 사용하겠습니다. 문자열을 쉼표로 분리하여 리스트로 만들어주는 파서입니다.

from langchain.output_parsers import CommaSeparatedListOutputParser

output_parser=CommaSeparatedListOutputParser()
prompt = PromptTemplate(
    input_variables=["what"],
    template="{what} 이름 세 개를 추천해줘. \n{format_instructions}",
    partial_variables={"format_instructions": output_parser.get_format_instructions()}
)

위 코드에서 partial_variables를 사용했는데, 이는 템플릿 안의 변수들 중 일부만 미리 치환할 수 있는 기능입니다. PromptTemplate에 output_parser의 포맷 명령을 넣었는데, chain에 output_parser를 안 넣고 실행해보겠습니다.

chain = LLMChain(llm=chat, prompt=prompt)
chain.predict(what="새로 만든 거대 언어 모델")

### 결과
'GigaNLP, MegaLang, SuperGPT'

프롬프트에 출력 포맷을 지정했기 때문에 결과가 쉼표로 구분되 나오기는 했는데, 문자열이 반환되어 나왔습니다. 이번에는 chain에 output_parser를 추가해보겠습니다.

chain = LLMChain(llm=chat, prompt=prompt, output_parser=output_parser)
chain.predict(what="새로 만든 거대 언어 모델")

### 결과
['GLOMO', 'MEGALINGUA', 'LEXICON3000']

결과가 리스트로 나왔습니다! 이렇게 Chain을 이용하면 PromptTemplate과 LLM, OutputParser를 연결해 사용할 수 있습니다. Chain은 LangChain 이름에서 알 수 있는 것처럼 LangChain의 핵심 기능입니다.

LangChain: OutputParser로 LLM 출력 다루기

앞에서 ChatGPT와 OpenAI API를 이용해 json 형식의 문자열을 출력하는 예제를 살펴봤습니다. LangChain에는 이렇게 원하는 형식의 출력을 얻을 수 있도록 OutputParser 기능을 제공합니다. 앞에서 봤던 예제를 OutputParser를 이용해 구현해보겠습니다.

OutputParser

LangChain의 OutputParser에는 미리 만들어져있는 parser들도 있지만 여기서는 직접 만들어보겠습니다. 아래 예제에서 ResponseSchema는 특정 항목이 어떤 형식으로 출력되어야 하는지 지정하는데 사용하고, StructuredOutputParser는 전체 schema들을 모아 parser를 생성하는데 사용합니다. 여기서는 하나의 항목만 ‘output’이라는 이름으로 추출하려고 합니다. 이 항목의 내용은 {"사람 이름": "영어 색깔 이름"}의 json 형식(파이썬 딕셔너리)으로 만들고 싶습니다.

from langchain.output_parsers import ResponseSchema
from langchain.output_parsers import StructuredOutputParser

schema = ResponseSchema(name='output', description="json object, key=name, value=color in English")

response_schemas = [schema]

output_parser = StructuredOutputParser.from_response_schemas(response_schemas)

이렇게 만든 output_parser에서는 get_format_instructions라는 메소드와 parse 메소드를 지원합니다. get_format_instructions는 출력 형식 지정 프롬프트를 만들어주는 메소드입니다. 형식 지정 프롬프트는 LLM에 보낼 전체 프롬프트에 포함시킵니다. parse 메소드는 LLM의 출력이 문자열로 나오니 최종적으로 원하는 형식으로 바꿔주는 메소드입니다. format_instructions를 확인해보죠.

format_instructions: 출력 형식 지정 프롬프트

format_instructions = output_parser.get_format_instructions()
print(format_instructions)

### 출력
The output should be a markdown code snippet formatted in the following schema, including the leading and trailing "```json" and "```":

```json
{
	"<strong>output</strong>": string  // json object, key=name, value=color in English
}
```

위와 같이 json 형식의 문자열을 포함한 마크다운 코드를 출력하라는 내용은 StructuredOutputParser에 미리 입력되어 있고, ResponseSchema에 적은 내용이 json 항목 설명으로 들어가게 됩니다. 그럼 전체 프롬프트를 만들어보죠.

from langchain.prompts import ChatPromptTemplate

template="""
주어진 text로부터 다음 정보를 추출하세요.

"사람 이름": "color in English"

text: ```{text}```

{format_instructions}
"""

prompt = ChatPromptTemplate.from_template(template)

messages = prompt.format_messages(text="영희는 파란색을 좋아하고 영수는 분홍색을 좋아한다.",
format_instructions=format_instructions)

최종 프롬프트 messages의 내용은 다음과 같습니다.

[HumanMessage(content='\n주어진 text로부터 다음 정보를 추출하세요.\n\n"사람 이름": "color in English"\n\ntext: ```영희는 파란색을 좋아하고 영수는 분홍색을 좋아한다.```\n\nThe output should be a markdown code snippet formatted in the following schema, including the leading and trailing "```json" and "```":\n\n```json\n{\n\t"output": string  // json object, key=name, value=color in English\n}\n```\n', additional_kwargs={}, example=False)]

이 메시지를 LLM에 보내 응답을 받습니다.

from langchain.chat_models import ChatOpenAI
chat = ChatOpenAI(temperature=0.0, model='gpt-3.5-turbo')
response = chat(messages)

response는 다음과 같습니다.

AIMessage(content='```json\n{\n\t"output": {\n\t\t"영희": "blue",\n\t\t"영수": "pink"\n\t}\n}\n```', additional_kwargs={}, example=False)

response.content는 문자열입니다. 최종적으로 원하는 결과를 얻기 위해 이 내용을 output_parser에 전달합니다.

parser: 최종 결과 출력

output = output_parser.parse(response.content)

### 결과
{'output': {'영희': 'blue', '영수': 'pink'}}

그 결과로 딕셔너리를 얻었고, output['output']에 원하는 결과를 얻었습니다.

ChatGPT에서 JSON 출력하기

ChatGPT에서의 프롬프트 엔지니어링

ChatGPT에서 원하는 출력을 얻기 위해서는 시행착오가 필요합니다. 원하는 출력을 얻는 프롬프트를 찾아내는 과정을 프롬프트 엔지니어링이라고 하죠. 문자열을 해석해서 컴퓨터에서 작업하기 좋도록 json 형식으로 만들고 싶습니다. 그래서 ChatGPT에게 다음과 같이 요청했습니다. One-shot learning을 위한 예시도 넣었죠.

문자열에서 정보를 추출해서 json으로 바꿔줘.

## 예시
“수연이는 파란색을 좋아하고, 철호는 빨간색을 좋아한다” => {“수연”: “blue”,”철호”: “red”}

## 입력
“영희는 노란색을 좋아하고, 민수는 초록색을 좋아한다” =>

그랬더니 직접 json을 만들어주는 대신 json으로 변환하는 코드를 만들어 줬습니다.(결과는 물어볼 때마다 달라질 수 있습니다.)

입력된 문자열에서 정보를 추출하고 JSON 형식으로 바꾸려면 Python 프로그래밍 언어를 사용하여 다음과 같이 할 수 있습니다.

import re
import json

# 입력 문자열
input_string = "영희는 노란색을 좋아하고, 민수는 초록색을 좋아한다"

# 이름과 색상 정보 추출
name_color_pattern = r'(\w+)는\s(\w+)색을\s좋아한다'
matches = re.findall(name_color_pattern, input_string)

# 결과를 JSON 형식으로 변환
result = {name: color for name, color in matches}

# JSON 문자열로 변환
json_result = json.dumps(result, ensure_ascii=False)

print(json_result)


위 코드는 입력된 문자열에서 이름과 색상 정보를 추출하고 이를 JSON 형식으로 변환합니다. 코드를 실행하면 원하는 결과가 출력됩니다.

그러나 이 코드는 예시에서 보여준 것처럼 색깔을 영어로 만들지 못하죠. 그래서 “키는 이름, 값은 색깔을 영어로” 만들어 달라고 프롬프트에 추가했는데 앞에서와 비슷한 대답을 했습니다. 이번엔 코드를 작성하지 말고 직접 바꿔달라고 했습니다.

입력 문자열에서 정보를 추출해서 아래와 같이 json으로 바꿔줘. 키는 이름, 값은 색깔을 영어로. 코드를 작성하지 말고 직접 바꿔서 결과를 보여줘.

## 예시
“수연이는 파란색을 좋아하고, 철호는 빨간색을 좋아한다” => {“수연”: “blue”,”철호”: “red”}

## 입력
“영희는 노란색을 좋아하고, 민수는 초록색을 좋아한다” =>

그랬더니 아래와 같이 대답했습니다.

입력 문자열을 파싱하여 아래와 같이 JSON 형식으로 변환합니다.

입력 문자열:
"영희는 노란색을 좋아하고, 민수는 초록색을 좋아한다"

JSON 결과:
{
"영희": "yellow",
"민수": "green"
}

원하는 결과가 마지막에 나오긴 했지만 앞에 불필요한 설명이 들어가 있습니다. 그래서 다시 프롬프트를 수정했습니다.

당신은 json 변환기입니다. 보기처럼 문장을 입력 받으면 json 형식으로 출력하세요. 설명 없이 json만 출력하세요.

## 보기
“수연이는 파란색을 좋아하고, 철호는 빨간색을 좋아한다” => {“수연”: “blue”, “철호”: “red”}

## 문장
“민수는 노란색을 좋아하고, 은정이는 분홍색을 좋아한다”

이제 원하는 답이 나옵니다.

{"민수": "yellow", "은정이": "pink"}

입력 문장을 바꿔가며 실행해봐도 결과가 잘 나옵니다. 몇 번 실행해봤을 때 결과가 잘 나왔으나 항상 잘 나온다는 보장은 없습니다. 응답이 확률적으로 나오므로 언제 부연 설명이 들어가게 될지 알 수 없죠. 앞에 설명이 들어가면 {} 기호를 이용해 json 부분만 추출할 수도 있으나 결과가 항상 json 형식으로 나온다면 더 좋겠죠?

사실 json 출력은 ChatGPT에서 대화할 때 보다는 API를 사용할 때 더 유용합니다. LLM의 응답을 바로 파이썬에서 딕셔너리로 바꿔서 사용할 수 있기 때문이죠. 위의 프롬프트를 가지고 API를 사용할 수도 있지만, 함수 호출 기능을 이용하면 더 확실하게 부연 설명 없는 json 결과물을 얻을 수 있습니다.

API에서 function_call을 이용해 json 출력하기

OpenAI API 호출시 functions 인자를 이용해 함수 사용법을 알려줄 수 있습니다. LLM은 필요하다고 판단될 경우 응답의 function_call을 통해 호출할 함수 이름과 호출할 때 사용할 인자(arguments)를 알려줍니다. 이 때 인자를 json object 형식(파이썬 딕셔너리에 해당)으로 받아서 출력하면 됩니다. 이를 위해 사용할 functions 인자는 다음과 같습니다.

functions = [
    {
        "name": "name_and_color",
        "description": "사람 이름과 좋아하는 색깔의 영어 이름을 매칭시킴",
        "parameters": {
            "type":"object",
            "properties": {
                "match": {
                    "type": "object",
                    "description": "key=name, value=color"
                }
            },
            "required": ["match"]
        }
    }
]

위에서 함수 이름은 중요하지 않습니다. 실제로는 존재하지 않는 함수입니다. 중요한 것은 함수 설명과 함수 인자 설명입니다. 함수 설명에는 우리의 목적에 대한 설명을 넣어서 LLM에서 호출해야 하는 함수로 판단할 수 있도록 합니다. 인자는 object 형식으로 만들고 인자 설명에 key는 사람 이름, value는 색깔을 넣도록 했습니다.

messages = [{"role": "system", "content": "You are a helpful assistant."},
           {"role": "user", "content": "수연이는 파란색을 좋아하고 철호는 빨간색을 좋아한다."}]
response = openai.ChatCompletion.create(
    model="gpt-3.5-turbo",
    messages=messages,
    functions=functions
)
print(response["choices"][0]['message']["function_call"]["arguments"])

### 결과
{
  "수연": "blue",
  "철호": "red"
}

원하는대로 json 문자열이 결과로 나왔습니다. 문장을 바꿔도 잘 나옵니다. 이제 부연 설명이 나올지 안 나올지 걱정할 필요가 없죠. json.loads 함수를 이용해 파이썬 딕셔너리로 바꿔서 사용하면 이후 과정을 편리하게 진행할 수 있습니다.

LangChain: FewShotPromptTemplate + ExampleSelector

앞에서 LangChainPromptTemplate과 ChatPromptTemplate에 대해 살펴봤습니다. 좋은 프롬프트를 만드는 방법 중 하나가 예시(one-shot, few-shot)를 주는 것이었죠? 이번에는 예시를 포함한 프롬프트를 만들기 위한 FewShotPromptTemplate에 대해 살펴보겠습니다. 먼저 사용할 예시들을 보겠습니다.

Few-shot 예시 리스트

examples = [
    {
        "question":"선희는 빨강색을 좋아하고 영호는 노란색을 좋아한다.",
        "answer":'"선희":"빨강색", "영호":"노란색"'
    },
    {
        "question":"영철이는 탕수육을 좋아하고, 숙희는 깐풍기를 좋아한다.",
        "answer":'"영철":"탕수육","숙희":"깐풍기"'
    },
    {
        "question":"한주는 국어를 좋아하고 영수는 수학을 좋아한다.",
        "answer":'"한주":"국어", "영수":"수학"'
    },
    {
        "question":"민지는 에너지자원공학과이고 민석이는 국어국문학과이다.",
        "answer":'"민지":"에너지자원공학과","민석":"국어국문학과"'
    },
    {
        "question":"남주는 부산에 살고, 남식이는 서울에 산다.",
        "answer":'"남주":"부산", "남식":"서울"'
    }
]

위의 예시는 문장을 입력받아 사람:색깔, 사람:음식, 사람:과목, 사람:학과, 사람:도시 – 이런 형태로 바꿔주는 예시들입니다. 딕셔너리들의 리스트로 주어져있고, 프롬프트에 넣기 위해서는 문자열로 바꿔야겠죠? 문자열로 바꾸기 위해 PromptTemplate를 이용하겠습니다.

from langchain.prompts.prompt import PromptTemplate
example_prompt = PromptTemplate(
    input_variables=["question","answer"],
    template="Text: {question}\nParsed: {answer}"
)
print(example_prompt.format(**examples[0]))

### 출력
Text: 선희는 빨강색을 좋아하고 영호는 노란색을 좋아한다.
Parsed: "선희":"빨강색", "영호":"노란색"

확인을 위해 첫 번째 예시만 추출해서 Text와 Parsed를 앞에 붙이도록 포맷했습니다. 이제 예시를 포함한 실제 프롬프트를 만들어보겠습니다.

FewShotPromptTemplate

from langchain.prompts.few_shot import FewShotPromptTemplate

prompt = FewShotPromptTemplate(
    examples = examples,
    example_prompt = example_prompt,
    suffix = "Text: {input}\nParsed: ",
    input_variables = ["input"]
)

input_prompt = prompt.format(input="성훈이는 김치찌개를 좋아하고 소영이는 파스타를 좋아한다.")
print(input_prompt)

### 출력
Text: 선희는 빨강색을 좋아하고 영호는 노란색을 좋아한다.
Parsed: "선희":"빨강색", "영호":"노란색"

Text: 영철이는 탕수육을 좋아하고, 숙희는 깐풍기를 좋아한다.
Parsed: "영철":"탕수육","숙희":"깐풍기"

Text: 한주는 국어를 좋아하고 영수는 수학을 좋아한다.
Parsed: "한주":"국어", "영수":"수학"

Text: 민지는 에너지자원공학과이고 민석이는 국어국문학과이다.
Parsed: "민지":"에너지자원공학과","민석":"국어국문학과"

Text: 남주는 부산에 살고, 남식이는 서울에 산다.
Parsed: "남주":"부산", "남식":"서울"

Text: 성훈이는 김치찌개를 좋아하고 소영이는 파스타를 좋아한다.
Parsed: 

여기에 나온 프롬프트가 LLM에 전달할 메시지입니다. 전달해서 응답을 얻어봅시다.

from langchain.llms import OpenAI

llm = OpenAI(temperature=0.9)

response = llm.predict(input_prompt)
print(response)

### 결과
"성훈":"김치찌개", "소영":"파스타"

원하는 결과가 잘 나왔습니다. 그런데, 예시가 많다보니 토큰 사용량이 많아지고 따라서 API 사용료가 많이 나오겠네요. 최종 질문과 유사한 예시 한 두 개만 남겨도 같은 결과를 얻을 수 있지 않을까요? 이 때 쓰는게 ExampleSelector입니다.

ExampleSelector

여러 개의 예시들중 질문과 비슷한 것만 실제 프롬프트에 넣으면 토큰 소모량을 줄일 수 있습니다. API 사용료도 줄일 수 있지만, 토큰 제한도 있으니 토큰 소모량을 줄일 수 있다면 줄이는게 좋습니다. ExampleSelector에도 여러 종류가 있습니다.

  • BaseExampleSelector: 상속 받아 사용자 정의 ExampleSelector를 만들 수 있습니다.
  • LengthBasedExampleSelector: 지정 길이가 넘어가지 않도록 예시 개수를 조절합니다.
  • MaxMarginalRelevanceExampleSelector: Maximal Marginal Relevance (MMR)을 이용해 질문과 가까우면서도 다양한 예시를 선택합니다.
  • SemanticSimilarityExampleSelector: 벡터 임베딩의 코사인 유사도를 이용해 질문과 의미가 가까운 예시를 추출합니다.
  • NGramOverlapExampleSelector: n-gram overlap score를 이용해 질문과 가까운 예시를 추출합니다.

SemanticSimilarityExampleSelector를 사용해보겠습니다. 벡터 임베딩을 이용하기 때문에 임베딩 모델도 필요하고 임베딩 벡터를 저장하기 위한 벡터 데이터베이스도 필요합니다. 임베딩은 OpenAIEmbedding, 벡터 데이터베이스는 ChromaDB를 사용하겠습니다.

from langchain.prompts.example_selector import SemanticSimilarityExampleSelector
from langchain.vectorstores import Chroma
from langchain.embeddings import OpenAIEmbeddings

example_selector = SemanticSimilarityExampleSelector.from_examples(
    examples,
    OpenAIEmbeddings(), # embedding
    Chroma, # VectorDB
    k=1, # number of examples
)

selected_examples = example_selector.select_examples(
    {"question": "성훈이는 김치찌개를 좋아하고 소영이는 파스타를 좋아한다."}
)

### 결과 selected_examples
[{'answer': '"영철":"탕수육","숙희":"깐풍기"',
  'question': '영철이는 탕수육을 좋아하고, 숙희는 깐풍기를 좋아한다.'}]

SemanticSimilarityExampleSelector에서 선택할 예시 개수를 한 개로 지정했습니다(k=1). 질문으로 "성훈이는 김치찌개를 좋아하고 소영이는 파스타를 좋아한다."를 넣었더니 음식이 들어간 예시를 선택했습니다!

FewShotPromptTemplate + ExampleSelector

이제 FewShotPromptTemplate과 ExampleSelector를 함께 사용해봅시다.

prompt = FewShotPromptTemplate(
    example_selector = example_selector,
    example_prompt = example_prompt,
    suffix = "Text: {input}\nParsed: ",
    input_variables = ["input"]
)

input_prompt = prompt.format(input="성훈이는 김치찌개를 좋아하고 소영이는 파스타를 좋아한다.")
print(input_prompt)

### 결과
Text: 영철이는 탕수육을 좋아하고, 숙희는 깐풍기를 좋아한다.
Parsed: "영철":"탕수육","숙희":"깐풍기"

Text: 성훈이는 김치찌개를 좋아하고 소영이는 파스타를 좋아한다.
Parsed: 

FewShotPromptTemplate의 인자에 examples 대신 example_selector를 사용했고, 질문과 유사한 예시를 포함한 프롬프트를 얻었습니다. 이번에는 다른 질문을 넣어볼까요?

input_prompt2 = prompt.format(input="영훈이는 영어를 좋아하고 수영이는 체육을 좋아한다.")
print(input_prompt2)

### 결과
Text: 한주는 국어를 좋아하고 영수는 수학을 좋아한다.
Parsed: "한주":"국어", "영수":"수학"

Text: 영훈이는 영어를 좋아하고 수영이는 체육을 좋아한다.
Parsed: 

질문과 가장 가까운 예시가 잘 선택된 것을 볼 수 있습니다. LLM에 위의 프롬프트를 전달해서 원하는 결과가 나오는지 보겠습니다.

response = llm.predict(input_prompt)
print(response) # 결과 - "성훈":"김치찌개","소영":"파스타"

response = llm.predict(input_prompt2)
print(response) # 결과 - "영훈":"영어", "수영":"체육"

각각 예시를 하나만 추출해서 One-shot learning을 수행했고, 원하는 결과가 잘 나왔습니다.

LangChain: Prompt Template으로 프롬프트 다루기

PromptTemplate

LangChain의 프롬프트 템플릿은 LLMs에 메시지를 전달하기 전에 문장 구성을 편리하게 만들어주는 기능입니다. 코드를 보겠습니다.

from langchain.prompts import PromptTemplate

prompt = PromptTemplate.from_template("부산에 대해 알려줘.")
prompt.format() # 결과: "부산에 대해 알려줘."

이게 다라면 그냥 문자열을 쓰는게 더 간편하겠죠? 포맷 기능을 봅시다.

from langchain.prompts import PromptTemplate

prompt = PromptTemplate.from_template("{city}에 대해 알려줘.")
prompt.format(city="부산") # 결과: "부산에 대해 알려줘."

# 다른 방법
prompt = PromptTemplate(input_variables=["city"],
                        template="{city}에 대해 알려줘.")
prompt.format(city="부산") # 결과: "부산에 대해 알려줘."

사실 위의 포맷 기능도 파이썬 기본 문자열에서 지원하는 기능입니다. 그럼에도 불구하고 프롬프트 템플릿을 사용하면 코드 가독성, 일관성과 재사용성이 좋아지고, 에러를 방지할 수 있습니다.

ChatPromptTemplate

앞에서 본 PromptTemplate은 기본적으로 문장 완성 모델(Completion model: llms)을 위한 템플릿입니다. 챗모델(Chat completion model: chat_models)을 위한 템플릿은 ChatPromptTemplate입니다.

from langchain.prompts import (
    ChatPromptTemplate,
    SystemMessagePromptTemplate,
    HumanMessagePromptTemplate,
)

system_message_prompt = SystemMessagePromptTemplate.from_template(
    "당신은 {input_language}를 {output_language}로 번역하는 전문 번역가입니다."
)

human_message_prompt = HumanMessagePromptTemplate.from_template("{text}")

chat_prompt = ChatPromptTemplate.from_messages(
    [system_message_prompt, human_message_prompt]
)

chat_prompt.format_messages(input_language="영어", output_language="한국어", text="I love programming.")

### 결과
[SystemMessage(content='당신은 영어를 한국어로 번역하는 전문 번역가입니다.', additional_kwargs={}),
 HumanMessage(content='I love programming.', additional_kwargs={}, example=False)]

SystemMessagePromptTemplate과 HumanMessagePromptTemplate은 각각 시스템 메시지와 사용자 메시지를 만드는 템플릿입니다. ChatPromptTemplate은 위 코드에서와 같이 메시지들의 리스트를 입력으로 받습니다. 포맷할 때도 format_messages를 사용하는데, 시스템 메시지의 변수인 input_languageoutput_language, 사용자 메시지의 변수인 text를 모두 치환할 수 있습니다. 위 코드는 아래와 같이 쓸 수도 있습니다.

from langchain.prompts import ChatPromptTemplate

chat_prompt = ChatPromptTemplate.from_messages(
    [("system","당신은 {input_language}를 {output_language}로 번역하는 전문 번역가입니다."),
     ("human","{text}")]
)

chat_prompt.format_messages(input_language="영어", output_language="한국어", text="I love programming.")

시스템 메시지와 사용자 메시지를 (type, content) 튜플로 만들어서 입력했습니다. 앞에서는 MessagePromptTemplate를 사용했었죠. 그래도 결과는 동일합니다. 치환이 필요 없는 경우 BaseMessage 객체(아래에서 SystemMessage)를 사용하는 것도 가능합니다.

from langchain.prompts import ChatPromptTemplate
from langchain.prompts.chat import SystemMessage, HumanMessagePromptTemplate

template = ChatPromptTemplate.from_messages(
    [
        SystemMessage(content="당신은 영어를 한국어로 번역하는 전문 번역가입니다."),
        HumanMessagePromptTemplate.from_template("{text}")
    ]
)

from langchain.chat_models import ChatOpenAI

llm = ChatOpenAI()
llm(template.format_messages(text='I love programming.'))

### 결과
AIMessage(content='나는 프로그래밍을 사랑합니다.', additional_kwargs={}, example=False)

참고로, 위 코드들에서 ChatPromptTemplate, SystemMessagePromptTemplate, HumanMessagePromptTemplate은 langchain.prompts 또는 langchain.prompts.chat 모듈에서 import 가능합니다. 반면에 SystemMessage는 langchain.prompts.chat 모듈에서 import 해야 합니다.

기타 프롬프트 템플릿, 메시지 프롬프트 템플릿과 메시지 클래스

위에서 다룬 프롬프트 템플릿 외에도 예제를 제시하기 위한 FewShotPromptTemplate, FewShotChatMessagePromptTemplate이 있고, 여러 개의 예제들 중 몇 개를 추출해서 넣을 수 있는 ExampleSelector들도 있습니다. StringPromptTemplate을 이용해 사용자 정의 프롬프트 템플릿을 만들 수도 있습니다.

MessagePromptTemplate에는 HumanMessagePromptTemplate 외에도 SystemMessagePromptTemplate과 AIMessagePromptTemplate이 있습니다. 다른 role을 지정하고 싶으면 ChatMessagePromptTemplate를 사용하면 됩니다.

BaseMessage에는 SystemMessage 외에도 HumanMessage, AIMessage, FunctionMessage도 있습니다.

LangChain: llms vs. chat_models

랭체인 LangChain

LangChain은 다양한 LLM들을 공통된 인터페이스로 사용할 수 있도록 만든 파이썬/자바스크립트 패키지입니다. LLM 뿐 아니라 프롬프트 템플릿, 출력 파서, 외부 API 호출 기능도 지원하고, 각각의 기능들을 연결(chain)하는 기능도 제공합니다.

각 기능들의 이름은 다음과 같습니다.

  • LLM (Wrappers): 다양한 LLM의 표준 인터페이스를 제공합니다.
  • Prompt Templates: LLM에게 전달할 메시지를 구성하기 쉽게 만들어줍니다.
  • Output Parsers: LLM의 응답을 사용하기 쉽게 변환해줍니다.
  • Indexes: LLM 응답 품질 향상을 위해 외부 정보를 가지고 올 때 사용합니다.
  • LLMChain: 위의 구성 요소들을 연결합니다.

설치는 다음과 같이 합니다.

pip install langchain

간단한 코드를 봅시다. OPENAI_API_KEY라는 환경변수가 지정된 상태입니다.

from langchain.llms import OpenAI

llm = OpenAI(temperature=0.9)
response = llm.predict("이순신은 누구지?")

LLMs, ChatModels

LLM에는 두 가지 유형이 있습니다.

  • LLMs: 문자열(하나의 메시지)을 입력받아 문자열을 반환하는 LLM
  • ChatModels: 메시지 리스트를 입력받아 문자열을 반환하는 LLM

앞에서 봤던 코드에서는 llms를 이용했죠? chat_models도 사용할 수 있습니다.

from langchain.chat_models import ChatOpenAI

chat_model = ChatOpenAI()
response = chat_model.predict("이순신은 누구지?")

앞의 코드랑 다를게 없네요. 같다면 llms와 chat_models를 따로 만들 이유가 없겠죠? 앞에서 LLM을 호출하기 위해 predict를 사용했는데, LLMs와 ChatModels 모두 두 가지 메서드가 있습니다.

  • predict: 문자열을 입력받아 문자열을 반환합니다.
  • predict_messages: 메시지 리스트를 입력받아 하나의 메시지를 반환합니다.

predict_messages를 사용한 코드입니다.

from langchain.schema import HumanMessage

messages = [HumanMessage(content="이순신은 누구지?")]

llm.predict_messages(messages)
chat_model.predict_messages(messages)

ChatModels의 입력인 ChatMessages에는 두 가지 필수 구성 요소가 있는데, OpenAI API에서 봤던 role과 content입니다. OpenAI API에서 role에는 “user”, “assistant”, “system”, “function“이 올 수 있었는데, LangChain에는 이에 해당하는 객체들이 있습니다.

  • HumanMessage: 사용자가 입력한 ChatMessage
  • AIMessage: LLM 응답
  • SystemMessage: 시스템 메시지
  • FunctionMessage: 함수 호출 결과 메시지

이 외에 사용자가 역할을 별도로 지정할 수 있는 ChatMessage 클래스도 있습니다.

predict vs. predict_messages

잠깐, 앞에서 LLMs는 문자열을 입력받아 문자열을 반환한다고 했는데, predict_messages 메소드를 가지고 있고 이 메소드는 메시지 리스트를 입력받는다고요? ChatModels는 메시지 리스트를 입력받는다고 했는데, 문자열을 입력받는 predict 메소드가 있다고요? 네 그렇습니다. 작동은 다음과 같습니다.

  • llm.predict, chat_model.predict: 문자열을 입력받아 문자열 반환. chat_model의 경우 문자열을 입력받으면 하나의 메시지만 있는 리스트를 입력받은 것과 유사합니다.
  • llm.predict_messages: 메시지의 리스트를 입력받는데, 리스트의 각 원소별로 LLM의 응답을 구합니다. 리스트의 메시지들은 서로 관련 없는 별개의 메시지가 됩니다.
  • chat_model.predict_messages: 메시지의 리스트를 입력받는데, 리스트의 원소들은 연속된 대화로 인식됩니다.

따라서 일반적으로 llm.predict 또는 chat_model.predict_messages를 사용하는 것이 좋습니다. 이 때 predict는 문자열을 반환하지만 predict_messages는 AIMessage 객체를 반환합니다. 응답은 객체의 content 속성에 저장됩니다.

다음 글에서는 프롬프트 템플릿에 대해 알아보겠습니다.

OpenAI 임베딩과 벡터 거리

OpenAI 임베딩 모델

OpenAI API를 이용해 문자열을 임베딩 벡터로 변환해보겠습니다. 임베딩 모델에 몇 가지가 있지만 그 중 OpenAI에서 권장하는 text-embedding-ada-002 모델을 사용하면 됩니다. 모델 정보는 아래 표에 정리했습니다.

토크나이저cl100k_base
최대 입력 토큰수8192
출력 벡터 차원1536
API 사용료$0.0001/1K tokens
text-embedding-ada-002 모델

파이썬 API

임베딩 계산을 위한 파이썬 코드는 다음과 같습니다.

import os
import openai
openai.api_key = os.getenv("OPENAI_API_KEY")

text = "I love Python!"

response = openai.Embedding.create(
    input = text,
    model = "text-embedding-ada-002"
)

결과로 나오는 response는 다음과 같습니다.

{
  "object": "list",
  "data": [
    {
      "object": "embedding",
      "index": 0,
      "embedding": [
        -0.0078111737966537476,
        -0.01599879004061222,
        -0.0008188456413336098,
        -0.03275046497583389,
        -0.01546101551502943,
        ...
        0.027292054146528244,
        -0.0034115067683160305,
        -0.005542438011616468,
        -0.006056685000658035,
        -0.03979530930519104
      ]
    }
  ],
  "model": "text-embedding-ada-002-v2",
  "usage": {
    "prompt_tokens": 4,
    "total_tokens": 4
  }
}

결과 임베딩 벡터는 1536차원이기 때문에 중간은 생략했습니다. response에서 임베딩 벡터만 추출하려면 다음과 같이 합니다.

embeddings = response['data'][0]['embedding']

벡터 거리와 의미 검색

텍스트를 임베딩 벡터로 변환하면 벡터들 사이의 거리를 계산할 수 있게 됩니다. 두 임베딩 벡터의 거리가 가까우면 의미가 유사한 텍스트, 벡터의 거리가 멀먼 관련 없는 텍스트라고 할 수 있습니다. 따라서 텍스트 사이에 일치하는 단어가 없더라도 벡터 거리를 이용해 관련된 텍스트를 찾아낼 수 있죠. 의미 검색(Semantic Search)에서 사용하는 방법입니다.

코사인 유사도와 코사인 거리

벡터 사이의 거리를 계산하는 방법에는 여러 가지가 있는데, LLM에서 많이 사용하는 방법은 코사인 유사도(Cosine Similarity)를 이용하는 방법입니다.

S_C \left(\mathbf{A}, \mathbf{B} \right) = \frac{\mathbf{A} \cdot \mathbf{B}}{\lVert\mathbf{A}\rVert \lVert \mathbf{B} \rVert}

코사인 유사도는 두 벡터가 가까우면 1, 멀면 0이 됩니다. 거리를 재려면 가까운게 0, 먼게 1이 되도록 하는게 좋겠죠? 코사인 거리(Cosine Distance)는 다음과 같이 계산합니다.

D_C \left( \mathbf{A},\mathbf{B}  \right) = 1- S_C \left( \mathbf{A},\mathbf{B}  \right)

예제

I love Python!“에서 한 단어씩만 바꾼 문장을 몇 개 만들어서 임베딩 벡터를 만들고, 원래 문장의 임베딩 벡터와 코사인 거리를 계산해보겠습니다. 사용할 문장들입니다.

  • I love Python (느낌표 삭제)
  • I like Python!
  • I hate Python!
  • I love JavaScript!
  • I love you!

다음은 테스트에 사용한 함수들입니다. 먼저 임베딩 벡터를 한 번에 얻는 get_embedding 함수를 만들고 코사인 유사도와 코사인 거리를 계산하는 함수를 만들었습니다.

import numpy as np
from numpy.linalg import norm

def get_embedding(text):
    response = openai.Embedding.create(
        input=text,
        model="text-embedding-ada-002"
    )
    return np.array(response['data'][0]['embedding'])

def cosine_similarity(a,b):
    return np.dot(a,b) / (norm(a)*norm(b))

def cosine_dist(a,b):
    return 1-cosine_similarity(a,b)

테스트 코드입니다.

text_org = "I love Python!"
texts = ["I love Python",
        "I like Python!",
        "I hate Python!",
        "I love JavaScript!",
        "I love you!"]

# 임베딩 벡터 구하기
emb_org = get_embedding(text_org)
emb_comp = [get_embedding(t) for t in texts]

# 코사인 거리 계산
dist = [cosine_dist(emb_org, emb) for emb in emb_comp]

# 결과 출력
for t,d in zip(texts,dist):
    print(f"{d:.4f}: {t}")

다음은 출력된 결과입니다.

0.0235: I love Python
0.0164: I like Python!
0.0790: I hate Python!
0.0888: I love JavaScript!
0.1631: I love you!

결과를 관찰해 봅시다. 숫자는 옆에 있는 문장이 기준 문장 “I love Python!“과 얼마나 가까운 문장인지 나타냅니다. 값이 작을수록 의미가 더 가깝죠.

  • “I like Python!”이 “I love Python”보다 더 가깝습니다. love/like 차이보다 느낌표 유무의 차이가 더 크네요!
  • hate는 당연히 like에 비해 love로부터의 거리가 멀죠.
  • 하지만 “I hate Python!”은 “I love JavaScript!” 보다는 가깝습니다. 좋던 싫던(정반대의 의미지만) 파이썬 선호도를 나타낸 문장들끼리는 JavaScript 선호도를 나타낸 문장보다 의미가 가깝네요.
  • JavaScript가 Python은 아니지만 둘 다 프로그래밍 언어이기 때문에 “I love JavaScript!”는 “I love you!” 보다 의미가 훨씬 가깝습니다.

이렇게 텍스트를 임베딩 벡터로 만들면 벡터 사이의 거리를 이용해 의미가 가까운 정도를 정량적으로 계산할 수 있습니다. 벡터 데이터베이스를 이용하면 데이터베이스에 저장해놓은 수많은 임베딩 벡터들 중 기준이 되는 문장(query)과 거리가 가까운 벡터들을 빠르게 추출할 수 있습니다.

OpenAI tiktoken을 이용한 토큰 수 계산하기

OpenAI API를 이용할 때 모델별로 최대 토큰 수가 정해져 있습니다. API를 호출하기 전에 토큰 수를 미리 알 수 있으면 좋겠죠? 파이썬에서 텍스트를 토큰으로 변환해주는 tiktoken 패키지를 이용하면 토큰 수를 알 수 있습니다.

tiktoken 설치

tiktoken은 pip를 이용해 설치할 수 있습니다.

pip install tiktoken

인코딩 방법(토크나이저) 결정

OpenAI에 여러 종류의 토크나이저들이 있기 때문에 토큰화하기 전에 먼저 토크나이저를 지정해줍니다. 지정 방법에는 두 가지가 있는데, 토크나이저 이름을 이용하는 방법과 LLM 이름을 이용하는 방법 중 하나를 선택해 사용합니다.

import tiktoken

# 토크나이저 이름을 이용하는 방법
encoder = tiktoken.get_encoding("cl100k_base")

# LLM 이름을 이용하는 방법
encoder = tiktoken.encoding_for_model("gpt-3.5-turbo")

인코딩

토크나이저를 정한 후에는 다음과 같이 텍스트를 인코딩(토큰화)할 수 있습니다.

text = "I love Python!"

result = encoder.encode(text)
print(result) # 결과: [40, 3021, 13325, 0]

토큰 번호로 결과가 출력되네요. 토큰의 길이를 알고 싶다면 토큰 리스트의 길이를 이용하면 됩니다.

len(result) # 결과: 4

디코딩

다시 텍스트로 변환하려면 decode를 사용합니다.

encoder.decode(result) # 결과: 'I love Python!'

처음 입력했던 텍스트가 반환되는 것을 볼 수 있습니다. 토큰별로 확인하고 싶다면 decode_single_token_bytes를 사용합니다.

[encoder.decode_single_token_bytes(token) for token in result]
# 결과: [b'I', b' love', b' Python', b'!']

위 결과에서 작은 따옴표 앞에 붙은 bbytes 자료형을 의미합니다.

한글 인코딩/디코딩

이번에는 한글을 토큰화해보겠습니다.

text2 = "나는 파이썬이 좋아요!"

result2 = encoder.encode(text2)
print(result2) 
# 결과: [61415, 16969, 56069, 13094, 168, 235, 105, 13094, 66799, 233, 54059, 36811, 0]

print(len(result2)) # 결과: 13

한글 문장과 영어 문장의 단어수는 같은데 한글의 토큰 수가 훨씬 많은 것을 볼 수 있습니다. 토큰 수가 많아지면 OpenAI API 사용료도 증가하게 됩니다. 디코딩 결과는 다음과 같습니다.

encoder.decode(result2) # '나는 파이썬이 좋아요!'

[encoding.decode_single_token_bytes(token) for token in result2]
# 결과: [b'\xeb\x82\x98', b'\xeb\x8a\x94', b' \xed\x8c\x8c', b'\xec\x9d\xb4', b'\xec', b'\x8d', b'\xac', b'\xec\x9d\xb4', b' \xec\xa2', b'\x8b', b'\xec\x95\x84', b'\xec\x9a\x94', b'!']