문이 둘이 되면 다 달라져
첫걸음엔 터미널이 하나뿐이라 누가 먼저냐를 물을 일이 없었어. 두 번째 클라이언트가 같은 대화에 글을 보낼 수 있게 되는 순간, 그 질문이 생겨. 이번 걸음은 대화 하나에 문 두 개를 달아. 이미 있는 명령줄, 그리고 폰이나 브라우저 역할을 대신할 작은 HTTP 엔드포인트. 둘 다 같은 대화 ID를 대고, 둘 다 send라는 함수 하나를 지나가. 2트랙의 방 하나를 장난감으로 만든 거야. 어느 문이든 같은 대화를 찾아가고, 턴이 대화에 어떻게 붙을지는 문이 아니라 엔진이 정해.
규칙 셋
부모는 서버가 골라. 클라이언트는 다른 클라이언트가 방금 뭘 보냈는지 알 수가 없어. 그러니까 "내가 답하는 메시지"라는 클라이언트의 생각은 애초에 낡은 거야. 스케치는 클라이언트가 주는 부모를 절대 안 받아. 새 턴은 어느 문이 썼든 로그의 가장 새 행에 매달려. Master Chief도 새 턴마다 똑같이 하고, 클라이언트가 보낸 부모는 버려.
대화 하나엔 한 번에 한 턴. 잠금은 기록을 읽는 순간부터 답장을 다 쓸 때까지 쥐고 있어. 그래서 다음 턴은 직전 답이 이미 들어 있는 기록에서 출발해. 스케치는 로그 옆 잠금 파일에 fcntl.flock을 걸어. 스레드끼리도, Mac이나 리눅스 한 대 안의 프로세스끼리도 통해. 한 터미널의 서버와 다른 터미널의 CLI가 진짜로 서로를 기다려. Master Chief는 공정한 asyncio.Lock을 써서 도착한 순서대로 턴을 들여보내. flock은 순서를 약속 안 해. 이게 장난감인 이유 중 하나야.
요청 ID는 클라이언트가 만들어. 연결이 끊긴 폰은 메시지가 도착했는지 알 길이 없으니까 또 보내. 서버가 두 번째 사본을 못 알아보면 지시가 두 번 돌아. 그래서 클라이언트는 사용자가 보내기를 누를 때 ID를 딱 한 번 만들고, 재시도할 때마다 그 ID를 그대로 다시 써. 서버는 먼저 기록에서 그 ID를 찾고, 아는 ID면 모델을 또 부르는 대신 저장된 답을 already_accepted 상태로 돌려줘. Master Chief의 클라이언트들은 ID 앞에 어느 화면이 만들었는지 이름을 붙여. 텔레그램은 tg-<chat>-<message>, 브라우저는 webui-<uuid> 같은 모양이야.
경주를 돌려봐
python3 step2_doors.py race는 HTTP 문을 스레드로 띄워서 거기로 보내고, 50밀리초 뒤에 CLI 문을 쏴. 브라우저의 턴이 느린 가짜 모델을 아직 기다리는 동안이지. 잠금 없이 한 번, 잠금 걸고 한 번 돌리고, 그다음 브라우저의 요청을 같은 ID로 다시 보내.
잠금 없는 쪽을 꼼꼼히 읽어. 2트랙에서 말한 엉킴이 바로 이거야. 터미널의 메시지가 브라우저의 질문에 매달렸어. 사용자 행에 사용자 행이 이어진 거고, 브라우저의 질문엔 자식이 둘 생겼어. 갈라진 거지. 두 답장 모두 뭔가를 놓쳤어. 브라우저의 답은 터미널의 메시지를 못 봤고, 터미널의 답은 브라우저의 답을 못 봤어. 영원히 이어질 대화에서 이런 갈라짐은 영원히 남아. 잠금을 걸면 사슬이 깨끗해. 터미널의 턴이 기다렸고, 그 답은 자기 질문 앞에 놓인 브라우저의 질문과 답까지 봤어. 두 번 다 재전송은 already_accepted로 돌아왔고 행은 하나도 안 늘었어.
진짜 프로세스 두 개로 돌려볼 수도 있어. 한 터미널에서 python3 step2_doors.py serve, 다른 터미널에서 python3 step2_doors.py send hello나 serve 옆 주석에 있는 curl 줄.
이 스케치에 일부러 없는 것
진짜 시스템에 2트랙에서 본 장치가 전부 필요해지기 시작하는 게 딱 여기서부터야. 그리고 스케치엔 그중 하나도 없어.
- 턴 상태가 없어. 질문을 쓰고 답을 쓰기 전에 서버가 죽으면, 재전송은 답 없는
already_accepted를 영원히 받아. 그다음 턴은 답 못 받은 그 질문에 매달려. 그래서 재전송 조회는 assistant 자식만 집어. 아무 자식이나 집으면 다음 턴의 질문을 답이라고 돌려주거든. Master Chief는 기다리는 턴을pending이라고 말해줄 수 있고, Stop으로 철회된 턴과 종료 때문에 끊긴 턴을 구분해. 끊긴 턴은 클라이언트가 그냥 다시 보내면 돼. - Stop도, 종료 유예도 없어. 줄 선 턴을 철회할 방법이 없고, 서버를 죽이면 돌던 게 다 죽어. 재시작이 정확히 그렇게 한 게 콜드 리뷰의 2번 지적이었어.
- 입장 심사가 없어. 포트에 닿는 누구든 대화에 글을 넣을 수 있어. 비공개 턴도, 자동 작업도, 다른 사람도 거절하지 않아. 스레드가 언제 문이 되는지 정하는 것도 없고.
- 시간 제한이 없어. 모델 호출 하나가 멈추면 잠금을 쥔 채로 있고, 모든 문이 그 뒤에서 기다려.
- 기계 한 대뿐이야. 잠금 파일은 디스크 하나에 있어. 서버 두 대가 기계 두 대에 있으면 완전히 다른 답이 필요해.