Published on

LangChain 스트리밍 중복·순서꼬임 버그 해결

Authors

서버에서 LLM 응답을 스트리밍으로 내보낼 때, 사용자가 가장 먼저 체감하는 장애는 두 가지입니다. 첫째, 토큰이 중복되어 문장이 두 번씩 이어 붙는 현상. 둘째, 토큰 순서가 뒤섞여 문장이 깨지는 현상입니다. LangChain을 붙인 뒤 특히 자주 발생하는데, 이유는 단순히 모델이 이상해서가 아니라 스트리밍 이벤트의 전달 경로가 길고, 그 사이에서 재시도·병렬 실행·콜백 중복 등록 같은 “분산 시스템의 고전적 문제”가 그대로 나타나기 때문입니다.

이 글은 LangChain 기반 스트리밍에서 중복·순서꼬임을 만드는 대표 원인을 분해하고, 서버와 클라이언트 각각에서 적용할 수 있는 방어 패턴을 코드로 정리합니다. 또한 “한 번만 처리”를 보장하는 아이디어는 결제 중복 방지에서 쓰는 Outbox 사고방식과도 닮아 있으니, 필요하면 MSA 사가 패턴 - Outbox+CDC로 중복결제 막기도 함께 참고하면 좋습니다.

증상: 무엇이 “중복”이고 무엇이 “순서꼬임”인가

문제는 보통 아래 형태로 관측됩니다.

  • 중복: 안녕하세요 안녕하세요 저는... 처럼 동일 토큰 덩어리가 반복
  • 순서꼬임: 저는 오늘오늘 저는처럼 섞이거나, 문장 중간에 이전 문장이 끼어듦
  • 혼합: 중복도 있고 순서도 섞임

중요한 점은 “토큰 단위” 문제가 아니라 “이벤트 단위” 문제인 경우가 많다는 것입니다. 즉, 모델이 내보낸 스트림을 우리가 어떤 단위로 쪼개고, 어떤 키로 식별하고, 어떤 순서로 합성하느냐에서 버그가 생깁니다.

원인 1: 콜백 핸들러 중복 등록으로 동일 이벤트가 두 번 처리됨

LangChain은 callbacks를 체인/모델/런타임에 걸쳐 전달합니다. 실무에서 흔한 실수는 다음과 같습니다.

  • 전역 콜백 매니저에 핸들러를 등록해두고
  • 요청마다 또 핸들러를 추가 등록
  • 또는 체인과 LLM 각각에 동일 핸들러를 주입

이 경우 on_llm_new_token이 동일 토큰에 대해 두 번 호출될 수 있습니다.

체크리스트

  • 동일 요청에서 CallbackHandler 인스턴스가 두 번 이상 생성·등록되는지
  • 체인 레벨과 LLM 레벨에 같은 핸들러를 동시에 넣고 있는지
  • 프레임워크 미들웨어에서 스트림을 복제하고 있지 않은지

해결: 요청 단위로 콜백을 “단일 진입점”에만 연결

아래는 Python에서 요청마다 단 하나의 스트림 핸들러만 만들고, 그 핸들러만 callbacks로 전달하는 예시입니다.

import asyncio
from typing import AsyncIterator

from langchain.callbacks.base import AsyncCallbackHandler

class SSETokenHandler(AsyncCallbackHandler):
    def __init__(self, queue: asyncio.Queue):
        self.queue = queue

    async def on_llm_new_token(self, token: str, **kwargs):
        await self.queue.put(("token", token))

    async def on_llm_end(self, response, **kwargs):
        await self.queue.put(("end", None))

async def stream_chain(chain, inputs) -> AsyncIterator[str]:
    q: asyncio.Queue = asyncio.Queue()
    handler = SSETokenHandler(q)

    task = asyncio.create_task(chain.ainvoke(inputs, config={"callbacks": [handler]}))

    while True:
        kind, payload = await q.get()
        if kind == "token":
            yield payload
        else:
            break

    await task

핵심은 “어디에서든 콜백을 추가하지 말고”, 스트리밍을 담당하는 레이어에서만 콜백을 구성하는 것입니다.

원인 2: 재시도·네트워크 재연결로 같은 구간이 다시 전송됨

