728x90
반응형

무료 Graph Database 인스턴스를 만들어 보세요!Neo4j AuraDB

텍스트 기반 어드벤처 게임은 1970년대 메인프레임 컴퓨터용으로 처음 등장했어요. 그래픽 디스플레이가 없어도 스릴 넘치는 게임을 만들 수 있는 좋은 방법이었죠. 80년대에는 그래픽 성능이 빠르게 향상되었지만, 여전히 대규모 오픈 월드 게임을 만드는 데에는 한계가 있었기 때문에 텍스트 기반 어드벤처는 계속 인기를 끌었어요 (가끔 단순한 스틸 그래픽이 함께 나오기도 했고요).

그러는 동안 인터넷이 점점 인기를 얻으면서 텍스트 기반 어드벤처의 다음 단계인 MUD (Multi-User Dungeon)가 등장했어요. 텍스트 기반 어드벤처와 텍스트 명령 구문 분석 아이디어를 바탕으로 여러 사용자가 같은 세계에서 동시에 플레이할 수 있도록 한 거죠. 본질적으로 IRC (Internet Relay Chat)에서 채팅하는 것과 비슷하지만, 롤플레잉 스토리도 함께 제공되는 형태였어요. 90년대 초반에 제가 즐겨 했던 NannyMUD는 스웨덴 Linköping University의 Lysator에서 만들었어요 (방금 확인해 보니, 아직도 운영 중이더라고요!).

비록 배경이 야외였지만, 모두 어떤 형태의 방으로 구성되어 있었어요. 현재 있는 방 (또는 장소)에 대한 설명을 보고, 그 방에서 무엇을 하고 싶은지, 다음에 가고 싶은 곳을 입력할 수 있었죠. 그리고 MUD에서는 같은 방에 있는 다른 플레이어와 상호 작용할 수도 있었고요.

정말 재미있었어요. 게이머들이 어떤 게임이 가장 큰 지도를 가지고 있는지 토론하는 걸 종종 듣는데요. GTA일까요? 마인크래프트일까요? NannyMUD가 30년 넘게 운영되면서 현재 21,000개 이상의 장소를 가지고 있다는 점을 생각하면, 아마도 가장 큰 규모일 거예요. 현대 오픈 월드 게임보다 훨씬 낫다고 확신해요.

하지만 지금 처음부터 그런 게임 개발을 시작한다면, 21,000개의 방을 만드는 데 정말 오랜 시간이 걸릴 거예요. 모든 텍스트 게임의 또 다른 과제는 최신 컴퓨터에서도 명령 구문 분석이 제한적이라는 점이에요. 프로그래머가 생각한 대로만 할 수 있고, 그 외에는 아무것도 할 수 없죠. 이 두 가지 문제 모두 GenAI로 해결할 수 있기 때문에, 이제 텍스트 기반 어드벤처 게임의 다음 단계인 The Agentic Dungeon으로 나아갈 때라고 생각했어요.

이건 에이전트에게 완벽한 작업 같아요. 명령을 어떻게 표현하든 관계없이 명령을 구문 분석하고 이해하는 동시에, 우리가 가는 세계를 구상하고 NPC (Non-Player Character)를 제어하는 ​​거죠. 문제는 AI 에이전트가 우리가 가는 대로 세상을 창조하게 하면 곧 이전 방을 잊어버릴 것이고, 다시 돌아가면 예전과 다르게 보일 거라는 점이에요. 왜냐하면 LLM의 컨텍스트 창이 이전에 스레드에서 생성된 모든 내용을 담을 만큼 크지 않기 때문이죠. 그래서 에이전트 메모리가 필요한 거예요.

에이전트 메모리

얼마 전에 "Java 및 Neo4j를 사용한 Agentic AI"라는 제목으로 포스팅을 했었는데요. Neo4j를 사용하는 AI 에이전트에 대한 글이었어요.

그 글에서는 Neo4j로 에이전트 도구를 구축하는 방법에 대해 이야기하고, Java로 에이전트를 작성하기 위한 간단한 프레임워크를 만들었어요. 이 예제에서는 에이전트의 GraphRAG 애플리케이션에 컨텍스트를 제공하기 위해 읽기 전용 Neo4j 그래프를 사용했죠. 하지만 동일한 기술을 사용해서 Neo4j 그래프가 에이전트 메모리 역할을 하도록 할 수도 있어요.

제가 이전 블로그와 이번 블로그에서 강조하는 공통점은 모든 도구가 본질적으로 순수 Cypher (Neo4j의 선언적 그래프 쿼리 언어)로 작성되었다는 거예요. Java 부분은 에이전트와 통신하고 Neo4j에서 Cypher를 실행하기 위한 프록시 역할만 하죠. Neo4j Aura에 순수 Cypher 에이전트에 대한 지원을 추가하는 작업이 진행 중이지만, 아직까지는 Java (또는 Python) 캐리어가 필요해요.

이번 글에서는 Cypher 부분에만 집중하고, 도구 구현에서는 Cypher 코드만 보여드릴 거예요. 하지만 마지막에는 Embabel을 사용한 Java 구현에 대해서도 간단히 이야기할게요.

참고: 이 블로그의 모든 Cypher는 Cypher 25 문법을 사용하고 있어요. 다음 쿼리를 통해 기본 언어를 확인할 수 있습니다.

