
상태 유지형(Stateful) AI 채팅 백엔드를 위한 5가지 불변량 (Invariants)
요약
상태 유지형(Stateful) AI 채팅 백엔드 구축 시 발생할 수 있는 복잡한 버그를 방지하기 위한 5가지 불변량(Invariants)을 소개합니다. 레이스 컨디션이나 데이터 중복 문제를 해결하기 위해 프롬프트 튜닝 전 시스템의 안정성을 보장하는 설계 원칙을 제안합니다.
핵심 포인트
- 상태 유지형 시스템은 단순 Stateless 데모보다 훨씬 복잡한 버그를 유발함
- 프롬프트 튜닝보다 시스템의 불변량(Invariants)을 먼저 정의하는 것이 중요함
- 현재 턴의 메시지가 히스토리 쿼리에 중복 포함되지 않도록 경계를 설정해야 함
- 데이터의 범위, 신선도, 우선순위 및 예산을 고려한 메모리 검색이 필요함
상태 비저장(Stateless) 채팅 데모는 관대합니다. 모델에 메시지 배열을 보내고, 텍스트를 받아 렌더링하기만 하면 됩니다.
하지만 상태 유지형(Stateful) 채팅 백엔드는 그렇지 않습니다.
지속적인 대화(Persistent conversations), 추출된 사실(Extracted facts), 요약(Summaries), 재시도(Retries), 스트리밍(Streaming) 또는 다중 워커(Multiple workers)를 추가하는 즉시, 가장 까다로운 버그들은 더 이상 구문 오류(Syntax errors)처럼 보이지 않게 됩니다. 대신 그들은 그럴듯한 대화처럼 보입니다:
- 현재 사용자 메시지가 프롬프트(Prompt)에 두 번 나타남;
- 수정 후에도 오래된 사실이 남아 있음;
- 재시도(Retry)가 두 개의 어시스턴트(Assistant) 메시지를 생성함;
- 요약(Summary)이 일어나지 않은 사건을 주장함;
- 긴 메시지가 히스토리의 잘못된 부분을 조용히 삭제함.
이 시스템을 구축하는 가장 안전한 방법은 프롬프트(Prompt)를 튜닝하기 전에 불변량(Invariants)을 정의하는 것입니다.

