- Published on
LangChain SSE 스트리밍 끊김·중복 토큰 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 LangChain 스트리밍을 SSE로 내보내면, 데모는 잘 되는데 운영에서는 끊김, 중복 토큰, 문장 되감기처럼 다시 출력, 마지막에 한 번 더 합쳐져 출력 같은 증상이 자주 터집니다. 원인은 하나가 아니라 모델 스트리밍 이벤트, SSE 프로토콜 특성, 프록시 버퍼링, 재연결 로직, LangChain 콜백 중복 등록이 겹치면서 생깁니다.
이 글은 다음 두 가지를 목표로 합니다.
- SSE 스트리밍이 왜 끊기고, 왜 중복 토큰이 생기는지 계층별로 원인을 분해
- 재연결이 있어도 중복 없이 이어서 보여주는 실전 패턴 제공
관련해서 스트리밍 자체의 지연·버벅임은 별도 이슈인 경우가 많습니다. 로컬 모델 스트리밍 품질 문제는 Transformers 로컬 LLM 스트리밍 끊김·지연 해결도 함께 참고하면 원인 분리가 쉬워집니다.
문제 증상 체크리스트
아래 중 하나라도 해당되면, 거의 항상 SSE 레이어 + 재시도 로직에서 중복이 발생합니다.
- 같은 토큰이 두 번씩 출력됨 (예:
안녕안녕) - 스트리밍이 중간에 끊기고, 재연결 후 앞부분부터 다시 출력됨
- 서버 로그에는 한 번만 생성했는데, 클라이언트에서만 중복됨
- 특정 환경에서만 발생 (예: 로컬은 정상, Cloudflare·Nginx 뒤에서만 중복)
근본 원인 1: SSE 재연결은 기본값이고, 클라이언트가 “처음부터 다시” 요청한다
SSE는 연결이 끊기면 브라우저가 자동으로 재연결할 수 있고, EventSource는 Last-Event-ID를 활용해 이어받을 수도 있습니다. 하지만 대부분의 구현은 다음 중 하나로 인해 재연결 시 서버가 새 스트림을 새로 생성합니다.
- 서버가
id:를 보내지 않아서 클라이언트가 이어받을 기준이 없음 - 서버가
Last-Event-ID를 읽지 않음 - 서버가 “요청 하나당 체인 실행” 구조라서 재연결이 곧 재실행
해결 핵심
- 서버는 각 토큰 이벤트에 단조 증가하는
id를 붙여 전송 - 클라이언트는 마지막으로 처리한
id를 저장하고, 재연결 시 서버에 전달 - 서버는
Last-Event-ID또는 쿼리 파라미터로 받은 오프셋을 기준으로 중복 이벤트를 스킵하거나 리플레이 버퍼에서 이어서 전송
아래는 Node.js(Express) 기준의 안전한 SSE 프레임 형태입니다.
// data는 반드시 한 줄 JSON 문자열로
// SSE는 빈 줄로 이벤트를 종료
res.write(`id: ${seq}\n`);
res.write(`event: token\n`);
res.write(`data: ${JSON.stringify({ token, seq })}\n\n`);
id:를 보내면 브라우저가 자동으로 Last-Event-ID 헤더를 붙여 재연결하는 경우가 많습니다(환경에 따라 다름). 다만 프록시나 커스텀 클라이언트에서는 쿼리로 명시하는 편이 더 확실합니다.
근본 원인 2: 프록시 버퍼링으로 “실시간”이 깨지고, 끊김 후 덩어리로 재전송된다
SSE는 flush가 생명인데, Nginx, Cloudflare, ALB, 일부 런타임은 응답을 버퍼링합니다. 그러면 토큰이 즉시 전송되지 않고 쌓였다가 한 번에 내려가거나, 타임아웃으로 끊겼다가 재연결 후 다시 내려가면서 중복처럼 보이는 현상이 생깁니다.
Nginx에서 꼭 확인할 헤더
서버 응답 헤더에 아래를 넣어 버퍼링을 줄입니다.
Content-Type: text/event-stream; charset=utf-8Cache-Control: no-cache, no-transformConnection: keep-aliveX-Accel-Buffering: no(Nginx)
Express 예시:
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?.();
Nginx 설정 예시:
location /api/stream {
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
# SSE는 긴 연결이므로 타임아웃도 늘려야 함
proxy_read_timeout 3600s;
}
여기서 중요한 건 proxy_read_timeout 입니다. 기본값이 낮으면 “모델이 생각하는 시간” 동안 데이터가 없을 때 끊겨 재연결이 발생하고, 그 순간부터 중복의 도미노가 시작됩니다.
근본 원인 3: LangChain 콜백이 중복 등록되어 토큰 이벤트가 두 번 발행된다
중복 토큰이 네트워크와 무관하게 항상 일정 패턴으로 발생한다면, 서버 내부에서 토큰 이벤트가 두 번 발생하는 케이스가 많습니다.
자주 보는 실수:
- 체인 실행마다
callbacks를 push하는데, 전역 객체를 재사용해서 누적 등록됨 on_llm_new_token과on_chat_model_stream을 동시에 처리하면서 같은 내용을 두 번 write- LangChain Runnable에서
stream과astream_events를 동시에 소비
해결 핵심
- 요청 단위로 새 콜백 인스턴스를 만들고, 전역에 누적시키지 않기
- “토큰 이벤트”는 단일 소스만 사용하기
- 디버그를 위해 이벤트 타입을 로깅해서 어떤 이벤트가 중복인지 먼저 특정하기
Node.js에서 요청 단위 콜백 생성 예시(개념 코드):
function makeSseCallback(res: any) {
let seq = 0;
return {
handleLLMNewToken(token: string) {
seq += 1;
res.write(`id: ${seq}\n`);
res.write(`event: token\n`);
res.write(`data: ${JSON.stringify({ token, seq })}\n\n`);
},
};
}
// 요청 핸들러 내부에서만 생성
const cb = makeSseCallback(res);
// chain.invoke({ ... }, { callbacks: [cb] }) 같은 형태로 주입
프레임워크/버전에 따라 콜백 인터페이스는 다르지만, 핵심은 콜백 객체를 요청 스코프에 고정하는 것입니다.
근본 원인 4: 클라이언트가 토큰을 “누적 문자열”로 합칠 때 중복을 만든다
서버는 토큰을 정상적으로 한 번씩 보내는데, 클라이언트 렌더링에서 중복이 생기는 경우도 흔합니다.
대표 패턴:
- 상태 업데이트가 비동기 배치되면서 이전 값과 새 값이 섞임
- React에서 Strict Mode로 인해 개발 환경에서 이벤트 핸들러가 두 번 실행되는 것처럼 보임
- 토큰이 아니라 “현재까지의 전체 텍스트”를 보내는데, 클라이언트가 또 누적함
해결 핵심
- 서버가 보내는 payload가
delta인지full_text인지 명확히 고정 - 클라이언트는
seq기반으로 멱등 처리
브라우저에서 EventSource로 멱등 처리하는 예시:
let lastSeq = 0;
let text = '';
const es = new EventSource('/api/stream');
es.addEventListener('token', (e: MessageEvent) => {
const msg = JSON.parse(e.data) as { token: string; seq: number };
if (msg.seq <= lastSeq) return; // 중복 방지
lastSeq = msg.seq;
text += msg.token;
render(text);
});
seq는 서버가 단조 증가만 보장하면 됩니다. 재연결을 고려하면 seq를 요청 단위가 아니라 stream 단위로 관리하는 편이 더 안전합니다.
운영에서 가장 안전한 아키텍처: streamId + 리플레이 버퍼 + 멱등 처리
재연결이 발생해도 중복 없이 이어가려면, 아래 3요소가 사실상 필수입니다.
streamId: 스트림을 식별하는 IDseq: 이벤트 순번replay buffer: 최근 N개 이벤트를 저장해 재연결 시 이어서 전송
서버 설계 예시
- 클라이언트가 먼저
/api/stream/create로streamId를 발급받음 /api/stream?streamId=...&fromSeq=...로 SSE 연결- 서버는 체인을 실행하면서 토큰을 버퍼에 저장하고 SSE로 push
- 끊기면 클라이언트는 마지막
seq이후부터 다시 연결
간단한 인메모리 버퍼 예시(운영에서는 Redis 권장):
type TokenEvent = { seq: number; token: string };
const buffers = new Map<string, TokenEvent[]>();
function appendEvent(streamId: string, ev: TokenEvent) {
const buf = buffers.get(streamId) ?? [];
buf.push(ev);
// 메모리 보호: 최근 500개만 유지
if (buf.length > 500) buf.splice(0, buf.length - 500);
buffers.set(streamId, buf);
}
function replayFrom(streamId: string, fromSeq: number) {
const buf = buffers.get(streamId) ?? [];
return buf.filter(e => e.seq > fromSeq);
}
SSE 핸들러에서 리플레이 후 실시간 스트리밍을 이어붙입니다.
const streamId = String(req.query.streamId);
const fromSeq = Number(req.query.fromSeq ?? 0);
// 1) 리플레이
for (const ev of replayFrom(streamId, fromSeq)) {
res.write(`id: ${ev.seq}\n`);
res.write(`event: token\n`);
res.write(`data: ${JSON.stringify(ev)}\n\n`);
}
// 2) 이후 실시간 이벤트는 appendEvent와 함께 write
이 구조는 “연결이 끊겨도 체인 실행을 다시 하지 않고” 이어붙일 수 있게 해줍니다. 반대로 체인 실행을 요청마다 다시 한다면, 어떤 멱등 처리도 완벽할 수 없습니다.
하트비트로 끊김 줄이기: 주기적 ping 이벤트
중간에 토큰이 한동안 안 나오는 구간(툴 호출, 긴 추론, RAG 검색)에서 프록시가 idle로 판단해 끊는 경우가 많습니다. 이때는 주기적으로 하트비트를 보내면 효과가 큽니다.
const ping = setInterval(() => {
res.write('event: ping\n');
res.write(`data: ${JSON.stringify({ t: Date.now() })}\n\n`);
}, 15000);
req.on('close', () => clearInterval(ping));
클라이언트는 ping을 무시하면 됩니다.
LangChain에서 특히 자주 만나는 케이스: 툴 호출 구간에서 스트림이 멈춘다
에이전트/툴을 쓰면, 토큰 스트림이 끊긴 것처럼 보이는 구간이 생깁니다.
- 모델이
tool_calls를 만들고 - 툴 실행이 오래 걸리고
- 그동안 토큰이 안 나오면
- 프록시 타임아웃 또는 클라이언트가 “멈춤”으로 오해
이 구간은 하트비트로 UX를 개선할 수 있고, 동시에 에이전트 루프가 비정상 반복되면 스트림이 끝나지 않는 문제도 생깁니다. 에이전트가 같은 생각을 반복하며 이벤트를 계속 만들면 SSE가 길게 유지되면서 장애로 커질 수 있으니, LangChain 에이전트 루프 무한반복 감지·차단법처럼 루프 차단 장치도 같이 두는 것을 권합니다.
디버깅 순서: “어디서 중복이 생기는지”를 10분 안에 분리하는 법
- 서버에서
seq를 찍고, 전송 직전에 로그로 남깁니다.
console.log('send', { streamId, seq, token });
- 클라이언트에서도 수신한
seq를 그대로 로그로 남깁니다.
- 서버 로그에
seq=10이 한 번인데 클라이언트에 두 번이면 네트워크/재연결/렌더링 문제 - 서버 로그에
seq=10이 두 번이면 콜백 중복/이중 스트림 소비 문제
- 프록시를 우회해 직접 붙여봅니다.
- 로컬에서 직접 서버에 붙이면 정상인데, Nginx 뒤에서만 문제면 버퍼링/타임아웃
- 마지막으로 클라이언트 누적 방식을 점검합니다.
- 서버가
full_text를 보내는데 클라이언트가 또+=하면 100퍼센트 중복
운영 체크리스트(요약)
- 서버 SSE 헤더:
text/event-stream,no-transform,X-Accel-Buffering: no - 프록시:
proxy_buffering off,proxy_read_timeout충분히 크게 - 이벤트:
id또는seq를 반드시 포함 - 클라이언트:
seq기반 멱등 처리 - 재연결:
Last-Event-ID또는fromSeq로 이어받기 - 하트비트:
ping이벤트로 idle 타임아웃 방지 - LangChain: 콜백 중복 등록 금지, 스트림 소비 경로 단일화
마무리
LangChain 스트리밍 SSE에서 끊김과 중복 토큰은 “한 군데 버그”라기보다, 연결이 끊길 수 있다는 전제를 두고 멱등성을 설계했는지의 문제인 경우가 많습니다. streamId + seq + replay buffer로 재연결을 정상 플로우로 만들고, 프록시 버퍼링과 타임아웃을 제거하면 대부분의 중복/끊김 이슈는 재현 불가 수준으로 줄어듭니다.
추가로, Next.js App Router에서 스트리밍 UI가 과도하게 리렌더링되며 중복처럼 보이는 경우도 있습니다. 렌더링 폭증과 상태 누적 문제는 Next.js App Router 렌더링 폭증 진단 - RSC 캐시·useMemo 관점에서 함께 점검하면 좋습니다.