Published on

LangChain 스트리밍 토큰 중복·누락 버그 해결

Authors

서버에서 LLM 응답을 스트리밍으로 내보내다 보면, 사용자 화면에는 종종 이상한 증상이 나타납니다. 문장이 두 번 반복되거나(중복), 중간 단어가 사라지거나(누락), 줄바꿈이 깨지거나, 최종 결과와 스트림으로 받은 결과가 서로 다르게 보이는 문제입니다. 특히 LangChain 기반으로 streaming=true 또는 이벤트 스트림을 붙였을 때 이슈가 도드라지는데, 원인은 LangChain 자체라기보다 콜백/전송/재시도/병합이 얽히면서 생기는 경우가 많습니다.

이 글에서는 재현 가능한 증상 분류부터 시작해, 중복·누락의 대표 원인을 단계적으로 분리하고, 프로덕션에서 바로 적용 가능한 안전한 스트리밍 누적 패턴디버깅 체크리스트를 제공합니다.

RAG를 함께 쓰는 경우, 스트리밍 문제와 별개로 검색 품질 이슈가 같이 보일 수 있습니다. 회수율/리랭킹 튜닝은 RAG 회수율 급락? 하이브리드+리랭커 튜닝도 참고하세요.

문제를 먼저 분류하기: 중복 vs 누락 vs 재정렬

스트리밍 버그는 겉보기엔 비슷하지만 원인과 처방이 다릅니다.

1) 토큰 중복

  • 예: 안녕하세요 안녕하세요 처럼 동일 구간이 반복
  • 예: 문단 전체가 두 번 출력
  • 예: 특정 구간만 반복 출력(특히 네트워크 재연결 직후)

2) 토큰 누락

  • 예: 오늘 날씨는 좋습니다오늘 씨는 좋습니다처럼 중간이 빠짐
  • 예: 줄바꿈/공백이 사라져 문장이 붙음

3) 토큰 재정렬

  • 예: 뒤에 나와야 할 문장이 먼저 출력
  • 대부분 멀티 태스크/멀티 스트림을 한 채널에 섞어 쓸 때 발생

이 글은 주로 1)과 2)를 다루되, 3)까지 예방하는 구조를 권장합니다.

LangChain 스트리밍에서 흔한 원인 7가지

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

LangChain은 콜백을 여러 레벨에서 받을 수 있습니다. 대표적으로:

  • 전역 콜백 매니저
  • 체인/에이전트 단 콜백
  • 모델 인스턴스 단 콜백

여기에 핸들러를 여러 군데에 동시에 붙이면 on_llm_new_token동일 토큰에 대해 여러 번 호출되어 중복이 발생합니다.

진단법

  • 핸들러 인스턴스에 유니크 ID를 부여하고, 토큰 이벤트마다 handler_id를 로그로 찍어 중복 호출 여부 확인
  • 체인 생성 코드에서 callbacks=를 어디에 주입했는지 검색

원인 B: 재시도 로직이 스트림을 재생성하면서 중복 전송

네트워크 타임아웃, 게이트웨이 502/504, 서버 측 예외로 재시도가 걸리면, 클라이언트는 이미 일부 토큰을 받았는데 서버가 스트림을 처음부터 다시 시작해 앞부분이 중복됩니다.

특히 다음 조합에서 자주 발생합니다.

  • 서버: 자동 재시도(HTTP client retry, LangChain retry, 프록시 retry)
  • 클라이언트: EventSource 또는 fetch 스트림 재연결

핵심 포인트

  • 스트리밍은 “멱등”이 아닙니다. 재시도는 곧 중복 가능성을 의미합니다.

원인 C: 청크 병합 로직이 문자열 경계를 깨뜨림

스트리밍은 토큰 단위가 아니라 청크 단위로 전달되는 경우가 많습니다.

  • UTF-8 멀티바이트 경계
  • 서러게이트 페어
  • 줄바꿈 \n 경계

이때 서버 또는 클라이언트에서 청크를 split하거나 임의로 버퍼링/합치기 하면 누락/깨짐이 생깁니다.

원인 D: 동시에 두 스트림을 한 출력 채널로 합침

예를 들어:

  • LangChain의 Runnable 그래프에서 병렬 실행
  • 툴 호출 결과와 LLM 토큰을 같은 SSE 이벤트로 섞음

이 경우 토큰이 섞여 보이거나, UI가 덮어쓰기 방식이면 일부가 “사라진 것처럼” 보입니다.

원인 E: 프록시/로드밸런서 버퍼링

Nginx, ALB, Cloudflare 같은 중간 계층이 응답을 버퍼링하면:

  • 토큰이 한 번에 뭉쳐서 도착
  • 연결이 끊겼다가 재연결되며 중복
  • flush 타이밍이 바뀌어 클라이언트 로직이 꼬임

