Published on

LangChain Tool Calling - JSON 스키마 오류 9종 해결

Authors

서버나 에이전트에 툴을 붙이는 순간, “모델이 함수를 호출한다”는 기대는 금방 “스키마가 거부됐다”, “arguments가 파싱되지 않는다”, “required가 맞지 않는다” 같은 오류로 바뀝니다. 특히 LangChain은 다양한 LLM 백엔드(OpenAI, Azure, Anthropic 등)와 어댑터가 섞이고, 그 과정에서 JSON Schema의 미묘한 제약이 드러납니다.

이 글은 LangChain Tool Calling에서 실제로 자주 겪는 JSON 스키마/인자 오류 9종을 유형별로 묶고, 원인해결 패턴을 코드로 정리합니다. 예시는 TypeScript 기준이지만, 핵심은 스키마 구조이므로 Python에서도 동일하게 적용됩니다.

또한 운영 중 타임아웃이 섞여 “스키마 문제인지 네트워크 문제인지” 헷갈릴 때가 많습니다. 툴 호출이 많은 워크로드라면 OpenAI Responses API 504 Timeout 재현·해결도 같이 참고하면 원인 분리가 빨라집니다.

전제: LangChain 툴 스키마는 어디서 생기나

LangChain에서 툴 스키마는 대체로 아래 3가지 경로로 만들어집니다.

  1. Zod 기반: zodToJsonSchema 등을 통해 JSON Schema로 변환
  2. JSON Schema 직접 작성: parameters를 수동 구성
  3. 타입/데코레이터 기반(일부 프레임워크): 메타데이터로부터 생성

문제는 1)에서 변환 결과가 모델/벤더가 허용하는 JSON Schema subset과 어긋나는 경우가 많다는 점입니다. 따라서 해결 전략은 크게 두 가지입니다.

  • 변환된 JSON Schema를 검증/정규화(normalize) 해서 제출
  • 모델이 잘 따르는 형태로 스키마를 단순화

아래 예시에서는 LangChain의 tool 정의를 단순화한 형태로 설명합니다.

import { z } from "zod";
import { tool } from "@langchain/core/tools";

const GetWeather = tool(
  async (input: { city: string; unit?: "c" | "f" }) => {
    return { city: input.city, unit: input.unit ?? "c", temp: 21 };
  },
  {
    name: "get_weather",
    description: "Get weather by city",
    schema: z.object({
      city: z.string().min(1),
      unit: z.enum(["c", "f"]).optional(),
    }),
  }
);

오류 1) required 누락/불일치: 필수인데 required에 없음

증상

  • required must be an array”
  • “property is required” 류의 런타임 실패
  • 모델이 city를 빼먹고 호출하거나, 호출은 했는데 서버에서 검증 실패

원인

JSON Schema에서 type: "object"인 경우, 필수 필드는 required: ["city"]로 명시해야 합니다. Zod 변환 과정에서 optional 처리와 결합되면, required가 비거나 누락되는 경우가 있습니다(특히 스키마를 수동으로 만질 때).

해결

  • type: "object"required를 명시
  • 입력 검증을 툴 실행 직전에 한 번 더 수행
const schema = {
  type: "object",
  properties: {
    city: { type: "string", minLength: 1 },
    unit: { type: "string", enum: ["c", "f"] },
  },
  required: ["city"],
  additionalProperties: false,
};

오류 2) additionalProperties 미지정으로 “스키마는 통과, 실행은 실패”

증상

  • 모델이 cityName, location 같은 엉뚱한 키를 섞어 보냄
  • 툴 내부에서 input.cityundefined

원인

LLM은 “비슷해 보이는 키”를 쉽게 만들어냅니다. additionalProperties를 닫지 않으면 검증 계층이 허술해지고, 결국 툴 실행 단계에서 실패합니다.

해결

  • 객체 스키마에는 원칙적으로 additionalProperties: false
  • 대신 “별칭 키”를 허용하려면 oneOf로 분기하거나, 서버에서 매핑 레이어를 둡니다.
const schema = {
  type: "object",
  properties: {
    city: { type: "string" },
  },
  required: ["city"],
  additionalProperties: false,
};

오류 3) oneOf/anyOf/allOf 과다 사용으로 벤더가 거부

증상

  • “Unsupported schema keyword: oneOf
  • “Invalid JSON schema”

원인

일부 모델/플랫폼은 JSON Schema 전체를 지원하지 않고, 제한된 subset만 받습니다. 특히 oneOf 중첩, allOf 조합은 변환기에서 흔히 생성되지만 거부되는 케이스가 있습니다.

해결

  • 분기 스키마는 가능한 한 “단일 object + 명시적 discriminator 필드”로 단순화
  • 복잡한 타입은 툴을 분리(툴을 여러 개로 쪼개기)
