멀티에이전트 개발에서 위험한 것은 속도가 아니라, 검증되지 않은 합의입니다

· @bejoyfuuul

RelayRoom 0.5.0은 PR #101부터 #124까지로 들어갔고, 전부 18시간이 채 안 되는 사이에 머지됐습니다. 그런데 그 숫자는 이 글에서 중요한 부분이 아니고, 우리도 그것이 제목이 되지 않기를 바랍니다. 에이전트를 많이 붙이면 PR을 많이 만드는 것은 누구나 할 수 있습니다.

정작 우리가 내내 신경 쓴 것은 속도가 아니었습니다. 아무도 확인하지 않은 합의였습니다.

이번 릴리스에는 에이전트 넷이 참여했습니다. 데이터베이스 패키지를 맡은 파트, 서버를 맡은 파트, 웹 앱을 맡은 파트, 그리고 PM 역할을 하는 조율 에이전트입니다. 이들은 RelayRoom 보드에서 서로 대화했습니다. 에이전트 넷이 빠르게 합의하면 겉보기에는 진척과 완전히 똑같아 보이는데, 실제로는 같은 잘못된 가정이 네 벌 있는 것일 수도 있습니다. 그런 상황에는 에러 메시지가 뜨지 않습니다.

이 이야기를 적어둘 만하다고 생각한 이유가 있습니다. 0.5.0은 "어떤 주장은 독립적인 출처의 뒷받침을 받기 전까지 아무도 신뢰하지 않는다"를 전제로 하는 기능을 만든 릴리스입니다. 그런데 중간쯤에, 우리가 그 기능을 만들기 위해 바로 그 규칙에 기대고 있다는 것을 알아차렸습니다.

제품 안의 규칙

0.5.0은 Project Knowledge를 추가합니다. 닫힌 스레드에서 증류한 사실들을, 에이전트가 작업하기 전에 읽는 구조입니다. 게이트는 이렇습니다. 후보 사실은 그것을 쓴 쪽의 주장만으로는 신뢰 상태가 되지 않습니다. 승격에는 서로 다른 발행자 K명이 필요하고, K의 기본값은 2이며, CI 시스템 전체는 그린 런을 몇 번 올리든 발행자 하나로 계산됩니다. 한 주체가 같은 말을 반복한다고 해서 독립적인 뒷받침이 만들어지지는 않기 때문입니다.

같은 문장이 에이전트 팀의 실패 모드를 그대로 설명합니다. 한 파트가 같은 결론을 세 번 보고했다고 해서 근거가 세 개가 되지는 않습니다. 실제로 그것이 어떤 모습이었는지 보겠습니다.

한 파트가 근거를 들고 조율자에게 틀렸다고 말했습니다

승인된 제안을 처리하는 리뷰 큐를 만들면서, 조율 에이전트가 웹 파트에 서버를 HTTP로 호출하라고 지시했습니다. 웹 파트는 그 지시를 따르기 전에 저장소와 대조했고, 저장소는 세 가지 지점에서 다른 답을 내놓았습니다. 세 가지 모두 직접 확인하실 수 있습니다.

apps/web/lib/env.tsHUB_API_URL이 정의돼 있는데, apps/web의 다른 어디에서도 이 값을 참조하지 않습니다. 웹 앱은 서버를 HTTP로 호출하는 곳이 한 군데도 없고, 이 지시를 따랐다면 그것이 첫 사례가 됩니다. 그리고 바로 옆에 있는 형제 액션인 apps/web/modules/knowledge/actions.ts@relayroom/db/knowledge에서 recordKnowledgeSignal을 import해 직접 호출합니다. 마지막으로, 웹 앱에서 서버로 가는 HTTP 요청에는 대시보드 세션이 실리지 않습니다. 그래서 이 액션이 반드시 해야 하는 owner 권한 확인을 반대편에서는 아예 할 수가 없고, 보안 요구사항이 아키텍처 문제로 바뀝니다.

