엔지니어링 노트대화 엔진

턴 도중 보낸 메시지는 DB 행에 두고, 행 잠금이 가져갈 쪽을 정한다

딥 리서치는 한 턴이 기본 한 시간까지 돕니다. 턴은 질문 하나를 받아 답을 내기까지의 한 번의 질의응답입니다. 동시에 도는 조사원 넷(기본 상한)이 경쟁사 자료를 모으는 동안 사용자가 「국내 업체만 봐 주세요」를 보냈다고 합시다. 이 메시지 하나를 세 쪽이 거의 동시에 건드릴 수 있습니다. 사용자는 한 줄을 더 보내거나 취소 버튼으로 거두고, 워커는 다음 라운드에서 모델 입력에 싣고, 턴은 바로 그 순간 끝날 수도 있습니다. 셋이 엇갈리면 메시지가 어느 턴에도 실리지 않고 사라지거나, 거둔 메시지가 모델에 실리거나, 같은 말이 두 번 실립니다.

메시지를 어느 자리에 싣는지는 「에이전트 루프는 하나만 두고, 기능별 동작은 훅으로 끼운다」와 「400으로 막힌 대화, 도구 결과는 저장할 때·모양은 보낼 때 맞춘다」에서 다뤘습니다. 직전 라운드의 도구 호출에 결과가 모두 붙은 뒤, 다음 모델 호출 직전입니다. 이 글은 그다음 결정을 다룹니다. 메시지를 어디에 두고 누가 가져가는지, 실을 수 없는 자리에서는 어떻게 하는지, 실은 뒤 턴의 상한을 어떻게 세는지입니다.

턴 도중 보낸 메시지: 다음 라운드 머리에 실리거나 후속 턴으로 넘어간다
그림 1. 턴 도중 보낸 메시지: 다음 라운드 머리에 실리거나 후속 턴으로 넘어간다

메시지는 받는 즉시 저장하고, 상태는 대화마다 대기열 행 하나가 든다

가장 단순한 방법은 턴을 돌리는 워커의 메모리에 큐를 두는 것입니다. 그런데 메시지를 받는 쪽은 웹 서버이고 턴을 돌리는 쪽은 워커라서, 둘은 다른 프로세스입니다. 메모리 큐에 두면 워커가 재시작될 때(「워커를 재시작해도 돌던 턴은 답을 내거나 실패를 남긴다」) 메시지가 사라지고, 사용자가 화면을 새로 고치면 보낸 말풍선도 사라집니다.

그래서 웹 서버는 메시지를 받는 즉시 대화의 메시지로 저장합니다. 전달 상태는 짝이 되는 대기열 행이 듭니다. 상태는 「대기」와 「실림」 둘뿐이고, 대화마다 「대기」 행은 많아야 하나입니다. 대기 중에 메시지가 더 오면 새 행을 만들지 않고 기존 메시지에 이어 붙입니다. 본문은 빈 줄 하나로 잇고, 첨부와 @멘션은 합치되 같은 파일·같은 대상은 한 번만 남깁니다. 「국내 업체만」 뒤에 「상장사로 한정」을 보내면 모델은 두 줄을 한 메시지로 받습니다.

워커는 라운드 머리, 곧 직전 라운드의 호출과 결과가 모두 짝을 이룬 자리에서 이 행을 DB에서 직접 읽어 가져갑니다. DB 행 하나가 메시지가 있는 유일한 곳입니다.

이어 붙이기·가져가기·거두기는 같은 행을 잠그고 겨룬다

세 동작은 모두 「아직 대기 중」이라는 조건을 건 채 그 행을 SELECT … FOR UPDATE 로 잠급니다. 먼저 잠근 쪽이 바꾸고 커밋하면, 뒤에 온 쪽은 커밋된 상태를 다시 보고 자기 할 일을 정합니다.

먼저 잠근 쪽 뒤에 온 쪽 결과
가져가기 이어 붙이기 이어 붙일 행이 없으므로 새 대기 메시지를 만든다. 보낸 글은 한 글자도 버리지 않는다
가져가기 거두기 거두기는 「이미 실렸다」로 거절되고, 말풍선은 보통 메시지로 남는다
거두기 이어 붙이기 새 대기 메시지를 만든다
이어 붙이기 가져가기 합친 글 전체가 한 번에 실린다

이어 붙이기가 첨부를 더한 경우에는 한 번 더 조심합니다. 첨부가 든 메시지는 지금 턴에 싣지 않는데(아래 절), 가져가기가 잠금을 기다리는 사이 이어 붙이기가 첨부를 더하고 커밋할 수 있습니다. 잠그는 문장의 조건으로 첨부를 판정하면 그 문장이 시작될 때의 스냅샷, 곧 첨부가 붙기 전을 봅니다. 그래서 가져가기는 행을 잠근 뒤 메시지를 다시 읽어 판정합니다.

