본문으로 건너뛰기

채팅 영속성

대화가 단일 요청보다 오래 유지되기를 원할 수 있습니다. 대화 기록이 각 실행의 완료 여부 또는 인터럽트 대기 여부와 관계없이 프로세스 재시작 후에도 남아야 합니다. withPersistence는 이 상태를 선택한 스토어에 기록하는 채팅 미들웨어이므로, 서버가 모든 스레드의 권위 있는 사본을 보유하게 됩니다.

pnpm add @tanstack/ai-persistence
npx @tanstack/intent@latest install

두 번째 명령은 이 패키지의 에이전트 스킬을 코딩 어시스턴트에 연결합니다. 시작하기 전에 실행해야 합니다. 레시피가 기존 데이터베이스 설정을 읽고 이에 맞는 어댑터를 작성하며, 잘못 구현하기 쉽고 디버깅 비용이 큰 불변 조건(전체 덮어쓰기 saveThread, 없을 때만 실행 및 인터럽트 생성)을 포함하기 때문입니다.

서버에 상태 영속화

미들웨어를 chat()에 추가하고 백엔드를 지정합니다. 여기서 persistence는 로컬 ./persistence 모듈이며, 이미 운영 중인 데이터베이스를 기반으로 코어 위에 구축하는 어댑터입니다. 어댑터 직접 만들기에서 완전한 SQLite 버전을 처음부터 끝까지 설명합니다.