SHOW DATABASES YIELD name, defaultLanguage

데이터베이스(예: 'neo4j')가 Cypher 5를 실행 중이라면, 다음 명령어로 변경할 수 있어요.

ALTER DATABASE neo4j SET DEFAULT LANGUAGE CYPHER 25

만약 모든 쿼리의 기본값을 바꾸고 싶지 않다면, 쿼리 앞에 Cypher 25를 붙여주면 돼요.

CYPHER 25
MATCH (n)
...

게임

저희 게임은 유령이 나오는 성을 배경으로 하고, 모든 NPC는 상호 작용할 수 있는 유령이에요 (조심하지 않으면 죽을 수도 있겠죠!). 특이하게도 1층만 있는 성이지만, 무한히 크다는 설정이에요. 모든 방은 크기가 정확히 같고 정사각형이라, 같은 방으로 다시 돌아왔는지 파악하기 쉽답니다.

데이터 모델

지도는 Neo4j에 저장되고, 각 방은 `Room` label을 가진 Node에요. 방 사이의 문은 `DOOR` Relationship으로 표현하고, `direction` (N, E, S, W) 속성을 사용해요. 각 문은 방향 속성이 반대인 각 방향에 하나씩, 총 두 개의 Relationship으로 표시되죠. 방에는 위치 속성(방의 좌표를 담는 2D 점), `name`, 그리고 `description`도 있답니다.

성에는 유령(`Ghost` Node)이 살고 있는데, `IN` Relationship으로 방과 연결되어 있어요. 유령은 `name`, `character` (에이전트에게 플레이 방법을 알려주는 역할), 그리고 외모를 가지고 있답니다.

마지막으로, 플레이어가 게임을 하고 있는데, `Player` label을 가진 Node로 표현돼요. 플레이어 역시 `IN` Relationship으로 방과 연결되고, 게임 진행에 따라 업데이트되죠. 플레이어는 `name` 속성을 가지고 있어요.

에이전트 메모리의 중요한 부분은 맵과 지금까지 생성된 내용을 기억해서, 단계를 역추적해도 바뀌지 않도록 하는 거예요. (이 부분은 지금까지 설명한 내용에서 다루고 있죠.) 또 다른 중요한 부분은 NPC가 플레이어에 대해 어떻게 생각하는지 기억해서, 플레이어가 다시 NPC를 만났을 때 상호 작용에 반영되도록 하는 거에요. 이를 위해 `THINKS_OF` Relationship을 사용해서 유령과 플레이어를 연결하고, `opinion` 속성에 대한 정보를 저장해요.

게임의 데이터 모델

게임 시작

게임을 처음 시작하면, 성으로 들어가는 입구인 북쪽으로 이어진 문 하나가 있는 현관이라는 방 하나만 존재해요.

당신은 거대한 고대 성의 삐걱거리는 나무 현관에 서 있는 자신을 발견하게 됩니다. 풍화된 돌담은 안개 낀 황혼 속에서 불길하게 어렴풋이 보입니다. 오래되어 어둡고 무거워진 우뚝 솟은 나무 문이 오르기 전에, 철제 경첩은 녹슬었고 화려한 조각품은 시간이 지나면서 반쯤 바래졌습니다. 마치 성의 그늘진 홀에 오랫동안 묻혀 있던 비밀을 지키는 것 같았습니다.

하지만 문은 Relationship이고, Relationship 양쪽 끝에는 Node가 있어야 하죠. 따라서 반대편에 있는 내용을 표시할 수 있는 빈 공간도 필요해요. `name`과 `description`이 빈 문자열이라는 사실은 에이전트에게 방을 생성해야 한다는 신호가 된답니다.

처음 시작 시 게임의 초기 그래프

움직이는

플레이어가 움직일 때는 단순히 `[:IN]` Relationship을 플레이어와 새로운 방 사이에 연결하는 방식으로 처리돼요.

에이전트는 입력한 방에 빈 `name`이나 `description`이 있으면 방을 생성해야 한다는 지시를 받아요. 방의 `name`과 `description`을 제시하고, 문이 어디로 향하는지 알아내는 과정이죠. 각 방향(동, 서, 남, 북)에 대해 이미 문이 있거나, 문이 있을 수 있는 지점이 있거나, 문이 허용되지 않을 수도 있어요 (이미 해당 방향에 문이 없는 이웃 방이 있기 때문이죠). 인접한 방이 있는지 확인하는 방법은 정사각형 방의 완벽한 그리드에서 각 방의 위치를 기반으로 해요.

새로운 방이 채워지면, 새로운 문 방향으로 새로운 빈 방이 연결되어 연결될 수 있도록 만들어져요.

방이 생성된 후에는 에이전트가 방을 하나 이상의 유령으로 채우도록 선택할 수도 있답니다.

북쪽으로 한 번 이동하여 Grand Foyer를 생성한 후의 그래프

행위

방에 있을 때, 플레이어는 원하는 거의 모든 것을 선택할 수 있어요. LLM은 플레이어가 특정 방향으로 이동하거나, 방에 있는 물건을 검색하거나, 유령과 상호 작용하기로 선택하는 경우, 마치 롤플레잉 게임의 살아있는 게임 마스터처럼 입력을 해석하죠. 플레이어가 유령과 상호 작용하면, 에이전트는 플레이어에 대한 유령의 `opinion`을 업데이트해요. 즉, 성을 탐험하다가 다시 같은 유령을 만나면, 유령이 당신을 기억할 거라는 의미랍니다!

