에이전트와 LLM 제공자 사이에 내구성 있는 버퍼를 두면, 스트리밍 도중 프로세스가 죽어도 이미 비용을 낸 토큰을 다시 지불하지 않고 복구할 수 있다. 같은 로그로 재연결과 크래시 복구를 모두 처리하는 방법을 설명한다.
(이 글 자체도 LLM 슬롭이지만, 맛은 꽤 괜찮다)
tl;dr - 에이전트와 LLM 제공자 사이에 내구성 있는 버퍼를 두어라. 그러면 제공자 연결이 이제 당신의 프로세스보다 오래 살아남고, 스트림 도중 배포가 일어나도 이미 비용을 낸 토큰을 날리지 않는다. 그리고 연결이 끊긴 브라우저가 다시 따라잡게 해 주는 바로 그 버퍼가, 크래시 난 턴도 복구해 준다. 하나의 로그, 두 명의 리더다.
지난 몇 주 동안 나는 한 가지 질문에 붙잡혀 있었다. 에이전트를 실행하던 프로세스가 턴 도중 죽어버리면, 그 에이전트에는 무슨 일이 일어날까?
이건 금방 깊어진다. 실행됐을 수도 있고 아닐 수도 있는 도구 호출. 서브 에이전트. 사람을 기다리며 반쯤 써진 스트림. 이 모든 건 따로 정리하고 있다(내구성 있는 에이전트 루프, 곧 공개). 하지만 그중 한 조각은 작고 독립적이어서 따로 떼어내 이야기할 수 있다.
프로세스가 추론 도중 죽으면, 위치만 잃는 게 아니다. 돈도 잃는다.
에이전트가 모델에 스트리밍 요청을 열고, 모델이 생성을 시작한다. 당신은 그 출력 토큰이 생성되는 순간 바로 과금된다. 그러고 나서 프로세스가 교체된다. 배포일 수도 있고, eviction일 수도 있고, OOM일 수도 있다.
보통은 “걱정 마라, 상태는 내구적이다”라는 위안이 따라온다. 물론 대화 이력은 살아남았다. 하지만 제공자에 대한 비행 중 HTTP 요청 은 살아남지 못했다. 그건 방금 죽은 프로세스의 메모리 안에만 있었다. 그래서 복구할 때 당신에게 남는 선택지는 그 호출을 다시 하는 것뿐 이다. 이미 한 번 비용을 낸 출력 토큰에 대해 두 번째로 돈을 낸다.
이제 이걸 에이전트로 생각해 보자. 진짜 에이전트는 한 턴 안에서 여러 번 도구를 호출한다.
user message
→ stream some text
→ tool call → tool result
→ stream more text
→ tool call → tool result
→ stream the answer
모든 중단은 그 턴에서 지금까지 생성된 모든 출력 토큰을 버리게 만든다. 그리고 이건 실제로 쓰고 싶은 모델일수록 더 커진다. gpt-5.5의 출력은 백만 토큰당 $30인데 gpt-5.5-mini는 $2다. 즉 플래그십 모델 재시도는 mini보다 대략 15배를 태운다. 모델이 좋을수록 더 아프다. 배포는 끊임없이 일어나고, eviction도 끊임없이 일어나며, 그중 하나라도 라이브 스트림 위에 떨어지면 그건 그대로 창밖으로 날아가는 돈이다.
행복 경로에서는 이게 가려진다. 사고 후에 토큰을 세기 시작하고 나서야 숫자가 맞지 않는다는 걸 보게 된다.
크래시가 토큰을 낭비하게 만드는 이유는 제공자 연결이 죽은 그 대상 내부에 살고 있기 때문이다. 그러니 그걸 밖으로 옮겨라.
에이전트와 제공자 사이에 버퍼를 두고, 그 버퍼를 별도 배포 로 만들어라. 자기만의 Worker, 자기만의 Durable Object.
요청이 들어오면, 버퍼는 순서대로 세 가지를 한다. 새 스트림을 위해 상태를 리셋한다. 제공자 연결을 SQLite로 흘려 넣는 백그라운드 작업을 시작한다. 그리고 즉시 호출자에게, 같은 행들이 들어오는 대로 따라가는 스트림을 돌려준다.
async proxyAndBuffer(req: ProviderRequest): Promise<Response> {
this.resetBuffer(); // status = "streaming", chunkCount = 0
const reader = (await fetch(req.url, req)).body!.getReader();
// drain the provider in the background. deliberately NOT awaited - the
// response below returns right away while this keeps running.
this.keepAliveWhile(() => this.consumeProvider(reader));
// give the caller a stream that tails the rows as they're written.
return new Response(this.tailFrom(0), {
headers: { "X-Buffer-Status": "streaming" }
});
}
private async consumeProvider(reader: Reader) {
for (let i = 0; ; i++) {
const { done, value } = await reader.read();
if (done) break;
this.sql`INSERT INTO buffer_chunks VALUES (${i}, ${decode(value)})`;
this.notify(); // wake any tailers (more below)
}
this.setStatus("completed");
}
핵심 하중을 받는 부분은 consumeProvider가 어디에 붙어 있지 않은가 이다. 이건 에이전트 내부에서 돌지 않는다. 여기서, 에이전트 배포의 영향을 받지 않는 별도 배포 안에서 돈다. 그래서 에이전트가 스트림 도중 eviction되어 tail 연결이 취소되더라도, drain 루프는 계속 읽는다. 당신이 비용을 낸 토큰은 누가 듣고 있든 아니든 계속 SQLite에 기록된다.
keepAliveWhile는 drain이 도는 동안 버퍼를 열어 두는 역할을 한다. 긴 생성에는 조용한 구간이 있고, Durable Object는 유휴 상태로 보이면 eviction될 수 있다. keepAliveWhile는 drain이 지속되는 동안 알람에 heartbeat를 넣고, 작업이 끝나거나 예외를 던지는 즉시 그것을 제거한다. 그래서 그 공백 구간에도 버퍼는 살아남고, 이후에는 heartbeat가 새지 않는다.
에이전트가 재시작되면 /resume?from=N을 호출하고 놓친 청크를 받는다. 낭비된 토큰도 없고, 중복 제공자 호출도 없다.
이걸 만들면서 계속 묘한 기분이 들었다. 이 문제의 한 조각은 전에 이미 풀어본 것 같았다. 실제로 그랬다. 이건 재개 가능한 스트리밍 과 같은 문제다. 익숙한 상황이다. 사용자가 응답 도중 노트북을 닫고, wifi에서 셀룰러로 바꾸고, 다시 돌아왔는데 스트림이 그냥… 이어지는 경우다. 이걸 구현하는 방법은 스트리밍되는 모든 청크를 내구성 있는 로그에 저장해 두고, 재연결 시 클라이언트가 저장된 청크를 읽으면서 라이브 커서를 따라잡을 때까지 진행하게 하는 것이다.
복구는 정확히 같은 로그 다. 버퍼는 각 청크를 인덱스로 키잉해 SQLite에 저장한다.
CREATE TABLE buffer_chunks (
chunk_index INTEGER PRIMARY KEY,
data TEXT NOT NULL
)
되읽기도 하나의 함수다. 라이브 프록시는 tailFrom(0)을 호출했고, 재개하는 에이전트는 마지막으로 본 청크 인덱스를 넣어 tailFrom(N)을 호출한다. 차이는 커서뿐이다.
tailFrom(cursor: number): ReadableStream {
return new ReadableStream({
pull: async (c) => {
const rows = this.rowsFrom(cursor); // everything stored since `cursor`
if (rows.length) { c.enqueue(rows); cursor += rows.length; return; }
if (this.isDone()) return c.close(); // completed / interrupted / error
await this.signal.promise; // else wait for the next notify()
}
});
}
그러니 /resume?from=N은 그냥 tailFrom(N)이다. 그리고 이게 맞닥뜨리는 상황은 둘뿐이다.
같은 내구성 로그다. 유일한 차이는 아직 라이브 producer가 붙어 있느냐뿐이다. 재개 가능한 스트리밍은 “클라이언트가 재연결했다. 따라잡게 하자”에 답한다. 복구는 “producer가 죽었다. 그가 쓰던 것을 마무리하자”에 답한다. 재연결을 위해 청크를 영속화해 두면, 크래시 복구의 대부분은 공짜로 따라온다.
버퍼는 이 구분을 상태 기계 안에 명시한다. 재시작 시 자기 상태가 여전히 streaming으로 표시되어 있으면, 이전 인스턴스가 비행 중에 죽었다는 것을 알고 스스로를 interrupted로 바꾼다. 그러면 호출자는 부분 데이터가 오고 있음을 알 수 있다.
idle → streaming → completed → (ack / TTL) → idle
│
│ [DO evicted]
▼
interrupted ← "the producer is gone, here's what I have"
하나 유지할 만한 디테일이 있다. tail reader는 SQLite를 폴링하지 않는다. 토큰이 들어왔는지 보려고 핫 루프에서 데이터베이스를 폴링하는 건 데모에서는 괜찮아 보여도 프로덕션에서는 끔찍하다. 대신 drain 루프의 notify()가 매 insert 뒤에 공유 promise를 resolve하고, tailer는 그냥 그것을 await한다.
이게 가능한 이유는 Durable Object가 단일 스레드로 실행되기 때문이다. insert와 notify가 하나의 동기 블록 안에서 일어나므로, 깨어난 tailer는 항상 새 행이 이미 커밋된 상태를 본다. 레이스도 없고, 조정할 폴 간격도 없다. 런타임이 어려운 부분을 대신 처리해 준다. DO 위에 에이전트를 짓는 가장 큰 이유 중 하나가 바로 그 점이다.
좋다. 이제 런을 되찾았다. 다음은 그걸 에이전트 루프가 이어서 실행할 수 있는 무언가로 바꾸는 일이다. 가장 당연한 접근은 버퍼링된 SSE를 직접 파싱하는 것이다. 그건 함정이다. 그러면 OpenAI 형식용, Anthropic 형식용, Google 형식용의 맞춤 파서를 영원히 떠안게 되고, 그들이 내놓는 모든 wire-format 변경을 쫓아다녀야 한다.
그러니 그러지 마라. raw bytes 를 저장하고, 돌아오는 길에는 제공자의 자체 파서를 재사용하라. 그것이 workers-ai-provider에 들어가는 모델이다. 하나의 provider가 모든 모델을 AI Gateway를 통해 라우팅하고, 각 플러그인은 자기 고유의 wire format을 품고 있으며, resume은 기본 활성화다.
import { createWorkersAI } from "workers-ai-provider";
import { openai } from "workers-ai-provider/openai";
import { anthropic } from "workers-ai-provider/anthropic";
const workersai = createWorkersAI({
binding: env.AI,
providers: [openai, anthropic] // each plugin brings its own SSE parser
});
const result = streamText({
model: workersai("openai/gpt-5.5", { resume: true })
});
// result.response.headers["cf-aig-run-id"] identifies the run to re-attach to.
streamText()는 파싱하고, 도구 루프를 실행하고, reasoning을 처리하고, 새 호출 때와 똑같이 네이티브하게 응답을 렌더링한다. 어디에도 커스텀 SSE 파싱은 없다. 그리고 제공자가 포맷을 바꿔도 비용이 들지 않는다. 당신이 그들의 파서 위에 올라가 있기 때문이다.
eviction 케이스가 핵심이다. 스트림이 도는 동안 onDispatch와 onProgress를 통해 { runId, eventOffset }를 영속화해 두고, 에이전트가 돌아오면 모델을 다시 호출하는 대신 같은 런에 재부착한다.
const stream = createResumableStream({
binding: env.AI,
gateway: "my-gateway",
runId, // saved from cf-aig-run-id
fromEvent: savedOffset, // saved from onProgress
onResumeExpired: "accept-partial" // once the ~5.5 min buffer TTL elapses
});
재과금도 없고, 중복 호출도 없고, 유지해야 할 파서도 없다.
나도 확인해 봤다. “토큰을 절대 낭비하지 마라”는 누군가 이미 만들어 뒀을 것 같았기 때문이다. 결과적으로 한 제공자는 거의 정확히 이것을 자기 API용으로 이미 만들었고, 나머지는 전부 당신 몫으로 남겨둔다.
중요한 질문은 두 가지다.
| 연결이 끊긴 뒤에도 계속 생성하나? | 커서로 재개, 재과금 없음? | 방법 | |
|---|---|---|---|
| OpenAI (Responses, background mode) | 예 | 예 | background: true + stream: true, ?starting_after={sequence_number}로 재개 |
| Anthropic | 아니오 | 아니오 | 모델에 “continue”하라고 다시 프롬프트 - 토큰 재과금, 드리프트 가능 |
| Google Gemini | 아니오 | 아니오 | 같은 continue-from-here 재프롬프트 꼼수 |
| OpenRouter | 부분적 (cancel이 과금을 멈춤) | 아니오 (응답 전체 캐시만) | 응답 캐싱 + cancellation; 직접 버퍼 구축 |
Vercel AI SDK (resumable-stream) | 예 (producer를 waitUntil로 유지) | 예, 하지만 페이지 리로드만 | 앱 계층 Redis 버퍼 - 같은 트릭, 더 좁은 범위 |
| 이것 / AI Gateway | 예 | 예 | 인프라 계층 내구성 버퍼, provider-agnostic, 당신의 배포 를 살아남음 |
여기서 몇 가지가 눈에 띈다.
OpenAI는 이미 이 아이디어를 증명했다. Background mode는 Responses API에서 연결이 끊겨도 작업을 서버 측에서 계속 실행하고, sequence_number 커서를 추적해 GET /v1/responses/{id}?stream=true&starting_after={n}으로 재개한다. 이건 제공자 네이티브의 durable inference이며, 오늘 당장 쓰고 있다. 다만 OpenAI 자체 API에만 묶여 있다(store: true가 필요하고, TTFT는 더 높다).
Anthropic과 Gemini는 다시 돈을 내게 만든다. 둘 다 서버 측 resume을 지원하지 않는다. 문서화된 복구 방법은 부분 응답을 캡처한 뒤, 모델에게 거기서 이어서 계속하라고 새 요청 을 보내는 것이다. 그러면 출력 토큰 비용을 다시 내게 되고, 이어지는 내용이 정확히 같으리라는 보장도 없다. Anthropic 문서는 tool-use와 thinking 블록은 부분적으로 복구할 수 없다고까지 적고 있다. 이 글의 맨 위에서 말한 세금이 정확히 이것이다.
Vercel의 resumable-stream은 가장 가까운 사촌이다. 독립적으로 같은 핵심 트릭에 도달했다. 원래 reader가 사라져도 producer는 스트림을 끝까지 완료하고, 두 번째 consumer가 뒤따라갈 수 있다. 하지만 그건 앱 계층이라 Redis를 직접 운영해야 하고, producer가 당신의 프로세스 안에 있으므로 코드를 재배포하면 살아남지 못한다. 내가 별도 배포가 필요했던 이유가 바로 그 마지막 케이스다.
그러니 이걸 아무도 안 하는 건 아니다. OpenAI는 한 API에 대해 이게 가치 있다는 걸 보여줬다. 다른 모두는 다시 프롬프트하고 다시 비용을 내라고 한다. 그리고 당신 자신의 프로세스 가 죽은 경우를 다뤄 주는 곳은 없다. 그게 빈틈이다.
그렇다면 이건 어디에 살아야 할까? 비교표가 답을 가리킨다. OpenAI 버전이 먹히는 이유는 당신이 배포하지 않는 인프라 위에서 돌아가기 때문이고, Vercel 버전이 모자라는 이유는 그렇지 않기 때문이다. 버퍼는 이미 요청 경로 위에 있고, 이미 모든 provider 앞단에 있으며, 당신이 코드를 배포할 때 절대 함께 재배포되지 않는 곳에 있어야 한다. 그곳이 AI Gateway다.
나는 이걸 Durable Object로 프로토타이핑했다(RFC #1257). 하지만 원래부터 에이전트 내부가 아니라 관리형 인프라에 살아야 할 물건이었다. 이걸 gateway에 넣으면 OpenAI의 background-mode 아이디어를 가져와, 오늘은 다시 비용을 내게 만드는 provider들까지 포함한 모든 provider에게 건네줄 수 있다.
그리고 좋은 소식이 있다. durable resume은 곧 Cloudflare AI Gateway에 들어온다. 아직 널리 출시되지는 않았지만, 나는 이미 실제 트래픽을 태워 보고 있다. 모든 런은 cf-aig-run-id를 돌려주고, gateway에 이벤트 인덱스부터 리플레이해 달라고 요청할 수 있다. 나는 여섯 개 모델에 대해 각 스트림을 중간 지점에서 잘라 보고, 꼬리 부분을 요청했다.
| model | events | bytes | 중간에서 resume → tail 일치? |
|---|---|---|---|
| gpt-4o-mini | 63 | 20,604 | ✅ byte-exact |
| gpt-5.4 | 25 | 7,350 | ✅ byte-exact |
| claude-haiku-4.5 | 11 | 1,924 | ✅ byte-exact |
| claude-sonnet-4.5 | 10 | 1,868 | ✅ byte-exact |
| gemini-3-flash | 4 | 2,253 | ✅ byte-exact |
gpt-4o-mini 런에서 이벤트 인덱스 31부터 resume했더니 스트림의 뒤 절반이 정확히, 바이트 단위까지 동일하게 돌아왔다. (한 가지 주의할 점은 from이 바이트 오프셋 이 아니라 이벤트 인덱스 라는 것이다. 바이트 오프셋은 동작하지 않았다. 출시되면 알아둘 만하다.)
내가 사람들이 가져가길 바라는 건 바로 이것이다. 이 DO 해킹을 영원히 직접 만들 필요는 없다. 목표는 이것을 일급의 opt-in 기능으로 만드는 것이다. 곧 AIChatAgent와 Think 같은 채팅 에이전트 베이스 클래스에 들어갈 모습은 아마 이런 식일 것이다.
export class MyAgent extends Think<Env> {
override durableBuffer = true; // route inference through a durable buffer (coming soon)
}
스위치 하나만 켜면, 같은 토큰에 두 번 돈을 내지 않는다.
keepAliveWhile가 백그라운드 drain이 도는 동안 그것을 열어 둔다.더 큰 이야기는 별도의 글감이다. 복구된 스트림을 손에 넣고 나서 그걸 어떻게 처리할지, 그리고 에이전트 루프 복구 결정 트리의 나머지 부분 말이다. 이 글은 그 곁가지였다. 하지만 배포가 일어날 때마다 돈을 아껴 주는 부분이니, 따로 떼어낼 가치가 있다고 생각했다.
토큰을 절대 낭비하지 마라.