Published on

LangChain 스트리밍 중복 토큰·끊김 7가지 원인

Authors

서버에서 LLM 응답을 스트리밍으로 흘려보내면 UX는 좋아지지만, 운영에서 자주 마주치는 두 가지 악성 증상이 있습니다.

  • 중복 토큰: 같은 문장이 두 번씩 출력되거나, 토큰이 겹쳐서 이어붙는 현상
  • 끊김/지연: 중간에 멈춘 것처럼 보이다가 한꺼번에 쏟아지거나, 아예 스트림이 끊기는 현상

LangChain 자체 버그로 보이기도 하지만, 실제로는 애플리케이션 레이어(콜백, 재시도, SSE, 프록시, 런타임)에서 발생하는 경우가 더 많습니다. 아래는 현장에서 가장 많이 보는 원인 7가지와 각 케이스의 진단 포인트 및 해결 패턴입니다.

참고: Node.js 환경에서 ESM 전환이나 패키지 exports 이슈로 런타임이 꼬이면 스트리밍 디버깅이 더 어려워집니다. 관련해서는 Node.js ESM 전환 후 exports import 오류 해결도 함께 확인해두면 좋습니다.

1) 콜백(Callback) 중복 등록으로 토큰이 2번씩 들어옴

전형적 증상

  • on_llm_new_token이 동일 토큰에 대해 두 번 호출
  • 로그를 찍어보면 콜백 체인이 중복으로 실행됨

흔한 원인

  • LangChain 객체 생성 시 callbacks를 넣었는데, 호출 시점에도 callbacks를 또 넣음
  • 전역 콜백 매니저에 핸들러를 등록하고, 요청마다 추가 등록

진단

  • 토큰 콜백에서 handlerId 같은 식별자를 출력해 중복 호출 여부 확인

해결 패턴

  • 콜백은 한 레이어에서만 주입하고, 요청 단위로 생성된 핸들러는 요청 종료 시 정리
import { ChatOpenAI } from "@langchain/openai";
import { CallbackManager } from "@langchain/core/callbacks/manager";

function makeModelForRequest(streamHandler: any) {
  // 요청 단위로만 콜백을 구성
  const manager = CallbackManager.fromHandlers({
    handleLLMNewToken(token: string) {
      streamHandler.write(token);
    },
  });

  return new ChatOpenAI({
    model: "gpt-4o-mini",
    streaming: true,
    callbackManager: manager,
  });
}

// 호출 시점에는 callbacks/callbackManager를 또 넘기지 않기
// await model.invoke(messages)

2) 재시도(Retry) 로직이 스트림에 “부분 응답”을 중첩시킴

전형적 증상

  • 초반 몇 토큰이 나오다가 네트워크 오류 발생
  • 재시도 후 처음부터 다시 생성되며 기존 토큰과 섞여 중복처럼 보임

흔한 원인

  • HTTP 클라이언트/SDK 레벨에서 자동 재시도 활성화
  • 애플리케이션에서 try/catch로 재시도하면서, 이미 클라이언트로 보낸 토큰을 되돌리지 못함

진단

  • 서버 로그에 attempt=1, attempt=2가 같은 요청에서 발생하는지 확인
  • 에러 발생 시점까지 전송된 바이트 수를 기록

해결 패턴

  • 스트리밍 중에는 자동 재시도를 끄거나, 재시도 시 새 스트림으로 전환(클라이언트에 재연결 유도)
  • 또는 토큰에 증분 시퀀스를 붙이고 클라이언트에서 중복 제거
type Chunk = { i: number; token: string };

let i = 0;
function sendChunk(res: any, token: string) {
  const chunk: Chunk = { i: i++, token };
  res.write(`data: ${JSON.stringify(chunk)}\n\n`);
}

// 클라이언트는 i가 이미 처리된 값이면 무시

3) SSE/프록시 버퍼링으로 “끊김 후 한 번에 출력”

전형적 증상

  • 서버는 토큰을 계속 write하는데 브라우저에는 안 보임
  • 어느 순간 수백 글자가 한꺼번에 나타남

