Flue durable 도구만 크래시 후 재실행된다

결제나 메일 발송을 맡은 도구가 도는 중에 서버가 죽으면, 서버가 돌아온 뒤 그 도구도 다시 실행될 거라고 기대하기 쉽다. Flue에서 크래시 뒤에 재실행되는 도구는 durable: true로 선언한 도구뿐이다. 일반 도구 호출은 결과를 알 수 없다는 오류로 마감되고, 모델이 그 오류를 보고 다음 행동을 정한다. durable 도구는 처음부터 다시 돌면서 step.do로 기록해 둔 단계만 건너뛴다. 그래서 두 번 일어나면 안 되는 일은 단계 안에 넣고, 그 단계마저 드물게 다시 실행될 수 있으니 받는 쪽이 같은 요청을 한 번만 처리하게 만든다

메시지 하나는 크래시가 몇 번 나도 한 가지 결말로 끝난다

강의는 에이전트가 중단되거나 오류가 나면 Flue가 복구하고 다시 시도한다고 설명한다. 서버가 죽어 처리가 끊긴 경우와 모델 제공자의 일시적 오류라면 메시지가 결국 처리된다는 점에서 그 설명대로다(Durability — Recovery after an interruption). 코드가 던진 예외는 다시 시도하지 않고 모델이 보는 오류 결과가 된다(Tools — How a tool call works). HTTP 요청이든 dispatch(...) 호출이든 에이전트에 들어온 입력은 모델 작업이 시작되기 전에 제출(submission)로 기록되고, 1편 「Flue 에이전트는 시스템 프롬프트를 반환하는 함수다」에서 본 POST /:id의 202 응답이 이 기록의 영수증이다. 문서는 접수된 제출이 크래시가 몇 번 일어나든 completed, failed, aborted 가운데 정확히 하나의 결말에 이른다고 적는다(Durability — Submissions and the accepted-work contract). 이 계약은 저장소가 살아남을 때 성립한다. Cloudflare 타깃은 대화마다 Durable Object(DO)의 SQLite에 기록하지만, Node 타깃의 기본 저장소는 메모리라서 재시작하면 접수된 작업도 사라지므로 db.ts에 영속 어댑터를 둬야 한다(Durability — Node.js recovery)

제출은 시도(attempt) 단위로 처리되고, 중단될 때마다 시도 하나를 쓰고 새 시도로 이어 간다. 기본 한도는 시도 10회와 제출당 1시간이며, 한도를 넘긴 제출은 더 시도하지 않고 failed로 끝난다. 이때 결과를 기다리던 쪽은 결과가 불명으로 남은 도구 호출 목록이 담긴 오류를 받는다. 한도는 에이전트마다 바꿀 수 있다(Durability — Retry budget and timeout)

CustomerSupport.durability = { maxAttempts: 5, timeoutMs: 7_200_000 };

시도 5회, 제출당 2시간으로 바꾼 설정이다. 에이전트 함수에 붙인 정적 속성이라 플랫폼은 함수를 실행하지 않고도 이 값을 읽는다. 그래서 에이전트 함수를 실행하는 렌더 도중에 크래시가 나도 이 설정은 적용된다(Agent API — Agent statics). 이 계약이 약속하는 것은 결말이 하나로 정해진다는 데까지다. 제출당 1시간을 넘기면 작업이 남아 있어도 실패로 끝나므로, 오래 걸리는 작업을 맡긴 에이전트라면 기본값이 그 작업에 맞는지부터 본다

실행 중이던 일반 도구는 다시 실행되지 않는다

죽은 프로세스는 자기가 죽었다는 기록을 남기지 못하므로, 복구는 다음에 깨어난 쪽이 남은 기록의 종류를 보고 어디서 이을지 정한다(Durability — Recovery after an interruption). 도구 호출에 해당하는 규칙은 이렇다. 크래시 전에 결과가 기록된 호출은 다시 실행하지 않는다. 결과 없이 끊긴 일반 도구 호출도 다시 실행하지 않는데, 그 도구의 부작용이 어디까지 일어났는지 런타임이 알 수 없기 때문이다. 런타임은 그 호출을 결과 불명 오류로 마감하고, 모델은 이 오류를 도구 결과로 받아 대응한다. 결과 불명 오류 대신 실제 결과로 마감되는 호출은 두 종류다

