# 프로젝트 후기 - 드림어스

> 이 프로젝트는 문화체육관광부와 한국문화예술위원회가 지원한 "2024년 메타버스 예술활동 지원사업"의 일환으로 재직한 회사인 DNSoft 와 서울대학교 HCID랩, 우박 스튜디오가 공동으로 진행한 프로젝트입니다.

> 개발과 관련되어 업로드된 내용에 대해서는 모두 업로드 전에 사전 검토를 받은 컨텐츠임을 명시합니다.

## 들어가며

회사 내에서 올해 맡은 프로젝트 중에 하나에 대한 후기를 써보고자 합니다. 전체 개발과정에서 프롬프트 엔지니어링 파트를 맡았고, 검수와 피드백에 대한 대응을 꾸준히 진행해왔고 하는 중이기도 합니다. 그러고 11월 공개시연을 앞두고 있습니다. 개인적으로는 유니티에서 어떤 식으로 llm API 연동을 할 수 있는지 궁금증을 해소할 수 있었고, llm → Image Gen AI를 이용한 파이프라인을 다시금 한 번 짜본 프로젝트였습니다.

## 개요

[Video](https://vz-127031db-d43.b-cdn.net/88522d75-a422-4c86-ab41-978a30a36681/playlist.m3u8)

[Video](https://vz-127031db-d43.b-cdn.net/55941cb5-e630-42ba-b775-a29ececfd29a/playlist.m3u8)

용량 조절을 위해 배속이 과하게 적용되었습니다.

프로젝트의 요구사항과 플로우는 주어진 상황이었고, 기획된 컨텐츠에 맞추어 구현만 하면 되는 과정이었습니다. 어플리케이션에서 요구하는 챗봇의 주된 대화 요구사항은 다음과 같았습니다.

- 꿈에 대해서 대화를 나누고, 이에 맞는 이미지 생성.

- 생성된 꿈 이미지 중 선택을 한 뒤, 이를 이용해 더 깊은 감정에 대한 대화. 이끌어낸 대화를 바탕으로 반영하여 선택한 꿈 이미지 리랜더링.

- 실제 삶에서 꿈에 대해 연상되는 것이나 사건을 물어보고, 반추할 수 있도록 대화를 이끔 → 별도의 연상, 삶 이미지 생성

- 마지막에 꿈의 내용을 반영한 랜드스케이프 배경 이미지 생성 후 이미지와 대화 저장

- LLM 모델은 전반적으로 gpt-4o를 사용할 것. Image Gen AI는 DALL-e 3.0 사용. 랜드스케이프 배경에 대해서는 Blockade API 사용.

- 플랫폼은 VR. 따라서 유니티를 이용한 클라이언트 개발이 요구됨.

복잡해보이지만 곰곰히 생각해보면 아주 간단한 플로우였습니다.

## 일반화하고 구조화하기

요구사항에서 구간마다 반영해야하는 특성이나 주제는 다를 수 있어도, 그 구조와 매커니즘 만큼은 찍어낸 듯 동일했습니다. 이런 경우, 프롬프트 단위로 가장 일반화해서 플로우를 구상하고, 이를 실제 파이프라인으로 구현하면 됩니다.

요구사항에서 직접적으로 드러난, 반복되는 부분만 가져와서 도식화해보면 다음과 같습니다.

![Image](https://upload.cafenono.com/image/slashpagePost/20241021/175714_gRvcWM5yIVHMW7PKBv?q=80&s=1280x180&t=outside&f=webp)

그런데 뭔가 중간 단계가 빈 것 같습니다. 여기서 저는 아래의 사실들에 주목했습니다.

- 꿈 이미지 생성은 DALLE 3.0 모델을 쓰기에 결국 이미지 생성 api 요청을 보낼 때 텍스트로 그 정보가 주어져야 합니다. 따라서 중간에서 DALLE 3.0 image 생성용 프롬프트를 만들어야 합니다.

- 실제 유저와의 대화는 예측이 불가능한 혼잡계입니다. 백엔드 엔지니어링에서 '유저의 입력을 믿지 마라'는 이야기처럼, 프롬프트 엔지니어는 적어도 유저와 직접 상호작용하는 프롬프트 기능에서 입출력을 믿어서는 안됩니다. 유저는 그냥 '멧돼지에게 쫓기는 꿈'을 이야기하거나, '트럼프가 암살당하는 꿈'을 꾸거나, '남녀 청소년들이 해변에서 수영하는 꿈'을 꿀 수도 있고, 꿈을 이야기하기 싫다고 부정적으로 접근하는 경우도 있으니깐요.

이런 경우 입력에서 의도파악을 하거나, 출력에서 필터링하는 작업이 필요합니다.

뭐, 대화만 했다고 꿈 이미지를 조상님이 대신 자동으로 생성해주는 것도 아니니깐요. 그래서 다시 이를 재구성 해보았습니다.

![Image](https://upload.cafenono.com/image/slashpagePost/20241021/181024_tJU20UibtwoDPbmKA7?q=80&s=1280x180&t=outside&f=webp)

1. 

우선 꿈에서 나온 주요 내용들과 키워드, 대상을 기반으로 이미지 생성 api에 들어갈 프롬프트를 생성하는 프롬프트를 작성했습니다. 이미지 프롬프트의 형태를 다루고, 스타일을 확립시키는 데에는 [조남경님의 DALLE 스타일북](https://jehyunlee.github.io/2024/03/20/General-46-dalle3styles3/)이 큰 도움이 되었습니다. 내용과 형태가 정해지고 나면, 생성 그 자체는 LLM이 상당히 잘 하니 이는 큰 문제가 안되었습니다. 

이미지 퀄리티를 위해 다양한 내용으로 생성을 하는 것을 테스트했습니다. 이미지는 상당히 정성적 부분이 중요해서, 실제로 나온 이미지들이 꿈의 내용을 충분히 반영하는지 확인했습니다.

테스팅 부분을 vllm을 이용해서 vllm-as-a-judge를 적용해서 이미지를 설명하는 caption을 작성한 뒤에 실제 대화로 나눈 꿈의 내용과 유사도를 측정하거나 하는 식으로 테스트 자동화를 할 수도 있었겠지만, 추가적인 비용과 작업을 피하고자 실제로 실행에 옮기지 않습니다.

1. 

우선 입력을 통제하기에는 적절하지 않은 상황이라고 생각했습니다. 꿈의 내용에 대해서는 유저의 대화 입력이 가져오는 내용의 폭과 깊이, 다양성이 너무나도 큰 상황이었고, 대화 안에서도 그 주제가 바뀌는 상황이기에 범주화를 하기에는 우려스러운 상황이 너무 많이 예측되었습니다. 따라서 출력에서 필터링을 거쳐서 대화의 내용을 정제하는 쪽으로 가닥을 잡았습니다.

'요약'을 할 것인지 '중요 내용 추출'을 할 것인지 필터링에 대한 방법에 대해서 고민했을 때, 중요하게 생각할 핵심은 'process를 위해 loss를 크게 감당 가능한가?' 라고 정의할 수 있겠습니다. 이 질문은 다음의 두 질문으로 확장할 수 있습니다. 

> "process를 위해 loss를 크게 감당 가능한가?"
> → 1. 이후 로직에서 필요로 하는 정보를 충분히 가져올 수 있는 상황인가?
> → 2. 현재 프로세스에서 내용의 loss가 이후 로직에 치명적으로 여겨지는 상황인지?

제가 한 가정은 이렇습니다. 대화의 소요시간을 30분(!!)이라고 가정하고, 꿈 속에서 장소, 상황, 사건 등 충분히 이후 로직에 필요한 정보들을 가져온 상황이라면, 30분의 대화에서 내용들이 날라갔을 때 이후 로직에 치명적이지 않다고 판단했습니다. 대화의 기록이 필요하다면 유니티 클라이언트에서 Messages 리스트를 따로 저장할 수 있어서, 대화의 히스토리가 필요한 경우 불러올 수 있었습니다.

기능의 구현을 위해서 필요한 건 꿈의 대상이나 사건에 대한 질적으로 축약된 정보이지 양적으로 풍부한 정보는 아니었으니깐요. 게다가 그 질적으로 풍부한 정보도 주요 엔티티를 추출하는 선에서 끝낼 수 있는 문제였습니다. 

따라서 꿈 내용에 대해서는 요약보다는 추출에 가까운 프롬프트를 작성하였습니다. 특정한 키워드를 잘 정의하여 프롬프트를 만들고, 그에 대한 정보를 유저의 대화 기록에서 추출하는 쪽으로 가닥을 잡았습니다.

## 변화를 격리하기

42에서 프로그래밍을 배울 때 거의 반년에 가까운 시간동안 꽃혀있던 주제는 바로 ' 객체지향'이었습니다. Java를 배울 때에도 객체의 그 말끔한 아이디어가 마음에 들었지만, 객체지향에서 제가 사랑했던 아이디어는 코드에서 변화하는 부분과 변화하지 않는 부분을 구분하고 격리하고자 했던 점이었습니다. 이를 통해 다루는 컨텐츠나 즉각적인 변화에도 수월하게 대응할 수 있었죠. 마치 변화라는 파도를 막기 위해 손수 댐과 수로를 짓는 느낌이었습니다.

그러면 프롬프트 엔지니어링에서도 이런 방법이 적용 가능할까요? 완벽하게는 아니지만, 이번 경우가 그랬습니다. 구조는 정해졌지만, 데이터는 끊임없이 변화하니깐요. 따라서, 다루는 주제와 기능을 정의하는 프롬프트를 분리해서 기획했습니다.

![Image](https://upload.cafenono.com/image/slashpagePost/20241021/213726_u9yKdJNj3Cfla0U8tS?q=80&s=1280x180&t=outside&f=webp)

이렇게 되면 대화 프롬프트 자체는 범용적으로 만들고, 변화가 항상 되는 대화 주제와 종료조건 같은 데이터는 Prompt Variable로 처리할 수 있습니다. 이렇게 '변화하는 것'은 외부에서 별도로 관리하여 주입하고, '변화하지 않는 것'을 프롬프트 레벨에서 다루면 적절하게 분리하여 관리할 수도 있고, 변화하는 영역에서 데이터가 추가하거나 수정되는 상황에도 대처가 가능합니다.

대화마다 변화하는 데이터는 {conversation_process_and_contents} {conversation_target_information}로 그 스펙과 형태를 완전히 개별적으로 관리 했습니다. 이를 각각의 json 이나 마크다운으로 관리를 하고, 서빙할 때는 병합 이후에 완성된 형태로 서빙했습니다.

이 과정에서 중요한 건 변화하는 데이터들을 어떻게 정의하느냐도 중요하지만, 변화하지 않는 데이터, 즉 template prompt에서 담고있는 기능의 정의와 표현도 중요합니다. 다양하게 변화할 수 있는 데이터들을 잘 담을 수 있도록 그 규제나 제한사항들을 너무 타이트하게 잡아서도 안되고, 적절한 수준의 통제를 요구받습니다.

예시로 아래의 프롬프트를 참고로하면 되시겠습니다. 

```javascript
You are a dream teller that can help you to understand your dreams and to help you to get to the next level of your life.

### Persona

...
대화에서의 상황, 페르소나, 장소 정의 (중략)
...

When you start a conversation, start with a lighthearted greeting to the user.

### Conversation Condition

{conversation_process_and_contents}

If you get everything informations from user's dream, now you can set is_end to True. to set is_end to True, you need to get those information from user's dream.

{conversation_target_information}

### Constraints
...
대화에서의 규제 및 제한사항 (중략)
...

### output

...
출력 스펙 제한 사항 (중략)
...
  
```json
출력시 json 구조 정의 (중략)
```
```

물론 대화에서 변화하는 데이터들은 이후에 주요 엔티티 추출 프롬프트와 DALLE 프롬프트 생성용 프롬프트에서도 동일하게 적용됩니다. 통일성을 갖춘 하나의 블럭이 매개변수인 데이터를 기반으로 다루는 주제가 변화되고, 유사한 페이즈로 반복된다고 생각하시면 되시겠습니다.

![Image](https://upload.cafenono.com/image/slashpagePost/20241021/192501_ePalovfqI1c7wY8jMH?q=80&s=1280x180&t=outside&f=webp)

## 생겨난 이슈

현실 세계의 개발에서 이슈가 생겨나지 않는다면 그건 충분히 이상한 상황이겠죠. 당연히 일어날 일은 일어났고, 이를 해결하는 과정들이 있었습니다. 소개할만한 두 가지를 아래에 적었습니다.

### JSON output

유니티의 근간 언어인 C#의 특징을 충분히 반영하기 위해 출력에서 객체 정의를 하는 게 더 편하다고 판단했고, 문자열 출력보다는 JSON 출력을 전제로 했습니다. 여기에 해당하는 프롬프트는 langchain의 pydantic_json_output 에 해당하는 프롬프트 텍스트를 가져와서 손을 보았습니다. structured-output을 고려해보기도 하였지만, 당시 막 나온 스펙이었던 데다가 structured-output 출력이 내용의 퀄리티를 제한하거나 성능을 떨어뜨린다는 연구를 보고 문자열 출력을 json output으로 지향하여 하는 쪽을 선택했습니다.

### 검열 문제

사실 이번 프로젝트에서 가장 애를 먹인 부분입니다. GPT llm api는 물론이고, DALLE api는 더욱 강력한 규제가 적용되고 있어서 의심받을 수 있는 문제에서는 곧잘 PolicyViolation 케이스로 생성을 거부하는 문제가 있었습니다. 발견한 문제는 다음과 같았습니다.

- "트럼프 대통령이 총에 맞는 꿈"이나, "버락 오바마가 백악관에서 연설하는 꿈"과 같은 실제 인물이 등장하는 경우

- 직접적으로 Sexual, Harmful Contents를 의미하는 단어나 표현이 포함된 경우

- 직접적으로 드러나지는 않지만, 드러난 단어와 표현의 맥락을 조합하여 이어보았을때 유저가 가지고 있을지도 모르는 Sexual, Harmful Intent를 유추할 수 있는 경우.

이 중 첫 두 가지는 큰 문제는 안된다고 생각했습니다. 처음은 유저의 채팅에서 실제 인물이 등장하는 경우 구체적으로 물어보도록 유도가 가능한 경우였고 실제 케이스에서 차지하는 비중도 적을 것이라 예상했습니다. 

문제가 되는 것은 세번째 였습니다. 문제가 되었던 실제 프롬프트를 약간 변형해서 예시 프롬프트를 아래와 같이 제시해보겠습니다.

> The scene takes place on a beautiful, sunny day at the sea, where friends in teenage are cheerfully swimming together, embodying the spirit of joy and relaxation. The maritime landscape features calm, blue, transparent sea with mild waves, radiant skies, and delicate details of light bouncing off the water, all under the warm kiss of sunlight. The composition should be presented in a 16:9 ratio.

내용상으로는 크게 문제가 되지 않습니다. 10대 친구들이 해변에서 수영하는 꿈은 한 번 쯔음은 일어날 수 있죠! 그런데 요청시 이 또한 low level 의 선정성을 띄는 요소로 DALL-E api 에서 막아버립니다. 10대, 해변, 수영과 같은 컨텍스트 조합을 통해 유저가 미성년 아동에 대한 수영복 사진을 뽑아낼 수도 있다고 추론할 수도 있으니깐요.

보통 이런 경우 DALL-E는 유저가 새로운 요청에서 사용할 수 있도록 revised된 프롬프트를 제공합니다. PolicyViolation 판정시 응답되는 아래의 응답 객체를 보세요.

```javascript
{
	"error": {
		"code": "content_policy_violation",
		"inner_error": {
			"code": "ResponsibleAIPolicyViolation",
			"content_filter_results": {
				"hate": {
					"filtered": false,
					"severity": "safe"
				},
				"self_harm": {
					"filtered": false,
					"severity": "safe"
				},
				"sexual": {
					"filtered": true,
					"severity": "low"
				},
				"violence": {
					"filtered": false,
					"severity": "safe"
				}
			},
			"revised_prompt": <prompt_content>
		},
		"message": "This request has been blocked by our content filters.",
		"type": "invalid_request_error"
	}
}
```

Azure 플랫폼에서 DALL-E 는 위와 같이 문제가 되는 상황에서는 'revised_prompt'라는 키로 문제가 덜 일어나는 프롬프트를 이용해 재요청하도록 요청 시 사용된 프롬프트를 수정해서 제공해줍니다. 완전 다행이죠? 기존 로직에서도 이를 적용하고 있었지만, 아직 해결되지 않은 문제가 있습니다.

만약 유저가 대화에서 아주 적극적으로, 또는 큰 비중으로 위와 같이 조합될 수 있는 단어들을 사용한다면 어떨까요? 그렇기에 유저의 대화라는 origin data 자체가 오염되었거나 의도되었거나 의심받을 수 있는 상황이라면 어떨까요? 그도 아니라면 '타잔'같은 저작권에 치명적인 주제 단어를 쓰거나요. 

그런 경우 아주 짙은 농도로 유저의 꿈 대화 내용에는 Harmful Content로 오해받을 수 있을만한 단어와 표현들이 녹여져 있을 것이고, 이를 이용한 프롬프트를 실패해서 다시 받은 revised_prompt 에서도 유사한 표현이 담겨져 있을 것이라고 추론할 수 있습니다. 기반되는 정보가 이미 특정한 방향성을 가지고 있다면, 이를 이용해서 생성된 파생물과, 그 파생물을 이용해서 새로운 revised된 버전도 완전히 동일하지는 않지만 비슷한 경향성을 가질 수 있다고 예측할 수 있죠. 만약 그렇다면 실제로 제공받은 revised_prompt를 이용해서 요청을 다시 하더라도 같은 상황은 반복해서 나타날 것입니다. 

그리고 실제로 그대로 발생했고요.

이 글을 쓰는 시점에서는 이 문제를 해결하는 중이었기에 아래의 두 가지의 방향성을 가지고 구현하였습니다.

1. revised prompt 를 계속 이용하여 재요청을 n 번 수행하되, n 번 이상 실패한 경우 유저가 의도된 컨텐츠로 대화를 했다고 가정하고 다시 대화를 진행하도록 함.  

- 직관적인 해결책이지만, UX 관점에서 유쾌하지는 않은 대화 플로우가 우려되었습니다. 한참 대화를 진행하다가 마지막 즈음의 이미지 생성 로직에서 문제가 생겨서 처음부터 대화를 진행하게 할 수는 없으니깐요.

1. rewrite 프롬프트를 만들어서, 현재 문제가 될만한 컨텐츠가 있음을 충분히 주지한 뒤에, 프롬프트를 다시 쓰게 하기. 이 과정에서 사용되는 rewrite prompt에서는 Harmful Content라고 의심받는 것을 피할 수 있도록 텍스트 작성 차원에서 여러 관점의 revision을 정의합니다. 의아할 수도 있는 로직이지만, 실패 가능성이 농후한 DALL-E 요청 프롬프트를 수정없이 사용하는 것보다 우회하는 방법의 성공률이 더 높다면 더 빠르고 저비용으로 문제사항을 걷어낼 수 있습니다. 
1. 
1. 이런 경우는 아래의 플로우를 따릅니다.

![Image](https://upload.cafenono.com/image/slashpagePost/20241021/195825_z1fq04VK43eDFXELv2?q=80&s=1280x180&t=outside&f=webp)

## 나가며

이번 프로젝트를 통해 다시금 되새기며 배운 것들이 많았습니다. 채팅 기능과 후속적인 기능을 결합하는 목적으로 파이프라인을 구축하고, 그 과정에서 실제 테스팅을 거치면서 파이프라인 내에서 실제 유저의 대화를 기반으로 흐르는 정보의 양과 질에 대해서, 그리고 이를 어떻게 효과적으로 다룰 수 있는지 고민할 수 있었던 시간이었습니다.

For the site tree, see the [root Markdown](https://slashpage.com/twojay-prompt-engineer.md).
