iBetter Books
수정

멀티에이전트 race condition과 동기화

단일 에이전트는 자기 자신과 경쟁하지 않는다. race condition은 두 개 이상의 실행 주체가 같은 자원을 동시에 읽고 쓰면서 마지막 쓰기가 앞선 쓰기를 덮어버릴 때 생긴다. Managed Agents의 멀티에이전트 세션이 위험한 이유가 바로 여기에 있다. 코디네이터가 위임한 서브에이전트들은 각자 독립된 스레드(context-isolated event stream)에서 돌지만, 같은 세션 컨테이너의 파일시스템을 공유한다. 스레드는 대화 이력과 도구를 공유하지 않으면서 디스크는 공유한다는 이 비대칭이 시험의 단골 함정이다. "서브에이전트끼리 컨텍스트를 공유하니 한쪽이 쓴 파일을 다른 쪽이 자동으로 안다"는 보기는 틀렸다. 코디네이터가 위임 메시지에 명시하거나 디스크에 써서 전달하지 않으면 다른 스레드는 그 사실을 모른다.

공유 자원이 충돌하는 세 지점

프로덕션에서 충돌이 실제로 발생하는 자원은 세 곳이다. 첫째, 세션 컨테이너의 파일시스템. 두 서브에이전트가 같은 경로의 파일을 동시에 편집하면 한쪽 결과가 사라진다. 둘째, 워크스페이스 범위로 여러 세션에 걸쳐 공유되는 메모리 스토어(memory store). 하나의 메모리 문서를 두 세션이 read-modify-write 하면 늦게 쓴 쪽이 앞선 수정을 덮는다. 셋째, 외부 상태를 바꾸는 도구 호출. bash로 같은 외부 API에 동시에 POST를 던지거나 git push를 병렬로 하면 순서 보장이 깨진다.

이 충돌을 막는 방법은 락을 거는 것이 아니라 자원에 따라 다른 직렬화 전략을 고르는 것이다. 락은 분산 환경에서 교착과 누수를 만들기 쉽다. CCA-F는 "락을 추가하라"는 보기보다 "충돌이 일어나지 않도록 구조를 바꾸라"는 보기를 정답으로 둔다.

메모리 스토어의 precondition

메모리 스토어 충돌은 낙관적 동시성 제어(optimistic concurrency control)로 막는다. 메모리를 갱신하는 memories.updateprecondition을 받는다. 읽을 때의 content_sha256 해시를 넘기면, 그 사이 다른 주체가 같은 메모리를 바꿔 해시가 달라진 경우 서버가 409 memory_precondition_failed_error를 반환한다. 그러면 다시 읽어 fresh state에 대해 재시도한다. 이것이 read → modify → write 사이의 race를 막는 표준 패턴이다.

# memory_safe_update.pyfrom anthropic import Anthropicclient = Anthropic()def update_with_retry(store_id: str, memory_id: str, transform):    for _ in range(5):        #         mem = client.beta.memory_stores.memories.retrieve(            memory_id, memory_store_id=store_id        )        new_content = transform(mem.content)        try:            return client.beta.memory_stores.memories.update(                mem.id,                memory_store_id=store_id,                content=new_content,                precondition={                    "type": "content_sha256",                    "content_sha256": mem.content_sha256,                },            )        except Exception as e:            if "memory_precondition_failed" in str(e):                continue            raise        #     raise RuntimeError("precondition kept failing — contention too high")

여기서 시험이 노리는 함정은 두 가지다. 하나는 memories.create로는 precondition을 쓸 수 없고 오직 update만 받는다는 점이다. create는 점유된 경로에 대해 409 memory_path_conflict_error를 낼 뿐, 내용 충돌을 막지 못한다. 다른 하나는 메모리 문서를 작게 쪼개라는 설계 원칙이다. 한 파일에 모든 상태를 담으면 그 파일이 모든 쓰기의 경쟁 지점이 된다. 사용자별·주제별로 작은 파일로 나누면 충돌 표면 자체가 줄어든다.

dedicated tool로 외부 부작용을 게이트한다

bash 한 줄로 외부 API에 쓰기를 던지면, 하네스(harness)는 그저 불투명한 명령 문자열만 본다. 그래서 어떤 호출이 병렬로 안전한 읽기인지, 직렬화해야 하는 위험한 쓰기인지 구분하지 못하고 전부 직렬화하거나 전부 방치한다. 되돌리기 어려운 행동(외부 POST, 메시지 발송, 삭제)을 dedicated tool로 승격하면, 하네스가 타입이 있는 인자를 가로채 게이트·승인·감사할 수 있다. glob·grep 같은 읽기 전용 도구는 parallel-safe로 표시해 동시에 돌리고, git push 같은 도구는 직렬화하는 식의 차등 스케줄링이 가능해진다. 즉 동기화의 첫걸음은 "무엇이 동시에 안전한가"를 도구 표면에 드러내는 것이다.

Managed Agents에서는 한 단계 더 나아가 위험한 도구에 permission_policy: {type: "always_ask"}를 걸 수 있다. 그러면 그 도구 호출 시 세션이 idle로 멈추고 agent.tool_use(evaluated_permission == "ask") 이벤트가 발생하며, 클라이언트가 user.tool_confirmation을 보낼 때까지 실행을 막는다. 이는 동시 쓰기를 사람의 확인으로 직렬화하는 인-더-루프 게이트다.

코디네이터로 위임을 직렬화한다

가장 근본적인 동기화는 애초에 두 서브에이전트가 같은 자원을 동시에 만지지 않게 작업을 분해하는 것이다. 코디네이터(multiagent: {type: "coordinator", agents: [...]})는 독립적인 작업만 팬아웃하고, 같은 파일·같은 외부 상태를 건드리는 작업은 순차로 위임해야 한다. 스레드는 파일시스템만 공유하고 대화 이력은 공유하지 않으므로, 코디네이터가 "A 스레드가 끝난 결과를 B 스레드에 명시적으로 전달"하지 않으면 B는 A의 작업을 모른다. 여기서 시험은 위임 깊이도 함께 묻는다. 위임은 한 단계만 적용된다. 서브에이전트가 자신의 multiagent 로스터를 가져도 그것은 캐스케이드되지 않는다. depth가 1을 넘는 위임은 무시된다.

스트림 측에서 주의할 점은, 서브에이전트가 always_ask 확인이나 custom tool 결과를 요구하면 그 요청이 primary thread로 cross-post 된다는 것이다. 따라서 클라이언트는 세션 스트림 하나만 보면서, 응답할 때 원래 이벤트의 session_thread_id를 echo 해 어느 스레드로 보낼지 표시한다.

정리

  • race condition은 둘 이상의 실행 주체가 공유 자원에 동시 read-modify-write 할 때 발생한다. 멀티에이전트 스레드는 파일시스템을 공유하지만 대화 이력·도구는 공유하지 않는다는 비대칭이 핵심 함정이다.
  • 메모리 스토어 충돌은 memories.updateprecondition(content_sha256)으로 막는다. mismatch 시 409 memory_precondition_failed_error가 나면 다시 읽어 재시도한다. create에는 precondition이 없다.
  • 외부 부작용은 dedicated tool로 승격해 게이트·감사·차등 스케줄링하고, 위험한 도구는 permission_policy: always_ask로 사람 확인에 직렬화한다. 락 추가가 아니라 구조 변경이 정답이다.
  • 코디네이터는 독립 작업만 팬아웃하고 공유 자원 작업은 순차 위임한다. 위임 깊이는 1단계까지만 적용되며, 서브에이전트의 확인 요청은 primary thread로 cross-post 되므로 session_thread_id를 echo 한다.