Published on

LangChain OpenAI 함수호출 400에러 7가지

Authors

서버가 400 Bad Request를 반환한다는 건 대개 “요청 JSON이 스펙과 다르다”는 뜻입니다. LangChain에서 OpenAI 함수 호출(도구 호출)을 붙이면 요청 구조가 한 단계 복잡해져서, 사소한 불일치가 곧바로 400으로 이어집니다. 특히 스키마(JSON Schema), 메시지 배열, 도구 결과 메시지의 연결 관계가 조금만 어긋나도 즉시 실패합니다.

이 글에서는 LangChain 기반으로 OpenAI 도구 호출을 사용할 때 자주 발생하는 400 원인 7가지를 “어떤 상황에서 터지는지”, “에러를 어떻게 읽어야 하는지”, “어떤 형태로 고치면 되는지”까지 코드와 함께 정리합니다.


사전 점검: 400 디버깅을 빠르게 만드는 로그 설정

먼저 요청 페이로드를 확인할 수 있어야 합니다. LangChain은 내부에서 OpenAI SDK를 호출하므로, 최소한 다음을 해두면 원인 파악 속도가 확 빨라집니다.

Python: LangChain 디버그와 요청 추적

import os

os.environ["LANGCHAIN_TRACING_V2"] = "true"
os.environ["LANGCHAIN_ENDPOINT"] = "https://api.smith.langchain.com"
# os.environ["LANGCHAIN_API_KEY"] = "..."

os.environ["LANGCHAIN_VERBOSE"] = "true"

또는 코드에서:

from langchain.globals import set_debug, set_verbose

set_debug(True)
set_verbose(True)

요청 JSON이 보이지 않으면 400은 “감”으로 고치게 됩니다. 반드시 요청 구조를 눈으로 확인하는 루틴을 먼저 만드세요.


1) 모델이 도구 호출을 지원하지 않는데 tools를 보냄

가장 흔한 실수입니다. 어떤 모델은 도구 호출을 지원하지 않거나, 특정 엔드포인트/SDK 조합에서 tools 필드를 허용하지 않습니다. 이때 서버는 보통 “Unknown field” 또는 “tools is not allowed” 류로 400을 반환합니다.

증상

  • tools 또는 tool_choice 를 포함한 요청에서만 400
  • 동일 프롬프트를 도구 없이 보내면 정상

해결

  • 도구 호출을 지원하는 모델로 변경
  • LangChain 버전과 OpenAI SDK가 해당 모델의 도구 호출 인터페이스와 맞는지 확인

예시(권장 패턴):

from langchain_openai import ChatOpenAI

llm = ChatOpenAI(
    model="gpt-4.1-mini",  # 예시: 도구 호출 지원 모델 사용
    temperature=0,
)

모델 선택은 성능 이슈처럼 보이지만, 실제로는 API 스펙 호환성 문제로 400을 유발합니다.


2) JSON Schema가 유효하지 않음: type, required, properties 불일치

도구 정의의 parameters 는 JSON Schema 형태여야 합니다. 여기서 흔히 하는 실수는 다음과 같습니다.

  • type 누락
  • properties 가 객체가 아님
  • required 에 없는 필드를 넣거나, properties 에 없는 키를 required 에 넣음
  • additionalProperties 처리로 인해 모델이 임의 필드를 생성해 실패

잘못된 예

tools = [
  {
    "type": "function",
    "function": {
      "name": "search_docs",
      "description": "search",
      "parameters": {
        "properties": {"query": {"type": "string"}},
        "required": ["query"]
        # type: object 누락
      }
    }
  }
]

올바른 예

tools = [
  {
    "type": "function",
    "function": {
      "name": "search_docs",
      "description": "Search internal docs",
      "parameters": {
        "type": "object",
        "properties": {
          "query": {"type": "string", "description": "search query"},
          "top_k": {"type": "integer", "minimum": 1, "maximum": 10}
        },
        "required": ["query"],
        "additionalProperties": False
      }
    }
  }
]

additionalPropertiesFalse 로 두면 모델이 스키마에 없는 키를 만들어내는 것을 막아, 도구 인자 파싱 단계에서의 불필요한 실패를 줄일 수 있습니다.


3) 도구 이름/함수 이름이 스펙을 위반함

도구(함수) 이름은 보통 제한된 문자 집합을 요구합니다. 공백, 한글, 특수문자, 너무 긴 이름은 400의 단골입니다.

