서버에는 마지막 노드의 체크포인트가 저장됐는데 클라이언트는 그 직전 상태까지만 받았습니다. 클라이언트가 자신이 마지막으로 본 체크포인트를 지정해 재개하면 완료한 노드를 다시 실행할 수 있습니다. 저장 성공과 상태 전달 성공을 나눠 봐야 하는 이유입니다.

10월 26일 LangGraph 1.2.18 릴리스는 이런 중단·스트리밍 경로와 error_handler 동작을 수정했습니다. 이 글은 10월 29일 릴리스·PR·태그 코드를 검토한 결과입니다. LangGraph 런타임의 버전 간 실험이나 데이터베이스 성능 측정은 수행하지 않았습니다.

마지막 체크포인트가 저장소와 스트림에서 달랐던 이유

PR #9409의 설명에서 핵심은 순서입니다. 기존 흐름은 다음 tick에서 체크포인트 이벤트를 내보냈습니다. 그런데 interrupt_after는 저장 직후 멈추고, 재귀 한도는 다음 tick에서 이벤트를 내보내기 전에 실행을 끝냈습니다. 저장된 마지막 상태가 checkpoints·debug 스트림에 빠질 수 있었습니다.

노드 완료 → 체크포인트 B 저장 → 중단
                              ↓
                 클라이언트가 마지막으로 본 것은 A
                              ↓
               A를 지정해 재개하면 마지막 노드 재실행 가능

자체 도식이며 실제 장애 로그는 아닙니다. 수정 후 두 중단 경로에서는 멈춘 체크포인트를 추가로 전달합니다. 따라서 스트림 소비자는 이벤트 개수만 고정해 검사하기보다 체크포인트 ID와 최종 상태를 확인하는 편이 좋습니다. 이 수정이 모든 재연결 상황의 전달 보장이나 외부 API의 중복 방지를 제공하는 것은 아닙니다.

PR #9408은 다른 층의 문제를 다룹니다. error_handler가 처리한 오류도 병렬 노드·서브그래프·messages/custom 스트리밍의 실행 경로에서 다시 발생할 수 있었습니다. executor가 이미 처리한 실패를 구분하도록 고쳤습니다. tasks/debug 단독 스트림에서 실패 결과를 내보내기 전에 예외가 발생하던 경로도 수정했습니다.

두 수정을 묶어 “자동 복구 성공률이 올랐다”고 부를 수는 없습니다. 하나는 처리된 오류의 전파, 다른 하나는 저장된 상태의 관측 문제입니다. 각각 다른 회귀 조건이 필요합니다.

스냅샷을 자주 쓰면 무엇을 덜 읽는가

DeltaChannel은 매번 전체 상태를 쓰는 대신 이전 쓰기 기록을 reducer로 합쳐 상태를 재구성합니다. PR #9420은 기본 전체 스냅샷 주기를 1,000회에서 100회 업데이트로 바꿨습니다. 명시적 설정은 유지하며 snapshot_frequency=1000으로 이전 주기를 선택할 수 있습니다.

다음 계산은 주기 N마다 전체 스냅샷을 쓰고, 조회 위치가 한 주기 안에서 균등하다고 가정합니다. 조회 때 마지막 스냅샷 이후 합칠 업데이트는 0~N−1개입니다. Node.js 18 이상에서 snapshot.mjs에 저장해 실행할 수 있으며 패키지·네트워크는 필요 없습니다.

const updates = 10000;
for (const every of [100, 1000]) {
  const replay = Array.from({ length: every }, (_, i) => i);
  const mean = replay.reduce((sum, n) => sum + n, 0) / replay.length;
  console.log(`every=${every} snapshots=${Math.floor(updates / every)} max_replay=${every - 1} mean_replay=${mean}`);
}

로컬 실행 결과:

every=100 snapshots=100 max_replay=99 mean_replay=49.5
every=1000 snapshots=10 max_replay=999 mean_replay=499.5

