엔지니어링 노트대화 엔진

400으로 막힌 대화, 도구 결과는 저장할 때·모양은 보낼 때 맞춘다

모델 API는 호출 사이에 아무것도 기억하지 않아서 에이전트는 라운드마다 대화 맥락 전체를 다시 보냅니다. 라운드는 모델을 한 번 부르고 그 응답에 든 도구 호출을 실행하기까지를 말합니다. 그 대화 맥락에서 항목 하나만 프로바이더의 규칙에 어긋나도 프로바이더는 요청 전체를 400으로 거절합니다. 문제는 거절이 한 번으로 끝나지 않는다는 점입니다. 다음 턴도 저장된 같은 대화 맥락에서 요청을 조립하므로 같은 400을 받고, 사용자가 무엇을 물어도 그 대화는 다시 움직이지 않습니다. 도구 호출 상한에 걸려 마감한 턴 하나가 결과 없는 호출 하나를 남기면, 그 대화가 이렇게 막힙니다.

거절을 부르는 모양은 네 가지입니다. 결과가 붙지 않은 도구 호출, 도구 호출과 그 결과 사이에 끼운 메시지, 다른 프로바이더가 만든 항목, 응답에만 있는 필드입니다. 앞의 둘은 어느 프로바이더에서나 대화의 뜻이 어긋난 것이라 저장할 때 막고, 뒤의 둘은 프로바이더마다 규칙이 달라 저장본은 그대로 두고 보낼 때 맞춥니다. 보내기 직전에는 결과 없는 호출을 한 번 더 걷어 냅니다. 이 글은 이 나눔을 다룹니다.

거절을 부르는 네 가지 모양과 각각을 막는 자리
그림 1. 거절을 부르는 네 가지 모양과 각각을 막는 자리

거절된 대화 맥락은 다음 턴에도 그대로 조립된다

OwlAI는 LLM 호출 하나를 노드 하나로 저장하고, 다음 요청은 대화의 끝 노드에서 부모를 따라 거슬러 올라가 조립합니다(「고친 질문에 옛 답이 섞이지 않게 대화를 트리로 저장하기」). 도구 호출은 노드 N이 돌려준 항목에, 그 결과는 노드 N+1이 보낸 항목에 들어 있습니다. 정상 흐름에서는 다음 라운드가 언제나 결과를 들고 출발하므로 모든 호출에 결과가 붙습니다. 깨지는 것은 다음 라운드가 오지 않을 때입니다.

네 가지 모양은 실제 API에서 다음과 같이 거절됩니다.

모양 프로바이더가 돌려준 오류
결과 없는 도구 호출 No tool output found for function call (OpenAI)
호출과 결과 사이의 다른 메시지 tool_use ids were found without tool_result blocks immediately after (Anthropic)
다른 프로바이더가 만든 항목 Invalid 'input[1].id': '', Expected an ID that begins with 'fc' (OpenAI)
응답에만 있는 필드 Unknown parameter: 'input[0].created_by' (OpenAI)

어느 경우든 막히는 것은 요청 하나가 아니라 대화 하나입니다. 사용자가 피할 방법이 없으므로, 이 모양은 시스템이 아예 만들지 않아야 합니다.

실행하지 않은 호출에도 그 자리에서 결과를 붙인다

모델이 도구 호출을 냈는데 루프가 그 호출을 실행하지 않는 경우는 넷입니다.

  • 턴의 도구 호출 총량이나 시간 상한에 닿아 남은 호출을 실행하지 않고 마감할 때
  • 같은 인자로 반복한 호출이나 승인이 필요한 호출의 상한처럼, 실행 전 검사가 거절할 때
  • 모델이 최종 답을 내는 도구를 부르면서 다른 도구도 함께 불렀을 때
  • 사용자가 턴을 중단해 진행 중이던 호출이 멈출 때

루프는 실행하지 않기로 정한 그 자리에서 호출마다 결과를 붙입니다. 결과에는 실행하지 않았다는 사실과 사유가 들어갑니다. 그리고 이 결과를 다음 모델 호출까지 들고 가지 않고 곧바로 노드로 저장합니다. LLM을 부르지 않고 이 결과만 든 노드를 직전 노드 아래에 만들어 성공으로 기록하는 것입니다. 최종 답을 내는 도구에만은 성공 결과를 붙입니다. 실패로 붙이면 다음 턴의 모델이 제출이 실패했다고 읽고 다시 제출하려 하기 때문입니다.

다음 라운드의 입력에 얹어 보내면 노드를 따로 만들 필요가 없어 코드가 단순합니다. 그런데 상한 마감이나 중단은 대개 다음 라운드가 없다는 뜻이고, 다음 라운드가 있어도 그 사이에 워커가 죽으면 결과 없는 호출이 그대로 남습니다. 실행하지 않기로 정한 순간에 저장해야, 턴이 어떤 경로로 끝나든 모든 호출에 결과가 붙어 있습니다.

