- Published on
LangChain 스트리밍 중복응답·토큰폭주 디버깅
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 LangChain 스트리밍을 붙이면 처음엔 그럴듯하게 동작하다가, 어느 순간부터 중복 응답, 토큰 폭주, 같은 문장이 반복 출력, 클라이언트가 스크롤을 따라가지 못할 정도의 이벤트 난사 같은 문제가 터집니다. 특히 SSE나 WebSocket으로 토큰을 흘려보내는 구조에서는, “모델이 말을 많이 한 것”이 아니라 내 파이프라인이 토큰을 여러 번 전송하고 있는 것인 경우가 훨씬 많습니다.
이 글은 LangChain 기반 스트리밍에서 자주 발생하는 중복/폭주 증상을 재현 → 계측 → 원인 분류 → 차단 순서로 디버깅하는 실전 가이드입니다. (프론트 쪽에서 이벤트를 잘못 합쳐서 폭주하는 케이스는 React 렌더링 폭주? 리렌더 원인 추적 실전 가이드도 함께 참고하면 진단 속도가 빨라집니다.)
증상 정의부터: “중복”의 4가지 형태
문제를 빨리 잡으려면 “중복”을 하나로 뭉개지 말고 형태를 구분해야 합니다.
- 토큰 단위 중복: 같은 토큰이 연속으로 반복 전송됨. 예:
안녕안녕안녕... - 청크 단위 중복: 같은 문장/구문이 덩어리째 여러 번 전송됨.
- 요청 단위 중복: 체인이 두 번 실행되어 완전히 동일한 답변이 두 번 옴.
- 재연결 리플레이: SSE 재연결 후
Last-Event-ID처리 미흡으로 이미 보낸 이벤트를 다시 받음.
각 형태는 원인이 다르고, 계측 포인트도 다릅니다.
가장 먼저 넣어야 하는 계측: run_id, request_id, chunk_seq
스트리밍 디버깅은 “로그를 많이 찍자”가 아니라 상관관계를 보장하는 키를 심자가 핵심입니다.
request_id: HTTP 요청 1회 식별run_id: LangChain 실행 1회 식별 (LangChain이 내부적으로 생성하는 ID를 활용하거나 직접 부여)chunk_seq: 스트림 청크 증가 번호event_id: SSE 이벤트 ID (재연결 대비)
아래는 Node.js에서 SSE로 스트리밍할 때 최소 계측 예시입니다.
import { randomUUID } from "crypto";
type StreamChunk = {
requestId: string;
runId: string;
seq: number;
token?: string;
done?: boolean;
};
function sseWrite(res: any, eventId: number, data: unknown) {
// `id:`는 SSE 재연결 시 클라이언트가 Last-Event-ID로 보내는 값이 됨
res.write(`id: ${eventId}\n`);
res.write(`event: message\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
export async function handler(req: any, res: any) {
const requestId = req.headers["x-request-id"] ?? randomUUID();
const runId = randomUUID();
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache, no-transform");
res.setHeader("Connection", "keep-alive");
let seq = 0;
let eventId = 0;
const emit = (token: string) => {
const chunk: StreamChunk = { requestId, runId, seq: seq++, token };
sseWrite(res, eventId++, chunk);
};
// ... LangChain 스트리밍 연결 후 emit 호출
const done = () => {
const chunk: StreamChunk = { requestId, runId, seq: seq++, done: true };
sseWrite(res, eventId++, chunk);
res.end();
};
// done() 호출
}
이렇게 해두면 “중복”이 발생했을 때
requestId가 같은데runId가 2개면 체인이 2번 돈 것runId가 같은데seq가 되돌아가거나 튀면 서버 측 버퍼/재시도/멀티 핸들러 의심eventId가 반복 수신되면 SSE 재연결 리플레이 의심 으로 빠르게 분류가 됩니다.
원인 1: 콜백 핸들러 중복 등록 (가장 흔함)
LangChain 스트리밍은 보통 콜백 기반입니다. 문제는 요청마다 새 핸들러를 만들지 않고 전역에 누적되거나, 반대로 요청마다 등록은 하는데 해제하지 않아서 다음 요청에 같이 발화하는 케이스가 많습니다.
전형적인 실수 패턴
- 전역
callbacks배열에 push만 하고 pop을 안 함 - Express 미들웨어에서 체인 생성 시 콜백을 계속 append
- 개발 환경 핫리로드로 모듈이 재평가되며 핸들러가 중복 등록
방지 패턴: 요청 스코프 콜백 + 단일 writer
import { CallbackManager } from "@langchain/core/callbacks/manager";
function createRequestScopedCallbacks(onToken: (t: string) => void) {
return CallbackManager.fromHandlers({
handleLLMNewToken(token) {
onToken(token);
},
});
}
// 요청 핸들러 내부에서만 생성
const callbacks = createRequestScopedCallbacks(emit);
// 모델/체인 생성 시 callbacks를 "요청 스코프"로 주입
핵심은 “콜백을 어디에 저장하느냐”입니다. 전역에 저장되는 순간 중복 가능성이 급상승합니다.
원인 2: 스트리밍 응답을 누적 문자열로 다시 전송
서버가 토큰을 받을 때마다 acc += token을 하고, 그 acc를 매번 전송하면 클라이언트는 같은 텍스트를 계속 덧붙이면서 기하급수적으로 길어지는 것처럼 보이는 중복이 생깁니다.
잘못된 예
let acc = "";
onToken((t) => {
acc += t;
// acc 전체를 매번 보내면, 클라이언트가 append할 때 중복이 됨
emit(acc);
});
올바른 예: delta만 전송하거나, full-text면 replace 렌더
- delta 전송: 서버는 토큰만 보냄, 클라이언트는 append
- full-text 전송: 서버는 전체를 보냄, 클라이언트는 replace
// delta 전송
onToken((t) => emit(t));
클라이언트가 어떤 방식으로 렌더링하는지와 반드시 짝을 맞춰야 합니다.
원인 3: 재시도 로직이 스트리밍과 충돌
네트워크 오류나 타임아웃을 대비해 retry를 넣는 순간, 스트리밍에서는 “부분적으로 이미 보낸 토큰”이 존재합니다. 이 상태에서 같은 요청을 재시도하면 앞부분이 다시 스트리밍되어 중복이 됩니다.
- 서버 내부 재시도(LLM 호출 재시도)
- API Gateway나 프록시의 자동 재시도
- 클라이언트의 자동 재연결
특히 Cloud Run 같은 환경에서 타임아웃/콜드스타트로 요청이 끊기면, 클라이언트가 재요청하면서 중복이 쉽게 발생합니다. 인프라 레벨 타임아웃 이슈는 GCP Cloud Run 503·콜드스타트 타임아웃 해결법도 같이 점검하는 게 좋습니다.
방지 패턴: 스트리밍 요청은 멱등 키로 “단일 실행” 보장
idempotency_key를 요청에 포함- 서버는
idempotency_key당 하나의run_id만 허용 - 이미 실행 중이면 기존 스트림에 attach하거나, 명시적으로
409반환
간단한 인메모리 가드(단일 인스턴스에서만 유효) 예시:
const inflight = new Map<string, { runId: string; startedAt: number }>();
function beginRun(idempotencyKey: string) {
const existing = inflight.get(idempotencyKey);
if (existing) return { ok: false as const, existing };
const runId = crypto.randomUUID();
inflight.set(idempotencyKey, { runId, startedAt: Date.now() });
return { ok: true as const, runId };
}
function endRun(idempotencyKey: string) {
inflight.delete(idempotencyKey);
}
프로덕션에서는 Redis 같은 외부 저장소로 옮겨야 “인스턴스가 여러 개”일 때도 중복 실행을 막을 수 있습니다.
원인 4: SSE 재연결과 Last-Event-ID 미처리
SSE는 연결이 끊기면 브라우저가 자동 재연결을 시도할 수 있습니다. 이때 서버가 이벤트 ID를 부여하고, 클라이언트가 Last-Event-ID를 보내면, 서버는 그 이후 이벤트만 보내야 합니다.
하지만 대부분의 구현은
id:를 아예 안 붙임Last-Event-ID를 읽지 않음- 서버가 이벤트를 버퍼링하지 않음
이라서, 재연결 시 “처음부터 다시” 보내거나, 클라이언트가 “이미 받은 이벤트를 또 append”하며 중복이 생깁니다.
최소 대응
- SSE 이벤트에
id:를 항상 부여 - 서버는
Last-Event-ID를 읽고, 가능하면 짧게라도 링버퍼를 둬서 재전송
링버퍼 예시(개념용):
type BufItem = { eventId: number; payload: any };
class RingBuffer {
private buf: BufItem[] = [];
constructor(private cap: number) {}
push(item: BufItem) {
this.buf.push(item);
if (this.buf.length > this.cap) this.buf.shift();
}
since(lastEventId: number) {
return this.buf.filter((x) => x.eventId > lastEventId);
}
}
const rb = new RingBuffer(200);
function sseWriteWithBuffer(res: any, item: BufItem) {
rb.push(item);
res.write(`id: ${item.eventId}\n`);
res.write(`event: message\n`);
res.write(`data: ${JSON.stringify(item.payload)}\n\n`);
}
완벽한 재전송을 원하면 스트림 상태를 외부 저장소에 두는 설계가 필요하지만, “끊김 직후 중복” 정도는 링버퍼로도 많이 줄일 수 있습니다.
원인 5: Abort 처리 누락으로 백그라운드에서 계속 생성
사용자가 페이지를 닫거나 “중지” 버튼을 눌렀는데도 서버가 LLM 스트리밍을 계속 돌리면
- 토큰이 계속 생성되어 비용이 증가
- 다음 요청과 로그가 섞여 “중복처럼” 보임
- Node 프로세스의 핸들/소켓이 쌓여 장애로 번짐
클라이언트 연결 종료를 감지해 AbortController로 LLM 호출을 끊어야 합니다.
const ac = new AbortController();
req.on("close", () => {
// 클라이언트가 연결을 끊으면 즉시 중단
ac.abort("client_disconnected");
});
// LangChain 호출에 signal 전달 (지원되는 API에 연결)
await chain.invoke(input, { signal: ac.signal, callbacks });
연결이 끊겼는데도 계속 토큰이 찍히면, 중복 이전에 리소스 누수입니다. 이런 누수는 결국 파일 디스크립터 고갈로 이어질 수 있으니 Linux EMFILE(Too many open files) 원인과 해결 같은 관점으로도 점검해보세요.
원인 6: 체인 내부에서 스트리밍을 “두 번 소비”
LangChain 구성에서 다음 같은 조합이 나오면, 동일한 모델 호출 결과가
- 한 번은 스트리밍 콜백으로
- 한 번은 최종 결과 처리 로직에서 각각 흘러 “중복 출력”처럼 보일 수 있습니다.
예:
- 스트리밍 콜백에서 토큰을 출력
- 동시에
onEnd에서 최종 텍스트를 또 emit
방지 패턴: 최종 결과는 메타 이벤트로만 보내기
let finalText = "";
callbacks = CallbackManager.fromHandlers({
handleLLMNewToken(t) {
emit({ type: "delta", t });
},
handleLLMEnd(output) {
// 최종 텍스트를 그대로 재전송하지 말고, 완료 신호만
finalText = output.generations?.[0]?.[0]?.text ?? "";
emit({ type: "done", length: finalText.length });
},
});
클라이언트가 delta로 조립한 결과를 최종본으로 간주하면, 서버가 최종 텍스트를 다시 보내지 않아도 됩니다.
재현을 자동화하는 테스트: “중복 없는 seq”를 어서션
스트리밍 버그는 수동으로 보기 시작하면 끝이 없습니다. 최소한 아래 3가지는 자동화해두면 회귀를 막을 수 있습니다.
seq가 0부터 단조 증가- 같은
seq가 두 번 오지 않음 done은 정확히 한 번
간단한 Jest 스타일 의사코드:
test("stream chunks are strictly increasing", async () => {
const chunks = await collectSse("/api/chat?prompt=hi");
const seqs = chunks.map((c) => c.seq);
const uniq = new Set(seqs);
expect(uniq.size).toBe(seqs.length);
for (let i = 1; i < seqs.length; i++) {
expect(seqs[i]).toBe(seqs[i - 1] + 1);
}
expect(chunks.filter((c) => c.done).length).toBe(1);
});
여기서 한 번이라도 깨지면, 원인은 대개
- 이중 실행
- 재연결 리플레이
- 서버의 비동기 경쟁 조건 중 하나로 좁혀집니다.
체크리스트: 원인별 빠른 처방
- 콜백 중복 등록 의심: 콜백을 요청 스코프로 만들고 전역 누적 제거
- 누적 문자열 재전송 의심:
delta전송으로 바꾸거나, 클라이언트 렌더를 replace로 변경 - 재시도 충돌 의심: 멱등 키로 단일 실행 보장, 스트리밍 요청에 자동 재시도 비활성화 검토
- SSE 재연결 의심:
id:부여,Last-Event-ID처리, 짧은 링버퍼 도입 - Abort 누락 의심:
req.close에서AbortController로 중단 - 최종 결과 이중 emit 의심:
done이벤트만 보내고 최종 텍스트 재전송 금지
마무리: “모델이 이상하다” 전에 파이프라인을 의심하자
LangChain 스트리밍의 중복응답·토큰폭주는 대부분 모델 품질 문제가 아니라 스트림을 둘 이상으로 복제해 전달하는 구조적 버그입니다. request_id와 run_id, seq만 제대로 심어도 원인의 70퍼센트는 로그 한 번으로 갈라집니다.
다음 단계로는
- 프로덕션에서
run_id단위로 비용과 토큰 수를 집계하고 - 특정
idempotency_key에서만 폭주하는지 확인하며 - 재연결/재시도를 명시적으로 설계 하는 쪽으로 안정성을 끌어올리면, “가끔 중복되는” 문제를 “절대 중복되지 않는” 수준으로 줄일 수 있습니다.