import {
chat,
chatParamsFromRequestBody,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { withPersistence } from '@tanstack/ai-persistence'
import { persistence } from './persistence'

export async function POST(request: Request) {
const params = await chatParamsFromRequestBody(await request.json())
const stream = chat({
adapter: openaiText('gpt-5.5'),
messages: params.messages,
threadId: params.threadId,
runId: params.runId,
// Forward the resume batch so a thread with pending interrupts continues.
...(params.resume ? { resume: params.resume } : {}),
middleware: [withPersistence(persistence)],
})
return toServerSentEventsResponse(stream)
}

이 미들웨어는 백엔드가 제공하는 상태 스토어를 기능 플래그 없이 사용합니다. messages는 필수이고 나머지는 선택 사항입니다.

  • messages (필수)은 전체 모델 메시지 스레드를 로드하고 저장합니다.
  • runs는 실행 중, 인터럽트됨, 완료됨, 실패함 또는 중단됨 상태를 기록합니다.
  • interrupts는 대기 중인 도구 승인 / 클라이언트 도구 / 일반 대기를 기록하며, runs가 필요합니다.

워커 간 뮤텍스가 필요합니까? 다른 미들웨어에 다중 인스턴스 조정이 필요할 때 withLocks를 추가합니다. 잠금을 참고하세요.

열 때 테이블을 생성하면 로컬 개발에 편리합니다. 프로덕션에서는 대신 배포 워크플로를 통해 스키마 변경을 적용합니다. 마이그레이션을 참고하세요.

스레드, 실행, 턴

대화 기록은 threadId별로 저장되고, 각 실행에는 상태, 타이밍, 프로바이더 호출 전체에서 보고된 사용량이 포함된 runs 레코드가 생성됩니다. 재연결하는 클라이언트는 더 이상 알지 못할 수 있는 실행 ID를 제시할 필요가 없습니다. 스토어가 findActiveRun(threadId)로 스레드의 활성 실행을 확인하고 클라이언트가 이를 따라갑니다.

ID 맵에서 스레드 ID를 선택하는 방법과 생성 훅에서 두 ID가 의미하는 바를 설명합니다. 나머지는 영속성 작동 방식을 참고하세요.

전체 대화 기록을 보내거나, 아무것도 보내지 않습니다

withPersistence는 권위 있는 기록 계약이라는 하나의 규칙을 따릅니다.

  • 비어 있지 않은 messages 배열이 있는 요청은 전체 대화입니다. 완료 시 저장된 스레드를 덮어씁니다. 델타가 아닌 전체 대화 기록을 게시해야 하며, 그렇지 않으면 저장된 스레드가 최신 메시지만 포함하도록 교체됩니다.
  • messages 배열이 있는 요청은 저장된 스레드를 계속합니다. 미들웨어가 저장된 대화 기록을 로드하고 실행이 그 지점부터 이어지므로 클라이언트가 기록을 다시 보낼 필요가 없습니다.

압축해도 대화 기록은 완전하게 유지됩니다

같은 chat()withCompaction을 추가합니까? 저장된 스레드는 계속 정본으로 유지됩니다. 압축은 프로바이더 컨텍스트만 변경하고 ctx.messages는 변경하지 않습니다. 메시지 스토어는 제거된 콘텐츠를 유지하며, 요약이 이전 턴을 대체하지 않고 삭제된 도구 출력도 다시 로드할 수 있습니다.

어댑터가 stores.metadata를 제공하면 withPersistence가 이를 다른 미들웨어에 노출합니다. 압축은 검증된 체크포인트에 이를 자동으로 사용합니다. 압축과 영속성을 참고하세요.

영속화되는 항목과 시점

withPersistence는 다시 로드해도 턴이 손실되지 않도록 시점에 기록합니다.

시점기록되는 항목최선의 노력인가요?
실행 시작 (onStart)생성 중간에 다시 로드해도 질문이 표시되도록 대기 중인 턴(방금 제출한 사용자 메시지 + 이전 기록)예. 실패해도 실행을 중단하지 않으며 완료가 권위 있는 저장입니다.
인터럽트 경계새 인터럽트 레코드, 실행 상태 interrupted, 알려진 사용량, 현재 메시지의 스레드 스냅샷아니요. 스토어 오류가 전파됩니다.
완료 (onFinish)완료된 어시스턴트 메시지, 인플레이스 다시 로드 식별을 위한 최종 응답 스트림의 messageId, 완료된 구조화된 출력 파트를 포함한 전체 대화 기록, 실행 상태 completed, 알려진 사용량 및 소비된 재개 처리의 커밋아니요. 실행이 완료됨으로 표시되기 전에 대화 기록을 저장합니다.
스트리밍 중 선택 사항snapshotStreaming: true일 때 제한된 빈도로 기록하는 부분 어시스턴트 텍스트
const streamingMiddleware = [
withPersistence(persistence, { snapshotStreaming: true }),
]

스트리밍 스냅샷은 기본적으로 꺼져 있습니다(완료가 권위 있는 저장입니다). 부분 출력의 내구성을 위해 추가 기록을 허용하려면 활성화합니다. 간격은 snapshotIntervalMs(기본값 1000)로 조정합니다.

채팅 엔진은 onFinish가 실행되기 전에 정본 대화 기록을 완료하며, withPersistence는 해당 기록을 직접 저장합니다.

  • 네이티브 결합 출력은 최종 어시스턴트 메시지에 구조화된 결과를 유지합니다.
  • 별도의 완료 처리는 일반 텍스트 어시스턴트 메시지 다음에 구조화된 출력 어시스턴트 메시지를 보존할 수 있습니다.
  • 하네스 어댑터는 실행 중 structured-output.complete를 내보냅니다. 새 메시지 ID는 산문과 구조화된 출력을 두 개의 어시스턴트 메시지로 저장합니다. 마지막 텍스트 메시지 ID는 두 항목을 하나의 어시스턴트 메시지에 유지합니다.

서버 권위 클라이언트는 마운트 시 해당 대화 기록으로 하이드레이션합니다. 재구성된 구조화된 출력 파트는 messages[].parts를 순회하여 확인합니다.

import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
import { z } from 'zod'

const PersonSchema = z.object({ name: z.string() })

function PersistentStructuredChat({ threadId }: { threadId: string }) {
const { messages } = useChat({
threadId,
connection: fetchServerSentEvents('/api/chat'),
persistence: true,
outputSchema: PersonSchema,
})

return (
<div>
{messages.map((message) => {
const part = message.parts.find(
(candidate) => candidate.type === 'structured-output',
)
if (!part) return null
const person = part.data ?? part.partial
return <p key={message.id}>{person?.name}</p>
})}
</div>
)
}

이에 대응하는 서버 GETreconstructChat을 사용합니다. 클라이언트 영속성을 참고하세요.

오류가 발생하면 실행이 failed로 표시됩니다. 중단되면 실행이 finishedAt과 함께 aborted로 표시됩니다. interrupted는 인터럽트 경계에서만 기록되며 터미널 상태가 아닙니다. 두 터미널 경로 모두 실패 또는 중단 전에 보고된 사용량을 보존합니다. onConfig에서 수락한 재개는 성공 경계(인터럽트 또는 완료)에 도달할 때까지 소비되지 않습니다. 재개 커밋 전에 실행이 실패하면 대기 중인 모든 인터럽트를 다시 시도할 수 있습니다. 원자적 commitBatch()가 실패한 경우에도 동일하게 전체 배치가 대기 상태로 남습니다.

레거시 순차 대체 방식은 다릅니다. 앞선 항목이 커밋된 후 기록이 실패할 수 있습니다. 스레드의 현재 대기 중인 인터럽트 레코드를 다시 로드하고 남은 배치만 제출합니다. 원래의 전체 배치를 다시 보내지 마세요.

한 번의 중단이 항상 터미널 상태로 만드는 것은 아닙니다. 다른 미들웨어가 분리 가능으로 선언한 실행(내구성 있는 이벤트 로그와 실행 스토어가 있으며, 실제로는 내구성이 연결된 withSandbox)에서 단순한 클라이언트 연결 해제가 발생한 경우가 그렇습니다. 이 경우 onAbort는 아무것도 기록하지 않고 레코드는 'running'으로 유지되며, 분리 경로는 detachedSince를 기록하여 이후 요청이 실행을 인계받을 수 있게 합니다. 연결 해제만으로 의도를 추론하지 않습니다. Stop을 누른 경우와 탭을 닫은 경우의 연결 종료가 동일하기 때문입니다. 취소는 대역 외에서 실행 자체의 중단 이유 또는 RunRecord.cancelRequested로 도착하며, 둘 중 하나가 중단을 다시 터미널 상태로 만듭니다. 인계 및 분리된 실행을 참고하세요.

실행 레코드는 다음 수명 주기를 거칩니다. completed, failed, aborted는 터미널 상태이고 interrupted는 터미널이 아닌 대기 상태입니다. 일반적인 클라이언트 흐름은 새 runId로 연속 실행을 시작합니다. 서버 통합은 같은 runId를 재사용할 수 있으며, createOrResume는 다음 인터럽트 또는 터미널 경계까지 상태를 interrupted로 유지합니다. findActiveRunrunning 레코드만 반환하므로 해당 연속 실행이 진행되는 동안 같은 ID의 연속 실행을 찾을 수 없습니다.

stateDiagram-v2
[*] --> running : run starts (idempotent createOrResume)
running --> completed : finish, transcript saved first
running --> failed : error
running --> aborted : abort (explicit cancel, or a non-detachable run)
running --> interrupted : interrupt boundary
running --> running : plain disconnect on a DETACHABLE run (detachedSince set, taken over later)
completed --> [*]
failed --> [*]
aborted --> [*]
interrupted --> [*] : continuation may use a new runId
interrupted --> interrupted : same runId pauses again
interrupted --> completed : same runId completes
interrupted --> failed : same runId fails
interrupted --> aborted : same runId aborts

인터럽트는 재시작 후에도 유지됩니다

실행이 인터럽트(도구 승인, 클라이언트 측 도구 또는 일반 미들웨어 요청)에서 일시 중지되면 미들웨어가 이를 기록합니다. 해당 스레드의 이후 요청은 새 입력을 받기 전에 대기 중인 인터럽트에 답하는 resume 배치를 포함해야 합니다. 그렇지 않으면 거부되므로 위 예제에서 params.resume을 전달합니다.

혼합 배치의 경우 영속성 미들웨어가 계속 진행하기 전에 모든 항목을 검증합니다. 해결되거나 취소된 모든 항목을 하나의 성공 경계에서 커밋합니다. InterruptStore는 해당 기록을 원자적으로 만들도록 commitBatch()를 구현할 수 있습니다. 이를 구현하지 않으면 호환성 대체 방식이 항목을 한 번에 하나씩 기록하므로 원자적이지 않습니다.

영속성은 서버 권위 재개 경로입니다. 미들웨어는 대기 중인 인터럽트에 대해 재개 배치를 검증하고 ChatResumeToolState(승인 / 클라이언트 도구 결과 / 일반 요청)을 생성하고, 채팅 엔진이 임시 재구성을 건너뛰도록 config.resume삭제합니다. 임시 재구성에는 영속성 흐름에서 의도적으로 제외한 클라이언트 메시지 기록이 필요합니다. 재개는 실행이 성공적인 인터럽트 또는 완료 경계에 도달한 경우에만 커밋됩니다(스토어에서 해결됨/취소됨으로 처리됩니다).

onInterruptResolution은 영속성이 없는 요청과 같은 시점, 즉 초기화 후 onConfigonStart 전에 실행됩니다. 답변 적용을 참고하세요.

인터럽트 레코드는 pending으로 생성되며 커밋을 통해서만 상태가 변경됩니다. commitBatch()를 사용하면 커밋 실패 시 전체 배치에 다시 답할 수 있습니다. 순차 대체 방식을 사용한다면 일부 앞선 항목이 이미 해결되거나 취소되었을 수 있으므로 먼저 다시 로드합니다.

stateDiagram-v2
[*] --> pending : run pauses, interrupt recorded
pending --> resolved : resume answers it, committed at a success boundary
pending --> cancelled : resume cancels it
resolved --> [*]
cancelled --> [*]

다음 단계

  • 브라우저에도 내구성을 추가하여 전체 페이지를 다시 로드해도 대화를 복원하고 진행 중인 실행에 다시 참여하도록 합니다: 클라이언트 영속성.
  • 코어 위에 백엔드를 구축하고 스토어 계약을 확인합니다: 어댑터 직접 만들기. 이미 사용 중인 항목이 무엇이든(Drizzle, Prisma, Cloudflare D1, raw SQL), 제공된 에이전트 스킬npx @tanstack/intent@latest install로 설치하고 어시스턴트가 기존 스키마에 맞는 chat-persistence.ts를 작성하도록 합니다.
  • 실행할 스토어를 선택합니다: 컨트롤.