전체 응답을 기다리면 늦는 이유

LLM에 JSON 같은 구조화 출력을 요청하면, 응답은 토큰 단위로 스트리밍된다. 흔한 구현은 스트림을 끝까지 모아 문자열을 완성한 뒤 json.loads로 파싱하고 스키마를 검증한다. 이 방식은 단순하지만 두 가지 비용을 치른다. 첫째, 필수 필드가 누락되거나 enum이 어긋난 경우를 가장 마지막에야 알게 된다. 5초짜리 응답이 끝나고 나서 재시도하면 실질 지연은 10초가 된다. 둘째, 앞부분 필드는 이미 확정됐는데도 다운스트림 작업(DB 조회, 외부 API 호출)을 시작하지 못한다.

부분 파싱은 스트림이 흐르는 동안 지금까지 도착한 JSON 조각을 반복적으로 파싱해, 필드가 확정되는 즉시 검증하고 후속 작업을 트리거하는 기법이다.

불완전 JSON은 왜 파싱이 어려운가

스트리밍 도중의 버퍼는 거의 항상 문법적으로 깨진 상태다. 예를 들어 {"status": "ok", "items": [{"id": 1 처럼 닫히지 않은 괄호와 따옴표가 남는다. 표준 파서는 이를 예외로 처리하고 아무것도 돌려주지 않는다. 핵심은 "닫히지 않은 구조를 잠정적으로 닫아서" 유효한 부분 객체를 만들어내는 관대한(lenient) 파싱이다.

관대한 파서로 부분 객체 복구

미완성 버퍼를 받아 열린 문자열·배열·객체를 임시로 닫아 파싱을 시도한다. 실패하면 뒤에서 한 글자씩 잘라내며 재시도한다.

def parse_partial(buf: str):
    stack, in_str, esc = [], False, False
    for ch in buf:
        if esc:
            esc = False; continue
        if ch == '\\' and in_str:
            esc = True; continue
        if ch == '"':
            in_str = not in_str
        elif not in_str and ch in '{[':
            stack.append('}' if ch == '{' else ']')
        elif not in_str and ch in '}]':
            if stack: stack.pop()
    fixed = buf + ('"' if in_str else '') + ''.join(reversed(stack))
    try:
        return json.loads(fixed)
    except json.JSONDecodeError:
        # 값이 잘린 경우: 마지막 쉼표/미완성 토큰까지 되감아 재시도
        cut = max(buf.rfind(','), buf.rfind(':'))
        return parse_partial(buf[:cut]) if cut > 0 else None

스트림 중 조기 검증 파이프라인

매 청크마다 버퍼에 붙이고 부분 파싱한 뒤, 이전 스냅샷과 비교해 새로 "확정된" 필드만 검증한다. 배열의 마지막 원소나 아직 값이 흐르는 필드는 확정으로 보지 않는다.

seen = {}
buf = ""
for chunk in stream:
    buf += chunk.text
    obj = parse_partial(buf)
    if not obj:
        continue
    for key, val in obj.items():
        if key in seen or key not in TERMINAL_KEYS:
            continue
        # 스트림 끝에서 값이 바뀔 수 있는 마지막 필드는 제외
        if key == last_key(obj):
            continue
        err = validate_field(key, val)   # jsonschema 등
        if err:
            stream.close()               # 조기 중단 → 즉시 재시도
            raise SchemaError(key, err)
        seen[key] = val
        dispatch(key, val)               # 다운스트림 조기 트리거

필드 단위 검증과 조기 중단

전체 스키마 대신 필드별 규칙을 개별 적용하는 것이 핵심이다. status가 정의된 enum을 벗어나면 나머지 토큰을 받을 이유가 없다. 스트림을 즉시 닫으면 남은 출력 토큰 비용과 지연을 모두 아낀다. 다만 검증은 "확정된" 필드에만 걸어야 한다. 값이 스트리밍되는 중간 상태("emai)를 검증하면 거짓 실패가 발생한다.

방식 비교

항목완성 후 일괄 검증스트리밍 부분 검증
오류 감지 시점응답 종료 후해당 필드 도착 즉시
재시도 지연전체 응답 × 2실패 지점까지 + 재시도
다운스트림 시작종료 후필드 확정 즉시
구현 복잡도낮음중간(멱등 dispatch 필요)

주의점

첫째, dispatch는 멱등해야 한다. 재파싱 과정에서 같은 필드가 여러 번 관찰될 수 있으므로 seen 집합으로 중복 실행을 막는다. 둘째, 마지막 필드는 항상 흐르는 중일 수 있으니 확정 판정에서 제외한다. 셋째, 부분 파서는 방어선일 뿐 스키마 대체물이 아니다. 스트림이 정상 종료되면 반드시 완성 버퍼로 전체 스키마를 한 번 더 검증해, 부분 파싱이 놓친 제약(필수 필드 누락, 필드 간 상호 의존)을 잡아야 한다. 넷째, 모델이 구조화 출력/도구 호출 모드를 지원한다면 우선 그 기능으로 문법적 유효성을 보장받고, 부분 파싱은 지연 최적화와 조기 중단 용도로 좁혀 쓰는 편이 안전하다.