크래시 때 실행 중이던 도구 호출
 ├─ 일반 도구            → 다시 실행하지 않음. 결과 불명 오류를 모델에 전달
 ├─ durable: true 도구  → 처음부터 다시 실행. 완료된 step.do는 기록값 반환
 └─ 서브에이전트 작업     → 자식 대화 기록에서 이어 감

이 가운데 도구를 처음부터 다시 실행하는 것은 durable 도구다. 서브에이전트에 맡긴 작업은 자식 세션이 자기 대화 기록을 따로 가지므로, 복구는 그 기록에서 자식을 이어 끝까지 실행하고 자식의 최종 결과를 부모의 도구 결과로 기록한다(Durability — Delegated tasks). 서브에이전트 자체는 4편 “Flue 스킬·MCP·서브에이전트·샌드박스는 컨텍스트에 들어오는 방식이 다르다”에서 다룬다

결과를 모르는 도구를 런타임이 알아서 다시 돌리지 않는 쪽이 맞다고 본다. 알아서 다시 돌리는 런타임이라면 결제 API를 호출한 직후 죽은 도구가 복구 때 결제를 한 번 더 요청한다. 대신 일반 도구로 만든 결제는 크래시가 나면 결제가 됐는지 모르는 채로 모델에게 넘어간다. 크래시가 나도 끝까지 마쳐야 하는 작업이라면 durable 도구로 옮긴다. durable 도구도 다시 돌지만, 부작용을 모두 단계 안에 두었다면 끝난 단계는 건너뛰므로 중복 위험은 단계 안의 외부 호출이 상대 시스템에 반영된 뒤 그 단계가 기록되기 전까지로 줄어든다

durable 도구는 처음부터 다시 돌고 기록된 단계만 건너뛴다

강의는 단계가 필요한 이유로, 이메일을 보낸 뒤 DB에 저장하는 도중 크래시가 나면 도구를 다시 돌릴 때 이메일이 또 나간다는 예를 든다. 예제에서는 10초 걸리는 계산을 단계로 감싸고, 그 뒤에 10초를 더 기다리게 했다.

useTool({
    name: "durable_tool",
    description: "Run a slow calculation and save the result",
    durable: true,
    async run({step}) {
        console.log("starting tool");
        console.log(Date.now());

        const expensiveValue = await step.do("expensive_calculation", async () => {
            // 10초 걸리는 계산 대신 기다린다
            await new Promise(resolve => setTimeout(resolve, 10000));
            return 123123123;
        });

        console.log(Date.now());
        // 이 대기 중에 서버를 종료해 본다
        await new Promise(resolve => setTimeout(resolve, 10000));
        console.log(expensiveValue);

        await step.do("save_in_db", () => {
            // save(expensiveValue)
            return "ok";
        });
    },
});

durable: true를 선언해야 run이 step을 받는다. step.do(이름, 함수)는 이 도구 호출 안에서 이름마다 함수를 한 번 실행하고, 반환값을 저장소에 기록한 다음에 돌려준다. 복구로 도구가 다시 실행되면 기록이 있는 단계는 함수 대신 기록값을 바로 돌려준다(Tools — Durable tools). 단계 기록은 도구 호출 ID에 묶여 있어서 같은 호출의 시도 사이에는 이어지지만, 모델이 도구를 새로 부르면 모든 단계가 처음부터 실행된다(Durability — Durable tools and step.do)

두 번째 시각이 찍힌 뒤 10초를 기다리는 동안 개발 서버를 종료하고, npm run dev로 다시 띄운 다음 화면을 새로고침했다. 도구는 곧바로 다시 실행됐다. 콘솔에는 “starting tool”이 한 번 더 찍혔고, 그 뒤의 두 시각 사이에는 10초 간격이 없었다. 아래 그림은 콘솔 출력을 옮긴 것이 아니라 두 시도를 정리해 다시 그린 것으로, 관측은 콘솔과 화면에서 본 줄이고 문서는 코드 흐름과 문서의 규칙으로 채운 줄이다