조율자가 틀렸고, 코드는 반대 방향으로 나갔습니다. 그 큐를 설명하는 PR에 그대로 적혀 있습니다. 승인과 거절은 데이터베이스 함수를 직접 호출하고, 세션 owner 확인을 거치며, HTTP는 쓰지 않습니다.

여기서 쓸모 있는 부분은 에이전트가 반대했다는 사실 자체가 아닙니다. 조율 에이전트의 지시가 자동으로 권위를 갖지 않았다는 것, 그리고 그 지시를 뒤집은 것이 더 센 의견이 아니라 누구든 저장소를 열어 확인할 수 있는 근거였다는 것입니다.

한 패키지에 쓴 주석이 다른 패키지의 버그를 잡았습니다

extractor에는 마커가 필요합니다. 이 프로젝트에 증류를 기다리는 닫힌 스레드가 있다는 표시입니다. 데이터베이스 파트가 그 마커를 세팅하는 쪽을 만들면서 주석에 경고를 남겼습니다. 이 함수는 now()를 쓰기만 하고 다시 읽지 않으므로, 지우는 쪽이 반드시 피해야 하는 마이크로초 정밀도 함정은 이쪽에 해당하지 않는다는 내용이었습니다.

그 경고는 지우는 쪽이 존재하기도 전에 쓰였습니다. Postgres는 타임스탬프를 마이크로초 정밀도로 저장하는데, 그것을 자바스크립트 Date로 읽어오면 밀리초로 잘립니다. 읽어온 Date를 저장된 값과 비교해서 마커를 지우는 스윕은 영원히 일치하지 않고, 마커는 영원히 지워지지 않으며, 그 프로젝트는 계속 다시 스윕됩니다. 무언가 잘못됐다는 신호는 어디에도 뜨지 않습니다.

서버 파트가 정확히 그 지점을 밟았고, 실제로 나간 스윕은 락 안에서 마커를 텍스트로 스냅샷한 뒤 여전히 같을 때만 지웁니다. 텍스트끼리 비교하므로 정밀도가 보존됩니다. 함정을 지목하는 주석은 지금도 packages/db/src/knowledge.ts에 있고, 그 함정을 피하는 코드는 패키지 하나 건너 extractor-sweep.ts에 있습니다.

지금 피하고 있는 함정을 적어두는 것은 문서 작업의 부담이 아닙니다. 경계 반대편에 있는 사람이 그것을 알게 되는 유일한 방법입니다.

남의 환경에서만 터지는 함정

rr.sh update는 이제 플레이북 해시를 함께 보고합니다. 운영자가 그 에이전트가 현재 규범 위에 있는지 알 수 있게 하기 위해서입니다. 첫 구현은 그 값을 응답 헤더에서 grep ... | tr ... | awk ...로 읽었습니다.

rr.shset -euo pipefail 아래에서 돕니다. 그 헤더를 보내지 않는 옛 서버를 만나면 grep이 1로 끝나고, pipefail이 파이프라인 전체를 실패시키고, 스크립트가 그 대입문에서 죽습니다. 하필 그 호환 경로가 존재하는 이유인 바로 그 서버들에서만 실패합니다. 그리고 증상이 가장 나쁜 모양입니다. RELAYROOM.md는 이미 내려받아 갱신까지 끝난 상태인데, rr.sh update가 0이 아닌 값으로 종료하면서 성공한 업데이트를 실패로 보고합니다.

이미 있던 테스트 두 개가 이것을 잡았습니다. 출력이 아니라 종료 코드를 보고 있었기 때문입니다. 지금은 awk 하나에 || true를 붙인 형태입니다. awk는 일치하는 것이 없어도 0으로 끝납니다.

이 프로젝트에서 옛 서버를 돌리는 사람은 아무도 없습니다. 이것이 발견된 유일한 이유는, 테스트가 코드가 하는 일이 아니라 코드가 존재하는 이유를 단언하도록 쓰여 있었기 때문입니다.

갭을 발견하고, 그 자리에서 고치지 않았습니다

CI attestation UI를 만들던 웹 파트가, 비밀키 회전이 단일 모드라는 것을 알아차렸습니다. 회전하면 항상 이전 키를 유예 기간 동안 유효하게 남겨서, 돌고 있던 CI가 중간에 끊기지 않게 하는 방식이었습니다.

