- Published on
Assistants API+LangChain 툴콜 오류 디버깅 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 LLM을 붙이다 보면, “모델이 툴을 호출한다”는 개념 자체는 단순한데 실제 운영에서는 툴콜이 가장 먼저 깨집니다. 특히 OpenAI Assistants API에 LangChain을 얹으면 추상화가 한 겹 더 생기면서, 에러 메시지는 모호해지고 원인은 여러 층(프롬프트, 스키마, 런타임, 네트워크, 직렬화)로 흩어집니다.
이 글은 Assistants API와 LangChain 조합에서 흔한 툴콜 오류를 재현 가능하게 만들고, 관측 지점을 늘리고, 스키마와 런타임을 단단하게 고정해 해결하는 디버깅 루틴을 제공합니다.
참고로 “모델이 엉뚱한 형식으로 답했다” 류의 문제는 본질적으로 스키마 강제에 가깝습니다. 관련해서는 CoT 유출 막는 프롬프트 - JSON 스키마 강제 글의 접근(스키마 기반 강제)도 같이 보면 도움이 됩니다.
문제를 3층으로 나눠야 빨리 잡는다
Assistants API + LangChain 툴콜 문제는 보통 아래 3층 중 하나(혹은 복합)에서 발생합니다.
- 계약(Contract) 층: 툴 스키마(JSON Schema), 인자 타입, 필수 필드, enum, 날짜 포맷 등
- 오케스트레이션 층: 멀티툴 호출 순서, tool output 제출 방식, run 상태 전이, 재시도/타임아웃
- 실행(Runtime) 층: 실제 툴 함수 예외, 네트워크 오류, DB 커넥션, 외부 API 401/403/429, 직렬화 실패
디버깅의 핵심은 “지금 에러가 어느 층인가”를 빠르게 분류하는 것입니다. 이를 위해 아래 두 가지를 먼저 고정합니다.
- 툴 입력/출력을 반드시 JSON으로 로깅
- Assistants run 이벤트(또는 LangChain 콜백)를 통해 tool call id, arguments, output, status를 모두 수집
가장 흔한 증상 6가지와 1차 분류
1) 모델이 툴을 호출하지 않는다
- 원인: 툴 설명이 빈약하거나, 시스템 지시가 툴 사용을 막거나, 모델이 텍스트로 해결해버림
- 조치: 툴 사용 조건을 시스템 메시지에 명시하고, 툴 description을 “언제 호출해야 하는지” 중심으로 보강
2) arguments가 JSON이 아니거나 깨진다
- 원인: 스키마가 너무 느슨하거나, 예시가 부족하거나, 모델이 자연어를 섞음
- 조치:
additionalProperties: false와 필수 필드, 타입을 강하게 지정하고, 실패 시 재질의 루프를 만든다
3) 필수 필드 누락/타입 불일치
- 원인:
required누락, enum/format 불명확, 숫자를 문자열로 주는 등 - 조치: 스키마 강화 + 런타임에서 Zod/Pydantic으로 2차 검증
4) 멀티툴 호출에서 run이 멈춘다
- 원인: tool output 제출을 일부만 하거나, tool call id 매칭이 틀림
- 조치: tool call 목록을 순회하며 모든 call id에 대해 output을 제출
5) 툴 실행은 됐는데 Assistant가 결과를 무시한다
- 원인: tool output 포맷이 모델이 기대한 형태가 아님(문자열/JSON 혼용), 너무 장황함
- 조치: tool output을 “짧은 JSON 요약”으로 표준화
6) 간헐적 타임아웃/재시도 폭발
- 원인: 외부 API 지연, 429, 네트워크 문제, 동시성 과다
- 조치: 지수 백오프, 서킷 브레이커, 캐시, 동시성 제한
429나 레이트리밋 계열은 쿠버네티스에서도 유사 패턴으로 나타납니다. 접근 방식 자체는 EKS ImagePullBackOff 429 Too Many Requests 해결에서 다루는 “제한을 전제로 설계”와 비슷합니다.
디버깅을 위한 최소 재현 코드(Assistants API 중심)
아래는 Node.js에서 툴콜을 처리할 때, “무슨 tool call이 왔고 어떤 arguments였는지”를 확실히 남기는 형태의 예시입니다. 실제 SDK 메서드명은 버전에 따라 다를 수 있으니, 핵심은 run 상태 전이와 tool call id 매칭을 로깅하는 구조입니다.
import OpenAI from "openai";
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
type ToolArgs = { query: string; limit: number };
function safeJsonParse(input: string) {
try {
return { ok: true as const, value: JSON.parse(input) };
} catch (e: any) {
return { ok: false as const, error: e?.message ?? "json_parse_error" };
}
}
async function searchDocs(args: ToolArgs) {
// 실제로는 DB/벡터DB/검색엔진 호출
return {
items: [
{ title: "Doc A", score: 0.91 },
{ title: "Doc B", score: 0.87 }
].slice(0, args.limit),
query: args.query
};
}
export async function runOnce() {
const thread = await client.beta.threads.create();
await client.beta.threads.messages.create(thread.id, {
role: "user",
content: "우리 서비스 환불 정책 문서 2개만 찾아줘"
});
const assistantId = process.env.ASSISTANT_ID!;
let run = await client.beta.threads.runs.create(thread.id, {
assistant_id: assistantId
});
while (true) {
run = await client.beta.threads.runs.retrieve(thread.id, run.id);
console.log(JSON.stringify({
runId: run.id,
status: run.status
}));
if (run.status === "completed" || run.status === "failed" || run.status === "cancelled") {
break;
}
if (run.status === "requires_action") {
const toolCalls = run.required_action?.submit_tool_outputs?.tool_calls ?? [];
const outputs = [] as Array<{ tool_call_id: string; output: string }>;
for (const call of toolCalls) {
const fnName = call.function.name;
const rawArgs = call.function.arguments;
console.log(JSON.stringify({
toolCallId: call.id,
fnName,
rawArgs
}));
const parsed = safeJsonParse(rawArgs);
if (!parsed.ok) {
// 여기서부터가 디버깅 핵심: 모델이 JSON을 깨뜨렸다면
// output으로 에러를 돌려주고, 시스템 프롬프트/스키마를 강화해야 함
outputs.push({
tool_call_id: call.id,
output: JSON.stringify({ error: "invalid_json_arguments", detail: parsed.error })
});
continue;
}
if (fnName === "search_docs") {
const result = await searchDocs(parsed.value as ToolArgs);
outputs.push({
tool_call_id: call.id,
output: JSON.stringify({ ok: true, result })
});
} else {
outputs.push({
tool_call_id: call.id,
output: JSON.stringify({ error: "unknown_tool", fnName })
});
}
}
run = await client.beta.threads.runs.submit_tool_outputs(thread.id, run.id, {
tool_outputs: outputs
});
}
await new Promise((r) => setTimeout(r, 300));
}
const messages = await client.beta.threads.messages.list(thread.id);
console.log(messages.data.map((m) => ({ role: m.role, content: m.content })));
}
이 코드가 주는 이점은 명확합니다.
requires_action에서 모델이 준rawArgs를 그대로 남긴다- tool call id별로 output을 반드시 반환한다(일부만 반환하면 run이 멈출 수 있음)
- JSON 파싱 실패를 “툴 실행 실패”로 뭉개지 않고, “계약 층 문제”로 분리한다
스키마를 느슨하게 두면 100% 언젠가 터진다
툴콜 디버깅의 절반은 스키마 설계입니다. 특히 아래 3가지를 지키면 오류가 급감합니다.
required를 명시한다additionalProperties를false로 둔다- 문자열 포맷(날짜, 통화, ID 규칙)을 강제한다
예시 JSON Schema(개념 예시):
{
"name": "search_docs",
"description": "고객지원 문서를 검색한다. 환불/결제/계정 관련 질문이면 반드시 호출한다.",
"parameters": {
"type": "object",
"additionalProperties": false,
"properties": {
"query": { "type": "string", "minLength": 2 },
"limit": { "type": "integer", "minimum": 1, "maximum": 5 }
},
"required": ["query", "limit"]
}
}
여기서 additionalProperties: false가 중요합니다. 모델이 q 같은 임의 필드를 만들거나, limit 대신 topK를 만들어도 “그냥 통과”하면, 런타임에서 결국 깨지고 디버깅이 어려워집니다.
LangChain에서 툴콜 디버깅 포인트: 콜백과 트레이싱
LangChain을 끼면 “어디서 인자가 변형됐는지”가 안 보일 때가 많습니다. 그래서 콜백(Callback) 또는 트레이싱을 켜서 아래를 반드시 수집하세요.
- 모델에 전달된 최종 프롬프트(시스템/유저/툴 설명 포함)
- 모델이 생성한 tool call 이름과 arguments 원문
- 실제 실행된 함수와 반환값
- 예외 스택트레이스
Node.js 기준으로는 보통 아래처럼 콜백을 꽂아 이벤트를 로깅합니다(개념 예시).
import { CallbackManager } from "langchain/callbacks";
const callbackManager = CallbackManager.fromHandlers({
async handleLLMStart(_llm, prompts) {
console.log(JSON.stringify({ event: "llm_start", prompts }));
},
async handleLLMEnd(output) {
console.log(JSON.stringify({ event: "llm_end", output }));
},
async handleToolStart(tool, input) {
console.log(JSON.stringify({ event: "tool_start", tool: tool.name, input }));
},
async handleToolEnd(output) {
console.log(JSON.stringify({ event: "tool_end", output }));
},
async handleToolError(err) {
console.log(JSON.stringify({ event: "tool_error", error: String(err) }));
}
});
여기서 핵심은 handleToolStart의 input이 “모델이 준 원문”인지, LangChain이 중간에 파싱/변환한 값인지 구분해 기록하는 것입니다. 가능하면 “원문 arguments 문자열”과 “파싱된 객체”를 둘 다 남기세요.
멀티툴 호출 디버깅: 모든 call id에 응답했는지 확인
Assistants API에서 흔한 함정은 “모델이 한 번에 여러 툴을 호출했는데, 서버가 하나만 처리하고 output을 제출”하는 경우입니다. 그러면 run은 계속 requires_action에 머물거나, 다음 단계로 진행하지 못합니다.
체크리스트:
tool_calls.length만큼 output을 만들었는가- 각 output에
tool_call_id가 정확히 매칭되는가 - output이 문자열(JSON stringify)로 들어갔는가
특히 output을 객체로 그대로 넘기면 직렬화 단계에서 깨질 수 있으니, “항상 문자열”로 통일하는 게 안전합니다.
런타임 예외를 계약 오류와 분리하라
툴 함수 내부에서 터지는 예외(예: DB timeout, 외부 API 401/403)는 모델 입장에서는 “툴이 실패했다”로만 보입니다. 이때 툴 output을 아무렇게나 던지면, 모델이 재시도를 무한 반복하거나 엉뚱한 대안을 말할 수 있습니다.
권장 패턴은 에러를 다음처럼 표준화하는 것입니다.
{ "ok": false, "error": { "type": "upstream_401", "message": "auth failed", "retryable": false } }
retryable을 명시하면, 상위 오케스트레이터에서 재시도 정책을 분기하기 쉽습니다.- 401/403은 대개 재시도해도 안 되므로
retryable: false로 두고 사용자 액션(재로그인 등)을 유도합니다.
인증/토큰 갱신 문제는 시스템 전체에서 반복됩니다. 원리 자체는 JWT 캐시/로테이션 이슈와 비슷하니, 백엔드 인증 문제가 섞여 있다면 Spring Security JWT 401 - JWK 로테이션·캐시 대응처럼 “캐시와 갱신 타이밍” 관점도 같이 점검하세요.
재시도 설계: 모델 재질의와 툴 재시도를 섞지 말 것
툴콜 실패 시 재시도는 크게 두 종류입니다.
- 툴 재시도: 네트워크 타임아웃, 429 등 “같은 입력으로 다시 하면 성공할 가능성”이 있을 때
- 모델 재질의: arguments가 스키마를 위반했을 때(계약 층 문제)
이 둘을 섞으면 디버깅이 지옥이 됩니다. 예를 들어 JSON 파싱 실패를 “툴 재시도”로 처리하면, 같은 깨진 arguments로 3번 호출하고 3번 실패합니다.
권장 정책:
invalid_json_arguments,schema_validation_failed는 모델 재질의 루프로 보낸다timeout,429,connection_reset은 툴 재시도(지수 백오프, 최대 횟수 제한)
스키마 검증을 런타임에서 한 번 더: Zod 예시
모델이 준 arguments는 “그럴듯한 JSON”일 뿐 신뢰할 수 없습니다. 런타임에서 Zod로 한 번 더 조여서, 실패 시 명확한 에러를 tool output으로 반환하세요.
import { z } from "zod";
const SearchDocsSchema = z.object({
query: z.string().min(2),
limit: z.number().int().min(1).max(5)
}).strict();
function validateArgs(raw: unknown) {
const parsed = SearchDocsSchema.safeParse(raw);
if (!parsed.success) {
return {
ok: false as const,
error: {
type: "schema_validation_failed",
issues: parsed.error.issues
}
};
}
return { ok: true as const, value: parsed.data };
}
이렇게 하면 “모델이 limit를 문자열로 줬다” 같은 문제가 즉시 관측되고, 재현도 쉬워집니다.
운영에서 꼭 넣어야 하는 관측(Observability) 필드
툴콜 오류는 대부분 “로그가 부족해서” 오래 갑니다. 아래 필드는 최소로 넣는 것을 권합니다.
traceId또는requestIdassistantId,threadId,runIdtoolCallId,toolNamerawArguments(문자열)parsedArguments(객체, 단 개인정보는 마스킹)toolOutput(요약본)durationMs,retryCounterror.type,error.message,retryable
특히 개인정보/토큰이 arguments에 섞일 수 있으니, 마스킹 규칙을 먼저 정하고 로깅하세요.
결론: 툴콜 디버깅은 “스키마+상태머신+로그” 싸움
Assistants API와 LangChain을 함께 쓸 때 툴콜 오류를 빨리 잡는 순서는 항상 같습니다.
- 원문 arguments와 tool call id를 남긴다
- JSON 파싱 실패와 스키마 실패를 런타임 예외와 분리한다
- 멀티툴 호출이면 모든 call id에 output을 제출한다
- 스키마를 강하게(
required,additionalProperties: false) 만들고, Zod/Pydantic으로 2차 검증한다 - 재시도는 “툴 재시도”와 “모델 재질의”를 분리한다
이 루틴을 적용하면, “가끔 한 번씩 멈춤”, “arguments가 이상함”, “왜 툴을 안 부르지?” 같은 문제를 감으로 때려잡는 대신, 재현 가능한 데이터로 빠르게 수렴시킬 수 있습니다.