Published on

LangChain 스트리밍 중복토큰·메모리누수 9분 해결

Authors

서버에서 LLM 응답을 스트리밍으로 내려주다 보면 두 가지 문제가 같이 따라오는 경우가 많습니다.

  • 중복 토큰: 같은 문장이 두 번씩 붙거나, 이미 보낸 토큰이 다시 전송됨
  • 메모리 누수처럼 보이는 증가: 요청이 끝나도 프로세스 RSS가 계속 올라가고 GC가 따라오지 못함

이 글은 LangChain 기반 Node.js 서버를 기준으로, 9분 안에 문제를 재현하고 원인을 분리한 뒤, 실전에서 가장 자주 먹히는 해결책을 적용하는 흐름으로 구성했습니다.

관련해서 LangChain v0.2에서 메모리 기능이 바뀐 뒤 대화 상태를 유지하는 패턴도 함께 보면 좋습니다. LangChain v0.2 메모리 폐기 후 대화상태 유지법

0. 전제: “중복 토큰”은 보통 두 군데에서 생긴다

중복 토큰은 대개 아래 둘 중 하나입니다.

  1. 이벤트를 두 번 소비한다

    • 같은 스트림을 두 곳에서 읽거나
    • 콜백 핸들러를 중복 등록하거나
    • 재시도 로직이 스트림을 다시 붙인다
  2. 누적 버퍼를 매번 전체로 보내는 방식

    • 예를 들어 UI에 fullText를 매 토큰마다 보내면, 프론트가 append 할 때 중복이 발생

즉, 중복 토큰은 “모델이 이상하다”기보다 스트리밍 파이프라인의 조립 문제인 경우가 대부분입니다.

1분: 증상 재현용 최소 코드 만들기

먼저 “어디서 중복이 생기는지”를 보려면, 체인을 최대한 단순화해서 재현해야 합니다. 아래는 LangChain v0.2 계열에서 흔히 쓰는 형태의 스트리밍 예시입니다.

import { ChatOpenAI } from "@langchain/openai";

const llm = new ChatOpenAI({
  model: "gpt-4o-mini",
  temperature: 0,
});

export async function streamOnce(prompt: string) {
  const stream = await llm.stream(prompt);
  let count = 0;

  for await (const chunk of stream) {
    count += 1;
    // chunk.content는 보통 문자열 또는 문자열 배열일 수 있음
    process.stdout.write(String(chunk.content ?? ""));
  }

  process.stderr.write(`\nchunks=${count}\n`);
}

이 코드가 정상인데, 여러분의 서비스 코드에서만 중복이 생기면 SSE 라우트, 콜백, 재시도, 프론트 렌더 중 어딘가가 원인입니다.

2분: 중복 토큰 1번 원인 — 콜백 핸들러 중복 등록

LangChain에서 스트리밍 토큰을 콜백으로 받는 구조를 쓸 때, 다음과 같은 실수가 잦습니다.

  • 요청마다 handler를 새로 만들지 않고 전역 배열에 push
  • 체인 생성 시 handler를 붙이고, 실행 시 또 붙임
  • 프레임워크 핫리로드로 동일 모듈이 중복 로드되어 handler가 누적

나쁜 패턴 예시

import { CallbackManager } from "@langchain/core/callbacks/manager";

const handlers: any[] = [];

export function getManager(onToken: (t: string) => void) {
  handlers.push({
    handleLLMNewToken(token: string) {
      onToken(token);
    },
  });

  return CallbackManager.fromHandlers(handlers);
}

이 코드는 요청이 늘어날수록 handler가 누적되어, 토큰이 N배로 발행됩니다. 동시에 메모리도 같이 증가합니다.

해결: 요청 단위로 핸들러를 생성하고, 전역 누적을 금지

import { CallbackManager } from "@langchain/core/callbacks/manager";

export function createManager(onToken: (t: string) => void) {
  return CallbackManager.fromHandlers({
    handleLLMNewToken(token: string) {
      onToken(token);
    },
  });
}

핵심은 “요청이 끝나면 핸들러도 같이 사라져야 한다”입니다.

