카테고리 보관물: AI

OpenAI Assistant로 함수 호출하기

Assistant의 함수 호출 (function call) 기능은 사용자가 정의한 함수를 Assistant가 사용할 수 있도록 해주는 기능입니다. Assistant가 직접 함수를 호출하는 것은 아니고, 사용자가 함수 사용법을 알려주면 Assistant가 필요할 때 함수를 호출해달라고 사용자에게 요청합니다. 사용자가 요청대로 함수를 호출하고 그 결과를 Assistant에게 전달해주면, 그 결과를 바탕으로 Assistant가 응답을 생성합니다.

예제

이번에는 Assistant에게 현재 유가를 원화로 알려달라고 해보겠습니다. Assistant가 질문에 답할 수 있도록 현재 석유 가격을 달러로 알려주는 함수와 현재 원/달러 환율을 알려주는 함수를 준비했습니다. 11월에 업데이트된 API에서 Assistant는 한 번에 여러 개의 함수를 호출할 수 있게 되었는데, 한 번에 두 함수를 호출해 답하는지 확인해보겠습니다.

함수, 함수 설명 준비

현재 석유 가격을 반환하는 함수와 현재 원/달러 환율을 알려주는 함수는 BeautifulSoup을 이용한 Web Scraping으로 구현했습니다.

from bs4 import BeautifulSoup
import requests

def oil_price():
url = "https://www.knoc.co.kr/"
r = requests.get(url)
soup = BeautifulSoup(r.text, 'html.parser')
price=[]
for tag in soup.find_all("dl",class_="money_list")[3:]:
price.append(tag.find('li').text)
return {"Dubai":price[0], "WTI":price[1], "Brent":price[2]}

def usd_krw():
url = "https://finance.naver.com/marketindex/"
r = requests.get(url)
soup = BeautifulSoup(r.text, 'html.parser')
usdkrw = soup.find("span",class_='value').text
return usdkrw

Assistant에게 함수 사용법을 알려주기 위해 함수 설명도 준비했습니다.

tools = [
{
"type":"function",
"function": {
"name":"oil_price",
"description":"Get current oil prices - Dubai, WTI, and Brent",
"parameters": {
"type":"object",
"properties": {}
},
"required": []
}
},
{
"type":"function",
"function": {
"name":"usd_krw",
"description":"Get current exchange rate from USD to KRW",
"parameters": {
"type":"object",
"properties": {}
},
"required": []
}
}
]

Assistant, 질문 준비

from openai import OpenAI

client = OpenAI(api_key="sk-")

assistant = client.beta.assistants.create(
instructions = "You are an oil expert.",
model="gpt-4-1106-preview",
tools = tools
)

thread = client.beta.threads.create(
messages=[
{
"role":"user",
"content": "현재 WTI 석유 가격을 원화로 알려줄래?"
}
]
)

Run 실행

Assistant의 응답을 얻기 위해서는 Run 객체를 이용합니다. 함수 호출을 포함하는 경우 Assistant의 Run 상태는 다음과 같은 순서로 변합니다.

  • queued: 사용자의 입력을 받으면 바로 queued 상태가 됩니다.
  • in_progress: Assistant가 입력을 분석하는 상태입니다.
  • requires_action: 함수 호출이 필요하다고 판단해서 사용자에게 특정 함수를 호출해달라고 요청하는 상태입니다. 사용자는 해당 함수를 호출하고 결과를 전달해주게 됩니다.
  • queued: 함수 호출 결과를 받았습니다.
  • in_progress: 질문과 함수 호출 결과를 이용해 응답을 생성합니다.
  • completed: 응답 생성이 끝났습니다. 사용자는 Thread의 Message를 가져와 응답을 확인할 수 있습니다.

Run 객체의 실행 상태가 queued, in_progress 상태인 동안은 기다렸다가 다른 상태로 바뀌면 진행하도록 하는 함수를 정의하겠습니다.

import time
def wait_run(client, run, thread):
while True:
run_check = client.beta.threads.runs.retrieve(
thread_id=thread.id,
run_id=run.id
)
print(run_check.status)
if run_check.status in ['queued','in_progress']:
time.sleep(2)
else:
break
return run_check

Run 객체를 생성하고 결과를 기다리겠습니다.

run = client.beta.threads.runs.create(
thread_id=thread.id,
assistant_id=assistant.id
)
run_check = wait_run(client, run, thread)
run_check.status
# 결과: requires_action

함수 호출 요청

Run 상태가 required_action으로 바뀌었습니다. 사용자에게 함수를 호출해달라고 요청하는 상태입니다. 어떤 함수를 어떻게 호출해야하는지에 관한 정보는 다음과 같이 확인할 수 있습니다.

run_check.required_action.submit_tool_outputs.tool_calls
## 결과
[RequiredActionFunctionToolCall(id='call_M5haSVtL6eox816h1ibHKdTv', function=Function(arguments='{}', name='oil_price'), type='function'),
RequiredActionFunctionToolCall(id='call_PmZ6Fp8bh12sb7qi0jeGGZj1', function=Function(arguments='{}', name='usd_krw'), type='function')]

위 결과를 보면, 두 개의 RequiredActionFunctionToolCall 객체로 구성된 리스트가 보입니다. 각각의 객체가 함수 호출에 관한 정보를 담고 있습니다. 두 개의 객체가 있으므로 두 개의 함수를 함께 호출해달라는 의미입니다. 각 객체는 id, arguments, name 정보를 가지고 있습니다. id는 여러 개의 함수 호출 결과를 전달할 때 어떤 함수의 결과인지 알려주기 위함이고, namearguments는 사용자가 어떤 함수를 어떤 인자를 사용해 호출해야하는지에 관한 정보입니다. 이 정보를 이용해 함수를 호출하고 결과를 알려주면 됩니다.

함수 호출

tool_calls = run_check.required_action.submit_tool_outputs.tool_calls

tool_outputs = []
for tool in tool_calls:
func_name = tool.function.name
kwargs = json.loads(tool.function.arguments)
output = locals()[func_name](**kwargs)
tool_outputs.append(
{
"tool_call_id":tool.id,
"output":str(output)
}
)

tool_outputs
## 결과 (2023-11-29)
[{'tool_call_id': 'call_M5haSVtL6eox816h1ibHKdTv',
'output': "{'Dubai': '82.14', 'WTI': '76.41', 'Brent': '81.68'}"},
{'tool_call_id': 'call_PmZ6Fp8bh12sb7qi0jeGGZj1', 'output': '1,289.00'}]

함수 호출 결과에서 석유 가격과 환율을 확인할 수 있습니다.

여기서는 locals()를 이용해 함수 이름 문자열로 함수를 호출하고 결과를 tool_outputsid와 함께 문자열로 저장했습니다. 함수가 다른 모듈에 정의되어 있다면 getattr을 이용할 수도 있습니다. 물론, 함수의 수가 많지 않다면 if 문을 사용하는 것도 괜찮습니다.

함수 호출 결과 제출

이제 위에서 저장한 함수 호출 결과를 다시 Assistant에게 알려주면 Assistant가 응답을 생성하게 됩니다. 함수 호출 결과를 알려주기 위해서는 runs.submit_tool_outputs를 이용합니다.

run = client.beta.threads.runs.submit_tool_outputs(
thread_id=thread.id,
run_id=run.id,
tool_outputs=tool_outputs
)
run_check = wait_run(client,run,thread)
run_check.status
# 결과: completed

Assistant의 응답

응답이 끝났으니 응답을 확인해보겠습니다.

thread_messages = client.beta.threads.messages.list(thread.id)
for msg in reversed(thread_messages.data):
print(f"{msg.role}: {msg.content[0].text.value}")

## 결과
user: 현재 WTI 석유 가격을 원화로 알려줄래?
assistant: 현재 WTI 원유 가격은 배럴당 $76.41 이며, USD/KRW 환율은 1,289.00원입니다. 따라서 WTI 원유 가격을 원화로 환산하면 배럴당 약 98,436.89원입니다.

76.41 x 1289 = 98,492.49로 Assistant의 응답에 약간의 오차가 있긴 하지만 가깝게 맞췄습니다. 수학 계산 함수를 추가하거나 code interpreter를 사용하면 더 정확한 결과를 얻을 수 있을 것입니다.

Run steps

어떤 과정을 거쳐서 위의 결과를 얻었는지 정확히 알고 싶다면 run steps를 확인하면 됩니다.

run_steps = client.beta.threads.runs.steps.list(
thread_id=thread.id,
run_id=run.id
)
for i,run_step in enumerate(run_steps.data):
print(i, run_step.step_details)

