메타데이터
메타데이터 (Metadata)
이벤트, 메시지, 도구 호출에 추가 정보를 붙이는 방법을 다루는 페이지예요. 토큰 사용량, 트레이스 ID, 종료 사유처럼 대화와 함께 운반해야 하는 모든 것을 메타데이터로 달아 둘 수 있습니다.
출처: 문서
본문
메타데이터는 프로토콜에 추가 정보를 붙이는 공식적인 방법이에요 — 토큰 사용량, 트레이스 ID, 종료 사유(finish reason), 그리고 애플리케이션이 대화와 함께 운반해야 하는 어떤 것이든 담을 수 있습니다.
메타데이터가 생기기 전에는, 생산자(producer)가 선언되지 않은 속성을 이벤트에 매달고 소비자(consumer)가 그걸 그대로 전달해 주길 바랐어요. 그래서 1.0에서 구독자에게 도달하는 미지의 속성은 제거됐습니다. 메타데이터가 그 자리를 선언되고 타입이 지정된 형태로 대체하는데, 소비자는 이를 반드시 운반해야 합니다.
어디에 있는가 (Where it lives)
네 곳이며, 모두 선택사항입니다:
metadata를 담는 곳 |
설명 |
|---|---|
| 모든 이벤트 | 기본 이벤트(베이스 이벤트)에 한 번 선언되므로 모든 이벤트 타입이 갖고 있어요 |
| 모든 메시지 | 일곱 가지 역할 전부: developer, system, assistant, user, tool, activity, reasoning |
| 모든 도구 호출 | 도구 호출은 메시지가 아니에요 — 도구 호출을 참고하세요 |
| 모든 재개 항목 | 요청 필드라서 아무것도 병합되지 않아요 — 재개 항목을 참고하세요 |
형태 (Shape)
이 객체는 키 기준으로 열려 있어요(open by key). 키 아래에는 어떤 JSON 값이든 허용되며 null도 포함됩니다. 그 안에 무엇을 넣을지에 대한 스키마는 없어요.
{
type: EventType.TEXT_MESSAGE_END,
messageId: "msg_123",
metadata: {
"ag-ui": { usage: { input: 1200, output: 340 } },
finishReason: "stop",
traceId: "abc-123",
retries: 0,
labels: ["experimental"]
}
}
객체 자체는 없거나 객체여야 하며 — 절대 null이 아니에요. 빈 객체는 유효하며 "말할 게 없다"는 뜻인데, 이는 생략하는 것과 같습니다.
참고: 생산자는 절대
"metadata": null을 내보내지 않아요. 값이 없는 선택 필드는 모든 공식 SDK에서 JSON에서 완전히 생략됩니다. TypeScript 클라이언트는 이를 강제하며null메타데이터 객체를 거부해요 — 일부 오래된 선택 필드와 달리, 메타데이터에는 용인해 줄 레거시 생산자가 없기 때문입니다. 비대칭성에 주의하세요: 키 아래의null값은 의미 있는 데이터라 언제나 보존됩니다. 객체 전체를 대신하는null만이 유효하지 않습니다.
예약 키 (The reserved key)
ag-ui 키는 AG-UI 자신의 용도로 예약되어 있어요. 그 밖의 모든 키는 여러분의 것입니다.
런타임에서 ag-ui에 쓰는 것을 거부하는 처리는 없어요 — 그걸 강제하면 open-by-key 규칙과 모순되기 때문입니다 — 하지만 미래 버전에서 AG-UI가 자신의 값을 그곳에 넣을 수 있으니 손대지 말아야 할 영역으로 취급하세요.
메시지로의 병합 (Merging into messages)
메시지는 일련의 이벤트로 조립되며, 흥미로운 값들은 끝에서야 알 수 있어요. 프로바이더는 생성을 끝내기 전까지 자신의 토큰 사용량을 알지 못합니다. 그래서 소비자는 시퀀스가 도착하면서 각 이벤트의 메타데이터를 그 이벤트가 만드는 메시지에 병합합니다.
이 설명은 스트림에서 메시지를 조립하는 클라이언트(예: TypeScript 클라이언트)를 가리켜요. .NET 클라이언트는 AG-UI 메시지를 조립하지 않습니다 — 아래 SDK 노트를 참고하세요.
규칙은 키 단위로 마지막 쓰기가 이기는 것(last write wins)입니다:
// TEXT_MESSAGE_START metadata: { source: "openai", stage: "start" }
// TEXT_MESSAGE_CONTENT metadata: { stage: "content" }
// TEXT_MESSAGE_END metadata: { stage: "end", usage: { output: 340 } }
message.metadata
// { source: "openai", stage: "end", usage: { output: 340 } }
source는 나중에 아무도 설정하지 않았으므로 살아남았어요. stage는 마지막으로 쓰인 값에서 끝났습니다. usage는 끝에 가서야 도착했는데, 그게 바로 핵심입니다.
값은 교체되지 혼합되지 않아요 (Values are replaced, never blended)
병합은 절대 재귀하지 않습니다. 배열이나 객체를 가진 키는 통째로 교체됩니다:
// earlier: { tags: ["a", "b", "c"] }
// later: { tags: ["z"] }
// result: { tags: ["z"] } not ["a", "b", "c", "z"]
이 규칙은 ag-ui 아래에서도 동일하게 적용됩니다. 중첩 구조에 더하고 싶다면, 완전한 새 값을 보내세요.
병합되지 않는 것 (What does not merge)
메시지를 만들지 않는 이벤트의 메타데이터는 그 이벤트에 남고 결코 메시지에 도달하지 않아요:
RUN_STARTED,RUN_FINISHED,RUN_ERRORSTEP_STARTED,STEP_FINISHEDSTATE_SNAPSHOT,STATE_DELTARAW,CUSTOMREASONING_START,REASONING_END,REASONING_ENCRYPTED_VALUE
MESSAGES_SNAPSHOT은 특수한 경우예요. 그 안의 메시지들은 자신만의 메타데이터를 이미 붙인 채 도착하므로, 이벤트 자신의 메타데이터는 그 어떤 메시지에도 병합되지 않습니다.
팁: 실행 수준 합계를
RUN_FINISHED에 두는 것은 괜찮고 종종 맞는 선택이에요 — 다만 메시지에서 그걸 기대하지 말고 이벤트에서 읽으면 됩니다. 특정 메시지에 두고 싶다면, 그 메시지의*_END이벤트에 보내세요.
도구 호출 (Tool calls)
도구 호출 이벤트 — TOOL_CALL_START, TOOL_CALL_ARGS, TOOL_CALL_END — 은 이를 소유한 어시스턴트 메시지가 아니라 도구 호출에 병합됩니다.
assistantMessage.toolCalls[0].metadata
// { provider: "anthropic", latencyMs: 84 }
이유는 여러 도구 호출이 하나의 부모 어시스턴트 메시지를 공유할 수 있기 때문이에요. 이들의 메타데이터를 전부 부모에게 접으면, 그 결과가 호출들이 실제로 섞인 순서에 따라 달라지게 됩니다 — 그리고 스트림 변환(transform)은 그 순서를 바꿀 수 있어요. 각 도구 호출에 자신만의 메타데이터를 주면 공유 목적지가 사라지므로, 스트림이 어떻게 처리되든 결과는 같습니다.
TOOL_CALL_RESULT는 다릅니다. 그것은 도구 메시지를 만들기 때문에, 그 메타데이터는 다른 메시지 생성 이벤트처럼 그 메시지로 병합됩니다.
재개 항목 (Resume entries)
RunAgentInput.resume의 각 항목 — 인터럽트 결과로 끝난 실행 후 클라이언트가 보내는 인터럽트별 응답 — 은 자신만의 메타데이터를 가집니다. 사람의 결정이 조작되지 않았다는 것을 증명하는 서명 같은 응답에 대한 봉투(envelope) 데이터나 라우팅 키를 담아요. 에이전트가 물어본 답은 payload에 들어갑니다.
재개 항목은 요청 필드이지 스트림에서 조립되는 것이 아니므로 병합 의미론이 없어요 — 아무것도 쌓이지 않습니다. 전체 재개 계약은 인터럽트 (Interrupts)를 참고하세요.
전송 (Transports)
JSON 위에서 — SSE가 운반하고 모든 SDK가 읽고 쓰는 것 — 메타데이터는 평범한 객체이고, 모든 값 형태가 정확히 라운드트립합니다: null, 배열, 중첩 객체, 문자열, 숫자.
바이너리 protobuf 형식은 이를 google.protobuf.Struct로 운반하는데, 그 값 타입은 진짜 null 케이스가 있어서 키 아래의 null이 근사치가 아니라 그대로 보존되고, 없는 객체는 빈 객체와 구별된 채 유지됩니다. 여기에는 두 가지 주의점이 있고, 둘 다 메타데이터에만 국한된 것은 아닙니다:
- 모든 이벤트가 protobuf 표현을 갖는 것은 아니에요. 유선 형식은 이벤트 타입의 부분집합만 다룹니다 — tool result, activity, reasoning, 그리고 폐기된 thinking 이벤트는 protobuf 메시지가 없어서, 메타데이터가 있든 없든 그 전송을 아예 건널 수 없습니다. .NET 인코더는 이를 통째로 거부하고, TypeScript 인코더는 빈 페이로드를 만들어 디코딩이 거부합니다. 어느 쪽이든 이벤트는 도착하지 않으므로, 그 이벤트들에 바이너리 전송을 의존하지 마세요.
- 숫자는 IEEE-754 double입니다.
google.protobuf.Value는 모든 숫자를 double로 모델링하므로, 2^53을 넘는 정수는 라운드트립 중 정밀도를 잃습니다. 이는 형식의 속성이며state와 다른 동적 페이로드에도 동일하게 적용됩니다.
인코더 간에 바이트 단위 출력은 보장되지 않습니다: Struct는 map<string, Value>이고, protobuf 맵 항목 순서는 표준적(canonical)이지 않아요. 보장되는 것은 양쪽이 같은 값으로 디코딩한다는 것입니다. 실제로 TypeScript와 .NET 인코더는 크로스 언어 스위트의 모든 픽스처에서 동일한 바이트를 내보내며 그게 그곳에서 단언되지만, 그에 의존하지 마세요.
SDK 참고 (SDK reference)
참고: 메타데이터는 모든 SDK에서 키 기준으로 열려 있어서 타입이 의도적으로 관대합니다. 내용물을 JSON 직렬화 가능하게 유지하는 것은 여러분 책임이에요 — 함수나
bigint는 타입 수준에선 받아들여지지만, 인코딩할 때 실패합니다.
TypeScript — 이벤트, 메시지, 도구 호출, 재개 항목에 metadata?: Record<string, any>. 직접 메시지를 조립한다면 @ag-ui/core에서 mergeMetadata(existing, incoming)이 내보내지며, 예약 키용 AGUI_METADATA_KEY도 있습니다.
Python — metadata: Optional[Dict[str, Any]]. 베이스 모델은 모든 직렬화 경로에서 설정되지 않은 선택 필드를 생략하므로, 없는 객체는 null로 내보내지지 않고 생략됩니다 — exclude_none=True가 필요 없어요. Metadata와 AGUI_METADATA_KEY는 ag_ui.core에서 내보내집니다.
.NET — BaseEvent, AGUIMessage, AGUIToolCall, AGUIResume에 JsonElement? Metadata, 그리고 예약 키로 AGUIMetadata.ReservedKey.
이것은 유선 수준 필드(wire-level field) 예요. JSON과 protobuf를 통해 충실히 라운드트립하므로, RunAgentInput을 읽는 서버나 스트림을 디코딩하는 클라이언트는 그것을 온전히 봅니다. AGUIMessage.Metadata는 MESSAGES_SNAPSHOT 안의 메시지에서 그것을 운반해요. encryptedValue와 AGUIToolMessage.Error가 이미 행동하는 방식과 맞춰, Microsoft.Extensions.AI의 ChatMessage에는 의도적으로 드러내지 않습니다.
AGUIChatClient로 스트림을 소비한다면, 그게 실제로 무엇을 뜻하는지 유의하세요: EventStreamConverter는 TEXT_MESSAGE_START, TEXT_MESSAGE_END, TOOL_CALL_START, TOOL_CALL_ARGS에 대한 ChatResponseUpdate를 내보내지 않으므로, 그 이벤트들의 메타데이터 — 위에서 권장한 대로 TEXT_MESSAGE_END에 둔 usage 포함 — 은 RawRepresentation에도 도달하지 않아요. 고수준 채팅 클라이언트가 아니라 원시 이벤트 스트림에서 읽으세요.
압축 (Compaction)
TypeScript 클라이언트의 compactEvents는 스토리지나 재생을 위해 일련의 스트리밍 이벤트를 더 적은 이벤트로 줄이면서, 각 스트림의 이벤트가 함께 유지되도록 의도적으로 순서를 재배열합니다.
메타데이터는 그 자체로 새로운 순서 민감성을 추가하지 않아요. 모든 병합 목적지는 고유합니다 — 각 메시지는 자신만의 목적지를 갖고, 각 도구 호출은 공유할 수 있는 부모로 접지 않고 자신만의 것을 갖습니다 — 그래서 같은 대상에 병합되는 두 이벤트는 서로에 대해 결코 재배열되지 않습니다.
다만 압축의 재배열은 일반적으로 의미 보존적이지 않으며, 이는 메타데이터보다 오래된 이야기예요. 스트림을 인터럽트하는 이벤트는 그 뒤에 내보내지므로, 메시지 도중에 도착한 MESSAGES_SNAPSHOT은 그 메시지 자신의 이벤트 뒤에 재생되고 그것이 만든 것 — 병합된 메타데이터만큼이나 추가된 콘텐츠 — 을 덮어씁니다. 정확한 재생 동등성에 의존한다면, 열린 스트림에 스냅샷을 끼워 넣지 마세요.
더 알아보기 (Learn more)
- 직렬화 (Serialization) — 이벤트 스트림 저장과 재생
- 도구 (Tools) — 도구 호출과 메타데이터
- 메타데이터 (1.0 스펙) — 스펙 버전의 메타데이터 정의