스트리밍 tool call의 null 처리, 응답 인덱스 기준으로 대응
스트리밍 도구 호출의 후속 조각에 null이 섞일 때 응답 인덱스로 증분 필드를 처리하고 거부 분기를 보존하는 방법을 공개 수정 사례와 함께 설명했다.
중국어 원문을 AI로 번역했습니다. 고유명사와 수치는 원문 표기를 우선하며, 중요한 판단에는 아래 출처 원문을 함께 확인하세요.
모델이 이미 도구 파라미터를 생성하기 시작했고 스트림도 계속 반환되는데, Agent는 실제로 도구를 호출하기 직전에 실패했다. 끝까지 파고들어 밝혀낸 것은, 거절을 촉발한 것이 잘못된 도구 이름도 불법 파라미터도 아니고, 이후 조각에 등장한 하나의 id: null이었다는 점이다.
이런 유형의 수정은 쉽게 두 극단으로 흘러가기 쉽다. 모든 null을 계속 거부하거나, null을 보면 무조건 관대하게 통과시키는 것이다. 우리가 최종적으로 택한 것은 세 번째 방법이다. 바로 동일한 인덱스에 이미 비어 있지 않은 호출 신원이 확립된 경우에만, 이후의 null을 "신원을 갱신하지 않음"으로 해석하는 것이다.
이것은 open-etf-agent v0.15.2의 좁은 범위의 호환성 수정이다. 도구 ID를 새로 발명하지도, 도구 권한 부여를 바꾸지도, 전체 호출에 대한 최종 검증을 건너뛰지도 않았다.
一. 도구 호출은 증분 이벤트이지, 프레임마다 완전한 객체가 아니다
스트리밍 Chat Completions에서 하나의 도구 호출은 여러 이벤트에 분산될 수 있다. 애플리케이션은 이번 응답 내의 index로 현재 조립 중인 호출을 찾고, 함수 이름과 파라미터 문자열을 이어 붙인 다음, 최종적으로 완전한 제안을 검증 및 실행 체인에 넘긴다.
아래는 간략화한 합성 조각으로, 외부 SSE, choices, 종료 이벤트를 생략한 것이며 원 공급자의 페이로드가 아니다:
{"index":0,"id":"call-a","function":{"name":"read_page","arguments":"{\"page\":"}} {"index":0,"id":null,"function":{"arguments":"1}"}}
두 번째 조각은 여전히 인덱스 0에 속한다. 함수 파라미터는 {"page":에서 계속해서 {"page":1}로 변한다. 진짜 결정해야 할 것은, 뒤따르는 null이 앞서 이미 확정된 call-a를 뒤집어야 하느냐이다.
구현에서는 판단이 대략 "id 필드가 제공되기만 하면 문자열이어야 하고, 충돌해서는 안 된다"에 가까웠다. 이것은 완전한 객체 검증에는 직관적이지만, 증분 이벤트에서의 "신원 갱신 없음"까지도 새로운 신원 값으로 취급했다.
핵심은 null 자체가 아니라, 입력 값과 기존 상태의 조합에 있다.
二. 먼저 허용되는 상태 전이를 명확히 그린다
수정의 전제는 상어 응답 내의 동일한 도구 인덱스이며, 우연히 도구 이름이 같다는 것도 아니고 이전 라운드 요청에서 특정 ID를 썼다는 것도 아니다.
기존 상태와 새 조각·이번 처리·기존 비어 있지 않은 ID, 이후 id 생략 → 원래 ID 유지·기존 비어 있지 않은 ID, 이후 id가 null → 원래 ID 유지, 이것이 이번에 새로 추가된 호환·기존 비어 있지 않은 ID, 이후 동일한 문자열 제공 → 원래 ID 유지·기존 비어 있지 않은 ID, 이후 다른 문자열 또는 빈 문자열 제공 → 신원 충돌로 거부·첫 조각 또는 새 인덱스에 확립된 비어 있지 않은 ID가 없는데 null이 제공됨 → 거부, 무에서 신원을 창조할 수 없음·id가 숫자, 불리언, 배열 또는 객체 → 계속 거부
원래 구현은 초기 빈 문자열이 이후에 유효한 문자열로 채워지는 것을 허용했는데, 이 기존 동작은 이번 수정으로 달라지지 않는다. 그러나 빈 문자열은 "확립된 비어 있지 않은 신원"으로 치지 않으며, 그것으로 null 후속 조각에 청신호를 줄 수 없다. 완전한 제안은 여전히 최종 검증을 통과해야 한다.
새로 추가된 예외를 별도로 표현하면, 판단은 아주 작아진다:
const hasEstablishedId = typeof call. id === 'string' && call. id . length > 0 ; const keepsEstablishedId = part. id === null && hasEstablishedId; if (part. id !== undefined && !keepsEstablishedId) { validateOriginalIdRules (call. id , part. id ); call. id = part. id ; }
이것은 정제된 교육용 의사코드로, validateOriginalIdRules는 기존의 타입 및 충돌 검사를 나타낸다. 이것은 전체 스트림 파서를 단독으로 대체할 수 있는 구현이 아니며, 파라미터, 권한 또는 실행 검증을 담당하지도 않는다.
三. 호환은 조립 계층에서 일어나지만, 신뢰가 함께 확대되어서는 안 된다
만약 "null 허용"을 범용 정리 규칙으로 작성하면, 예컨대 누락된 ID에 자동으로 랜덤 값을 채워 넣으면, 프로그램은 더 이상 오류를 내지 않을 수 있겠지만, 공급자가 제공한 적 없는 호출 신원을 만들어 낸다. 이후의 결과 연관, 중복 제거, 진단이 원래 근거를 잃게 된다.
또 다른 위험한 방법은 직전 호출의 ID를 가져다 현재 조각을 채우는 것이다. 병렬 도구는 교차하여 반환될 수 있어, 서로 다른 인덱스의 파라미터를 잘못된 호출에 붙이게 된다.
올바른 위치는 이번 응답, 현재 인덱스에 대응하는 조립 상태다. 이미 확립된 그 ID만 유지하고, 인덱스를 넘어 빌려 오지도, 요청을 넘어 상속하지도, 충돌 시 "더 합리적으로 보이는" 값을 하나 고르지도 않는다.
흐름은 차례로 이번 응답 인덱스를 찾고, 기존 신원을 검사한다. 확립된 신원의 null 후속 조각은 그 신원을 유지하고, 다른 입력은 기존 신원 검사를 계속하며, 불법 신원은 거부된다. 통과한 뒤에야 파라미터를 누적하고, 완전한 제안을 검증하며, 원 Host의 권한 부여에 넘긴다. 불법 제안은 디스패치되지 않는다.
이후 null이면서 이미 비어 있지 않은 ID가 있을 때만 신원 유지 분기를 타고, 나머지 경우는 기존 검사를 계속한다. 최종 검증은 여전히 조립 후의 파라미터와 호출 신원을 검사하며, 그 후 원 Host가 도구의 실행 승인 여부를 결정한다. 호환 조각은 실행 허가가 아니다.
스트림이 완전히 끝났는지도 또 하나의 조건이다. 이 수정은 반쪽 응답, 비정상 종료 또는 지원 범위를 벗어난 프로토콜을 실행 가능한 호출로 위장하지 않았다.
四. 고친 것이 문제이지 보호를 지운 것이 아님을 어떻게 증명할 것인가
최소 단위 테스트는 두 조각을 직접 먹여 ID가 여전히 call-a임을 검증할 수 있다. 하지만 이것만으로는 충분하지 않다. 우리가 진짜 관심을 두는 것은 이 호출이 원 게이트웨이를 통과해 런타임에 들어가 원래의 사용자 작업을 완수할 수 있느냐이다.
따라서 회귀는 하나의 합성된 완전한 체인을 사용한다. 자료를 읽고, 보고서를 저장하고, 다시 원 런타임을 통해 작업을 종료한다. 대조군의 후속 조각은 ID를 생략하고, 목표군의 후속 조각은 null을 담는다. 양쪽은 최종적으로 동일한 호출 신원을 유지하고 동일한 보고서 내용을 얻는다.
동시에 거부 분기도 커버해야 한다. 최초 null, 새 인덱스 null, 다른 문자열, 불법 타입은 정상 사례가 고쳐졌다고 해서 받아들여지기 시작해서는 안 된다. ID 후속 조각이 합법적이더라도, 파라미터를 이어 붙인 결과가 유효한 JSON이 아니거나 최종적으로 중복 호출 ID가 나타나도 여전히 실패해야 한다.
이것은 상태 전이 테스트에 매우 적합한 유형의 호환 문제다. 단위 테스트 입력은 "필드에 어떤 값이 있는지"뿐만 아니라, "이 값이 도착할 때 이미 어떤 상태가 축적되어 있었는지"도 포함해야 한다.
후속의 독립적인 실제 모델 작업은 통합 후의 정상 읽기, 게시, 복구 경로를 검증했지만, 그것으로 그 스트림들에 실제로 null 후속 조각이 나타났음을 증명할 수는 없다. 목표 경계는 통제된 null 반례와 정상 사례로 증명되고, 실제 작업이 검증하는 것은 통합이 다른 동작을 망가뜨리지 않았다는 점이다. 두 종류의 증거는 서로를 사칭할 수 없다.
五. 오류 진단은 원본 파라미터를 보존할 필요가 없다
이전의 여러 신원 문제가 하나의 복합 오류로 묶여 분류될 수 있었다. TOOL_FRAGMENT_ID만 보아서는 첫 조각에 신원이 없는 것인지, 후속 ID가 충돌한 것인지, 아니면 타입이 지원되지 않는 것인지 아직 구분할 수 없다.
하지만 이런 상황들을 구분하기 위해 실제 도구 파라미터나 사용자 정보, 혹은 전체 모델 출력을 로그에 기록할 필요는 없다. 제한적 진단은 오류 범주, 입력 ID 타입, 이전에 이미 비어 있지 않은 ID가 있었는지, 문자열이 비어 있는지 등 제한된 정보만 남길 수 있다.
예를 들어 "입력 타입이 null이고 기존 신원이 참임"은 이번 호환성 문제를 특정하는 데 도움이 되며, "입력이 문자열이고 기존 신원이 참이며 충돌함"은 계속 원래의 거부 경로로 가야 한다. 이 둘은 같은 수정 이유가 아니다.
진단이 상태 관계를 더 잘 기술할수록, 원본 내용을 유출하여 현장을 재구성하는 데 의존할 필요가 줄어든다.
六. 하나의 수정이 모든 과거 장애를 다 처리할 수는 없다
이번 조사에서는 실제 자격 검증에서 "기존 ID 이후 null이 나타남"에 대한 제한적 진단 증거를 확보했으며, 이는 이 구체적 수정을 제안하고 검증하기에 충분하다.
더 이전의 복합 신원 오류는 이와 같이 완전한 분류 정보가 없어, 돌아가서 그것들 역시 모두 null 때문에 발생했다고 선언할 수는 없다. 오래된 실패는 여전히 오래된 실패이며, 새 버전의 작업이 통과했다고 해서 기록을 다시 쓰지는 않는다.
마찬가지로, 어떤 벤더에서 이러한 스트림 형태가 나타났다고 해서 모든 벤더의 프로토콜이 null 이어붙이기 조각을 합법이라고 보장하는 것은 아니다. 여기서 구현한 것은 선행 조건이 있고 기존 신원을 유지하는 호환 규칙이지, 임의의 응답에 대한 보편적 허용이 아니다.
결론: 좋은 호환 수정은 조건부 설명을 하나 더하는 것이다
이번 소스 변경은 아주 작으며, 진짜로 고민해야 할 것은 어떤 상태 전이가 불변 조건을 유지할 수 있는가이다. 즉 기존 신원이 지워지지 않고, 서로 다른 호출이 섞이지 않고, 결여된 신원이 위조되지 않고, 완전한 제안은 여전히 검증되어야 한다.
다음 번 프로토콜 차이를 만나면 먼저 물을 수 있다. 그것은 완전한 객체 필드인가, 아니면 증분 업데이트인가? 어떤 기존 상태가 그것을 안전하게 만드는가? 어느 계층에서 그것을 해석해야 권한, 실행 또는 복구 규칙을 함께 완화하지 않을 수 있는가?
호환은 검증을 꺼버리는 것이 아니라, 원래의 사실을 바꾸지 않으면서 원래 다루지 못했던 하나의 표현을 정확히 받아들이는 것이다.
구현과 회귀는 공개 저장소의 스트림 조립 코드, null 이어붙이기 조각 회귀 및 버전 검증 설명에서 확인할 수 있다.
더 읽기: 도구 호출에서 모델 입력까지 · 관측 가능한 전경에서의 Trace 완전성
open-etf-agent에 Star를 하나 눌러 주시고, 소스 코드와 함께 이 경계들을 읽어 보시기 바란다.
원문 읽기: 하나의 null이 어떻게 Agent 도구 호출을 실패하게 하는가.