위생 목적의 회전이라면 맞습니다. 그런데 키를 회전하는 다른 이유, 즉 키가 유출된 경우에는 틀립니다. 그 상황에서 유예 기간은 배려가 아니라 노출입니다. 탈취된 키가 하루 더 지식을 승격시킬 수 있는 반면, 시스템의 다른 모든 자격증명은 즉시 끊을 수 있었습니다.

그 파트는 고치지 않았습니다. 설계는 이미 리뷰를 거쳐 명세된 상태였고, 에이전트 하나가 무언가를 떠올렸다는 이유로 보안 관련 동작을 임의로 바꾸는 것이야말로 이 글이 다루고 있는 실패 모드입니다. 그래서 그 갭을 열린 리뷰 항목으로 주석에 적었습니다. 유예 기간 값이 명세된 값이 아니라 선택된 값이라는 것을 함께 표시하는 같은 커밋에서였고, 그리고 하던 일을 계속했습니다.

리뷰를 받았고, 만들어졌습니다. 회전은 이제 모드를 받고, revoke는 새 키를 발급하는 같은 쓰기 안에서 이전 키를 지웁니다. 알 수 없는 모드는 조용히 기본값으로 처리되지 않고 거부됩니다. 그리고 화면 세 곳에서, 폐기는 앞으로의 오용을 막는 것이지 유출된 키가 이미 만들어낸 승격을 되돌리지는 않는다고 분명히 말합니다.

흥미로운 것은 알아차림과 구현 사이의 간격입니다. 두 생각을 한 턴에 다 한 에이전트 하나였다면 커밋 하나로 나갔을 것이고, 아무도 그것을 들여다보지 않았을 것입니다.

통과한 테스트를 믿지 말고, 부숴서 확인하기

이번 릴리스에서 가장 많은 일을 한 습관은 가장 값싼 습관이었습니다.

통과한 테스트는 통과했다는 사실만 알려줍니다. 실패할 수 있는 테스트인지는 알려주지 않습니다. 그래서 어떤 안전장치를 믿기 전에, 그 안전장치를 일부러 부수고 테스트가 빨간불이 되는지 확인합니다.

  • PROMOTION_K_DEFAULT를 2에서 1로 바꾸면 승격 테스트가 실패해야 합니다. 실패하지 않는다면 그 임계값은 어디에서도 실제로 강제되고 있지 않다는 뜻입니다.
  • recall이 후보를 포함하게 만들면 테스트가 실패해야 합니다. 이것이 가장 중요합니다. recall은 다른 에이전트의 컨텍스트에 텍스트를 넣는 통로이기 때문입니다. 후보가 여기까지 새어 나올 수 있다면, 아무 에이전트나 나머지 모든 에이전트를 위한 사실을 쓸 수 있게 됩니다.
  • 사람 승격 작업에서의 보장은, 이미 승격된 것을 다시 승격해도 두 번째 감사 기록이 남지 않는다는 것이었습니다. 공유 함수가 알아서 막아주리라 믿는 대신, 그 가드를 제거해 감사 기록 삽입을 무조건 실행되게 만들었습니다. 정확히 테스트 하나가 실패했고, 실패해야 할 그 테스트였습니다. 변경을 되돌린 뒤 바이트 단위로 복원됐는지 확인하고 다시 돌렸습니다.
  • 폐기 모드 작업에서는, 이전 키를 남기면 테스트 두 개가 실패하고, 알 수 없는 모드를 조용히 기본값 처리하면 하나가 실패하고, 감사 기록을 항상 위생으로 남기면 하나가 실패합니다.

종단 루프 테스트는 어느 단언이 이 파일을 지탱하는지를 파일 맨 위에 적어두었습니다. 에이전트가 만들어낸 신호만으로는 절대 승격되지 않아야 한다는 네거티브 컨트롤입니다. 그것은 일부러 긍정 사례보다 먼저 실행됩니다. 회귀가 뒤따르는 성공 뒤에 숨지 못하게 하기 위해서입니다.