1차 시도
  [관측] "starting tool"과 시각 t0 출력
  [문서] step.do("expensive_calculation")   ← 10초 뒤 123123123 기록
  [관측] 시각 t1 출력 (t0 + 10초)
         (이어지는 10초 대기 중에 서버 종료)

2차 시도 (서버 재시작 후)
  [관측] "starting tool"과 시각 t0' 출력
  [문서] step.do("expensive_calculation")   ← 함수 실행 없이 기록값 반환
  [관측] 시각 t1' 출력 (t0'와 간격 없음)
  [문서] 10초 대기 → step.do("save_in_db") 실행·기록
  [관측] 모델 답변: 도구는 실행됐지만 출력은 없음

“starting tool”이 다시 찍힌 것은 복구가 도구 전체를 처음부터 다시 돌리기 때문이고, 두 시각 사이에 간격이 없는 것은 10초짜리 계산 단계가 함수를 실행하는 대신 기록값을 돌려줬기 때문이다. 이 도구는 아무것도 반환하지 않아 모델에는 null이 전달되고(Agent API — defineTool()), 모델은 도구가 실행됐지만 출력은 없다고 답했다

서버를 다시 켰을 때 복구를 시작한 것은 대화마다 하나씩 있는 DO다. DO는 시작될 때마다 이전 인스턴스가 돌리던 시도를 정리한 뒤 새 작업을 받고, 끝나지 않은 작업이 있으면 스스로 깨어나는 일정을 짧게 잡아 외부 요청 없이도 복구를 진행한다(Durability — Cloudflare recovery). 이번에 새로고침과 이 일정 가운데 어느 쪽이 DO를 깨웠는지는 구분하지 않았다

단계 밖 코드와 단계 안의 외부 호출은 두 번 실행될 수 있다

durable 도구 안에서 다시 이어 갈 수 있는 지점은 step.do로 감싼 단계다. 문서가 durable 도구에 두는 규칙은 네 가지다(Tools — Durable tools)

  1. 부작용이 있는 코드는 모두 단계 안에 둔다. 단계 사이의 코드는 복구 때 다시 실행되므로 값 계산이나 분기처럼 싸고 부작용 없는 일만 둔다
  2. 단계 이름은 난수나 시각 없이 하는 일을 가리키게 짓는다(seed:${project.name}). 한 호출 안에서 같은 이름을 두 번 쓰면 예외가 난다
  3. 단계 반환값은 작은 JSON으로 둔다. 큰 결과물은 샌드박스에 저장하고 경로만 기록한다
  4. 단계는 기록은 정확히 한 번, 실행은 최소 한 번이다. 함수가 도는 도중이나, 끝난 뒤 기록이 저장되기 전에 죽으면 그 단계는 다시 실행된다

3번의 경로는 복구 뒤에도 같은 작업 공간이 다시 붙는 샌드박스에서만 쓸모가 있다(Durability — Keep workspace state separate). 예제의 console.log와 10초 대기도 단계 밖이라 복구 때마다 다시 실행되는데, 로그와 대기라 해가 없다. 4번 규칙 때문에 단계 안에 넣은 외부 호출도 두 번 실행될 수 있다. 그래서 문서는 외부 효과를 멱등(idempotent)하게, 곧 같은 요청을 두 번 보내도 한 번 보낸 것과 결과가 같게 만들고, toolCallId나 단계 이름처럼 바뀌지 않는 ID를 키로 쓰라고 한다(Durability — External side effects). 결제가 끝난 주문의 영수증 메일을 보내는 도구라면 다음처럼 설계할 수 있다

useTool({
    name: "send_receipt",
    description: "Email the payment receipt for one order. Use after the payment succeeds.",
    input: v.object({ orderId: v.string() }),
    durable: true,
    async run({data, step}) {
        const { email } = await step.do("load_receipt", async () => {
            const receipt = await receipts.load(data.orderId);
            return { email: receipt.email };
        });
        await step.do("send_email", async () => {
            // 같은 키로 다시 요청하면 메일 서비스가 두 번째 발송을 거른다
            await mailer.sendReceipt({
                to: email,
                orderId: data.orderId,
                idempotencyKey: `receipt:${data.orderId}`,
            });
            return "sent";
        });
        return { output: { sent: true } };
    },
});