## 결과
0 MessageCreationStepDetails(message_creation=MessageCreation(message_id='msg_40MzHjYXvYvpqYKf1059yrRb'), type='message_creation')
1 ToolCallsStepDetails(tool_calls=[FunctionToolCall(id='call_M5haSVtL6eox816h1ibHKdTv', function=Function(arguments='{}', name='oil_price', output="{'Dubai': '82.14', 'WTI': '76.41', 'Brent': '81.68'}"), type='function'), FunctionToolCall(id='call_PmZ6Fp8bh12sb7qi0jeGGZj1', function=Function(arguments='{}', name='usd_krw', output='1,289.00'), type='function')], type='tool_calls')

위 결과는 역순으로, 먼저 oil_priceusd_krw두 개의 함수를 호출해서 결과를 얻었고, 그 다음 응답 메시지를 생성했다는 것을 알 수 있습니다.

모든 작업이 끝났다면 Thread와 Assistant를 삭제합니다.

response = client.beta.threads.delete(thread.id)
response = client.beta.assistants.delete(assistant.id)

앞에서 살펴본 Retrieval, Code interpreter와 함께 오늘 살펴본 함수 호출 기능을 이용하면 Assistant의 능력을 다양하게 확장할 수 있습니다.

Update된 OpenAI Whisper로 받아쓰기

(이전 버전의 Whisper로 작성했던 글을 새 버전에 맞춰 수정했습니다.)

OpenAI에서 공개한 Whisper는 인공지능 음성 인식 프로그램입니다. Whisper 사용법을 알아봅시다. 실험을 위한 음성 파일이 필요하다면 유튜브 영상에서 음성을 mp3로 추출한 후 사용하면 됩니다.

Youtube에서 mp3 추출하기

!pip install pytube
from pytube import YouTube

video = YouTube(url)
stream = video.streams.filter(only_audio=True).first()
mp3_filename = stream.download(filename="test.mp3")

Whisper models

Whisper는 OpenAI API를 통해 사용할 수도 있고, open source로 공개된 프로그램을 받아서 사용할 수도 있습니다. 2023년 11월 현재 사용 가능한 모델들은 다음 표와 같습니다.

ModelParametersRequired VRAMRelative speed
tiny39 M~1 GB~32x
base74 M~1 GB~16x
small244 M~2 GB~6x
medium769 M~5 GB~2x
large1550 M~10 GB1x
Whisper models

VRAM은 open source 프로그램을 받아서 GPU로 실행할 때 필요한 비디오 메모리 용량을 의미합니다. 영어 전용 모델도 있는데, 여기서는 다국어 모델을 사용하겠습니다. 오류율(character error rates)을 보면 한글의 경우 5% 약간 넘게 틀린다고 하네요.

OpenAI API 이용하기

먼저 API를 이용하는 방법을 보겠습니다. API에서는 large 모델을 제공합니다.

API를 이용하면 사용료가 드는 대신, GPU가 없어도 빠르게 결과를 얻을 수 있습니다. 사용료는 음성 파일 분당 $0.006입니다(2023년 11월). 100분 분량의 음성을 받아 적어도 1000원이 안 됩니다. 직접 받아 적는 노력을 생각하면 저렴하다고 할 수 있겠죠.

입력할 수 있는 최대 파일 크기는 25 MB, 입력 파일의 형식은 mp3, mp4, mpeg, mpga, m4a, wav, webm 형식을 지원합니다. 파일 크기가 크다면 pydub 등을 이용해 잘라서 사용하면 됩니다.

사용법은 다음과 같습니다.

from openai import OpenAI
client = OpenAI(api_key = 'sk-')

def stt(mp3_filename, response_format='text'):
  audio_file = open(mp3_filename, 'rb')
  return client.audio.transcriptions.create(
      model = "whisper-1", 
      file = audio_file, 
      response_format=response_format
      )

text_en = stt(mp3_filename, 'text')

위 코드에서 response_format은 text 외에도 json, verbose_json, srt, vtt 형식을 사용할 수 있습니다. Open source 버전은 사용법이 약간 다릅니다.

Whisper Open Source

Open source 버전도 간단하게 설치해서 사용할 수 있습니다.

! pip install git+https://github.com/openai/whisper.git -q
import whisper
model = whisper.load_model("medium")
result = model.transcribe(mp3_filename)

