LangGraph 실행이 성공했는데 UI에는 마지막 단계가 나타나지 않는 경우가 있습니다. 반대로 연결이 끊겼다가 다시 붙으면서 같은 이벤트가 두 번 표시될 수도 있습니다. 실행 결과의 내구성과 이벤트 전달의 내구성은 같은 문제가 아닙니다.

LangGraph 1.2.19와 Python SDK 0.4.11은 10월 30일 같은 커밋 5bd231c를 가리키는 별도 릴리스로 공개됐습니다. 1.2.19 릴리스 노트와 SDK 0.4.11 릴리스 노트를 보면 이번 변경의 중심은 그래프 실행 엔진의 새 기능보다 스트림을 끊김 이후에도 해석할 수 있게 만드는 데 있습니다.

1.2.19: 중첩 그래프의 이벤트에 자기 자리를 준다

1.2.19의 변경 목록에는 메시지와 도구 이벤트에 해당 그래프의 네임스페이스를 부여하는 수정, 노드 안에서 스트리밍된 그래프의 노드 출력 메시지를 내보내는 수정, 스트리밍 부모 아래에서 RemoteGraph.stream의 subgraphs=False를 존중하는 수정이 포함됩니다. 일시 중지된 도구를 실패로 보고하지 않고, 스트리밍 중에도 기다린 task의 값을 반환하며, ToolNode 도구 이름을 그래프 노드 메타데이터에 나열하는 변경도 있습니다.

이 항목들은 한 가지 방향을 공유합니다. 중첩된 그래프와 도구 호출이 늘어날수록 “어느 그래프에서 발생한 어떤 이벤트인가”가 중요해지고, 스트림 소비자는 그 문맥 없이는 이벤트를 올바르게 재구성하기 어렵습니다. 다만 릴리스 노트만으로 모든 이벤트가 정확히 한 번 전달된다고 결론내릴 수는 없습니다.

SDK 0.4.11: 커서, 청크, 재연결 백오프

Python SDK 쪽 수정은 더 구체적입니다. 릴리스 노트에 기록된 항목은 다음과 같습니다.

수정 연결된 PR 의미
청크 경계를 넘어 SSE 데이터와 커서 상태 보존 #9477 줄이 나뉘어 도착해도 이벤트와 재개 지점을 잃지 않게 함
빈 id마다 재연결 커서 초기화 #9440 호출자가 넘긴 Last-Event-ID도 포함
실패한 스트림 재연결을 백오프로 재시도 #9462 429·5xx·전송 오류가 즉시 반복 호출로 이어지지 않게 함
runs.wait, runs.join, runs.cancel(wait=True) 재연결 재시도 #9481 스트림 외의 대기 API에도 같은 정책 적용
Last-Event-ID 없이 재연결하면 경고 #9482 재개 위치를 모르는 상태를 조용히 넘기지 않음

SSE에서 id 필드는 이벤트의 식별자이자 재연결 커서로 쓰입니다. 클라이언트가 연결이 끊긴 뒤 Last-Event-ID 헤더를 보내면 서버는 그 지점 이후부터 다시 보낼 수 있습니다. 반대로 커서가 없으면 클라이언트는 어디서 이어야 할지 모릅니다. 이번 SDK는 그 상황을 경고로 드러냅니다.

0.4.10과 0.4.11을 같은 입력으로 실행해 봤다

변경이 실제로 어떤 차이를 만드는지 확인하려고 CodeBridge가 Python 3.13 환경에서 langgraph-sdk 0.4.10과 0.4.11을 각각 설치하고, 이슈 #9299에 보고된 최소 재현을 그대로 실행했습니다. 서버나 모델은 호출하지 않고 SSE 디코더만 사용했습니다.

from langgraph_sdk.sse import SSEDecoder

decoder = SSEDecoder()
for line in (b"id: 42", b"event: values", b"data: {}", b"", b": keepalive", b""):
    event = decoder.decode(line)
    if event is not None:
        print(event)

0.4.10의 실제 출력입니다.

StreamPart(event='values', data={}, id='42')
StreamPart(event='', data=None, id='42')

data가 없는 주석 전용 하트비트인데도 두 번째 이벤트가 만들어졌습니다. 앞서 id: 42를 본 상태가 남아 있어 빈 블록이 이벤트로 승격된 것입니다. 0.4.11의 같은 실행 결과는 다음과 같습니다.