위 코드는 실행해 보지 않은 설계 예시다. receipts와 mailer는 애플리케이션이 구현할 객체이고, mailer는 멱등 키를 받는 메일 서비스를 감쌌다고 가정했다. 멱등 키는 무엇이 한 번이어야 하는지로 고른다. 이 도구 호출 안에서 한 번이면 run이 컨텍스트로 받는 toolCallId로 충분하다(Agent API — defineTool()). 단계 기록처럼 이 ID도 복구 시도가 바뀌어도 유지되지만, 모델이 같은 주문으로 도구를 다시 부르면 새 호출이라 toolCallId도 바뀐다. 영수증은 주문마다 한 번 나가야 하므로 위 코드에서는 주문 ID로 키를 만들었다

durable 계약이 적용되지 않는 경우도 두 가지 있다. 하나는 예외다. 예외는 중단으로 치지 않으므로, durable 도구도 예외를 던지면 일반 도구처럼 모델이 보는 도구 오류로 마감되고 자동 재시도는 없다(Tools — Durable tools). 다른 하나는 재배포다. durable 도구가 도는 중에 그 도구를 빼거나 durable을 지운 코드를 배포하면, 복구 시점의 렌더에는 그 계약이 없으므로 런타임은 일반 도구처럼 결과 불명으로 마감한다(Durability — Durable tools and step.do)

이벤트 훅의 메일은 영속 상태 표시와 멱등 키를 함께 걸어야 한 번만 나간다

2편 「Flue 에이전트 훅의 반영 시점은 모델과 프롬프트가 다르다」에서 이벤트 훅 콜백은 최소 한 번 실행되고, 메일 같은 외부 효과는 드물게 두 번 일어날 수 있다고 적었다. 게다가 useAgentStart는 메시지가 전달될 때마다 실행되므로, 환영 메일을 여기서 그냥 보내면 크래시가 없어도 메시지마다 메일이 나간다. 문서는 인스턴스당 한 번 할 일을 영속 상태로 막으라고 권한다(Agent Hooks API — useAgentStart(), Agent Hooks — Event hooks). 환영 메일을 대화당 한 번만 보내도록 설계하면 다음과 같다

'use agent';
import { type AgentProps, useAgentStart, useModel, usePersistentState } from "@flue/runtime";

export function CustomerSupport({ id }: AgentProps) {
    useModel("cloudflare/@cf/zai-org/glm-5.3-flash");
    const [welcomeSent, setWelcomeSent] = usePersistentState("welcomeSent", false);

    useAgentStart(async ({log}) => {
        if (welcomeSent) return;
        try {
            await mailer.sendWelcome({
                conversationId: id,
                idempotencyKey: `welcome:${id}`,
            });
            setWelcomeSent(true);
        } catch (error) {
            // 메일 실패로 메시지 처리까지 실패시키지 않는다. 표시가 false로 남아 다음 메시지에서 다시 보낸다
            log.warn("welcome mail failed", { error: String(error) });
        }
    });

    return "You are a customer support agent";
}

이 코드도 실행해 보지 않은 설계 예시다. welcomeSent는 렌더 시점의 값이라, 표시가 true이면 콜백은 아무것도 하지 않고 끝나고, false이면 메일을 보낸 다음 표시를 바꾼다. id는 에이전트 함수가 인자로 받는 대화 ID로, 인스턴스가 사는 동안 바뀌지 않는다(Agent API — AgentProps). 상태 setter는 렌더 중에 부르면 예외가 나므로 표시를 바꾸는 일은 콜백 안에서 한다(Agent Hooks API — usePersistentState()). 메일 호출을 try로 감싼 것은 useAgentStart 콜백이 예외를 던지면 그 메시지의 제출 전체가 실패하기 때문이다. 모델은 콜백이 끝난 뒤에야 메시지를 읽으므로 메일 서비스가 느리면 첫 답도 그만큼 늦는다(Agent Hooks API — useAgentStart())

