Game Development/Unreal Engine

[UE5] LLM을 활용한 NPC 대화 시스템 구현

Dlaiml 2024. 11. 14. 01:25

NPC와의 대화 기능을 추가하려고 개발을 하던 중, 딥러닝 언어모델을 활용해 NPC와 더욱 자연스럽게 대화할 수 있는 기능을 넣어보면 재미있겠다는 생각이 들었다.

그래서 간단하게 Claude, ChatGPT API와 집 데스크탑에 세팅해 둔 LLaMA를 연동해서 테스트를 해보았다.

 

이번 포스팅은 주제는 언리얼 엔진에서 간단한 대화 기능 구현과 LLM API 연결이다.

 

 

 

LLM 기반 NPC 대화 시스템 설계

LLM을 모든 NPC의 대화 모델로 사용하기에는 Hallucination, Context Limitations, Bias 등 언어 모델의 고질적인 문제로 인해 대화가 길어지거나 플레이어가 예외적인 질문을 던질 경우 몰입감이 크게 저하될 수 있다.

 

그럼에도 불구하고 일단 LLM을 사용한다고 가정해보자. 게임 진행 상황을 요약하거나 기존 대화를 Context로 제공해야 하므로 시스템이 게임 전반에서 언제든 접근 가능해야 하며, 다른 시스템들과 독립적으로 작동하면서도 필요할 때 쉽게 호출할 수 있도록 하는 것이 중요하다.

 

대화 시스템을 GameInstance의 Subsystem으로 구현하면, 대화 시스템이 레벨에 관계없이 전역적으로 존재하며 재사용이 용이할 것이라 생각해 대화 기능 클래스를 GameInstance의 Subsystem을 상속받아 구현하기로 결정했다.

 

class ASR_API UDialogManagerSubsystem : public UGameInstanceSubsystem

 
UDialogManagerSubsystem은 아래 함수들로 구성되어 있다.

  • StartConversation: 대사를 입력받아 전달하는 주요 함수
  • AddToDialogHistory: 대화 내역에 새로운 대화를 추가, 대화 내역은 Context로 프롬프트에 삽입하여 언어 모델이 이전 대화를 참고할 수 있도록 하였다.
  • ClearDialogHistory: 대화 내역을 초기화 하는 함수, 주어진 형식의 대답을 뱉지 않거나 API를 사용한 언어모델에서 검열에 의해 연속적으로 Response를 받지 못할 때 셀프 힐링 용도로 사용. 또는 Context 길이가 상한에 가까워지면 이를 요약해서 다시 대화 내역을 재편할 때 사용.
  • CallLanguageModelAPI: 언어 모델과 통신하는 함수
  • OnResponseRecieved: 언어 모델의 응답을 처리하는 함수

 

NPC 대화 시스템 함수

 

Json, HTTP를 사용하기 때문에 Build.cs 파일에서 아래 모듈을 추가하고 빌드하자.

PublicDependencyModuleNames.AddRange(new string[] { 
    //  생략
    "HTTP", "Json", "JsonUtilities"
});

 

 

StartConversation 함수는 현재는 기능이 없다. 기본적인 대화 포맷은 "[상태]이름: 대사" 형태로 지정하였다.

// UDialogManagerSubsystem.cpp
void UDialogManagerSubsystem::StartConversation(const FName& SpeakerName, const FString& Dialog)
{
	if (bUseLanguageModel)
	{
		UE_LOG(LogTemp, Log, TEXT("StartConversation: %s"), *Dialog);
		FString FormattedDialog = SpeakerName.ToString() + ": " + Dialog;
		CallLanguageModelAPI(FormattedDialog);
	}
	else
    {
    	// Local LLM or DataTable based Dialog
    }
}

 

대화 내역은 우선 테스트를 위해 단순하게 TArray를 사용하여 저장한다. 대화 데이터 구조체는 대화의 ID, 발화자 이름, 대사로 이루어져 있다.

 

// UDialogManagerSubsystem.h
struct FDialogData
{
	GENERATED_BODY()

	UPROPERTY(BlueprintReadWrite)
	int32 DialogID;

	UPROPERTY(BlueprintReadWrite)
	FName SpeakerName;