// bad: oneOf 중첩
// good: mode 필드로 분기
const schema = {
  type: "object",
  properties: {
    mode: { type: "string", enum: ["by_city", "by_zip"] },
    city: { type: "string" },
    zip: { type: "string" },
  },
  required: ["mode"],
  additionalProperties: false,
};

오류 4) format 키워드(예: date-time, uri)가 무시되거나 거부

증상

  • “Unknown keyword format
  • 모델이 date-time을 완전히 무시하고 자유 텍스트로 보냄

원인

format은 JSON Schema에서 “의미적 힌트”에 가깝고, 검증기/플랫폼마다 지원이 갈립니다. LLM도 이를 강제 규칙으로 잘 인식하지 못합니다.

해결

  • format에 의존하지 말고, 정규식 pattern과 예시를 함께 제공
  • 설명(description)에 입력 규칙을 짧게 강제 문장으로 작성
const schema = {
  type: "object",
  properties: {
    isoTime: {
      type: "string",
      description: "ISO 8601. Example: 2026-02-26T10:30:00Z",
      pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$",
    },
  },
  required: ["isoTime"],
  additionalProperties: false,
};

오류 5) null 허용 표현이 꼬임: type에 null을 섞었더니 실패

증상

  • “type must be string”
  • “Invalid type: ["string","null"]”

원인

JSON Schema에서 nullable 표현은 여러 방식이 있는데, 플랫폼이 type: ["string","null"]을 싫어하거나, 변환 과정에서 anyOf로 바뀌어 거부되기도 합니다.

해결

  • 가능하면 null을 스키마로 표현하지 말고 “필드 생략”으로 처리
  • 정말 필요하면 문자열로 받고, 툴 내부에서 "" 또는 특수 토큰을 null로 해석
// 권장: optional로 처리
const schema = {
  type: "object",
  properties: {
    note: { type: "string" },
  },
  required: [],
  additionalProperties: false,
};

오류 6) 배열 아이템 스키마 누락: items가 없거나 너무 느슨함

증상

  • “Array schema must have items
  • 모델이 배열에 객체/문자열을 섞어서 보냄

원인

배열은 items가 사실상 핵심입니다. items: {}처럼 비워두면 모델이 마음대로 섞습니다.

해결

  • items에 타입을 정확히 지정
  • 길이 제한(minItems, maxItems)도 함께 주면 품질이 올라갑니다.
const schema = {
  type: "object",
  properties: {
    tags: {
      type: "array",
      items: { type: "string" },
      minItems: 1,
      maxItems: 10,
    },
  },
  required: ["tags"],
  additionalProperties: false,
};

오류 7) 숫자 타입 함정: integer를 줬는데 소수로 옴

증상

  • 모델이 count: 3.14처럼 소수를 보냄
  • 검증기에서 “Expected integer”

원인

LLM은 숫자 제약을 자주 어깁니다. 또한 일부 환경은 integer를 제대로 강제하지 않거나, 반대로 너무 엄격하게 강제해 실패를 유발합니다.

해결

  • 스키마에서는 type: "number"로 받고, 실행 전에 반올림/파싱
  • 또는 multipleOf: 1을 함께 사용(지원되는 경우)
const schema = {
  type: "object",
  properties: {
    count: {
      type: "number",
      description: "Whole number only",
      multipleOf: 1,
      minimum: 1,
      maximum: 100,
    },
  },
  required: ["count"],
  additionalProperties: false,
};

function toInt(n: unknown) {
  const x = typeof n === "number" ? n : Number(n);
  if (!Number.isFinite(x)) throw new Error("count is not a number");
  return Math.trunc(x);
}

오류 8) $ref/definitions 사용으로 스키마가 깨짐

증상

  • $ref is not allowed”
  • “Cannot resolve reference”

원인

Zod나 OpenAPI 변환기는 재사용을 위해 $ref를 생성합니다. 하지만 일부 툴 콜링 인터페이스는 단일 JSON 스키마 문서 내 참조 해석을 지원하지 않거나 제한합니다.

해결

  • $ref전개(dereference) 해서 인라인 스키마로 만들기
  • 재사용이 필요하면 “코드에서만 재사용”하고, 제출 스키마는 펼친 버전을 사용
// 제출용 스키마는 가능한 한 인라인으로
const schema = {
  type: "object",
  properties: {
    user: {
      type: "object",
      properties: {
        id: { type: "string" },
        name: { type: "string" },
      },
      required: ["id", "name"],
      additionalProperties: false,
    },
  },
  required: ["user"],
  additionalProperties: false,
};

오류 9) 모델이 arguments를 JSON이 아닌 문자열로 보냄

증상

  • LangChain이 JSON.parse에서 터짐
  • arguments"{ city: seoul }" 같은 유사 JSON

원인

모델이 “JSON을 생성한다”와 “정확한 JSON 문자열을 생성한다”는 다릅니다. 특히 프롬프트가 길거나, 이전 대화에서 코드블록 습관이 생기면 유사 JSON이 잦아집니다.