StreamPart(event='values', data={}, id='42')

하트비트는 더 이상 빈 이벤트를 만들지 않습니다. 이어서 빈 id가 커서를 초기화하는지도 확인했습니다.

decoder = SSEDecoder()
for line in (b"id: 42", b"event: values", b"data: {}", b"",
             b"id", b"event: values", b"data: {}", b""):
    event = decoder.decode(line)
    if event is not None:
        print(event)

0.4.11 출력:

StreamPart(event='values', data={}, id='42')
StreamPart(event='values', data={}, id=None)

두 번째 이벤트의 id가 None인 것은 빈 id 필드가 이전 커서를 물려주지 않고 초기화했다는 뜻입니다. 이는 서버가 명시적으로 “이 지점부터는 재개 ID가 없다”고 말하는 경우와, 단순히 ID를 빠뜨린 경우를 구분하는 데 중요합니다.

이 실험은 SDK의 SSE 디코더 동작만 확인합니다. LangGraph 서버의 재전송 정책, 네트워크 끊김, 체크포인트 복구, 중복 이벤트 제거까지 검증한 것은 아닙니다.

실행 성공과 전달 성공을 같은 표에 두지 말 것

그래프 실행이 끝났다는 사실은 체크포인트나 최종 상태로 확인할 수 있습니다. 반면 UI가 그 사실을 보았다는 것은 이벤트 스트림의 문제입니다. 두 가지를 분리하면 장애 분석이 쉬워집니다.

질문 확인할 곳 이번 릴리스가 보장하지 않는 것
작업이 끝났는가 실행 상태, 체크포인트, 최종 출력 화면에 마지막 이벤트가 도착했는가
어디서부터 다시 읽을 것인가 SSE id, Last-Event-ID 서버가 모든 이벤트를 보관하는가
같은 이벤트가 두 번 왔는가 소비자 쪽 ID 중복 제거 전송 계층의 exactly-once
재연결이 폭주하지 않는가 백오프 로그, 429/5xx 비율 재연결이 항상 성공하는가
중간 이벤트를 놓쳤는가 체크포인트와 이벤트 로그 대조 최종 상태만으로 중간 과정을 복원할 수 있는가

따라서 “0.4.11로 올렸으니 재연결 문제가 해결됐다”는 결론은 범위를 넘습니다. 이번 수정은 재개 위치를 보존하고, 커서가 없을 때 경고하며, 재시도 간격을 두는 데 초점이 있습니다. 서버가 이벤트를 얼마나 보관하는지, 클라이언트가 어떤 ID를 중복으로 볼지는 애플리케이션이 정해야 합니다.

적용 전에 추가할 테스트

  1. 스트림 중간에 연결을 끊고, 마지막으로 받은 ID로 재연결했을 때 빠진 이벤트가 없는지 확인합니다.
  2. 같은 ID를 가진 이벤트를 두 번 주입해 소비자가 중복 표시하지 않는지 확인합니다.
  3. 빈 id가 온 뒤 재연결하면 이전 커서를 잘못 재사용하지 않는지 확인합니다.
  4. 429와 5xx를 반환하는 프록시를 두고 재시도 간격이 증가하는지, 최대 횟수에서 멈추는지 확인합니다.
  5. Last-Event-ID 없이 재연결했을 때 경고가 로그에 남는지 확인합니다.
  6. 실행은 성공했지만 마지막 이벤트가 유실된 상황을 만들어, 최종 상태와 UI 표시가 불일치하는지 확인합니다.

소비자 쪽에서는 이벤트 ID를 기준으로 중복을 제거하고, ID가 없는 이벤트는 “재개 불가 구간”으로 표시하는 편이 안전합니다. 화면의 이벤트 목록을 실행의 원본 데이터로 쓰지 말고, 체크포인트나 최종 상태를 기준으로 삼아야 합니다.

LangGraph의 그래프 구조 자체를 처음 본다면 그래프 엔지니어링 입문 글과 루프 엔지니어링 글이 배경을 설명합니다. 이번 릴리스는 그 구조 위에서 “끊긴 연결을 어떻게 이어 읽을 것인가”라는 운영 계층의 문제를 다룹니다.

아래 유료 강의는 에이전트의 실행·검증·그래프 구조를 설계하는 내용을 다룹니다. LangGraph 0.4.11의 재연결 동작이나 이 실험을 재현하는 강의는 아니며, 링크는 홍보 링크입니다.