남은 경합은 턴이 끝나는 순간입니다. 끝나는 턴이 「남은 메시지가 없다」고 보고 손을 떼는 사이에 메시지가 접수되면, 그 메시지는 돌 턴 없이 남습니다. 이 틈은 두 번째 잠금이 막습니다. 대화마다 지금 도는 턴 하나를 적어 두는 실행 행이 있는데, 접수와 턴 종료가 모두 같은 트랜잭션에서 이 행을 먼저 잠근 뒤 대기열 행을 봅니다. 그러면 결과는 둘 중 하나입니다. 접수가 먼저면 끝나는 턴이 그 메시지를 보고 후속 턴을 만듭니다. 종료가 먼저면 접수는 「도는 턴이 없다」로 거절되고, 화면은 같은 내용을 일반 질문으로 다시 보냅니다.

사용자가 중단 버튼을 누르면 중지 표시를 실행 행에 먼저 세우고, 남은 대기 메시지를 거둬 입력창으로 돌려보냅니다. 중지 표시가 선 뒤로는 접수를 받지 않으므로, 거둔 뒤에 새 메시지가 끼어들 수 없습니다.

메시지가 실리는 순간에는 저장된 메시지의 시각을 실린 시각으로 옮깁니다. 대화 화면은 시각 순으로 그리므로, 옮기지 않으면 앞 턴이 답을 쓰던 중에 접수된 메시지가 그 답보다 위에 보입니다. 모델이 받은 순서가 곧 화면의 순서입니다.

라운드 머리의 가져가기는 2초 안에 끝나지 않으면 건너뛴다

워커는 이벤트 루프 하나에서 여러 턴을 함께 돌립니다. 가져가기를 그 루프에서 바로 돌리면, DB가 느린 동안 같은 워커의 다른 턴이 모두 멈춥니다. 그래서 가져가기는 별도 스레드에서 돌리고, 그 트랜잭션에 statement_timeout 2,000ms를 겁니다. 행 하나를 조건부로 바꾸는 일이라 평소에는 수 ms에 끝납니다.

시간을 넘기거나 DB 오류가 나면 그 라운드는 메시지 없이 진행하고 경고 로그만 남깁니다. 메시지는 대기 상태로 남아 다음 라운드 머리에서 다시 가져가고, 끝내 못 실리면 후속 턴의 요청이 됩니다. 덧붙인 말 하나를 못 가져갔다고 30분째 돌던 턴을 실패로 끝내면, 사용자는 원래 질문의 답까지 잃습니다.

딥 리서치의 총괄은 라운드 머리 밖에서 오래 기다립니다. 조사원의 결과를 기다리는 동안에는 모델을 부르지 않기 때문입니다. 이때 총괄은 2초마다 실을 메시지가 있는지 읽기만 하고, 있으면 기다림을 풀어 다음 라운드 머리에서 가져갑니다. 기다리는 자리에서 바로 가져가지 않는 이유가 있습니다. 기다림을 푼 뒤 취소나 시간 상한으로 턴이 라운드 머리까지 가지 못하면, 가져간 메시지는 「실림」 상태인데 어디에도 실리지 않습니다. 조사 계획 카드를 검토하는 중에 보낸 메시지도 같은 방식으로 읽혀, 총괄이 계획을 다시 짜는 수정 요청이 됩니다.

답을 쓰는 라운드와 첨부가 든 메시지는 후속 턴으로 넘긴다

턴에는 도구를 떼고 답만 쓰게 하는 라운드가 있습니다. 반복 상한에 닿아 지금까지 모은 내용으로 답을 쓰게 하는 라운드, 빈 응답을 한 번 다시 받는 라운드, 형식이 깨진 최종 답을 다시 받는 라운드입니다. 여기에 새 요청을 실으면 모델은 도구 없이 그 요청에 답해야 하고, 「지금 마무리하라」는 지시가 사용자 메시지보다 앞에 놓입니다. 그래서 이 라운드에는 메시지를 싣지 않습니다.

실리지 못한 메시지는 대기 상태로 남았다가, 턴이 끝나는 트랜잭션에서 후속 턴의 요청이 됩니다. 후속 턴은 도구를 모두 가진 새 턴입니다. 입력창 안내도 이 차이를 보여 줍니다. 모델이 도구를 쓰는 동안에는 「추가로 말할 것이 있으면 보내세요」, 답 글이 흐르기 시작하면 「추가로 말할 것이 있으면 보내세요 — 답이 끝나면 새 질문으로 보냅니다」가 보입니다. 턴이 실패로 끝나도 남은 메시지는 같은 길로 후속 턴이 되고, 브라우저 탭을 닫아도 서버가 후속 턴을 돌립니다.

첨부나 @멘션이 든 메시지도 후속 턴으로 보냅니다. 멘션이 정하는 조회 범위와 첨부 파일의 준비는 턴을 시작할 때 정해지기 때문입니다. 첨부 수 상한은 합친 메시지 기준으로 셉니다. 따로 세면 상한 안의 두 메시지가 합쳐져 상한을 넘는 한 턴이 됩니다. 합친 수가 상한을 넘으면 접수를 거절하고, 화면은 상한을 알려 줍니다.

