An Agent That Counts My Receipts, Not My Claims
주장이 아니라 확인 가능한 근거를 세는 에이전트
Sanity에 저장된 120개 문서를 조회하는 에이전트가 기록된 주장과 실제로 확인한 근거를 분리합니다. Knowledge Base의 식별자 바인딩 한계를 측정한 뒤, 정확한 필드 조회는 GROQ로 라우팅하고 검색 실패와 불확실성을 답변에 남깁니다.
- 주제
AI 요약
Sanity Challenge의 ‘실제 콘텐츠를 조회하는 에이전트’ 과제로 만든 프로젝트입니다. 공개 Sanity 레코드에는 문서 120개가 들어 있으며, 기사 74개, finding 14개, 사람 10명, patch 3개, claim 19개로 구성됩니다. 작성자는 에이전트가 저장된 주장을 그대로 확정하지 않고, 실제로 찾은 근거와 데이터의 기준일을 함께 제시하도록 설계했습니다.
기록된 사람과 확인된 근거를 분리합니다
예를 들어 B8 결함에는 외부 엔지니어 세 명이 기록되어 있습니다. 하지만 댓글 트리에서 위치를 확인할 수 있는 근거는 Pushpendra의 댓글 3ee98 하나뿐입니다. Vinh Nguyen과 quashudev의 댓글은 검색한 트리에서 찾지 못했습니다. 에이전트는 ‘세 명이 찾았다’는 기록을 ‘확인 가능한 근거 세 개’로 바꾸지 않고, B8에 locatable comment가 하나 있다고 답합니다.
이 구분은 다른 질문에서도 적용됩니다. B1의 수정 사항은 공개 브랜치에 올라갔지만 origin/main에는 아직 병합되지 않았으므로 inMain: false로 보고합니다. finding 14개 가운데 12개는 외부 엔지니어 8명이 제기했고, 2개는 내부 기록입니다. 내부 기록 중 하나는 코딩 에이전트 Ka'el이 만들었고 다른 하나는 내부 감사에서 나왔습니다. 구현된 finding은 2개이며 12개는 아직 구현되지 않았습니다. 기록이 만들어진 뒤 브랜치에서 어떤 일이 일어났는지는 알 수 없다고 답변에 명시합니다.
답변 계약과 검색 실패 처리
배포된 데모는 로그인이나 사용자 키 입력 없이 다섯 개 버튼으로 질문을 받습니다. 답변에는 항상 다섯 필드가 들어갑니다.
ANSWER: 평이하게 적은 주장입니다.SOURCES: 읽은 자료와 레코드에 저장된 source URL입니다.EVIDENCE DATE: 현재 날짜가 아니라 문서의asOf값입니다.VERDICT:STANDING,RETRACTED,SUPERSEDED,UNBUILT,EXPIRED,NO_EXPIRY_SET,INSUFFICIENT_EVIDENCE중 하나입니다.UNCERTAINTY: 답변만으로 확정할 수 없는 범위입니다. 빈 값은 허용하지 않습니다.
검색에 실패하면 모델에게 답변을 만들게 하지 않고 오류를 반환합니다. 실질적인 결론이 성공적인 읽기 없이 반환되면 계약 위반으로 표시합니다. 반대로 INSUFFICIENT_EVIDENCE는 근거가 부족할 때 숫자나 결론을 꾸며내지 않고 중단하는 결과입니다. Kubernetes 버튼은 지식 기반 자료만으로 개수를 입증할 수 없으므로 숫자 없이 이 verdict를 반환합니다. 다만 현재 validator가 모든 abstention에 지원되지 않는 주장이 없는지 독립적으로 검증하는 것은 아닙니다.
페이지는 필수 필드 누락, 빈 uncertainty, 도구별 인용 형식 오류도 검사합니다. 데이터셋 답변에는 URL이 필요하고 Knowledge Base 답변에는 entry path와 Knowledge Base ID가 모두 필요합니다. 다만 URL을 실제로 열어 보지는 않으며, 인용된 URL이 방금 검색한 근거에 속하는지도 아직 검증하지 않습니다. 브라우저에는 키를 전달하지 않고 서버에만 보관합니다.
스키마와 라우팅 구조
Studio의 스키마는 person, article, finding, patch, claim으로 나뉩니다. finding에는 결함을 발견한 댓글 위치인 commentOn과 작성자가 이를 정리한 글인 writtenUpIn을 따로 둡니다. B1은 한 글의 댓글에서 pm25coder가 찾았지만 다른 글에 정리됐기 때문에 두 위치를 하나의 필드로 합치면 잘못된 귀속이 생깁니다.
claim의 status와 expiryStatus도 분리합니다. 어떤 claim은 standing이면서 동시에 no_expiry_set일 수 있습니다. 후자는 영구적으로 참이라는 뜻이 아니라 만료일을 정한 사람이 없다는 뜻입니다. patch.inMain 역시 공개 여부와 main 병합 여부를 구분하는 불리언 필드입니다. 세 patch는 모두 push됐지만 main에는 하나도 병합되지 않았습니다.
처음에는 Knowledge Base만 사용했습니다. 네 개의 서술형 질문은 처리했지만, 특정 claim의 상태와 만료 정보를 안정적으로 복원하지 못했습니다. Knowledge Base의 24개 항목 전체는 190,503자였고 claim-ledger-population은 Sources 목록의 라벨로 단 한 번 등장했습니다. 관련 값은 텍스트 안에 있었지만 해당 식별자에 연결되지 않았습니다. 검색 결과가 식별자와 필드의 연결을 보존하지 않으면 프롬프트만으로 그 관계를 안정화하기 어렵다고 판단해, 정확한 claim 필드 조회를 GROQ로 옮겼습니다.
두 번째 Context endpoint는 같은 데이터셋에 GROQ를 실행합니다.
*[_id=="claim-ledger-population"][0]
쿼리 결과는 status: "standing", expiryStatus: "no_expiry_set", asOf: "2026-09-11", sourceUrl: "https://dev.to/kenielzep97/my-harness-used-one-label-for-three-different-failures-2gc3"입니다. B1 귀속, B1 병합 상태, B8 근거, Kubernetes 질문은 Knowledge Base에 남기고 정확한 claim 필드 질문만 GROQ로 보냅니다. 실제 라우터는 claim- 토큰이 들어간 질문을 모두 데이터셋 endpoint로 보내므로, 작성자가 설명한 질문 형태보다 넓게 동작합니다. 이 차이는 남은 문제로 기록했습니다.
각 endpoint의 초기 컨텍스트에는 ‘주장이 아니라 근거를 보고하라’, ‘status와 expiry를 분리하라’, ‘변경 가능한 상태는 스냅샷 날짜에 묶어라’ 같은 지침이 들어갑니다. harness는 모델이 Knowledge Base를 읽거나 데이터셋을 조회하기 전에 이 컨텍스트를 불러옵니다. Python harness와 serverless agent는 Python 표준 라이브러리만 사용하고, Studio는 Sanity의 일반적인 React와 TypeScript 의존성을 사용합니다.
검증 과정과 남은 한계
harness는 검색 성공 여부, 필수 출력 필드, 인용 문법, 답변에 등장한 객체 이름을 검사합니다. 하지만 반환된 값을 문서의 각 필드와 대조하지는 않습니다. 데이터 값의 정확성은 endpoint 지침과 모델의 조회 과정에 맡겨져 있습니다. 응답의 VERDICT 하나에 claim 상태, finding 상태, 만료 정보, 검색 결과가 섞여 있다는 점도 작성자가 인정한 설계상의 문제입니다. 데이터셋 내부에서는 status와 expiryStatus가 분리되어 있지만, 출력 계약을 더 엄격하게 만들려면 verdict도 여러 필드로 나눠야 합니다.
독립적인 AI 검토 세션이 첫 실시간 실행에서 이 문제를 드러냈습니다. 초기 BLOCK에서는 실패한 검색이 모델에 전달됐고, 근거 없는 답변이 정상 종료 코드로 빠져나갔으며, 출력 계약 일부가 검사되지 않았습니다. 요구했던 도구 탐색 단계도 빠져 있었습니다. 이후 v6은 다섯 답변의 내용은 유지했지만 Knowledge Base 인용 검사를 entry path 또는 ID 하나만 있어도 통과시키는 약점이 있었습니다. v7에서 둘 다 요구하도록 고쳤고, 나중에는 해석할 수 없는 VERDICT가 verdict 의존 검사를 우회하는 문제를 발견해 v8에서 거부하도록 수정했습니다. v8은 독립 실행에서 다섯 질문 모두 통과했고 종료 코드 0을 반환했습니다.
현재 수정된 CLI harness는 gemini-3.6-flash로 한 차례 독립 v8 실행을 통과했습니다. 이전 BLOCK은 gemini-2.5-flash에서 발생했으므로 같은 모델이 실패한 뒤 통과했다고 주장하지 않습니다. 다섯 질문의 실제 응답 시간은 13.8초에서 33.8초 사이였고, 수정 전후의 로그와 latency 기록은 evidence 디렉터리에 보존했습니다. 작성자는 과거의 실패 실행을 삭제하지 않고 BLOCK, v6, v7, v8의 원본 기록을 모두 남겼습니다. 배포된 함수의 SHA-256도 GET /api/ask에서 확인할 수 있습니다.
dev.to 반응
- @naw103 — 이 방식은 GPTree knowledge APIs와 어떻게 비교되나요? gp-tree.com/docs
- @kenielzep97 — 서로 다른 계층이라고 생각합니다. 당신의 API는 인용된 구절을 반환하지만, 제 것은 검색 품질을 전혀 다루지 않습니다. 저는 식별자를 특정 필드에 연결합니다. 처음에는 Knowledge Base로 시작했지만 그 부분을 제대로 작동시키지 못했습니다. 추측하지 않고 측정했습니다.
claim-ledger-population은 190,503자에 이르는 24개 항목 전체에서 한 번만 나타났고, 그 한 번도 Sources 목록의 라벨이었습니다. 값은 본문 안에 있었지만 ID에 연결되지 않았습니다. 그래서 이제 정확한 객체 조회는 데이터셋의 GROQ로 보내고, 서술형 질문은 KB에 남깁니다. 질문마다 endpoint 하나와 도구 하나만 사용합니다. 저도 아직 당신의 제품을 써보지 않았으니 되묻고 싶습니다. Knowledge API가 식별자를 필드에 연결하나요, 아니면 구절만 돌려주고 그 연결을 모델에 맡기나요? 제가 겪은 문제는 바로 그 부분에서 발생했기 때문에, 두 제품이 같은 일을 한다고 말하기 전에 확인하고 싶습니다.
- @kenielzep97 — 서로 다른 계층이라고 생각합니다. 당신의 API는 인용된 구절을 반환하지만, 제 것은 검색 품질을 전혀 다루지 않습니다. 저는 식별자를 특정 필드에 연결합니다. 처음에는 Knowledge Base로 시작했지만 그 부분을 제대로 작동시키지 못했습니다. 추측하지 않고 측정했습니다.
원문: dev.to / 번역·요약: Trawling