증상

  • function.name 관련 validation 에러
  • 특정 도구만 추가하면 400

해결

  • 이름은 영문 소문자, 숫자, 언더스코어 중심으로 짧게
  • 버전 표기나 환경 표기는 이름이 아니라 description 에 넣기
# 좋은 이름 예
name = "get_weather"

# 피해야 할 예
# "날씨 가져오기", "get weather", "get-weather-v1-prod!!!"

4) tool 결과 메시지 연결이 끊김: tool_call_id 불일치

도구 호출 흐름은 대체로 다음 순서를 가집니다.

  1. 모델이 도구 호출을 포함한 assistant 메시지를 생성
  2. 클라이언트가 실제 함수를 실행
  3. tool 메시지로 결과를 다시 모델에 전달

이때 tool_call_id 가 정확히 이어져야 합니다. LangChain을 직접 조합하거나, 중간에 메시지를 가공하는 로직이 있으면 tool_call_id 를 누락하거나 다른 값으로 넣어 400이 발생합니다.

올바른 형태(개념)

  • assistant 메시지 안에 tool_calls 가 있고
  • 이어지는 tool 메시지에 동일한 tool_call_id 가 있어야 함

LangChain에서 수동으로 메시지를 구성한다면 다음을 특히 조심하세요.

from langchain_core.messages import HumanMessage, AIMessage, ToolMessage

messages = [HumanMessage(content="서울 날씨 알려줘")]

# ... 모델 호출 결과로 tool_calls 를 받았다고 가정
# tool_call_id = result.tool_calls[0]["id"] 같은 값을 반드시 보존

tool_result = "{\"temp\": 3, \"condition\": \"snow\"}"
messages.append(ToolMessage(content=tool_result, tool_call_id="call_abc123"))

도구 실행 결과를 보내면서 tool_call_id 를 빼먹으면 서버는 “어떤 호출에 대한 결과인지”를 매칭할 수 없어 400을 반환할 수 있습니다.


5) tool 메시지 content 타입/직렬화가 잘못됨

도구 결과는 문자열로 보내는 것이 안전합니다. 파이썬 dict를 그대로 넣거나, bytes, 커스텀 객체를 넣으면 직렬화 과정에서 깨지거나 서버에서 스키마 불일치로 400이 납니다.

흔한 실수

  • ToolMessage(content={"a": 1}) 처럼 dict를 직접 넣음
  • datetime 같은 JSON 직렬화 불가 타입을 포함

해결: JSON 문자열로 명시적 직렬화

import json
from datetime import datetime
from langchain_core.messages import ToolMessage

result = {
    "ok": True,
    "generated_at": datetime.utcnow().isoformat(),
    "items": [1, 2, 3],
}

tool_msg = ToolMessage(
    content=json.dumps(result, ensure_ascii=False),
    tool_call_id="call_abc123",
)

직렬화를 명시적으로 통제하면, “로컬에서는 돌아가는데 API에서만 400” 같은 상황을 크게 줄일 수 있습니다.


6) messages 배열의 role/순서가 깨짐 (특히 system, tool, assistant)

OpenAI 채팅 요청은 messages 배열의 role과 순서에 민감합니다. LangChain 체인을 섞어 쓰거나, 대화 이력을 DB에서 복원하는 과정에서 다음 문제가 자주 생깁니다.

  • tool 메시지가 assistant의 tool_calls 없이 먼저 등장
  • role 값 오타: "tools", "function" 같은 잘못된 role
  • assistant 메시지에 content가 None 인데 도구 호출도 없는 상태

해결 체크리스트

  • tool 메시지는 반드시 직전 어딘가에 해당 tool_call_id 를 가진 tool_calls가 존재해야 함
  • role은 system, user, assistant, tool 만 사용
  • assistant가 도구 호출을 하는 턴에는 content가 비어 있어도 되지만, 그 구조가 스펙에 맞아야 함(프레임워크가 생성한 메시지를 임의로 변형하지 않기)

LangChain을 쓴다면 “메시지 조립”을 직접 하기보다, 가급적 bind_tools 같은 고수준 API로 일관되게 처리하는 편이 안전합니다.


7) tool_choice 강제 설정이 도구 목록과 충돌

tool_choice 를 강제로 지정할 때, 지정한 도구가 tools 목록에 없거나 이름이 다르면 즉시 400이 날 수 있습니다. 또한 “반드시 이 도구를 호출하라”는 강제는 프롬프트/스키마와 충돌을 만들기도 합니다.

