- Published on
OpenAI Responses API 400 invalid_output_text 해결 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 OpenAI Responses API를 붙이다 보면, 요청 자체는 정상처럼 보이는데도 400 Bad Request와 함께 invalid_output_text가 떨어지는 경우가 있습니다. 이 에러는 이름 그대로 “text 출력으로 해석할 수 없는 출력이 만들어졌다” 혹은 “클라이언트가 text로 받을 거라 가정했는데 실제 출력이 text가 아니다” 같은 상황에서 주로 발생합니다.
문제는 이게 단순히 “모델이 이상한 텍스트를 냈다”가 아니라, 요청 바디의 output 구성 / response_format / tool 호출 / 스트리밍 파서 등과 얽혀서 발생하는 경우가 많다는 점입니다. 이 글에서는 invalid_output_text를 재현할 수 있는 패턴과, 운영 환경에서 안전하게 고치는 방법을 체크리스트 형태로 정리합니다.
관련해서 다른 400 계열 원인도 함께 보고 싶다면 아래 글도 같이 참고하면 좋습니다.
- OpenAI Responses API 400 invalid_request_error 원인과 해결
- OpenAI Responses API 415 Unsupported Media Type 해결
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가 발생할 수 있습니다.
핵심은 아래 둘 중 하나입니다.
- 요청에서 output을 text로 받도록 구성했는데, 모델이 tool 호출이나 JSON을 우선 출력해버림
- 응답 파싱 코드가 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/멀티 타입)의 계약이 어긋난 것에 가깝습니다.
정리하면, 가장 빠른 해결 순서는 다음과 같습니다.
- 응답 전체(
output배열)를 확인해 실제로 어떤 타입이 오는지 본다. - JSON schema/tool을 켰다면 “text-only 파싱”을 버리고 타입 기반 파서로 바꾼다.
- 스트리밍/SSE라면 프록시 버퍼링/압축/프레임 파싱을 점검한다.
- 운영에서는 “text가 없을 수 있음”을 정상 케이스로 처리하고, 상위 레이어에서 정책적으로 분기한다.
같은 400 계열이라도 요청 형식 자체가 잘못된 경우는 invalid_request_error로 떨어지는 경우가 많으니, 증상이 섞여 보인다면 아래 글도 함께 확인해 원인을 분리하는 것을 권장합니다.