Etc/Langchain

랭체인 기초

Seung-o 2024. 9. 19. 00:45

- 랭체인은 LLM을 이용한 애플리케이션 개발 프레임워크이다. 

- 랭체인의 모듈은 크게 6가지로 나뉜다. 

  - Model I/O 

  - Data Connection

  - Chains

  - Agents

  - Memory

  - Callbacks

 

각 모듈의 역할은 아래와 같다.

 

Language Models

- Language models는 랭체인에서 언어 모델을 사용하는 방법을 제공하는 모듈이다. 

- 다양한 언어 모델을 공통된 인터페이스로 사용할 수 있다.

- Language models를 크게 'LLMs'와 'Chat models'로 분류할 수 있다.

 

LLMs

- 하나의 텍스트 입력에 대해 하나의 텍스트 출력을 반환하는 전형적인 대규모 언어 모델을 다루는 모듈이다. 

 

from langchain_openai import OpenAI

llm = OpenAI(model_name="gpt-3.5-turbo-instruct", temperature=0)
result = llm.invoke("자기소개를 해주세요")
print(result)

# 안녕하세요. 저는 ... (생략)

 

Chat Models

- 단순히 하나의 텍스트를 입력하는 것이 아니라, 채팅 형식의 대화를 입력하면 응답을 받을 수 있도록 하는 모듈이다.

 

from langchain_openai import ChatOpenAI
from langchain.schema import AIMessage, HumanMessage, SystemMessage

chat = ChatOpenAI(model_name="gpt-3.5-turbo", temperature=0)

messages = [
    SystemMessage(content="You are a helpful assistant."),
    HumanMessage(content="안녕하세요! 저는 존이라고 합니다!"),
    AIMessage(content="안녕하세요, 존 씨! 어떻게 도와드릴까요?"),
    HumanMessage(content= "제 이름을 아세요?")
]

result = chat.invoke(messages)
print(result.content)

# 네, 앞서 말씀해주신대로 존 씨 맞으시죠?

 

Callback을 이용한 스트리밍

- 랭체인의 Callback을 활용하면, Chat Completions API의 응답을 스트리밍으로 받을 수 있다. 

- 다음은 랭체인이 제공하는 StreamingStdOutCallbackHandler를 콜백으로 설정하는 예시이다.

 

from langchain.callbacks.streaming_stdout import StreamingStdOutCallbackHandler
from langchain_openai import ChatOpenAI
from langchain.schema import HumanMessage

chat = ChatOpenAI(
    model_name="gpt-3.5-turbo",
    temperature=0,
    streaming=True,
    callbacks=[StreamingStdOutCallbackHandler()],
)

messages = [HumanMessage(content="자기소개를 해주세요")]
result = chat.invoke(messages)

 

Prompts

- LLM을 이용한 애플리케이션 개발에서 가장 중요한 요소는 입력 프롬프트이다.

- 랭체인에서는 프롬프트를 추상화한 모듈을 제공하고 있다.

 

PromptTemplate

- LLMs 모델에 대응되는 프롬프트를 클래스 형태로 사용할 수 있게 모듈화한 것이다.

from langchain.prompts import PromptTemplate

template = """
다음 요리의 레시피를 생각해 주세요.

요리: {dish}
"""

prompt = PromptTemplate(
   input_variables=["dish"],
   template=template,
)

result = prompt.format(dish="카레")
print(result)

# 다음 요리의 레시피를 생각해 주세요.
# 요리: 카레

 

 

ChatPromptTemplate

- PromptTemplate을 Chat Completions API의 형식에 맞게 만든 것이다. 

- SystemMessage, HumanMessage, AIMessage를 각각 템플릿화하여 ChatPromptTemplate이라는 클래스에 일괄적으로 처리할 수 있게 하였다. 

 

from langchain.prompts import (
    ChatPromptTemplate,
    PromptTemplate,
    SystemMessagePromptTemplate,
    HumanMessagePromptTemplate,
)
from langchain.schema import HumanMessage, SystemMessage

chat_prompt = ChatPromptTemplate.from_messages([
    SystemMessagePromptTemplate.from_template("당신은 {country} 요리 전문가입니다."),
    HumanMessagePromptTemplate.from_template("다음 요리의 레시피를 생각해 주세요.\n\n요리: {dish}")
])

messages = chat_prompt.format_prompt(country="영국", dish="고기감자조림").to_messages()

print(messages)

# [SystemMessage(content='당신은 영국 요리 전문가입니다.'), HumanMessage(content='다음 요리의 레시피를 생각해 주세요.\n\n요리: 고기감자조림')]

 

 

Output parsers

- Output parsers는 JSON과 같은 출력 형식을 지정하는 프롬프트 생성 및 응답 텍스트를 Python 객체로 변환하는 기능을 제공한다. 

- 이를 활용하면 애플리케이션 단의 처리가 쉬워질 수 있다.