흔한 패턴

  • 환경별로 tools 목록이 달라지는데 tool_choice는 고정
  • 도구 이름 리팩터링 후 tool_choice만 미수정

해결

  • tool_choice를 강제하기 전에, 실제 요청에 포함된 tools와 이름이 일치하는지 assert
  • 강제가 필요 없다면 auto로 두고 프롬프트로 유도
def assert_tool_exists(tools, name):
    names = [t["function"]["name"] for t in tools]
    if name not in names:
        raise ValueError(f"tool_choice name not in tools: {name}, available={names}")

assert_tool_exists(tools, "search_docs")

실전 권장 구성: LangChain bind_tools 로 최소 실수 패턴 만들기

아래 예시는 “스키마 유효”, “도구 결과 직렬화”, “메시지 순서”를 프레임워크에 최대한 맡기는 형태입니다.

import json
from typing import Optional
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool

@tool
def search_docs(query: str, top_k: int = 3) -> str:
    """Search internal docs and return JSON string."""
    # 실제로는 벡터DB/검색엔진 호출
    result = {
        "query": query,
        "top_k": top_k,
        "hits": [
            {"title": "Doc A", "score": 0.91},
            {"title": "Doc B", "score": 0.87},
        ][:top_k],
    }
    return json.dumps(result, ensure_ascii=False)

llm = ChatOpenAI(model="gpt-4.1-mini", temperature=0)
llm_with_tools = llm.bind_tools([search_docs])

resp = llm_with_tools.invoke("에러 400 원인 문서 찾아줘. 키워드는 tool_call_id")
print(resp)

핵심은 “도구 결과를 문자열로 반환”하는 것입니다. 이렇게 하면 ToolMessage content 타입 실수를 원천 봉쇄할 수 있습니다.


400을 재발 방지하는 요청 검증 체크리스트

배포 전에 아래를 자동 테스트로 묶어두면, 운영에서 400이 터지는 빈도를 크게 줄일 수 있습니다.

  1. tools의 parameters 가 JSON Schema로 유효한가 (type: object, properties, required 정합)
  2. additionalProperties 정책이 명확한가 (가능하면 False)
  3. 함수 이름이 안전한 문자열 규칙을 따르는가
  4. tool 결과는 항상 문자열(JSON)인가
  5. tool 메시지에 tool_call_id 가 존재하고, 이전 assistant tool_calls와 매칭되는가
  6. messages role 값이 스펙에 맞고, tool 메시지의 순서가 올바른가
  7. tool_choice 를 강제한다면 tools 목록과 이름이 일치하는가

운영 관점 팁: “400은 입력 검증 실패”로 다뤄라

400은 서버 장애가 아니라 요청 계약 위반입니다. 따라서 재시도(retry)로 해결되지 않는 경우가 대부분이고, 오히려 불필요한 트래픽만 늘립니다. 이 점은 인증 문제(예: 401)나 리소스 문제(예: OOMKilled)를 다루는 방식과도 다릅니다. 비슷한 결의 장애 분류/대응 관점은 K8s ImagePullBackOff 401 해결 - ECR·Secret 같은 글의 “원인별로 재시도 전략을 다르게 가져가라”는 접근과도 통합니다.

또한, “환경/버전 차이로 특정 요청만 실패”하는 유형은 빌드 캐시나 의존성 불일치에서 자주 보이는 패턴과 유사합니다. CI에서 락파일/의존성 버전을 고정하고 재현성을 확보하는 습관은 Docker BuildKit 캐시 안먹힘 원인·해결 7가지에서 다루는 방식처럼, LLM 호출 레이어에서도 그대로 유효합니다.


마무리

LangChain에서 OpenAI 함수 호출로 인한 400은 대부분 “스키마”, “메시지 연결”, “직렬화” 세 축에서 발생합니다. 특히 tool_call_id 매칭과 JSON Schema 정합성은 한 번만 체계를 잡아두면 이후 장애율이 눈에 띄게 떨어집니다.

원인 파악이 막힐 때는 다음 순서로 보세요.

  • 요청 JSON을 로깅한다
  • tools 스키마를 최소 형태로 줄여서 재현한다
  • tool 결과를 무조건 문자열 JSON으로 바꾼다
  • tool_call_id 연결을 끝까지 추적한다

이 네 가지만 지켜도 “왜 400인지 모르겠다”는 상황은 대부분 사라집니다.