Published on

LangChain tool_calls 400 에러 원인·해결 9가지

Authors

서버 로그에 400 Bad Request가 찍히고, 에러 메시지에 tool_calls 혹은 tools가 언급된다면 대개 “모델이 툴을 호출하려고 했는데, 요청 페이로드가 API 스펙과 미묘하게 어긋난 상태”입니다. 특히 OpenAI 계열 Chat Completions 호환 API를 LangChain으로 감쌀 때, 메시지 배열과 툴 스키마가 조금만 틀어져도 즉시 400이 납니다.

이 글은 LangChain에서 tool_calls 400이 나는 대표 원인 9가지를 “증상”과 “진단 포인트”, “해결 코드” 중심으로 정리합니다. 스트리밍과 섞일 때의 함정은 별도로 다룬 글도 참고하세요: LangChain 스트리밍 끊김·중복 토큰 해결법

문제를 빨리 줄이는 1분 체크

아래 3가지만 먼저 확인해도 상당수가 해결됩니다.

  1. LangChain 버전과 provider SDK 버전이 최신인지 확인
  2. tools 정의에 name, description, parameters JSON Schema가 정확한지 확인
  3. 툴 결과 메시지를 ToolMessage로 넣고 tool_call_id가 정확히 매칭되는지 확인

이제 본격적으로 9가지 원인을 봅니다.

1) 툴 스키마(JSON Schema) 자체가 유효하지 않음

증상

  • 에러 메시지에 Invalid schema 또는 tools[0].function.parameters 관련 힌트가 있음
  • 모델이 툴 호출을 시도하는 순간 400

원인

OpenAI 스타일의 function calling은 parameters가 JSON Schema여야 하고, 최소한 type: "object"properties 구조가 맞아야 합니다. LangChain tool 데코레이터를 쓰더라도, 타입 힌트가 복잡하면 스키마 생성이 기대와 다르게 나올 수 있습니다.

해결

  • 스키마를 단순화하거나 명시적으로 정의
  • additionalProperties 등 모델/게이트웨이가 싫어하는 필드를 제거
from langchain_core.tools import tool

@tool
def search_docs(query: str) -> str:
    """Search internal docs by keyword."""
    return f"found: {query}"

# 도구가 생성한 스키마를 로그로 확인하는 습관이 중요
print(search_docs.args_schema.model_json_schema())

스키마가 예상과 다르면, Pydantic 모델을 직접 정의해 통제합니다.

from pydantic import BaseModel, Field
from langchain_core.tools import StructuredTool

class SearchArgs(BaseModel):
    query: str = Field(..., description="keyword")

def _search_docs(query: str) -> str:
    return f"found: {query}"

search_tool = StructuredTool.from_function(
    func=_search_docs,
    name="search_docs",
    description="Search internal docs by keyword",
    args_schema=SearchArgs,
)

2) 툴 이름(name) 불일치 또는 잘못된 문자

증상

  • tool_calls[0].function.name 관련 오류
  • 모델 응답에는 툴 호출이 있는데, 실행 단계에서 매칭 실패

원인

툴 이름은 provider가 요구하는 규칙을 따릅니다. 공백, 특수문자, 너무 긴 이름 등이 문제를 만들 수 있고, LangChain에서 등록한 이름과 모델이 호출한 이름이 다르면 실행이 불가합니다.

해결

  • 툴 이름은 영문/숫자/언더스코어 위주로 짧게
  • name을 명시적으로 고정
from langchain_core.tools import tool

@tool("search_docs")
def search_docs_tool(query: str) -> str:
    """Search docs."""
    return query

3) tool_call_id 매칭이 깨짐 (툴 결과 메시지 포맷 오류)

증상

  • tool_call_id is required 또는 No tool call found for id 류의 메시지
  • 첫 턴은 되는데, 툴 결과를 넣는 순간 400

원인

OpenAI 호환 스펙에서 툴 호출은 두 단계입니다.

  1. assistant 메시지가 tool_calls를 포함
  2. client가 tool을 실행한 뒤, role=tool 메시지로 결과를 넣되 tool_call_id를 정확히 넣어야 함

LangChain이 자동으로 연결해주기도 하지만, 수동으로 메시지를 조립하거나 중간에 메시지를 변형하면 ID 연결이 깨집니다.

해결

  • LangChain의 ToolMessage를 사용
  • tool_call_id를 모델이 준 값 그대로 사용
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, ToolMessage

llm = ChatOpenAI(model="gpt-4o-mini")

messages = [HumanMessage(content="문서에서 LangChain tool_calls 예시 찾아줘")]

