Published on

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

Authors

LangChain으로 OpenAI 응답을 스트리밍하면 UX가 좋아지지만, 운영 단계에서는 두 가지 문제가 유독 자주 터집니다.

  • 중복 토큰: 같은 문장이 두 번 출력되거나, 일부 토큰이 반복되어 붙습니다.
  • 끊김/유실: 문장이 중간에서 끊기거나, 스트림이 끝났는데도 마지막 몇 글자가 빠집니다.

이 글은 “왜 이런 현상이 생기는지”를 전송 계층부터 애플리케이션 계층까지 나눠서 진단하고, LangChain 콜백/러너블 구성, SSE 파이프라인, 재시도 정책에서의 구체적인 고치는 방법을 코드로 제시합니다.

운영에서 재시도까지 포함해 안정화하려면, 스트리밍을 단순히 streaming=true로 켜는 것만으로는 부족합니다. 특히 오류 재시도에서 스트리밍은 일반 요청보다 훨씬 까다롭습니다. 관련해서 재시도·폴백·서킷브레이커 패턴은 아래 글도 같이 보면 도움이 됩니다.

문제를 4가지 유형으로 분류하기

중복/끊김은 대체로 아래 4가지 중 하나입니다.

1) 네트워크 재연결로 인한 이벤트 재수신

SSE는 연결이 끊기면 클라이언트가 재연결을 시도할 수 있고, 서버/프록시가 버퍼링한 데이터를 다시 흘려보내면 같은 델타를 다시 받는 것처럼 보일 수 있습니다.

특히 다음 조합에서 흔합니다.

  • 프록시가 응답을 버퍼링하거나 청크를 합침
  • 클라이언트가 자동 재연결하면서 이전 이벤트를 다시 처리
  • 서버가 이벤트 id를 주지 않아 Last-Event-ID 기반 복구가 불가

2) 애플리케이션 레벨 재시도에서 “부분 출력”이 남아 중복

스트리밍 중간에 타임아웃이나 5xx가 나면, 애플리케이션이 재시도를 걸어 새 요청을 다시 스트리밍합니다. 이때 이미 사용자에게 흘려보낸 텍스트를 롤백하지 않으면, 재시도 스트림의 앞부분이 기존 출력 뒤에 붙어 중복이 됩니다.

3) 콜백/핸들러 중복 등록으로 동일 토큰을 두 번 렌더링

LangChain에서 콜백을 체인과 모델 양쪽에 동시에 붙이거나, 체인을 감싸는 레이어가 여러 번 이벤트를 전달하면 한 델타가 두 번 UI로 전달될 수 있습니다.

4) 토큰 경계 처리 미흡(클라이언트 병합 로직 문제)

클라이언트가 델타를 문자열로 합치는 과정에서, 멀티바이트 문자 경계나 줄바꿈 처리 때문에 UI가 깨지거나, 마지막 청크가 flush되지 않아 끝부분이 유실될 수 있습니다.

먼저 확인할 체크리스트(운영에서 바로 쓰는 순서)

  1. 프록시 버퍼링 끄기: Nginx라면 X-Accel-Buffering: no, proxy_buffering off를 확인
  2. 서버 응답 헤더: Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive
  3. 클라이언트 재연결 정책: EventSource의 자동 재연결을 쓰는지, 끊겼을 때 UI가 어떻게 동작하는지
  4. 재시도 레이어: 5xx/타임아웃 재시도 시, 이미 출력된 텍스트를 어떻게 처리하는지
  5. 콜백 중복: LangChain 콜백이 모델과 체인에 중복으로 등록되지 않았는지

LangChain에서 중복 토큰이 생기는 대표 패턴

패턴 A: 콜백을 두 군데에 붙여서 두 번 받는 경우

예를 들어 모델 생성 시 콜백을 넣고, 체인 실행 시에도 콜백을 넣으면 이벤트가 중복으로 들어올 수 있습니다.

아래는 “안 좋은 예”의 전형입니다.

import { ChatOpenAI } from "@langchain/openai";
import { HumanMessage } from "@langchain/core/messages";

const model = new ChatOpenAI({
  model: "gpt-4o-mini",
  streaming: true,
  callbacks: [myCallback],
});

// 실행할 때도 callbacks를 또 넣음
await model.invoke([new HumanMessage("hello")], {
  callbacks: [myCallback],
});

해결은 간단합니다. 콜백은 한 레벨에만 붙이거나, “전달용 콜백”과 “렌더링 콜백”을 분리해서 중복 렌더링을 막습니다.

const model = new ChatOpenAI({
  model: "gpt-4o-mini",
  streaming: true,
});

await model.invoke([new HumanMessage("hello")], {
  callbacks: [myRenderCallback],
});

패턴 B: 체인을 감싼 래퍼가 토큰 이벤트를 재방송

Runnable을 여러 겹으로 감싸거나, 서버에서 스트림을 받아 다시 SSE로 “중계”할 때 동일 델타를 두 번 write하는 버그가 흔합니다.