프록시 이슈는 OAuth 리다이렉트 같은 다른 문제에서도 자주 원인으로 등장합니다. 프록시 환경에서의 헤더/버퍼링 감각은 Proxy 뒤 Nginx에서 OAuth 리다이렉트 URI 불일치 해결도 같이 보면 도움이 됩니다.

원인 F: LangChain의 스트리밍 콜백과 최종 결과를 둘 다 출력

가장 흔한 실수 중 하나입니다.

  • 스트리밍 토큰을 UI에 append
  • 동시에 invoke()의 최종 결과(전체 텍스트)를 다시 append

결과적으로 앞부분이 통째로 중복됩니다.

원인 G: 클라이언트 렌더링이 “append”가 아니라 “replace”인데, delta 처리 실수

UI가 최신 상태를 “전체 문자열로 replace”하는 방식이면, 서버가 보내는 것이 delta인지 full인지에 따라 누락처럼 보일 수 있습니다.

  • 서버: delta를 보냄
  • 클라이언트: full로 가정하고 replace

또는 반대.

재현 가능한 최소 예제로 원인 분리하기

아래는 Python LangChain에서 스트리밍 토큰을 수집하는 가장 단순한 형태입니다. 여기서부터 한 단계씩 기능을 붙여가며 어느 지점에서 중복/누락이 생기는지 확인하는 것이 가장 빠릅니다.

예제 1: 토큰을 안전하게 누적하는 콜백

from typing import Any, Dict, List
from langchain_openai import ChatOpenAI
from langchain_core.callbacks import BaseCallbackHandler

class CollectTokensHandler(BaseCallbackHandler):
    def __init__(self):
        self.tokens: List[str] = []

    def on_llm_new_token(self, token: str, **kwargs: Any) -> None:
        # 토큰은 그대로 append만 하고, 별도의 split/strip을 하지 않는다.
        self.tokens.append(token)

handler = CollectTokensHandler()

llm = ChatOpenAI(
    model="gpt-4o-mini",
    temperature=0,
    streaming=True,
    callbacks=[handler],
)

# 스트리밍이 끝난 뒤
_ = llm.invoke("한국어로 3문장 자기소개를 해줘")

final_text = "".join(handler.tokens)
print(final_text)

포인트

  • strip()을 토큰마다 적용하면 공백/줄바꿈이 사라져 “누락”처럼 보일 수 있습니다.
  • 토큰을 중간에 split(" ") 같은 방식으로 다루면 높은 확률로 깨집니다.

예제 2: 중복 콜백 등록을 탐지하는 로그

import uuid
from typing import Any
from langchain_core.callbacks import BaseCallbackHandler

class DebugHandler(BaseCallbackHandler):
    def __init__(self, name: str):
        self.name = name
        self.id = str(uuid.uuid4())

    def on_llm_new_token(self, token: str, **kwargs: Any) -> None:
        print(f"handler={self.name} id={self.id} token={repr(token)}")

이 핸들러를 모델/체인/전역에 각각 붙여보면, 동일 토큰이 몇 번 호출되는지 즉시 보입니다.

실전 해결책 1: 스트리밍과 최종 결과 출력 경로를 분리

가장 안전한 규칙은 다음입니다.

  • UI에 보여주는 텍스트는 오직 스트리밍 이벤트로만 갱신한다.
  • 최종 결과는 서버에서 검증/저장용으로만 쓰고, UI에 다시 append하지 않는다.

만약 최종 결과를 반드시 UI에 반영해야 한다면, “append”가 아니라 “replace”로 처리하고, 스트리밍 누적 결과와 최종 결과를 비교해 불일치가 있으면 최종 결과로 덮어쓰는 방식이 안정적입니다.

실전 해결책 2: SSE 이벤트에 시퀀스 번호를 붙여 중복 제거

재시도/재연결이 섞이면 서버가 같은 토큰을 다시 보내는 상황이 생깁니다. 이때는 이벤트를 멱등하게 만들어야 합니다.

서버: seq를 증가시키며 전송

from typing import Iterator
import json

class SequencedEmitter:
    def __init__(self):
        self.seq = 0

    def event(self, chunk: str) -> str:
        self.seq += 1
        payload = {"seq": self.seq, "delta": chunk}
        # SSE 포맷: data: ...\n\n
        return "data: " + json.dumps(payload, ensure_ascii=False) + "\n\n"

클라이언트: last_seq 기반으로 중복 무시

let lastSeq = 0;

function onSseMessage(e: MessageEvent) {
  const msg = JSON.parse(e.data) as { seq: number; delta: string };
  if (msg.seq <= lastSeq) return; // 중복 방지
  lastSeq = msg.seq;
  appendToUI(msg.delta);
}

추가 팁

  • SSE 표준에는 id: 필드가 있어 Last-Event-ID로 재개를 지원합니다. 가능하면 id:를 함께 쓰고, 서버가 해당 ID 이후부터 재전송하도록 설계하면 더 견고합니다.
  • 단, MDX 본문에서 부등호가 들어간 예시는 빌드 에러를 유발할 수 있으니 문서화할 때는 항상 인라인 코드로 감싸세요.

