Published on

OpenAI Responses API 400 invalid_output_text 해결 가이드

Authors

서버에서 OpenAI Responses API를 붙이다 보면, 요청 자체는 정상처럼 보이는데도 400 Bad Request와 함께 invalid_output_text가 떨어지는 경우가 있습니다. 이 에러는 이름 그대로 “text 출력으로 해석할 수 없는 출력이 만들어졌다” 혹은 “클라이언트가 text로 받을 거라 가정했는데 실제 출력이 text가 아니다” 같은 상황에서 주로 발생합니다.

문제는 이게 단순히 “모델이 이상한 텍스트를 냈다”가 아니라, 요청 바디의 output 구성 / response_format / tool 호출 / 스트리밍 파서 등과 얽혀서 발생하는 경우가 많다는 점입니다. 이 글에서는 invalid_output_text를 재현할 수 있는 패턴과, 운영 환경에서 안전하게 고치는 방법을 체크리스트 형태로 정리합니다.

관련해서 다른 400 계열 원인도 함께 보고 싶다면 아래 글도 같이 참고하면 좋습니다.

1) invalid_output_text의 의미: “text 출력” 가정이 깨질 때

Responses API는 “출력”을 여러 타입으로 만들 수 있습니다.

  • output_text: 사람이 읽는 텍스트
  • output_json 또는 JSON schema 기반 구조화 출력
  • tool 호출 결과(함수 호출 인자/결과)
  • 멀티모달(이미지 등) 출력

그런데 클라이언트(혹은 SDK)가 output_text만 존재할 거라고 가정하고 .output_text 같은 필드를 바로 읽거나, 서버가 text로만 내려오도록 강제했는데 실제로는 JSON/툴 호출/빈 출력이 오면, “text로 성립하지 않는 output”으로 간주되며 invalid_output_text가 발생할 수 있습니다.

핵심은 아래 둘 중 하나입니다.

  1. 요청에서 output을 text로 받도록 구성했는데, 모델이 tool 호출이나 JSON을 우선 출력해버림
  2. 응답 파싱 코드가 text만 처리하도록 만들어져 있는데, 실제 응답 output 배열에는 text가 없거나 다른 타입이 섞임

2) 가장 흔한 원인 5가지와 해결책

원인 A. response_format/스키마를 쓰면서 “text로 읽기”를 시도

구조화 출력을 켜면 모델은 보통 output_text가 아니라 JSON 타입 출력을 생성합니다. 그런데도 코드에서 response.output_text만 읽으면, SDK 내부에서 “text가 아닌데 text로 달라 한다”는 상황이 되고 에러가 터질 수 있습니다.

잘못된 예(개념적으로)

# (개념 예시) JSON schema로 출력 강제
resp = client.responses.create(
    model="gpt-4.1-mini",
    input="사용자 정보를 JSON으로 만들어줘",
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": "user_profile",
            "schema": {
                "type": "object",
                "properties": {
                    "name": {"type": "string"},
                    "age": {"type": "integer"}
                },
                "required": ["name", "age"],
                "additionalProperties": False
            }
        }
    }
)

# 여기서 output_text만 기대하면 문제가 될 수 있음
print(resp.output_text)

해결

  • JSON 스키마를 쓸 때는 JSON output을 파싱하거나,
  • text가 필요하면 스키마를 끄고 text로만 받도록 정책을 바꿉니다.

아래는 “output 배열에서 타입별로 안전하게 분기”하는 파서 예시입니다.

from openai import OpenAI

client = OpenAI()

def extract_texts(response):
    texts = []
    for item in response.output:
        # SDK/버전에 따라 구조가 다를 수 있어 방어적으로 처리
        t = getattr(item, "type", None)
        if t in ("output_text", "text"):
            # 일부 SDK는 item.text, 일부는 item.content 등
            if hasattr(item, "text"):
                texts.append(item.text)
            elif hasattr(item, "content"):
                texts.append(item.content)
    return "\n".join(texts).strip()

resp = client.responses.create(
    model="gpt-4.1-mini",
    input="한 문장으로 요약해줘: HTTP 400은 클라이언트 요청 오류다."
)

print(extract_texts(resp))

원인 B. tool 호출이 섞였는데 text-only로 처리

함수 호출(tool)을 활성화하면 모델은 종종 “먼저 tool 호출 → 결과 반영 → 최종 텍스트” 흐름을 탑니다. 그런데 서버가 tool 결과를 처리하지 않고 바로 text를 읽으려 하면, 첫 응답은 tool 호출만 있고 text가 없을 수 있습니다.

