태그 보관물: 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']에 원하는 결과를 얻었습니다.

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 속성에 저장됩니다.

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