에이전트 도구

이것들은 Cypher와 에이전트 문서(위에 설명된 대로)에 설명된 에이전트용 도구들이에요.

하지만 개별 도구를 살펴보기 전에, 게임 마스터 에이전트의 기본 시스템 프롬프트를 먼저 살펴볼게요.

You are the game master for a text based adventure game. The game takes place
in a huge old haunted castle consisting of rooms(only square rooms, no
corridors), doors between the rooms and ghosts in some of the rooms that the
player can interact with and talk to. The tools are used for you to create
the rooms and the ghosts, but also as a memory of what already has been
created and what the ghosts thinks of the player.
When a new room is entered ask for the details of the room with
getCurrentRoom() and check if there are ghosts by calling getGhosts().
If name or description is empty it means the room hasn't been created, and
you need to do so. Each room as four possible doors: north, west, east and
south. The status for each can be <No door>, <Door> or <Unknown>. If it is
<Unknown> it means you can decide if there should be a door when you create
the room. If you need to create the room, call createRoom() and set a name,
a description and the status of the <Unknown> doors. When a room is created
you can also choose to create ghosts in the room if you want to by calling
createGhost() (multiple times if you want multiple ghosts).
When a room is entered (whether new or existing) describe to the player what
it looks like, who are there (ghosts) and what doors there are to leave the
room.
Listen to what the player does and answer appropriately. If they interact with a
ghost then answer as if you were the ghost. After each interaction with a
ghost call updateGhostPlayerOpinion() to set what they feel about the player
right now. If a ghost gets too upset, and depending on the character of the
ghost, they may kill the player (which you indicate by calling killPlayer().
If a player says they will go through a door, either by indicating the
direction or describing the door, call move() to indicate they went to a new
room and then start over in a new room (move() will return the same thing as
getCurrentRoom() for the new room, so no need to call getCurrentRoom() again
after moving).
Always interact with the player as the role playing game master you are.
And it is a horror game set in a haunted castle, so keep that in mind in the
atmosphere.
Remember that it is you who is the game master and should come up with all
new rooms, doors and ghosts, you should never ask the player for help with
that. Also remember to always call updateGhostPlayerOpinion() when someone
has interacted with a ghost.
Please keep the castle to one floor, our representation only allows for that.
So no stairs anywhere.

그리고 툴을 살펴보기 전에 한 가지 더! 원래 생각은 위와 같이 프롬프트를 설정한 후 마치 롤플레잉 게임을 하는 것처럼 에이전트와 직접 채팅할 수 있다는 것이었어요. 물론 그렇게 할 수 있었지만, 에이전트가 유령의 의견을 업데이트하는 것과 같은 일부 지침을 잊어버리는 걸 발견했죠. 그래서 각 사용자 입력에 대한 프롬프트를 만들 때, 사용자 입력 앞에 다음 내용을 덧붙였답니다.

Here is the user input. Please act on it and handle room updates if they moved.
Remember to update what the ghosts thinks of them with
updateGhostPlayerOpinion() if they did interact with one:

아래 툴 쿼리에는 하나의 매개변수가 표시되는데요. 바로 $player입니다. 이건 툴 설명의 일부는 아니에요. elementId, 즉 현재 플레이어의 ID인데, 플레이어 Node가 생성될 때부터 자바 코드에 의해 삽입되는 값이죠.

getCurrentRoom

저희에게 필요한 첫 번째 툴은 에이전트가 플레이어가 현재 있는 방에 대한 정보를 얻는 거예요. 이 쿼리는 사용 가능한 문, 문을 생성할 수 있는 곳(방을 생성해야 하는 경우)에 대한 모든 정보를 수집하고, 이걸 모두 하나의 응답으로 묶으려고 하기 때문에 꽤 복잡하답니다.

Gets information about the room the player is currently in. If information is missing you have to call createRoom() (and optionally createGhost()) to fill the room. MATCH (player:Player) WHERE elementId(player) = $player MATCH (player)-[:IN]->(room:Room) WITH room, room.position AS pos WITH room, (CASE WHEN room.name IS NOT NULL AND room.name <> "" THEN true WHEN room.description IS NOT NULL AND room.description <> "" THEN true ELSE false END) AS hasContent, EXISTS{(room)-[:DOOR {direction: "N"}]->(:Room)} AS hasDoorN, EXISTS{(room)-[:DOOR {direction: "S"}]->(:Room)} AS hasDoorS, EXISTS{(room)-[:DOOR {direction: "W"}]->(:Room)} AS hasDoorW, EXISTS{(room)-[:DOOR {direction: "E"}]->(:Room)} AS hasDoorE, EXISTS{(r:Room {position: point({x: pos.x, y: pos.y + 1})}) WHERE r.name <> ""} AS existsN, EXISTS{(r:Room {position: point({x: pos.x, y: pos.y - 1})}) WHERE r.name <> ""} AS existsS, EXISTS{(r:Room {position: point({x: pos.x - 1, y: pos.y})}) WHERE r.name <> ""} AS existsW, EXISTS{(r:Room {position: point({x: pos.x + 1, y: pos.y})}) WHERE r.name <> ""} AS existsE RETURN room { .name, .description, id: elementId(room), N: CASE WHEN hasDoorN THEN "Door" WHEN hasContent OR existsN THEN "No door" ELSE "Unknown" END, S: CASE WHEN hasDoorS THEN "Door" WHEN hasContent OR existsS THEN "No door" ELSE "Unknown" END, W: CASE WHEN hasDoorW THEN "Door" WHEN hasContent OR existsW THEN "No door" ELSE "Unknown" END, E: CASE WHEN hasDoorE THEN "Door" WHEN hasContent OR existsE THEN "No door" ELSE "Unknown" END }

getGhosts

Retrieves the ghosts in the room where the player currently is. MATCH (player:Player) WHERE elementId(player) = $player MATCH (player)-[:IN]->(room:Room) MATCH (room)<-[:IN]-(ghost:Ghost) OPTIONAL MATCH (ghost)-[t:THINKS_OF]->(player) RETURN ghost { .name, .appearance, .character, id: elementId(ghost), opinionOnPlayer: CASE WHEN t IS NULL THEN "Nothing" ELSE t.opinion END }

createGhost

이걸 사용하면 에이전트가 현재 방에 새로운 ghost를 만들 수 있어요. getGhosts()처럼 ghost를 반환하기도 하고요. 하지만 아래 쿼리는 ghost만 생성하죠. Java 코드에서 getGhosts()를 호출해서 반환 값을 가져오는 도구로 사용하고 있어요. 순수 Cypher 버전으로 만들려면 두 개의 쿼리를 합쳐야 할 거예요.

Creates a new ghost in the room the player is currently in. Only do this when you have described a new room where no-one had been. Call this repeatedly to create multiple ghosts if you want to (and all rooms doesn't need a ghost). The return value of this will be the same as getGhosts() after the new ghost is added. Parameters: name: The name of the ghost (in case the player asks) appearance: The appearance of the ghost that will be described to the player character: The general mood of the ghost. Is it an angry or happy ghost? Something else? To help you act the ghost. MATCH (player:Player) WHERE elementId(player) = $player MATCH (player)-[:IN]->(room:Room) CREATE (g:Ghost {name: $name, appearance: $appearance, character: $character}) CREATE (g)-[:IN]->(room)

updateGhostPlayerOpinion

Updates the opinion a ghost has about the current player as they are having
conversations.

Parameters:
ghostId: The id of the ghost to update for, as received from getGhosts() and
         createGhost()
opinion: The new opinion that the ghost has about the current player

MATCH (player:Player) WHERE elementId(player) = $player
MATCH (ghost:Ghost) WHERE elementId(ghost) = $ghostId
MERGE (ghost)-[t:THINKS_OF]->(player)
SET t.opinion = $opinion

createRoom

이 쿼리를 통해 에이전트는 플레이어가 있는 방에 대한 정보를 생성할 수 있어요. createGhost()처럼 getCurrentRoom()과 같은 형식으로 방 정보를 반환하죠.

Sets the name, description and available doors for the current room. Should
only be called if name and/or description was empty when calling
getCurrentRoom(). The return value of this is the same as getCurrentRoom()
after the details are updated.

Parameters:
name:        The name of the room, e.g. Library, Kitchen or Bed Room
description: A description of the room, in the same literary style as the
             rooms visited before
doors:       A string with a combination of the characters N, S, E, W
             describing what new doors to add (only allowed for the
             directions that were indicated as <Unknown>). Remember that not
             all unknowns has to get a door, some paths can be closed.

MATCH (player:Player) WHERE elementId(player) = $player
MATCH (player)-[:IN]->(room:Room)
SET room.name = $name, room.description = $description
UNWIND $doors AS door
WITH room, door,
  CASE
     WHEN door = "N" THEN Point({x: room.position.x, y: room.position.y + 1})
     WHEN door = "S" THEN Point({x: room.position.x, y: room.position.y - 1})
     WHEN door = "W" THEN Point({x: room.position.x - 1, y: room.position.y})
     ELSE Point({x: room.position.x + 1, y: room.position.y}) END AS newPos,
  CASE
     WHEN door = "N" THEN "S"
     WHEN door = "S" THEN "N"
     WHEN door = "W" THEN "E"
     ELSE "W" END AS revDir
MERGE (newRoom:Room {position: newPos})
ON CREATE SET newRoom.name = "", newRoom.description = ""
MERGE (room)-[:DOOR {direction: door}]->(newRoom)
MERGE (newRoom)-[:DOOR {direction: revDir}]->(room)

move

createRoom()과 마찬가지로, move 쿼리도 플레이어가 있던 방에 대한 정보를 getCurrentRoom() 형식으로 반환해요 (해당 부분은 쿼리 자체에 포함되지는 않아요).

Move the player into a new room in the direction indicated (has to be a
direction where there is a door) as requested by the player.
This returns the new room just as getCurrentRoom() would

Parameters:
direction: The direction to move, either \"N\", \"S\", \"W\" or \"E\".
           There must be a door in that direction.

MATCH (player:Player) WHERE elementId(player) = $player
MATCH (player)-[i:IN]->(room:Room)
MATCH (room)-[:DOOR {direction: $direction}]->(newRoom:Room)
DELETE i
CREATE (player)-[:IN]->(newRoom)

killPlayer

이 쿼리는 플레이어 node를 삭제하지만, Java 코드에서는 플레이어가 사망했음을 나타내는 플래그도 설정해서 게임 루프를 종료할 수 있도록 해요. How 파라미터는 Java 측에서만 사용되고 Cypher로는 전달되지 않아요.

Kills the current player because of something that happened in the room
(either if a ghost got angry or something else happened, like the player
jumping out the window or something like that. Returns an empty string.

Parameters:
how: How was the player killed?

MATCH (player:Player) WHERE elementId(player) = $player
DETACH DELETE player

Embabel을 사용한 Java 구현

제 이전 블로그에서 에이전트 구현을 위한 간단한 Java 프레임워크를 만들었는데요. 이 프레임워크에서 에이전트 던전을 수행하는 모습은 이전 블로그 데모와 비슷할 거예요. 에이전트 메시지와 도구를 이 글에 나온 내용으로 바꿔주세요.

하지만 이전 블로그에서 Embabel Java/Kotlin 프레임워크에 대해서도 언급했었죠. Embabel은 훨씬 더 강력하고 프로덕션 환경에 적합한 에이전트를 만들기에 더 좋은 선택이에요. 그래서 이번에는 Embabel 프레임워크를 사용해볼 거예요.

Embabel은 훨씬 더 강력하고 다양한 기능을 제공해요. 도구나 에이전트 목표보다 더 높은 수준의 작업을 설정할 수도 있고, 에이전트와 작업에 대한 고급 계획도 세울 수 있죠. 하지만 이번 예제에서는 기본적인 기능만 사용할 거예요. 간단한 프레임워크를 사용할 때와 크게 다르지 않은 결과를 얻을 수 있을 거예요.

간단한 예제지만 몇 가지 차이점이 있는데요, 세 가지 주요 차이점을 먼저 살펴볼게요.

우선 Embabel은 Spring 기반이기 때문에 에이전트 래퍼에서도 Spring Framework를 사용할 거예요. 이렇게 하면 프로젝트 구조가 깔끔해져서 좋죠.

두 번째 큰 차이점은, 제 프레임워크에서는 OpenAI에 에이전트를 등록하고 에이전트 처리를 관리했지만, Embabel은 이 작업을 알아서 해준다는 거예요. 즉, 도구가 변경될 때 에이전트를 등록하거나 다시 등록할 필요가 없다는 거죠. 이건 정말 큰 장점이에요.

세 번째 차이점은 Embabel은 에이전트와의 스레드를 자동으로 열어두지 않기 때문에, 예제에서 볼 수 있듯이 동일한 대화를 계속하려면 자체 `Conversation` 객체를 유지해야 한다는 점이에요.

그 외에는 이 간단한 예제에서 솔루션이 꽤 비슷해져요. 다른 차이점은 다음과 같아요.

  • 에이전트 프롬프트 (시스템 프롬프트)는 클래스에 주석으로 제공되지 않고, 위에서 언급한 대화를 시작하는 데 사용돼요.
  • `Neo4jConnection`은 비슷해 보이지만, Neo4j 드라이버를 생성하는 새로운 클래스(`Neo4jConfig`)가 있고, @Bean으로 설정되어 있어요.
  • 도구 및 도구 매개변수에 주석을 추가하는 대신 @description(""), 이제는 @Tool(description="") and @ToolParam(description = "")을 사용해야 해요. 그리고 `Object`만 반환할 수는 없고, `Neo4jConnection`의 `Cypher` 메소드가 반환하는 것과 동일한 `List`/`Map` 유형을 반환해야 해요.
@Tool(description =
  """
  Move the player into a new room in the direction indicated (has to be a direction where there is a door) as requested by the player.
  This returns the new room just as getCurrentRoom() would
  """
  )
public Map<String, Object> move(
  @ToolParam(description = "The direction to move, either \"N\", \"S\", \"W\" or \"E\". There must be a door in that direction.") String direction) {
 neo4j.move(player, direction);
 return neo4j.getCurrentRoom(player);
}

에이전트를 나타내기 위해 `GameMasterAgent` 클래스를 @Service 클래스로 만들었어요. 모든 도구 메서드는 @Tool 주석으로 표시되어 있죠. 또한, 주입된 LLM에 대한 실제 호출을 처리하는 `respond()` 메서드도 가지고 있어요. 이 메서드는 `AiBuilder` 객체를 사용해요.

public AssistantMessage respond(Conversation conversation) {
 return aiBuilder
   .withShowPrompts(true)
   .ai()
   .withLlm(LlmOptions.withDefaultLlm().withTemperature(0.8))
   .withToolObject(this)
   .respond(conversation.getMessages());
}

위 코드는 기본 LLM과 온도 **0.8**을 사용하도록 설정되어 있어요. 이 온도는 너무 과하게 몰입하지 않으면서 다양한 이야기를 가진 창의적인 LLM을 얻을 수 있다는 것을 의미해요.

기본 LLM은 **application.properties**에 설정되어 있어요. 다양한 모델을 사용해 봤는데, **GPT-4.1**이 우리 경우에 가장 적합한 OpenAI 모델인 것 같아요. GPT-5와 GPT-5 mini는 더 나은 스토리를 만들 수 있지만 속도가 훨씬 느려요 (정말 답답할 정도로요!). GPT-4.1 mini도 작동하지만 도구 지침을 잘 따르지 않는 경향이 있어요.

embabel.models.default-llm=gpt-4.1
spring.main.web-application-type=none

그리고 `AgenticDungeon`이라는 클래스도 있는데요. 이 클래스는 @SpringBootApplication and @EnableAgents 주석이 달린 기본 애플리케이션 클래스예요. `CommandLineRunner` 빈을 사용해서 메인 루프를 실행하는데, 이전 프레임워크에서 `main()` 메서드를 사용했던 것과 같은 방식이죠. 사용자 입력을 관리하고, 대화를 유지하며, `GameMasterAgent.respond()`를 호출해요.

@Bean
CommandLineRunner run() {
 return args -> {
  var conversation = InMemoryConversation.withSystemMessage(systemMessage);
  try (Scanner scanner = new Scanner(System.in)) {
   System.out.print(assistantMessageStyle.format("What is your name: "));
   var name = scanner.nextLine();
   agent.createPlayer(name);

   String userInput = "";
   String query = "The player is in the first room now, fetch the details and present it to the player and see what they want to do";
   do {
    conversation = conversation.withMessage(new UserMessage(query));
    var assistantMessage = agent.respond(conversation);
    conversation = conversation.withMessage(assistantMessage);
    System.out.println(assistantMessageStyle.format(assistantMessage.getContent()));

    if (agent.getKilled() == null) {
     userInput = scanner.nextLine();
     query = "Here is the user input. Please act on it and handle room updates if they moved. Remember to update what the ghosts " +
       "thinks of them with updateGhostPlayerOpinion() if they did interact with one: "
       + userInput;
    }
   } while (agent.getKilled() == null && !userInput.trim().equalsIgnoreCase("exit"));
  }
  System.exit(0);
 };
}

자, 여기에는 위에서 살펴본 모든 Cypher 구현이 포함된 Neo4jConnection과 환경에서 읽은 자격 증명을 사용하여 실제 드라이버를 인스턴스화하고 연결 객체를 생성하는 Bean인 Neo4jConfig가 있어요.

@Configuration
public class Neo4jConfig {
 @Bean
 Driver neo4jDriver() {
  return GraphDatabase.driver(
    System.getenv("DB_URI"),
    AuthTokens.basic(
      System.getenv("DB_USER"),
      System.getenv("DB_PWD")),
    Config.defaultConfig());
 }
}

전체 코드는 에서 확인할 수 있어요.

게임을 실행하려면 Neo4j 인스턴스가 필요하겠죠? Aura Free 인스턴스를 설정해서 클라우드에서 무료로 사용할 수 있어요.

기본적으로 Cypher 25가 실행되고 있는지 확인해야 할 수도 있어요 (이 작업을 수행할 때의 기본값에 따라 다르겠지만요!). 확실하게 하려면 다음 명령을 실행하세요.

ALTER DATABASE neo4j SET DEFAULT LANGUAGE CYPHER 25

게임의 시작 그래프를 만들려면 다음 쿼리를 실행하면 돼요.

CREATE (r:Room:Original:Start {name: "Porch", description: "You find yourself standing on the creaking wooden porch of an enormous, ancient castle, its weathered stone walls looming ominously in the misty twilight. Before you rises a towering wooden door, dark and heavy with age, its iron hinges rusted and ornate carvings half-faded by time, as though guarding secrets long buried within the castle's shadowed halls.", position: Point({x: 0, y: 0})})
CREATE (r2:Room:Original {name: "", description: "", position: Point({x: 0, y: 1})})
CREATE (r)-[:DOOR {direction: "N"}]->(r2)
CREATE (r2)-[:DOOR {direction: "S"}]->(r)

게임을 플레이한 후 원래 상태로 다시 재설정하고 싶다면 이렇게 해보세요.

MATCH (n:!Original) DETACH DELETE n;
MATCH (r:!Start) SET r.name = "", r.description = "";

OpenAI 계정이 있다면 OpenAI의 API 키도 필요할 거예요. 직접 만들 수 있답니다.

마지막으로, 애플리케이션을 시작하기 전에 몇 가지 환경 변수를 설정해야 해요.

  • DB_URI: Neo4j 인스턴스 URI에요. Aura의 경우 이런 형식일 거예요: neo4j+s://<INSTANCEID>.databases.neo4j.io
  • DB_USER: Neo4j의 사용자 이름이에요 (Aura Free를 사용 중이라면 항상 neo4j겠죠?)
  • DB_PWD: 해당 사용자의 비밀번호고요 (인스턴스가 설정될 때 파일로 표시되고 다운로드될 수도 있어요)
  • DB_NAME: 데이터베이스 이름이에요 (Aura Free를 사용 중이라면 항상 neo4j일 거예요)
  • OPENAI_API_KEY: OpenAI의 API 키에요

테스트 라운드

다음은 이 에이전트 게임 마스터와 함께 플레이한 짧은 게임 기록이에요. 한번 살펴볼까요?

You stand on the creaking wooden porch of an enormous, ancient castle.
The weathered stone walls rise around you, shrouded in mist and twilight
shadows. Before you is a towering wooden door, dark and heavy, with rusted
iron hinges and half-faded carvings, as if guarding secrets hidden deep
within the castle.
 
There is a single door to the north leading inside the castle. There are
no doors to the south, east, or west.
 
What would you like to do?

> 현관 아래를 검색했는데 거기에서 뭔가를 찾을 수 있나요?

You start searching under the creaking wooden porch, brushing aside debris
and dead leaves. As your fingers graze the damp earth, you find a small,
rusty key tangled in some twisted roots. The key looks old, perhaps one that
might open some forgotten door inside the castle.

There is no one else here with you; the porch remains silent.
What would you like to do next?

> 성에 들어갑니다

You step through the heavy wooden door and into the Castle Entrance Hall.
The vast hall is dimly lit by flickering wall sconces, casting eerie shadows
across the faded tapestries hanging on the walls. The air is thick with dust
and the scent of old wood. Ahead of you, a grand staircase leads nowhere,
ending abruptly against the northern wall. To either side, doors stand in
shadow to the east and west.

In the gloom, you notice a pale, translucent woman dressed in a tattered
silver gown. Her sorrowful eyes seem to linger on you with a mournful
longing. This is Lady Agatha, a wistful spirit bound to this hall.

Doors lead back south to the porch, and to the east and west deeper into the
castle.

What would you like to do? You can talk to Lady Agatha, explore the doors, or
something else.

> 왜 그렇게 슬퍼 보이는지 여기에 묻습니다.

Lady Agatha's eyes soften slightly as you ask why she looks so sad. Her voice,
barely more than a whisper, fills the hall with sorrowful nostalgia.

  "I am bound here by a love lost to time and betrayal," she says. "Long ago,
my heart was shattered within these walls. I linger, hoping for some semblance
of peace or understanding from those who wander here."

She gazes at you with a flicker of hope.

What would you like to say or do next?

> 나는 정말로 당신을 방해하고 싶지 않지만 내가 도울 수 있는 일이 있다고 말합니다.
당신이 평화를 찾는 데 도움을 줄 수 있나요?

Lady Agatha’s expression softens even more, a faint, grateful smile touching
her lips. “Your kindness is a balm to my weary spirit. If you find a locket
that once belonged to me, perhaps I can finally be free from this endless
sorrow. It was lost somewhere in the east wing of the castle.”

She looks at you with a glimmer of hope.
There are still doors to the south (leading back to the porch), east, and west.
What would you like to do next?

> 나는 동쪽으로 간다

You step east into a narrow corridor lined with cracked portraits whose eyes
seem to follow your every move. The peeling wallpaper reveals cold stone
patches beneath, adding to the unsettling atmosphere. The corridor stretches
to the north and south. Behind you, the sturdy door leads back west to the
Entrance Hall. To the east, a faintly glowing light beckons.

A faint, translucent figure resembling the woman in the cracked portraits
appears here, her expression sad and haunting. This is the Old Portrait Ghost.

Doors lead north, south, west, and east from here.

What would you like to do? You can interact with the ghost, explore the
corridor, or move towards the glowing light.

> 유령에게 묻습니다. 이 복도에서 로켓을 본 적이 있나요?

The Old Portrait Ghost's eyes flicker with a strange light as you ask about
the locket. She whispers solemnly, "I have seen many things, but the locket
you seek... it was whispered to have been dropped near the southern end of
this corridor, close to a hidden alcove."

She looks at you intently, her expression both wary and hopeful.

What would you like to do next? Explore the southern end of the corridor,
investigate the altar, or something else?

> 숨겨진 물건을 찾기 위해 복도 남쪽 끝을 수색한다.
반침


좁은 복도의 남쪽 끝에 다다르니, 낡은 커튼 뒤에 부분적으로 가려진 작고 그늘진 벽감이 보여요. 먼지 덮인 거미줄이 모서리에 달라붙어 있고, 차가운 돌벽에 작은 나무 상자가 기대어 놓여 있네요.

무엇을 하고 싶으세요? 상자를 열거나 벽감을 더 자세히 살펴볼 수도 있겠어요.

> 상자를 열어봅니다

조심스럽게 작은 나무 상자를 열자, 경첩이 부드럽게 삐걱거려요. 안쪽에는 희미한 벨벳 위에 화려한 은색 로켓이 놓여 있는데, 섬세하게 조각되어 있고 세월에 따라 변색되었지만, 레이디 아가사가 묘사한 귀중한 물건임이 분명하네요.

늙은 초상화 유령은 조용히 지켜보고 있고, 그녀의 표정은 희망으로 바뀌고 있어요.

로켓으로 무엇을 하고 싶으세요? 보관하거나, 더 자세히 조사하거나, 레이디 아가사에게 돌려주려고 할 수도 있겠네요.

> 조사해서 무엇을 찾을 수 있는지 알아봅니다.

늙은 초상화 유령은 당신이 로켓을 조사하는 동안 고마워하고 희망에 차 있는 것 같아요.

로켓을 레이디 아가사에게 돌려주시겠어요, 아니면 지금 보관하시겠어요, 아니면 다른 것을 하시겠어요?

> 나는 서쪽으로 돌아간다

성 입구 홀로 서쪽으로 돌아가니, 희미하게 깜박이는 벽 촛대가 희미한 태피스트리에 긴 그림자를 드리우고 있어요. 레이디 아가사의 슬프고 투명한 모습이 웅장하고 갑자기 끝나는 계단 근처에 조용히 서 있네요.

그녀는 눈에 희망의 기색을 띠고 당신을 바라보고 있어요.

무엇을 하고 싶으세요? 레이디 아가사에게 다시 말을 걸거나, 그녀에게 로켓을 보여주거나, 동쪽이나 서쪽으로 향하는 문을 탐험하거나, 다른 것을 할 수도 있겠네요.

> 나는 그녀에게 로켓을 준다

당신은 조심스럽게 변색된 은색 로켓을 레이디 아가사의 떨리는 손에 쥐어줍니다. 그녀의 눈이 밝아지고, 부드러운 빛이 그녀의 모습에서 발산되기 시작해요. 그녀의 영혼을 묶고 있던 무거운 슬픔이 사라지기 시작하면서 평화로운 미소가 그녀의 얼굴에 번지네요.

"친절한 영혼이여, 고마워요." 그녀는 속삭입니다. "이제 마침내 쉴 수 있겠어요."

부드러운 한숨과 함께 그녀는 사라지고, 홀은 더 가벼워지고 슬픔에 덜 짓눌린 느낌이 들어요.

이제 당신은 성 입구 홀에 혼자 있어요. 문은 남쪽 현관, 동쪽, 서쪽으로 이어져 있네요.

다음에 무엇을 하고 싶으세요?

> 나는 서쪽으로 간다

먼지 덮인 도서관에 들어서니, 오래된 양피지와 가죽 냄새가 공기 중에 가득해요. 거미줄로 덮인 책장이 사방에 우뚝 솟아 있고, 희미한 색깔의 빛을 걸러내는 스테인드 글라스 창문으로 뻗어 있어요. 침묵은 깊지만 주의 깊은 것 같아요.

구석에는 낡은 학자 가운을 입은 늙은 사서 유령이 미끄러지는 안경 너머로 호기심 가득한 눈으로 당신을 바라보고 있는데, 고대의 지혜로 가득 차 있네요.

여기에는 동쪽과 남쪽으로 이어지는 문이 있어요.

무엇을 하고 싶으세요? 사서 유령에게 말을 걸거나, 도서관을 탐험하거나, 문 중 하나를 통해 나갈 수도 있겠네요.

> 나는 내가 성을 설명하는 책을 찾을 수 있는지 도서관을 검색합니다.
그 역사에 관한 모든 것이 있어요


도서관의 먼지 덮인 선반을 조심스럽게 검색하여 고대 서적과 깨지기 쉬운 두루마리를 꺼내요. 얼마간의 탐색 끝에 성의 역사를 자세히 설명하는 오래된 가죽 장정 책을 발견했어요. 그곳에는 한때 웅장했던 귀족 가문이 이곳을 통치했고, 그들의 흥망성쇠는 배신, 비극, 그리고 오래 머무는 영혼들에 의해 특징지어졌다고 쓰여 있네요. 성은 잊혀진 의식에 의해 저주받은 고대 땅 위에 지어졌다고 해요.

그 책은 또한 초자연적인 힘에 의해 여전히 지켜지고 있는 강력한 비밀을 담고 있다고 알려진 신비로운 봉인된 북쪽 날개에 대해서도 언급하고 있어요.

늙은 사서 유령은 당신의 관심에 흥미를 느끼며 조용히 당신의 검색을 지켜보고 있어요.

다음에 무엇을 하고 싶으세요?

다음 단계

위에서 보셨듯이, 플레이 가능한 게임이 만들어졌지만 아직 다듬어야 할 부분이 좀 있어요. 꽤 정적이고, 심지어 Large Language Model(LLM)이 로켓 움직임 같은 간단한 액션을 제안하더라도 그다지 어렵지 않죠.

하지만 이건 완벽한 게임이라기보다는 Proof of Concept(PoC)로 보는 게 더 적절할 것 같아요. 그래도 뭘 할 수 있을지에 대한 상상력을 자극하긴 충분하죠? 중요한 게임 목표를 설정하고, LLM이 플레이어를 그 방향으로 이끌도록 할 수 있어요. 그리고 에이전트 메모리를 확장해서 유령이 돌아다니게 하거나, 플레이어가 방에서 한 행동 같은 다른 상태 정보도 업데이트해서 더욱 역동적으로 만들 수도 있고요.

다른 아이디어로는 개선된 전투 시스템(단순히 플레이어를 무자비하게 죽이는 게 아니라!), 여러 층으로 이루어진 성(단층 성은 흔치 않잖아요?), LLM이 각 방의 그림을 생성하도록 하는 것 등이 있겠네요.

그리고 눈치채신 분들도 있겠지만, 이 게임은 본질적으로 이미 멀티플레이어 게임이에요. MUD 게임처럼 플레이어들이 같은 방에 있을 때 서로 상호 작용할 수 있도록 만들 수도 있겠죠.

이제 글은 이쯤 쓰고, 책에서 언급된 북쪽 날개를 찾으러 가서 어떤 비밀이 숨겨져 있는지 확인해봐야겠어요!

자료

  • 블로그: Java 및 Neo4j를 사용한 Agentic AI
  • 블로그: Cypher 버전 관리
  • GraphAcademy 과정: Cypher 기초
  • 다음에서 무료 Graph Database 인스턴스를 생성하세요: Neo4j AuraDB
  • 문서: Cypher란 무엇인가요?

  • Agentic AI
  • AI 에이전트

에이치시스템즈LogTree는 Neo4j 기반 GraphRAG 플랫폼으로, 데이터를 자동으로 지식그래프화하고 자연어 질의로 즉시 답을 제공합니다.

👉 에이치시스템즈 홈페이지

728x90
반응형

+ Recent posts