Published on

LangChain에서 OpenAI 툴콜 JSON 깨짐 9가지

Authors

서버에서 LangChain으로 OpenAI 툴콜을 붙이면, 가장 흔한 장애는 의외로 모델 성능이 아니라 tool_calls[].function.arguments가 유효한 JSON이 아니어서 파싱이 깨지는 문제입니다.

특히 스트리밍, 멀티툴, 긴 컨텍스트, 프롬프트 오염이 겹치면 JSON.parse 한 번에 터지고, 이후 체인 전체가 실패하거나 재시도 루프에 빠집니다. 이 글은 실제 운영에서 자주 만나는 툴콜 JSON 깨짐 9가지 패턴을 원인별로 분류하고, LangChain 기준으로 방어하는 방법을 코드와 함께 정리합니다.

참고로, 이런 류의 "입력은 맞는데 런타임에서 깨지는" 문제는 타입 시스템으로도 어느 정도 예방할 수 있습니다. 프런트/백에서 스키마를 엄격히 맞추는 패턴은 TS 5.x satisfies로 타입 좁히기 실전 패턴도 같이 보면 도움이 됩니다.

전제: 툴콜 JSON은 어디서 깨지나

OpenAI 툴콜 응답은 대략 아래 형태입니다.

  • tool_calls[i].function.name: 호출할 함수 이름
  • tool_calls[i].function.arguments: 문자열로 내려오는 JSON(여기가 자주 깨짐)

LangChain에서는 모델이 반환한 메시지에서 툴콜을 추출하고, arguments를 파싱해 실제 함수 실행으로 넘깁니다. 따라서 실패 지점은 크게 2가지입니다.

  1. 모델이 애초에 잘못된 JSON 문자열을 생성
  2. 모델은 맞게 생성했지만, 스트리밍/조합/후처리 과정에서 문자열이 변형

이제 대표적인 9가지를 보겠습니다.

1) 스트리밍 조각이 중간에서 끊겨 미완성 JSON

증상

스트리밍에서 arguments가 여러 델타로 나뉘어 오는데, 중간 청크가 유실되거나 조립 타이밍이 어긋나면 아래처럼 끝 괄호가 없는 상태로 파싱됩니다.

  • 예: {"query":"foo" 로 끝나버림

재현 포인트

  • 네트워크 재시도
  • 스트리밍 이벤트 핸들러에서 tool_calls 델타를 누적하지 않고 마지막 청크만 사용

방어 코드(조립 후 파싱)

아래는 LangChain에서 스트리밍을 쓰지 않더라도 유용한 패턴입니다. 핵심은 arguments를 즉시 파싱하지 말고, 완성 여부를 확인하는 것입니다.

function safeJsonParse<T>(raw: string): { ok: true; value: T } | { ok: false; error: string } {
  try {
    return { ok: true, value: JSON.parse(raw) as T };
  } catch (e) {
    return { ok: false, error: e instanceof Error ? e.message : String(e) };
  }
}

function looksCompleteJson(raw: string): boolean {
  const s = raw.trim();
  // 매우 단순한 휴리스틱: 객체/배열 시작과 끝이 맞는지
  if ((s.startsWith("{") && s.endsWith("}")) || (s.startsWith("[") && s.endsWith("]"))) return true;
  return false;
}

const parsed = looksCompleteJson(argsRaw) ? safeJsonParse(argsRaw) : { ok: false, error: "incomplete json" };

스트리밍이라면 델타 누적 로직을 반드시 검증하고, 누적이 끝난 시점에만 파싱하세요.

2) 작은따옴표, 트레일링 콤마 등 JSON이 아닌 JS 오브젝트 문법

증상

모델이 아래처럼 "자바스크립트 객체"를 만들어 버립니다.

  • 작은따옴표 사용: {'q':'hi'}
  • 트레일링 콤마: {"q":"hi",}
  • 키에 따옴표 누락: {q:"hi"}

원인

프롬프트에서 "JSON"을 강하게 요구하지 않거나, 예시가 JS 객체 형태로 들어가 있으면 모델이 그대로 따라 합니다.

해결

  • 프롬프트에 **"반드시 RFC 8259 JSON"**을 명시
  • 예시는 항상 큰따옴표 JSON으로 제공
  • 가능하면 LangChain의 스키마 기반 출력 사용
import { z } from "zod";

const SearchArgs = z.object({
  query: z.string(),
  topK: z.number().int().min(1).max(20).default(5),
});

스키마를 주면 모델이 문법을 더 잘 맞춥니다.

3) 코드펜스가 섞여 arguments에 ```json 이 들어감

증상

모델이 arguments에 코드블록을 포함해 버립니다.

  • 예: "```json\n&#123;\"q\":\"hi\"&#125;\n```"

원인

시스템/유저 메시지에서 "코드블록으로 출력" 같은 지시가 남아 있거나, 이전 대화에서 코드펜스가 계속 등장하면 툴콜에도 전염됩니다.

