Published on

Responses API 400 context_length_exceeded 해결법

Authors

서버에서 OpenAI Responses API를 붙이다 보면 어느 순간 갑자기 400과 함께 context_length_exceeded가 터집니다. 특히 RAG(검색 증강 생성)로 문서를 여러 개 붙이거나, 대화 히스토리를 전부 누적하는 방식으로 구현했을 때 재현이 쉽습니다. 문제는 “프롬프트가 좀 길었나?” 수준이 아니라, 모델별 컨텍스트 윈도우 한도를 초과하면 요청 자체가 거절된다는 점입니다.

이 글에서는 context_length_exceeded의 정확한 의미, 어디서 토큰이 새는지(시스템/개발자 메시지, 툴 스키마, 첨부 문서, 출력 토큰 예약), 그리고 현업에서 가장 많이 쓰는 해결 패턴(max_output_tokens 제한, 히스토리 슬라이딩, 요약 캐시, RAG top-k/청크 튜닝, 사전 토큰 계산, 오류 재시도 전략)을 코드와 함께 정리합니다.

> 참고: Responses API에서 400이 나는 다른 케이스(필드 형식, tool 정의 오류, input 구조 오류 등)는 별도 글로 정리해두었습니다. OpenAI Responses API 400 invalid_request_error 원인과 해결

1) 에러 메시지의 정체: “입력만”이 아니라 “입력+출력 예약”

context_length_exceeded는 단순히 input이 길다는 뜻이 아닙니다. 대부분의 API/SDK는 다음을 합산한 총량이 모델 한도를 넘으면 실패합니다.

  • 시스템/개발자 지시문(상수처럼 보이지만 토큰을 먹음)
  • 사용자 입력(질문)
  • 대화 히스토리(이전 질답)
  • 툴 호출 관련 텍스트(함수 스키마/인자/결과)
  • RAG로 붙인 문서 본문
  • 모델이 생성할 출력 토큰을 위한 여유분(예: max_output_tokens)

즉, max_output_tokens를 크게 잡아두면 입력이 그만큼 짧아야 합니다. “입력은 한도 미만인데도” 에러가 나는 이유가 여기서 나옵니다.

2) 가장 흔한 원인 6가지(체크리스트)

2.1 히스토리를 무제한으로 누적

채팅 UI에서 흔히 하는 실수:

  • 매 요청마다 지난 모든 메시지를 그대로 input에 포함
  • 시스템 프롬프트도 매번 길게 포함

해결: 슬라이딩 윈도우(최근 N턴만) 또는 요약 메모리로 전환.

2.2 RAG 문서를 “그냥 다 붙임”

검색 결과 top-k를 1020개로 뽑고, 청크도 12k 토큰으로 크게 가져오면 폭발합니다.

해결: top-k 축소 + 청크 크기 축소 + 중복 제거 + 질문과 무관한 섹션 제거.

2.3 툴 스키마/JSON Schema가 과도하게 큼

Responses API에서 tool 정의(특히 JSON Schema)를 길게 넣으면 매 요청 토큰을 많이 차지합니다.

해결: 스키마 최소화(필드 축소, 설명 축약), 툴 수 줄이기, 정말 필요한 요청에서만 툴 제공.

2.4 출력 토큰을 과도하게 예약

max_output_tokens=2048 같은 값을 상수로 박아두면, 입력이 조금만 길어져도 한도를 초과할 수 있습니다.

해결: 상황별로 동적 조절(요약이면 256, 분석이면 1024 등).

2.5 “로그/디버그 텍스트”를 프롬프트에 포함

서버 로그, SQL 결과 전체, HTML 원문, 에러 스택트레이스를 그대로 붙이면 토큰이 순식간에 늘어납니다.

해결: 필요한 부분만 발췌 + 구조화(핵심 필드만) + 길이 제한.

2.6 멀티턴에서 tool 결과(특히 문서) 재주입

툴 호출로 가져온 긴 결과를 다음 턴에도 계속 포함하면 누적됩니다.

해결: 툴 결과는 저장해두고, 다음 턴에는 요약/참조 키만 넣기.

3) 빠른 해결 1순위: max_output_tokens부터 줄여라

가장 즉효가 큰 조치가 max_output_tokens 조절입니다. 입력이 길어지는 상황(RAG, 긴 히스토리)에서는 출력 토큰을 적절히 제한해야 요청이 통과합니다.

from openai import OpenAI

client = OpenAI()