흔한 원인

  • NGINX, Cloudflare, ALB 등에서 응답 버퍼링
  • Content-Type이 SSE로 설정되지 않거나, flush가 되지 않음

진단

  • 서버에서 토큰 발생 시각과 클라이언트 수신 시각을 비교
  • 프록시를 우회(직접 서버 호출)했을 때 정상인지 확인

해결 패턴 (Node/Express 예시)

  • SSE 헤더를 정확히 설정
  • NGINX 사용 시 X-Accel-Buffering: no 헤더 추가
  • 가능한 경우 res.flushHeaders() 호출
import express from "express";

const app = express();

app.get("/sse", async (req, res) => {
  res.status(200);
  res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
  res.setHeader("Cache-Control", "no-cache, no-transform");
  res.setHeader("Connection", "keep-alive");
  res.setHeader("X-Accel-Buffering", "no");
  // 일부 런타임에서 필요
  res.flushHeaders?.();

  // heartbeat: 프록시 idle timeout 방지
  const heartbeat = setInterval(() => {
    res.write(`: ping\n\n`);
  }, 15000);

  try {
    // LangChain 토큰을 여기서 res.write로 흘린다고 가정
    res.write(`data: ${JSON.stringify({ token: "hello" })}\n\n`);
  } finally {
    clearInterval(heartbeat);
    res.end();
  }
});

인프라 레벨에서 클라이언트가 먼저 연결을 끊는 문제(예: 499)가 섞이면 “끊김”으로 보이기도 합니다. 엣지/인그레스 관점은 EKS NGINX Ingress 499 폭주 원인과 해결도 참고하세요.

4) Abort/Cancel 처리 누락으로 “이전 요청 스트림”이 살아있음

전형적 증상

  • 새 질문을 보냈는데 이전 답변이 뒤늦게 섞여 나옴
  • 탭 이동/리렌더 후에도 백그라운드에서 토큰이 계속 생성됨

흔한 원인

  • 브라우저 AbortController로 요청을 취소했지만, 서버에서 취소 이벤트를 LangChain 실행에 연결하지 않음
  • 서버에서 req.on("close")를 감지하지 않음

진단

  • 서버에서 req.on("close") 시 로그를 남기고, 이후에도 토큰 콜백이 호출되는지 확인

해결 패턴

  • 연결 종료 시 LLM 실행을 중단하고, 콜백도 더 이상 쓰지 않도록 가드
app.get("/chat", async (req, res) => {
  let closed = false;
  req.on("close", () => {
    closed = true;
  });

  const model = makeModelForRequest({
    write(token: string) {
      if (closed) return;
      res.write(`data: ${JSON.stringify({ token })}\n\n`);
    },
  });

  // closed를 폴링하는 것보다, 가능하면 SDK/런타임이 제공하는 abort signal을 연결
  await model.invoke([{ role: "user", content: "hi" }]);

  if (!closed) res.end();
});

5) Runnable/Chain을 “두 번 실행”하는 실수

전형적 증상

  • 토큰이 거의 완전히 동일한 패턴으로 2회 출력
  • 특히 LangChain Runnable 조합 후 streaminvoke를 같이 호출했을 때 발생

흔한 원인

  • 디버깅을 위해 await chain.invoke()를 남겨두고, 실제 응답은 chain.stream()으로 또 흘림
  • 프론트엔드에서 동일 요청을 2번 보내는 상태(React Strict Mode 개발 환경에서 더 흔함)

진단

  • 요청 ID를 생성해 서버/클라이언트 로그에 남기고, 동일 ID로 2번 실행되는지 확인

해결 패턴

  • 스트리밍 응답 경로에서는 오직 한 번만 실행
  • 개발 환경에서 React Strict Mode로 인한 이중 호출 여부 점검
// 잘못된 예: 같은 입력을 invoke + stream
// await chain.invoke(input);
// for await (const chunk of await chain.stream(input)) { ... }

// 올바른 예: stream만 사용
for await (const chunk of await chain.stream("hello")) {
  // chunk 처리
}

6) 청크(Chunk) 경계 처리 오류로 중복/깨짐 발생

