본문으로 건너뛰기

[Open WebUI] ResponseMessage에서 JSON.stringify 비교를 O(1) fast-path로 우회

PR 링크: open-webui/open-webui#21884 상태: Merged | 변경: +10 / -2

들어가며

Open WebUI의 ResponseMessage.svelte는 Svelte의 리액티브 구문($:)으로 히스토리 변경을 감지하고, 변경이 있으면 메시지 객체를 딥 카피합니다. 문제는 변경 감지 자체가 JSON.stringify()를 2번 호출하는 O(n) 연산이라는 점입니다. 스트리밍 중에는 content가 매 토큰마다 바뀌므로, 이 비교는 항상 다른 결과를 반환하면서 순수한 낭비입니다. 이 PR은 O(1) fast-path를 추가합니다.

핵심 코드 분석

Before: 매번 전체 비교

$: if (history.messages) {
    if (JSON.stringify(message) !== JSON.stringify(history.messages[messageId])) {
        message = JSON.parse(JSON.stringify(history.messages[messageId]));
    }
}

스트리밍 중에는 content가 매 토큰마다 변경되므로, JSON.stringify() 2회 + JSON.parse(JSON.stringify()) 1회 = 총 3회의 직렬화가 무조건 실행됩니다.

After: O(1) fast-path 추가

$: if (history.messages) {
    const source = history.messages[messageId];
    if (source) {
        // Fast path: O(1) - 스트리밍 중 가장 빈번한 변경
        if (message.content !== source.content || message.done !== source.done) {
            message = JSON.parse(JSON.stringify(source));
        } else if (JSON.stringify(message) !== JSON.stringify(source)) {
            // Slow path: 드문 변경 (sources, annotations, status 등)
            message = JSON.parse(JSON.stringify(source));
        }
    }
}

contentdone 필드의 참조 비교는 O(1)입니다. 스트리밍 중에는 거의 항상 content가 다르므로 fast-path에서 바로 딥 카피로 진행하고, 비용이 큰 JSON.stringify() 비교를 건너뜁니다.

왜 이게 좋은가

1. 완전한 "공짜 점심"

이 변경은 동작 결과가 완전히 동일합니다. 변경 감지 방식만 최적화했으므로 사이드 이펙트가 없습니다.

2. 스트리밍 핫 패스에서의 절감

초당 30개 토큰을 생성하는 LLM 응답에서, 기존에는 초당 60회의 JSON.stringify()가 실행되었습니다. 응답이 2000자 정도면 매초 약 120KB의 임시 문자열을 생성하고 버리는 셈입니다. fast-path로 이 비용이 0이 됩니다.

3. 점진적 최적화 전략

이 PR은 후속 PR(#21948)에서 JSON.parse(JSON.stringify())structuredClone()으로 교체하는 작업의 전 단계입니다. 먼저 감지 비용을 줄이고, 다음으로 복사 비용을 줄이는 단계적 접근입니다.

참고 자료

댓글

관련 포스트

PR Analysis 의 다른글