Published on

LangChain 스트리밍 끊김·중복 토큰 해결법

Authors

LangChain으로 챗 UI를 만들다 보면 초반에는 잘 되다가도, 운영 트래픽이 붙는 순간 스트리밍 응답이 중간에 끊기거나, 같은 문장이 두 번씩 출력되는 문제가 자주 발생합니다. 특히 stream=true 기반 SSE(Server-Sent Events)나 WebSocket을 붙였을 때, 프론트와 백엔드 어디에서든 “한 번 더 붙는” 작은 버그가 전체 UX를 망가뜨립니다.

이 글은 다음 두 증상을 원인별로 분해해서 해결합니다.

  • 스트리밍이 중간에 끊김(클라이언트는 아직 대기 중인데 서버가 종료, 혹은 반대로)
  • 토큰/문장 중복(재시도, 이벤트 재연결, 콜백 중복 등록, 렌더링 중복 반영)

운영 환경에서 특히 자주 섞이는 이슈가 네트워크 단절 + 재시도 + 프론트 상태관리 조합이라, 각 레이어에서 “중복이 발생하지 않도록” 안전장치를 넣는 방식으로 접근합니다.

관련해서 네트워크가 원인인 경우가 많습니다. EKS에서 간헐 끊김을 추적하는 방식은 LLM 스트리밍에도 그대로 적용됩니다. 필요하면 아래 글도 함께 보세요.


1) 먼저 증상을 로그로 “분리”하기

스트리밍 문제는 감으로 고치기 어렵습니다. 최소한 아래 3가지 식별자를 로그에 남겨서, 중복이 어디에서 시작됐는지를 분리해야 합니다.

  • request_id: 사용자 요청 단위
  • run_id: LangChain 실행 단위(체인/에이전트 실행 한 번)
  • stream_id: 스트리밍 연결 단위(SSE 연결 한 번)

예를 들어 SSE라면 연결마다 stream_id를 새로 만들고, 재연결이 발생하면 stream_id가 바뀌도록 하세요. 중복 토큰이 stream_id가 바뀌는 시점부터 시작되면 “재연결/재시도” 계열일 확률이 큽니다.


2) 중복 토큰의 대표 원인 6가지

원인 A: 클라이언트가 자동 재연결하면서 동일 응답을 다시 받음

SSE는 브라우저/라이브러리가 자동 재연결을 지원합니다. 서버가 연결을 끊거나 프록시가 idle timeout을 걸면 클라이언트는 재연결하고, 서버는 처음부터 다시 생성을 시작할 수 있습니다. 이때 사용자는 “중간부터 이어지는 게 아니라 같은 내용을 다시 받는” 것처럼 보입니다.

해결책

  1. 서버가 이벤트에 event_id를 붙이고, 클라이언트가 마지막 event_id를 저장합니다.
  2. 재연결 시 Last-Event-ID를 보내고, 서버는 그 이후 이벤트만 재전송합니다.

단, LLM 생성 자체를 “중간부터 이어서” 재개하는 건 어렵습니다. 그래서 현실적인 방식은 아래 둘 중 하나입니다.

  • 서버에서 생성 결과를 버퍼링/저장하고, 재연결 시 버퍼를 재전송(권장)
  • 재연결은 허용하되, 클라이언트에서 중복 토큰을 제거(차선)

원인 B: 백엔드에서 재시도 로직이 스트리밍과 충돌

네트워크 오류나 429/5xx에 대해 재시도를 걸어둔 경우, 스트리밍 중간에 에러가 나면 “처음부터 다시 호출”이 일어나 토큰이 중복됩니다.

해결책

  • 스트리밍 요청은 원칙적으로 “중간 재시도”가 UX를 더 망칠 수 있습니다.
  • 재시도는 스트리밍 시작 전(첫 토큰 전)까지만 허용하거나, 재시도 시에는 기존 스트림을 명확히 종료하고 클라이언트에 “재시도 중” 이벤트를 보내 UI에서 새 응답으로 분리하세요.