	UPROPERTY(BlueprintReadWrite)
	FString Dialog;

};

TArray<FDialogData> DialogHistory;

void UDialogManagerSubsystem::AddToDialogHistory(const FName& SpeakerName, const FString& Dialog)
{
	FDialogData DialogData;
	DialogData.DialogID = DialogHistory.Num();
	DialogData.SpeakerName = SpeakerName;
	DialogData.Dialog = Dialog;

	DialogHistory.Add(DialogData);
	OnDialogHistoryUpdated.Broadcast();
}

 

다음으로 LLM API (코드 예시는 ChatGPT API)를 호출하는 CallLanguageModelAPI 함수이다.

요청을 보낼 때 필요한 정보를 세팅하고 요청을 보내며, 응답을 받게되면 이를 처리하기 위한 함수를 Delegate에 바인드 하고 요청을 전송한다.

 

프롬프트는 취미로 챗봇을 만들면서 꾸준히 실험하며 개선하고 있는 개인 프롬프트를 사용하였습니다.
배경 설명, 반복 방지, 문체 고정, 능동적인 대화, 목표 제시 등 게임 NPC에 맞는 프롬프트를 추천합니다.
LLM 프롬프팅은 매우 방대하기 때문에 이 글에서는 설명하지 않고 스킵합니다.

 

void UDialogManagerSubsystem::CallLanguageModelAPI(const FString& Prompt)
{
	FString Response;

	TSharedRef<IHttpRequest> Request = FHttpModule::Get().CreateRequest();
	
    // 설정 파일에서 API Key 가져오기
	FString APIKey;
	if (GConfig)
	{
		GConfig->GetString(
			TEXT("Cred"),
			TEXT("CHATGPT_API_KEY"),
			APIKey,
			FPaths::ProjectConfigDir() / TEXT("Cred.ini")
		);
	}
	
    // ChatGPT API 호출 정보 작성
	Request->SetURL("https://api.openai.com/v1/chat/completions");
	Request->SetVerb("POST");
	Request->SetHeader("Content-Type", "application/json");
	Request->SetHeader("Authorization", "Bearer " + APIKey);

	TSharedPtr<FJsonObject> RequestData = MakeShareable(new FJsonObject);
    
    // Prompt 작성
	FString SystemPrompt = "";

	RequestData->SetStringField("model", "gpt-3.5-turbo");
    
	TArray<TSharedPtr<FJsonValue>> MessagesArray;
	TSharedPtr<FJsonObject> SystemMessage = MakeShareable(new FJsonObject);
	SystemMessage->SetStringField("role", "system");
	SystemMessage->SetStringField("content", SystemPrompt + "\nHistory:\n");

	// 이전 대화 내역을 프롬프트에 추가
	for (FDialogData DialogData : DialogHistory)
	{
		SystemPrompt = SystemPrompt + DialogData.Dialog + '\n';
	}

	// 한국어 출력 지시
    if (bUseKorean)
	{
		SystemPrompt = SystemPrompt + KoreanOutputPromptPiece;
	}
	
    
	// CHATGPT_API에 맞는 JSON 구성
	SystemMessage->SetStringField("content", SystemPrompt);
	MessagesArray.Add(MakeShareable(new FJsonValueObject(SystemMessage)));

	TSharedPtr<FJsonObject> UserMessage = MakeShareable(new FJsonObject);
	UserMessage->SetStringField("role", "user");

	UserMessage->SetStringField("content", Prompt);
	MessagesArray.Add(MakeShareable(new FJsonValueObject(UserMessage)));
	RequestData->SetArrayField("messages", MessagesArray);
    
    // 대화 내역에 추가
	AddToDialogHistory("Player", Prompt);

	FString RequestBody;
	TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&RequestBody);
	FJsonSerializer::Serialize(RequestData.ToSharedRef(), Writer);

	Request->SetContentAsString(RequestBody);
	
    
    // Response를 받을 때 호출되는 Delegate에 콜백함수인 OnResponseReceived Bind
	Request->OnProcessRequestComplete().BindUObject(this, &UDialogManagerSubsystem::OnResponseReceived);
	UE_LOG(LogTemp, Log, TEXT("Calling ChatGPT API"));
	Request->ProcessRequest();
}

 