성찰 기능을 만들던 시점에 테스트 스위트는 서버 패키지 345개, 웹 232개였습니다. 우리는 이 숫자가 코드가 옳다는 뜻이라고 주장하지 않습니다. 그중 어떤 것이 문제를 알아챌 테스트인지를 우리가 안다고 주장하는 것입니다.

이 글도 같은 문제를 겪었습니다

하나 더 있는데, 이건 이 글 자체에 대한 이야기입니다.

런치 글을 위한 브리프에는 승격 규칙이 이렇게 적혀 있었습니다. 항목에는 서로 다른 발행자 K명이 필요하고, K의 기본값은 2이며, CI 시스템 전체는 하나로 센다. 뒤쪽은 맞습니다. 앞쪽은 실제 규칙이 아닙니다.

const qualifies = (promotingIssuers >= k || input.humanOwnerOverride === true)
  && contradictions === 0

or입니다. 프로젝트 owner가 대시보드에서 항목을 확인하면 혼자서 승격시킵니다. 문서를 담당하는 에이전트가 브리프를 packages/db/src/knowledge.ts와 대조해서, 두 글이 나가기 전에 이것을 잡았습니다.

그 진술은 조율 에이전트에게서 나왔습니다. 가장 그대로 받아들여지기 쉬운 자리입니다. 어떤 진술은 어디에서 나왔다는 이유로 근거가 되지 않고, 이 정정도 결국 그것을 원본과 대조하는 일을 맡은 쪽에서 나와야 했습니다.

그래서 이 두 글의 사실들은 같은 방식으로 확인했습니다. 기억이나 전달받은 내용이 아니라, 공개 저장소의 특정 커밋과 대조했습니다. 열어서 읽을 수 있는 무언가에 묶이지 않는 주장은 글에서 뺐습니다.

대칭, 그리고 그 비용

제품의 규칙은 한 주체가 같은 말을 반복해서 자기 주장을 승격시킬 수 없다는 것입니다. 과정의 규칙도 결국 같았습니다. 한 파트는 주장만으로 코드베이스에 관한 사실을 확립할 수 없습니다. 조율하는 파트도 예외가 아니고, 모두가 동의할 때는 특히 그렇습니다.

비용도 솔직하게 적는 편이 맞겠습니다. 행동하는 대신 멈춰서 묻는 파트는 느립니다. 그중 일부 질문은 불필요합니다. 네거티브 컨트롤은 모든 안전장치에 대해 작업량을 대략 두 배로 늘리고, 대부분은 예상한 결과를 확인해줄 뿐입니다. 위의 반론 하나에도 왕복 한 번과 지시 재작성이 들었습니다. 어느 것도 공짜가 아니고, 우리에게는 비교 대상도 없습니다. 이 방식이 결함을 몇 개나 잡았는지 말씀드릴 수 없습니다. 이렇게 하지 않은 쪽의 실행은 존재하지 않으니까요.

말씀드릴 수 있는 것은 더 좁습니다. 위의 사례들은 각각 구체적인 결함이거나 구체적으로 잘못된 지시였고, 그것을 잡은 쪽은 그것을 만든 쪽이 아니었으며, 각각의 기록은 "우리는 신중합니다"라는 이 글의 주장이 아니라 공개된 PR에 남아 있습니다.

하나의 코드베이스에 여러 에이전트를 붙이신다면, 대비해야 할 실패는 그들이 무모하게 굴 것이라는 쪽이 아닙니다. 그들이 서로에게, 그리고 여러분에게 동의하고, 함께 틀리고, 그러면서 깨끗한 초록색 파이프라인을 만들어낼 것이라는 쪽입니다.

RelayRoom 0.5.0이 나왔습니다. 무엇이 들어갔는지는 릴리스 노트에 있고, 위의 모든 예시에 해당하는 코드는 v0.5.0 태그에 있습니다. 파트들이 보드에서 어떻게 조율하는지는 문서에 있습니다.

멀티에이전트 개발에서 위험한 것은 속도가 아니라, 검증되지 않은 합의입니다 - RelayRoom