중계 서버를 만들 때는 “델타를 받는 곳”과 “SSE로 내보내는 곳” 사이에 단 하나의 병합/전송 루프만 존재하도록 구조를 단순화하세요.

끊김/유실이 생기는 대표 패턴

패턴 C: 서버가 마지막 flush를 못하고 종료

Node.js에서 SSE를 쓸 때, 응답을 끝낼 때 res.end() 직전에 마지막 이벤트를 보내지 못하거나, 프록시가 마지막 청크를 합치다가 유실되는 경우가 있습니다.

해결책은 다음을 지키는 겁니다.

  • 이벤트는 항상 \n\n으로 끝내기
  • 종료 이벤트를 명시적으로 보내고 종료
  • 타임아웃을 너무 짧게 두지 않기

패턴 D: 클라이언트가 청크 병합을 잘못함

특히 브라우저에서 fetch 스트림을 직접 읽을 때 TextDecoder를 매번 새로 만들면 멀티바이트 경계에서 글자가 깨질 수 있습니다. TextDecoder는 재사용하고, stream: true 옵션을 사용하세요.

서버(Next.js/Node)에서 안전한 SSE 중계 예제

아래 예제는 서버가 OpenAI 스트리밍을 받아서 중복 방지용 시퀀스를 붙여 SSE로 내보내는 형태입니다. 핵심은 다음입니다.

  • 각 이벤트에 id를 부여해서 클라이언트가 중복을 제거할 수 있게 함
  • Cache-Control 등 SSE 필수 헤더 설정
  • 종료 이벤트를 명확히 전송

<> 문자가 본문에 노출되면 MDX에서 문제가 될 수 있으니, 코드 블록 안에서만 사용하거나 인라인은 백틱으로 감쌉니다.

import type { NextRequest } from "next/server";
import { ChatOpenAI } from "@langchain/openai";
import { HumanMessage } from "@langchain/core/messages";

export const runtime = "nodejs";

export async function POST(req: NextRequest) {
  const { prompt } = await req.json();

  const model = new ChatOpenAI({
    model: "gpt-4o-mini",
    streaming: true,
    temperature: 0.2,
  });

  const encoder = new TextEncoder();
  let seq = 0;

  const stream = new ReadableStream<Uint8Array>({
    async start(controller) {
      const writeEvent = (event: string, data: unknown) => {
        const id = String(seq++);
        const payload =
          `id: ${id}\n` +
          `event: ${event}\n` +
          `data: ${JSON.stringify(data)}\n\n`;
        controller.enqueue(encoder.encode(payload));
      };

      try {
        const iterable = await model.stream([new HumanMessage(prompt)]);

        for await (const chunk of iterable) {
          // chunk.content는 델타 문자열일 수 있음
          writeEvent("delta", { text: chunk.content ?? "" });
        }

        writeEvent("done", { ok: true });
      } catch (e: any) {
        writeEvent("error", {
          message: e?.message ?? "stream error",
        });
      } finally {
        controller.close();
      }
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream; charset=utf-8",
      "Cache-Control": "no-cache, no-transform",
      Connection: "keep-alive",
      // Nginx 사용 시 버퍼링 방지 힌트
      "X-Accel-Buffering": "no",
    },
  });
}

이 방식의 장점은, OpenAI 원본 스트림 포맷에 의존하지 않고 내 서비스의 SSE 규격을 고정할 수 있다는 점입니다. 또한 id가 있으니 클라이언트에서 중복 제거가 쉬워집니다.

클라이언트에서 중복 토큰 제거(이벤트 id 기반)

EventSource를 쓰든, fetch로 SSE를 파싱하든 핵심은 동일합니다.

  • 이벤트 id(또는 서버가 보내는 seq)를 기준으로 이미 처리한 이벤트는 무시
  • 델타 텍스트는 “append-only”로 합치되, 재시도 시에는 정책을 명확히

EventSource 예시는 다음과 같습니다.

const es = new EventSource("/api/chat/stream");

let lastId = -1;
let text = "";

es.addEventListener("delta", (ev: MessageEvent) => {
  const id = Number((ev as any).lastEventId ?? "-1");
  if (id <= lastId) return; // 중복 제거
  lastId = id;

  const { text: delta } = JSON.parse(ev.data);
  text += delta;
  render(text);
});

es.addEventListener("done", () => {
  es.close();
});

es.addEventListener("error", () => {
  // 네트워크 오류 시 자동 재연결이 중복을 만들 수 있으니
  // 여기서 재연결 정책을 직접 제어하는 것도 방법
});

만약 fetch로 스트림을 읽는다면 TextDecoder를 재사용해야 끊김/깨짐을 줄일 수 있습니다.

const res = await fetch("/api/chat/stream", { method: "POST" });
const reader = res.body!.getReader();
const decoder = new TextDecoder("utf-8");

let buf = "";