Response를 처리하는 OnResponseRecieved 함수는 성공적으로 응답을 받았는지 확인하고 Json을 파싱하는 함수이다.

아래 코드는 ChatGPT API에 맞는 설정이기 때문에 response의 형태가 다른 API에서는 작동하지 않는다. 

void UDialogManagerSubsystem::OnResponseReceived(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful)
{
	if (!bWasSuccessful || !Response.IsValid())
	{
		UE_LOG(LogTemp, Error, TEXT("Failed to call ChatGPT API"));
		return;
	}

	FString ResponseString = Response->GetContentAsString();
	UE_LOG(LogTemp, Log, TEXT("Response: %s"), *ResponseString);

	// JSON 응답 파싱
	TSharedPtr<FJsonObject> JsonResponse;
	TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(ResponseString);

	if (!FJsonSerializer::Deserialize(Reader, JsonResponse) || !JsonResponse.IsValid())
	{
		UE_LOG(LogTemp, Error, TEXT("Failed to deserialize ChatGPT API response"));
		return;
	}

	// choices 필드에서 첫 번째 항목 파싱
	const TArray<TSharedPtr<FJsonValue>>* ChoicesArray;
	if (!JsonResponse->TryGetArrayField("choices", ChoicesArray) || ChoicesArray->Num() == 0)
	{
		UE_LOG(LogTemp, Error, TEXT("No choices available in the response"));
		return;
	}

	TSharedPtr<FJsonObject> ChoiceObject = (*ChoicesArray)[0]->AsObject();
	if (!ChoiceObject.IsValid())
	{
		UE_LOG(LogTemp, Error, TEXT("Invalid choice object"));
		return;
	}

	// message 필드 파싱
	ParseMessageContent(ChoiceObject);

	// finish_reason 필드 로그 출력
	FString FinishReason = ChoiceObject->GetStringField("finish_reason");
	UE_LOG(LogTemp, Log, TEXT("Finish Reason: %s"), *FinishReason);
}

void UDialogManagerSubsystem::ParseMessageContent(TSharedPtr<FJsonObject> ChoiceObject)
{
	TSharedPtr<FJsonObject> MessageObject = ChoiceObject->GetObjectField(TEXT("message"));
	if (!MessageObject.IsValid())
	{
		UE_LOG(LogTemp, Error, TEXT("Invalid message object"));
		return;
	}

	FString Role = MessageObject->GetStringField("role");
	FString Content = MessageObject->GetStringField("content");
	FString ToneToken = Content.Mid(0, Content.Find("]") + 1);

	UE_LOG(LogTemp, Log, TEXT("Role: %s"), *Role);
	UE_LOG(LogTemp, Log, TEXT("Tone Token: %s"), *ToneToken);
	UE_LOG(LogTemp, Log, TEXT("Content: %s"), *Content);

	// 대화 내역에 추가
	AddToDialogHistory("Test", Content);
	// Tone Token 및 Content에 따른 추가 로직
}

 

UserWidget

빠르게 테스트하기 위해 대화창 UI는 Widget Blueprint로 구현하였다.

 

 

Overlay 위에 Image로 대화창 배경, ScrollBox로 대화가 쌓이면 스크롤이 가능하게끔 하였고 Overlay 상단에는 입력을 위한 Editable Text box와 전송 버튼을 추가하였다.

해상도를 꽉 채우는 Canvas Panel을 사용하여 Dialog 위젯을 만들었으며 이를 HUD의 Canvas에 자식으로 추가하였다.

 

 

Dialog 위젯의 이벤트 그래프에서는 엔터키를 입력받으면 Visibility를 Visible로 변경하고 Input Mode를 UI Only로 변경하여 대화창이 켜지고 나서 키보드 입력이 모두 캐릭터의 이동이나 공격이 아닌 대화 타이핑으로 처리되도록 하였다.

 

 