전형적 증상

  • 단어가 중간에서 잘려 붙거나, 같은 토큰이 반복된 것처럼 보임
  • 특히 SSE에서 data: 라인을 파싱할 때 자주 발생

흔한 원인

  • 네트워크 청크는 메시지 경계를 보장하지 않는데, \n\n 기준을 무시하고 임의로 JSON.parse를 시도
  • UTF-8 멀티바이트 문자가 청크 경계에서 잘려 디코딩이 꼬임

진단

  • 클라이언트에서 원본 텍스트 스트림을 그대로 로그로 찍어 data: 프레임이 깨지는지 확인

해결 패턴

  • 반드시 프레이밍을 구현: SSE는 이벤트 구분자 \n\n까지 버퍼링 후 처리
  • TextDecoder를 스트리밍 모드로 사용
// 브라우저 fetch SSE 파서 간단 예시
const res = await fetch("/sse");
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 });

  let idx;
  while ((idx = buf.indexOf("\n\n")) !== -1) {
    const frame = buf.slice(0, idx);
    buf = buf.slice(idx + 2);

    const line = frame.split("\n").find((l) => l.startsWith("data: "));
    if (!line) continue;

    const payload = JSON.parse(line.slice("data: ".length));
    // payload.token 처리
  }
}

7) 모델/프로바이더의 스트리밍 의미 차이(델타 vs 누적) 오해

전형적 증상

  • 어떤 프로바이더에서는 토큰이 “누적 문자열”로 오고, 다른 곳에서는 “델타 토큰”으로 옴
  • 누적을 델타로 가정하고 이어붙이면 중복이 폭발

흔한 원인

  • 이벤트 타입을 구분하지 않고 무조건 acc += chunk 처리
  • LangChain의 특정 스트리밍 이벤트가 content 전체를 다시 주는 형태인데, 이를 토큰 단위로 오해

진단

  • 스트림 이벤트에서 실제로 오는 필드가 “증분인지 누적인지”를 로깅
  • 예: chunk 길이가 계속 커지는지(누적) 또는 1~몇 글자 수준인지(델타)

해결 패턴

  • 누적 스트림이면 마지막 길이를 기억하고 증가분만 반영
let last = "";
function onChunkText(text: string) {
  // text가 누적 문자열이라면
  const delta = text.startsWith(last) ? text.slice(last.length) : text;
  last = text;
  // delta만 전송
}

운영에서 바로 쓰는 체크리스트

아래 순서로 보면 원인 좁히기가 빠릅니다.

  1. 서버에서 콜백이 2번 호출되는가? (원인 1, 5)
  2. 같은 요청이 2번 실행되는가? (클라이언트 중복 호출 포함) (원인 5)
  3. 스트리밍 중 재시도가 있는가? (원인 2)
  4. 프록시 버퍼링/idle timeout이 있는가? (원인 3)
  5. Abort가 서버 실행 중단으로 연결되는가? (원인 4)
  6. SSE 프레이밍/디코딩이 안전한가? (원인 6)
  7. 델타/누적 의미를 정확히 처리하는가? (원인 7)

마무리: “중복”은 대개 2중 실행, “끊김”은 대개 버퍼링

LangChain 스트리밍에서 중복 토큰은 대부분 콜백/체인 실행이 중복되거나 누적 스트림을 델타로 오해해서 발생합니다. 끊김은 대부분 SSE 프록시 버퍼링, idle timeout, Abort 처리 미흡에서 시작합니다.

스트리밍은 한 번 꼬이면 증상이 비슷하게 보이므로, 위 7가지를 기준으로 로그를 구조화해두는 것이 가장 큰 비용 절감 포인트입니다. 특히 요청 ID, attempt 번호, 토큰 시퀀스(증분 번호)만 잘 남겨도 “중복인지, 재시도인지, 프록시인지”가 빠르게 갈립니다.

추가로, 서버 리소스 누수나 정리 누락이 스트리밍 안정성을 갉아먹는 경우가 많습니다. Python 기반으로 LangChain을 운영한다면 컨텍스트 정리 패턴은 Python 3.11+ asynccontextmanager로 리소스 누수 막기도 같이 보면 도움이 됩니다.