본문으로 건너뛰기

영속성 작동 방식

예상과 다른 동작이 발생했거나 백엔드를 작성하기 전에 읽어 보세요. 영속성을 간단히 설정하려면 개요의 세 가지 코드 조각을 확인하세요.

하나가 아닌 두 계층

계층답하는 질문저장 위치문서
전달 내구성"아직 실행 중인 스트림에 어떻게 다시 연결하나요?"runId를 키로 사용하는 실행별 로그재개 가능한 스트림
상태 영속성"대화란 무엇이며 나중에도 남아 있나요?"내구성 저장소(클라이언트 및/또는 서버)이 섹션

두 계층은 코드를 공유하지 않습니다. 전달 내구성은 실시간 바이트 스트림을 재생해 연결이 끊겨도 중단된 지점에서 정확히 재개합니다. 상태 영속성은 대화 자체를 저장하므로 다시 로드해도 유지되며 다른 기기에서도 사용할 수 있습니다. 재생 가능한 스트림은 저장된 대화가 아니고, 저장된 대화는 실시간 스트림이 아닙니다. 대부분의 실제 앱에는 둘 다 필요합니다.

식별자: 스레드와 실행

스레드(threadId)는 대화입니다. 안정적이며 다시 로드해도 유지되고 모든 기기에 존재합니다. 실행(runId)은 그 안에서 수행되는 한 번의 실행이며, 스트리밍 응답마다 새로 발급됩니다. 하나의 스레드에는 여러 실행이 쌓이고, 전달 내구성은 한 실행을 로그로 남기며 상태 영속성은 전체 스레드를 저장합니다.