턴 도중 사용자가 보낸 말도 같은 규칙을 따릅니다. 모델이 DB를 여러 번 조회하는 동안 사용자는 「작년 말고 올해 기준으로」처럼 방향을 고칠 수 있습니다. 그 말을 도구 실행 중에 바로 끼우면 호출과 결과 사이에 메시지가 들어갑니다. Anthropic API 문서는 도구 사용 메시지와 도구 결과 메시지 사이에 어떤 메시지도 둘 수 없다고 밝힙니다(2026-11-03 확인). 그래서 사용자 메시지는 직전 라운드의 호출에 결과가 모두 붙은 뒤, 다음 모델 호출 직전에만 싣습니다(「에이전트 루프는 하나만 두고, 기능별 동작은 훅으로 끼운다」). 대가는 지연입니다. 오래 걸리는 도구가 돌고 있으면 사용자의 말은 그 도구가 끝난 뒤에 실립니다.

프로바이더마다 다른 모양은 저장본을 두고 보낼 때 맞춘다

대화 도중 모델을 바꾸면 앞선 대화 맥락에는 이전 프로바이더가 만든 항목이 섞여 있습니다. 같은 추론이라도 프로바이더마다 담는 모양이 다릅니다.

프로바이더 추론 항목 서버 측 압축 결과
OpenAI 추론 모델 암호화한 블록. OpenAI만 푼다 암호화된 블록
Claude 서명이 붙은 thinking 블록 요약문이 든 압축 블록
vLLM 평문으로 채운 추론 없음

OpenAI는 Claude가 만든 추론 항목을 ID가 비어 있어서, vLLM이 만든 추론 항목을 내용이 채워져 있어서 거절합니다. 도구 호출 ID도 같습니다. OpenAI는 자기가 붙인 fc로 시작하는 ID만 받아서, Claude가 붙인 toolu_… ID를 그대로 보내면 400이 납니다.

저장본은 이 항목들을 받은 그대로 둡니다. 저장본을 새 프로바이더 형식으로 바꿔 두면 원래 모델로 돌아왔을 때 암호화된 추론과 thinking 블록을 다시 쓸 수 없기 때문입니다(「OpenAI는 응답을 기본으로 저장한다, 대화 맥락은 직접 보낸다」). 대신 요청을 조립할 때 이번 호출의 대상 프로바이더와 모델을 함께 넘기고, 보내기 직전에 그 대상이 읽는 모양으로 바꿉니다.

  • 다른 프로바이더가 만든 추론 항목은 뺀다.
  • 다른 프로바이더가 붙인 도구 호출 ID는 떼고, 호출과 결과를 잇는 call_id만 남긴다. ID만 뺀 같은 요청은 200으로 통과했다.
  • 대상이 텍스트 전용 모델이면 앞선 메시지의 이미지를 파일 이름이 든 안내 문장으로 바꾼다.

같은 프로바이더 종류 안에서 모델을 바꾸면 보내는 요청이 바꾸기 전과 바이트 단위로 같습니다.

응답에만 있는 필드도 보낼 때 걷습니다. OpenAI Responses API가 돌려준 항목에는 status 같은 응답 전용 필드가 붙어 오는데, 받은 그대로 다음 요청에 넣으면 거절됩니다. 알려진 거부 필드만 떼는 차단 목록은 구현이 짧지만, 프로바이더가 응답에 새 필드를 더하면 그대로 새어 나갑니다. 서버 측 압축 항목의 created_by가 실제로 이렇게 400을 냈습니다. 그래서 OpenAI로 보낼 때는 항목 종류마다 허용 목록을 두고, 실제 API 왕복에서 200을 확인한 필드만 싣습니다.

허용 목록의 근거는 SDK 타입 선언이 아니라 실제 왕복입니다. OpenAI SDK의 요청 타입은 status를 선언하지만 배포된 API는 이 필드를 거부합니다. 그래서 허용 목록과 SDK 요청 타입을 대조하는 테스트가 SDK를 올려 새 필드가 생기면 실패하고, 사람이 실제 API로 다시 재서 그 필드를 정해야 통과합니다. vLLM에는 이 목록을 쓰지 않습니다. 같은 Responses API 형식이지만 추론 항목의 status와 id가 없으면 거절하고, OpenAI 경로가 싣는 암호화 필드는 오히려 받지 않습니다. 그래서 이 규칙은 프로바이더마다 따로 둡니다.

대상이 못 읽는 압축 결과는 압축 전 원본으로 펼친다

서버 측 압축 결과는 그것을 만든 프로바이더 종류만 읽습니다. 한 OpenAI 모델이 만든 압축 결과를 다른 OpenAI 모델은 정확히 요약해 냈지만, Claude에게는 읽을 수 없는 암호문입니다.