이것은 실제 saver를 실행한 결과가 아닌 단순 주기 모델의 계산입니다. 초기 스냅샷, 비활성 채널, 별도의 superstep 상한, 압축·보존 정책은 제외했습니다. 태그 코드에는 업데이트 수 외에 superstep 수로도 스냅샷을 제한하는 조건이 설명되어 있으므로, 표의 값이 실제 재구성 횟수의 보편적인 상한이라고 해석해서는 안 됩니다.

이 모델에서 스냅샷 발생 횟수는 열 배지만 저장 바이트가 열 배라는 뜻은 아닙니다. 상태 크기의 증가, delta 기록, 인덱스, 압축에 따라 달라집니다. 읽기 지연시간도 디스크 접근·역직렬화·reducer 비용을 포함하므로 “열 배 빨라졌다”로 환산할 수 없습니다. 운영에서는 스레드 길이별 복원 p50/p95와 저장 바이트를 함께 비교해야 합니다.

재구성 결과를 지키는 reducer 계약

성능 설정보다 먼저 확인할 조건도 있습니다. 고정 리비전의 DeltaChannel 코드는 reducer가 결정적이고 배치 분할에 불변이어야 한다고 명시합니다. 두 묶음을 순서대로 반영한 상태와 두 묶음을 합쳐 한 번에 반영한 상태가 같아야 합니다.

다음은 그 계약만 검증하는 원본 예제입니다. 같은 Node.js 환경에서 실행했으며 LangGraph API를 흉내 낸 호환 구현은 아닙니다.

const sum = (state, batch) => state + batch.reduce((n, x) => n + x, 0);
const bad = (state, batch) => state + batch.reduce((n, x) => n + x, 0) / batch.length;
const xs = [2, 4], ys = [8];
for (const [name, reduce] of [['sum', sum], ['batchMean', bad]]) {
  const split = reduce(reduce(0, xs), ys);
  const joined = reduce(0, [...xs, ...ys]);
  console.log(`${name}: split=${split} joined=${joined} equal=${split === joined}`);
}
sum: split=14 joined=14 equal=true
batchMean: split=11 joined=4.666666666666667 equal=false

두 번째 함수는 묶음별 평균을 더해서 묶음 크기의 영향을 받습니다. 전체 평균이 필요하면 합계와 개수를 상태로 누적한 뒤 읽을 때 나누는 식으로 설계할 수 있습니다. 외부 현재 시각이나 네트워크 결과에 의존하는 reducer 역시 과거 상태 재구성과 맞지 않습니다. 코드에는 DeltaChannel 자체가 beta이고 관련 저장 계약이 안정화되지 않았다는 안내도 있습니다. 명시적 버전 관리와 저장소 호환성 확인이 필요한 이유입니다.

업그레이드는 실패 경로별로 확인한다

다음은 실제 통과한 제품 테스트 목록이 아니라, 해당 경로를 사용하는 팀을 위한 회귀 확인표입니다.

현재 쓰는 경로 버전 변경 전후 대조할 결과
병렬 노드·서브그래프의 error handler 처리된 오류가 다시 발생하는지, 다음 상태가 맞는지
tasks/debug 실패 스트림 실패 task 결과와 최종 예외를 모두 관측하는지
interrupt_after·재귀 한도 마지막 저장 ID와 받은 체크포인트 ID가 맞는지
긴 DeltaChannel 스레드 같은 상태 복원, reducer 배치 불변성, 지연시간·저장 바이트

짧고 상태가 작은 그래프에서는 조회 비용보다 운영 복잡성이 더 클 수 있습니다. 외부 결제·게시처럼 재실행 부작용이 있는 작업에는 이번 수정과 별도로 안정적인 업무 ID와 멱등성 계약이 필요합니다.

그래프 엔지니어링의 품질은 노드가 연결됐는지보다 중단 후 어느 상태를 믿고 이어가는지에서 드러납니다. 이번 버전을 적용할 때는 마지막 이벤트·reducer 계약·저장 비용을 각각 확인하세요. 하나의 “안정성” 지표로 합치면 회귀 원인을 놓치기 쉽습니다.

아래 공개 유료 강의는 에이전트 상태·실행·검증과 그래프 설계 학습에 연결됩니다. 특정 LangGraph 버전의 마이그레이션 강의로 소개하는 것은 아니며, 링크는 홍보 링크입니다.