while (true) {
  const { value, done } = await reader.read();
  if (done) break;

  buf += decoder.decode(value, { stream: true });

  // SSE는 빈 줄(\n\n)로 이벤트 경계를 구분
  let idx;
  while ((idx = buf.indexOf("\n\n")) !== -1) {
    const raw = buf.slice(0, idx);
    buf = buf.slice(idx + 2);

    // raw에서 id/event/data 파싱
    // 파싱 결과로 중복 제거 후 렌더링
  }
}

재시도 때문에 생기는 “중복 출력”을 막는 설계

스트리밍에서 재시도를 안전하게 하려면, 두 가지 중 하나를 선택해야 합니다.

선택 1: 스트리밍 중에는 재시도하지 않는다

가장 단순하고 운영에서 예측 가능한 방법입니다.

  • 스트리밍 요청은 “한 번만 시도”
  • 실패하면 UI에 “재생성” 버튼 제공
  • 서버는 실패 시 error 이벤트를 보내고 종료

이 방식은 중복 토큰을 구조적으로 차단합니다.

선택 2: 재시도하되, 출력 스냅샷/체크포인트를 둔다

정말 재시도가 필요하다면 “이미 사용자에게 보여준 prefix”를 기준으로 이어붙이기를 해야 합니다.

현실적으로 LLM 출력은 결정적이지 않아서, 이어붙이기보다 재시도 시 전체를 다시 생성하고 UI를 리셋하는 편이 안전합니다. 즉,

  • 재시도 시작 시 기존 텍스트를 지우고
  • 새 스트림을 처음부터 다시 렌더링

이때 사용자 경험을 위해 “이전 결과 보관”만 해두는 식이 낫습니다.

재시도/폴백/서킷브레이커를 적용해야 한다면, 스트리밍 요청과 비스트리밍 요청의 정책을 분리하세요. 예를 들어:

  • 비스트리밍: 지수 백오프 재시도 n
  • 스트리밍: 재시도 0회, 실패 시 폴백 모델로 “비스트리밍 완성본” 제공

관련 패턴은 아래 글에서 더 깊게 다룹니다.

LangChain 구성에서 끊김을 줄이는 팁

1) 프롬프트를 “짧고 안정적”으로 만들기

토큰이 길어질수록 네트워크/프록시/타임아웃 변수에 더 오래 노출됩니다. 시스템 프롬프트를 과도하게 길게 넣는 대신:

  • 공통 지침은 서버에서 템플릿으로 관리
  • 사용자 입력은 필요한 만큼만
  • 출력 포맷을 지나치게 복잡하게 강제하지 않기

2) 출력 포맷을 JSON으로 강제할 때는 스트리밍 UX를 재설계

JSON은 닫는 중괄호가 마지막에 오므로, 스트리밍 중간에는 “깨진 JSON” 상태가 오래 유지됩니다. 이때 프론트가 JSON 파싱을 시도하면 끊김처럼 보일 수 있습니다.

해결책:

  • 스트리밍 중에는 텍스트로만 표시
  • 완료 이벤트 done 이후에만 JSON 파싱
  • 또는 라인 단위 JSONL로 설계

3) 서버 타임아웃과 프록시 타임아웃 정렬

서버는 살아있는데 프록시가 먼저 끊으면 “중간 끊김”이 됩니다. 다음을 한 세트로 맞추세요.

  • 로드밸런서 idle timeout
  • Nginx proxy_read_timeout
  • 애플리케이션 서버 타임아웃

운영에서 자주 놓치는 프록시/Nginx 설정 포인트

SSE가 끊기거나 중복처럼 보이면, 애플리케이션보다 프록시가 원인인 경우가 많습니다.

  • proxy_buffering off 또는 경로별 버퍼링 해제
  • gzip 압축이 SSE 청크를 합쳐서 지연을 만들 수 있으니 SSE 경로는 압축 제외
  • no-transform 캐시 지시어로 중간 변환 방지

클라이언트 체감 성능(끊김/지연)을 최적화하는 관점은 스크롤/리페인트 최적화 글과 결이 비슷합니다. “중간 레이어가 무엇을 버퍼링하고 변형하는지”를 추적하는 게 핵심입니다.

최종 정리: 중복/끊김을 동시에 잡는 권장 아키텍처

  1. 서버는 OpenAI 스트림을 그대로 노출하지 말고, 내부 표준 SSE로 래핑
  2. 모든 이벤트에 단조 증가 id 를 부여
  3. 클라이언트는 id 기반으로 중복 제거
  4. 스트리밍 요청은 원칙적으로 재시도 0회(실패 시 UI 재생성)
  5. 프록시 버퍼링/압축/타임아웃을 SSE에 맞게 조정
  6. LangChain 콜백은 한 군데에서만 렌더링되도록 구성

이 6가지만 지켜도, “중복 토큰”과 “중간 끊김”의 대부분은 재현이 멈추고, 남는 이슈는 로그로 추적 가능한 형태로 정리됩니다. 특히 id를 도입하면 문제를 ‘느낌’이 아니라 정확한 이벤트 중복/유실로 관측할 수 있어, 디버깅 난이도가 크게 내려갑니다.