내구성 있는 워크플로 작성 — 멈춘 실행을 체크포인트로 재개하기

내구성 있는 워크플로 작성 — 멈춘 실행을 체크포인트로 재개하기

워크플로는 기본적으로 휘발성이에요. run()이 끝나면 상태가 사라지고, 다음 run()은 처음부터 시작하죠. 그런데 백 개 문서에 팬아웃해서 청크마다 LLM을 호출하고 결과를 모으는 것 같은, 한 번에 오래 돌아가는 작업을 중간에 프로세스가 죽어서 처음부터 다시 해야 한다면 너무 아까워요. 이 페이지는 그런 실행을 체크포인트로 저장했다가 재시작 후에 이어서 진행하는 법을 보여줍니다.

출처: 공식문서

컨텍스트 스냅샷

실행은 이벤트를 통제된 방식으로 줄여 나가며 진행되기 때문에, 각 스텝 경계마다 상태가 명확히 정의돼요. 그 상태는 아직 처리 중인 이벤트들 더하기 상태 저장소입니다. Context.to_dict()가 이를 직렬화하고, Context.from_dict()가 다시 만들며, run(ctx=...)이 그 지점에서 이어서 진행해요 — 심지어 다른 프로세스에서도요.

w = MyWorkflow()
handler = w.run()
result = await handler

# 스냅샷 저장
db.save("my-run", json.dumps(handler.ctx.to_dict()))

# ...새 프로세스...
w = MyWorkflow()
ctx = Context.from_dict(w, json.loads(db.load("my-run")))
result = await w.run(ctx=ctx)   # 복원된 상태로 이어서 진행

이 방식은 재시작은 견디지만, 실행 중 크래시는 견디지 못해요. 마지막 to_dict() 시점의 상태만 갖고 있으니까요. 크래시까지 견디려면 실행이 진행되는 동안 스냅샷을 계속 찍어야 합니다.

재개 시 복원된 실행은 아직 대기 중이던 이벤트를 다시 발송하고, 부분적으로 채워진 팬인 버퍼를 재구성해요. 완료된 스텝은 상태에 이미 결과가 있으니 다시 실행되지 않아요. 스냅샷 시점에 실행 중이던 스텝은 되감겨 처음부터 다시 실행됩니다. 재개는 최소 한 번(at-least-once) 이라서, 스텝의 부수 효과는 반복되어도 안전해야 해요.

체크포인트 루프

활성화할 내장 체크포인터는 없어요. 워크플로 스트림은 스텝이 상태를 바꿀 때마다 내부 이벤트를 내보내고, 스텝 하나가 끝나는 걸 보면 스냅샷을 찍으면 됩니다. stream_events(expose_internal=True)가 그 내부 이벤트를 드러내고, StepStateChangedStepState.NOT_RUNNING이 스텝 완료를 가리켜요.

from llama_index.core.workflow.events import StepStateChanged, StepState

handler = w.run()
async for ev in handler.stream_events(expose_internal=True):
    if isinstance(ev, StepStateChanged) and ev.step_state == StepState.NOT_RUNNING:
        db.save("my-run", json.dumps(handler.ctx.to_dict()))
result = await handler

바쁜 실행에서는 모든 경계에 쓸 필요는 없어요. 스냅샷 하나가 직렬화+쓰기라 소음이 많으면 흘려보내고, 그러면 크래시 시 최대 그 구간만큼 다시 작업하면 돼요. 스냅샷은 찍는 순간의 상태이고, 쓰기를 일으킨 이벤트 이후로 다른 워커가 진행했을지라도 상태는 여전히 일관되고 재개 가능해요.

재시작을 견디는 동시 팬아웃 예시

작업에 팬아웃하고 항목을 동시에 처리하며 결과를 모으는 워크플로를 생각해 볼게요. 100개 중 60개를 처리한 뒤 프로세스가 죽었다고 합시다. 완료된 enrich 호출은 복원된 상태의 일부이고, 대기 중인 WorkItem은 여전히 대기 상태예요. 재개하면 dispatch는 다시 실행되지 않고, 완료된 항목은 재처리되지 않으며, 리스트 팬인은 두 실행에 걸쳐 WorkDone 100개가 모두 모였을 때 발화해요.

중요한 점이 하나 있어요. 진행 중이던 작업은 반복돼요. 어느 순간이든 최대 num_workers만큼 enrich 호출이 실행 중이지만 끝나지 않았고, 그 각각은 재개 시 되감겨 다시 실행됩니다. num_workers=8이면 최대 8개 항목을 다시 하는 셈이죠. 이건 낭비된 컴퓨팅이지 잘못된 출력이 아니고, 그래서 enrich는 반복되어도 안전해야 하는 겁니다.

class EnrichBatch(Workflow):
    @step
    async def dispatch(self, ev: StartEvent) -> list[WorkItem]:
        return [WorkItem(item_id=item_id) for item_id in ev.item_ids]

    @step(num_workers=8)
    async def enrich(self, ev: WorkItem,
                     client: Annotated[MyApiClient, Resource(get_client)]) -> WorkDone:
        result = await client.enrich(ev.item_id)   # 비싸고 반복 가능해야 하는 작업
        return WorkDone(item_id=ev.item_id, result=result)

    @step
    async def collect(self, events: list[WorkDone]) -> StopEvent:
        return StopEvent(result={ev.item_id: ev.result for ev in events})

스냅샷을 싸고 정확하게 유지하기

스냅샷은 이벤트와 상태에 뭘 담느냐에 따라 싸고 믿을 만해져요.

  • 워크플로 인스턴스 속성은 스냅샷의 일부가 아니에요. 워크플로 인스턴스가 사는 동안 함께하는 평범한 파이썬 객체에는 유용하지만 내구성 있는 상태는 아니에요. 재시작을 견뎌야 하는 값은 이벤트나 상태 저장소에 두세요.
  • 무겁거나 직렬화 안 되는 입력은 이벤트·상태가 아니라 Resource에 두세요. Resource 팩토리는 한 번 해석되어 워크플로에 캐시되고 컨텍스트에 절대 직렬화되지 않아요. 재개 시 팩토리를 다시 호출해 재생성하죠. API 클라이언트·모델 핸들·대용량 참조 데이터는 리소스에 두고, 이벤트에는 작은 식별자만 실어 나르세요.
  • 처리 중인 이벤트와 상태 저장소의 모든 것은 직렬화 가능해야 해요. 스냅샷은 JSON 시리얼라이저(Pydantic 모델 포함)를 쓴다. 인코딩할 수 없는 값을 만나면 to_dict()가 해당 필드만 실패하는 게 아니라 테스트 스냅샷 전체가 실패해요. 이벤트에 raw bytes나 열린 연결을 얹지 마세요.

내구성을 위한 런타임 플러그인

체크포인트 루프는 우리가 제어하는 내구성 방식이에요. 언제 스냅샷을 찍을지 정하고, 재개는 몇 줄이면 돼요. 그걸 직접 관리하고 싶지 않다면, 영속성과 복구를 소유한 런타임 플러그인을 쓰면 됩니다. 기본 런타임은 워크플로를 프로세스 안에서 실행하지만, 플러그인 런타임은 워크플로 코드를 바꾸지 않고 다른 실행 모델로 갈아탈 수 있어요. 예를 들어 DBOS 런타임은 스텝 전이를 데이터베이스에 저널링해서, 크래시된 워크플로가 체크포인트 루프 없이도 재개돼요. WorkflowServer도 같은 런타임 플러그인으로 워크플로를 실행하니, 서빙되는 워크플로도 같은 영속성·복구 동작을 얻습니다.

더 알아보기