후속 턴은 앞 턴이 딥 리서치였어도 일반 채팅 턴으로 돕니다. 실리지 못한 한 줄 때문에 한 시간짜리 조사를 또 돌리지 않기 위해서입니다. 조사가 다시 필요하면 사용자가 딥 리서치를 다시 고릅니다. 후속 턴을 작업 큐에 넣지 못하면 메시지를 대기열로 되돌리고, 그사이 들어온 메시지는 되돌린 메시지 뒤에 이어 붙여 보낸 순서를 지킵니다.

실린 메시지는 새 요청이라 턴의 상한을 처음부터 센다

채팅 턴에는 반복 상한 40 라운드와 시간 상한 40분이 있습니다. 메시지가 39번째 라운드에 실렸는데 상한을 이어서 세면, 모델은 라운드 하나 만에 마무리 지시를 받습니다. 새 조건으로 다시 조회해야 하는 요청이라면 답이 반쪽이 됩니다. 사용자가 새로 말한 것은 새 요청이므로, 메시지가 실린 라운드부터 턴을 처음 시작한 상태로 다시 셉니다.

다시 세는 것은 라운드 수, 경과 시간, 도구 호출 수, 같은 도구의 연속 실패 수, 그리고 「빈 응답은 한 번만 다시 받는다」처럼 턴당 한 번 쓰는 복구 장치입니다. 옛 상한에서 이미 「마무리하라」는 경고를 보냈다면, 그 경고가 대화 맥락에 남아 있으므로 실은 메시지 뒤에 경고가 무효라는 지시를 한 줄 붙입니다. 이 줄이 없으면 모델은 새 요청도 서둘러 끝냅니다.

다음은 새 말로도 바뀌지 않습니다.

  • 대화 맥락과 사고 과정의 단계 번호
  • 사용한 토큰과 비용
  • 이 턴에서 받은 DB 권한과 멘션 범위
  • 사용자가 거부한 승인, 이미 실행된 작업의 기록
  • 이미지 생성 상한(사용자 메시지 하나에 걸리는 비용 상한이라 줄이지 않는 쪽을 골랐다)

사고 과정은 메시지가 실린 자리에서 나눠 저장한다

메시지가 실리는 순간 턴은 답 위의 사고 과정에 경계 단계를 하나 남기고, 화면에 경계를 알리는 이벤트를 보냅니다. 화면은 이 경계에서 사고 과정을 끊어, 경계 앞 단계는 원래 질문 밑에, 뒤 단계는 덧붙인 메시지 밑에 접어 둡니다. 경계 단계는 턴의 단계 기록에도 그대로 저장됩니다. 그래서 지난 대화를 다시 열어도 어느 단계가 어느 메시지에 답한 것인지 같은 자리에서 갈립니다.

대기 중인 메시지를 고치려면 말풍선 옆 취소 버튼으로 거둡니다. 본문과 첨부가 입력창으로 돌아오고, 사용자가 고쳐서 다시 보냅니다. 이미 실린 메시지는 거둘 수 없습니다.

설계에서 고른 것

메모리 큐 대신 DB 행을 골랐습니다. 메모리 큐는 라운드마다 DB를 오가지 않아 빠르고 단순합니다. 대신 웹 서버와 워커가 같은 프로세스여야 하고, 워커 재시작과 새로 고침에 메시지를 잃습니다. DB 행에서는 행 잠금이 경합을 가르고, 그 대가로 라운드마다 수 ms의 DB 왕복 하나를 냅니다.

받는 즉시 끼우는 대신 라운드 머리까지 기다립니다. 대가는 지연입니다(「400으로 막힌 대화, 도구 결과는 저장할 때·모양은 보낼 때 맞춘다」). 오래 걸리는 도구가 돌고 있으면 메시지는 그 도구가 끝난 뒤에 실리고, 답 글이 이미 흐르기 시작했으면 답이 끝난 뒤 후속 턴으로 갑니다.

실린 메시지마다 턴의 상한을 새로 셉니다. 사용자가 계속 덧붙이면 한 턴이 그만큼 길어지고 비용도 늘어납니다. 그래서 메시지 접수에도 새 질문과 같은 사용량 한도 확인을 겁니다.

대기 메시지 편집 대신 거두고 다시 보내기를 골랐습니다. 편집을 넣으면 같은 행을 바꾸는 동작이 하나 더 늘어나 경합 표도 그만큼 커집니다. 거두기와 다시 보내기는 이미 있는 두 동작이고, 두 동작의 경합은 위 표가 모두 정합니다.

에이전트가 몇 분 이상 도는 채팅을 만들고 그 사이 사용자 입력을 받는다면, 메시지를 넣는 쪽과 꺼내는 쪽이 다른 프로세스인지부터 보면 됩니다. 다르다면 메시지를 둘 곳은 둘이 함께 잠글 수 있는 DB 행입니다.

어떤 문제를 해결하나요?

대화 엔진의 다른 글