원인 C: LangChain 콜백 핸들러가 중복 등록됨

FastAPI/Express 등에서 글로벌 객체로 체인/콜백을 만들고, 요청마다 또 핸들러를 추가하면 같은 토큰 이벤트가 2번씩 발행될 수 있습니다.

체크 포인트

  • 요청마다 callbacks=[handler]를 새로 만들었는지
  • CallbackManager.add_handler를 전역에서 누적시키고 있지 않은지

원인 D: 프론트에서 스트림 이벤트를 두 번 구독

React에서 흔한 패턴입니다.

  • useEffect가 의도치 않게 2번 실행(개발 모드 Strict Mode)
  • 컴포넌트 재마운트 시 이전 연결을 정리하지 않음
  • 상태 업데이트 로직이 “append”를 두 군데에서 수행

해결책

  • 연결 생성은 반드시 useEffect에서 하고, cleanup에서 close()
  • 이벤트 핸들러는 단 하나의 경로로만 토큰을 append

원인 E: 프록시/로드밸런서 버퍼링으로 chunk가 합쳐지거나 지연

Nginx/ALB/Cloudflare 등에서 응답 버퍼링이 켜져 있으면 스트리밍이 “끊기는 것처럼” 보이고, 재연결을 유발해 중복으로 이어집니다.

해결책

  • Cache-Control: no-cache
  • Content-Type: text/event-stream
  • Nginx라면 X-Accel-Buffering: no
  • 적절한 heartbeat(예: 15초마다 주석 이벤트)로 idle timeout 회피

원인 F: 타임아웃/취소 처리가 없어 서버가 백그라운드에서 계속 생성

클라이언트가 탭을 닫거나 네트워크가 끊겼는데, 서버는 LLM 호출을 계속 진행합니다. 이후 클라이언트가 재요청하면 같은 질문에 대한 응답이 두 번 생성되어 “중복”처럼 보일 수 있습니다.

해결책

  • 연결 종료를 감지해 LLM 호출을 취소
  • AbortSignal/context를 체인 실행에 연결

Go에서 selectcontext로 데드락/취소를 다루는 감각은 스트리밍 취소 설계에도 그대로 유용합니다.


3) Python LangChain: 중복 콜백 방지 + 스트림 안전 종료

아래 예시는 FastAPI에서 SSE로 토큰을 흘리는 전형적인 형태를 기준으로 합니다.

핵심은 4가지입니다.

  • 요청마다 콜백 핸들러 인스턴스를 새로 만들기(전역 누적 금지)
  • 토큰 이벤트에 seq를 붙여 클라이언트에서 중복 제거 가능하게 만들기
  • 클라이언트 disconnect 시 생성 중단
  • heartbeat로 중간 프록시 idle timeout 회피
import asyncio
import json
import time
from typing import AsyncIterator

from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse

from langchain_openai import ChatOpenAI
from langchain_core.callbacks import AsyncCallbackHandler
from langchain_core.messages import HumanMessage

app = FastAPI()

class SSETokenHandler(AsyncCallbackHandler):
    def __init__(self):
        self.queue: asyncio.Queue[str] = asyncio.Queue()
        self.seq = 0

    async def on_llm_new_token(self, token: str, **kwargs):
        self.seq += 1
        payload = {"type": "token", "seq": self.seq, "token": token}
        await self.queue.put(json.dumps(payload, ensure_ascii=False))

    async def on_llm_end(self, response, **kwargs):
        await self.queue.put(json.dumps({"type": "done"}))

    async def on_llm_error(self, error: Exception, **kwargs):
        await self.queue.put(json.dumps({"type": "error", "message": str(error)}))

async def sse_event_stream(request: Request, handler: SSETokenHandler) -> AsyncIterator[bytes]:
    last_heartbeat = time.time()

    while True:
        # 클라이언트가 끊겼으면 즉시 종료
        if await request.is_disconnected():
            break

        try:
            item = await asyncio.wait_for(handler.queue.get(), timeout=1.0)
            yield f"data: {item}\n\n".encode("utf-8")

            # done이면 스트림 종료
            if '"type": "done"' in item:
                break
        except asyncio.TimeoutError:
            # heartbeat (SSE 주석 이벤트)
            now = time.time()
            if now - last_heartbeat >= 15:
                yield b": ping\n\n"
                last_heartbeat = now

