수정 과정이 진행되었다. 이제 주장에는 출시가 7월에 이루어졌다고 명시되어 있다. 나는 에이전트에게 계획을 다시 확인해 달라고 요청했고, 그것은 "7월, 확실히"라며 동의했다. 그러고 나서 왜 그렇게 확신하는지 보기 위해 증거 패널을 열었다.
그것은 원래 문서를 인용하고 있었다. 6월이라고 되어 있던 문서. 내가 방금 수정했던 바로 그 문서였다.
나의 메모리 수리는 문장을 고쳤지만, 거짓말 자체는 여전히 아래에 남아 있었고, confidence: 1.0이라는 신뢰도를 가지고 있었다.
아무도 시연하지 않는 장면
내가 본 모든 에이전트-메모리 데모는 만족스러운 부분만을 보여준다. 즉, 에이전트가 무언가를 기억하는 것이다. 월요일에 사실을 알려주면 목요일에 그것을 언급하고 모두가 박수를 친다.
아무도 다음 장면은 보여주지 않는다. 그 사실이 틀렸다는 것이 밝혀지고, 에이전트는 이미 그 위에 의사결정을 구축해 버린 경우이다. 어쩌면 출시 계획을 작성했을 수도 있다. 어쩌면 "6월 12일에 장소를 예약하자"고 제안했을 수도 있다. 잘못된 사실은 더 이상 단순히 메모리에 놓여 있는 것이 아니다. 이제 여러 가지가 그것에 의존하고 있다.
그 사실을 삭제하는 것은 한 줄의 코드로 가능하다. 하지만 그것에 조용히 의존하고 있는 모든 것을 찾아내는 것이 실제 문제이다.
내가 WeMakeDevs Graph Hacks 주말 동안 집중한 것이 바로 이것이었다. 이 프로젝트는 RECALL로 시작했지만, 내가 계속 되돌아오는 질문이 "에이전트는 어떻게 기억하는가?"가 아니라 "잘못 기억했을 후에는 무엇을 해야 하는가?"였기 때문에 Afterhack으로 마무리되었다.
벡터 저장소(vector store)가 아닌 그래프를 사용해야 하는 이유
내 첫 번째 본능은 일반적인 에이전트-메모리 설정이었다: 문서를 청크(chunk)로 나누고, 임베딩(embed)하고, 유사성(similarity)으로 검색하는 방식이다. 이것은 무언가를 찾는 데는 괜찮다. 하지만 "만약 이것이 틀렸다면, 또 무엇이 잘못되었는가?"라는 질문에 답하는 데는 쓸모없다. 왜냐하면 유사성이 의존성은 아니기 때문이다. 두 개의 청크가 같은 출시 건에 관한 것일 수 있지만, 하나가 다른 하나의 이유가 될 수는 없다.
내가 필요했던 것은 ~때문에의 연쇄 고리였다:
출처(Source) ──▶ 조각(Fragment) ──▶ 주장(Claim) ──▶ 결정(Decision) ──▶ 행동(Action)
^
수정(Correction)
...
이렇게 글로 적으면, "이 잘못된 사실은 무엇에 닿았는가?"라는 문제가 검색 문제(search problem)가 아니라 순회(traversal) 문제가 되고, 이는 그래프 데이터베이스(graph database)가 정확히 하는 일입니다. 저는 FalkorDB를 사용했고, 작업 공간(workspace)마다 하나의 그래프를 두었습니다. 그래프 이름은 서버에서 생성된 UUID에서 파생되기 때문에, 어떤 요청도 어느 그래프와 통신할지 선택할 수 없습니다.
첫 번째 규칙: 인용문이 아니면 일어나지 않은 일
수정 사항을 걱정하기 전에, 애초에 신뢰할 수 있는 주장(claim)들이 필요했습니다. 그래서 추출 과정에는 하나의 엄격한 규칙이 생겼습니다. 모델은 모든 주장과 함께 evidence_quote를 반환해야 하고, API는 그 인용문이 원본의 **정확한 부분 문자열(exact substring)**인지 확인합니다. "충분히 가깝다" 같은 건 안 됩니다. "의미적으로 유사하다" 같은 것도 안 됩니다. 글자 그대로여야 합니다.
만약 모델이 의역(paraphrase)을 하면, 그 주장은 폐기됩니다. 만약 인용문을 지어내면, 주장도 폐기됩니다. 살아남은 것들은 시작 오프셋과 끝 오프셋과 함께 저장되어, UI가 주장이 어디에서 왔는지 정확히 하이라이트할 수 있게 합니다.
이는 다소 무딘 규칙이며, 기술적으로는 괜찮았지만 일부 주장을 버리게 만듭니다. 저는 그것에 만족했습니다. 가끔 깜빡하는 기억 시스템은 짜증나지만, 자신감 있게 거짓말을 하는 것은 훨씬 더 심각합니다.
모든 것과 아무것도 반환했던 데이터베이스
FalkorDB를 대상으로 한 저의 첫 실제 엔드투엔드(end-to-end) 실행은 다음과 같았습니다. 모든 쿼리는 성공했지만, 제가 사용할 수 있는 것은 하나도 없었습니다. 방금 만든 작업 공간들은 "찾을 수 없음"이었습니다. 주장 목록은 의미 없는 키를 가진 딕셔너리(dictionary)로 돌아왔습니다.
제 로우 매퍼(row mapper)는 당연하고 지루한 방식이었습니다:
return [dict(zip(result.header, row)) for row in result.result_set]
실제로 result.header를 출력하는 데 너무 오래 걸렸습니다:
>>> result.header
[[1, 'id'], [1, 'name'], [1, 'revision']]
각 컬럼 이름은 문자열이 아니었습니다. 그것은 쌍(pair)이었습니다: 타입 코드와 별칭(alias). 저는 리스트를 딕셔너리 키로 사용했고, 왜 아무것도 일치하지 않는지 궁금해했습니다.
FalkorDB 클라이언트가 헤더를 단순히 별칭(alias)으로만 노출하는 것이 아니라 [타입 코드, 별칭] 형태로 노출합니다.
headers = [item[-1] if isinstance(item, (list, tuple)) else item for item in result.header]
return [dict(zip(headers, row)) for row in result.result_set]
한 줄 수정으로 해결되었습니다. 또한 이번 주말에 버그가 제 논리 안에 있는 것이 아니라 제가 도구가 제공한다고 가정한 부분에 있다는 것을 깨달은 것은 이번이 몇 번째인지 모릅니다.
“검색 결과는 증거가 아니다”
에이전트 측에는 두 가지 역할이 있어 두 개의 별도 모델 호출을 수행합니다. **분석가(Analyst)**는 그래프에서 주장(claim)을 검색하고 그 뒤에 있는 정확한 출처 조각(source fragments)을 가져오는 도구를 받습니다. 분석가는 초안 제안을 **코디네이터(Coordinator)**에게 전달하고, 코디네이터는 이를 검토하여 초안 결정을 작성합니다.
제가 라이브로 작동하는 것을 지켜본 첫 번째 때 두 가지 문제가 발생했습니다.
첫째, 분석가가 마치 구글에 타이핑하는 사람처럼 검색했습니다. `
[코드 스니펫] 이 부분이 강조되어 있습니다. 이것은 게시물이 "모델에게 공손하게 요청하는 단계"에서 "모델이 우회할 수 없는 단계"로 전환되는 순간입니다.
Coordinator는 해당 클레임이 이번 실행(run) 중에 발견되었고 출처가 추출된 경우에만 인용할 수 있습니다. 임의로 ID를 만들거나 다른 곳에서 기억하는 ID를 재사용할 수 없습니다. 필터를 통과하는 것이 아무것도 없다면, 결정은 needs_review로 기록되며, 제목은 **"No source-backed answer yet"**이 되고 어떤 액션도 생성되지 않습니다.
저는 관련 증거가 포함되지 않은 워크스페이스에서 의도적으로 모호한 프롬프트로 이를 테스트했습니다. 그것은 정확히 그렇게 작동했습니다: 정직하게 "모릅니다"라는 내용이 핸드오프 트레일(handoff trail)과 함께 그래프에 저장되었고, 임의로 만들어낸 다음 단계는 없었습니다. 이상하게도, 이것이 이번 주말 최고의 결과였습니다.
"아..." 하는 순간
이제 처음으로 돌아가겠습니다.
수정(Correction)은 이 프로젝트 전체가 존재했던 기능이었습니다. 흐름은 다음과 같았습니다:
- 검토자(reviewer)가 수정한 진술을 입력합니다.
- **미리보기(Preview)**는 어떤 결정과 액션이 해당 클레임에 의존하는지 보여줍니다.
- **적용(Apply)**은 이전 클레임을
superseded(대체됨)로 표시하고, SUPERSEDES와 연결된 대체본을 생성하며, 이전에 의존했던 결정들을 업데이트합니다.
제가 그것을 만들었고, 작동했고, 대체 클레임은 올바른 텍스트와 함께 나타났습니다. 그러고 나서 저는 그 대체 클레임이 무엇에 의해 _뒷받침되는지_를 확인했습니다.
첫 번째 버전은 다음과 같이 작동했습니다:
CREATE (replacement:Claim {
statement: $statement,
evidence_quote: old.evidence_quote, -- 👀
...
저는 이전 클레임의 증거를 새 클레임에 복사했습니다. 당시에는 말이 되었습니다: 맥락을 유지하여 어디서 왔는지 볼 수 있도록 말입니다. 실제로는, 대체 클레임은 "7월(July)"이라고 주장하면서 그 증거로 "6월(June)"이라고 적힌 문서를 가리켰습니다. 그것도 확신에 찬 어조로 말입니다.
나중에 에이전트(agent)가 출시 날짜에 대한 증거를 찾으려고 했을 때, 수정된 주장(claim)을 발견하고, 그 "출처(source)"를 가져와서 원래의 실수를 고쳐진 내용의 근거로 다시 인용했습니다. 모든 개별 단계는 제가 지시한 대로 작동했습니다. 그래프는 거짓인 링크를 충실하게 유지하고 있었습니다.
그때 제가 실제로 구축하고 있던 것이 명확해졌습니다. 수정은 편집(edit)이 아닙니다. 그것은 다른 출처, 즉 인간 검토자로부터 온 새로운 증거 조각이며, 다른 모든 출처와 마찬가지로 자체적인 출처 기록(provenance)이 필요합니다.
[BEFORE/AFTER] 두 개의 카드가 나란히 배치되어 있습니다. 전(Before): 대체 주장("July"), 증거 인용구("…launch in June…"), 신뢰도 1.0. 후(After): 동일한 주장, 증거 인용구는 검토자 자신의 텍스트이며, 출처(origin)는 "Workspace reviewer correction", 신뢰도 종류(confidence_kind)는 human_correction입니다. 이것은 게시물에서 가장 중요한 시각 자료입니다. 하나만 추가한다면 이것을 추가하세요.
수정 내용:
CREATE (f:SourceFragment {
text: $statement,
origin_type: 'human_correction',
...
이제 수정은 자체적인 조각(fragment)을 생성하며, 이는 Correction 노드가 소유하고 인간으로부터 왔음이 표시됩니다. 대체 주장은 _그것_을 인용합니다. 신뢰도는 명시적인 confidence_kind와 함께 0.0에서 시작하는데, 검토자가 문장을 타이핑하는 것이 모델이 인용구와 일치시키는 것과 같은 종류의 신뢰도가 아니기 때문에 두 가지를 모두 의미하는 것처럼 보이는 하나의 숫자는 원하지 않았습니다.
저는 전체 과정을 실시간으로 다시 실행했습니다: 출처를 수집(ingest)하고, 주장을 추출(extract)하며, 에이전트를 실행하고, 주장을 수정한 다음, 새로운 에이전트 작업을 시작합니다. 이번에는 에이전트가 대체 주장과 검토자의 수정을 인용했고, 더 이상 유효하지 않은 출처를 인용하지 않았습니다.
Reachable은 틀리지 않았다
증거를 수정하는 과정에서 같은 기능의 두 번째 실수가 노출되었습니다.
저의 첫 번째 전파 규칙(propagation rule)은 너무 단순했습니다. 만약 결정이 수정된 주장을 사용했다면, 그것을 needs_review로 표시합니다. 만약 어떤 행동이 그 결정을 필요로 했다면, 그것을 held로 표시합니다. 간단하고 신중했지만, 사람들이 이 도구 사용을 중단하게 만들 만큼 잘못되었습니다.
세 가지 주장(claim)에 의해 뒷받침된 결정을 상상해 보세요. 그중 하나가 수정되더라도 나머지 두 개가 여전히 그것을 완전히 지지한다면 말입니다. 그러한 결정을 플래그 지정하는 것은 마치 토스트를 구울 때마다 울리는 연기 감지기와 같습니다. 세 번째 오경보가 울린 후에는 아무도 더 이상 신경 쓰지 않습니다.
잘못된 사실과 연결되어 있다는 것이 그 결정 자체를 잘못되게 만드는 것은 아닙니다. 중요한 것은 그것이 여전히 충분한 지지를 받고 있느냐입니다. 따라서 이제 재검사(re-checks)를 적용해 보세요:
def _reconcile_correction_dependencies(self, graph, claim_id):
decisions = self.rows(graph.ro_query(
"MATCH (c:Claim {id:$claim_id})<-[:USED_CLAIM]-(d:Decision) "
...
오직 마지막 지지 주장을 잃은 결정들만이 플래그 지정됩니다. 그리고 그러한 결정들에 의존하는 행동(actions)들만 보류됩니다.
문제가 발생한 부분
만약 이 함수가 루프와 예상했던 하나의 깔끔한 쿼리 대신 결정마다 별도의 쿼리를 사용하는 것처럼 조금 어설퍼 보인다면, 그 이유가 있습니다.
첫 번째 버전은 하나의 깔끔한 쿼리로 구현되었습니다. 저는 NOT EXISTS { ... } 서브쿼리를 사용하여
만약 누군가 수정을 미리 보고, 팀원이 같은 주장을 변경하고, 그 후 첫 번째 사람이 이제는 오래된 프리뷰를 기반으로 '적용(Apply)'을 누른다면 어떻게 될까요? 만약 '적용' 버튼이 두 번 클릭되거나, 타임아웃 후에 요청이 재시도된다면 어떨까요?
모든 워크스페이스와 모든 주장은 revision 번호를 가집니다. **프리뷰(Preview)**는 자신이 본 수정 사항들의 버전을 반환합니다. **적용(Apply)**은 그 버전들을 다시 보내야 하며, 쓰기 작업은 여전히 일치할 경우에만 진행됩니다:
MATCH (w:Workspace {id:$workspace_id}) MATCH (old:Claim {id:$id})
WHERE w.revision = $workspace_revision
AND old.revision = $claim_revision
...