본문으로 건너뛰기

ID 맵

알아야 할 ID는 두 개뿐이며, 이를 혼동하면 영속성이 작동하지 않는 것처럼 보입니다.

ID이름수명직접 제공하는지
threadId실행이 속하는 대상: 대화 또는 생성 슬롯앱이 같은 문자열을 계속 사용하는 동안예, 자체 도메인에서 제공합니다
runId하나의 실행: 스트리밍 답변 하나 또는 생성 작업 하나시작 시 생성되고 종료 시 사라집니다아니요, 대신 생성됩니다

영속성은 threadId를 기준으로 저장하고 복원합니다. runId는 현재 실행 중인 작업에 관해 자체 서버와 통신해야 할 때 읽는 값입니다.

threadId를 생략하면 뷰가 마운트된 후 클라이언트가 하나를 생성합니다. 이 임시 ID가 이 세션의 wire thread와 DevTools 행입니다. 새로고침하면 새 대화가 시작됩니다. 영속성을 사용하려면 앱에서 명시적인 threadId를 제공해야 합니다.

별도의 id를 전달하지 마세요. 채팅 및 생성 클라이언트에서 유일한 식별자는 threadId입니다.

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

export function ProductPage({ productId }: { productId: string }) {
// Chat: the thread id names a conversation.
const support = useChat({
threadId: `support-${productId}`,
connection: fetchServerSentEvents('/api/chat'),
persistence: true,
})

// Generation: the thread id names a slot that jobs fill.
const hero = useGenerateImage({
threadId: `product-${productId}-hero`,
connection: fetchServerSentEvents('/api/generate/image'),
persistence: true,
})

// Each hook also reports the id of whatever is running right now.
return (
<p>
chat run {support.runId ?? 'none'}, image job {hero.runId ?? 'none'}
</p>
)
}

threadId: 모든 항목을 분류하는 키

레코드는 threadId마다 기록되고 새로고침 시 threadId 조회됩니다. 새로고침 후 문자열이 동일하지 않으면 찾을 수 있는 항목이 없습니다. 자체 도메인에서 ID를 정하고 안정적으로 유지하면 복원이 작동합니다. 마운트할 때마다 새 ID를 생성하면 아무것도 복원되지 않습니다.

채팅에서는 thread가 대화입니다

해당 thread의 모든 실행은 하나의 확장되는 트랜스크립트에 메시지를 추가하며, 트랜스크립트는 thread ID 아래에 저장됩니다. 복원은 해당 트랜스크립트를 재생하는 것입니다. 실행은 사용자가 볼 수 없는 내부 세부 정보이며, 사용자는 대화만 봅니다.

생성에서는 thread가 슬롯입니다

생성 작업은 어떤 항목에도 추가되지 않고 하나의 결과를 생성합니다. 따라서 각 작업은 thread에 연결된 자체 레코드를 가지며, 복원 시 해당 thread의 가장 최근 작업인 상태, 오류, 결과 메타데이터가 반환됩니다. 같은 대상에 대한 연속 작업(첫 시도, 재시도, 프롬프트를 수정한 후의 재생성)은 모두 하나의 슬롯에 저장되며, 사용자는 최신 작업을 보게 됩니다.

따라서 생성 thread ID는 대화가 아니라 앱 내 위치처럼 읽힙니다.

화면에 표시되는 대상적절한 threadId
제품의 히어로 이미지product-${productId}-hero
동영상의 시작 프레임video-${videoId}-start-frame
챕터의 보이스오버chapter-${chapterId}-narration
업로드 파일의 트랜스크립션upload-${uploadId}-transcript
지원 대화chat-${conversationId}

"하나의 슬롯에서 최신 작업이 승리한다"는 원칙에서 두 가지 규칙이 따릅니다.

  • 서로 다른 대상에는 서로 다른 ID가 필요합니다. 히어로 이미지 훅과 썸네일 훅이 같은 thread를 가리키면 각각 상대 UI에서 마지막으로 실행된 작업을 복원합니다.
  • 같은 대상은 ID를 영구적으로 유지합니다. 재생성은 새 슬롯이 아니라 같은 슬롯의 새 작업입니다. 따라서 새로고침하면 최신 시도가 표시됩니다.