- 대표적인 랭체인의 Output parser는 'PydanticOutputParser'이며, 아래와 같이 활용 가능하다. 

from pydantic import BaseModel, Field
from langchain.output_parsers import PydanticOutputParser

class Recipe(BaseModel):
   ingredients: list[str] = Field(description="ingredients of the dish")
   steps: list[str] = Field(description="steps to make the dish")
   
parser = PydanticOutputParser(pydantic_object=Recipe)
format_instructions = parser.get_format_instructions()

print(format_instructions)

"""
The output should be formatted as a JSON instance that conforms to the JSON schema below.

As an example, for the schema {"properties": {"foo": {"title": "Foo", "description": "a list of strings", "type": "array", "items": {"type": "string"}}}, "required": ["foo"]}
the object {"foo": ["bar", "baz"]} is a well-formatted instance of the schema. The object {"properties": {"foo": ["bar", "baz"]}} is not well-formatted.

Here is the output schema:
```
{"properties": {"ingredients": {"description": "ingredients of the dish", "items": {"type": "string"}, "title": "Ingredients", "type": "array"}, "steps": {"description": "steps to make the dish", "items": {"type": "string"}, "title": "Steps", "type": "array"}}, "required": ["ingredients", "steps"]}
"""

 

- 이를 활용하여, 전체 프롬프트를 작성하면 아래와 같다.

from langchain.prompts import PromptTemplate

template = """다음 요리의 레시피를 생각해 주세요.

{format_instructions}

요리: {dish}
"""

prompt = PromptTemplate(
   template=template,
   input_variables=["dish"],
   partial_variables={"format_instructions": format_instructions}
)

formatted_prompt = prompt.format(dish="카레")

print(formatted_prompt)

"""
다음 요리의 레시피를 생각해 주세요.

The output should be formatted as a JSON instance that conforms to the JSON schema below.

As an example, for the schema {"properties": {"foo": {"title": "Foo", "description": "a list of strings", "type": "array", "items": {"type": "string"}}}, "required": ["foo"]}
the object {"foo": ["bar", "baz"]} is a well-formatted instance of the schema. The object {"properties": {"foo": ["bar", "baz"]}} is not well-formatted.

Here is the output schema:
```
{"properties": {"ingredients": {"description": "ingredients of the dish", "items": {"type": "string"}, "title": "Ingredients", "type": "array"}, "steps": {"description": "steps to make the dish", "items": {"type": "string"}, "title": "Steps", "type": "array"}}, "required": ["ingredients", "steps"]}
```

요리: 카레
"""

 

- Output parsers가 안정적으로 작동하지 않을 수 있다. 지정한 형식과 다른 출력을 LLM이 반환하는 경우가 적잖기 때문이다. 

- 이러한 오류에 대응하기 위해 Output parsers에는 변환하지 못한 텍스트를 LLM에 수정하도록 하는 OutputFixingParser, RetryWithErrorOutputParser와 같은 클래스도 있다. 

 

Chains

- LLM을 사용하는 애플리케이션에서는 단순히 LLM에 입력해서 출력을 얻고 끝나는 것이 아니라, 처리를 연쇄적으로 연결하고 싶은 경우가 많다. 

- 예를 들어, "이번달 MAU가 얼마나 돼?" 라는 질문에, (1) MAU를 계산하는 SQL 문을 LLM을 통해서 얻고 (2) 얻은 결과를 바탕으로 실제 SQL을 실행해서 데이터를 파악하여 답변을 주고 싶을 수 있다. 

- 이런 연쇄적 처리를 실현하는 모듈이 Chains이다. 

 

LLM Chains

- LLMChain은 PromptTemplate과 Language model, OutputParser를 연결한다. 

 

from langchain_openai import ChatOpenAI
from langchain.output_parsers import PydanticOutputParser
from langchain.prompts import PromptTemplate
from pydantic import BaseModel, Field

class Recipe(BaseModel):
   ingredients: list[str] = Field(description="ingredients of the dish")
   steps: list[str] = Field(description="steps to make the dish")

# Output Parser
output_parser = PydanticOutputParser(pydantic_object=Recipe)

template = """다음 요리의 레시피를 생각해 주세요.

{format_instructions}

요리: {dish}
"""

# Prompt Template
prompt = PromptTemplate(
   template=template,
   input_variables=["dish"],
   partial_variables={"format_instructions": output_parser.get_format_instructions()}
)

# Language Model
chat = ChatOpenAI(model_name="gpt-3.5-turbo", temperature=0)

 

from langchain.chains import LLMChain

chain = LLMChain(prompt=prompt, llm=chat, output_parser=output_parser)

recipe = chain.invoke("카레")

print(type(recipe))

"""
<class 'dict'>
"""

print(recipe)

