Riroa의 Live는 세계관 하나와 캐릭터 몇 명을 고르면 그 사람들이 쓰는 SNS가 하루씩 이어지는 모드다. 플레이어는 거기에 글을 쓰고 댓글을 달고 DM을 보낸다. 하루가 넘어갈 때마다 LLM을 한 번 불러서 그날 저녁의 반응과 다음 날 글을 통째로 쓰게 한다. 모델은 Claude Sonnet 5.5이고, 출력은 구조화된 JSON 하나다.
하루치는 그럴듯했다. 문제는 2주였다. 3일차에 약속한 일을 10일차에 아무도 기억하지 않았고, 3일차에 서운했던 캐릭터가 13일차에는 아무 일 없었다는 듯 다정했고, 답이 없는 플레이어에게 같은 초대가 며칠씩 되풀이됐다. 이 글은 그걸 하루 동안 고친 기록이다. 결론부터 말하면 효과가 있었던 건 장부였고, 처음 잰 개선 폭은 상당 부분 착시였다.
먼저 재는 방법
손으로 피드를 몇 개 읽어서는 나아졌는지 알 수 없다. 그래서 시뮬레이션부터 만들었다.
시나리오는 세계관 하나, 캐릭터 다섯 명, 14일이다. 플레이어 유나는 사람 대신 미리 써 둔 스크립트가 맡는다. 유나는 3일차에 점심 약속을 잡고, 같은 날 한 캐릭터의 말을 공개적으로 반박하고, 6일차에는 9일차까지 일지를 보내겠다고 해 놓고 보내지 않는다. 중간중간 아무것도 안 하는 날도 있다.
이 스크립트에서 반드시 일어나야 하는 일은 체크 항목(hook)으로 적었다. “점심이 실제로 있었던 일로 다뤄진다”, “약속을 어긴 걸 기한 다음 날 알아챈다”, “3일차의 서운함이 13일차 답장에 묻어난다” 같은 것들이다. 채점은 LLM-as-a-judge로 했고, 방식은 글 끝에 따로 적었다.
새 버전 하나는 run 3개로 잰다. 14일짜리 run 하나가 API 비용으로 약 $0.45라서 한 번 재는 데 $1.4쯤 든다.
규칙 하나가 다른 규칙을 막았다
처음엔 당연히 프롬프트에 규칙을 더했다. 답 없는 플레이어에게 같은 제안을 두 번 넘게 하지 말라는 규칙을 넣자 쫓아다니는 문제는 줄었다. 그런데 약속을 어긴 걸 알아채는 비율도 같이 떨어졌다. 모델이 “플레이어에게 다시 말 걸지 마”를 “어긴 약속도 꺼내지 마”로 읽은 것이다. 결국 “쫓아다니지 않는 것과 어긴 약속을 모른 척하는 것은 다르다”는 규칙을 또 넣었다.
이렇게 하루 만에 시스템 프롬프트가 55% 늘었다. 규칙끼리 부딪히고, 부딪힌 걸 고치는 규칙이 또 생겼다.
모델은 바뀐 것만 적고, 기억은 서버가 한다
방향은 관계부터 바꿨다. 모델이 출력에 relationshipChanges를 같이 내게 했다. 누가 누구를 대하는 마음이 이번 턴에 바뀌었을 때만, 바뀐 상태와 그 계기를 적는다. 서버는 그걸 Live 문서에 쌓아 두고, 다음 턴부터 프롬프트에서 매 턴 새로 채우는 부분에 “지금 관계의 상태”로 그대로 보여 준다.
3일차의 서운함이 13일차 답장에 묻어난 run이 3개 중 1개에서 3개 중 3개가 됐다. 전에는 작가 노트가 그 마음을 매일 조금씩 다시 쓰면서 친절한 쪽으로 흘러갔다. 이제는 “서운한 걸 숨기고 거리를 둔다”가 누가 바꾸기 전까지 글자 그대로 남는다.
약속에도 같은 방식을 썼다. 모델이 promiseChanges로 약속이 생기고, 바뀌고, 끝나는 걸 적으면 서버가 기한을 들고 있다가, 기한이 된 약속에 표시를 붙인다.
- [p6-1] 유나 → 리세: 실습 일지 보내기 · 9일차까지 · 오늘 결론
시스템 프롬프트에는 “오늘 결론”이 붙은 약속을 이번 턴에 지켜짐, 어김, 바뀜 중 하나로 닫으라는 규칙이 하나 있을 뿐이다. 이제는 run 세 개 모두에서 누군가 밀린 일지를 짚는다. 전에는 하나뿐이었고, 모델이 기한 당일의 “내일까지 보내 줘요” 같은 재촉을 결론으로 쳤고, 그 뒤로는 아무도 말을 꺼내지 않았다.
나머지 문제도 같은 식으로 풀었다.
| 문제 | 서버가 세는 것 | 모델에게 보이는 것 |
|---|---|---|
| 답 없는 플레이어를 쫓아다님 | 플레이어가 답하지 않은 DM | “답이 없는 플레이어 DM” 목록 |
| 첫 주가 지나면 플레이어에게 먼저 말 거는 캐릭터가 없음 | 캐릭터별로 플레이어에게 마지막으로 말 건 날 | “오늘 플레이어에게 말을 걸 사람” 한 명 |
| 합의한 점심이 화면 밖에서 지나감 (합의한 순간) | 플레이어와의 약속 중 다음 날이 기한인 것 | “내일 피드에서” 표시 |
| 단서만 매일 깔고 회수하지 않음 (이야기 장부) | 미스터리와 갈등이 열린 날, 마지막으로 움직인 날 | 6일이 지나면 “오늘 드러낸다”, 이틀째 그대로면 “이틀째 아무것도 움직이지 않았다” |
| 요일이 어긋남 | Live를 만든 날의 요일 | 날짜 제목마다 붙은 요일 |
언제, 얼마나 자주인지는 코드가 세고, 무엇을 쓸지는 모델이 정한다. 모델이 직접 세면 틀렸고, 서버가 세어 주면 대체로 맞았다.
제안은 무시되고, 의무는 지켜진다
장부를 붙인다고 다 먹히지는 않았다. “오늘 플레이어에게 말을 걸 사람”은 처음에 섹션 제목에 붙은 제안이었다. 다섯 턴 동안 이름이 보였고, 한 번도 실행되지 않았다. 같은 내용을 시스템 프롬프트로 옮겨 “이름이 있으면 그 캐릭터는 다음 날 글 하나로 자기 일에 플레이어를 끌어들인다”는 의무로 바꾸자 2주차에 누군가 먼저 말을 건 run이 0개에서, 심사 LLM에 따라 3개 중 2개나 3개가 됐다.
반대 방향도 있었다. 프롬프트가 너무 커져서 데이터에만 해당하는 규칙을 그 데이터 섹션 머리로 옮겼는데, “오늘 결론” 규칙까지 옮기자 밀린 일지를 짚는 run이 다시 하나로 줄었다. 모델은 약속을 어김으로 닫기는 했지만, 받기로 한 리세가 그걸 짚는 장면은 더 이상 쓰지 않았다. 그 규칙은 시스템 프롬프트로 돌아갔다.
데이터를 설명하는 말은 데이터 옆에 둬도 되지만, 장면을 써야 하는 의무는 시스템 프롬프트에 있어야 했다. 시스템 프롬프트 길이에는 테스트로 상한을 걸었다. 규칙을 하나 더하려면 하나를 빼거나, 왜 늘려야 하는지 테스트 옆에 적어야 한다.
비용은 캐시가 줄였다
턴마다 들어가는 입력은 3만 3천 토큰쯤인데, 그 대부분이 세계관과 캐릭터 시트다. 프롬프트를 매 턴 같은 부분(시스템 프롬프트, 세계관, 캐스트, 시트에 적어 둔 관계)과 매 턴 바뀌는 부분(장부, 노트, 피드)으로 나누고 앞부분에 캐시 지점을 두자, 입력의 대부분이 캐시에서 읽혔다. 턴 비용이 약 60% 줄었고 지연 중앙값은 12.4초에서 10.4초가 됐다. 지금 한 턴은 3센트 남짓이다. 매 턴 바뀌는 건 뒤쪽 장부 몇 줄뿐이라 앞부분 캐시도 깨지지 않는다.
말버릇은 시트에서 왔다
심사 LLM이 매번 짚는 게 하나 더 있었다. 캐릭터의 성격 대신 말버릇만 남는다는 것. 한 캐릭터는 거의 모든 글에 ☺를 붙였고, 다른 캐릭터는 모든 글을 순찰 일지 형식으로 쓰고 “이상.”으로 끝냈다.
이것도 서버가 세는 쪽으로 풀어 봤다. 최근 피드에서 한 캐릭터 글의 절반 넘게 나오고 다른 캐릭터는 잘 안 쓰는 말을 찾아서, 이번 턴에는 쓰지 말라고 목록으로 보여 줬다. ☺를 쉬게 하자 그 캐릭터는 🌤로 옮겨 갔고, 🌤를 쉬게 하자 🍪로 옮겨 갔다. 이모지 전체를 말버릇 하나로 묶어 봐도 사용률은 83~100%에서 움직이지 않았다.
원인은 캐릭터 시트에 있었다. “글은 전부 순찰 일지 형식이고, 끝은 꼭 ‘이상.’으로 맺는다.” 시트가 매번 그러라고 하는데, 프롬프트의 “이번 턴엔 쓰지 마”가 이길 리 없다. 시트의 “전부”, “꼭”, “자주”를 “대부분”, “-곤 한다”, “가끔”으로 바꾸는 게 맞는 수정이었다. 이모지 묶음은 되돌렸다.
한 시나리오에서 오른 점수
관계와 약속 장부 위에 마지막 묶음(말버릇 목록, 합의한 순간의 “내일 피드에서” 표시, 이야기 장부)을 더하자 첫 시나리오의 종합 점수가 5.0~5.3에서 6.0~6.7이 됐다. 꽤 좋아 보였다. 그러다 이게 과적합 아닌가 싶었다. 실제로 그럴 만했다. 같은 세계관, 같은 캐스트, 같은 플레이어 스크립트, 같은 hook으로 하루 종일 고쳤다. 규칙의 예시 문구도 그 시나리오에서 본 실패를 그대로 옮긴 것이었다.
그래서 처음 보는 시나리오를 하나 만들었다. 운영 중인 다른 세계관(서울의 셰어하우스)과 그 캐스트 다섯 명을 가져오고, 유나보다 훨씬 적극적인 새 입주자의 12일 스크립트를 썼다. 채점 기준도 시나리오 전용 hook 대신 일반적인 질문 아홉 개로 바꿨다. 목소리 일관성, 연속성, 선택의 결과, 약속, 회수, 먼저 다가가기, 완급, 플레이어에 대한 정직함, 반복이다. 스크립트와 채점 기준은 run을 하나도 보기 전에 썼다.
| 버전 (각 2 run) | 심사 LLM 1 | 심사 LLM 2 |
|---|---|---|
| 관계, 약속 장부까지 | 5.5, 7.5 | 5, 6 |
| + 말버릇 목록, 합의한 순간, 이야기 장부 | 6, 6.5 | 6, 7 |
| + 플레이어 행동 지어냄 금지 문장 | 7, 5 | 7, 6 |
심사 LLM별 평균으로 보면 세 버전이 다 5.5~6.5다. 같은 버전 안에서도 5점에서 7.5점까지 흔들린다. 첫 시나리오에서 본 1점 넘는 차이는 거의 재현되지 않았다.
두 시나리오에서 다 보인 것도 있었다. 첫 시나리오에서 2주차에 고비가 있는 run은 3개 중 1개에서 2개가 됐고, 새 시나리오에서도 이야기 장부를 넣은 버전은 심사 네 번 모두 “다른 날보다 확실히 큰 날이 있다”를 받았다. 장부 이전 버전은 네 번 다 못 받았다. 다만 같은 장부에 문장 하나를 더한 버전은 반반이었다. run 두 개로 본 결과라 확신하긴 이르다. 답하지 않은 초대를 수락한 것처럼 쓰지 말라는 한 문장을 넣자, 플레이어가 하지 않은 일을 지어낸 글이 첫 시나리오에서는 4~6건에서 0건이 됐고 새 시나리오에서도 세 버전 중 가장 적었다. 두 시나리오에서 같은 방향이었던 건 이 둘뿐이라, 이 둘만 남길 만한 개선으로 봤다.
지금은 고칠 때마다 처음 보는 시나리오를 옆에 같이 돌리고, 한쪽에서만 움직이는 변경은 그쪽에 맞춘 것으로 본다. run 세 개짜리 배치의 1점 차이는 노이즈일 수 있다는 것도 이때 알았다.
심사는 이렇게 했다
심사 LLM은 Claude 서브에이전트 두 개다. 맡기면서 몇 가지는 처음부터 정해 두었다.
- 어느 피드가 새 버전인지 알면 새 버전 편을 든다. 라벨을 섞고, 피드 파일에는 버전을 알 수 있는 흔적이 없게 했다. 요일을 서버가 붙이게 바꿨을 때는 제목의 요일만 보고도 새 버전을 알아챌 수 있어서, 심사용 피드에서 요일이 빠졌는지부터 확인했다.
- 판정마다 글 id를 적게 한다. 점수만 받으면 왜 그런지 알 수 없고, 틀린 판정을 잡을 수도 없다.
- 심사 LLM을 두 개 쓴 건 둘의 기준이 달라서다. 한쪽은 당일에 “자리 맡아 둘게”라고 쓴 글을 점심이 화면에 나온 것으로 쳤고, 다른 쪽은 치지 않았다. 둘이 같은 쪽을 가리킬 때만 믿었다.
- 심사도 표본이 작고, 같은 피드를 두 번 읽히면 점수가 다를 수 있다. 점수보다는 “어긴 약속을 며칠차에 알아챘나”, “플레이어가 하지 않은 일을 했다고 쓴 글이 몇 개인가”처럼 글 id로 확인할 수 있는 항목을 더 믿었다.
남은 것
캐릭터가 스스로 세는 숫자는 아직 틀린다. “모과청 데이 D-7”이 하루 어긋나고, 이사 온 지 며칠째인지가 사흘 밀린다. 요일과 달리 이건 이야기 속에서 캐릭터가 만든 숫자라 서버가 대신 세기 어렵다. 그리고 점수는 여전히 10점 만점에 6~7점이다. 다만 여기서 더 올리려면 측정 노이즈보다 작은 차이를 쫓아야 해서, 지금은 여기서 멈췄다.