- Published on
OpenAI API+LangChain 스트리밍 툴콜 400 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 LangChain으로 스트리밍 응답을 붙이고, 동시에 툴콜(tool call)까지 켜는 순간 갑자기 OpenAI API가 400 Bad Request를 뱉는 경우가 많습니다. 문제는 대부분 “모델이 싫어하는 요청 형태”를 LangChain이 중간에서 만들거나, 개발자가 스트리밍 이벤트를 잘못 조립하면서 messages/tool 결과의 연결 규칙을 깨뜨릴 때 발생합니다.
이 글에서는 실제로 자주 나오는 400 케이스를 재현 가능한 형태로 분류하고, OpenAI API와 LangChain 조합에서 안정적으로 고치는 방법을 정리합니다. 특히 스트리밍 중 툴 실행을 끼워 넣는 구조에서 흔히 생기는 “tool 결과 누락”, “tool_call_id 불일치”, “병렬 툴콜 처리 실수”, “잘못된 파라미터 전달”을 중점적으로 다룹니다.
참고로 에이전트 구조를 쓰는 경우, 툴콜이 꼬이면 무한 루프나 비용 폭탄으로 이어지기 쉬우니 사전에 가드레일도 함께 두는 걸 권합니다: LangChain Agent 무한루프·비용폭탄 차단 7가지
왜 스트리밍+툴콜에서 400이 자주 터질까
스트리밍을 켜면 응답이 토큰 단위로 흘러오고, 툴콜이 켜져 있으면 모델이 중간에 “이 툴을 호출하라”는 구조화된 이벤트를 내보냅니다. 이때 클라이언트(또는 LangChain)가 해야 할 일은 다음 순서를 지키는 것입니다.
- 모델 스트림을 읽다가 툴콜 이벤트를 감지
- 툴을 실행하고 결과를 얻음
- 반드시 해당 툴콜과 매칭되는
tool_call_id로 tool 결과 메시지를 추가 - 그 다음 턴에서 모델에게 이어서 생성하도록 다시 요청
여기서 400은 보통 “요청 본문이 스키마/규칙을 위반했다”는 의미입니다. 즉, 네트워크 문제라기보다 messages 배열의 구성, tool 메시지 연결, 파라미터 타입/필드가 잘못된 경우가 압도적입니다.
400 원인 TOP 6 (실전에서 제일 많이 봄)
1) tool 결과 메시지를 누락했거나, 순서가 틀림
모델이 툴콜을 했는데 다음 요청에서 tool 결과를 안 보내면, 모델 입장에서는 “내가 요청한 tool 실행 결과가 없는데 왜 다음 턴으로 넘어가냐”가 됩니다. 일부 SDK/프레임워크는 이를 400으로 처리합니다.
체크 포인트
- 툴콜이 발생한 턴 이후에는
role: "tool"메시지가 반드시 들어가야 함 - tool 메시지는 해당 툴콜의
tool_call_id를 정확히 참조해야 함
2) tool_call_id 불일치
가장 흔한 실수입니다. 스트리밍 이벤트에서 받은 tool_call_id를 저장해두지 않고, 임의로 새 ID를 만들거나, 다른 툴콜의 ID를 재사용하면 400이 납니다.
체크 포인트
- 스트리밍 이벤트에서 받은
tool_call_id를 그대로 사용 - 병렬 툴콜이면 ID를 각각 매칭해서 tool 결과를 여러 개 넣어야 함
3) 병렬 툴콜(parallel tool calls) 처리 실수
모델이 한 번에 여러 툴을 호출할 수 있습니다. 이때 하나만 실행하고 나머지를 누락하면 다음 요청이 깨집니다.
체크 포인트
tool_calls배열 길이만큼 tool 결과 메시지를 추가- 실행 실패한 툴도 실패 결과를 tool 메시지로 반환(에러를 던지고 흐름을 끊지 말 것)
4) tools 스키마(JSON Schema) 불일치
특히 parameters에서 type, required, properties를 대충 쓰면 모델이 생성한 arguments와 검증이 맞지 않아 400이 납니다.
체크 포인트
parameters.type은 보통"object"properties에 정의된 필드만 받도록 하고,additionalProperties를 명시적으로 관리- 숫자/문자 타입 혼동(예:
"42"vs42)을 서버에서 보정하거나, 스키마를 현실에 맞게 수정
5) LangChain 버전/모델 조합에서 파라미터가 잘못 전달됨
LangChain이 내부적으로 OpenAI 요청을 만들 때, 특정 버전에서 stream_options, tool_choice, parallel_tool_calls 같은 필드를 잘못 구성해 400을 내는 경우가 있습니다.
체크 포인트
- LangChain, OpenAI SDK를 최신으로 올리고 재현 여부 확인
- 문제가 나는 요청을 로깅해서 “실제로 OpenAI에 어떤 JSON이 나갔는지”를 확인
6) 스트리밍 이벤트를 UI에만 흘리고, 서버 상태 머신은 안 만든 경우
프론트에서 SSE를 받아 그대로 출력만 하다 보면, 툴콜 이벤트를 “텍스트처럼” 처리해서 tool 실행 타이밍을 놓칩니다. 그 결과 다음 턴 요청이 잘못 조립됩니다.
체크 포인트
- 스트리밍 이벤트를 파싱하는 상태 머신(텍스트/툴콜/툴결과)을 분리
- “툴콜이 발생하면 텍스트 스트림을 잠시 멈추고 툴 실행 후 재개” 같은 제어가 필요
(재현) 잘못된 메시지 조립 예시
아래는 대표적인 안티 패턴입니다. 모델이 툴콜을 했는데 tool 결과 메시지가 없거나, ID가 맞지 않아 400으로 이어집니다.
// 안티 패턴: tool_call_id를 무시하거나 tool 결과를 누락
const messages = [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "오늘 서울 날씨 알려줘" },
// 모델이 tool call을 요청했다고 가정
{ role: "assistant", content: "", tool_calls: [
{
id: "call_abc123",
type: "function",
function: { name: "get_weather", arguments: "{\"city\":\"Seoul\"}" }
}
]},
// 문제: tool 결과 메시지가 없는데 다음 턴을 요청
];
이 상태로 다음 요청을 보내면, 모델이 기대하는 “tool 결과”가 빠져 있어서 400이 날 수 있습니다.
(해결) OpenAI API에서 올바른 툴콜 루프 패턴
핵심은 “툴콜 이벤트를 받으면, 그 ID로 tool 결과를 넣고, 그 다음 턴을 이어간다”입니다.
아래는 Node.js 기준의 최소 패턴 예시입니다(스트리밍은 개념적으로 표현). 실제로는 SDK 이벤트 타입에 맞춰 파싱하세요.
import OpenAI from "openai";
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const tools = [
{
type: "function",
function: {
name: "get_weather",
description: "Get weather by city",
parameters: {
type: "object",
properties: {
city: { type: "string" }
},
required: ["city"],
additionalProperties: false
}
}
}
];
async function getWeather(city: string) {
// 실제로는 외부 API 호출
return { city, tempC: 2, condition: "cloudy" };
}
export async function chatWithToolLoop(userText: string) {
const messages: any[] = [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: userText }
];
while (true) {
const resp = await client.chat.completions.create({
model: "gpt-4.1-mini",
messages,
tools,
tool_choice: "auto",
// stream: true 를 쓸 수도 있지만, 핵심은 tool_calls 처리 규칙
});
const msg = resp.choices[0]?.message;
if (!msg) throw new Error("No message");
// 1) 일반 텍스트 응답이면 종료
if (!msg.tool_calls || msg.tool_calls.length === 0) {
messages.push(msg);
return msg.content;
}
// 2) 툴콜이면 assistant 메시지를 먼저 누적
messages.push({
role: "assistant",
content: msg.content ?? "",
tool_calls: msg.tool_calls
});
// 3) 툴콜 각각 실행하고 tool 결과를 tool_call_id로 연결
for (const tc of msg.tool_calls) {
const name = tc.function.name;
const args = JSON.parse(tc.function.arguments || "{}");
let result: any;
try {
if (name === "get_weather") {
result = await getWeather(args.city);
} else {
result = { error: `Unknown tool: ${name}` };
}
} catch (e: any) {
result = { error: e?.message || "tool failed" };
}
messages.push({
role: "tool",
tool_call_id: tc.id,
content: JSON.stringify(result)
});
}
// 루프 계속: tool 결과를 포함한 messages로 다음 턴 생성
}
}
이 패턴을 지키면, “툴콜이 발생한 턴”과 “툴 결과를 반환하는 턴”의 연결이 보장되어 400이 크게 줄어듭니다.
LangChain에서 400을 줄이는 실전 체크리스트
LangChain은 편하지만, 내부에서 만들어지는 요청이 보이지 않으면 디버깅이 지옥이 됩니다. 아래를 우선 적용하세요.
1) 실제로 나간 요청 JSON 로깅하기
- LangChain 콜백(Callback)이나 transport 레벨에서 request body를 남기세요.
- 400 응답의 에러 메시지는 대부분 “어떤 필드가 잘못됐는지” 힌트를 줍니다.
2) 툴 스키마를 보수적으로 설계하기
additionalProperties: false를 켜면 모델이 쓸데없는 필드를 만들어도 서버에서 즉시 잡을 수 있습니다.- 단, 너무 빡빡하면 모델이 자주 실패하므로, 현실적으로 들어올 수 있는 필드는 열어두고 서버에서 보정하는 방식도 고려하세요.
3) 병렬 툴콜을 끄거나, 확실히 처리하기
- 병렬 툴콜을 제대로 처리하지 못하면 누락이 생겨 400이 납니다.
- 가능하면 초기에 병렬을 끄고, 안정화 후 켜는 순서를 추천합니다.
4) 스트리밍은 “표시”와 “상태”를 분리
- UI에 흘려보내는 텍스트 스트림과
- 내부적으로 tool_calls를 모아 실행하는 상태 머신을
분리하지 않으면, 중간에 툴콜 이벤트를 텍스트로 오인하거나 순서가 꼬이기 쉽습니다.
5) 타임아웃/리트라이를 툴 실행에만 적용
스트리밍 전체를 리트라이하면 같은 툴이 중복 실행될 수 있습니다. 툴 실행은 멱등성(예: 결제, 예약 등) 이슈가 크니, 리트라이는 “툴 레벨”에만 제한적으로 적용하는 편이 안전합니다. 패턴이 필요하면 아래 글의 데코레이터/컨텍스트 접근이 도움이 됩니다: Python 데코레이터+컨텍스트로 리트라이·타임아웃
스트리밍에서 툴콜 이벤트를 안전하게 모으는 방법(개념 코드)
스트리밍을 켜면 tool_calls가 한 번에 완성되어 오지 않고, 조각(delta)으로 나뉘어 올 수 있습니다. 따라서 “툴콜이 완성될 때까지 버퍼링”이 필요합니다.
아래는 개념적인 버퍼링 예시입니다(이벤트 이름은 SDK에 따라 다릅니다).
type ToolCallBuffer = {
id: string;
name: string;
argumentsJson: string; // delta로 이어붙임
};
const buffers = new Map<string, ToolCallBuffer>();
function onStreamDelta(delta: any) {
// delta.tool_calls 가 조각으로 올 수 있다고 가정
for (const tcDelta of delta.tool_calls ?? []) {
const id = tcDelta.id;
const prev = buffers.get(id) ?? {
id,
name: "",
argumentsJson: ""
};
if (tcDelta.function?.name) prev.name = tcDelta.function.name;
if (tcDelta.function?.arguments) prev.argumentsJson += tcDelta.function.arguments;
buffers.set(id, prev);
}
}
function getCompletedToolCalls() {
// 실제로는 finish_reason 또는 별도 이벤트로 완료를 판단
return [...buffers.values()].map(b => ({
id: b.id,
name: b.name,
arguments: JSON.parse(b.argumentsJson || "{}")
}));
}
포인트는 두 가지입니다.
arguments는 문자열 조각이므로 이어붙여야 함- 완료 시점을 정확히 잡아야 함(완료 전에 JSON.parse 하면 예외가 나고, 그 예외 처리 흐름이 messages를 깨뜨리면 다음 요청에서 400으로 이어질 수 있음)
에러 메시지로 빠르게 원인 좁히기
400은 대개 에러 바디에 힌트가 있습니다. 로깅에서 아래 키워드를 찾으면 진단이 빨라집니다.
Invalid tool_call_id또는tool_call_id not found- tool 결과 메시지가 누락됐거나 ID 매칭이 틀림
messages관련 검증 실패- role 순서, 필드 누락, tool 메시지 형식 오류
Invalid schema또는parameters- tools JSON Schema가 잘못됐거나, 모델이 만든 arguments가 스키마와 충돌
운영 팁: 400을 “재현 가능”하게 만드는 관측 포인트
스트리밍+툴콜은 재현이 어렵습니다. 아래 3가지를 남기면 원인 추적이 쉬워집니다.
- OpenAI로 나간 최종 request JSON(민감정보 제거)
- 스트리밍으로 받은 이벤트 타임라인(텍스트 델타, 툴콜 델타, 완료 시점)
- 툴 실행 로그(입력 args, 실행 시간, 결과, 실패 시 에러)
그리고 에이전트 구조라면, 루프가 꼬일 때 비용이 급증할 수 있으니 안전장치도 같이 넣으세요: LangChain Agent 무한루프·비용폭탄 차단 7가지
마무리: 400은 “요청 조립 오류”로 보면 거의 맞다
OpenAI API+LangChain에서 스트리밍 툴콜 400은 대부분 다음 중 하나로 귀결됩니다.
- tool 결과 메시지 누락
tool_call_id매칭 실패- 병렬 툴콜 일부 누락
- tools 스키마 부정확
- 스트리밍 델타를 버퍼링하지 못해 arguments JSON이 깨짐
위의 “툴콜 루프 패턴”대로 messages를 구성하고, 스트리밍 이벤트를 상태 머신으로 처리하며, 나간 요청 JSON을 로깅하면 400은 빠르게 사라집니다. 문제가 계속되면, LangChain이 만든 최종 요청을 그대로 복사해 OpenAI API에 직접 쏴보는 방식으로(프레임워크를 우회해) 원인을 더 정확히 분리할 수 있습니다.