# 1) 모델이 tool_calls 생성
ai_msg = llm.invoke(messages, tools=[search_tool])

# 2) tool_calls에서 id와 args를 꺼내 실행
call = ai_msg.tool_calls[0]
result = search_tool.invoke(call["args"])  # 또는 search_tool.run(**call["args"])

# 3) tool 결과를 ToolMessage로 연결
messages += [ai_msg, ToolMessage(content=result, tool_call_id=call["id"])]

final = llm.invoke(messages, tools=[search_tool])
print(final.content)

4) 메시지 role 순서/구성이 스펙과 다름

증상

  • messages[?].role 관련 400
  • “tool 메시지는 assistant 뒤에 와야 한다” 류의 힌트

원인

툴 호출 흐름에서 메시지 순서가 중요합니다. 특히 다음 케이스가 흔합니다.

  • tool 결과를 HumanMessage로 넣음
  • tool 결과를 assistant 메시지로 넣음
  • tool 결과가 tool_calls가 없는 assistant 메시지 뒤에 붙음

해결

  • tool 결과는 반드시 ToolMessage
  • tool 결과는 해당 tool_calls를 만든 assistant 메시지 직후
# 잘못된 예: tool 결과를 HumanMessage로 넣으면 provider가 거부할 수 있음
# messages.append(HumanMessage(content=result))

# 올바른 예
messages.append(ToolMessage(content=result, tool_call_id=call_id))

5) toolstool_choice 조합이 잘못됨

증상

  • tool_choice 관련 400
  • “도구를 강제했는데 tools가 비어있다” 같은 오류

원인

일부 provider는 tool_choice를 엄격히 검사합니다. 예를 들어 특정 툴을 강제하려면, 그 툴이 tools 배열에 정확히 포함되어 있어야 합니다.

해결

  • 툴을 강제할 때는 등록 툴과 이름을 정확히 맞춤
  • 디버깅 중에는 강제를 풀고 자동 선택으로 먼저 정상화
from langchain_openai import ChatOpenAI

llm = ChatOpenAI(model="gpt-4o-mini")

# 디버깅 단계: tool_choice 강제 대신 자동으로 두고 먼저 통과시키기
resp = llm.invoke(
    "검색해줘",
    tools=[search_tool],
)

특정 provider나 게이트웨이를 쓰면 tool_choice 포맷이 약간 다를 수 있으니, LangChain이 생성한 최종 payload를 로깅해 확인하세요.

6) 툴 인자(args)가 JSON으로 직렬화 불가

증상

  • arguments must be a JSON object 또는 invalid type
  • Python 객체가 그대로 들어가 400

원인

툴 인자는 JSON으로 직렬화 가능한 타입이어야 합니다. 흔한 실수는 다음과 같습니다.

  • datetime, UUID, Decimal 등을 그대로 전달
  • Pydantic 모델 인스턴스를 그대로 전달
  • set, bytes 같은 비 JSON 타입 사용

해결

  • 툴 입력은 문자열/숫자/불리언/리스트/딕셔너리로 제한
  • 날짜는 ISO 문자열로 변환
from datetime import datetime, timezone

@tool
def log_event(event: str, ts_iso: str) -> str:
    """Log an event."""
    return f"{ts_iso} {event}"

# 호출 측에서 datetime을 문자열로 변환
ts_iso = datetime.now(timezone.utc).isoformat()
log_event.invoke({"event": "deploy", "ts_iso": ts_iso})

7) 스트리밍 중 tool_calls 파편 처리 미흡

증상

  • 스트리밍에서는 400 혹은 중간에 끊김
  • tool_calls의 arguments가 조각나 들어오다 실패

원인

스트리밍에서는 tool_calls 관련 필드가 델타로 쪼개져 오고, 이를 합쳐서 완전한 JSON이 되었을 때만 실행해야 합니다. 중간 조각 상태에서 실행하거나, 조각을 잘못 이어붙이면 provider가 거부하거나 애플리케이션이 잘못된 args로 tool을 호출합니다.

해결

  • LangChain의 스트리밍 콜백/이벤트에서 tool_calls 조립을 신뢰할 수 있는 방식으로 처리
  • 가능하면 “툴 호출은 non-streaming, 최종 답변만 streaming”으로 분리

스트리밍 관련 실전 패턴은 아래 글에서 더 자세히 다뤘습니다.

8) 모델/엔드포인트가 tool_calls를 지원하지 않음 (또는 설정 불일치)

증상

  • 같은 코드가 어떤 모델에서는 되고, 다른 모델에서는 400
  • Unknown field: tool_calls 또는 tools not supported