runId: 하나의 실행

하나의 run은 RUN_STARTED부터 RUN_FINISHED까지의 모든 과정입니다. 매번 새로 생성되고 종료되면 폐기됩니다. 두 종류의 훅 모두 이 클라이언트에서 현재 진행 중인 run을 보고하며, 없으면 null을 보고합니다.

채팅에서는 run이 한 턴입니다

run은 턴마다 바뀌며, 사용자의 동작과 일대일로 대응하지 않습니다.

  • 전체 도구 루프가 하나의 run입니다. 모델이 도구를 호출하고, 사용자가 결과를 반환한 다음 모델이 다른 도구를 호출하고 최종 답변을 작성합니다. 에이전트 사이클에 루프가 몇 번 포함되더라도 이는 하나의 runId입니다.
  • 사용자 메시지 하나가 여러 run을 만들 수 있습니다. 인터럽트에서 일시 중지하면 run이 종료되고, 재개하면 같은 턴이 새로운 runId로 계속됩니다. 도구 두 개를 순서대로 승인하면 하나의 메시지가 세 run에 걸칠 수 있습니다. 승인을 기다리며 run이 일시 중지된 동안에는 진행 중인 작업이 없으므로 runIdnull입니다.

따라서 useChat().runId는 "이 클라이언트가 지금 실행 중인 것은 무엇인가"에 답하며, "이것은 어떤 메시지인가"에는 답하지 않습니다. 라이브 구독에서는 다른 클라이언트가 시작한 run을 취소할 수 없으므로 여기에는 보고되지 않습니다.

