OpenClaw 서브에이전트의 스트리밍 응답을 청크 단위로 상위 루프에 흘려보내는 방법
이 글에서 다루는
OpenClaw는 필자가 사내 파이프라인을 설명하기 위해 사용하는 가상의 에이전트 프레임워크 이름입니다. 아래 문서 링크, PR 번호, 내부 API명(sessions_yield,blockStreamingCoalesce등)은 모두 개념 설명을 위한 예시이며 외부에 실존하는 저장소나 문서가 아닙니다. 각자의 스택(LangGraph, ADK, Semantic Kernel, 사내 오케스트레이터 등)에 매핑해서 읽어 주세요.
LLM 에이전트 파이프라인을 처음 설계할 때 가장 먼저 부딪히는 벽 중 하나가 "서브에이전트가 응답을 생성하는 동안 상위 루프는 뭘 하고 있어야 하나?"라는 문제입니다. 저도 처음엔 단순하게 await를 걸어두고 완료를 기다렸는데, TTFT(Time To First Token)가 사용자 체감에 얼마나 크게 작용하는지 실제 대시보드에서 확인하고 나서야 이 구조를 다시 짜기로 결정했습니다.
이 글에서는 스트리밍(Streaming)과 청킹(Chunking)을 의도적으로 분리된 두 단계로 다루는 설계 관점을 정리합니다. 토큰 단위 델타를 뽑아내는 것이 스트리밍이라면, 그 미세한 조각들을 문장·단락 단위로 합산해서 채널로 내보내는 후처리가 청킹입니다. 그리고 이 두 층 위에서 서브에이전트가 상위 루프에 중간 결과를 어떻게 흘려야 하는지, 어떤 지점에서 로직이 꼬이는지가 이 글의 초점입니다.
먼저 세 가지 관점을 미리 정리하고 시작하겠습니다. 첫째, 청킹 파라미터(예: minChars, maxChars, idleMs)는 채널별로 조정 가능한 튜닝 대상이지 마법 상수가 아닙니다. 둘째, 서브에이전트에서 흘러나오는 라이브 프리뷰 스트림과 최종 결과 경로는 완전히 다른 채널이며, 이 둘을 혼동하면 미묘한 버그가 생깁니다. 셋째, 스트리밍 중 부분 결과를 근거로 액션을 실행할지 여부는 성능 문제가 아니라 가역성 설계 문제입니다.
에이전트 루프와 스트리밍이 교차하는 지점
상위 루프의 실행 흐름
가상의 OpenClaw 에이전트 루프는 세션별로 직렬화된 실행 흐름을 갖습니다. 대략 이런 형태입니다.
이 흐름에 서브에이전트가 끼어들면 상황이 복잡해집니다. 상위 루프가 서브에이전트를 생성한 시점부터 완료 시점까지 자연스럽게 블로킹되면, 그동안 상위 루프의 컨텍스트 슬롯은 낭비되고 사용자에게는 아무 반응도 나가지 않습니다.
이 구간을 처리하기 위해 개념적으로 sessions_yield 같은 협조적 대기 메커니즘을 두는 방식을 자주 씁니다. 상위 루프가 현재 모델 턴을 자발적으로 종료하고, 자식 작업 완료 이벤트가 들어오면 다시 깨어나는 형태입니다. 이 재개 시점을 명시적 플래그(예: wakeOnDescendantSettle)로 표시해 두면 오케스트레이터가 자식 작업이 모두 완료된 순간에만 상위 루프를 깨우도록 조율할 수 있습니다.
스트리밍과 청킹을 왜 굳이 분리하는가
문서로만 보면 "왜 굳이 두 단계로 나누나?" 싶었습니다. 그런데 Discord나 Slack 같은 채널에 그대로 붙여 보면 바로 이해가 됩니다. LLM이 뱉는 토큰 델타는 단어 하나, 심지어 서브워드 단위로 날아오는데, 이걸 그대로 메시지 편집에 반영하면 채널 API 레이트 리밋에 금방 걸리고 클라이언트 렌더링도 요동칩니다.
청킹은 이 미세한 델타들을 조건이 충족될 때까지 버퍼에 담아뒀다가 한 번에 플러시합니다. 조건은 대체로 세 축으로 잡습니다.
| 파라미터 | 역할 |
|---|---|
minChars |
이 크기에 도달하기 전까지는 버퍼를 플러시하지 않음 |
maxChars |
이 크기를 초과하면 즉시 강제 플러시 |
idleMs |
신규 델타가 이 시간만큼 들어오지 않으면 버퍼를 플러시 |
각 값의 최적치는 채널마다 다릅니다. 구체적인 숫자는 실제로 붙인 채널의 레이트 리밋과 UX 감각에 따라 튜닝해야 하는 값이지, 어디에도 통용되는 "기본값"이 있는 것은 아닙니다. Discord처럼 메시지 편집 레이트 리밋이 빡빡한 채널은 idleMs를 짧게 잡으면 429가 자주 뜨고, 텔레그램 초안 모드처럼 짧은 편집이 자연스러운 채널은 minChars를 작게 잡아도 됩니다. 각 채널 공식 문서의 레이트 리밋 스펙을 확인하고, 실측으로 잡으시길 권합니다.
서브에이전트 중간 결과를 받는 두 개의 경로
라이브 프리뷰 경로 vs. 최종 결과 경로
여기가 이 글의 핵심입니다. 서브에이전트가 실행되는 동안 상위 루프에 도달하는 데이터는 두 종류이고, 두 경로의 의미론적 성격이 다릅니다.
시퀀스 다이어그램에 라이브 프리뷰 화살표를 그려 놓았지만, 이건 어디까지나 UI 노출용 채널입니다. 상위 루프의 분기 조건, 도구 호출, 상태 전이 같은 파이프라인 로직에는 절대 이 스트림을 소비하지 않아야 합니다. 이유는 두 가지입니다.
첫째, 라이브 프리뷰는 취소·재시도·부분 폐기가 언제든 일어날 수 있는 잠정 상태입니다. 여기에 파이프라인 결정을 걸면 롤백 경로가 폭발합니다.
둘째, 라이브 프리뷰 스트림을 상위 루프에서 파싱하는 것 자체가 성능 부담이 됩니다. 서브에이전트의 토큰 델타를 하나하나 상위 루프에서 파싱·정규화하면 자식 컨텍스트만큼 부모의 이벤트 루프도 바빠지고, 도구 호출 처리 지연으로 이어집니다. 그래서 실제 프로덕션 파이프라인에서는 라이브 스트림 파싱을 아예 우회하는 최적화가 자주 등장합니다.
정리하면 이렇습니다.
- UI 렌더링용 경로: 라이브 프리뷰 스트림. 사용자에게 진행 중임을 보여주는 용도.
- 파이프라인 로직용 경로: 종단 메시지. 부모의 다음 스텝을 결정하는 유일한 근거.
청킹 파이프라인을 파이썬으로 스케치하기
원시 토큰 스트림을 구조화된 이벤트로 변환할 때는 async generator로 감싸는 패턴이 편합니다. 하류 소비자 속도에 맞춰 백프레셔가 자연스럽게 걸리는 것도 장점입니다.
아래는 minChars + idleMs 조건을 동시에 반영한 개념적 예시입니다. 실제 프레임워크의 API 형태와 정확히 일치하지 않고, 에러 처리와 취소 신호 처리는 생략했습니다.
import asyncio
from typing import AsyncIterator
async def coalesce_stream(
tokens: AsyncIterator[str],
min_chars: int = 200,
idle_ms: int = 500,
) -> AsyncIterator[dict]:
queue: asyncio.Queue[str | None] = asyncio.Queue(maxsize=1024)
async def producer() -> None:
async for token in tokens:
await queue.put(token)
await queue.put(None)
prod_task = asyncio.create_task(producer())
buffer: list[str] = []
buffer_len = 0
idle_seconds = idle_ms / 1000
try:
while True:
try:
item = await asyncio.wait_for(queue.get(), timeout=idle_seconds)
except asyncio.TimeoutError:
if buffer:
yield {"type": "chunk", "text": "".join(buffer)}
buffer, buffer_len = [], 0
continue
if item is None:
if buffer:
yield {"type": "chunk", "text": "".join(buffer)}
return
buffer.append(item)
buffer_len += len(item)
if buffer_len >= min_chars:
yield {"type": "chunk", "text": "".join(buffer)}
buffer, buffer_len = [], 0
finally:
prod_task.cancel()초안에 있던 예시에서 몇 가지를 손봤습니다.
idle_ms를 시그니처에만 두고 구현을 빼놓으면 안 되니,asyncio.wait_for로 실제 타임아웃 플러시를 넣었습니다.- 순수 문자열 결합만 하는 함수를
async로 감싸는 건 오해 여지가 커서 인라인 처리로 바꿨습니다.async/await는 I/O나 다른 태스크로 제어를 넘겨줄 필요가 있을 때만 씁니다. Queue(maxsize=1024)처럼 명시적 상한을 두어 상류가 폭주할 때 메모리가 무한히 부풀지 않도록 했습니다.
소비자 쪽에서 처리 시간을 흉내 내고 싶다면 await asyncio.sleep(0.05)처럼 의미 있는 지연을 넣어야 합니다. asyncio.sleep(0)은 이벤트 루프에 제어권을 한 번 양보할 뿐 처리 시간을 시뮬레이션하지 않습니다.
분할 경계와 코드 블록 보호
청킹은 단순히 "N자마다 자른다"가 아니라, 마크다운 유효성을 유지하면서 자를 위치를 찾는 과정입니다. 우선순위는 대체로 이런 순서를 씁니다.
단락 > 줄바꿈 > 문장 > 공백 > 강제 분할가장 까다로운 예외가 코드 펜스 내부에서 자르지 않기입니다. 여기서 실수하면 렌더러가 남은 텍스트를 몽땅 코드처럼 표시하거나, 반대로 코드가 통째로 산문으로 뭉개집니다.
초안에는 백틱 개수의 홀짝만 세는 감지 로직을 넣었었는데, 그 방식은 인라인 코드(`), 들여쓰기 코드 블록, 문자열 리터럴 속 백틱을 구분하지 못합니다. 그래서 실무에서는 마크다운을 토큰 스캔하면서 펜스 진입/이탈 상태를 상태 머신으로 추적하는 편이 안전합니다. 아래는 상태 추적을 명시하고 폴스루 분기를 닫은 개념적 예시입니다.
def find_split_point(text: str, max_chars: int) -> int:
"""코드 펜스 안이면 자르지 않는 개념적 예시. 실제 마크다운 파서로 교체 권장."""
if len(text) <= max_chars:
return len(text)
in_fence = False
i = 0
while i < max_chars:
if text.startswith("```", i):
in_fence = not in_fence
i += 3
continue
i += 1
if in_fence:
close_pos = text.find("```", max_chars)
if close_pos == -1:
# 닫는 펜스가 없으면 안전하게 전체 유지
return len(text)
return close_pos + 3
for delimiter in ("\n\n", "\n", ". ", " "):
pos = text.rfind(delimiter, 0, max_chars)
if pos != -1:
return pos + len(delimiter)
return max_chars이 예시도 인라인 코드나 백틱 이스케이프까지 완전히 처리하지는 못합니다. 프로덕션에서는 자체 스캐너 대신 mistune, markdown-it-py 같은 정식 마크다운 파서에서 코드 노드 구간을 뽑아 오는 편이 낫습니다.
스트리밍 중 액션 실행: 성능이 아니라 가역성 문제
TTFT를 줄이려고 중간 청크만 보고 도구를 즉시 실행하고 싶은 유혹이 큽니다. 그런데 이건 성능 최적화 문제가 아니라 가역성 설계 문제입니다. 액션의 되돌림 특성에 따라 가능한 전략이 달라집니다.
- Idempotent / Reversible: 스트리밍 중 조기 실행 가능. 잘못 실행되어도 재시도 또는 원복으로 복구 가능.
- Compensable: 조기 실행은 가능하나, 보상 트랜잭션을 함께 설계해야 합니다. 결제 후 환불, 이메일 전송 후 정정 메시지 등이 여기 해당합니다.
- Irreversible: 스트리밍 중 조기 실행 금지. 반드시 최종 메시지 확정 이후에 실행합니다. 물리적 인쇄, 온체인 트랜잭션, 외부 시스템에 대한 파괴적 API 호출이 대표적입니다.
이 분류 자체는 특정 논문이 창안한 게 아니라 분산 시스템의 사가(Saga) 패턴, 액션 유형론에서 오래 쓰여 온 통상 분류를 에이전트 맥락에 옮긴 것입니다. 새로운 프레임워크를 발명해서 인용할 필요는 없고, 팀 내부의 액션 카탈로그에 이 네 태그를 붙여 관리하는 것만으로도 사고를 크게 줄일 수 있습니다.
전송 계층 선택지: SSE와 Streamable HTTP
2026년 8월 기준으로 스트리밍 응답의 전송 계층은 크게 두 갈래입니다. SSE(Server-Sent Events)와 청크드 인코딩 기반의 Streamable HTTP입니다. MCP(Model Context Protocol) 사양은 2025년 개정에서 Streamable HTTP를 새로운 권장 전송 방식으로 채택하면서 SSE 하위 호환을 함께 유지했습니다. "SSE를 완전히 제거했다"는 서술을 종종 보는데, 실제로는 교체가 아니라 디폴트 전환 + 병행 지원에 가깝습니다.
선택의 실전 기준은 배포 환경입니다.
- 지속 연결이 자연스러운 환경(전통적 서버, 컨테이너 워크로드): SSE가 여전히 단순하고 강력합니다. OpenAI, Anthropic, Google이 모두 채택 중입니다.
- 서버리스 환경(Lambda, Cloudflare Workers 등): 함수 실행 시간과 연결 유지 제약이 있어 청크드 응답이 더 잘 맞습니다.
이미 굴러가는 SSE 파이프라인을 급하게 갈아엎을 이유는 없지만, 신규 설계에서 서버리스가 배포 옵션에 있다면 Streamable HTTP를 처음부터 고려하는 편이 나중 마이그레이션 비용을 낮춥니다.
마무리하며 챙길 것
이 글에서 가장 강조하고 싶은 지점은 하나입니다. UI에 흐르는 라이브 프리뷰와 파이프라인이 소비하는 종단 메시지를 절대로 같은 것으로 취급하지 말 것. 이 원칙만 지켜도 서브에이전트 도입 초기에 흔히 겪는 "왜 가끔씩만 재현되는" 상태 꼬임의 상당수가 사라집니다.
당장 오늘 코드베이스에 적용해 볼 만한 것들을 짚어 두겠습니다.
- 서브에이전트 스트림을 구독하는 지점마다 어느 경로인지 주석으로 표시합니다. 최소
# ui-only/# pipeline두 태그면 충분합니다. - 청킹 파라미터를 채널별 설정 파일로 분리하고, 각 채널의 레이트 리밋 문서 링크를 주석으로 남겨 둡니다. 숫자만 커밋에 남기면 6개월 뒤 자신조차 그 값의 근거를 잊습니다.
- 액션 카탈로그를 훑어보고 각 도구에
idempotent/reversible/compensable/irreversible중 하나를 붙입니다. 스트리밍 중 조기 실행 가능한 도구와 확정 후에만 실행할 도구를 이 태그로 분기시키면, 파이프라인 검토가 훨씬 쉬워집니다.
세 가지 다 30분 안에 시작할 수 있는 작업입니다. 큰 리팩터링에 들어가기 전에 이 정도만 정리해 두어도, 이후 스트리밍/청킹 관련 이슈가 올라올 때 이야기의 결이 달라집니다.