SSE나 웹소켓은 중간에 끊기면 클라이언트가 재연결합니다. 이때 서버가 “이전까지 어디까지 보냈는지”를 모르고 처음부터 다시 보내면, 클라이언트 화면에서는 중복이 됩니다.

또한 서버 측에서 LLM 호출에 재시도 로직을 넣어두면, 스트리밍 도중 실패 후 재시도하면서 앞부분 토큰이 다시 생성되어 중복이 생길 수 있습니다.

  • 클라이언트 재연결: 같은 run의 스트림을 다시 받음
  • 서버 재시도: 같은 질문을 다시 호출해 토큰이 다시 생성됨

해결 A: 이벤트에 단조 증가하는 시퀀스 번호 부여

가장 확실한 방법은 서버가 각 토큰 이벤트에 seq를 붙여 보내고, 클라이언트는 마지막으로 처리한 seq보다 작은 이벤트를 버리는 것입니다.

SSE 예시로 id:를 시퀀스 번호로 쓰면 표준 동작과도 잘 맞습니다.

import json

async def sse_event_stream(token_iter):
    seq = 0
    async for token in token_iter:
        seq += 1
        data = json.dumps({"seq": seq, "token": token}, ensure_ascii=False)
        # SSE에서 id는 재연결 시 Last-Event-ID로 전달됨
        yield f"id: {seq}\n"
        yield "event: token\n"
        yield f"data: {data}\n\n"

    yield "event: end\n"
    yield "data: {}\n\n"

해결 B: 클라이언트에서 seq 기반으로 중복 제거 및 정렬

type TokenEvent = { seq: number; token: string };

let lastSeq = 0;
let buffer = new Map<number, string>();

function onTokenEvent(ev: MessageEvent<string>) {
  const msg = JSON.parse(ev.data) as TokenEvent;

  // 중복 제거
  if (msg.seq <= lastSeq) return;

  buffer.set(msg.seq, msg.token);

  // 순서대로만 커밋
  while (buffer.has(lastSeq + 1)) {
    const next = lastSeq + 1;
    appendToUI(buffer.get(next)!);
    buffer.delete(next);
    lastSeq = next;
  }
}

이 로직은 “도착 순서가 뒤섞여도 화면에는 항상 정렬된 순서로만 반영”되게 합니다.

원인 3: 병렬 실행과 합성 단계에서 순서가 깨짐

LangChain을 쓰다 보면 다음을 쉽게 추가합니다.

  • 문서 검색과 요약을 병렬로 실행
  • 여러 툴 호출을 동시에 실행
  • 여러 체인의 출력을 합쳐 최종 답변 생성

이때 스트리밍을 “각 작업 단위”로 받아서 합치면, 작업 완료 순서대로 토큰이 섞여 들어가며 순서꼬임이 발생합니다. 특히 asyncio.gather로 여러 스트림을 동시에 소비하면서 하나의 출력으로 합치는 구현이 위험합니다.

해결: 스트리밍은 “최종 응답을 만드는 단일 생성자”에서만 수행

권장 패턴은 이렇습니다.

  • 병렬 작업은 스트리밍하지 말고 결과를 모아서
  • 최종 LLM 호출 하나만 스트리밍한다

즉, 사용자가 보는 스트림은 항상 “단일 run”에서 나오게 만들면 순서 문제가 급감합니다.

만약 반드시 여러 소스 스트림을 합쳐야 한다면, 앞 절의 seq 기반 합성이 사실상 필수입니다.

원인 4: UI 상태 업데이트 경쟁으로 화면이 역전됨

클라이언트에서 토큰을 받을 때마다 상태를 갱신하면, 렌더링 스케줄링이나 비동기 상태 업데이트가 겹치면서 “나중 토큰이 먼저 붙고, 이전 토큰이 뒤늦게 붙는” 것처럼 보일 수 있습니다.

React/Next.js 환경에서는 특히 다음이 문제를 키웁니다.

  • 여러 setState 호출이 배치되며 예상과 다른 순서로 커밋
  • 스트림 이벤트 핸들러가 재등록되어 중복 처리
  • RSC 또는 hydration 경계에서 UI가 재생성

관련해서 Next.js 환경의 상태 불일치 문제는 Next.js 14 RSC에서 hydration mismatch 해결법도 참고할 만합니다.

