- Published on
LangChain에서 OpenAI 툴콜 JSON 깨짐 9가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 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가지입니다.
- 모델이 애초에 잘못된 JSON 문자열을 생성
- 모델은 맞게 생성했지만, 스트리밍/조합/후처리 과정에서 문자열이 변형
이제 대표적인 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{\"q\":\"hi\"}\n```"
원인
시스템/유저 메시지에서 "코드블록으로 출력" 같은 지시가 남아 있거나, 이전 대화에서 코드펜스가 계속 등장하면 툴콜에도 전염됩니다.
방어(코드펜스 제거 후 파싱)
function stripCodeFences(s: string): string {
return s
.replace(/^\s*```[a-zA-Z0-9_-]*\s*/m, "")
.replace(/\s*```\s*$/m, "")
.trim();
}
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 {
const s = raw.trim();
const start = s.indexOf("{");
if (start === -1) return null;
let depth = 0;
for (let i = start; i < s.length; i++) {
const ch = s[i];
if (ch === "{") depth++;
if (ch === "}") depth--;
if (depth === 0) return s.slice(start, i + 1);
}
return null;
}
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 등으로 인코딩해서 전달
import { Buffer } from "node:buffer";
function toBase64Utf8(s: string) {
return Buffer.from(s, "utf8").toString("base64");
}
// tool args에는 textBase64만 넣고, tool 내부에서 복원
6) 숫자/불리언이 문자열로 오거나, 스키마와 타입 불일치
증상
JSON 문법은 맞는데, 실행 단계에서 타입이 달라 실패합니다.
{"topK":"5"}{"include": "true"}{"ids": "[1,2,3]"}
원인
- 프롬프트가 타입을 명시하지 않음
- 예시가 문자열 위주
해결: Zod로 강제 검증 및 코어션
import { z } from "zod";
const Args = z.object({
topK: z.coerce.number().int().min(1).max(50),
include: z.coerce.boolean().default(false),
});
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 = { name?: string; args: string };
const bufs = new Map<string, ToolBuf>();
function onToolDelta(toolCallId: string, delta: { name?: string; arguments?: string }) {
const cur = bufs.get(toolCallId) ?? { args: "" };
if (delta.name) cur.name = delta.name;
if (delta.arguments) cur.args += delta.arguments;
bufs.set(toolCallId, cur);
}
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) {
// tool_calls 우선
if (Array.isArray(msg.tool_calls)) return msg.tool_calls;
// 구형/다른 형태 대비
if (msg.function_call) return [{ id: "legacy", function: msg.function_call }];
return [];
}
운영에서 바로 쓰는 체크리스트
툴콜 JSON 깨짐을 "근본적으로" 줄이려면 아래 순서가 효과적입니다.
- 스키마를 먼저 고정: Zod 등으로 입력을 엄격히 정의
- 프롬프트에서 JSON 규칙을 단일하게 강제: 코드펜스 금지, 자연어 금지
- 스트리밍이면
tool_call_id로 누적: 인덱스 기반 누적 금지 - 파싱 실패 시 원문 로깅:
arguments원문, 모델명, 프롬프트 버전, 요청 ID - 최후의 수단으로만 복구 파서: 코드펜스 제거, JSON 구간 추출
추가로, 타입 안정성을 더 끌어올리고 싶다면 런타임 스키마와 TS 타입을 맞추는 방식이 좋습니다. 이때 satisfies를 이용한 패턴은 TS 5.x satisfies로 타입 좁힘이 안될 때 해결법도 같이 참고할 만합니다.
LangChain 예시: 툴 스키마 + 검증 + 안전 파싱
아래는 "툴 인자 파싱이 깨져도" 장애를 최소화하는 기본 골격입니다.
import { z } from "zod";
const WeatherArgs = z.object({
city: z.string().min(1),
unit: z.enum(["c", "f"]).default("c"),
});
type WeatherArgsT = z.infer<typeof WeatherArgs>;
function parseToolArgs(raw: string): WeatherArgsT {
const cleaned = raw.trim();
const parsed = JSON.parse(cleaned);
return WeatherArgs.parse(parsed);
}
// 사용 예(툴 실행 직전)
function runWeatherTool(argsRaw: string) {
let args: WeatherArgsT;
try {
args = parseToolArgs(argsRaw);
} catch (e) {
// 운영에서는 반드시 argsRaw를 함께 로깅
throw new Error(`tool args parse failed: ${e instanceof Error ? e.message : String(e)}`);
}
// 실제 실행
return { ok: true, city: args.city, unit: args.unit };
}
핵심은 JSON.parse 성공 여부와 스키마 검증 성공 여부를 분리해서 다루는 것입니다. 전자는 문법 문제, 후자는 의미 문제이므로 대응이 달라집니다.
마무리
LangChain에서 OpenAI 툴콜 JSON이 깨지는 문제는 대부분 "모델이 멍청해서"가 아니라,
- 스트리밍 누적 방식
- 프롬프트의 포맷 지시 충돌
- 스키마/타입 검증 부재
- 멀티툴 상관관계 키 관리 부족
같은 엔지니어링 레이어의 빈틈에서 발생합니다.
위 9가지 패턴을 기준으로 로그를 분류해보면, 어디를 고쳐야 재발이 줄어드는지 빠르게 보일 겁니다. 특히 운영 환경에서는 "파싱 실패를 복구"하기보다, 스키마 강제와 프롬프트 단순화로 실패 자체를 줄이는 전략이 장기적으로 가장 싸게 먹힙니다.