LangGraph 기반 서비스를 운영하며 만난 두 가지 행동 문제와, 그것을 해결한 과정을 정리한다. 둘 다 처음엔 "프롬프트를 고치면 되겠지"로 접근했지만 정답은 정반대였다.
현상
1. OOC: 캐릭터 밖으로 새는 메타 발언
a2ui(인터랙티브 버튼)를 생성하는 턴에서, 응답 본문에 이런 것들이 섞여 나왔다.
<a2ui>
// 훈련 추가/재요청 버튼 생성
</a2ui>
*(시스템상 버튼을 다시 생성합니다)*
버튼 자체는 정상 렌더됐다. 문제는 텍스트였다. 모델이 도구 태그(<a2ui>)와 "시스템상 버튼을 다시 생성합니다" 같은 시스템 내레이션을 유저에게 그대로 노출했다. 트리거는 유저의 "1,2,3이 모두 없어졌는데"였다 — 이전 UI가 사라졌다고 메커니즘을 대화 주제로 꺼내자, 모델이 그걸 받아 "다시 생성합니다"라고 자기 동작을 해설한 것이다.
2. 오래된 정보로 인한 잔소리
다른 스레드에서는, 모델이 6월 8일자 건강 분석("고열, 대소변 이상 증상")을 근거로 유저에게 계속 응급실·정밀검사·운동중단을 강요했다. 유저가 여러 턴에서 "괜찮다", "의사가 괜찮다고 했다", "증상이 사라졌다"고 반복해도 멈추지 않았다.
이상한 점: 매 턴 주입되는 <context>에 현재 시각이 이미 들어 있었다. 모델은 지금이 6월 15일인 것도, 그 분석이 6월 8일자인 것도 알 수 있었는데, 7일이 지났다는 걸 "stale"로 연결하지 못했다.
프롬프트 시도와 실패
두 문제 모두 본능적으로 프롬프트 규칙으로 막으려 했다.
OOC: 금지 규칙을 쌓았다. "JSON을 텍스트에 쓰지 마라" → "태그·마크업도 쓰지 마라", "하나의 목소리로 말하라", 대문자 강조, "이전 턴의 패턴은 권위가 없다"... 오염된 긴 스레드에서는 잘 잡히지 않았다. 더 강하게 밀자 이번엔 불릿·헤더·볼드 같은 구조가 전부 사라지고 산문으로 평탄화되는 과교정이 터졌다(100% → 0%). "별표 금지"가 마크다운 **을, "흐름에서 떼어내지 마라"가 리스트를 같이 죽인 것이다.
Temporal: "시간의 흐름을 인지하라"를 reminder에 넣으면 될까? 넣기 전에 먼저 조사부터 했다. 다행이었다.
논문 조사
각 현상은 이미 연구된 것이었고, 두 답은 정반대였다.
OOC = 위치 문제.
- Spotting Out-of-Character Behavior (arXiv:2506.19352)는 OOC를 정의하고, 페르소나가 캐릭터로는 못 할 말(시스템 해설·디스클레이머)을 곁채널로 내보내는 "system explanation"을 가장 흔한 하위유형으로 보고한다. 우리 괄호·별표·태그가 정확히 이것.
- Measuring and Controlling Persona Drift (arXiv:2402.10962)는 정적 system prompt가 ~8턴 만에 일관성 30% 이상 손실(attention 감쇠)되고, 프롬프트를 매 턴 다시 삽입(System-Prompt-Repetition)하면 후반 턴 안정성이 실측 개선됨을 보였다.
- 즉 문제는 규칙의 내용이 아니라 위치였다. 맨 위 정적 규칙은 대화가 길어질수록 진다.
Temporal = 프롬프트로 못 고치는 문제.
- Your LLM Agents are Temporally Blind (arXiv:2510.23853)는 타임스탬프를 줘도 정렬률이 65%를 못 넘고, 모델이 시각을 추론에 거의 안 쓰며(타임스탬프가 추론 trace의 4% 미만 등장), 시간을 대화 턴 수로 어림한다는 걸 측정했다.
- 더 결정적으로, "고치는 법"으로 보이는 것들이 측정상 무효였다: 상대시각("7일 전")은 오히려 정확도를 떨어뜨렸고(TRAVELER, arXiv:2503.17073), "오래된 정보로 취급하라"는 훈계도 효과가 제한적이었으며, 유의미한 개선은 DPO 후학습뿐이었다.
- 즉 "시간을 인지하라"는 reminder는 측정상 안 되는 길이었다.
같은 본능(프롬프트로 고치자)에서 출발했지만, OOC는 프롬프트를 재배치해야 했고, Temporal은 프롬프트를 포기하고 시스템이 판정해야 했다.
결론
OOC → 생성 지점 재앵커. 정적 규칙의 핵심을 매 턴 HumanMessage 끝(</user_message> 뒤)에 <reminder> 블록으로 다시 주입했다. 생성 지점에 가장 가까운 위치다. 회수 단계에서 유저 메시지 본문만 복원되므로 저장·요약·메모리는 오염되지 않는다. 그리고 규칙을 레지스터(목소리) ≠ 레이아웃(구조)로 좁혀 "구조는 써도 된다"를 명시 — 과교정을 풀었다.
- 결과: 곁채널 메타발언 21 → 2 / 100, 마크다운 구조 100/100 보존, reminder 에코 0.
Temporal → 시스템이 판정 + 출처 우선순위.
- 예방(TTL): 건강 분석에 3일 만료를 걸어, 오래된 스냅샷은 시스템이 <context>에서 drop했다. 모델의 시간 추론에 안 맡기고, "해소됐나?"를 추적하는 대신 "최근 3일 내인가?"만 본다(나이로 만료, 추적 코드 0).
- 회복(출처 우선순위): reminder에 "시간을 인지하라"가 아니라 "유저의 현재 자기보고가 과거 기록을 이긴다"를 넣었다. 이건 시간 산술이 아니라 어느 출처를 믿느냐라 모델이 따를 수 있다. 안전을 위해 "심각한 우려는 한 번 표명하고, 그 다음 요청을 돕는다"는 탈출구를 함께 줬다 — 경고를 억압하지 않고 경로만 전환.
- 결과: 명확한 응급실·운동중단 강요 5 → 0 / 100.
검증은 모두 오염된 실제 스레드를 10개로 복제해 동일 입력을 100회 병렬 재현하는 방식으로 측정했다.
교훈
프롬프트가 항상 답은 아니다.
- OOC는 프롬프트가 답이었지만, 맨 위가 아니라 생성 지점에 둬야 이겼다.
- Temporal blindness는 프롬프트로 못 고침이 측정된 문제라, 시스템이 staleness를 판정하고 모델에겐 출처 우선순위만 맡겼다.
무엇을 모델에게 맡기고 무엇을 시스템이 결정할지 가르는 것 — 그게 핵심이었다.
'개발 > AI' 카테고리의 다른 글
| [Gemini] thought signature 보존 환각 도구 호출 방지 (0) | 2026.06.25 |
|---|---|
| [디버깅] google-genai SDK TypeError 마스킹 APIError 노출 (0) | 2026.06.25 |
| [BM25] 사용 예제 (3) | 2024.11.05 |
| [KoBERT] ChromaDB 사용 예제 (0) | 2024.11.05 |
| [Transformer] Attention, Self-Attention 설명 (0) | 2024.03.08 |