우리 API 문서가 AI 에이전트에게 실패할 수밖에 없는 행동을 지시했다
요약
AI 에이전트가 API를 호출할 때 발생하는 데이터 타입 불일치 문제를 다룹니다. 출력값은 숫자형(number)인데 입력 스키마는 문자열(string)을 요구하여 도구 체이닝이 실패하는 현상과 그 원인을 분석합니다.
핵심 포인트
- 에이전트는 인간과 달리 데이터를 가공하지 않고 출력값을 그대로 입력값으로 전달함
- 테스트 코드에서 ID를 강제로 문자열로 변환(String())하면 실제 에이전트의 오류를 놓칠 수 있음
- API 설계 시 출력 타입과 입력 스키마 간의 데이터 타입 일관성 유지가 필수적임
- MCP 등 에이전트 환경에서는 도구 간의 데이터 체이닝이 핵심적인 동작 방식임
우리는 AI 에이전트가 MCP(Model Context Protocol)를 통해 운영할 수 있는 헬프데스크를 운영하고 있습니다: 티켓 목록 조회, 스레드 읽기, 사람이 승인할 답장 초안 작성 등입니다. 지난주, 실제 에이전트가 호출을 수행하고, 이를 두 번째 호출로 체이닝(chaining)했으나 벽에 부딪혔습니다. 우리가 발견한 근본 원인은 글로 쓸 만큼 충분히 당혹스러웠는데, 왜냐하면 시중에 나와 있는 "에이전트 준비 완료(agent-ready)"된 API의 절반 정도가 동일한 버그를 가지고 있다고 생각하기 때문입니다.
버그
우리의 create_ticket 도구는 다음과 같이 반환합니다:
{ "ticketId": 47, "customerId": 18, "status": "active" }
그리고 우리의 get_ticket_context 도구는 다음과 같이 받습니다:
{ "ticketId": { "type": "string", "minLength": 1 } }
보이시나요? 데이터베이스가 정수형(integer) ID를 내보내기 때문에 ID는 JSON 숫자(number)로 나옵니다(OUT). 반면, 누군가 입력 스키마(input schema)에 z.string()이라고 작성했기 때문에 입력될 때는 문자열(string)로 들어와야 합니다(IN). 따라서 에이전트가 수행할 수 있는 가장 자연스러운 2단계 과정, 즉 한 응답에서 ID를 가져와 다음 도구로 전달하는 과정이 핸들러가 실행되기도 전에 유효성 검사(validation)에서 실패합니다:
ticketId: Expected string, received number
첫 번째 보고 이후 모든 도구를 감사(audit)했습니다. ID를 반환하는 24개 필드 모두 숫자를 내보냈습니다. ID를 받는 14개 필드 모두 문자열을 요구했습니다. 가능한 121개의 도구 체인(tool chains) 중 107개가 망가져 있었습니다.
고통스러운 부분은 이것입니다: 모든 입력 스키마의 자체 설명(description)에는 "list_tickets에 의해 반환된 ID"라고 적혀 있었습니다. 문서는 에이전트에게 실패하도록 적극적으로 지시하고 있었던 것입니다.
왜 몇 달 동안 아무도 눈치채지 못했나
인간은 원시 ID(raw ids)를 체이닝하지 않습니다. 대신 클릭을 합니다. 에이전트는 끊임없이 체이닝하며, 이를 문자 그대로 수행합니다. 그들은 당신의 출력을 가져와 문서에 명시된 그대로 당신의 입력에 집어넣습니다.
우리의 테스트 스위트(test suite)는 이를 전혀 잡아내지 못했는데, 모든 테스트가 ID를 방어적으로 감싸고 있었기 때문입니다:
const res = await runTool(draftReply, { ticketId: String(ticket.id) })
저 String()이 문제의 핵심입니다. 테스트는 주의 깊은 인간 작성자가 입력할 법한 내용을 인코딩했을 뿐, 글자 그대로 받아들이는 에이전트가 실제로 보내는 방식은 아니었습니다. 실제 모든 에이전트에게는 표면적으로 망가져 있는 상태였음에도, 테스트 스위트는 몇 달 동안 초록불(pass)을 유지했습니다.
해결책, 그리고 더 나쁜 두 가지 유혹적인 해결책
우리는 수용 범위(acceptors)를 넓혔습니다. 방출기(emitters)를 변경하는 것(예: 47 대신 "47"을 반환)은 기존의 모든 클라이언트에 대해 응답 형태(response shape)를 조용히 변경하게 되므로 고려 대상에서 제외되었습니다.
하지만 명백한 확장 방법들에는 둘 다 함정이 있습니다:
**z.union([z.string(), z.number()])**는 게시된 JSON Schema를 anyOf로 변경합니다. 만약 당신의 도구 목록이 클라이언트에게 광고된다면(MCP의 tools/list, OpenAPI 문서 등), 이는 모든 클라이언트가 확인할 수 있는 계약(contract)의 변경이며, 일부 클라이언트는 이를 제대로 처리하지 못할 것입니다.
**z.coerce.string()**은 모든 것을 수용합니다. null은 "null"이 되고, undefined는 "undefined"가 되며, 누락된 id는 깔끔한 유효성 검사 오류(validation error)에서 세 단계 더 깊은 곳에 있는 혼란스러운 "찾을 수 없음(not found)" 오류로 변질됩니다.
우리가 배포한 것은 보호된 전처리(guarded preprocess) 방식입니다:
const numericIdToString = (v: unknown) =>
typeof v === 'number' && Number.isSafeInteger(v) && v > 0 ? String(v) : v
...
양의 안전한 정수(positive safe integer)인 경우에만 다시 작성됩니다. 그 외의 모든 것은 손대지 않고 그대로 통과하므로, null, {}, 부동 소수점(floats), 음수는 여전히 기존과 동일한 메시지와 함께 실패합니다. 또한 생성된 JSON Schema는 기존의 z.string().min(1)과 바이트 단위로 일치하므로, 게시된 계약(contract)은 전혀 변하지 않습니다. 우리는 두 스키마를 렌더링하여 JSON을 비교하는 테스트를 통해 이를 검증했습니다.
우리가 현재 사용하는 체크리스트
- 자신의 출력을 다시 입력으로 사용하세요 (Round-trip your own outputs). API가 반환하는 모든
id에 대해, 어떠한 타입 변환(type massaging) 없이 동일한 엔티티를 지칭하는 모든 입력값에 해당id를 다시 전달하는 테스트를 작성하세요.String()이나Number()같은 변환을 사용해서는 안 됩니다. - 테스트 코드에서
id주변의 방어적 형변환 (defensive casts)을 검색(Grep)하세요. 각 형변환은 테스트 스위트가 버그를 정중하게 덮어주고 있는 지점입니다. - 수용자(acceptors)는 넓게, 방출자(emitters)는 결코 넓히지 마세요. 방출되는 형태(Emitted shapes)는 계약(contracts)입니다.
- 검증기(validator) 변경 전후의 생성된 스키마(schema)를 비교(Diff)하세요. "여전히 동일한 값을 검증한다"와 "동일한 계약을 광고한다"는 서로 다른 주장입니다.
- 실제 응답 하나를 직접 눈으로 읽어보세요. 이 모든 사실을 드러낸 유료 호출(paid call)은 응답 텍스트의 문법 오류도 보여주었습니다. 에이전트가 실제로 무엇을 받는지 실제로 읽어본 사람은 아무도 없었습니다.
에이전트는 당신이 경험할 수 있는 가장 문자 그대로(literal) API를 소비하는 소비자입니다. 그들은 당신의 문서를 정확하게 따르며, 이는 당신의 문서가 마침내 테스트된다는 것을 의미합니다.
우리에게 이 교훈을 준 표면을 더 살펴보고 싶다면, 에이전트 관련 내용은 deskcrew.io/agents에 문서화되어 있습니다. 무료로 읽을 수 있으며, 유료 액션(paid actions)은 무엇인가를 확정하기 전에 가격을 먼저 제시합니다.
당신의 API에는 어떤 유사한 버그가 있나요? number 대 string id 분리 문제가 제가 의심하는 만큼 흔한 일인지 진심으로 알고 싶습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기