영속적 실행 설명
아래 코드 조각으로 활성화합니다. 이 페이지의 나머지 부분은 각 구성 요소를 더 깊이 다루는 The Run Journal, Takeover & Detached Runs, Reaping & Retention을 읽기 전에 알아둘 개념을 쉬운 말로 설명합니다.
활성화하기
withSandbox에 두 옵션을 설정하면 영속적 실행을 사용할 수 있습니다. 둘 중 하나만
전달하면 영속성을 요청하지 않은 것으로 간주되어 기본값인 연결 해제 시 삭제 동작이
조용히 적용됩니다.
import {
chat,
chatParamsFromRequest,
memoryStream,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { withLocks } from '@tanstack/ai/locks'
import { claudeCodeText } from '@tanstack/ai-claude-code'
import { withPersistence } from '@tanstack/ai-persistence'
import { withSandbox } from '@tanstack/ai-sandbox'
// Your stores, your `defineSandbox(...)` result, your distributed LockStore.
import { locks } from './locks'
import { persistence } from './persistence'
import { sandbox } from './sandbox'
export async function POST(request: Request) {
const { messages, threadId, runId } = await chatParamsFromRequest(request)
// ONE adapter, handed to both the middleware and the response, so the journal
// and the delivery log describe the same run.
const adapter = memoryStream(request)
const stream = chat({
adapter: claudeCodeText('claude-opus-4-8'),
messages,
threadId,
// Required: the journal path and the log name are both derived from it.
runId,
middleware: [
withPersistence(persistence),
withLocks(locks),
withSandbox(sandbox, {
runs: persistence.stores.runs,
durability: { adapter },
}),
],
})
return toServerSentEventsResponse(stream, { durability: { adapter } })
}
여기까지가 생성 측 절반입니다. 돌아온 클라이언트에는 로그를 재생하고 실행을 인계받는
GET이 필요하며, 이는 Takeover & Detached Runs에서 다룹니다.
그다음에는 사람들이 자주 잊지만 문제가 발생하기 전까지는 괜찮아 보이는 두 가지가 있습니다.
- 스위퍼를 예약합니다. cron 라우트, 큐 소비자, Durable Object
alarm()등 플랫폼이 제공하는 방식을 사용합니다. 이를 건너뛰면 연결이 끊긴 독자가 아무도 닫지 않을 로그를 영원히 기다리는 동안 샌드박스에 요금이 계속 부과됩니다. 아무런 경고도 표시되지 않습니다. Reaping & Retention을 참조하세요. - 실제 분산 잠금을 사용합니다. 메모리 기반
LockStore는 호스트 간 조정이 불가능하므로 두 복제본의 경쟁을 막을 수 없습니다.withSandbox는 이 조합을 발견하면 경고합니다.
이 페이지의 나머지 부분에서는 이것이 작동하는 이유와 그 비용을 설명합니다.
문제
ChatGPT와 비슷하지만 샌드박스 안에서 작업하며 하나의 작업을 완료하는 데 10분이 걸릴 수 있는 코딩 에이전트를 만들고 있다고 가정합니다.
브라우저 탭에서 10분은 긴 시간입니다. 사용자가 새로 고침을 하거나, 노트북을 닫거나, Wi-Fi 연결을 잃을 수 있습니다. 또는 다음 요청이 작업을 실행 중인 복제본과 다른 복제본에 도착할 수 있습니다.
영속성이 없으면 발생하는 일
연결이 끊기면 모든 것이 종료됩니다. 샌드박스가 삭제되고 작업이 버려지며 사용자가 돌아왔을 때 아무것도 남지 않습니다.
이는 버그가 아닙니다. 기본적으로 사용할 수 있는 가장 덜 나쁜 선택입니다. 샌드박스에서 실행 중인 에이전트로 향하는 파이프를 닫아도 에이전트는 중지되지 않습니다. 계속 작업하며 토큰 비용도 계속 발생합니다. 따라서 연결이 끊긴 작업이 비용을 계속 소모하지 않도록 확실히 하는 유일한 방법은 샌드박스를 삭제하는 것입니다.
사용자가 Stop을 눌렀다면 이는 정확히 올바른 동작입니다. 하지만 단지 페이지를 새로 고침한 경우에는 완전히 잘못된 동작입니다.
영속성이 바꾸는 것
영속적 실행은 시스템이 두 상황을 구분하고 새로 고침 상황을 올바르게 처리하도록 합니다. 구성 요소는 네 가지입니다.
1. 연결 해제가 더 이상 아무것도 종료하지 않습니다
실행이 분리됩니다. 에이전트는 계속 작업하고 샌드박스는 유지되며 실행 레코드에는 _"오후 3시 42분 현재 아무도 지켜보고 있지 않음"_이 기록됩니다.
2. 에이전트가 출력을 네트워크로 보내는 대신 파일에 기록합니다
이것이 핵심 방법입니다. 에이전트가 브라우저와 직접 통신하면 브라우저가 떠나는 순간 내용이 사라집니다. 샌드박스 안의 파일에 기록하면 누군가 돌아왔을 때도 내용이 그대로 남아 있습니다. 이 파일이 저널입니다.
3. 누군가 돌아오면 문장 중간부터 이어갑니다
새 요청은 다른 복제본에서 처리될 수도 있으며, 파일을 읽고 사용자가 이미 본 부분을 계산한 뒤 보지 못한 부분만 스트리밍합니다. 문단이 반복되거나 누락되지 않습니다. 이것이 인계입니다.
4. 돌아오지 않는 사용자의 실행을 정리해야 합니다
그렇지 않으면 아무도 읽지 않을 작업으로 샌드박스가 영원히 실행됩니다. 따라서 스위퍼가 필요합니다. 스위퍼는 버려진 실행을 찾고 에이전트가 스스로 완료했는지 확인한 뒤 실행을 마무리하거나 중지합니다. 이것이 리퍼입니다.
과도하게 설계된 것처럼 들리지만 그렇지 않은 두 부분
두 복제본이 하나의 실행을 동시에 진행하지 않도록 보장하기
사용자가 같은 스레드를 두 탭에서 열거나 로드 밸런서가 재시도를 다른 곳으로 보내면, 두 복제본이 하나의 실행을 계속 진행하려 할 수 있습니다. 그러면 사용자에게 텍스트가 중복되고 서로 모순되는 "완료" 메시지가 표시됩니다.
따라서 복제본은 실행을 진행하기 위해 번호가 매겨진 티켓을 받아야 합니다. 더 새로운 복제본이 더 높은 번호를 받으면 이전 복제본은 어떤 것도 쓸 수 없도록 차단됩니다. 단순히 자제하도록 하는 것이 아니라 로그에 추가하거나 실행을 완료로 표시하는 것조차 할 수 없습니다.
"에이전트가 완료됨"을 위조할 수 없도록 만들기
실행이 끝났다는 것을 확인하는 방법은 저널 파일 끝에 특수한 줄이 나타나는지 보는 것입니다. 하지만 그 파일은 에이전트 자체가 기록하며 에이전트는 모델이 말하는 내용을 그대로 기록합니다.
모델이 우연히 해당 줄을 출력하면 리퍼는 실행 중인 작업이 끝났다고 믿고 실행 중인 샌드박스를 중지할 수 있습니다. 따라서 해당 줄에는 실행 자체의 ID에서 파생된 비밀 값이 포함됩니다. 에이전트는 이를 생성할 수 없으므로 자신의 샌드박스를 삭제할 수도 없습니다.
두 파이프는 끊길 수 있으며 서로 다른 해결책이 필요합니다
실행 출력은 사용자에게 전달되는 동안 두 파이프를 통과하며, 이 페이지의 모든 내용은 둘 중 하나가 끊기는 문제를 해결합니다.
agent (in sandbox) ──[capture]──▶ server host ──[delivery]──▶ client
│
└──▶ durable delivery log
전달(호스트 → 클라이언트)은 브라우저가 새로 고침되거나 연결이 끊길 때 중단됩니다. 전달 로그가 이를 해결합니다. 호스트는 스트리밍하는 각 청크를 추가하고, 돌아온 클라이언트는 중단된 지점부터 로그를 따라갑니다. 이것이 resumable streams이며 이 절반을 완전히 해결합니다.
캡처(에이전트 → 호스트)는 실행 중 _호스트_가 중단될 때 끊깁니다. 에이전트는 반대편이 사라진 파이프로 계속 출력하며, 문제는 로그 기록기보다 상류에 있으므로 전달 로그의 영속성으로는 해결되지 않습니다. 저널은 기록을 샌드박스 내부로 옮겨 이를 해결합니다. 에이전트가 파일에 기록하므로 바이트 생성자와 저장소가 운명을 공유하며, 그 경로에는 중단될 호스트가 없습니다.
당연한 질문은 캡처 문제의 해결책이 에이전트를 같은 영속 로그에 연결하는 대신 왜 파일인지입니다. 답은 다음에서 시작합니다. 에이전트는 프로토콜을 말할 수 없습니다. 에이전트는 다른 프로그램의 CLI가 출력하는 원시 텍스트입니다. 전달 로그의 청크, 오프셋, 커서는 호스트가 해당 출력을 변환한 뒤에만 존재합니다. 샌드박스가 로그를 기록하도록 하려면 변환기, 네트워크 클라이언트, 자격 증명을 컨테이너에 배포해야 합니다. 또한 클라이언트가 렌더링하는 것이 로그이므로, 그렇게 하면 모델 기반 프로세스에 클라이언트가 보는 진실에 대한 쓰기 권한을 부여하고 위의 번호가 매겨진 티켓 펜싱을 우회하게 됩니다(종료 센티넬을 위조할 수 없게 만드는 것과 같은 우려입니다). 파일에는 이 중 어느 것도 필요하지 않습니다. 네트워크도, 자격 증명도 필요 없으며, 에이전트가 손상시킬 수 있는 것은 자신의 출력뿐이고 호스트가 반환하는 과정에서 이를 검증합니다.
일반 영속성은 어느 파이프에도 해당하지 않습니다. 메시지 저장소는 사후에 기록된 완료된 대화를 보관하고 실행 간 스레드 기록을 복원할 뿐, 진행 중인 실행을 복원하지 않습니다.
두 계층
위의 모든 내용은 두 배포 계층을 가진 하나의 프로토콜이며, 두 계층의 유일한 차이는 출력의 영속적 사본이 위치하는 곳입니다.
Journal-only는 인프라가 필요 없는 기본 계층입니다. 실행 출력의 유일한 영속적 사본은 샌드박스 내부의 저널 파일이므로 로그의 영속성은 샌드박스 수명과 같습니다. 샌드박스가 실행 중인 한 어떤 호스트든 파일에서 전체 실행을 재구성할 수 있습니다. 추가로 배포할 것이 없으며, 연결 페이지를 작성된 대로 구성하면 이것을 사용하게 됩니다.
Log-first는 샌드박스 _외부_에 영속적 전달 로그를 추가하며 모든 청크를 두 곳에 기록합니다. 클라이언트는 항상 로그만 따라가므로 다시 연결할 때 샌드박스에 전혀 접근하지 않습니다. 저널이 사라지는 것은 아닙니다. 저널은 드라이버 복구, 즉 준비된 재연결과 재개하는 드라이버가 실행을 이어갈 지점을 찾는 용도로 격하됩니다.
두 사본이 모두 존재할 때 우선순위는 한 문장으로 설명할 수 있습니다. 클라이언트가 보는 내용에는 로그가 우선하고, 드라이버가 재개할 위치에는 저널이 우선합니다. 정렬이 바로 이를 구현합니다. 저장된 로그는 이미 전달된 사실로 취급하고, 저널 재생은 추가를 시작할 위치를 계산하는 데만 사용합니다.
Cloudflare는 병렬 아키텍처가 아니라 Durable Object 기반 구현을 사용하는 log-first 계층입니다(DO가 실행을 소유하고 이벤트 로그를 영속화하며 클라이언트는 DO를 따라갑니다). Cloudflare (Edge)를 참조하세요. 선택하기 전에 알아둘 비용은 log-first가 모든 청크를 두 번(저널 + 로그) 기록한다는 점이며, 이것이 journal-only가 기본값으로 유지되는 이유입니다.
관련 항목
- The Run Journal: 파일과 파일을 사용하는 이유
- Takeover & Detached Runs: 두 라우트의 구성
- Reaping & Retention: 스위퍼와 예약 방법
- Instance Durability: 복제본 간에 _샌드박스_를 찾을 수 있게 유지하는 기능으로, _출력_을 읽을 수 있게 유지하는 것과는 별개의 문제입니다.