원인

모든 모델이 tool calling을 지원하는 것은 아닙니다. 또한 OpenAI 호환을 표방하는 게이트웨이도 tool calling 구현이 부분적일 수 있습니다.

해결

  • provider 문서에서 “tools 지원 모델” 확인
  • LangChain에서 모델을 교체해 A/B 테스트
  • 게이트웨이를 쓴다면 OpenAI 정식 엔드포인트로 재현 여부 확인
from langchain_openai import ChatOpenAI

# tool calling 지원 모델로 교체해 비교
llm = ChatOpenAI(model="gpt-4o-mini")

9) LangChain 버전 호환성 문제 (특히 core/messages/tooling)

증상

  • 예전 예제 코드를 그대로 썼더니 400
  • function_calltool_calls가 혼재되어 이상한 payload 생성

원인

LangChain은 한동안 function_call에서 tool_calls로 중심이 이동했고, langchain, langchain-core, langchain-openai 조합에 따라 내부 변환 로직이 달라집니다. 버전이 꼬이면 provider가 기대하는 필드와 달라져 400이 날 수 있습니다.

해결

  • 패키지 버전을 세트로 맞춰 업그레이드
  • 잠깐이라도 pip freeze로 고정하고 재현성 확보
pip install -U langchain langchain-core langchain-openai
pip freeze | grep -E "langchain|openai"

또한 “요청 payload를 그대로 찍어보기”가 가장 빠른 디버깅 루트입니다. LangChain은 내부적으로 Runnable 체인을 타기 때문에, HTTP 클라이언트 레벨 로깅을 켜거나 provider SDK의 debug 옵션을 활용하세요.

재현 가능한 디버깅 루틴 (권장)

400은 대개 서버가 “요청 구조가 틀렸다”고 말하는 것입니다. 따라서 다음 순서가 가장 효율적입니다.

  1. 최소 재현 코드로 축소: 툴 1개, 메시지 1개
  2. 스트리밍 끄기
  3. tool_choice 강제 끄기
  4. 툴 스키마 출력 후 JSON Schema 검증
  5. tool_call_id 연결 확인
  6. 모델 교체로 지원 여부 확인

이런 접근은 분산 시스템에서 원인 범위를 좁히는 방식과 유사합니다. 복잡한 흐름을 단계적으로 분해해 실패 지점을 찾는 방법론은 아래 글도 참고할 만합니다.

실전 예제: “툴 호출 후 최종 답변”의 정석 템플릿

아래 템플릿은 tool_calls 400을 피하기 위한 안전한 기본형입니다.

from pydantic import BaseModel, Field
from langchain_core.tools import StructuredTool
from langchain_core.messages import HumanMessage, ToolMessage
from langchain_openai import ChatOpenAI

class CalcArgs(BaseModel):
    a: int = Field(..., description="first")
    b: int = Field(..., description="second")

def _add(a: int, b: int) -> int:
    return a + b

add_tool = StructuredTool.from_function(
    func=_add,
    name="add",
    description="Add two integers",
    args_schema=CalcArgs,
)

llm = ChatOpenAI(model="gpt-4o-mini")

messages = [HumanMessage(content="3 더하기 5를 계산해줘")]

ai = llm.invoke(messages, tools=[add_tool])

# 모델이 툴을 호출하지 않았을 수도 있으니 방어
if getattr(ai, "tool_calls", None):
    call = ai.tool_calls[0]
    tool_result = add_tool.invoke(call["args"])  # args는 dict

    messages += [
        ai,
        ToolMessage(content=str(tool_result), tool_call_id=call["id"]),
    ]

    final = llm.invoke(messages, tools=[add_tool])
    print(final.content)
else:
    print(ai.content)

핵심은 다음 3가지입니다.

  • args_schema로 스키마를 통제한다
  • tool 결과는 ToolMessage로, tool_call_id를 정확히 넣는다
  • 모델이 툴을 호출하지 않는 경우도 처리한다

마무리

LangChain의 tool_calls 400은 “툴 호출 로직이 틀렸다”기보다 “요청 포맷이 스펙에서 벗어났다”에 가깝습니다. 위 9가지 중에서 특히 1번(스키마), 3번(tool_call_id), 4번(role/순서)이 전체의 대부분을 차지합니다.

여전히 원인이 안 잡히면, 최종 HTTP 요청 payload를 확보해 toolsmessages 구조를 눈으로 검증하는 단계로 돌아가세요. 그리고 스트리밍을 다시 켤 때는 tool_calls 조각 처리까지 포함해 단계적으로 복원하는 것이 안전합니다.