해결 체크

  • tool 호출이 가능하도록 설정했다면, 응답에서 tool 호출 여부를 확인하고
  • tool을 실행한 뒤 다시 모델에 결과를 넣어 최종 text를 받는 루프를 구현합니다.
from openai import OpenAI
import json

client = OpenAI()

# 예시 tool(실제로는 DB 조회/외부 API 호출 등)
def get_weather(city: str):
    return {"city": city, "temp_c": 3, "condition": "cloudy"}

TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "도시의 현재 날씨를 가져옵니다",
            "parameters": {
                "type": "object",
                "properties": {"city": {"type": "string"}},
                "required": ["city"],
                "additionalProperties": False,
            },
        },
    }
]

resp = client.responses.create(
    model="gpt-4.1-mini",
    input="서울 날씨 알려줘",
    tools=TOOLS,
)

# 1) tool 호출이 있는지 확인
for item in resp.output:
    if getattr(item, "type", None) in ("tool_call", "function_call"):
        fn = item.function.name
        args = json.loads(item.function.arguments)

        if fn == "get_weather":
            result = get_weather(**args)

            # 2) tool 결과를 다시 모델에 전달해 최종 텍스트 생성
            resp2 = client.responses.create(
                model="gpt-4.1-mini",
                input=[
                    {"role": "user", "content": "서울 날씨 알려줘"},
                    {"role": "tool", "name": fn, "content": json.dumps(result)},
                ],
            )
            # 최종 텍스트 추출
            print(getattr(resp2, "output_text", ""))

포인트는 “첫 응답이 곧바로 텍스트일 거라는 가정”을 버리는 것입니다.

원인 C. output을 커스텀 구성하면서 text output을 제거

Responses API는 output을 명시적으로 구성할 수 있는데(버전/SDK에 따라 지원 범위가 다름), 이때 text 출력을 포함하지 않으면 당연히 output_text가 없습니다. 그런데 코드가 .output_text를 읽으면 에러가 납니다.

해결

  • text가 필요하면 output_text가 생성되도록 output 구성을 확인
  • 혹은 “output 배열 기반 파서”로 전환

운영 코드에서는 아래처럼 **“text가 없을 수 있다”**를 전제로 처리하는 게 안전합니다.

def safe_output_text(resp) -> str:
    # SDK에서 제공하는 output_text가 있으면 우선 사용
    text = getattr(resp, "output_text", None)
    if isinstance(text, str) and text.strip():
        return text

    # 없으면 output 배열에서 최대한 찾아본다
    if hasattr(resp, "output"):
        chunks = []
        for item in resp.output:
            if getattr(item, "type", None) in ("output_text", "text"):
                if hasattr(item, "text"):
                    chunks.append(item.text)
        return "\n".join(chunks).strip()

    return ""  # text가 없는 응답도 정상 케이스로 취급

원인 D. 스트리밍에서 이벤트 조립/디코딩이 깨짐

스트리밍(SSE)으로 받을 때, 이벤트를 잘못 조립하거나(줄바꿈/data: 프레임), 중간에 끊긴 데이터를 그대로 JSON 파싱하면 “부분 문자열”이 들어가며 결과적으로 text 출력이 유효하지 않다고 판단될 수 있습니다.

해결 체크

  • SSE 프레임을 표준대로 처리: data: 라인들을 모아 한 이벤트로 파싱
  • 마지막 data: [DONE] 처리
  • 네트워크 끊김 시 재시도(멱등 키/요청 재전송 전략)

네트워크 계층 이슈로 스트리밍이 끊기며 파싱이 꼬이는 경우도 많습니다. Python httpx를 쓴다면 아래 글의 케이스들이 그대로 재현될 수 있습니다.

원인 E. 프록시/게이트웨이가 응답을 변형(압축/인코딩/버퍼링)

Nginx, API Gateway, Cloudflare 같은 중간 계층이:

  • SSE를 버퍼링해서 “조각난 이벤트”를 합치거나
  • 압축을 강제하거나
  • 특정 문자 인코딩을 변형

하면 클라이언트 파서가 깨져 text 출력이 무효가 되는 경우가 있습니다.

해결

  • SSE 경로는 proxy_buffering off; 등 버퍼링 비활성화
  • Content-Type: text/event-stream 유지
  • gzip/브로틀리 압축 정책 점검

(이건 invalid_output_text라기보다 “결과적으로 text 파싱이 실패”하여 비슷한 증상으로 보일 수 있습니다.)