해결

  • 툴 스키마만 믿지 말고, 파서/리트라이 레이어를 둡니다.
  • LangChain에서는 StructuredOutputParser 계열 또는 “툴 호출 실패 시 재질문” 전략을 적용합니다.
  • 프롬프트에 “반드시 JSON만, 코드펜스 금지” 같은 금지 규칙을 짧게 추가합니다.
function safeParseArgs(raw: unknown): any {
  if (raw && typeof raw === "object") return raw;
  if (typeof raw !== "string") throw new Error("arguments is not string/object");

  // 1차: 정석 JSON
  try {
    return JSON.parse(raw);
  } catch {
    // 2차: 매우 보수적인 보정(운영에서는 로깅 필수)
    const fixed = raw
      .trim()
      .replace(/^```json\s*/i, "")
      .replace(/```$/, "")
      .replace(/\n/g, " ");
    return JSON.parse(fixed);
  }
}

실전 팁: 스키마 오류를 줄이는 운영 체크리스트

1) 스키마를 “작게, 평평하게” 유지

  • 중첩 객체, 분기(oneOf)가 늘수록 실패율이 올라갑니다.
  • 복잡하면 툴을 쪼개고, 상위 라우터 툴을 하나 둬서 “어떤 툴을 쓸지”만 결정하게 하는 패턴이 안정적입니다.

2) 검증은 두 번 한다

  • 모델 입력 단계: JSON Schema로 1차
  • 툴 실행 직전: Zod(또는 서버 검증)로 2차
import { z } from "zod";

const Input = z.object({
  city: z.string().min(1),
  unit: z.enum(["c", "f"]).optional(),
});

async function runTool(rawArgs: unknown) {
  const args = safeParseArgs(rawArgs);
  const input = Input.parse(args);
  // ... 실제 실행
  return input;
}

3) 장애 분석을 위해 “스키마/원문 arguments”를 함께 로깅

  • 스키마가 바뀐 배포 직후에 오류가 폭증하는 경우가 많습니다.
  • 단, 개인정보/토큰이 섞일 수 있으니 마스킹 규칙을 먼저 정하세요.

4) 타임아웃과 스키마 오류를 분리

툴 호출이 길어질수록 “스키마 오류처럼 보이는 실패”가 섞입니다. 특히 네트워크 지연이 있으면 재시도 로직이 중복 호출을 만들고, 그 결과 arguments가 꼬이기도 합니다. 이 경우는 OpenAI Responses API 504 Timeout 재현·해결처럼 타임아웃을 먼저 분리 진단하는 게 효율적입니다.

예시: 툴 스키마 정규화 함수(간단 버전)

운영에서 가장 효과가 좋았던 방법은 “제출 직전 스키마를 정규화해서, 위험 키워드를 제거/단순화”하는 것입니다. 아래는 아주 단순한 예시입니다.

type JsonSchema = any;

export function normalizeToolSchema(schema: JsonSchema): JsonSchema {
  if (!schema || typeof schema !== "object") return schema;

  // object면 additionalProperties 기본 닫기
  if (schema.type === "object" && schema.additionalProperties === undefined) {
    schema.additionalProperties = false;
  }

  // format 제거(플랫폼 호환성 목적)
  if (schema.format) {
    delete schema.format;
  }

  // properties 재귀
  if (schema.properties && typeof schema.properties === "object") {
    for (const key of Object.keys(schema.properties)) {
      schema.properties[key] = normalizeToolSchema(schema.properties[key]);
    }
  }

  // items 재귀
  if (schema.items) {
    schema.items = normalizeToolSchema(schema.items);
  }

  // $ref가 있으면 운영에서는 전개가 필요하지만,
  // 여기서는 최소한 제출을 막기 위해 감지
  if (schema.$ref) {
    throw new Error("Schema contains $ref. Please dereference before sending.");
  }

  return schema;
}

마무리: 9종 오류의 공통 원인

정리하면, LangChain Tool Calling에서 JSON 스키마 오류가 반복되는 이유는 대개 아래 3가지입니다.

  • 플랫폼이 지원하는 JSON Schema가 “표준 전체”가 아니라 subset이다
  • LLM은 스키마를 “엄격한 제약”이 아니라 “대충의 힌트”로 해석한다
  • 변환기(Zod 등)가 생성한 스키마가 복잡해져 벤더 제약을 건드린다

따라서 해결의 핵심은 스키마 단순화, 추가 속성 차단, 실행 직전의 강한 검증, arguments 파싱 방어입니다. 이 4가지만 체계적으로 적용해도, 툴 콜링의 실패율은 눈에 띄게 내려갑니다.

추론 품질은 올리되 민감한 내부 추론이 새지 않게 구성하고 싶다면 Chain-of-Thought 안 새게 추론 품질 올리는 법도 함께 읽어보면 프롬프트/출력 정책을 정리하는 데 도움이 됩니다.