resp = client.responses.create(
    model="gpt-4.1-mini",
    input=[
        {"role": "system", "content": "You are a helpful assistant."},
        {"role": "user", "content": "아래 문서를 읽고 핵심만 5줄로 요약해줘..."},
    ],
    # 요약 작업이면 과도한 출력 예약이 필요 없음
    max_output_tokens=300,
)

print(resp.output_text)

운영에서는 “항상 1024” 같은 고정값 대신, 요청 유형별로 상한을 두는 편이 안전합니다.

4) 근본 해결: 토큰 예산(Token Budget) 설계

4.1 입력 예산을 먼저 정하고, 초과분은 잘라낸다

실무적으로는 다음처럼 예산을 나눕니다.

  • 모델 컨텍스트 한도: CONTEXT_LIMIT
  • 출력 예약: OUTPUT_BUDGET
  • 시스템/상수 프롬프트: SYSTEM_BUDGET(실측 기반)
  • 남는 입력 예산: INPUT_BUDGET = CONTEXT_LIMIT - OUTPUT_BUDGET - SYSTEM_BUDGET - SAFETY_MARGIN

그리고 INPUT_BUDGET을 넘는 부분은 (1) 문서 줄이기 → (2) 히스토리 줄이기 → (3) 그래도 넘으면 요약 순으로 처리합니다.

4.2 tiktoken으로 사전 토큰 계산(가드레일)

아래 코드는 대략적인 토큰 수를 계산해 초과 전에 잘라내는 패턴입니다(모델별 인코딩은 상황에 맞게 선택).

import tiktoken

enc = tiktoken.get_encoding("o200k_base")

def count_tokens(text: str) -> int:
    return len(enc.encode(text))

def trim_to_tokens(text: str, max_tokens: int) -> str:
    ids = enc.encode(text)
    ids = ids[:max_tokens]
    return enc.decode(ids)

# 예: RAG 문서가 너무 길면 상한으로 컷
doc = open("doc.txt", "r", encoding="utf-8").read()
print("doc tokens:", count_tokens(doc))

doc_trimmed = trim_to_tokens(doc, 1200)

주의할 점:

  • 실제 API의 토큰 산정은 메시지 구조/역할/툴 정의 등을 포함해 더 복잡할 수 있습니다.
  • 그래도 사전 차단 목적에는 충분히 효과적입니다.

5) RAG에서 context_length_exceeded를 줄이는 튜닝 포인트

RAG는 “검색 품질”과 “토큰 비용/한도”가 트레이드오프입니다. 다음 순서로 줄이면 품질 손실을 최소화할 수 있습니다.

5.1 top-k를 줄이기 전에 “중복 제거”

동일 문서의 비슷한 청크가 여러 개 들어오면 낭비입니다.

  • 같은 doc_id에서 상위 1~2개만 채택
  • cosine 유사도가 너무 비슷하면 하나만 남김

5.2 청크 크기/오버랩 조정

  • 청크가 크면 한 번에 많이 먹지만, top-k를 줄여도 정보 손실이 큼
  • 청크가 너무 작으면 top-k가 커져야 해서 또 길어짐

권장 접근:

  • 300~800 토큰 수준의 청크로 시작
  • overlap은 10~20% 정도로 최소화

5.3 “질문에 필요한 섹션만” 발췌

문서 전체를 붙이지 말고, 다음처럼 서버에서 전처리합니다.

  • 제목/헤더/요약/결론 우선
  • 표/로그/코드 블록은 필요한 부분만

RAG 품질이 갑자기 떨어지는(정확도 급락) 케이스는 검색/정규화 문제일 때도 많습니다. 관련해서는 PostgreSQL pgvector RAG 검색 품질 급락 원인과 해결 체크리스트도 함께 보면 원인 분리가 빨라집니다.

6) 대화 히스토리 관리: 슬라이딩 + 요약 메모리 조합

6.1 최근 N턴만 유지(슬라이딩 윈도우)

가장 간단하고 안정적입니다.

def build_messages(system_prompt: str, history: list[dict], user_msg: str, keep_last_turns: int = 6):
    # history: [{"role":"user"|"assistant", "content":"..."}, ...]
    trimmed = history[-keep_last_turns*2:]  # user+assistant를 1턴으로 가정
    return (
        [{"role": "system", "content": system_prompt}] +
        trimmed +
        [{"role": "user", "content": user_msg}]
    )

단점: 오래된 정보가 사라짐.

6.2 요약 메모리(장기 기억)