3) 재현 가능한 디버깅 절차(운영에서 바로 쓰는 체크리스트)

1단계: 원본 응답 전체를 로깅(민감정보 마스킹)

output_text만 보지 말고, 반드시 response.output 전체 구조를 확인해야 합니다.

import json

def dump_response(resp):
    # SDK 객체를 dict로 바꾸는 방법은 버전에 따라 다릅니다.
    # 가능한 경우 model_dump_json / to_dict 등을 사용하세요.
    if hasattr(resp, "model_dump"):
        print(json.dumps(resp.model_dump(), ensure_ascii=False, indent=2))
    elif hasattr(resp, "to_dict"):
        print(json.dumps(resp.to_dict(), ensure_ascii=False, indent=2))
    else:
        # 최후의 수단
        print(resp)

여기서 확인할 것:

  • output 배열에 output_text가 있는가?
  • tool_call만 있는가?
  • output_json/json_schema 타입이 있는가?
  • error 필드가 별도로 존재하는가?

2단계: “text-only” 요구사항을 명확히 하기

정말 텍스트만 필요하다면, 프롬프트/설정에서 tool 호출이나 구조화 출력을 끄는 편이 단순합니다.

  • tool이 필요 없다면 tools 제거
  • JSON이 필요 없다면 response_format 제거
  • 멀티모달 출력이 필요 없다면 입력/출력 타입을 단순화

3단계: 파서를 타입 기반으로 바꾸기

운영 안정성 관점에서 가장 추천하는 방식은:

  • resp.output_text가 있으면 사용
  • 없으면 resp.output를 순회하며 타입별로 처리
  • 그래도 없으면 “빈 텍스트”를 정상 케이스로 취급하고 상위 로직에서 분기

즉, 파싱 단계에서 예외를 던지지 말고 관측 가능한 형태로 올려야 장애가 줄어듭니다.

4) 실전: FastAPI에서 invalid_output_text를 방어하는 예시

아래 예시는 “응답이 text가 아닐 수도 있다”를 전제로, API 레이어에서 안전하게 처리하는 패턴입니다.

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from openai import OpenAI

app = FastAPI()
client = OpenAI()

class Req(BaseModel):
    prompt: str

def extract_any_text(resp) -> str:
    text = getattr(resp, "output_text", None)
    if isinstance(text, str) and text.strip():
        return text

    if hasattr(resp, "output"):
        parts = []
        for item in resp.output:
            t = getattr(item, "type", None)
            if t in ("output_text", "text"):
                if hasattr(item, "text") and item.text:
                    parts.append(item.text)
        return "\n".join(parts).strip()

    return ""

@app.post("/ask")
def ask(req: Req):
    try:
        resp = client.responses.create(
            model="gpt-4.1-mini",
            input=req.prompt,
            # tools/response_format 등을 켰다면 여기서부터 text-only 가정이 깨질 수 있음
        )
    except Exception as e:
        raise HTTPException(status_code=502, detail=f"upstream error: {e}")

    text = extract_any_text(resp)
    if not text:
        # 여기서 바로 500으로 터뜨리기보다,
        # output 전체를 로깅하고 422/204 등 정책적으로 처리하는 게 운영에 유리
        raise HTTPException(status_code=422, detail="no text output (maybe tool/json output)")

    return {"answer": text}

5) 결론: invalid_output_text는 “모델 문제”보다 “출력 계약(contract) 문제”

400 invalid_output_text는 대개 모델이 이상한 말을 해서가 아니라, 내가 기대한 출력(text)과 실제 출력(JSON/tool/멀티 타입)의 계약이 어긋난 것에 가깝습니다.

정리하면, 가장 빠른 해결 순서는 다음과 같습니다.

  1. 응답 전체(output 배열)를 확인해 실제로 어떤 타입이 오는지 본다.
  2. JSON schema/tool을 켰다면 “text-only 파싱”을 버리고 타입 기반 파서로 바꾼다.
  3. 스트리밍/SSE라면 프록시 버퍼링/압축/프레임 파싱을 점검한다.
  4. 운영에서는 “text가 없을 수 있음”을 정상 케이스로 처리하고, 상위 레이어에서 정책적으로 분기한다.

같은 400 계열이라도 요청 형식 자체가 잘못된 경우는 invalid_request_error로 떨어지는 경우가 많으니, 증상이 섞여 보인다면 아래 글도 함께 확인해 원인을 분리하는 것을 권장합니다.