flowchart TB
subgraph chat ["useChat, threadId: support-42"]
direction LR
c1["run r1
tool loop, one turn"] --> c2["run r2
interrupted"] --> c3["run r3
the resume of that same turn"]
end

subgraph gen ["useGenerateImage, threadId: product-7-hero"]
direction LR
g1["job g1
first attempt"] --> g2["job g2
retry"] --> g3["job g3
running"]
end

chat -. "one transcript, keyed by threadId" .-> cstore["messages store"]
gen -. "one record per job, newest restores" .-> gstore["generationRuns store"]

생성에서는 run이 작업입니다

generate(...)를 한 번 호출하면 하나의 runId를 가진 하나의 작업이 생성됩니다. 도구 루프도 인터럽트도 없으므로 대응 관계는 정확히 일대일입니다. runId는 현재 진행 중인 provider 작업의 핸들입니다.

따라서 자체 서버에 전달할 ID가 됩니다. stop()은 로컬 스트림만 중단하고, provider에서 이미 크레딧을 소모하며 렌더링 중인 동영상은 중지하지 않기 때문입니다:

import { fetchServerSentEvents, useGenerateVideo } from '@tanstack/ai-react'

export function VideoPanel({ videoId }: { videoId: string }) {
const video = useGenerateVideo({
threadId: `video-${videoId}-clip`,
connection: fetchServerSentEvents('/api/generate/video'),
persistence: true,
})

async function cancel() {
// Stop the provider job server-side, then drop the local stream.
if (video.runId) {
await fetch(`/api/generate/video/cancel?runId=${video.runId}`, {
method: 'POST',
})
}
video.stop()
}

return (
<button type="button" onClick={() => void cancel()} disabled={!video.runId}>
Cancel
</button>
)
}

내구성 로그도 같은 ID를 키로 사용하므로, 서버 전체에서 하나의 실행을 추적할 때 로그 행에 기록하기에 적절한 값이기도 합니다.

복원 키가 run이 아닌 thread에 있는 이유

방금 새로고침된 페이지는 마지막 runId가 무엇이었는지 알 수 없으므로 이를 요청할 수 없습니다. 하지만 앱이 제품 ID, 경로 매개변수 또는 동영상 ID에서 도출한 threadId는 알고 있습니다. 따라서 클라이언트가 thread를 제시하면 저장소가 "이 thread에서 발생한 일과 아직 실행 중인 run이 있다면 그 run은 이것입니다"라고 응답합니다.

그 후에야 클라이언트가 해당 run의 전달 로그를 이어받습니다. 한 단계 아래에서는 run ID가 여전히 필수이지만, 진입점은 아닙니다. 프로토콜 구조는 스레드와 run을, 로그 자체는 재개 가능한 스트림을 참조하세요.

양쪽에서 같은 thread ID 사용

영속성은 클라이언트와 서버가 같은 문자열 아래에 기록할 때만 작동합니다. 클라이언트에서는 훅의 threadId이고, 서버에서는 activity의 threadId입니다. 생성의 경우 미들웨어가 activity에서 직접 읽으므로 withGenerationPersistence에 다시 지정할 필요가 없습니다:

import {
generateImage,
generationParamsFromRequest,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiImage } from '@tanstack/ai-openai'
import {
memoryPersistence,
withGenerationPersistence,
} from '@tanstack/ai-persistence'

const persistence = memoryPersistence()

export async function POST(request: Request) {
const { input, threadId } = await generationParamsFromRequest('image', request)

// No scope, nothing to file the job under, so nothing could ever hydrate it.
// Reject instead of inventing an id.
if (threadId === undefined) {
return new Response('`threadId` is required', { status: 400 })
}
if (typeof input.prompt !== 'string') {
throw new Error('This endpoint accepts text image prompts only.')
}

const stream = generateImage({
adapter: openaiImage('gpt-image-2'),
prompt: input.prompt,
threadId,
stream: true,
middleware: [withGenerationPersistence(persistence)],
})

return toServerSentEventsResponse(stream)
}

클라이언트가 threadId를 wire로 대신 전송하므로 훅 쪽에서는 옵션으로 지정하기만 하면 됩니다. 채팅도 같은 구조입니다. useChat에 전달한 threadIdchatParamsFromRequest가 라우트에 전달하고 withPersistence가 저장하는 값입니다. 전체 연결 방법은 채팅 영속성생성 영속성을 참조하세요.

threadId를 생략할 수 있는 경우

영속성을 사용하지 않으면 threadId는 선택 사항입니다. 뷰가 마운트된 후 클라이언트가 프로토콜을 충족하기 위해 임시 thread ID를 생성합니다. 이 ID는 세션의 DevTools 행이기도 합니다. run은 작동하지만 다시 찾을 수 없으며, 보여준 후 잊어도 되는 일회성 이미지에는 적합합니다.

persistence를 켜면 훅과 미들웨어가 래핑하는 activity 모두에서 threadId가 필수가 됩니다. activity와 자체 threadId override 중 어느 쪽도 제공하지 않으면 withGenerationPersistence가 예외를 발생시킵니다. 슬롯의 이름을 지정할 수 없는 앱에는 복원할 대상이 없습니다.

복원이 아무 작업도 하지 않는 경우

거의 항상 다음 중 하나입니다:

  • thread ID가 변경되었습니다. ID에 crypto.randomUUID() 또는 useId()를 사용하면 마운트할 때마다 새 키가 생성됩니다. 양쪽에서 ID를 기록하고 두 문자열을 비교하세요.
  • 클라이언트와 서버의 값이 다릅니다. 훅은 한 ID 아래에 기록하고 미들웨어는 다른 ID 아래에 기록합니다. 두 값은 정확히 일치해야 합니다.
  • run ID를 키로 사용했습니다. 새로 로드된 페이지에서는 runId만으로 영속 데이터를 조회할 수 없습니다. 복원은 thread에서 시작합니다.
  • 바이트 저장이 꺼져 있습니다. 생성의 경우 레코드에 미디어 바이트가 저장되지 않으므로 statuserror는 돌아오지만 resultnull로 유지됩니다. 미디어도 복원하려면 바이트 저장을 추가하세요.