실전 해결책 3: 프록시 버퍼링 끄기와 타임아웃 정렬

Nginx를 앞단에 둔 경우, 스트리밍이 “뭉쳐서” 오거나 중간에 끊기는 문제는 설정에서 시작되는 경우가 많습니다.

Nginx 예시 설정

location /api/stream {
  proxy_pass http://app;
  proxy_http_version 1.1;
  proxy_set_header Connection "";

  # 스트리밍 핵심
  proxy_buffering off;
  proxy_cache off;
  chunked_transfer_encoding on;

  # 타임아웃은 모델 응답 특성에 맞게
  proxy_read_timeout 3600s;
}

서버 응답 헤더 체크

  • Content-Typetext/event-stream으로
  • Cache-Control: no-cache
  • 가능한 경우 X-Accel-Buffering: no 추가

프록시/게이트웨이에서 특정 조건에만 502가 난다면 네트워크 레이어 추적이 필요합니다. EKS 환경이라면 EKS에서 Pod egress만 502? Envoy/NLB 추적기처럼 “어디서 끊기는지”를 먼저 확정하는 게 빠릅니다.

실전 해결책 4: 토큰 병합은 “그대로 join”하고, 후처리는 마지막에

누락의 상당수는 토큰을 받는 즉시 전처리하기 때문에 생깁니다.

안 좋은 예

  • 토큰마다 strip() 적용
  • 토큰마다 replace("\n", "") 적용
  • 토큰마다 마크다운 렌더링 수행

좋은 예

  • 토큰은 원문 그대로 배열에 저장
  • UI에는 그대로 append
  • 최종 완료 이벤트에서만 전체 문자열에 후처리(필요 최소)
# 완료 시점에만 후처리
text = "".join(tokens)
text = text.replace("\r\n", "\n")

실전 해결책 5: LangChain Runnable/Agent에서 스트림 소스를 단일화

에이전트/툴 호출이 섞이면 이벤트가 여러 경로로 발생할 수 있습니다.

권장 패턴:

  • “사용자에게 보여줄 스트림”은 하나의 채널에서만 흘러오게 한다.
  • 툴 로그/중간 추론/디버그 이벤트는 별도 채널(또는 별도 SSE 이벤트 타입)로 분리한다.

예를 들어 SSE에서 type을 분리합니다.

{ "type": "delta", "seq": 10, "delta": "안녕하세요" }
{ "type": "tool", "name": "search", "payload": { "q": "..." } }
{ "type": "final", "text": "..." }

클라이언트는 type=delta만 본문에 반영하면 중간 이벤트로 인한 “누락처럼 보이는 덮어쓰기”를 피할 수 있습니다.

디버깅 체크리스트: 15분 안에 범인 찾기

  1. 콜백 등록 위치를 전수 검색해서 중복 등록 여부 확인
  2. 스트리밍 토큰과 최종 결과를 둘 다 UI에 append하고 있지 않은지 확인
  3. 서버/클라이언트에 재시도가 켜져 있는지 확인(HTTP client, fetch, 프록시)
  4. SSE라면 이벤트에 seq 또는 id를 붙여 멱등 처리 적용
  5. Nginx/ALB/Cloudflare 등 중간 계층의 bufferingtimeout 확인
  6. 토큰마다 strip/split/replace 같은 전처리를 제거하고, 후처리를 완료 시점으로 이동
  7. 병렬 실행 결과를 한 스트림에 섞고 있다면 이벤트 타입 분리
  8. UTF-8 경계 문제 의심 시, 바이트 단위 조작을 제거하고 문자열 단위로만 합치기

마무리: “스트리밍은 멱등하게, 누적은 보수적으로”

LangChain 스트리밍에서 토큰 중복·누락이 보이면, 모델이 이상한 게 아니라 대개 이벤트가 두 번 흘렀거나, 전송 계층에서 끊겼거나, 클라이언트가 delta를 잘못 합쳤거나 중 하나입니다.

정리하면 다음 3가지만 지켜도 대부분 해결됩니다.

  • 스트리밍 이벤트에 seq를 붙여 클라이언트에서 중복 제거
  • 토큰은 원문 그대로 누적하고, 후처리는 마지막에
  • 콜백/출력 경로를 단일화하고 최종 결과와 스트림을 중복 출력하지 않기

원하시면 사용 중인 조합(예: LangChain 버전, ChatOpenAI 또는 다른 provider, SSE인지 WebSocket인지, Nginx/ALB 유무, 프론트 프레임워크)을 알려주시면, 그 스택에 맞춰 “가장 가능성 높은 원인 1~2개”로 좁혀서 패치 예시까지 더 구체화해드릴 수 있습니다.