오래된 히스토리는 한 번 요약해서 “메모리”로 저장하고, 매 요청에는 메모리만 포함합니다.

  • 메모리 요약은 별도 요청(짧은 max_output_tokens)으로 생성
  • 메모리는 200~400 토큰 정도로 제한

실전 패턴:

  • memory(요약) + recent_history(최근 몇 턴) + current_question

7) 에러가 났을 때의 운영 대응: 자동 축소 재시도

사용자 경험 관점에서는 “에러로 종료”보다 “자동으로 줄여서 재시도”가 낫습니다. 다만 무한 재시도는 금지하고, 단계적으로 축소합니다.

아래는 간단한 단계적 폴백 예시입니다.

from openai import OpenAI

client = OpenAI()

def call_with_fallback(model, system_prompt, user_msg, docs, history):
    # 1) 기본 시도
    attempts = [
        {"k": 6, "doc_tokens": 1800, "max_out": 800},
        {"k": 4, "doc_tokens": 1200, "max_out": 600},
        {"k": 2, "doc_tokens": 800,  "max_out": 400},
    ]

    last_err = None
    for a in attempts:
        try:
            selected_docs = docs[:a["k"]]
            context = "\n\n".join(selected_docs)
            # 문서 토큰 컷(예시는 단순 문자열 컷; 실제로는 tiktoken 기반 권장)
            context = context[: a["doc_tokens"] * 4]  # 매우 러프한 근사

            messages = [
                {"role": "system", "content": system_prompt},
                {"role": "system", "content": f"[CONTEXT]\n{context}"},
                *history[-8:],
                {"role": "user", "content": user_msg},
            ]

            return client.responses.create(
                model=model,
                input=messages,
                max_output_tokens=a["max_out"],
            )
        except Exception as e:
            last_err = e
            continue

    raise last_err

핵심은 “문서 수/문서 길이/출력 토큰/히스토리 길이”를 한 번에 다 줄이지 말고, 품질 손실이 적은 축부터 단계적으로 줄이는 것입니다.

8) 스트리밍은 해결책이 아니다(하지만 체감 개선에는 도움)

stream=true(또는 SDK 스트리밍)를 켠다고 컨텍스트 한도가 늘어나지는 않습니다. 요청이 모델에 들어가기 전에 이미 한도 초과면 400으로 실패합니다.

다만 스트리밍은 다음에 도움이 됩니다.

  • 출력이 길어질 때 TTFB/체감 지연 감소
  • 프록시/로드밸런서 환경에서 타임아웃 이슈를 줄이기 위한 튜닝 포인트 제공

프록시 뒤에서 SSE가 끊기거나 499/502가 늘어나는 케이스는 별도의 네트워크/버퍼링 이슈이므로, 해당 증상이 함께 있다면 아래 글의 체크리스트가 유용합니다.

9) 최종 점검: 재현 가능한 진단 로그 남기기

context_length_exceeded를 빨리 잡으려면 “현재 요청이 왜 길어졌는지”를 수치로 남겨야 합니다.

권장 로깅 항목:

  • 히스토리 메시지 개수/문자 수(또는 토큰 수)
  • RAG 문서 개수/총 토큰 수
  • 시스템 프롬프트 토큰 수(상수지만 버전별로 변할 수 있음)
  • max_output_tokens
  • 사용자 입력 토큰 수

그리고 배포 후에는 다음을 알람으로 묶으면 좋습니다.

  • context_length_exceeded 발생률
  • 요청당 평균 입력 토큰/출력 토큰 추정치
  • RAG top-k 평균/95퍼센타일

10) 정리: 가장 실용적인 해결 순서

운영에서 바로 적용 가능한 우선순위는 다음과 같습니다.

  1. max_output_tokens를 작업 유형별로 낮추고 동적으로 조절
  2. 히스토리 슬라이딩 윈도우 적용(최근 N턴만)
  3. RAG: top-k 축소 전에 중복 제거, 청크 크기/오버랩 튜닝
  4. 문서/로그/HTML 원문은 발췌·요약 후 주입
  5. tiktoken으로 토큰 예산 가드레일 구축
  6. 에러 발생 시 단계적 축소 재시도(품질 손실 최소화)

이 흐름대로만 정리해도 Responses API 400 context_length_exceeded는 대부분 “재발 방지 가능한 형태”로 잡힙니다. 특히 RAG/멀티턴/툴 호출이 결합된 서비스라면, 토큰 예산을 기능 요구사항이 아니라 SLO의 일부로 다루는 것이 장기적으로 가장 비용이 덜 듭니다.