도구 호출 뒤 문장이 깨진 이유: Pydantic AI 2.43의 스트림 경계 수정
정상 응답인데 프런트엔드가 멈췄다
LLM이 텍스트를 보내다가 도구를 호출하고, 같은 응답에서 다시 텍스트를 이어 보내는 흐름은 이상하지 않다. 문제는 화면으로 전달되는 이벤트의 수명주기가 어긋날 때 생긴다. Pydantic AI 이슈 #8208의 재현 순서는 text → tool call → text였다. 모델과 도구는 정상 동작했지만 Vercel AI SDK v6 어댑터는 두 번째 텍스트를 이미 끝난 텍스트 조각의 delta로 내보냈다. 프런트엔드는 열린 적이 없거나 이미 닫힌 ID에 delta가 왔다며 오류를 냈다.
이 결함은 2026년 9월 12일 00:54 UTC에 공개된 Pydantic AI 2.43.0에서 수정됐다. 릴리스 노트에는 OpenAIChatModel이 도구 호출 뒤 텍스트 경계를 보존한다고 적혀 있다. 짧은 한 줄이지만, 에이전트 스트리밍을 운영하는 쪽에서는 꽤 구체적인 계약 변경이다. 토큰 문자열만 순서대로 전달하면 되는 것이 아니라, 각 조각의 시작과 delta, 끝이 올바른 ID로 닫혀야 한다.
그림 1. 문자열 순서가 맞아도 닫힌 text part ID에 delta가 오면 UI stream protocol은 실패한다. 출처: github.com/pydantic/pydantic-ai/issues/8208.
고정된 content ID가 오래 살아남았다
2.42 이전 코드의 핵심 문제는 단순했다. OpenAIStreamedResponse._map_text_delta가 모든 텍스트 delta에 같은 vendor part ID인 content를 썼다. 첫 텍스트가 시작될 때는 맞는 선택이다. 하지만 도구 조각이 시작되면 상위 스트림은 앞선 텍스트 조각을 끝낸다. 그 뒤 도착한 텍스트까지 다시 content로 찾으면 parts manager는 이미 닫힌 첫 조각을 가리킨다.
이슈의 최소 재현에서는 이벤트가 PartStart(0) → PartEnd(0) → PartStart(1, tool) → PartDelta(0) 순으로 나왔다. 마지막 delta의 index 0은 앞에서 끝난 상태다. Vercel 어댑터가 이를 text-delta로 바꾸면서 화면 쪽 검사가 실패했다. 보고자는 OpenAI 호환 Qwen endpoint에서 현상을 봤지만, 공개 재현은 실제 provider cassette가 아니라 제어된 SSE 응답을 사용했다. 따라서 "모든 OpenAI 응답이 이 순서를 낸다"고 확대할 근거는 없다.
그림 2. 제어된 재실행에서 invalid delta index는 Pydantic AI 2.36.0의 0 한 건에서 2.43.0의 0건으로 바뀌었다. 출처: github.com/pydantic/pydantic-ai/pull/8235.
2.43은 ID를 무조건 바꾸지 않는다
PR #8235는 현재 텍스트용 _vendor_part_id 상태를 추가했다. 도구의 PartStartEvent가 들어오고, 그 ID가 지금 TextPart를 가리킬 때만 ID를 새 값으로 회전한다. 다음 텍스트는 새 ID로 시작하므로 닫힌 조각에 붙지 않는다. 반대로 도구가 없는 연속 텍스트는 한 조각으로 유지된다.
여기서 조건문이 중요하다. 생각 과정을 <think>...</think> 태그로 표현하는 OpenAI 호환 모델도 같은 텍스트 경로를 쓴다. 도구 호출이 왔다고 ID를 무조건 회전하면, 도구 뒤의 </think>가 앞선 ThinkingPart를 닫지 못하고 사용자에게 보이는 일반 텍스트로 샐 수 있다. 수정 코드는 현재 조각이 TextPart일 때만 회전한다. 새 회귀 테스트도 이 경우를 따로 확인한다.
Chat Completions의 과거 메시지는 assistant 메시지 하나에 content 문자열 하나를 둔다. Pydantic AI 내부에서 여러 TextPart로 나뉜 응답을 다시 과거 메시지로 보낼 때는 두 줄바꿈으로 합친다. 화면 스트림의 조각 경계와 다음 모델 요청의 문자열 표현은 같은 문제가 아니다. 이번 수정은 전자를 고치면서 후자의 기존 변환을 유지한다.
그림 3. 2.43.0은 현재 조각이 TextPart일 때만 ID를 회전해 닫힌 텍스트 재사용과 think 닫기 태그 노출을 함께 피한다. 출처: github.com/pydantic/pydantic-ai/blob/86b250f3d5e26f4cb25617a82904c720f690193d/pydantic_ai_slim/pydantic_ai/models/openai.py.
두 버전을 같은 입력으로 다시 돌려봤다
공개 이슈의 제어된 SSE 모양을 별도 임시 환경에서 재실행했다. Python 코드는 실제 자격 증명 없이 httpx2.MockTransport와 정규 도구 하나를 사용했다. Pydantic AI 2.36.0에서는 닫힌 index 0에 PartDeltaEvent가 다시 왔고, 검사 결과 invalid_delta_indices=[0]이었다. 같은 입력을 2.43.0에 넣자 텍스트 뒤 도구, 그 뒤 새 텍스트가 index 0, 1, 2로 각각 시작하고 끝났다. 닫힌 조각에 대한 delta는 0건이었다.
이 대조는 공개 이슈의 스트림 수명주기 결함과 2.43.0 수정 결과를 재현한다. 실제 Qwen 서비스나 브라우저 UI를 돌린 시험은 아니며, 네트워크 지연과 provider별 chunk 병합도 측정하지 않았다. 또 2.43.0 릴리스에는 다른 변경도 함께 들어갔다. 이 글의 결과를 릴리스 전체 안정성 평가로 읽으면 안 된다.
어댑터를 고칠 때 확인할 네 가지
첫째, 문자열 내용만 snapshot으로 비교하지 말고 조각별 상태 전이를 검사해야 한다. Vercel의 현재 AI SDK UI stream protocol 예시도 같은 ID에 대해 text-start, text-delta, text-end 순서를 명시한다. text-start 이전 delta, text-end 이후 delta, 중복 start를 모두 실패로 잡는 작은 상태 기계가 유용하다. 둘째, text → tool → text를 별도 fixture로 둔다. 보통의 text → tool 테스트는 후속 텍스트가 없어서 이 결함을 놓친다.
셋째, thinking 태그와 도구 호출을 같은 fixture에 넣어야 한다. 일반 텍스트 결함을 고친 패치가 생각 과정 닫기 태그를 화면에 노출할 수 있기 때문이다. 넷째, 내부 조각 경계와 대화 기록 직렬화를 나눠 검증해야 한다. UI에는 두 텍스트 조각이 필요해도 Chat Completions history에는 하나의 문자열로 합쳐질 수 있다.
버전 업그레이드가 당장 어렵다면 provider가 같은 응답에서 도구 뒤 텍스트를 보내는지 로그의 이벤트 종류와 ID만으로 먼저 확인할 수 있다. 원문이나 도구 인자를 통째로 남길 필요는 없다. 다만 애플리케이션에서 임의로 두 번째 텍스트를 버리는 우회는 결과 손실을 만들 수 있고, 닫힌 ID를 다시 여는 우회는 다른 SDK 계약을 깨뜨릴 수 있다. 가능한 해결은 2.43.0 이상으로 올린 뒤 해당 provider의 실제 chunk fixture를 추가하는 쪽이다.
작은 경계 하나가 에이전트 전체를 멈춘다
이번 결함은 모델 답변의 사실성이나 도구 실행 결과와 무관했다. 데이터 자체는 도착했는데, 스트림 조각의 ID가 잘못 재사용됐다. 에이전트 UI를 연결할 때는 토큰과 tool call만 세지 말고, 각 part의 시작과 종료도 외부 계약으로 다뤄야 한다.
Pydantic AI 2.43.0의 수정은 새 공개 API를 추가하지 않는다. 대신 이미 종료된 텍스트를 다시 쓰지 않는 규칙을 구현에 반영했다. 실무에서 챙길 결론도 소박하다. provider SDK, 에이전트 프레임워크, UI 어댑터 사이에는 문자열보다 긴 수명주기 계약이 있다. 그 계약은 도구 호출 앞뒤를 모두 넣은 테스트로만 보인다.
참고 자료
- Pydantic AI: 2.43.0 릴리스
- Pydantic AI 이슈: OpenAI Chat streams emit text deltas for an already-ended part after a tool call #8208
- Pydantic AI 구현 PR: Preserve text part boundaries after tool calls in OpenAIChatModel #8235
- Pydantic AI 2.43.0 고정 소스: OpenAI 스트림 구현, 회귀 테스트
- Vercel AI SDK UI: Stream Protocols



댓글
댓글 쓰기