키 설정이나 기능 구현은 가장 익숙한 채팅 UX를 그대로 가져왔다.

  • 엔터키로 채팅 입력창 켜기
  • 채팅 입력창이 켜진 상태에서는 WASD를 누르면 이동대신 채팅 입력, Shift 키를 입력하여 회피하려해도 작동 X
  • 내용을 입력하고 엔터키를 눌러 채팅 전송
  • 내용이 입력되지 않은 상태에서 엔터키를 누르면 채팅 입력 종료

 

엔터키로 채팅 입력창 켜기는 컨트롤러의 Input으로 처리하였고 바로 Editable Textbox인 PlayerInput에 Focus 하도록 하였으며 Input Mode를 UI로 변경하여 이후 입력이 모두 대화 입력으로 처리되도록 하였다.

 

이제 내용을 엔터키로 전송하여야 하는데 Input Mode를 UI로 변경하였기 때문에 Game Input Mode에 속하는 블루프린트의 Any Key(Enter) 노드로는 키 입력 이벤트를 잡아내지 못한다.

 

대신 위젯이 포커스 되었을 때 키 입력 이후 호출되는 On Preview Key Down을 Override 하였다.

 

 

위젯의 Visibility가 Visible이고 입력된 키가 Enter일 경우만 위 두번째 사진의 노드를 실행한다.

플레이어가 입력한 텍스트가 있다면 대화를 전송하는 Send Message 함수를 실행하고 그렇지 않으면 Visibility를 Collapsed로 변경하고 Input Mode를 Game Only로 다시 변경한다.

 

 

Send Message 함수는 Editable Textbox에 플레이어가 채워넣은 텍스트를 StartConversation 함수에 매개변수로 전달하고 Textbox의 텍스트를 비운다. (블루프린트에서 사용하기 위해 StartConversation 함수에 Specifier로 BlueprintCallable을 추가해야 호출 가능)

 

 

UI에 전송한 대화와, NPC의 답변을 widget에 추가하는 방식은 아래와 같다.

request, response 이후 호출되는 Delegate의 콜백 함수인 OnResponseReceived는 response를 파싱하고 대화 데이터를 만들어 대화 내역 배열에 추가하고 대화 내역이 추가될 때 Multicast Delegate인 OnDialogHistoryUpdated를 Broadcast 한다.

// UDialogManagerSubsystem.cpp

void UDialogManagerSubsystem::CallLanguageModelAPI(const FString& Prompt)
{
	// 생략
	Request->OnProcessRequestComplete().BindUObject(this, &UDialogManagerSubsystem::OnResponseReceived);
	// 생략
}
 
void UDialogManagerSubsystem::OnResponseReceived(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful)
{
	// 생략
	AddToDialogHistory("Test", Content);
    // 생략
}

void UDialogManagerSubsystem::AddToDialogHistory(const FName& SpeakerName, const FString& Dialog)
{
	FDialogData DialogData;
	DialogData.DialogID = DialogHistory.Num();
	DialogData.SpeakerName = SpeakerName;
	DialogData.Dialog = Dialog;

	DialogHistory.Add(DialogData);
	OnDialogHistoryUpdated.Broadcast();
}

 

 

Dialog 위젯 블루프린트는 대화 내역이 업데이트되면 대화 내역 배열을 받아와서 최근 추가된 데이터를 Widget의 스크롤 박스에 추가한다. 

 

Dialog Widget Event Bind

 

NPC 상태 시스템 추가

정상적으로 언어모델을 연결하는 데 성공하였다면 여러 가지 재미있는 기능을 추가해 볼 수 있다

 

ChatGPT로 간단하게 보스 이름(Varek)과 NPC 이름(Thalor), 마을 이름(Ravenmoor)을 넣고 세계관을 몇 문장으로 짜달라고 한 뒤, 이를 Context로 프롬프트에 추가하였다.

 

10개 정도의 담화 예시를 프롬프트에 추가하여 플레이어가 NPC를 화나게 하면 NPC가 점진적으로 Neutral -> Hostile -> Enemy 상태로 바뀌도록 하였으며 이를 대화 맨 앞에 대괄호 안에 넣도록 하였다.

 

대사를 파싱 해서 Enemy로 상태가 바뀌었다면 바로 전투를 준비하고 플레이어를 적으로 인식한다.