@app.get("/chat")
async def chat(request: Request, q: str):
    handler = SSETokenHandler()

    llm = ChatOpenAI(
        model="gpt-4o-mini",
        temperature=0,
        streaming=True,
        callbacks=[handler],
        # 스트리밍에서는 무리한 재시도는 중복을 만들기 쉬움
        max_retries=0,
        timeout=60,
    )

    async def run_llm():
        try:
            await llm.ainvoke([HumanMessage(content=q)])
        except Exception as e:
            await handler.on_llm_error(e)

    # LLM 실행은 백그라운드 태스크로
    task = asyncio.create_task(run_llm())

    async def stream():
        try:
            async for chunk in sse_event_stream(request, handler):
                yield chunk
        finally:
            # 스트림이 끝나면 태스크 정리
            if not task.done():
                task.cancel()

    headers = {
        "Cache-Control": "no-cache",
        "Content-Type": "text/event-stream",
        "Connection": "keep-alive",
        # Nginx 버퍼링 방지용
        "X-Accel-Buffering": "no",
    }

    return StreamingResponse(stream(), headers=headers)

이 구성의 장점은 “중복이 발생할 수 있는 지점”을 명확히 봉쇄한다는 점입니다.

  • 콜백이 요청마다 1개만 존재
  • 토큰에는 seq가 있어 클라이언트에서 중복 제거 가능
  • disconnect 시 태스크 취소
  • heartbeat로 중간 장비 idle timeout 감소

4) 프론트(React): Strict Mode 중복 실행과 중복 append 방지

React 개발 모드에서 Strict Mode는 일부 사이드이펙트를 두 번 실행해 문제를 빨리 드러내게 합니다. 스트리밍 연결을 useEffect에서 열고 닫지 않으면 “두 개의 SSE 연결”이 생기고 토큰이 두 번씩 들어옵니다.

아래는 EventSource를 쓰는 최소 예시입니다.

import { useEffect, useRef, useState } from "react";

type Chunk =
  | { type: "token"; seq: number; token: string }
  | { type: "done" }
  | { type: "error"; message: string };

export function useChatStream(url: string) {
  const [text, setText] = useState("");
  const esRef = useRef<EventSource | null>(null);
  const lastSeqRef = useRef<number>(0);

  useEffect(() => {
    // 기존 연결 정리
    if (esRef.current) {
      esRef.current.close();
      esRef.current = null;
    }

    lastSeqRef.current = 0;
    setText("");

    const es = new EventSource(url);
    esRef.current = es;

    es.onmessage = (evt) => {
      const data = JSON.parse(evt.data) as Chunk;

      if (data.type === "token") {
        // seq 기반 중복 제거
        if (data.seq <= lastSeqRef.current) return;
        lastSeqRef.current = data.seq;

        setText((prev) => prev + data.token);
      }

      if (data.type === "done") {
        es.close();
      }

      if (data.type === "error") {
        console.error(data.message);
        es.close();
      }
    };

    es.onerror = () => {
      // 자동 재연결이 중복을 만들 수 있으니,
      // 운영에서는 정책적으로 끄거나(직접 구현) UI에 표시
      // 여기서는 단순 종료
      es.close();
    };

    return () => {
      es.close();
    };
  }, [url]);

  return { text };
}

중복 제거를 “문자열 diff”로 하려 하면 케이스가 지옥처럼 늘어납니다. 가능한 한 서버가 seq를 제공하고, 클라이언트는 seq 단조 증가만 보장하면 됩니다.


5) 스트리밍 끊김의 대표 원인과 대응

1) 프록시 idle timeout

가장 흔합니다. 60초, 120초 등으로 끊깁니다.

  • 해결: heartbeat 주기적으로 전송
  • 해결: ALB/NLB/Ingress/Nginx의 timeout 상향