"""
{'dish': '카레', 'text': Recipe(ingredients=['카레 가루', '양파', '감자', '당근', '고기 (소고기, 닭고기, 돼지고기 중 선택)', '물', '식용유', '소금', '후추'], steps=['1. 양파, 감자, 당근을 깍뚝 썰어준다.', '2. 냄비에 식용유를 두르고 양파를 볶아준다.', '3. 고기를 넣고 익힌다.', '4. 감자와 당근을 넣고 볶아준다.', '5. 물을 부어 카레 가루를 넣고 끓인다.', '6. 소금과 후추로 간을 맞춰준다.', '7. 밥 위에 카레를 올려 맛있게 즐긴다.'])}
"""

 

- 최종 출력에 Recipe 클래스 인스턴스가 포함되었으며, 템플릿 채우기, LLM 호출, 출력 변환이 연쇄적으로 실행되었음을 알 수 있다. 

 

SimpleSequentialChain

- SimpleSequentialChain을 활용하면, Chain과 Chain을 직렬로 연결할 수 있다. 

- 가령, Zero-shot CoT Chain과 텍스트를 요약하는 Chain을 연결하는 경우를 떠올려보자. 

 

- Zero-shot CoT Chain

chat = ChatOpenAI(model_name="gpt-3.5-turbo", temperature=0)

cot_template = """다음 질문에 답하세요.

질문: {question}

단계별로 생각해 봅시다.
"""

cot_prompt = PromptTemplate(
   input_variables=["question"],
   template=cot_template,
)

cot_chain = LLMChain(llm=chat, prompt=cot_prompt)

 

- 텍스트 요약 Chain (사실 요약에 사용가능한 기성 체인이 있기도 하다)

summarize_template = """다음 문장을 결론만 간단히 요약하세요.

{input}
"""
summarize_prompt = PromptTemplate(
   input_variables=["input"],
   template=summarize_template,
)

summarize_chain = LLMChain(llm=chat, prompt=summarize_prompt)

 

- 두 개를 연결하는 Chain

from langchain.chains import SimpleSequentialChain

cot_summarize_chain = SimpleSequentialChain(chains=[cot_chain, summarize_chain])

result = cot_summarize_chain.invoke(
   "저는 시장에 가서 사과 10개를 샀습니다. 이웃에게 2개, 수리공에게 2개를 주었습니다. 그런 다음에 사과 5개를 더 사서 1개를 먹었습니다. 남은 개수는 몇 개인가요?"
)
print(result["output"])

# 총 10개의 사과가 남았다.

 

- 결과적으로 CoT Chain을 통해 답변의 정확도를 높이면서도 최종적으로 간단한 출력을 얻을 수 있다.

- SimpleSequentialChain 외에도 여러 입출력을 지원하는 SequentialChain, LLM의 판단에 다라 Chain의 분기를 실현하는 LLMRouterChain 등이 있다. 

 

Memory

- Chat Completions API는 Stateless로, 대화 이력을 바탕으로 응답을 얻기 위해서는 대화 이력을 요청에 포함시켜야 한다. 

- 대화 이력 저장과 관련된 편리한 기능을 제공하는 것이 랭체인의 Memory이다. 

 

ConversationBufferMemory

 

- 단순히 대화 기록을 보관하는 모듈이다. 

- ConversationChain과 함께 사용하면 다음과 같다. 

 

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

chat = ChatOpenAI(model_name="gpt-4", temperature=0)
conversation = ConversationChain(
    llm=chat,
    memory=ConversationBufferMemory()
)

while True:
    user_message = input("You: ")

    if user_message == "끝":
        print("(대화 종료)")
        break

    ai_message = conversation.invoke(input=user_message)["response"]
    print(f"AI: {ai_message}")
    
    
"""
You: 안녕하세요. 저는 존이라고 합니다!
AI: 안녕하세요, 존님! 저는 인공지능 대화형 도우미입니다. 어떻게 도와드릴까요?
You: 제 이름을 아세요?
AI: 네, 방금 말씀하셨듯이 당신의 이름은 존님이라고 알고 있습니다.
You: 끝
(대화 종료)
 """

 

- 단순히 대화 기록을 보관하는 것을 넘어, 프롬프트 길이의 제한 등을 극복하기 위해 히스토리에 대한 고급 처리를 하고 싶을 때는 아래와 같은 메모리 체인들을 활용할 수 있다. 

  - ConversationBufferWindowMemory: 최근 K개의 대화만 프롬프트에 포함한다.

  - ConversationSummaryMemory: LLM을 사용하여 대화기록을 요약한다.

 

- 랭체인의 Memory에서 대화 기록은 기본적으로 메모리 (인스턴스 변수)에 저장된다. 따라서 프로세스 중단되면 대화 기록은 유지 되지 않는다. 

- 분산 시스템이나 서버 리스 환경에 대응하기 위해서는 대화 이력을 애플리케이션 외부에 저장할 수 있어야한다. 

- 랭체인 Memory는 SQLite, PostgreSQL, Redis, DynamoDB와 같은 다양한 저장소를 지원한다.