대화를 압축하면 압축한 맥락을 든 노드가 부모 없이 새로 생기고, 이 노드에는 압축하기 전 원본 노드를 가리키는 포인터가 붙습니다(「고친 질문에 옛 답이 섞이지 않게 대화를 트리로 저장하기」). 조립하다가 경로의 시작이 대상이 못 읽는 압축 결과면, 그 결과를 버리고 포인터를 따라 원본 경로를 다시 조립합니다. 원본도 압축 노드에서 시작하면 같은 규칙을 다시 적용하고, 손상된 포인터가 순환을 만들지 않게 이미 펼친 노드를 기록합니다. 포인터가 없는 옛 압축 노드는 Claude 것이면 평문 요약을 싣고, OpenAI 것이면 앞선 맥락을 잃은 채 경고 로그를 남깁니다.

여러 번 압축된 대화를 펼치면 원본 전체가 돌아와 대상 모델의 입력 상한을 넘을 수 있습니다. 그때는 가장 오래된 턴부터 덜어 냅니다. 덜어 내기 시작하는 선은 입력 상한에서 2%를 뺀 값이고, 토큰 수는 보낼 요청을 대상 프로바이더의 토큰 수 세기 API로 셉니다. 펼침은 대상 쪽 압축이 일어나기 전까지 라운드마다 되풀이되므로, 세기 API 왕복이 라운드마다 붙지 않게 한 글자를 한 토큰으로 친 값이 선 아래면 세지 않습니다. 실측에서 가장 촘촘한 토크나이저도 글자당 0.64토큰을 넘지 않았기 때문입니다. 턴 단위로 덜어 내므로 도구 호출과 결과는 함께 빠지고, 이번 질문이 든 마지막 턴은 남깁니다. 대상이 vLLM이면 서버 측 압축이 없으므로, 펼친 대화 맥락은 OwlAI의 압축기가 받아 줄입니다(「압축 임계값은 컨텍스트 윈도우가 아니라 입력 상한에서 잡는다」).

보내기 직전에 결과 없는 호출을 한 번 더 걷어 낸다

결과를 붙이는 규칙이 모든 경로를 덮는다고 장담할 수는 없습니다. 프로세스가 갑자기 끝날 수 있고, 규칙이 생기기 전에 저장된 대화도 있습니다. 그래서 조립의 마지막 단계가 두 가지를 걷어 냅니다.

  • 같은 call_id의 결과가 없는 도구 호출, 그리고 call_id가 빈 도구 호출
  • 앞의 정리로 뒤따르던 메시지·도구 호출을 모두 잃은 추론 항목

OpenAI는 혼자 남은 추론 항목도 거절해서, 호출만 걷으면 400이 다른 400으로 바뀔 뿐입니다. 이 정리가 걷는 추론은 이 단계에서 뒤따를 항목을 잃은 것뿐이라, 정상 대화 맥락의 추론은 건드리지 않습니다.

정상 대화 맥락에서 이 정리는 아무것도 바꾸지 않습니다. 걷어 낸 것이 있으면 결과를 붙이지 않는 경로가 어딘가 있다는 뜻이라, 걷어 낸 항목을 경고 로그로 남깁니다. 결과가 빠진 채 저장된 옛 대화를 다시 움직이게 하는 경로도 이 정리입니다.

설계에서 고른 것

결과 없는 호출은 보내기 직전의 정리 하나로도 막을 수 있습니다. 모든 요청이 지나는 한 곳이라 경로마다 코드를 둘 필요가 없습니다. 그 길을 주된 장치로 삼지 않은 것은 모델이 보는 맥락이 사라지기 때문입니다. 정리가 걷어 낸 호출은 대화 맥락에서 빠지고, 다음 턴의 모델은 자기가 그 도구를 불렀다는 사실도 모릅니다. 그 자리에서 붙인 결과는 실행하지 않은 이유까지 남기므로 모델이 무엇이 막혔는지 압니다. 대가는 실행하지 않는 경로가 새로 생길 때마다 결과를 붙이는 코드를 함께 넣어야 한다는 점입니다.

허용 목록은 실수의 방향을 고른 것입니다. 차단 목록에서는 모르는 필드를 보내 대화가 막히는 것이 실수이고, 허용 목록에서는 새 필드를 아직 싣지 않는 것이 실수입니다. 앞의 것은 사용자가 겪는 장애이고 뒤의 것은 기능이 늦게 붙는 일입니다. 대신 SDK를 올리거나 프로바이더가 필드를 더할 때마다 사람이 실제 API로 다시 재야 합니다.

저장본을 원본 그대로 두는 대가는 모델을 바꾼 뒤 첫 요청의 비용입니다. 다른 프로바이더로 가면 압축 전 원본이 펼쳐져 입력이 커지고, 같은 프로바이더 안에서도 캐시가 비어 앞선 대화를 처음부터 다시 읽습니다. 그래서 대화가 압축된 적이 있거나 앞선 대화가 기본 10만 토큰을 넘으면, 모델을 바꾸기 전에 확인 창이 비용이 늘어난다고 알립니다.

한 대화에서 여러 프로바이더의 모델을 바꿔 쓰고 도구 호출을 실행하지 않고 끝나는 경로가 여럿인 에이전트라면, 대화의 뜻에 속하는 규칙은 저장할 때 지키고 모양 규칙은 프로바이더마다 보낼 때 맞추는 이 나눔을 그대로 쓸 수 있습니다.