## results
{'text': "...",
'segments': [
{'id':0, 'start':0.0, 'end':3.0', 'text': "..", ...},
{'id':1, ...}, ...
],
'language': '..'}

결과는 딕셔너리 형식으로, API와 차이가 있습니다. 받아쓰기한 글은 딕셔너리의 text 항목에서 찾을 수 있고, 자동으로 파악한 입력 언어는 language에서 찾을 수 있습니다. segments 리스트에는 시간대별로 나눠진 텍스트가 들어 있습니다. 각 segment의 id와 시작 시간, 마치는 시간 정보도 들어있으니 srt, vtt와 같은 자막 형식이 필요하다면 segments 리스트의 항목들을 이용하면 됩니다.

OpenAI의 공식 Whisper 외에도 CPU에서 빨리 돌아가도록 개선한 whisper.cpp, faster-whisper, 문장이 아닌, 단어 단위 시간을 반환하는 whisperX, whisper-timestamped 같은 모델도 있습니다.

Code Interpreter를 사용하는 OpenAI Assistant

OpenAI Assistant에서 기본으로 제공하는 도구에는 Retrieval 도구와 Code Interpreter가 있습니다. 원하는 도구가 없을 때는 함수 호출 기능을 이용해 도구를 추가해줄 수도 있죠. 여기서는 Code Interpreter를 사용해 데이터를 분석해 보겠습니다.

분석할 데이터

Code Interpreter를 이용해 Titanic 자료를 분석해보겠습니다. 먼저 Titanic 데이터(titanic.csv)를 받습니다. 파일을 Code Interpreter에서 사용할 수 있도록 OpenAI 서버에 올립니다.

from openai import OpenAI
client = OpenAI(api_key="sk-")

my_file = client.files.create(
    file = open("titanic.csv",'rb'),
    purpose='assistants'
)
my_file

## 출력
FileObject(id='file-zS9GLj45Ff9b2daNSQLrVPSt', bytes=60302, created_at=1699803397, filename='titanic.csv', object='file', purpose='assistants', status='processed', status_details=None)

참고로, Code Interpreter에서는 csv 파일 외에도 text, pdf, docx, pptx, xlsx, csv, jpg, png, zip 등 다양한 파일을 읽을 수 있습니다.

Code Interpreter를 사용하는 Assistant

다음으로 Assistant를 생성하며 Code Interpreter를 Tool로 사용할 수 있도록 해줍니다.

assistant = client.beta.assistants.create(
  name="데이터 분석 전문가",
  description="당신은 데이터 분석 전문가입니다.",
  model="gpt-4-1106-preview",
  tools=[{"type": "code_interpreter"}]
)

이제 Assistant에게 내릴 명령을 준비해봅시다. Thread를 생성하며 Message를 이용해 명령을 내리는데, 이 때 앞에서 올린 파일을 메시지에 첨부해줍니다.

thread = client.beta.threads.create(
  messages=[
    {
      "role": "user",
      "content": "titanic.csv 파일에서 생존률에 영향을 미친 주요 요인 세 가지를 찾고 차트로 그려서 설명해주세요.",
      "file_ids": [my_file.id]
    }
  ]
)

명령 실행

명령을 실행할 수 있도록 Run 객체를 만들고 응답을 기다립니다.

import time

def run_and_wait(client, assistant, thread):
  run = client.beta.threads.runs.create(
    thread_id=thread.id,
    assistant_id=assistant.id
  )
  while True:
    run_check = client.beta.threads.runs.retrieve(
      thread_id=thread.id,
      run_id=run.id
    )
    print(run_check.status)
    if run_check.status in ['queued','in_progress']:
      time.sleep(2)
    else:
      break
  return run

run = run_and_wait(client, assistant, thread)

Code Interpreter는 내부적으로 여러 단계를 거쳐 최종 응답을 내놓습니다. 중간 단계들을 확인하고 싶으면 Run Steps를 확인하면 됩니다.

run_steps = client.beta.threads.runs.steps.list(
    thread_id=thread.id,
    run_id=run.id
)
len(run_steps.data) ## 출력: 9

총 9단계를 거쳐 답을 내놓았습니다. 중간 과정은 run_steps 객체 내부에 저장되어 있으니 필요하면 확인할 수 있습니다.

여기서는 Thread에 저장된 메시지들을 가져와 확인해보겠습니다.

thread_messages = client.beta.threads.messages.list(thread.id)
for i,msg in enumerate(thread_messages.data):
  print(len(msg.content))

## 출력
2
2
2
1
1
1

결과를 보면 총 6개의 메시지가 있고, 세 개의 메시지는 content가 한 개, 세 개는 content가 두 개 있습니다. 이 예제에서 content가 2인 경우는 그림을 포함하고 있는 경우입니다. 메시지를 출력해보죠.

Code Interpreter의 응답

결과 확인에 사용한 코드입니다.

from IPython.display import display, Image

for i,msg in enumerate(reversed(thread_messages.data)):
  for c in msg.content:
    if c.type == 'text':
      print(i, c.text.value + '\n')

    if c.type == 'image_file':
      api_response = client.files.with_raw_response.retrieve_content(c.image_file.file_id)
      if api_response.status_code == 200:
        content = api_response.content
        fimg = f'image_{i}.png'
        with open(fimg, 'wb') as f:
          f.write(content)
        img = Image(filename=fimg)
        display(img)

결과는 다음과 같습니다.

0 titanic.csv 파일에서 생존률에 영향을 미친 주요 요인 세 가지를 찾고 차트로 그려서 설명해주세요.

1 먼저 `titanic.csv` 파일을 읽어서 데이터를 살펴본 다음, 타이타닉호의 생존률에 영향을 미칠 수 있는 주요 요소에 대한 가설을 세워볼 것입니다. 생존률에 영향을 줄 것으로 예상되는 요소로는 일반적으로 성별(Sex), 객실 등급(Pclass), 그리고 나이(Age)가 자주 언급됩니다. 이러한 요소들에 대한 데이터를 분석하고 해당 요소들이 생존률에 어떤 영향을 미쳤는지 시각화하여 설명드리겠습니다.

먼저 데이터를 로드하고 초기 분석을 수행해 보겠습니다.

2 데이터셋에는 여러 컬럼들이 있는데, 여기서 생존률에 영향을 미쳤을 것으로 추정되는 주요 요인인 성별(`Sex`), 객실 등급(`Pclass`), 나이(`Age`)를 중점으로 살펴보겠습니다. 생존 여부는 `Survived` 컬럼에 표시되어 있으며, 생존한 경우 1, 그렇지 않은 경우 0의 값을 가지고 있습니다.

세 가지 요인에 대해 생존률을 비교하기 위해 다음과 같은 단계로 진행하겠습니다:

1. `Sex`: 성별에 따른 생존률 비교
2. `Pclass`: 객실 등급에 따른 생존률 비교
3. `Age`: 나이 범주(어린이, 성인, 노인 등)에 따른 생존률 비교

각각의 요인 별로 생존률을 계산하고 시각화하여 차트를 그리겠습니다. 차트는 각 요인에 따른 생존자 수 및 생존률을 보여줄 것입니다.

우선 성별에 따른 생존률을 분석해 보겠습니다.
3 성별에 따른 생존률 차트를 보면 여성의 생존률이 약 74.2%로, 남성의 생존률인 약 18.9%보다 훨씬 높다는 것을 알 수 있습니다. 이는 성별이 타이타닉호 생존률에 큰 영향을 미쳤음을 시사합니다.

그 다음으로는 객실 등급(`Pclass`)에 따른 생존률을 분석해 보겠습니다.
4 객실 등급(`Pclass`)별 생존률 차트에 따르면 1등급 객실 승객의 생존률이 약 62.96%로 가장 높고, 2등급은 약 47.28%, 3등급은 약 24.24%로 나타나고 있습니다. 이는 객실 등급이 생존률에 중요한 요인으로 작용했음을 보여줍니다. 1등급 객실 승객이 생존 가능성이 가장 높았고, 객실 등급이 낮을수록 생존 확률이 감소하는 경향을 볼 수 있습니다.

마지막으로 나이(`Age`) 범주별 생존률을 분석해 보겠습니다. `Age` 데이터에는 결측치가 있을 수 있으므로 이를 처리한 후 나이를 어린이(0-17), 성인(18-65), 노인(65+)의 세 범주로 나누어 생존률을 비교해 보겠습니다.
5 나이 범주별 생존률 차트를 보면 어린이(17세 이하)의 생존률이 약 53.98%로 가장 높고, 성인(18-65세)의 생존률은 약 38.45%, 노인(65세 이상)은 12.5%로 나타나고 있습니다. 나이를 알 수 없는 승객들(Unkown)의 생존률은 약 29.38%입니다. 이는 나이도 생존에 영향을 미친 요인이라 할 수 있으며, 특히 어린이의 생존률이 높게 나타나고 있습니다.

이상의 분석 결과를 종합해보면 타이타닉호에서의 생존률에 가장 큰 영향을 미친 요인 세 가지는 성별(`Sex`), 객실 등급(`Pclass`), 그리고 나이(`Age`)라는 결론이 도출됩니다.

- **성별**: 여성의 생존률이 남성에 비해 훨씬 높으며, 이는 당시 사회적인 구조와 구명 보트에 대한 여성과 어린이 우선 정책의 영향일 것으로 보입니다.
- **객실 등급**: 1등급 객실 승객의 생존률이 가장 높고, 객실 등급이 낮아질수록 생존률이 감소하는 경향을 보여줍니다. 구명 보트에 접근하는 데 있어서 1등급 승객이 더 우선적이었기 때문일 수 있습니다.
- **나이**: 어린이의 생존률이 가장 높고, 성인과 노인 순으로 낮아짐을 보여줍니다. 이는 어린이를 보호하려는 경향과 구명 보트 배정에 어린이를 우선시한 결과로 볼 수 있습니다.

각 요인의 차트는 상기 데이터와 결론을 시각적으로 보여주며, 이러한 요인들이 생존률에 영향을 미친 방식을 직관적으로 이해하는 데 도움을 줍니다.

저는 데이터에서 생존률에 영향을 미친 주요 요인 세 가지를 찾으라고 했는데, Code Interpreter는 이미 알고 있는 정보를 바탕으로 분석 결과를 보여줬네요. 데이터 분석은 잘 해서 보여줬습니다.

Assistant, Thread, File은 더 이상 사용하지 않으면 삭제해줍니다.

response = client.beta.threads.delete(thread.id)

response = client.beta.assistants.delete(assistant.id)

response = client.files.delete(my_file.id)

Code Interpreter 사용료

Code Interpreter는 세션당 $0.03 씩 과금합니다. 세션은 한 번 시작하면 한 시간동안 유효하고, 질문 횟수와는 무관합니다. 물론, 질문과 응답에 따르는 토큰 사용료는 별도입니다.

OpenAI Chat Completions: JSON 모드

OpenAI의 새로 업데이트 된 API에서 JSON 출력을 지원합니다.

이전에도 프롬프트 엔지니어링을 통해 GPT 모델에서 출력을 JSON 형식으로 하도록 만들 수는 있었지만, 항상 JSON 형식으로 출력하는 것이 보장된 것은 아니었죠. 새로운 API에서는 JSON 모드를 사용하면 항상 JSON 형식으로 출력해줍니다.

JSON 모드를 사용하기 위해서는 두 가지 조건을 만족해야 합니다.

  1. API 호출시 response_format = {"type": "json_object"}을 지정해줍니다.
  2. 프롬프트에서 JSON으로 출력하라고 합니다.

예시 코드를 보겠습니다.

response = client.chat.completions.create(
  model="gpt-4-1106-preview",
  messages=[
    {"role": "system", "content": "You are a helpful assistant."},
    {"role": "user", "content": "LangChain은 무엇을 하는 라이브러리지? JSON으로 답해줘"}
  ],
  response_format = {'type':"json_object"}
)
print(response.choices[0].message.content)

## 출력
{
  "LangChain": {
    "Description": "LangChain is a library that provides a framework for building applications and systems that interact with language models. It facilitates the integration of different components necessary for creating robust language understanding and generation tasks, such as conversation agents, AI-powered assistants, and more.",
    "Purpose": "Facilitate the building and deployment of language-centric applications using state-of-the-art language models.",
    "Key Features": [
      "Modular design to plug in various language models",
      "High-level abstractions for language skills",
      "Support for multiple use cases including chatbots, data extraction, and content generation",
      "Tools for conversation context management and multi-turn dialogue handling"
    ],
    "Use Cases": [
      "Creating conversational agents",
      "Developing AI writing assistants",
      "Building systems for automated summarization",
      "Generating and manipulating natural language content"
    ]
  }
}

LangChain은 2022년 10월에 공개된 거대 언어 모델 라이브러리입니다. 기존 GPT 모델은 2021년 9월까지의 정보만 가지고 있었지만, 새로운 모델은 2023년 4월까지의 정보로 훈련시켰다고 합니다.

위 결과를 보면 JSON 형식으로 출력하는데, 키 값은 알아서 지정한 것을 볼 수 있습니다. 키를 지정하고 싶다면 다음과 같이 프롬프트에서 정해주면 됩니다.

response = client.chat.completions.create(
  model="gpt-4-1106-preview",
  messages=[
    {"role": "system", "content": "You are a helpful assistant."},
    {"role": "user", "content": "LangChain은 무엇을 하는 라이브러리지? JSON으로 답해줘. JSON 키워드는 Description, Features, Usage로 해줘."}
  ],
  response_format = {'type':"json_object"}
)

## 결과

{
  "Description": "LangChain은 자연어 처리(NLP) 작업을 위해 디자인된 파이썬 라이브러리입니다. 이 라이브러리는 NLP 모델을 쉽게 통합하고 사용할 수 있도록 다양한 기능과 도구를 제공하여 개발자들이 복잡한 NLP 관련 프로젝트를 빠르고 효율적으로 구현할 수 있게 돕습니다.",
  "Features": [
    "다양한 NLP 작업을 위한 사전 구축된 모듈과 인터페이스 지원",
    "간편한 사용자 정의 모델 통합 및 확장성",
    "다양한 NLP 프레임워크와 라이브러리(예: Hugging Face의 Transformers, spaCy 등)와의 호환성",
    "엔드-투-엔드 NLP 애플리케이션 구축을 위한 편리한 어플리케이션 프로그래밍 인터페이스(APIs)"
  ],
  "Usage": [
    "텍스트 분류, 개체명 인식, 기계 번역 등 다양한 NLP 태스크를 손쉽게 수행",
    "챗봇, 음성 인식 시스템, 감성 분석 도구 등의 NLP 기반 애플리케이션 개발",
    "구문 분석, 토픽 모델링, 문서 요약 등 고급 텍스트 분석 기능 구현",
    "NLP 모델들을 실험하고 최적화하기 위한 실험적 환경 제공"
  ]
}

프롬프트에서 지정한 키를 사용해 JSON 출력을 만들어준 것을 볼 수 있습니다. Text가 아닌, JSON 으로 출력하게 되면 결과를 프로그래밍 언어에서 가져다 쓰기 좋기 때문에 프로그래머들에게 편리한 기능이라 할 수 있습니다.

OpenAI Assistant를 이용한 Retrieval

앞에서 LangChain을 이용한 Retrieval Augmented Generation을 살펴봤습니다. OpenAI의 새로운 Assistant API를 이용하면 좀 더 쉽게 Retrieval을 구현할 수 있습니다.

구현 순서는 다음과 같습니다.

  1. Retrieval을 수행할 파일을 OpenAI 서버에 올리고 파일 ID를 저장합니다.
  2. Assistant를 생성합니다.
  3. Thread를 생성합니다.
  4. 파일을 포함하는 Message를 생성합니다. Message에는 파일의 내용과 관련된 질문을 넣습니다.
  5. Run 객체를 만들어 실행하면 파일에서 찾은 내용을 바탕으로 한 응답을 얻을 수 있습니다.

이 때 파일은 Message에 포함시킬 수도 있지만, Assistant에 포함시킬 수도 있습니다. Message는 Thread 생성 후에 만들 수도 있고, Thread를 생성하면서 동시에 만들 수도 있습니다. 최종 응답에는 파일의 어떤 내용을 참조했는지에 대한 정보도 포함되어 있습니다. 코드를 보겠습니다.

OpenAI 라이브러리

import openai
openai.__version__
# 결과: '1.2.3'

from openai import OpenAI
client = OpenAI(api_key="sk-")

파일 올리기

import requests

url="https://www.gutenberg.org/cache/epub/4/pg4.txt"
r = requests.get(url)

with open("GettysburgAddress.txt",'w') as fo:
  fo.write(r.text)

my_file = client.files.create(
    file = open("GettysburgAddress.txt",'rb'),
    purpose='assistants'
)
my_file

## 결과
FileObject(id='file-UdQTPO55auQ5CEEW562Ji9er', bytes=22030, created_at=1699803306, filename='GettysburgAddress.txt', object='file', purpose='assistants', status='processed', status_details=None)

구텐베르크 프로젝트 페이지에서 링컨의 게티스버그 연설을 받아서 파일로 저장하고 OpenAI 서버에 올렸습니다. 뒤에서 이 연설 내용에 관한 질문을 던지려고 합니다. 이 파일에는 게티스버그 연설문 앞 뒤로 프로젝트 소개, 안내와 같은 내용들도 들어 있습니다.

위 결과를 보면 파일 객체에 id가 있습니다. 파일 뿐 아니라 Assistant, Thread, Message, Run 객체 모두 id를 가지고 있씁니다. 앞으로 이 id를 이용해 각각의 객체들을 참조하게 됩니다.

Assistant, Thread, Message 생성

Retrieval을 위해서는 Assistant 생성시 tools에 retrieval 도구를 추가해줍니다.

assistant = client.beta.assistants.create(
  name="연설전문가",
  description="당신은 연설 전문가입니다.",
  model="gpt-4-1106-preview",
  tools=[{"type": "retrieval"}],
  #file_ids=[my_file.id]
)
assistant

## 결과
Assistant(id='asst_UojnLxCEe5wfEL0IZrHW43vu', created_at=1699803311, description='당신은 연설 전문가입니다.', file_ids=[], instructions=None, metadata={}, model='gpt-4-1106-preview', name='연설전문가', object='assistant', tools=[ToolRetrieval(type='retrieval')])

Assistant에 파일을 추가해주고 싶을 경우 위의 주석 처리한 file_ids 줄처럼 추가하면 됩니다.

이번엔 Thread를 생성하면서 Message도 추가해줬습니다. Message에 file_ids를 이용해 앞에서 올린 파일을 attach 했습니다.

thread = client.beta.threads.create(
  messages=[
    {
      "role": "user",
      "content": "링컨의 게티스버스 연설 내용을 한 문장으로 요약해주세요.",
      "file_ids": [my_file.id]
    }
  ]
)
thread

## 결과
Thread(id='thread_U4msr0xRagC356A7ZmYJB6sJ', created_at=1699803314, metadata={}, object='thread')

Run

Assistant와 Thread를 만든 후 Run을 실행해 응답을 받습니다. API를 이용해 Run 객체를 생성하면 LLM 모델이 응답을 생성할 때까지 기다리지 않고 바로 Run 객체를 반환하기 때문에 status를 확인해서 응답이 나올 때가지 기다립니다.

import time

def run_and_wait(client, assistant, thread):
  run = client.beta.threads.runs.create(
    thread_id=thread.id,
    assistant_id=assistant.id
  )
  while True:
    run_check = client.beta.threads.runs.retrieve(
      thread_id=thread.id,
      run_id=run.id
    )
    print(run_check.status)
    if run_check.status in ['queued','in_progress']:
      time.sleep(2)
    else:
      break
  return run

run_and_wait(client, assistant, thread)

## 결과
Run(id='run_FGObi8liSR7RhfodFQBFYFQ8', assistant_id='asst_UojnLxCEe5wfEL0IZrHW43vu', cancelled_at=None, completed_at=None, created_at=1699803317, expires_at=1699803917, failed_at=None, file_ids=[], instructions=None, last_error=None, metadata={}, model='gpt-4-1106-preview', object='thread.run', required_action=None, started_at=None, status='queued', thread_id='thread_U4msr0xRagC356A7ZmYJB6sJ', tools=[ToolAssistantToolsRetrieval(type='retrieval')])

결과 보기

응답은 Thread의 Message를 추출해 확인할 수 있습니다.

thread_messages = client.beta.threads.messages.list(thread.id)
for msg in thread_messages.data:
  print(f"{msg.role}: {msg.content[0].text.value}")

## 결과
assistant: 에이브러햄 링컨의 게티스버그 연설은 미국의 남북전쟁 중에 "전사들의 희생을 기리며, 자유를 위한 그들의 헌신이 헛되지 않았음을 다짐하고, '민주주의 정부가 이 땅에서 사라지지 않을 것'이라는 메시지를 전달한 것입니다【9†source】.
user: 링컨의 게티스버스 연설 내용을 한 문장으로 요약해주세요.

위의 결과를 보면 【9†source】 이런 참조 기호가 있습니다. 이건 Retrieval 도구에서 파일을 참조했다는 표시입니다. 파일의 어떤 내용을 참조했는지는 응답의 annotations에서 찾을 수 있습니다.

File Citation

응답에서 Annotations 부분에 어떤 내용이 있나 봅시다.

thread_messages.data[0].content[0].text.annotations

## 결과

[TextAnnotationFileCitation(end_index=130, file_citation=TextAnnotationFileCitationFileCitation(file_id='file-UdQTPO55auQ5CEEW562Ji9er', quote='Four score and seven years ago, our fathers brought forth\r\nupon this continent a new nation:  conceived in liberty, and\r\ndedicated to the proposition that all men are created equal.\r\n\r\nNow we are engaged in a great civil war. . .testing whether\r\nthat nation, or any nation so conceived and so dedicated. . .\r\ncan long endure.  We are met on a great battlefield of that war.\r\n\r\nWe have come to dedicate a portion of that field as a final resting place\r\nfor those who here gave their lives that this nation might live.\r\nIt is altogether fitting and proper that we should do this.\r\n\r\nBut, in a larger sense, we cannot dedicate. . .we cannot consecrate. . .\r\nwe cannot hallow this ground.  The brave men, living and dead,\r\nwho struggled here have consecrated it, far above our poor power\r\nto add or detract.  The world will little note, nor long remember,\r\nwhat we say here, but it can never forget what they did here.\r\n\r\nIt is for us the living, rather, to be dedicated here to the unfinished\r\nwork which they who fought here have thus far so nobly advanced.\r\nIt is rather for us to be here dedicated to the great task remaining\r\nbefore us. . .that from these honored dead we take increased devotion\r\nto that cause for which they gave the last full measure of devotion. . .\r\nthat we here highly resolve that these dead shall not have died in vain. . .\r\nthat this nation, under God, shall have a new birth of freedom. . .\r\nand that government of the people. . .by the people. . .for the people. . .\r\nshall not perish from this earth'), start_index=120, text='【9†source】', type='file_citation')]

위 내용을 보면 리스트 안에 TextAnnotationFileCitation 객체가 있고, text 항목에 인용 기호, quote에 인용한 내용이 있습니다. 인용 내용을 출력해봅시다.

print(thread_messages.data[0].content[0].text.annotations[0].file_citation.quote)

## 결과

Four score and seven years ago, our fathers brought forth
upon this continent a new nation:  conceived in liberty, and
dedicated to the proposition that all men are created equal.

Now we are engaged in a great civil war. . .testing whether
that nation, or any nation so conceived and so dedicated. . .
can long endure.  We are met on a great battlefield of that war.

We have come to dedicate a portion of that field as a final resting place
for those who here gave their lives that this nation might live.
It is altogether fitting and proper that we should do this.

But, in a larger sense, we cannot dedicate. . .we cannot consecrate. . .
we cannot hallow this ground.  The brave men, living and dead,
who struggled here have consecrated it, far above our poor power
to add or detract.  The world will little note, nor long remember,
what we say here, but it can never forget what they did here.

It is for us the living, rather, to be dedicated here to the unfinished
work which they who fought here have thus far so nobly advanced.
It is rather for us to be here dedicated to the great task remaining
before us. . .that from these honored dead we take increased devotion
to that cause for which they gave the last full measure of devotion. . .
that we here highly resolve that these dead shall not have died in vain. . .
that this nation, under God, shall have a new birth of freedom. . .
and that government of the people. . .by the people. . .for the people. . .
shall not perish from this earth

전체 문서 중에서 링컨 대통령의 연설 부분만 잘 추출해서 인용했습니다. 문서 생성시 LLM의 응답 메시지에서 위 text에 지정된 인용 기호 부분을 각주/미주 기호로 바꾸고 각주/미주 부분에 quote의 인용문을 넣으면 각주/미주를 만들 수 있습니다.

OpenAI RAG vs LangChain RAG

LangChain으로 RAG를 수행했을 때와 다른 부분을 생각해봅시다.

  • 문서를 벡터 데이터베이스에 넣고 검색하는 부분이 없습니다. OpenAI에서 알아서 짧은 문서는 텍스트 검색을 이용해 인용해주고, 긴 문서는 임베딩 벡터를 만들어 벡터 데이터베이스에 넣었다가 벡터 검색을 이용해 찾아서 인용해줍니다. 프로그래밍이 편해진 대신 임베딩 방법, 벡터 데이터베이스 종류와 검색 방법은 선택할 수 없습니다.
  • 길이 제한에 맞춰 이전 대화와 인용문을 프롬프트에 넣는 부분이 사라졌습니다. OpenAI에서 알아서 프롬프트 길이 제한에 맞춰줍니다. 역시 프로그래밍이 편해진 대신 이전 대화 내용과 인용문을 어떻게, 얼마나 줄여서 프롬프트에 넣을 것인지는 선택할 수 없습니다.

OpenAI Assistants API로 대화 내용 저장하기

OpenAI에서 11월 6일 새로운 API들을 발표했는데, 그 중 Assistants API가 있었습니다.

Assistants API의 Thread를 이용하면 LangChain에서 Memory를 이용해 대화 내용을 저장했던 것처럼 대화를 저장할 수 있습니다.

Assistants API의 Tools를 이용하면 LangChain Agent처럼 도구를 사용할 수 있습니다. 이전에도 함수 호출 기능이 있었지만, 이번 업데이트를 통해 Code Interpreter와 Retrieval 도구도 사용할 수 있게 되었습니다.

이 글에서는 Threads를 이용한 대화 저장 방법을 살펴보겠습니다.

Assistant, Thread, Message, and Run

먼저 API에서 사용하는 기본 용어들을 정리하겠습니다.

  • Assistant: 도구들을 사용할 수 있는 거대 언어 모델(GPT 모델들)입니다.
  • Thread: Message들을 저장하는 대화입니다.
  • Message: 대화에서 주고 받는 메시지들로, 텍스트 외에도 이미지나 파일을 포함할 수 있습니다.
  • Run: Assistant가 Thread에 저장된 Message들을 읽고 LLM의 응답 Message를 추가하는 과정입니다.
  • Run Step: Run을 실행할 때 단순히 LLM의 응답을 추가하는 경우도 있지만, 외부 도구들(Tools)을 사용한 후 결과를 이용해 응답하는 경우도 있습니다. 어떤 세부 단계를 거쳐 응답했는지 확인하고 싶을 때 Run Step을 이용합니다.

Run 상태

LLM에 질문을 던지면 응답을 받는데 시간이 걸립니다. 그래서 Run을 실행한다고 바로 Thread에 응답이 추가되지 않습니다. Run을 실행하면 queued → in_progress → completed 과정을 거쳐 응답이 생성됩니다. 만약 사용자가 제공한 Tool을 사용할 경우에는 사용자가 실행 결과를 다시 LLM에 제공해야 합니다. 이 때 결과를 기다리는 동안 Run은 requires_action 상태가 됩니다. 정리해보면,

  • 기본 응답: queued → in_progress → completed
  • Function calling 사용시: queued → in_progress → requires_action → (함수 호출 결과 입력 후 다시 Run) → queued → in_progress → completed
  • 응답 시간이 오래 걸리면: queued → in_progress → expired
  • 응답 실패시: queued → in_progress → failed
  • 사용자가 취소할 경우: queued → in_progress → (사용자가 취소) → cancelling → cancelled

따라서, Run 상태가 queued나 in_progress라면 응답을 얻기 위해 기다려야 합니다. 코드로 확인해보겠습니다.

Assistant 만들기

from openai import OpenAI
client = OpenAI(api_key="sk-")

assistant = client.beta.assistants.create(
  name="똑똑한 비서",
  description="당신은 똑똑한 비서입니다.",
  model="gpt-4-1106-preview"
)
assistant

## 출력
Assistant(id='asst_...', created_at=1699421567, description='당신은 똑똑한 비서입니다.', file_ids=[], instructions=None, metadata={}, model='gpt-4-1106-preview', name='똑똑한 비서', object='assistant', tools=[])

모델 이름이 “gpt-4-1106-preview”입니다. 아마 얼마 안 지나면 “gpt-4-turbo”로 바뀔 것입니다. API 호출 부분을 보면 beta가 들어가 있는데 이것도 얼마 안 있어 바뀌겠죠.

이렇게 생성한 assistant는 OpenAI의 서버에 저장되고, 고유한 id를 가지고 있습니다. 우리는 이 assistant를 id를 이용해 참조하게 됩니다.

Thread 만들기

thread = client.beta.threads.create(
  messages=[
    {
      "role": "user",
      "content": "링컨의 게티스버스 연설 내용을 한 문장으로 요약해주세요."
    }
  ]
)
thread

## 출력
Thread(id='thread_...', created_at=1699421567, metadata={}, object='thread')

Thread를 만들면서 Message도 넣었는데, Message는 나중에 따로 추가할 수도 있습니다. Assistant와 Thread를 만들었으니 이제 Run을 실행할 수 있습니다.

Run, 상태 확인

run = client.beta.threads.runs.create(
  thread_id=thread.id,
  assistant_id=assistant.id,
  #model="gpt-4-1106-preview",
  #instructions="additional instructions",
  #tools=[{"type": "code_interpreter"}, {"type": "retrieval"}]
)
run

## 출력
Run(id='run_...', assistant_id='asst_...', cancelled_at=None, completed_at=None, created_at=1699421567, expires_at=1699422167, failed_at=None, file_ids=[], instructions=None, last_error=None, metadata={}, model='gpt-4-1106-preview', object='thread.run', required_action=None, started_at=None, status='queued', thread_id='thread_...', tools=[])

Run을 생성하면서 Thread의 id와 Assistant의 id를 입력합니다. 필요할 경우 Assistant의 model, instruction, tools를 덮어쓸 수도 있습니다. 이렇게 Run을 생성하고 바로 내용을 확인해보면 status='queued'라고 나오는 것을 볼 수 있습니다. 바로 Thread를 확인해보면 응답이 아직 추가되어 있지 않으므로 Run 상태가 바뀌는 것을 확인한 후에 Thread의 Message를 확인해야 합니다.

import time

while True:
  run_check = client.beta.threads.runs.retrieve(
    thread_id=thread.id,
    run_id=run.id
  )
  if run_check.status in ['queued','in_progress']:
    time.sleep(2)
  else:
    break

Run 상태가 바뀌면 위 루프가 끝납니다. 이후에 Thread의 Message를 확인합니다.

Thread에 추가된 응답 Message 확인

thread_messages = client.beta.threads.messages.list(thread.id)
for msg in thread_messages.data:
  print(f"{msg.role}: {msg.content[0].text.value}")

## 출력
assistant: 링컨의 게티스버스 연설은 민주주의의 이상을 재확인하고, .....
user: 링컨의 게티스버스 연설 내용을 한 문장으로 요약해주세요.

이렇게 Assistant의 응답이 추가된 것을 확인할 수 있습니다. 이전의 대화 내용은 빼고 마지막 응답만 확인하고 싶다면 위 코드에서 list 함수 호출시 limit=1 옵션을 주면 됩니다.

새로운 메시지 추가

대화를 계속해 나가려면 Thread에 새로운 사용자 Message를 추가해주고 Run을 실행해서 Assistant Message를 확인하는 과정을 반복하면 됩니다. Thread에 새로운 Message를 추가하려면 Message를 생성하며 Thread id를 입력하면 됩니다.

new_message = client.beta.threads.messages.create(
  thread_id = thread.id,
  role="user",
  content="링컨은 어떤 사람이지?",
)
new_message

## 출력
ThreadMessage(id='msg_...', assistant_id=None, content=[MessageContentText(text=Text(annotations=[], value='링컨은 어떤 사람이지?'), type='text')], created_at=1699421965, file_ids=[], metadata={}, object='thread.message', role='user', run_id=None, thread_id='thread_...')

Run 반복

사용자 Message를 추가했으니 다시 Run을 실행해서 Assistant의 응답을 Thread에 추가합니다.

run = client.beta.threads.runs.create(
  thread_id=thread.id,
  assistant_id=assistant.id
)
while True:
  run_check = client.beta.threads.runs.retrieve(
    thread_id=thread.id,
    run_id=run.id
  )
  run_check
  if run_check.status not in ['queued','in_progress']:
    break
  else:
    time.sleep(2)

thread_messages = client.beta.threads.messages.list(thread.id)
for msg in thread_messages.data:
  print(f"{msg.role}: {msg.content[0].text.value}")

## 출력
assistant: 에이브러햄 링컨(Abraham Lincoln, 1809-1865)은 ...
user: 링컨은 어떤 사람이지?
assistant: 링컨의 게티스버스 연설은 ...
user: 링컨의 게티스버스 연설 내용을 한 문장으로 요약해주세요.

참고로, 이렇게 생성한 Assistant는 OpenAI Platform에서도 확인할 수 있습니다.

Assistant와 Thread 삭제

Assistant와 Thread는 OpenAI 서버에 저장되어 있는데, 더이상 필요가 없으면 삭제해줍니다.

# delete thread
response = client.beta.threads.delete(thread.id)
print(response)

# delete assistant
response = client.beta.assistants.delete(assistant.id)
print(response)

이 외에도 다양한 함수들이 있는데 API Reference를 확인하면 되겠습니다.

LangChain과 비교

LangChain에서는 Memory를 이용해 대화 내용을 저장했는데, 토큰 제한을 고려하여 최근 대화 내용만 남기거나 이전 대화 내용을 요약하는 등의 방식으로 분량을 줄였습니다. OpenAI API에서는 Thread를 통해 OpenAI 서버에 대화 내용을 저장해주고 토큰 제한에 맞추는 과정도 알아서 진행해주기 때문에 대화 진행이 간편해졌습니다.

대신 대화 내용 저장시 프로그래머가 컨트롤할 수 있는 부분은 별로 없습니다. 현재는 대화 내용을 최대한 보존하는 방식(토큰 소모가 많은 방식)인데, 앞으로 다른 방식을 사용할 수 있도록 옵션을 추가할 계획이라고 합니다.

OpenAI 음성 합성 (Text to Speech) API 사용하기

OpenAI API에 음성 합성 (Text to Speech, TTS) 기능이 새로 추가되었습니다. 기존에는 음성과 관련하여 음성 인식 (Speech to Text) 기능만 있었는데 API가 점점 다양해지고 있습니다.

TTS 모델

사용할 수 있는 모델은 다음과 같습니다.

ModelDescriptionPricing
tts-1속도가 빠름 (실시간용)$0.015 / 1K characters
tts-1-hd고품질$0.030 / 1K characters
Audio models

TTS 목소리

사용할 수 있는 목소리는 6 종류 입니다. OpenAI 사이트에 가보면 영어로 말하는 음성을 들어볼 수 있습니다. 한국말 하는 음성을 들어볼까요?

Alloy

Echo

Fable

Onyx

Nova

Shimmer

약간 어색한 부분도 있긴 하지만, 무료 TTS에 비하면 확실히 좋네요.

TTS API

코드를 봅시다.

import os
from openai import OpenAI

client = OpenAI(api_key=os.environ['OPENAI_API_KEY'])

response = client.audio.speech.create(
  model = "tts-1",
  voice = "alloy",
  input = text,
  speed = 1,
  response_format = 'mp3'
)

response.stream_to_file(mp3_file_path)

response_format은 mp3가 기본이고, opus, aac, flac 형식도 지원합니다. speed는 1이 기본이고 0.25 ~ 4.0 범위의 값을 사용할 수 있습니다.

혹시 실시간 음성 출력이 필요할 경우 OpenAI 사이트의 도움말(streaming real time audio)을 참고하면 되겠습니다.

OpenAI Whisper로 받아쓰기

(아래 글은 이전 버전의 API와 Whisper를 이용해 작성한 글입니다. 새로운 OpenAI API와 Whisper v3를 이용한 글은 여기에 있습니다.)

OpenAI에서 공개한 Whisper는 인공지능 음성 인식 프로그램입니다. Whisper 사용법을 알아봅시다. 실험을 위한 음성 파일이 필요하다면 유튜브 영상에서 음성을 mp3로 추출한 후 사용하면 됩니다.

Youtube에서 mp3 추출하기

!pip install pytube
from pytube import YouTube

video = YouTube(url)
stream = video.streams.filter(only_audio=True).first()
mp3_filename = stream.download(filename="test.mp3")

Whisper models

Whisper는 OpenAI API를 통해 사용할 수도 있고, open source로 공개된 프로그램을 받아서 사용할 수도 있습니다. 2023년 11월 현재 사용 가능한 모델들은 다음 표와 같습니다.

ModelParametersRequired VRAMRelative speed
tiny39 M~1 GB~32x
base74 M~1 GB~16x
small244 M~2 GB~6x
medium769 M~5 GB~2x
large1550 M~10 GB1x
Whisper models

VRAM은 open source 프로그램을 받아서 GPU로 실행할 때 필요한 비디오 메모리 용량을 의미합니다. 영어 전용 모델도 있는데, 여기서는 다국어 모델을 사용하겠습니다. 오류율(Word Error Rate)을 보면, 영어의 경우 4.2% 정도를 틀리게 받아쓰고, 한글의 경우 14.3% 정도를 틀린다고 하네요.

OpenAI API 이용하기

먼저 API를 이용하는 방법을 보겠습니다. API에서는 large 모델을 제공합니다.

API를 이용하면 사용료가 드는 대신, GPU가 없어도 빠르게 결과를 얻을 수 있습니다. 사용료는 음성 파일 분당 $0.006입니다(2023년 11월). 100분 분량의 음성을 받아 적어도 1000원이 안 됩니다. 직접 받아 적는 노력을 생각하면 저렴하다고 할 수 있겠죠.

입력할 수 있는 최대 파일 크기는 25 MB, 입력 파일의 형식은 mp3, mp4, mpeg, mpga, m4a, wav, webm 형식을 지원합니다. 파일 크기가 크다면 pydub 등을 이용해 잘라서 사용하면 됩니다.

사용법은 다음과 같습니다.

import openai
openai.api_key = 'sk-'

def stt(mp3_filename, response_format='text'):
  audio_file = open(mp3_filename, 'rb')
  return openai.Audio.transcribe("whisper-1", audio_file, response_format=response_format)

text_en = stt(mp3_filename, 'text')

위 코드에서 response_format은 text 외에도 json, verbose_json, srt, vtt 형식을 사용할 수 있습니다. Open source 버전은 사용법이 약간 다릅니다.

Whisper Open Source

Open source 버전도 간단하게 설치해서 사용할 수 있습니다.

! pip install git+https://github.com/openai/whisper.git -q
import whisper
model = whisper.load_model("medium")
result = model.transcribe(mp3_filename)

## results
{'text': "...",
'segments': [
{'id':0, 'start':0.0, 'end':3.0', 'text': "..", ...},
{'id':1, ...}, ...
],
'language': '..'}

결과는 딕셔너리 형식으로, API와 차이가 있습니다. 받아쓰기한 글은 딕셔너리의 text 항목에서 찾을 수 있고, 자동으로 파악한 입력 언어는 language에서 찾을 수 있습니다. segments 리스트에는 시간대별로 나눠진 텍스트가 들어 있습니다. 각 segment의 id와 시작 시간, 마치는 시간 정보도 들어있으니 srt, vtt와 같은 자막 형식이 필요하다면 segments 리스트의 항목들을 이용하면 됩니다.

OpenAI의 공식 Whisper 외에도 CPU에서 빨리 돌아가도록 개선한 whisper.cpp, faster-whisper, 문장이 아닌, 단어 단위 시간을 반환하는 whisperX, whisper-timestamped 같은 모델도 있습니다.

LangChain: Tool들을 사용하는 Agent

인공지능 Agent는 사용자의 요청을 받은 후 어떤 기능을 어떤 순서로 실행할지 결정하는 역할을 합니다. Agent의 주요 특징들은 다음과 같습니다.

  • 목표(정책) 기반 행동: 주어진 목표를 달성하기 위해 행동합니다.
  • 자율성: 목표가 주어지면 자동으로 작동합니다.
  • 감지: 주변 환경에서 정보를 수집합니다.

Tools는 Agent가 할 수 있는 일에 해당합니다. 도구, 기술, 함수들이죠.

Agent의 작업 처리 순서도

Agent의 처리 흐름은 다음과 같습니다.

  1. Input: 사용자가 Agent에게 작업을 할당합니다.
  2. Thought: Agent가 작업을 완수하기 위해 무엇을 할지 생각합니다.
  3. Action/Action Input: 사용할 도구를 결정하고, 도구의 입력(함수의 입력값)을 결정합니다.
  4. Observation: 도구의 출력 결과를 관찰합니다.
  5. 관찰 결과 작업을 완료(Finish)했다는 판단에 도달할 때까지 2~4번 과정을 반복합니다.

위의 과정이 Agent의 작업 수행 과정인데, LangChain에서는 이 과정을 처리하는 객체를 Agent Executor라고 합니다.

LangChain Agent의 종류

LangChain에는 여러 종류의 Agent들이 있습니다. 몇 가지를 살펴보겠습니다.

  • Zero-shot ReAct: 작업과 도구 설명을 보고 사용할 도구를 결정합니다. ReAct는 Reasoning + Acting으로 Yao et al. (2022)의 논문에 소개된 개념입니다. Reasoning은 생각(Chain-of-Thought, COT), Acting은 외부 도구 실행을 의미합니다.
  • Structured input ReAct: input이 여러 개인 도구를 사용할 때 필요한 Agent입니다.
  • Conversational: 대화 + ReAct로 대화 저장을 위한 메모리가 필요합니다.
  • Self-ask with search: 인터넷 검색 후 답변하는 Agent로 검색 도구가 필요합니다.
  • ReAct document store: 문서 저장소 + ReAct로 문서 검색 도구가 필요합니다.

필요한 작업에 따라 정하면 되는데, Zero-shot ReAct가 가장 기본이 되는 Agent입니다.

LangChain Tools

LangChain Tools는 Agent가 수행하는 특정 기능으로, 외부 환경에 영향을 끼쳐 Agent가 새로운 정보를 관찰할 수 있도록 합니다. 도구를 추가할 때마다 LLM에 새로운 능력을 부여하는 셈이죠. 영화 매트릭스에서 트리니티가 순식간에 헬리콥터 조종 기술을 다운받는 것처럼 LLM에게도 순식간에 새로운 능력을 가르칠 수 있습니다. LangChain에는 미리 준비된 다양한 도구들이 존재하고, 필요하면 직접 도구를 만들어 쓸 수도 있습니다. LangChain의 기본 도구들을 몇 가지 살펴보겠습니다.

  • 일반적인 검색 도구: Google, Bing, DuckDuckGo
  • 특수 목적 검색 도구: Wolfram Alpha, Wikipedia, Youtube, Weather, News
  • 수학 계산: Math
  • 인터넷 RestAPI: Requests
  • 컴퓨터: Shell, Python REPL

이 외에도 여러 가지 도구들이 계속 추가되고 있습니다. 도구들 중에는 별도의 API 키가 필요한 도구도 있고, 무료로 사용할 수 있는 도구들도 있습니다. API 키가 필요한 도구들은 사용량에 따라 해당 서비스 제공자에게 사용료를 지불해야 할 수도 있습니다. 또한 도구들 중에는 실행을 위해 LLM이 필요한 도구도 있고 필요 없는 도구도 있습니다. LLM이 필요한 도구는 사용시 LLM을 지정해줘야 합니다.

예제: Math, Wikipedia

예제 코드를 보겠습니다.

!pip install langchain openai wikipedia

from langchain.chat_models import ChatOpenAI
from langchain.agents import load_tools, initialize_agent

llm = ChatOpenAI(temperature=0)
tools = load_tools(["llm-math","wikipedia"], llm=llm)
# llm-math 도구는 llm이 필요합니다.

agent= initialize_agent(
    tools,
    llm,
    agent='zero-shot-react-description',
    handle_parsing_errors=True,
    verbose = True)

# 수학 문제
agent("500의 20.5%는 몇인가요?")
# 결과: {'input': '500의 20.5%는 몇인가요?', 'output': '102.5'}

# wikipedia 검색 문제
question = "광안대교 길이는 몇 km인가요?"
result = agent(question)
# 결과: {'input': '광안대교 길이는 몇 km인가요?', 'output': 'The length of the Gwangan Bridge is 7,420 meters.'}

LangChain 내부 프롬프트들이 영어로 되어 있어서 응답 결과는 기본적으로 영어로 나옵니다. Wikipedia 도구는 Wikipedia에서 검색한 페이지의 내용을 모두 가져오는게 아니라 요약 부분만 가져오기 때문에 요약 부분에 원하는 내용이 없으면 질문에 대한 답을 못 찾을 수도 있습니다. 그럴땐 Python wikipedia 라이브러리를 이용해 페이지 전체를 받아서 답을 찾는 사용자 정의 도구를 만들거나 다른 검색 도구를 사용할 수 있습니다. 다음 예제에서 DuckDuckGo 검색엔진을 사용해봅시다.

예제: DuckDuckGo Search

! pip install duckduckgo-search

tools = load_tools(["ddg-search"])
agent= initialize_agent(
    tools,
    llm,
    agent='zero-shot-react-description',
    handle_parsing_errors=True,
    verbose = True)

result = agent(question)
# 결과: {'input': '광안대교 길이는 몇 km인가요?', 'output': 'The length of the Gwangan Bridge is 7.4 kilometers.'}

Google이나 Bing은 API Key가 필요하지만, DuckDuckGo는 API Key 없이 사용할 수 있는 검색엔진입니다.

예제: Youtube

이번엔 인터넷 검색에 유튜브 검색까지 같이 해봅시다.

! pip install youtube_search

from langchain.tools import YouTubeSearchTool
tools = load_tools(["ddg-search"]) + [YouTubeSearchTool()]

agent= initialize_agent(
    tools,
    llm,
    agent='zero-shot-react-description',
    handle_parsing_errors=True,
    verbose = True)

result = agent("이퀼리브리엄 영화에서 주인공이 저항군 아지트에서 들은 클래식 음악이 무엇인지 찾아서 유튜브 링크를 알려줘")

### 길지만 이해를 위해 중간 과정을 살펴 봅시다.
Entering new AgentExecutor chain...
- Thought: I need to find the name of the classical music heard by the protagonist in the movie "Equilibrium" and provide a YouTube link.
- Action: duckduckgo_search
- Action Input: "Equilibrium movie protagonist classical music"
- Observation: What classical music is played in the movie 'Equilibrium'? The character of Preston listens to Ludwig van Beethoven 's 9th Symphony, First Movement that he accidentally activates on the Victrola. If the audio is a recording of the actual song please check the replies to this comment to see if the bots u/RecognizeSong, u/songfinderbot and u/find-song identified it correctly :) (might take a few seconds to reply) . Please flair your post as Answered if the bots got it right, or comment to say if they listed the wrong song(s), so that people would know if help is still needed. What classical music is played in the movie 'Equilibrium'? The character of Preston listens to Ludwig van Beethoven's 9th Symphony, First Movement that he accidentally activates on the Victrola. What classical music is played in the movie 'Equilibrium'? The character of Preston listens to Ludwig van Beethoven's 9th Symphony, First Movement that he accidentally activates on the Victrola. Equilibrium hit theaters on Dec. 6, 2002, with a budget of $20 million. It only made $5 million back. That's despite a positive review from Roger Ebert, who praised the film's blend of ...
- Thought:I have found the name of the classical music heard in the movie "Equilibrium" and it is Ludwig van Beethoven's 9th Symphony, First Movement.
- Action: youtube_search
- Action Input: "Ludwig van Beethoven 9th Symphony First Movement,1"
- Observation: 'https://www.youtube.com/watch?v=VKxX2EJozxo&pp=ygUwTHVkd2lnIHZhbiBCZWV0aG92ZW4gOXRoIFN5bXBob255IEZpcnN0IE1vdmVtZW50'
- Thought:I now know the final answer.
- Final Answer: The classical music heard by the protagonist in the movie "Equilibrium" is Ludwig van Beethoven's 9th Symphony, First Movement. You can listen to it on YouTube at this link: [https://www.youtube.com/watch?v=VKxX2EJozxo&pp=ygUwTHVkd2lnIHZhbiBCZWV0aG92ZW4gOXRoIFN5bXBob255IEZpcnN0IE1vdmVtZW50]
- Finished chain.

# 최종 반환값입니다.
{'input': '이퀼리브리엄 영화에서 주인공이 저항군 아지트에서 들은 클래식 음악이 무엇인지 찾아서 유튜브 링크를 알려줘',
 'output': 'The classical music heard by the protagonist in the movie "Equilibrium" is Ludwig van Beethoven\'s 9th Symphony, First Movement. You can listen to it on YouTube at this link: [https://www.youtube.com/watch?v=VKxX2EJozxo&pp=ygUwTHVkd2lnIHZhbiBCZWV0aG92ZW4gOXRoIFN5bXBob255IEZpcnN0IE1vdmVtZW50]'}

앞에 나온 순서도의 중간 과정을 두 번 반복했습니다. 먼저 DuckDuckGo 검색을 사용하고 그 결과를 이용해 두 번째에는 Youtube 검색으로 최종 결과를 얻었습니다. 만약 Youtube 링크가 주어졌을 때 영상을 틀어주는 도구를 추가한다면 바로 음악을 틀어달라고 요청할 수도 있겠죠? 이번엔 사용자 지정 도구를 만들어 봅시다.

예제: 사용자 도구 만들기

사용자 도구는 LangChain의 tool 데코레이터로 함수를 꾸며주면 됩니다. 함수 설명 문자열에 함수의 기능과 입력 인자 정보를 넣어야 Agent에서 해당 내용을 보고 어떤 도구인지, 어떻게 사용하는지 알 수 있습니다. OpenAI API의 Function Call에서 함수와 인자의 description에 해당하는 부분이죠. LLM이 사용하는 도구이므로 함수의 입력과 출력은 모두 문자열로 해줍니다.

from langchain.agents import tool
from datetime import date

@tool
def date_today(text):
  """오늘 날짜 반환, input은 ''"""
  return str(date.today())

agent= initialize_agent(
    [date_today],
    llm,
    agent='zero-shot-react-description',
    handle_parsing_errors=True,
    verbose = True)

agent("오늘 날짜는?")
{'input': '오늘 날짜는?', 'output': '오늘 날짜는 2023-10-26입니다.'}

이렇게 OpenAI API의 함수 호출 기능에 비해 비교적 간단히 LLM에게 새로운 기능을 추가해줄 수 있습니다.

LangChain 긴 문서 다루기: Stuff, Refine, Map Reduce, Map Re-rank

LangChain에서 document loader를 이용해 문서를 읽어들인 후 text splitter를 이용해 긴 문서를 chunk들로 나누었습니다. 그 다음엔 분할한 문서들로 요약이나 질의응답(QA)과 같은 LLM 작업을 수행해야겠죠? 문서를 이용해 LLM 작업을 수행하는 방법들에 대해 알아보겠습니다.

Stuff

먼저 Stuff 방법입니다. 이 방법은 분할할 필요가 없는 짧은 문서 전체를 프롬프트에 넣어 LLM에 전달하는 방법입니다. 간단하고 API를 한 번만 호출하면 되지만, 문서 길이가 길어지면 사용할 수 없는 방법입니다.

Refine

Refine 방법을 이용해 4개의 chunk로 나눠진 문서를 요약하는 과정을 생각해봅시다.

  • 우선 1번 chunk를 요약합니다.
  • 1번 요약에 2번 chunk를 더한 내용을 요약합니다.
  • 1+2번 요약에 3번 chunk를 더한 내용을 요약합니다.
  • 1+2+3번 요약에 4번 chunk를 더한 내용을 요약합니다. 이 내용이 최종 결과가 됩니다.

이 방법은 문서의 맥락을 유지하며 긴 문서를 요약할 수 있는 방법이지만, Stuff 방법에 비하면 API 호출 횟수가 많습니다. 또한 LLM을 순차적으로 호출해야 하므로 전체 작업 시간이 길어지게 됩니다.

Map Reduce

Map Reduce 방법을 이용해 4개의 chunk로 나눠진 문서를 요약하는 과정을 생각해봅시다.

  • 1번부터 4번 chunk를 각각 요약합니다.
  • 4개의 요약 내용을 합해서 다시 요약합니다. 이 내용이 최종 결과가 됩니다.

이 방법은 각 Chunk를 요약할 때 병렬로 요약할 수 있기 때문에 Refine 방법보다 빠르지만 Reduce 과정 포함하므로 API 호출 횟수가 더 많아집니다.

Map Re-rank

Map Re-rank 방법을 이용해 4개의 chunk로 나눈 문서에서 질문에 대한 답을 찾는 과정을 생각해봅시다.

  • 1번부터 4번까지의 Chunk에서 각각 답을 찾습니다. 이 때 답에 대해 확신하는 정도를 점수로 나타냅니다.
  • 확신 점수가 가장 높은 답을 결과로 채택합니다.

이 방법은 Map Reduce 방법에 비하면 API 호출 횟수가 적지만, 몇 개의 Chunk에 걸쳐 답에 관한 정보가 나눠져 있을 경우 이 정보들을 조합해서 답을 찾을 수는 없습니다.

Map Re-rank vs VectorDB

벡터 데이터베이스에서는 문서 Chunk들의 임베딩 벡터와 질문의 임베딩 벡터 사이의 코사인 거리를 이용해 질문과 의미가 가까운 내용을 찾았습니다. 이 방법도 일종의 점수를 매기는 방법이라고 할 수 있겠죠. 그럼 Map Re-rank와 벡터 임베딩을 이용하는 방법은 어떤 차이가 있을까요?

벡터 임베딩을 이용하는 경우에는 벡터 거리를 이용하므로 글의 의미가 질문과 얼마나 가까운지만 고려합니다. 반면에, Map Re-rank 방법은 의미 뿐 아니라 글의 품질과 같은 요소도 고려할 수 있습니다. 따라서 벡터 임베딩과 함께 사용하면 벡터 임베딩 기법을 보완할 수 있는 방법이라고 할 수 있습니다.