모델은 스트리밍 중인데 왜 텍스트는 여전히 튀는 걸까요?
요약
LLM 스트리밍 출력 시 발생하는 텍스트 끊김 현상의 원인을 분석하고 해결 방안을 제시합니다. 네트워크 배치 처리와 브라우저 렌더링 시점의 차이로 인해 발생하는 버스트 현상을 제어하는 방법을 다룹니다.
핵심 포인트
- 스트리밍 텍스트가 튀는 이유는 모델 생성 리듬과 브라우저 렌더링 리듬의 불일치 때문입니다.
- WebSocket 메시지 배치 처리와 이벤트 버퍼링이 텍스트를 뭉쳐서 전달하게 만듭니다.
- requestAnimationFrame만으로는 배치 단위로 들어오는 대량의 텍스트를 부드럽게 처리할 수 없습니다.
- 고정된 속도가 아닌 대기 중인 백로그(backlog)를 기준으로 출력 속도를 조절해야 합니다.
이 포스트는 Mosoo의 스트리밍 출력을 개선하면서 우리가 배운 내용을 공유합니다. 모델은 이미 네이티브 델타 (native deltas)를 스트리밍하고 있습니다. Web UI의 역할은 스트리밍의 이점을 상쇄할 만큼의 과도한 작업을 추가하지 않으면서, 버스트 형태 (bursty)의 전달을 지각적으로 연속적인 텍스트로 변환하는 것입니다.
모델은 스트리밍 중일지 모르지만, 화면상의 텍스트가 반드시 그렇게 보이지는 않을 수 있습니다.
모델은 네이티브 델타를 계속 반환할 수 있지만, 해당 델타들은 브라우저에 도달하기 전에 프로바이더 (provider), 에이전트 런타임 (Agent Runtime), 이벤트 버퍼링 (event buffering), 그리고 WebSocket 전달 과정을 거칩니다. 그 과정에서 브라우저는 종종 이들을 배치 (batches) 단위로 받게 됩니다. 만약 Web UI가 각 배치가 도착하자마자 React에 전체를 삽입한다면, 사용자들은 반복되는 패턴을 보게 됩니다: 아무것도 나타나지 않다가, 텍스트가 한 번에 툭 튀어나오고, 다시 아무것도 나타나지 않는 현상입니다.
이것이 모델의 생성이 중단되었다는 의미는 아닙니다. 반드시 네트워크가 느려졌다는 의미도 아닙니다. 문제는 우리가 두 개의 서로 다른 시계 (clocks)를 하나로 취급했다는 점입니다.
스트리밍 출력에는 두 개의 시계가 있습니다: 이벤트가 도착하는 시점과 텍스트가 다음 화면 페인트 (screen paint)에 진입하는 시점입니다.
requestAnimationFrame은 스무딩 (smoothing)과 같지 않습니다
mosoo의 서버는 아주 작은 델타 하나하나마다 별도의 WebSocket 메시지를 보내지 않습니다. 뷰어 이벤트는 최대 150ms까지 압축 및 병합됩니다. 버퍼는 4KB 또는 64개의 이벤트에 도달하면 조기에 플러시 (flush)되며, 첫 번째 델타 또는 터미널 런 이벤트 (terminal Run event) 시에도 플러시됩니다. 해당 제한 사항들은 전달 버퍼 (delivery buffer)에 직접 존재하며, 첫 번째 델타와 터미널 이벤트에 대한 패스트 패스 (fast paths)는 자체적인 명시적 조건을 가지고 있습니다.
이는 필수적인 시스템적 트레이드오프 (tradeoff)입니다. 배치 (Batching)는 메시지 수, 직렬화 (serialization) 작업, 그리고 다운스트림 상태 업데이트를 줄여줍니다. 그 대가로 브라우저의 입력 리듬이 모델의 생성 리듬과 더 이상 일치하지 않게 됩니다.
기존 구현은 이미 렌더링 작업을 결합하기 위해 requestAnimationFrame을 사용하고 있었지만, 다음 프레임에서 대기 중인 배치 전체를 전달했습니다. rAF는 언제 커밋할지는 알려주지만, 해당 프레임에서 얼마나 많이 커밋해야 하는지는 알려주지 않습니다. 만약 하나의 WebSocket 메시지에 이미 약 150ms 분량의 출력이 포함되어 있다면, 이를 다음 프레임에 모두 전달하는 것은 여전히 튀는 현상을 발생시킵니다.
제어 변수는 고정된 타이핑 속도가 아니라 대기 중인 백로그 (backlog)입니다
명백한 해결책은 타자기 방식입니다. 즉, 몇 밀리초마다 한 글자씩 보여주는 것이죠. 하지만 고정된 속도는 느린 모델, 빠른 모델, 짧은 꼬리 (short tails), 그리고 갑작스러운 대규모 배치에 동시에 적응할 수 없습니다.
- 너무 느리면, 사용자가 몇 초 전의 텍스트를 보고 있는 동안 모델은 이미 생성을 마칩니다.
- 너무 빠르면, 전송 배치 (transport batches)가 여전히 튀는 현상으로 남게 됩니다.
- 글자당 하나의 타이머를 사용하는 방식은 또한 방대한 양의 콜백 (callbacks)과 상태 업데이트를 생성합니다.
mosoo는 다른 제어 변수를 사용합니다: 이미 수신되었지만 아직 렌더링되지 않은 자소 (graphemes)의 수입니다.
pending = received - rendered
rate = clamp(pending / 0.25s, 20, 800)
budget += rate * elapsedSeconds
...
백로그 (backlog)가 클수록 다음 프레임이 더 빠르게 따라잡습니다. 백로그가 작을수록 속도는 더 완만해집니다. τ = 250ms의 시상수 (time constant)는 다음 배치가 도착할 것으로 예상되는 시점까지 약 150ms 분량의 전송 배치를 비행 상태 (in flight)로 유지하여, 인접한 단계들을 연속적인 스트림 (stream)으로 전환합니다. 초당 20~800 자소라는 경계값은 짧은 꼬리가 영원히 늘어지는 것을 방지하고, 애니메이션이 읽기 너무 빠를 정도로 빨라지는 것을 막아줍니다.
브라우저는 모든 모델 토큰을 다시 재생하지 않습니다. 첫 번째 시계를 따르기 위해 두 번째 시계를 사용합니다. 각 프레임의 예산(budget)은 고정된 타이핑 속도가 아니라 백로그(backlog)에 의해 결정됩니다.
예산은 모든 프레임이 정확히 16ms 동안 지속된다고 가정하는 대신, 실제 경과 시간인 Δt를 사용합니다. 따라서 60Hz 디스플레이, 120Hz 디스플레이, 그리고 스로틀링(throttled)된 페이지는 동일한 실제 시간(wall-clock) 간격 동안 유사한 가시적 진행 상황을 생성합니다. 하나의 자소(grapheme)보다 작은 예산은 버려지지 않으며, 그 소수점 잔여분은 다음 프레임으로 넘어갑니다. 전체 예산 계산 및 큐 슬라이싱(queue slicing) 로직은 하나의 Scheduler에 구현되어 있습니다.
슬라이싱의 단위는 UTF-16 인덱스가 아니라 자소(grapheme)입니다
JavaScript 문자열 인덱스로 슬라이싱하면 서로게이트 쌍(surrogate pair), 결합 문자(combining character), 또는 ZWJ 이모지가 분리될 수 있습니다. 이로 인해 잠시 동안 UI에 � 또는 반쪽짜리 이모지가 렌더링될 수 있습니다.
이벤트가 큐에 진입할 때, mosoo는 Intl.Segmenter를 사용하여 이를 자소 클러스터(grapheme clusters)로 미리 분할하며, 코드 포인트(code-point) 반복을 폴백(fallback)으로 사용합니다. 이 작업은 한 번만 수행되며, 프레임당 예산은 그 결과를 재사용합니다. 이를 통해 매 프레임마다 동일한 문자열을 다시 스캔하지 않고도 텍스트의 정확성을 유지합니다.
스무딩(Smoothing)은 언제 멈춰야 할지를 알아야 합니다
단순히 "텍스트를 천천히 보여주는" 방법만 아는 Scheduler는 불완전합니다. 애니메이션은 이벤트 의미론(event semantics)에 양보해야 합니다.
큐에 Message End, Reasoning End, 종료(terminal) Run 이벤트, 또는 재연결 후의 State / Messages Snapshot이 포함되어 있으면, mosoo는 즉시 이전의 백로그(backlog)를 전달합니다. 서버는 이미 해당 세그먼트(segment)가 완료되었음을 확정했으므로, 오래된(stale) 애니메이션을 계속 재생할 이유가 없습니다. 이러한 이벤트들은 코드 내에서 명시적인 페이싱 배리어(pacing barriers) 역할을 합니다.
마찬가지로, 백로그가 4,096 자(graphemes)를 초과하면 스케줄러(Scheduler)는 전체 배치를 즉시 전달합니다. 최대 애니메이션 속도로 계속 진행할 경우, 화면이 실제 상태보다 몇 초 뒤처진 상태로 남을 수 있습니다. 과부하 상황에서 다듬기(polish)를 중단하는 것은 성능 전략의 일부입니다.
스무딩(Smoothing)은 생성이 활성화되어 있고 백로그가 제어 가능한 범위 내에 있을 때만 존재합니다. 정확성(Correctness), 종료 상태(terminal state), 그리고 신선도(freshness)가 우선순위를 갖습니다.
다른 몇 가지 경계 조건들도 중요합니다:
- 모든 이벤트는 엄격한 FIFO(First-In-First-Out) 순서를 유지합니다. 나중에 발생한 도구(tool) 또는 상태(state) 이벤트가 아직 공개되고 있는 텍스트를 앞지를 수 없습니다.
- 한 프레임에 최대 512개의 이벤트만 전달되어, 병리적인 이벤트 폭풍(event storm)이 하나의 거대한 React 커밋(commit)을 생성하는 것을 방지합니다.
- 숨겨진 페이지에서
requestAnimationFrame이 일시 중지되더라도, 50ms 타이머가 계속해서 큐를 비웁니다. - 소켓(socket)이 닫히면, 활성 세션(Session)이
flushNow를 호출합니다. 비활성 세션의 큐는 삭제되어 콘텐츠가 다른 세션으로 유출되지 않도록 합니다.
WebSocket 이벤트가 도착하면, 스케줄러(Scheduler)는 프레임당 Live State로 최대 하나의 배치(batch)를 제출합니다. 연결을 종료하면 명시적인 플러시(flush)가 트리거됩니다. 해당 통합 경계는 Session Stream Socket 내부에 유지됩니다.
낮은 오버헤드는 마법의 숫자가 아닙니다
이 변경 사항은 애니메이션 라이브러리를 추가하지 않으며, 문자당 타이머를 생성하지도 않습니다. 브라우저의 프레임 클락(frame clock)을 재사용하며 예산 책정(budgeting), 슬라이싱(slicing), 배치된 상태 업데이트(batched state updates)를 하나의 큐(queue)에서 처리합니다:
- 자소(Graphemes)는 큐에 진입할 때 한 번 분할됩니다.
- 각 프레임은 실제 경과 시간으로부터 정수형 예산(integer budget)을 계산합니다.
- 한 프레임은 최대 하나의 배치된 상태 업데이트를 트리거합니다.
- 사용자에게 보이는 텍스트 차이(deltas)만 속도가 조절되며, 구조적 이벤트는 문자 단위의 애니메이션을 받지 않습니다.
- 종료 상태(Terminal state)와 과도한 백로그(backlog) 모두 직접적인 종료 경로를 가집니다.
이것들은 오버헤드를 낮게 유지하기 위한 구조적 제약 조건이지, 측정되지 않은 성능에 대한 약속이 아닙니다. PR #450은 검증의 초점을 타이밍 의미론(timing semantics)에 맞추었습니다: 제어된 클락은 60Hz / 120Hz 일관성, 이모지 경계, 종료 플러시(terminal flushing), 재연결 스냅샷(reconnection snapshots), 엄격한 순서 보장(strict ordering), 숨겨진 페이지 폴백(hidden-page fallback), 그리고 과도한 백로그를 다룹니다. 각 케이스는 테스트 파일에서 확인할 수 있습니다. 우리는 이 변경 사항을 위해 프로덕션 CPU나 롱 태스크(long-task) 벤치마크를 실행하지 않았으므로, "더 부드럽다"는 표현을 "측정 가능하게 더 빠르다"라는 근거 없는 주장으로 바꾸지 않습니다.
재사용 가능한 원칙
네이티브 모델 출력(native model output)을 기반으로 Web UI를 구축하고 있다면, 다음 네 가지 규칙으로 시작하십시오:
- 첫 번째 델타(delta)가 전송 버퍼링(transport buffering)을 우회하도록 하여 첫 텍스트 도달 시간(time to first text)을 보호하십시오.
- 생성(generation) 중에는 고정된 타이핑 속도를 사용하는 대신, 백로그(backlog)로부터 프레임당 속도(per-frame rate)를 조정하십시오.
- 터미널 이벤트(terminal events)와 스냅샷(snapshots)을 즉시 플러시(flush)하십시오. 연결이 종료되거나 활성 세션(Session)이 변경될 때는 명시적으로 큐(queue)를 플러시하거나 비우십시오.
- 백로그가 통제 범위를 벗어나면 애니메이션을 중단하십시오. 매끄러움(smoothness)보다 신선도(freshness)가 더 중요합니다.
전송 계층(transport layer)은 시스템 오버헤드(overhead)를 줄입니다. 렌더링 계층(rendering layer)은 지각적 연속성(perceptual continuity)을 유지합니다. 이벤트 의미론(event semantics)은 이 두 가지가 언제 양보해야 하는지를 결정합니다.
이것이 mosoo PR #450의 핵심입니다. 우리는 모델이 출력을 생성하는 방식을 변경하거나 더 많은 토큰(tokens)을 조작하지 않았습니다. 우리는 브라우저에 명시적인 종료 경로(exit paths)를 가진 제한된 디스플레이 클록(display clock)을 부여하여, 모델의 네이티브 스트림(native stream)을 충실하게 따라잡을 수 있도록 했습니다.
Mosoo 소개
Mosoo는 Codex, Claude Agent SDK, OpenCode를 위한 오픈 소스 기반의 Cloudflare-native 에이전트 런타임(agent runtime)입니다. API 엔드포인트(endpoints), 격리된 샌드박스(sandboxes), 내구성이 있는 스레드(Threads), 그리고 검사 가능한 실행(Runs)을 제공합니다.
원문은 Mosoo 엔지니어링 블로그에 게시되었습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기