3분: 중복 토큰 2번 원인 — SSE에서 누적 텍스트를 매번 전송

SSE로 토큰을 보낼 때, 아래처럼 acc를 계속 누적해서 보내면 프론트가 append 하는 순간 중복이 됩니다.

let acc = "";
for await (const chunk of stream) {
  acc += String(chunk.content ?? "");
  res.write(`data: ${JSON.stringify({ text: acc })}\n\n`);
}

해결: delta만 보내기

for await (const chunk of stream) {
  const delta = String(chunk.content ?? "");
  if (!delta) continue;
  res.write(`data: ${JSON.stringify({ delta })}\n\n`);
}

프론트에서는 반드시 delta를 append 하도록 계약을 고정하세요.

4분: 중복 토큰 3번 원인 — Abort 처리 누락으로 스트림이 “살아있음”

사용자가 페이지를 닫거나 네트워크가 끊겼는데도 서버에서 스트림을 계속 읽으면,

  • 토큰이 계속 생성되고
  • 소켓 버퍼가 쌓이며
  • 메모리 증가가 가속

특히 Node.js에서 req 종료 이벤트를 무시하면 흔히 발생합니다.

해결: AbortController로 연결 종료 시 스트림 중단

import type { Request, Response } from "express";
import { ChatOpenAI } from "@langchain/openai";

export async function sseHandler(req: Request, res: Response) {
  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache");
  res.setHeader("Connection", "keep-alive");

  const ac = new AbortController();
  req.on("close", () => ac.abort("client_disconnected"));

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

  try {
    const stream = await llm.stream("hello", { signal: ac.signal });
    for await (const chunk of stream) {
      const delta = String(chunk.content ?? "");
      if (!delta) continue;
      res.write(`data: ${JSON.stringify({ delta })}\n\n`);
    }
    res.write("event: done\n");
    res.write("data: {}\n\n");
  } catch (e: any) {
    // abort는 에러처럼 보일 수 있으니 분기
    res.write("event: error\n");
    res.write(`data: ${JSON.stringify({ message: e?.message ?? "error" })}\n\n`);
  } finally {
    res.end();
  }
}

여기서 중요한 점은 signal을 실제 LLM 호출에 전달하는 것입니다. 신호만 만들어놓고 체인에 전달하지 않으면 효과가 없습니다.

5분: 메모리 누수처럼 보이는 1번 원인 — 대화 히스토리 무한 누적

LangChain으로 챗봇을 만들 때 “메모리” 또는 “히스토리 배열”을 무심코 무한히 키우면, 스트리밍 여부와 상관없이 메모리가 계속 증가합니다.

  • 세션별 messages 배열이 계속 커짐
  • Redis 같은 외부 저장소가 아니라 프로세스 메모리에 들고 있음
  • 요약 없이 원문을 끝까지 유지

해결: 히스토리 상한, 요약, 또는 외부 저장소로 이동

가장 빠른 응급처치는 “최근 N턴만 유지”입니다.

type Msg = { role: "user" | "assistant"; content: string };

function keepLastTurns(messages: Msg[], turns: number) {
  // user+assistant를 1턴으로 가정하면 2*turns
  const max = turns * 2;
  return messages.slice(-max);
}

그리고 구조적으로는 v0.2에서 메모리 사용 방식이 바뀌었으므로, 대화 상태를 어떻게 보관할지 명확히 정리하는 것이 좋습니다.

6분: 메모리 누수처럼 보이는 2번 원인 — 스트림을 버퍼링하는 미들웨어

다음과 같은 구성에서는 “스트리밍”이 실제로는 스트리밍이 아닐 수 있습니다.

  • 프록시가 응답을 버퍼링
  • 압축 미들웨어가 chunk를 모아서 flush
  • 서버리스 플랫폼이 스트림을 지원하지 않거나 제한

증상은 이렇습니다.

  • 서버 메모리 증가
  • 클라이언트는 한 번에 몰아서 받음
  • 타임아웃 증가

해결 체크

  • Nginx라면 X-Accel-Buffering: no 헤더 고려
  • Express에서 compression을 스트리밍 라우트에만 제외
  • fetch 기반 프록시라면 res.flushHeaders() 호출