2) 백엔드 워커 타임아웃

Gunicorn/Uvicorn worker timeout, Node 서버의 request timeout 등으로 끊깁니다.

  • 해결: 스트리밍 엔드포인트만 별도 timeout
  • 해결: 워커 수 부족으로 큐잉이 늘면 “첫 토큰까지 지연”이 발생하므로, 오토스케일/동시성 제한도 함께 봐야 합니다.

3) 네트워크 egress 불안정

특히 쿠버네티스에서 NAT GW, SNAT 포트 고갈, 보안장비 등으로 장시간 커넥션이 흔들릴 수 있습니다.

  • 해결: 커넥션 재사용 정책 점검, NAT/프록시 관찰
  • 해결: 서버에서 재연결을 고려한 설계(버퍼/재전송 or 중복 제거)

네트워크 관측을 제대로 하려면 “간헐 끊김”을 시간축으로 추적해야 합니다.


6) 운영에서 추천하는 방어 설계(정답에 가까운 조합)

스트리밍을 안정적으로 만들려면 “끊김을 0으로 만들기”보다 “끊겨도 중복/깨짐 없이 복구”로 목표를 잡는 편이 현실적입니다.

(1) 서버 이벤트에 request_id, run_id, seq 포함

  • seq: 토큰 이벤트 단조 증가
  • run_id: 한 번의 생성 실행
  • request_id: 사용자 요청

클라이언트는 run_id가 바뀌면 새 답변으로 취급하고, 같은 run_id에서는 seq로 중복 제거합니다.

(2) 재시도 정책을 분리

  • 스트리밍 시작 전: 제한적 재시도 허용
  • 첫 토큰 이후: 기본은 재시도 금지
  • 반드시 재시도해야 한다면: 기존 스트림을 종료하고 “새 run”으로 분리

(3) 버퍼링 옵션과 헤더를 명시

  • Content-Typetext/event-stream으로 고정
  • X-Accel-Buffering 비활성
  • Cache-Control 비활성

(4) 메모리/큐 폭주 방지

스트리밍 큐를 무한정 쌓으면 느린 클라이언트가 있을 때 서버 메모리가 증가합니다.

  • 큐에 최대 크기 제한
  • 느린 소비자는 끊고 재시도 유도

메모리 급증이 의심되면 OOM 로그를 기반으로 원인을 좁히는 방식이 도움이 됩니다.


7) 체크리스트: “끊김”과 “중복”을 10분 안에 좁히기

  • 클라이언트가 SSE 자동 재연결을 하고 있나? 재연결 시 어떤 UI 동작을 하나?
  • 서버가 스트림 이벤트에 seq를 포함하나? 클라이언트가 seq로 중복 제거하나?
  • LangChain 콜백이 요청마다 1회만 등록되나? 전역 누적은 없는가?
  • 프론트에서 구독이 2번 열리지 않나? cleanup이 확실한가?
  • 프록시 버퍼링이 꺼져 있나? heartbeat가 있나?
  • disconnect 시 LLM 호출이 취소되나? 백그라운드에서 계속 돌지 않나?
  • 스트리밍 중간 재시도가 켜져 있나? 켜져 있다면 중복을 어떻게 막나?

마무리

LangChain 스트리밍의 끊김·중복 토큰 문제는 “LLM이 이상하다”기보다, 대부분 스트림 연결의 수명주기재시도/재연결 정책, 그리고 콜백/구독의 중복에서 발생합니다.

가장 비용 대비 효과가 큰 해결책은 두 가지입니다.

  1. 서버 이벤트에 seq를 넣고 클라이언트에서 단조 증가로 중복 제거
  2. 스트리밍 중간 재시도를 원칙적으로 금지하고, 필요하면 새 run_id로 분리

이 두 가지만 적용해도 운영에서 체감되는 “두 번 출력” 문제는 거의 사라지고, 끊김이 발생해도 UX를 통제 가능한 수준으로 만들 수 있습니다.