flowchart TB
subgraph thread ["One thread, threadId (stable, the conversation)"]
direction LR
run1["run r1
completed"] --> run2["run r2
completed"] --> run3["run r3
running"]
end

subgraph delivery ["Delivery durability, one byte log per run"]
log["log for r3
replays the live stream to a reconnecting client"]
end

subgraph state ["State persistence, durable store per thread"]
store["transcript, run records, interrupts"]
end

run3 -. "a dropped connection tails" .-> log
thread -- "saved on finish, loaded on mount" --> store

실행 id는 너무 일시적이어서 이를 사용해 다시 연결할 수 없습니다. 다시 로드된 클라이언트는 현재 실행을 모를 수 있기 때문입니다. 대신 안정적인 threadId로 재연결을 해결합니다. 저장소가 "이 스레드에 실행 중인 실행이 있나요?"(findActiveRun)에 답한 후에만 클라이언트가 해당 실행의 로그를 이어받습니다. 두 식별자의 간단한 설명은 식별자 맵을 참고하세요.

격리는 직접 적용해야 합니다

스토어 API는 단순한 threadId 문자열을 받으므로 어댑터는 단순해지지만 다중 사용자 격리는 직접 적용해야 합니다.

  • 세션 상태에서 Scope.userId / Scope.tenantId서버 측에서 도출합니다.
  • loadThread / saveThread / reconstructChat 전에 reconstructChat({ authorize })를 사용해 권한을 확인합니다.
  • 클라이언트가 제공한 스레드 id를 소유권으로 간주하지 않습니다. 스레드 id는 추측할 수 있습니다.

Scope@tanstack/ai-persistence에서 다시 내보내므로 식별자 타입이 저장소 계약 옆에 위치합니다.

양쪽에서 영속성을 사용할 때 기록을 소유하는 주체

어느 사본을 우선할지는 하나의 규칙으로 결정되며, 각 턴에 클라이언트가 messages로 무엇을 보내는지에 따라 선택됩니다.

  • **비어 있지 않은 messages**는 "이것이 전체 기록입니다"라는 뜻입니다. 완료되면 서버는 저장된 스레드를 이 내용으로 덮어씁니다. 클라이언트가 권위 있는 원본이고 서버는 이를 미러링합니다.
  • **비어 있는 messages**는 "자신의 사본에서 계속합니다"라는 뜻입니다. 서버는 저장된 대화 기록을 불러와 그 지점에서 실행합니다. 서버가 권위 있는 원본이고 클라이언트는 캐시입니다.

이 하나의 규칙 덕분에 병합 없이 두 사본이 공존할 수 있습니다. 여기서 두 가지 방식이 파생됩니다. 순수한 SPA에 가까운 클라이언트 권위 방식과, 다른 기기에서도 같은 스레드를 동일하게 열 수 있게 하는 서버 권위 방식입니다.

다시 로드할 때 복원되는 내용

클라이언트 저장소 어댑터를 사용하면 useChat은 로드할 때 클라이언트 레코드를 읽고 그 결과에 따라 동작합니다.

  1. 실행이 완료되었습니다. 레코드에는 대화 기록이 있고 재개 포인터가 없습니다. 네트워크 없이 저장소에서 대화가 즉시 표시됩니다.
  2. 인터럽트에서 실행이 일시 중지되었습니다. 재개 포인터에 보류 중인 인터럽트가 포함되어 있으므로 대화 기록이 표시되고 승인 UI가 이전 상태로 돌아옵니다.
  3. 실행이 여전히 스트리밍 중입니다. 저장소에서 대화 기록을 표시한 다음 클라이언트가 내구성 로그를 통해 실행 중인 실행에 다시 참여하고 응답이 현재 위치에서 완료됩니다. 두 계층이 모두 필요한 유일한 경우입니다.

페이지가 열려 있는 동안 연결이 끊기는 경우는 더 단순합니다. 전달 내구성이 자동으로 다시 연결합니다. 페이지 자체가 사라진 뒤에 영속성이 중요해집니다.

서버 권위 모드에서는 localStorage 대신 서버에서 읽은 내용으로 대화를 표시합니다. 전달 로그에는 스레드가 아니라 하나의 실행만 들어 있으므로 해당 기록을 제공할 수 없습니다.

두 계층 모두 클라이언트가 돌아올 때 작업 자체는 끝났다고 가정합니다. 로그를 재생하고 대화 기록을 읽는 것은 모두 이미 생성된 내용을 읽는 작업입니다. 장시간 실행되는 샌드박스 에이전트에서는 실행이 아직 진행 중이고 이를 생성하던 호스트가 사라졌기 때문에 어느 계층도 충분하지 않습니다. 이 경우 실행을 인수하고 계속 구동하는 나중의 요청이라는 세 번째 요소가 필요합니다. 인수 및 분리된 실행을 참고하세요.

  • 단일 진실 공급원. 기록이 서버에 있으므로 두 사본이 서로 달라질 수 없습니다. 어떤 기기에서든 같은 대화가 열리고 재시작 후에도 유지됩니다.
  • 가벼운 클라이언트. 브라우저는 긴 대화 기록을 파싱하거나 저장하지 않으므로 매우 큰 스레드에서도 저장 용량 한도나 시작 시 파싱 비용이 없습니다.
  • 재로드 내구성. 마운트 시 GET이 대화 기록을 다시 표시하고 activeRun이 있으면 보고하므로 클라이언트가 해당 실행에 다시 참여하고 보류 중인 인터럽트를 복원합니다.
  • 불필요한 작업 없음.GET은 내구성 스트림 재개와 같은 경로를 공유하며, loadThread는 스트림을 재생해 메시지를 재구성하는 대신 준비된 메시지를 반환합니다.
sequenceDiagram
participant Hook as useChat (persistence: true)
participant Route as GET /api/chat
participant Store as Durable store
participant Log as Delivery log

Note over Hook: page reloads while a run is streaming
Hook->>Route: ?threadId=support-chat
Route->>Store: reconstructChat, loadThread + findActiveRun
Store-->>Route: messages + activeRun (runId)
Route-->>Hook: transcript + activeRun cursor
Note over Hook: transcript paints
Hook->>Route: ?runId=…&offset=-1
Route->>Log: resumeServerSentEventsResponse
Log-->>Hook: replay + live tail of the run
Note over Hook: reply finishes in place

분리된 경계

서버 상태 영속성은 의도적으로 코드를 공유하지 않는 세 경계 중 하나입니다.

  • 서버 상태(이 페이지): 미들웨어 수명 주기로 구동되는 AIPersistence 저장소이며, 권위 있는 레코드입니다.
  • 클라이언트 하이드레이션: 브라우저가 렌더링된 대화를 복원하는 별도의 관심사이며 클라이언트 영속성에서 다룹니다.
  • 스트림 전달: 진행 중인 SSE 응답을 재생하며, 재개 가능한 스트림을 사용합니다.

상태 미들웨어는 전달 오프셋을 추가하기 위해 청크를 변경하지 않으며, 클라이언트의 렌더링된 메시지가 아니라 서버 이벤트 상태를 저장합니다.

채팅 미들웨어 수명 주기

withPersistence(persistence)는 저장소의 존재 여부에 따라 계획을 도출합니다.

  1. setup은 해당 저장소가 있을 때 영속성, 인터럽트, 잠금 기능을 제공합니다.
  2. onConfig은 실행을 생성하거나 재개하고, 기존 실행 레코드에서 사용량을 초기화하며, 레코드를 준비하고, 보류 중인 인터럽트를 로드하며, 요청의 재개 배치를 이 인터럽트와 비교해 검증한 다음 요청에 기록이 없으면 저장된 메시지를 요청에 병합합니다.
  3. onUsage는 각 공급자 터미널의 사용량을 누적합니다.
  4. onChunkRUN_FINISHED 인터럽트 결과에만 반응합니다. 직접 어댑터 터미널은 onUsage보다 먼저 도착하므로 핸들러가 해당 사용량을 포함하고 뒤따르는 onUsage는 무시합니다. 합성된 도구 경계는 원래 터미널의 onUsage 이후에 도착하므로 핸들러가 해당 집계를 재사용합니다. 그런 다음 승인된 재개를 커밋하고, 새 인터럽트를 저장하며, 실행을 중단됨으로 표시하고 메시지를 저장합니다.
  5. onFinish 전에 채팅 엔진은 완료된 터미널 어시스턴트 메시지를 ctx.messages에 추가합니다. 네이티브 결합 출력은 터미널 어시스턴트 메시지에 구조화된 결과를 유지합니다. 별도의 최종화 및 이벤트 소싱 하네스 출력은 해당 이벤트가 다른 메시지 id를 사용할 때만 두 번째 구조화된 출력 메시지를 추가합니다.
  6. onFinish은 실행을 완료로 표시하기 전에 해당 정식 대화 기록을 저장합니다. onError는 대화 기록을 바꾸지 않고 실행 레코드를 종료 상태로 만듭니다. 두 경우 모두 확인된 사용량을 유지합니다. 터미널 onAbort도 마찬가지지만 한 가지 예외가 있습니다. 다른 미들웨어가 실행을 분리 가능하다고 선언한 경우, 일반 연결 해제(어느 영역에도 취소가 기록되지 않은 경우)는 아무것도 기록하지 않고 나중에 인수할 수 있도록 레코드를 'running' 상태로 둡니다. 인수 및 분리된 실행을 참고하세요.

승인된 재개는 실행이 성공적인 경계에 도달한 후에만 커밋됩니다(인터럽트가 해결됨/취소됨으로 표시됩니다). 따라서 재개를 승인한 후 해당 경계에 도달하기 전에 공급자 오류나 중단이 발생하면 인터럽트가 보류 상태로 남고 같은 재개로 다시 시도할 수 있습니다. 표준 AG-UI 청크 스트림은 변경되지 않으며, 영속성이 두 번째 이벤트 스트림을 만들지도 않습니다.

요청에 비어 있지 않은 messages 배열이 있으면 전체 권위 기록으로 취급하며, 완료 시 저장된 스레드를 덮어씁니다. 기록을 다시 보내지 않고 저장된 스레드를 계속하려면 빈 messages 배열을 전달하면 저장된 대화 기록을 로드해 사용합니다.

자체 미들웨어에서 저장소 읽기

자체 미들웨어에서는 withPersistence가 이미 보유한 저장소가 필요한 경우가 많습니다. 예를 들어 metadata에 쓰는 감사 단계나 보류 중인 인터럽트를 확인하는 가드가 있습니다. 영속성 객체를 두 번 전달할 수도 있지만 미들웨어와 코드가 서로 다른 인스턴스를 사용하게 될 수 있어 상태가 어긋납니다.

대신 withPersistence는 자신이 보유한 내용을 기능으로 게시합니다. requires에 필요한 기능을 선언한 다음 컨텍스트에서 읽습니다.

import { chat, defineChatMiddleware, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import {
InterruptsCapability,
PersistenceCapability,
getInterrupts,
getPersistence,
memoryPersistence,
withPersistence,
} from '@tanstack/ai-persistence'
import type { ChatMiddlewareContext } from '@tanstack/ai'

const persistence = memoryPersistence()

const auditPending = defineChatMiddleware({
name: 'audit-pending',
// Fails fast at setup when the capability was never provided.
requires: [PersistenceCapability, InterruptsCapability],
async setup(ctx: ChatMiddlewareContext) {
const stores = getPersistence(ctx).stores
const interrupts = getInterrupts(ctx)
const pending = await interrupts.listPending(ctx.threadId)
await stores.metadata?.set(ctx.threadId, 'pending-count', {
count: pending.length,
})
},
})

export async function POST(request: Request) {
const { messages, threadId } = await request.json()
const stream = chat({
adapter: openaiText('gpt-5.5'),
messages,
threadId,
// Order matters: the provider runs before the consumer.
middleware: [withPersistence(persistence), auditPending],
})
return toServerSentEventsResponse(stream)
}

두 가지 기능이 게시됩니다.

  • PersistenceCapabilitygetPersistence(ctx)로 읽으며, 전체 AIPersistence 객체이므로 객체가 노출하는 모든 저장소에 접근할 수 있습니다.
  • InterruptsCapabilitygetInterrupts(ctx)로 읽으며, interrupts 저장소만 포함하고 영속성에 실제로 해당 저장소가 있을 때만 게시됩니다.

providePersistenceprovideInterruptswithPersistence 대신 저장소를 제공하는 자체 미들웨어를 위한 쓰기 측입니다. 잠금은 여기에 포함되지 않으며 다음의 별도 기능입니다: @tanstack/ai/locks.

생성 미들웨어 수명 주기

withGenerationPersistence(persistence)는 다음 세 지점에서 작업을 기록합니다.

  • onStart는 실행 레코드를 생성하거나 재개합니다.
  • onFinish / onError / onAbort는 실행 레코드를 종료 상태로 만듭니다.
  • 결과 변환은 터미널 결과 메타데이터(id, URL, 미디어 바이트는 제외)를 레코드에 캡처합니다.

artifactsblobs가 모두 제공되면 생성된 미디어도 영속화하고 내구성 참조를 결과와 실행 레코드 양쪽에 병합합니다.

생성은 자체 generationRuns 저장소(GenerationRunStore)를 사용하며 채팅의 runs / messages는 절대 사용하지 않습니다. 생성에는 대화가 없으므로 실행은 자체 runId(ctx.runId ?? ctx.requestId)를 키로 사용하고 threadId는 작업의 기본 식별자가 되지 않습니다.

threadId는 그럼에도 필수입니다. 실행을 기록할 슬롯이며 GenerationRunRecord.threadId는 필수 필드입니다. 미들웨어는 이를 opts.threadId ?? ctx.threadId로 확인합니다(일반적으로 호출자가 작업에 전달한 threadId를 사용하고, 옵션이 이를 재정의합니다). 둘 다 제공하지 않으면 예외를 발생시킵니다. 요청 id에서 이를 위조하지 않습니다. 만들어 낸 범위에 기록된 실행은 그 범위로 하이드레이션할 수 없으므로 복원 시 영원히 아무것도 반환하지 않게 됩니다.

구성 의미

import {
composePersistence,
memoryPersistence,
} from '@tanstack/ai-persistence'

const base = memoryPersistence()
const replacement = base.stores.messages

const result = composePersistence(base, {
overrides: {
messages: replacement,
metadata: undefined,
interrupts: false,
},
})
  • messages가 교체됩니다.
  • 재정의가 undefined이므로 metadata가 상속됩니다.
  • interrupts가 제거됩니다.
  • 생략된 모든 저장소가 상속됩니다.

구성은 저장소 맵을 복사하며 두 입력 중 어느 것도 변경하거나 폐기하지 않습니다. 반환 타입은 어떤 키가 필수, 선택 사항, 교체 또는 제거되는지 계산합니다. 알 수 없는 저장소 키는 정적 검사와 런타임 검증에서 거부됩니다.

미들웨어는 진입점 검증을 추가합니다.

  • chat에는 messages가 필요하며, runs 없이 interrupts가 있으면 거부합니다.
  • generation에는 generationRuns가 필요합니다.
  • reconstructChat에는 messages가 필요합니다.
  • reconstructGeneration에는 generationRuns가 필요합니다.

JavaScript, 구성 로딩, 명시적으로 확장된 타입은 정적 보장을 우회할 수 있으므로 런타임 검사가 필요합니다.

백엔드 소유권

어댑터는 연결 수명 주기, 마이그레이션 실행 시점, 각 저장소 레코드의 행 매핑 등 자체 리소스를 소유합니다. 미들웨어는 저장소 메서드만 호출하며 연결을 열거나 테이블을 검사하지 않습니다. 백엔드는 저장소의 일부만 제공할 수 있으며(예: metadata 없음), 반환 타입에는 노출하는 저장소가 정확히 반영됩니다. 채팅 어댑터 만들기에서 SQLite를 사용한 전체 과정을 보여 줍니다.

composePersistence는 분산 트랜잭션을 추가하지 않습니다. 관련 저장소가 서로 다른 시스템을 사용하면 어댑터 작성자가 재시도, 멱등성, 일관성 동작을 정의해야 합니다.

명확한 매핑 외에도 어댑터 작성자를 구속하는 RunStore 세부 사항이 두 가지 있으며, 둘 다 내구성 실행에 적용됩니다. updatedriverEpoch를 왕복 저장해야 합니다(인수가 증가시키는 펜싱 토큰이며, 대체된 호스트가 실행을 잃었다는 사실을 확인할 수 있는 유일한 방법입니다). 또한 생략된 패치 키(열을 그대로 둠)와 undefined를 전달하는 키(열을 지움)를 구분해야 합니다. 인수 경로는 정확히 이 방식으로 detachedSince를 지웁니다. 직접 어댑터 만들기인수 및 분리된 실행을 참고하세요.