방어(코드펜스 제거 후 파싱)

function stripCodeFences(s: string): string &#123;
  return s
    .replace(/^\s*```[a-zA-Z0-9_-]*\s*/m, "")
    .replace(/\s*```\s*$/m, "")
    .trim();
&#125;

const cleaned = stripCodeFences(argsRaw);
const parsed = safeJsonParse(cleaned);

가능하면 "툴 호출 시에는 코드펜스를 쓰지 말라"를 시스템 메시지에 넣는 편이 더 좋습니다.

4) arguments에 자연어 설명이 섞여 JSON 앞뒤가 오염됨

증상

이런 형태가 흔합니다.

  • Here is the JSON: {"query":"hi"}
  • {...} // comment

JSON 파서는 객체 외의 문자열이 섞이면 실패합니다.

원인

모델이 "친절하게" 설명을 붙이는 습관 + 프롬프트가 느슨함.

해결: JSON 구간만 추출하는 방어(최후의 수단)

function extractFirstJsonObject(raw: string): string | null &#123;
  const s = raw.trim();
  const start = s.indexOf("&#123;");
  if (start === -1) return null;

  let depth = 0;
  for (let i = start; i &lt; s.length; i++) &#123;
    const ch = s[i];
    if (ch === "&#123;") depth++;
    if (ch === "&#125;") depth--;
    if (depth === 0) return s.slice(start, i + 1);
  &#125;
  return null;
&#125;

const extracted = extractFirstJsonObject(argsRaw);
if (!extracted) throw new Error("no json object found in arguments");
const parsed = JSON.parse(extracted);

이 방식은 "살리는" 데는 좋지만, 오탐 위험이 있으니 로그를 남기고 프롬프트/스키마를 먼저 고치세요.

5) 이스케이프 깨짐: 따옴표, 줄바꿈, 백슬래시가 망가짐

증상

arguments 안에 문자열 값으로 긴 텍스트가 들어가면, 모델이 줄바꿈을 실제 개행으로 넣거나 백슬래시를 엉키게 만들어 파싱이 실패합니다.

  • 예: {"text":"hello world"} 처럼 개행이 그대로 들어감

원인

  • 모델이 JSON 문자열 이스케이프 규칙을 실수
  • 프롬프트에서 "여기에 원문을 그대로 넣어" 같은 요구

해결

  • 툴 인자에는 원문 전체를 넣지 말고, 원문은 별도 저장소 키로 참조
  • 불가피하면 text를 base64 등으로 인코딩해서 전달
&#105;mport &#123; Buffer &#125; from "node:buffer";

function toBase64Utf8(s: string) &#123;
  return Buffer.from(s, "utf8").toString("base64");
&#125;

// tool args에는 textBase64만 넣고, tool 내부에서 복원

6) 숫자/불리언이 문자열로 오거나, 스키마와 타입 불일치

증상

JSON 문법은 맞는데, 실행 단계에서 타입이 달라 실패합니다.

  • {"topK":"5"}
  • {"include": "true"}
  • {"ids": "[1,2,3]"}

원인

  • 프롬프트가 타입을 명시하지 않음
  • 예시가 문자열 위주

해결: Zod로 강제 검증 및 코어션

&#105;mport &#123; z &#125; from "zod";

const Args = z.object(&#123;
  topK: z.coerce.number().int().min(1).max(50),
  include: z.coerce.boolean().default(false),
&#125;);

const parsed = JSON.parse(argsRaw);
const args = Args.parse(parsed);

이렇게 하면 모델이 문자열로 보내도 안전하게 변환됩니다.

7) 멀티툴 호출에서 tool_calls가 섞이거나 인덱스가 꼬임

증상

한 번의 응답에 여러 툴을 호출할 때, 스트리밍 델타가 섞이거나, 누적 로직이 tool_call_id 기준이 아니라 배열 인덱스 기준이면 다른 툴의 arguments가 합쳐져 깨집니다.

원인

  • 스트리밍 델타 처리에서 id로 그룹핑하지 않음
  • 병렬 실행 중 로그/상태 저장이 레이스 컨디션

해결

  • 델타 누적 시 tool_call_id로 버퍼를 분리
  • 실행 큐에서 tool_call_id를 상관관계 키로 사용
type ToolBuf = &#123; name?: string; args: string &#125;;
const bufs = new Map&lt;string, ToolBuf&gt;();

function onToolDelta(toolCallId: string, delta: &#123; name?: string; arguments?: string &#125;) &#123;
  const cur = bufs.get(toolCallId) ?? &#123; args: "" &#125;;
  if (delta.name) cur.name = delta.name;
  if (delta.arguments) cur.args += delta.arguments;
  bufs.set(toolCallId, cur);
&#125;

8) 컨텍스트 오염: 이전 메시지의 JSON 예시가 툴 인자에 섞임

증상

툴 인자에 전혀 관계없는 키가 갑자기 등장하거나, 예전 예시 JSON이 그대로 섞입니다.

  • 예: {"query":"hi","example":{"foo":"bar"}}