이 글에서는 특정 모델 제공자(Model provider)에 의존하지 않고 테스트할 수 있는 5가지 불변량을 설명합니다.
최소 요청 모델 (A minimal request model)
한 번의 턴(Turn)이 이 파이프라인을 통과한다고 가정해 봅시다:
사용자 메시지 수락
-> 턴 경계(Turn boundary) 설정
-> 적절한(Eligible) 히스토리 및 메모리 검색
...
**적절한(Eligible)**이라는 단어가 중요합니다. 지속적인 데이터(Persistent data)가 자동으로 프롬프트 데이터가 되는 것은 아닙니다. 각 레코드는 여전히 범위(Scope), 신선도(Freshness), 우선순위(Precedence) 및 예산(Budget)이 필요합니다.
우리는 다음과 같은 단순화된 TypeScript 타입을 사용할 것입니다:
type ChatMessage = {
id: string
conversationId: string
...
정확한 데이터베이스는 중요하지 않습니다. 불변량(Invariants)이 중요합니다.
불변량 1: 현재 턴은 히스토리에 다시 진입할 수 없다
다음과 같은 레이스 컨디션(Race condition)을 생각해 보십시오:
- API가 현재 사용자 메시지를 저장합니다;
- 직후에 히스토리 쿼리(History query)가 실행됩니다;
- 프롬프트 빌더(Prompt builder)가 현재 메시지를 활성 요청(Active request)으로 다시 추가합니다.
모델은 동일한 입력을 두 번 보게 됩니다. 모델은 반복적으로 답변하거나, 하나의 지시사항을 과도하게 강조하거나, 사용자가 고집을 부리는 것처럼 행동할 수 있습니다.
히스토리 쿼리에는 명시적인 경계(Boundary)가 필요합니다:
async function loadHistory(
conversationId: string,
before: Date,
...
동시 작업(Concurrent work)으로 인해 경계가 흐려지기 전에 startedAt을 캡처하세요. 더욱 강력한 설계는 현재의 userMessageId 또한 제외하는 것입니다. 타임스탬프는 충돌하거나 데이터베이스에 의해 정규화(Normalized)될 수 있기 때문입니다.
이를 속성(Property)으로 테스트하세요:
expect(prompt.messageIds.filter(id => id === turn.userMessageId))
.toHaveLength(1)
"로컬 타이밍 하에서 보통 한 번"이 아니라, 정확히 한 번(Exactly once)이 불변량(Invariant)입니다.
불변량 2: 수정 사항은 교체되는 사실보다 우선해야 한다
추출된 모든 사실을 단순히 추가하기만 하면, 메모리 시스템이 아닌 메모리 더미(Memory pile)가 생성됩니다.
사용자가 다음과 같이 말했을 때:
The plant is named Harbor.
그리고 나중에 다음과 같이 말한다면:
I renamed it Marlowe. Do not call it Harbor anymore.
검색(Retrieval) 결과가 두 이름을 모두 동일하게 유효한 사실로 반환해서는 안 됩니다.
한 가지 옵션은 버전 관리되는 사실 스트림(Versioned fact stream)입니다:
const facts: MemoryFact[] = [
{
id: "f1",
...
프롬프트 조립(Prompt assembly) 전에 우선순위를 해결하세요:
function activeFacts(items: MemoryFact[]): MemoryFact[] {
const superseded = new Set(
items.flatMap(item => item.supersedes ? [item.supersedes] : []),
...
프로덕션 환경에서는 고유 키(Unique keys), 툼스톤(Tombstones), 유효 기간(Validity windows), 또는 병합 함수(Merge function)를 사용할 수 있습니다. 표현 방식이 무엇이든, 다음과 같이 의미론적 규칙(Semantic rule)을 테스트하세요:
expect(active.map(f => `${f.key}=${f.value}`)).toEqual([
"plant.name=Marlowe",
])
"프롬프트에 새로운 사실이 포함되어 있다"는 것만으로는 불충분합니다. 반드시 구식(Obsolete)이 된 사실도 제외해야 합니다.
불변량 3: 컨텍스트 트리밍(Context trimming)은 결정론적이어야 하며 가장 최신의 유효한 히스토리를 유지해야 한다
메시지 개수를 세는 것은 컨텍스트 예산(Context budget)이 아닙니다.
짧은 메시지 10개가 붙여넣은 문서 하나보다 작을 수 있습니다. 만약 최신 N개의 행을 가져오고 거기서 멈춘다면, 크기가 너무 큰 행 하나가 여전히 모델 요청을 망가뜨릴 수 있습니다.
두 단계를 사용하세요:
- 대화 전체를 로드하는 것을 방지하기 위한 데이터베이스 제한(Database limit);
- 가장 최신 메시지부터 역순으로 탐색하는 토큰(Token) 또는 문자(Character) 예산.
type SizedMessage = ChatMessage & { estimatedTokens: number }
function keepNewestWithinBudget(
...
이 샘플은 예산에 맞지 않는 메시지를 건너뜁니다. 연속적인 윈도우 (contiguous window)를 유지하기 위해 첫 번째 오버플로 (overflow)에서 중단하는 다른 정책을 사용할 수도 있습니다. 신중하게 선택하고 정확한 규칙을 테스트하십시오.
필수 테스트 항목은 다음과 같습니다:
- 전체 예산보다 큰 메시지 하나;
- 매우 작은 메시지 여러 개;
- 동일한 타임스탬프를 가진 두 개의 메시지;
- 유니코드 및 이모지가 많은 콘텐츠;
- 비정상적인 크기 추정치를 가진 도구 결과 (tool results) 또는 코드 블록;
- 빈 대화.
불변량 (invariant)은 동일한 저장된 상태 (state)와 예산이 동일한 순서의 프롬프트 컨텍스트 (prompt context)를 생성해야 한다는 것입니다.
불변량 4: 턴 (turn) 재시도가 두 번째 커밋된 답변을 생성할 수 없음
네트워크는 작업이 성공한 후에 실패할 수 있습니다.
모델이 답변을 반환하고, 서버가 이를 영구 저장(persist)했을 수 있지만, 클라이언트가 확인을 받기 전에 타임아웃 (timeout)이 발생할 수 있습니다. 이때 동일한 논리적 턴 (logical turn)을 가진 재시도 (retry) 요청이 도착합니다.
만약 "재시도"가 "모든 것을 다시 실행함"을 의미한다면, 사용자는 두 개의 답변을 받고 두 번의 비용을 청구받을 수 있습니다.
각 턴에 멱등성 키 (idempotency key)를 부여하고 한 번만 커밋 (commit)하십시오:
async function commitAssistantMessage(input: {
turnId: string
conversationId: string
...
데이터베이스 제약 조건 (database constraint)은 메모리 내의 "이미 실행 중" 플래그 (flag)보다 더 많은 안전 작업을 수행합니다. 프로세스 재시작과 다중 워커 (multiple workers) 환경에서는 로컬 플래그를 신뢰할 수 없습니다.
동시성 경계 (concurrency boundary)에서 테스트하십시오:
const results = await Promise.all(
Array.from({ length: 5 }, () => commitAssistantMessage(input)),
)
...
시스템이 크레딧을 예약하거나 도구 (tools)를 호출하는 경우, 해당 원장 (ledgers)들에서도 동일한 논리적 턴 또는 작업 식별자 (task identifier)를 사용하십시오.
불변량 5: 누락된 증거가 기억된 이력이 될 수 없음
요약 (summaries)은 파생 데이터 (derived data)입니다. 그것들이 자동으로 사실인 것은 아닙니다.
모델이 생성한 요약은 사용자가 명시적으로 보내지 않기로 결정했음에도 불구하고, 편지를 보내는 것에 대해 반복적으로 논의되었다는 이유로 "사용자가 편지를 보냈다"라고 말할 수 있습니다.
내구성이 있는 메모리 (durable memory)와 함께 출처 (provenance)를 저장하십시오:
type DurableMemory = {
key: string
value: string
...
강력한 주장(strong claim)을 주입하기 전에, 이를 뒷받침하는 메시지를 요구하고 결과에 적절한 신뢰도 임계값(confidence threshold)을 적용하십시오.
function canInject(memory: DurableMemory): boolean {
if (memory.extractionMethod === "user-confirmed") return true
return memory.sourceMessageIds.length > 0 && memory.confidence >= 0.85
...
불확실하거나 상충하는 증거의 경우, 생성된 응답은 질문을 던지거나 조건을 붙여야 합니다:
I remember that you were working on the letter, but I am not sure whether you sent it.
(당신이 그 편지를 작성하고 있었던 것으로 기억하지만, 실제로 보냈는지는 확실하지 않습니다.)
이것은 대화상의 약점이 아닙니다. 올바른 불확실성 동작(uncertainty behavior)입니다.
최종 산출물뿐만 아니라 프롬프트 계획(prompt plan)을 테스트하십시오
엔드 투 엔드(End-to-end) 모델 단언(assertion)은 유용하지만 노이즈가 많습니다. 모델은 잘못된 프롬프트로부터 우연히 정답을 만들어낼 수도 있습니다.
테스트 시 프롬프트 계획의 디버그 표현(debug representation)을 노출하십시오:
type PromptPlan = {
historyMessageIds: string[]
activeFactIds: string[]
...
그런 다음 구조적 사실(structural facts)을 단언하십시오:
- 현재 턴(turn)이 정확히 한 번 나타나는가;
- 대체된 메모리(superseded memory)가 올바른 이유로 제외되었는가;
- 토큰 추정치(token estimate)가 설정된 예산(budget) 미만으로 유지되는가;
- 재시도(retries)가 하나의 확정된 결과(committed result)로 해결되는가;
- 출처(provenance)가 없는 주장은 강력한 메모리(strong-memory) 섹션에 절대 들어가지 않는가.
이러한 테스트는 모델, 프롬프트 문구 또는 제공업체(provider)를 변경하더라도 안정적으로 유지됩니다.
관측 가능성(Observability)은 실패한 불변량(invariant)을 명시해야 합니다
"잘못된 AI 응답"은 실행 가능한 로그 메시지가 아닙니다.
다음 질문에 답할 수 있을 만큼 충분한 메타데이터를 기록하십시오:
- 어떤 턴 경계(turn boundary)가 사용되었는가;
- 얼마나 많은 히스토리 행(history rows)이 로드되고 트리밍(trimmed)되었는가;
- 어떤 메모리들이 포함되거나 제외되었는가;
- 조립(assembly) 전후의 예산(budget)은 얼마인가;
- 멱등성 키(idempotency key)와 커밋 결과(commit result)는 무엇인가;
- 메모리 주장(memory claim)에 출처(provenance)가 있었는가;
- API, 워커(worker) 및 데이터베이스 작업 전반에 걸친 트레이스 식별자(trace identifier)는 무엇인가.
기본적으로 개인적인 메시지 본문은 로그에 남기지 마십시오. 식별자, 수치, 이유 및 해시(hash)만으로도 민감한 대화에 대한 접근 권한을 확장하지 않고도 파이프라인 동작을 진단하기에 충분한 경우가 많습니다.
컴팩트한 테스트 매트릭스
| 시나리오 (Scenario) | 기대되는 불변량 (Expected invariant) |
|---|---|
| 저장 및 조회 경합 (Save and retrieve race) | 현재 메시지가 한 번만 나타남 |
| ... | |
| 실제 모델을 투입하기 전에 프롬프트 플래너 (Prompt planner)와 스토리지 계층 (Storage layer)을 대상으로 매트릭스를 실행하십시오. 그 다음, 어조 (Tone)와 불확실성 (Uncertainty)에 대한 더 작은 규모의 모델 수준 평가 (Model-level evaluations)를 추가하십시오. |
실질적인 시사점 (The practical takeaway)
상태 유지형 (Stateful) AI 채팅의 품질은 단지 모델 품질의 문제만이 아닙니다.
그것은 끝단에 모델이 존재하는 데이터 일관성 (Data-consistency)의 문제입니다.
만약 백엔드에서 중복된 입력, 오래된 사실 (Stale facts), 비결정론적 히스토리 (Nondeterministic history), 반복되는 작업, 또는 지원되지 않는 요약 (Unsupported summaries)을 제공한다면, 유창한 모델은 그 버그를 아주 오랫동안 숨겨두어 결국 수정 비용을 매우 비싸게 만들 것입니다.
불변량 (Invariants)을 정의하십시오. 제외 사항을 관찰 가능하게 만드십시오. 동시성 (Concurrency)을 테스트하십시오. 출처 (Provenance)를 보존하십시오. 불확실성은 불확실성으로 남겨두십시오.
소속 및 AI 지원 공개: 저는 상태 유지형 캐릭터 대화가 저희가 연구하는 엔지니어링 문제 중 하나인 LumiChat 팀과 함께 일하고 있습니다. 이 기사는 제품 추천이 아닌, 특정 벤더에 종속되지 않는 (Vendor-neutral) 백엔드 테스트 접근 방식을 제시합니다. AI 도구는 편집과 구조화에 도움을 주었으며, 예시, 불변량 및 최종 결론은 저자가 검토하였습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기