- Published on
LangChain 스트리밍 중복 토큰·끊김 7가지 원인
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 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 조합 후
stream과invoke를 같이 호출했을 때 발생
흔한 원인
- 디버깅을 위해
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만 전송
}
운영에서 바로 쓰는 체크리스트
아래 순서로 보면 원인 좁히기가 빠릅니다.
- 서버에서 콜백이 2번 호출되는가? (원인 1, 5)
- 같은 요청이 2번 실행되는가? (클라이언트 중복 호출 포함) (원인 5)
- 스트리밍 중 재시도가 있는가? (원인 2)
- 프록시 버퍼링/idle timeout이 있는가? (원인 3)
- Abort가 서버 실행 중단으로 연결되는가? (원인 4)
- SSE 프레이밍/디코딩이 안전한가? (원인 6)
- 델타/누적 의미를 정확히 처리하는가? (원인 7)
마무리: “중복”은 대개 2중 실행, “끊김”은 대개 버퍼링
LangChain 스트리밍에서 중복 토큰은 대부분 콜백/체인 실행이 중복되거나 누적 스트림을 델타로 오해해서 발생합니다. 끊김은 대부분 SSE 프록시 버퍼링, idle timeout, Abort 처리 미흡에서 시작합니다.
스트리밍은 한 번 꼬이면 증상이 비슷하게 보이므로, 위 7가지를 기준으로 로그를 구조화해두는 것이 가장 큰 비용 절감 포인트입니다. 특히 요청 ID, attempt 번호, 토큰 시퀀스(증분 번호)만 잘 남겨도 “중복인지, 재시도인지, 프록시인지”가 빠르게 갈립니다.
추가로, 서버 리소스 누수나 정리 누락이 스트리밍 안정성을 갉아먹는 경우가 많습니다. Python 기반으로 LangChain을 운영한다면 컨텍스트 정리 패턴은 Python 3.11+ asynccontextmanager로 리소스 누수 막기도 같이 보면 도움이 됩니다.