이 표시를 믿을 수 있는 이유는 쓰기가 확정되는 시점에 있다. 이벤트 훅 안의 상태 쓰기는 훅 구간(같은 시점에 함께 실행되는 이벤트 훅 콜백들)이 끝날 때 한꺼번에 확정되고, 구간이 끝나기 전에 죽으면 쓰기도 없던 일이 된다. 그래서 표시가 true로 남았다면 메일을 보낸 그 구간도 확정된 것이다(Durability — Persisted state). 반대 방향의 틈은 남는다. 메일이 나간 직후 구간이 확정되기 전에 죽으면 표시 쓰기는 사라지고, 다음 시도는 false를 읽어 메일을 한 번 더 보낸다. 2편에서 적은 ‘기록을 확인한 뒤 보내기’만으로는 이 틈이 남으므로 위 코드는 welcome:${id}를 멱등 키로 함께 넘긴다. 메일 서비스가 같은 키의 두 번째 요청을 거르지 못한다면 에이전트 코드만으로는 이 틈을 닫을 수 없으니, 그 메일이 드물게 두 번 가도 되는지부터 따져야 한다고 본다

복구가 되살리는 범위는 에이전트 안까지다

대화 저장소는 샌드박스 파일을 저장하지 않으므로, 가상 샌드박스에서 중단된 시도가 쓴 파일은 복구 때 사라진다(Durability — Keep workspace state separate). 잃으면 안 되는 사실은 영속 상태에 두고, 샌드박스 종류에 따라 파일이 남는 기간은 4편에서 다룬다

에이전트를 부르는 엔드포인트나 스크립트, 크론 작업도 복구 범위 밖이라 Cloudflare Workflows 같은 플랫폼의 워크플로 엔진으로 다룬다. 바깥 코드가 실패 뒤 같은 메시지를 다시 보내면 그 대화의 새 제출로 접수되어 한 번 더 처리되고, 앞 제출이 무엇을 마쳤는지는 대화 기록에 남는다(Durability — Code outside the agent). 기다리던 프로세스만 죽었다면 다시 보낼 필요가 없다. 접수된 작업은 그대로 진행되므로, dispatch(...)가 돌려준 접수 영수증을 저장해 두었다가 read(receipt)로 다시 붙으면 된다(Durability — In-flight local promises)

직접 크래시를 낸 것은 Cloudflare 타깃의 로컬 개발 서버에서 durable 도구의 단계 하나가 끝난 뒤 한 번이다. 일반 도구가 실행 중일 때 죽는 경우, 단계 안의 외부 호출이 반영된 뒤 기록되기 전에 죽는 경우, 이벤트 훅 구간이 확정되기 전에 죽는 경우, 서브에이전트 작업을 이어 가는 경우, 재배포로 durable 계약이 빠지는 경우, 배포한 환경에서의 복구는 문서 기준이고 재현하지 않았다. 이 가운데 기록 전과 확정 전에 죽는 틈은 결제나 메일처럼 되돌릴 수 없는 일에서 한 번만 생겨도 문제가 된다. 그런 도구라면 붙이기 전에 단계 밖에 부작용이 남아 있지 않은지, 받는 쪽이 같은 키의 요청을 거르는지가 확인할 항목이다


이 시리즈의 글: Flue로 AI 에이전트 만들기

  1. Flue 에이전트는 시스템 프롬프트를 반환하는 함수다
  2. Flue 에이전트 훅의 반영 시점은 모델과 프롬프트가 다르다
  3. Flue durable 도구만 크래시 후 재실행된다 (이 글)
  4. Flue 스킬·MCP·서브에이전트·샌드박스는 컨텍스트에 들어오는 방식이 다르다
  5. Flue 대화 스트림은 offset으로 이어 받는다

출처와 범위

노마드 코더 AX 스쿨 Flue 강의를 바탕으로 썼고, 2026년 11월 1일 기준 문서와 맞춰 보는 일은 AI(Claude)와 함께 했다

참고 자료