Express에서 헤더 플러시를 명시하면 도움이 됩니다.

res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.flushHeaders?.();

7분: 메모리 누수처럼 보이는 3번 원인 — 이벤트 리스너 미정리

요청마다 req.on 또는 전역 emitter에 리스너를 붙이고, 제거하지 않으면 누수로 이어집니다.

  • EventEmitter 경고 MaxListenersExceededWarning
  • 장시간 트래픽에서 RSS 우상향

해결: once 사용 또는 명시적 remove

const onClose = () => ac.abort("client_disconnected");
req.once("close", onClose);

전역 emitter에 붙였다면 finally에서 removeListener를 하세요.

8분: “진짜 누수”인지 60초 판별하는 방법

Node.js에서 메모리는 GC 타이밍 때문에 계단식으로 보입니다. 그래서 “누수처럼 보이는 정상”과 “누수”를 구분해야 합니다.

빠른 판별 체크리스트

  • 동일 트래픽을 10분 유지했을 때 heapUsed가 계속 우상향인가
  • 요청이 끝난 뒤에도 스트림이 살아있는가
  • 핸들러 수가 요청 수에 비례해 증가하는가

간단한 계측을 넣으면 감이 빨리 옵니다.

setInterval(() => {
  const m = process.memoryUsage();
  console.log({
    rssMB: Math.round(m.rss / 1024 / 1024),
    heapUsedMB: Math.round(m.heapUsed / 1024 / 1024),
  });
}, 5000);

컨테이너 환경이라면 메모리 압박이 바로 장애로 이어집니다. Pod가 Pending이 되거나 OOMKill이 반복되면 인프라 레벨 점검도 같이 하세요.

9분: 실전에서 가장 많이 쓰는 “정답 조합”

대부분의 서비스에서 아래 4가지를 동시에 적용하면 중복 토큰과 메모리 증가가 같이 잡힙니다.

  1. delta만 전송하고, 프론트는 delta만 append
  2. 요청 단위 콜백 핸들러 생성, 전역 누적 금지
  3. AbortController 연결로 클라이언트 종료 시 즉시 중단
  4. 히스토리 상한 또는 요약, 대화 상태는 외부 저장소 고려

아래는 위 원칙을 합친 SSE 라우트 뼈대입니다.

import type { Request, Response } from "express";
import { ChatOpenAI } from "@langchain/openai";

export async function chatStream(req: Request, res: Response) {
  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache");
  res.setHeader("Connection", "keep-alive");
  res.flushHeaders?.();

  const ac = new AbortController();
  req.once("close", () => ac.abort("client_disconnected"));

  const llm = new ChatOpenAI({ model: "gpt-4o-mini", temperature: 0 });

  try {
    const prompt = String(req.body?.prompt ?? "");
    const stream = await llm.stream(prompt, { signal: ac.signal });

    for await (const chunk of stream) {
      const delta = String(chunk.content ?? "");
      if (!delta) continue;
      res.write(`data: ${JSON.stringify({ delta })}\n\n`);
    }

    res.write("event: done\n");
    res.write("data: {}\n\n");
  } catch (e: any) {
    if (String(e?.name ?? "").includes("Abort")) {
      // 조용히 종료해도 됨
    } else {
      res.write("event: error\n");
      res.write(`data: ${JSON.stringify({ message: e?.message ?? "error" })}\n\n`);
    }
  } finally {
    res.end();
  }
}

마무리: 중복 토큰과 메모리 증가는 “같은 뿌리”인 경우가 많다

중복 토큰과 메모리 증가는 별개 문제처럼 보이지만, 실제로는

  • 스트림을 중단하지 못해 계속 생성하거나
  • 핸들러가 누적되어 이벤트가 중복 발화하거나
  • 누적 버퍼를 매번 전송하는 방식

처럼 스트리밍 파이프라인의 생명주기 관리 실패에서 같이 발생하는 경우가 많습니다.

위 체크리스트대로 최소 재현 코드로 분리하고, delta 전송과 abort, 핸들러 스코프를 정리하면 대부분 10분 안에 안정화됩니다.