해결: UI 반영은 버퍼링 후 단일 커밋

  • 토큰 이벤트를 바로 UI에 붙이지 말고 버퍼에 쌓기
  • requestAnimationFrame 또는 일정 간격으로 합쳐서 반영
let pending = "";
let scheduled = false;

function scheduleFlush() {
  if (scheduled) return;
  scheduled = true;

  requestAnimationFrame(() => {
    scheduled = false;
    if (pending.length === 0) return;
    appendToUI(pending);
    pending = "";
  });
}

function onToken(token: string) {
  pending += token;
  scheduleFlush();
}

이 방식은 UI 경쟁을 줄이고, 체감 성능도 좋아집니다.

원인 5: LangChain 이벤트 타입 혼용으로 “같은 의미”가 두 번 붙음

LangChain 이벤트에는 토큰 외에도 다음이 섞일 수 있습니다.

  • 중간 단계 로그
  • tool call 관련 메시지
  • 최종 답변 메시지

이를 모두 “사용자 답변”에 단순 concat하면, 최종 답변과 토큰 스트림이 둘 다 붙어 중복처럼 보일 수 있습니다.

해결: 사용자에게 보여줄 채널을 명확히 분리

  • 토큰 스트림은 final_answer 채널만
  • tool log는 debug 채널로 별도 UI 또는 서버 로그로만

예를 들어 SSE 이벤트를 event: token, event: debug, event: final처럼 분리하고, 클라이언트에서 token만 본문에 합칩니다.

실전 방어 설계: “중복 제거 + 순서 보장 + 단일 writer” 3종 세트

스트리밍 안정성을 빠르게 올리는 조합은 다음입니다.

  1. 단일 writer: 사용자 화면에 쓰는 스트림은 한 군데에서만 생성
  2. 시퀀스 번호: 모든 토큰 이벤트에 seq 부여
  3. 클라이언트 버퍼: seq 기반으로 정렬 커밋, 중복은 버림

이 3가지만 적용해도 재연결, 약한 네트워크, 서버 재시도, 병렬 처리의 상당 부분이 흡수됩니다.

디버깅 방법: 재현 가능한 로그를 남겨야 고친다

중복·순서꼬임은 “사용자 화면”만 보고는 원인을 특정하기 어렵습니다. 다음 정보를 반드시 로그로 남기면 추적이 쉬워집니다.

  • run_id 또는 요청 상관관계 ID
  • seq와 토큰 길이
  • 이벤트 타입(token, final, debug)
  • 전송 채널(SSE, WS)과 재연결 여부

서버 로그 예시입니다.

import logging
logger = logging.getLogger(__name__)

async def emit_token(run_id: str, seq: int, token: str):
    logger.info(
        "stream_token run_id=%s seq=%d len=%d",
        run_id, seq, len(token)
    )

자주 묻는 함정

스트리밍 중간에 예외가 나면 “끝 이벤트”가 안 와서 UI가 멈춘다

예외가 발생해도 반드시 종료 이벤트를 보내거나, 클라이언트에서 타임아웃으로 스트림을 닫아야 합니다. Python에서는 try/finallyend 이벤트를 보장하세요.

async def safe_stream(token_iter):
    try:
        async for t in token_iter:
            yield t
    finally:
        # end 이벤트를 보내거나 상태를 정리
        pass

멀티 프로세스/멀티 워커에서 전송 순서가 바뀐다

여러 워커가 같은 클라이언트 연결에 쓰면 순서 보장이 깨집니다. 연결 단위로 단일 프로세스가 책임지게 하거나, 브로커를 둬서 한 곳에서만 전송하세요.

마무리

LangChain 스트리밍의 중복·순서꼬임은 “모델 문제”라기보다 이벤트 전달과 합성의 문제인 경우가 대부분입니다. 콜백 중복 등록을 제거하고, 재연결과 재시도를 고려해 seq를 부여하며, 클라이언트에서 버퍼링 후 순서대로 커밋하는 구조로 바꾸면 체감 장애가 거의 사라집니다.

특히 seq 기반의 중복 제거는 결제/이벤트 처리에서의 exactly-once에 가까운 발상과 동일합니다. 스트리밍을 제품 기능으로 운영하려면, 생성 AI 이전에 분산 이벤트 처리의 기본기를 그대로 적용하는 것이 가장 빠른 해결책입니다.