원인

  • 대화가 길어지면서 모델이 "예시"와 "실제 출력"을 혼동
  • 시스템 프롬프트에 여러 포맷 지시가 공존

해결

  • 프롬프트를 짧고 단일 목적화
  • 툴 스키마를 최소화(필요한 키만)
  • 체인 앞단에서 대화 요약/메모리 정리

운영에서 이런 문제는 트래픽이 늘수록 자주 보입니다. 특히 서버리스 환경에서 타임아웃과 재시도가 겹치면 증상이 더 복잡해지는데, 장애 대응 관점은 GCP Cloud Run 504·콜드 스타트 10분 지연 해결법처럼 타임아웃/재시도 설계를 같이 점검하는 게 좋습니다.

9) 모델/SDK 버전 차이로 툴콜 필드 형태가 달라짐

증상

어제까지 잘 되던 파서가 갑자기 깨집니다.

  • function_call 기반 응답과 tool_calls 기반 응답을 혼용
  • LangChain 또는 OpenAI SDK 업데이트 후 메시지 구조가 미묘하게 변경

원인

  • 의존성 업데이트로 타입/필드가 달라짐
  • 호환 레이어 없이 "한 형태"만 가정한 파서

해결

  • 응답 구조를 런타임에서 방어적으로 분기
  • LangChain, OpenAI SDK 버전 고정 및 변경 시 회귀 테스트
type AnyMsg = any;

function getToolCalls(msg: AnyMsg) &#123;
  // tool_calls 우선
  if (Array.isArray(msg.tool_calls)) return msg.tool_calls;
  // 구형/다른 형태 대비
  if (msg.function_call) return [&#123; id: "legacy", function: msg.function_call &#125;];
  return [];
&#125;

운영에서 바로 쓰는 체크리스트

툴콜 JSON 깨짐을 "근본적으로" 줄이려면 아래 순서가 효과적입니다.

  1. 스키마를 먼저 고정: Zod 등으로 입력을 엄격히 정의
  2. 프롬프트에서 JSON 규칙을 단일하게 강제: 코드펜스 금지, 자연어 금지
  3. 스트리밍이면 tool_call_id로 누적: 인덱스 기반 누적 금지
  4. 파싱 실패 시 원문 로깅: arguments 원문, 모델명, 프롬프트 버전, 요청 ID
  5. 최후의 수단으로만 복구 파서: 코드펜스 제거, JSON 구간 추출

추가로, 타입 안정성을 더 끌어올리고 싶다면 런타임 스키마와 TS 타입을 맞추는 방식이 좋습니다. 이때 satisfies를 이용한 패턴은 TS 5.x satisfies로 타입 좁힘이 안될 때 해결법도 같이 참고할 만합니다.

LangChain 예시: 툴 스키마 + 검증 + 안전 파싱

아래는 "툴 인자 파싱이 깨져도" 장애를 최소화하는 기본 골격입니다.

&#105;mport &#123; z &#125; from "zod";

const WeatherArgs = z.object(&#123;
  city: z.string().min(1),
  unit: z.enum(["c", "f"]).default("c"),
&#125;);

type WeatherArgsT = z.infer&lt;typeof WeatherArgs&gt;;

function parseToolArgs(raw: string): WeatherArgsT &#123;
  const cleaned = raw.trim();
  const parsed = JSON.parse(cleaned);
  return WeatherArgs.parse(parsed);
&#125;

// 사용 예(툴 실행 직전)
function runWeatherTool(argsRaw: string) &#123;
  let args: WeatherArgsT;
  try &#123;
    args = parseToolArgs(argsRaw);
  &#125; catch (e) &#123;
    // 운영에서는 반드시 argsRaw를 함께 로깅
    throw new Error(`tool args parse failed: ${e instanceof Error ? e.message : String(e)}`);
  &#125;

  // 실제 실행
  return &#123; ok: true, city: args.city, unit: args.unit &#125;;
&#125;

핵심은 JSON.parse 성공 여부스키마 검증 성공 여부를 분리해서 다루는 것입니다. 전자는 문법 문제, 후자는 의미 문제이므로 대응이 달라집니다.

마무리

LangChain에서 OpenAI 툴콜 JSON이 깨지는 문제는 대부분 "모델이 멍청해서"가 아니라,

  • 스트리밍 누적 방식
  • 프롬프트의 포맷 지시 충돌
  • 스키마/타입 검증 부재
  • 멀티툴 상관관계 키 관리 부족

같은 엔지니어링 레이어의 빈틈에서 발생합니다.

위 9가지 패턴을 기준으로 로그를 분류해보면, 어디를 고쳐야 재발이 줄어드는지 빠르게 보일 겁니다. 특히 운영 환경에서는 "파싱 실패를 복구"하기보다, 스키마 강제와 프롬프트 단순화로 실패 자체를 줄이는 전략이 장기적으로 가장 싸게 먹힙니다.