- Published on
LangChain 스트리밍 중복토큰·메모리누수 9분 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 LLM 응답을 스트리밍으로 내려주다 보면 두 가지 문제가 같이 따라오는 경우가 많습니다.
- 중복 토큰: 같은 문장이 두 번씩 붙거나, 이미 보낸 토큰이 다시 전송됨
- 메모리 누수처럼 보이는 증가: 요청이 끝나도 프로세스 RSS가 계속 올라가고 GC가 따라오지 못함
이 글은 LangChain 기반 Node.js 서버를 기준으로, 9분 안에 문제를 재현하고 원인을 분리한 뒤, 실전에서 가장 자주 먹히는 해결책을 적용하는 흐름으로 구성했습니다.
관련해서 LangChain v0.2에서 메모리 기능이 바뀐 뒤 대화 상태를 유지하는 패턴도 함께 보면 좋습니다. LangChain v0.2 메모리 폐기 후 대화상태 유지법
0. 전제: “중복 토큰”은 보통 두 군데에서 생긴다
중복 토큰은 대개 아래 둘 중 하나입니다.
이벤트를 두 번 소비한다
- 같은 스트림을 두 곳에서 읽거나
- 콜백 핸들러를 중복 등록하거나
- 재시도 로직이 스트림을 다시 붙인다
누적 버퍼를 매번 전체로 보내는 방식
- 예를 들어 UI에
fullText를 매 토큰마다 보내면, 프론트가 append 할 때 중복이 발생
- 예를 들어 UI에
즉, 중복 토큰은 “모델이 이상하다”기보다 스트리밍 파이프라인의 조립 문제인 경우가 대부분입니다.
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가지를 동시에 적용하면 중복 토큰과 메모리 증가가 같이 잡힙니다.
- delta만 전송하고, 프론트는 delta만 append
- 요청 단위 콜백 핸들러 생성, 전역 누적 금지
- AbortController 연결로 클라이언트 종료 시 즉시 중단
- 히스토리 상한 또는 요약, 대화 상태는 외부 저장소 고려
아래는 위 원칙을 합친 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분 안에 안정화됩니다.