엔지니어링 노트대화 엔진

고친 질문에 옛 답이 섞이지 않게 대화를 트리로 저장하기

사용자가 「지난달 거래처별 매출 상위 10곳」을 물었는데, 모델이 국내 거래처만 담긴 테이블을 조회해 답했다고 해 보겠습니다. 사용자는 질문을 「해외 법인을 포함한 지난달 거래처별 매출 상위 10곳」으로 고쳐 다시 보냅니다. 이때 다음 요청에 옛 질문, 국내만 조회한 SQL과 그 결과, 옛 답이 그대로 남아 있으면 모델은 이미 맥락에 있는 숫자를 다시 쓸 수 있습니다. 고친 질문에 옛 매출 숫자가 섞인 답이 나오고, 그 답은 그대로 보고서에 들어갑니다.

OwlAI는 대화 맥락을 메시지 목록 하나 대신 트리로 저장합니다. LLM 호출 하나를 노드 하나로 저장하고, 노드마다 부모 노드를 기록해 대화 트리를 만듭니다. 대화 레코드는 지금 대화의 끝 노드를 가리키는 포인터 하나만 듭니다. 다음 요청은 그 끝 노드에서 부모를 따라 루트 노드까지 거슬러 올라가며 조립합니다. 그래서 질문을 고치는 일은 새 노드를 앞선 노드 아래에 붙이고 포인터를 옮기는 일이 되고, 옛 답을 지우는 코드는 따로 없습니다. 이 글은 이 구조와, 플레이북 변환·실패한 호출·대화 압축이 같은 구조에서 어떻게 정해지는지를 다룹니다.

메시지 목록이라면 고칠 때마다 옛 답을 잘라 내야 한다

메시지 목록은 자연스러운 첫 선택입니다. 화면의 말풍선이 목록이고, 프로바이더 API도 메시지 배열을 받습니다. 그런데 모델에게 보내는 대화 맥락에는 말풍선 말고도 도구 호출과 그 결과, 추론 블록이 들어 있습니다. 질문 하나에 답 하나가 끝나기까지를 턴, 모델을 한 번 부르고 그 응답의 도구 호출을 실행하기까지를 라운드라고 하면, 한 턴의 목록에는 라운드마다 이런 항목이 쌓입니다.

이 목록에서 질문을 고치려면 고친 질문 뒤의 항목을 모두 잘라 내야 합니다. 자르는 위치를 하나만 잘못 잡아도 문제가 큽니다. 도구 호출은 남고 그 결과가 잘려 나가면, 프로바이더는 결과 없는 호출이 든 요청 전체를 400으로 거절합니다. 그 뒤로는 그 대화의 모든 턴이 같은 이유로 실패합니다. 잘라 낸 옛 답은 사라지므로, 사용자가 두 답을 비교하려면 목록을 통째로 복사해 두어야 합니다.

같은 문제가 세 군데서 더 생깁니다.

상황 목록이라면 해야 할 일
대화를 플레이북으로 바꾸는 호출 대화 맥락은 다 봐야 하지만, 초안과 검증 오류를 고치는 재시도가 본 대화에 남으면 안 된다. 목록을 복사하거나, 붙였다가 지워야 한다
라운드 도중 실패한 호출 이미 받은 도구 결과를 맥락에 남길지 정해야 하고, 남기면 결과 없는 호출을 따로 걸러야 한다
긴 대화의 압축 압축한 맥락으로 목록을 덮어쓰면 원본이 사라진다. 다른 프로바이더의 모델로 바꾸면 원본이 다시 필요하다(「400으로 막힌 대화, 도구 결과는 저장할 때·모양은 보낼 때 맞춘다」)

네 경우 모두 목록을 복사하거나 일부를 지우는 코드가 따로 필요하고, 그 코드마다 도구 호출과 결과의 짝을 지켜야 합니다.

호출 하나를 노드 하나로 저장한다

그래서 저장 단위를 LLM 호출 하나로 정했습니다. 노드 하나에 기록하는 값은 다음과 같습니다.

  • 부모 노드 ID
  • 이 호출에서 새로 보낸 항목(사용자 메시지, 직전 라운드의 도구 결과 등)
  • 모델이 돌려준 항목(답, 도구 호출, 추론 블록)
  • 노드 종류(대화 턴인지, 플레이북 변환인지)
  • 프로바이더와 모델 이름, 상태(대기·성공·실패), 토큰 사용량

호출 직전에 노드를 대기 상태로 만들고, 응답을 받으면 모델이 돌려준 항목과 함께 성공으로 바꿉니다. 한 턴의 라운드들은 한 줄로 이어진 노드가 되고, 턴이 끝나면 대화 레코드의 끝 노드 포인터가 마지막 노드로 옮겨 갑니다. 대화를 지우면 그 대화의 노드도 함께 지워집니다.

도구 결과는 다음 노드에 실립니다. 노드 N이 돌려준 도구 호출의 결과는 노드 N+1이 새로 보낸 항목에 들어 있습니다. 호출과 결과의 짝이 노드 경계를 넘나들기 때문에, 어느 노드까지를 경로에 넣느냐가 그대로 짝이 맞느냐를 정합니다. 뒤에서 실패한 호출을 다룰 때 이 점이 중요해집니다.

다음 요청은 끝 노드에서 루트 노드까지 거슬러 올라가 조립한다

호출을 보낼 때는 끝 노드에서 부모를 따라 루트 노드까지 올라갑니다. PostgreSQL 재귀 CTE 한 번으로 경로 전체를 읽고, 루트 노드부터 차례로 각 노드의 보낸 항목과 받은 항목을 이어 붙입니다. 맨 뒤에 이번 호출의 새 항목을 붙이면 요청이 완성됩니다.

경로에 실패했거나 끝나지 않은 노드가 있으면, 그 노드가 받은 항목은 빈 것으로 칩니다. 보낸 항목은 남고 받은 항목만 빠지므로, 그 노드가 냈을지 모를 도구 호출은 맥락에 섞이지 않습니다.

경로 밖의 노드는 조립에 아예 나타나지 않습니다. 같은 부모 아래에 분기가 몇 개 있든, 끝 노드에서 루트 노드로 가는 한 줄만 맥락이 됩니다. 분기를 만들 때 무엇을 복사하거나 지울 필요가 없습니다.

조립의 마지막 단계는 결과 없는 도구 호출을 걸러 냅니다. 정상 경로에서는 아무것도 바꾸지 않고, 걸러 낸 것이 있으면 결함으로 보고 경고 로그를 남깁니다. 이 짝 맞추기는 「400으로 막힌 대화, 도구 결과는 저장할 때·모양은 보낼 때 맞춘다」에서 다룹니다.

대화 트리: 끝 노드에서 루트 노드까지의 경로만 다음 요청에 실린다
그림 1. 대화 트리: 끝 노드에서 루트 노드까지의 경로만 다음 요청에 실린다

질문을 고치면 앞선 노드에서 새 분기가 갈라진다

사용자는 지난 질문을 고쳐 다시 보내거나, 답 아래의 다시 생성으로 같은 질문에 새 답을 받을 수 있습니다. 둘 다 끝 노드 포인터를 앞선 노드로 옮기는 일입니다. 질문을 고치면 포인터는 그 질문 직전 턴의 마지막 노드로 돌아가고, 고친 질문이 그 노드를 부모로 새 노드를 만듭니다. 다시 생성은 같은 자리로 돌아가 원래 질문을 그대로 다시 보냅니다.

앞의 매출 예에서 국내만 조회한 SQL과 그 결과, 옛 답은 옛 분기에 남습니다. 새 분기의 요청은 끝 노드에서 루트 노드로만 올라가므로 옛 분기의 노드를 지나지 않습니다. 모델은 해외 법인을 포함한 질문을 처음 받은 것처럼 테이블을 다시 고르고 SQL을 새로 씁니다.

옛 분기는 지우지 않습니다. 분기가 생긴 말풍선에는 「2/3」 같은 표시와 좌우 화살표가 붙고, 화살표를 누르면 끝 노드 포인터가 그 분기의 마지막 노드로 옮겨 갑니다. 사용자는 분기를 오가며 두 답을 비교하고, 원하는 분기에서 대화를 이어 갑니다. 이어 쓰는 분기가 곧 다음 요청의 맥락입니다.

이 동작에는 경계가 둘 있습니다.

  • 턴이 도는 중에는 포인터를 옮기지 않습니다. 실행 중인 턴은 시작할 때의 끝 노드 아래로 노드를 붙이는데, 그사이 포인터가 다른 분기로 가면 턴이 끝날 때 포인터를 어디에 둘지 정할 수 없습니다. 그래서 질문 수정, 다시 생성, 분기 전환은 진행 중인 턴이 없을 때만 받습니다.
  • 압축 노드보다 앞의 질문은 고칠 수 없습니다. 압축 노드는 아래에서 다룹니다. 그 앞을 고치면 압축 전 원본에서 다시 시작해야 하고, 그 분기는 압축하기 전 크기의 맥락을 다시 들고 갑니다.

플레이북 변환·실패·압축도 부모와 포인터로 정한다

트리 위에서는 플레이북 변환, 실패, 압축이 모두 두 가지로 정해집니다. 새 노드의 부모를 어디에 두는지, 끝 노드 포인터를 어디로 옮기는지입니다.

상황 새 노드의 부모 끝 노드 포인터
질문 수정·다시 생성 고친 질문 직전 턴의 마지막 노드 새 분기의 마지막 노드로
플레이북 변환 대화의 끝 노드, 재시도는 직전 변환 노드 움직이지 않음
호출 실패(취소 포함) 직전 노드 실패한 노드로
컨텍스트 초과로 실패 직전 노드 마지막 성공 노드에 머묾
대화 압축 없음(새 루트 노드) 압축 노드 아래로 이어짐

플레이북 변환은 대화 맥락 전체를 보고 초안을 쓰고, 검증에 걸리면 오류를 보고 기본 세 번까지 다시 고칩니다. 변환 노드는 종류를 대화 턴과 다르게 표시하고, 재시도는 직전 변환 노드를 부모로 삼아 한 줄로 이어집니다. 끝 노드 포인터는 움직이지 않으므로 초안 JSON과 검증 오류는 다음 질문의 맥락에 들어가지 않습니다. 만든 플레이북을 대화로 고치는 편집 대화를 처음 열 때는 새 대화의 끝 노드 포인터를 원래 대화의 끝 노드로 정하기만 합니다. 대화 맥락은 한 줄도 복사하지 않았는데, 첫 편집 턴이 원래 대화의 전체 맥락 위에서 시작합니다. 원래 대화가 그 뒤로 더 진행돼도 새 노드는 원래 대화 쪽에만 붙으므로 편집 대화에는 섞이지 않습니다.

호출이 실패하면 포인터는 실패한 노드로 옮겨 갑니다. 그 노드가 받은 항목은 비어 있지만, 보낸 항목에는 직전 라운드의 도구 결과가 들어 있습니다. 포인터를 마지막 성공 노드에 두면 이 결과가 경로 밖으로 빠지고, 사용자가 같은 질문을 다시 하면 이미 비용을 낸 웹 검색을 처음부터 다시 부릅니다. 실패한 노드를 끝 노드로 삼으면 다음 턴이 이미 받은 결과를 보고 이어 갑니다.

컨텍스트 초과로 실패한 노드만은 끝 노드로 삼지 않습니다. 초과의 원인이 그 노드가 보낸 항목일 수 있습니다. 상한을 넘는 큰 도구 결과가 그대로 실린 경우가 그렇습니다. 그 노드를 끝 노드로 삼으면 다음 턴마다 같은 항목이 조립돼 대화가 영영 막힙니다. 그래서 포인터는 마지막 성공 노드에 머물고, 실패한 노드는 분기로 남습니다.

압축은 경로를 끊습니다. 대화 맥락을 압축하면 압축한 맥락을 든 노드를 부모 없이 새로 만들고, 다음 호출은 이 노드를 부모로 삼습니다. 이 노드를 압축 노드라고 하겠습니다. 이후 조립은 압축 노드에서 멈추고 압축 전 노드까지 올라가지 않습니다. 압축 전 노드는 지우지 않고, 압축 노드에 「압축 전 원본은 이 노드」라는 포인터를 따로 남깁니다. 평소에는 쓰지 않다가, 대화 모델을 다른 프로바이더의 모델로 바꿔 이전 압축 결과를 읽을 수 없을 때 이 포인터를 따라 원본을 펼칩니다. 압축 시점과 방법은 「압축 임계값은 컨텍스트 윈도우가 아니라 입력 상한에서 잡는다」에서, 모델을 바꿀 때 원본을 펼치는 일은 「400으로 막힌 대화, 도구 결과는 저장할 때·모양은 보낼 때 맞춘다」에서 다룹니다.

설계에서 고른 것

옛 분기를 남기는 쪽을 골랐습니다. 고치기 전의 답, 실패한 라운드, 끝난 변환 호출, 압축 전 원본이 모두 남아서, 그때 모델이 무엇을 보고 답했는지 다시 짜 맞출 수 있습니다. 대가는 저장 공간이고, 노드는 대화를 지울 때 함께 지워집니다.

조립은 호출마다 재귀 쿼리 한 번입니다. 매번 경로를 다시 읽는 비용을 내는 대신 목록을 복사하거나 잘라 내는 코드가 없습니다. 압축 노드가 경로를 끊으므로 긴 대화에서도 읽는 경로가 끝없이 길어지지 않습니다.

실패한 노드를 끝 노드로 삼는 것은 이미 받은 도구 결과를 지키려는 선택입니다. 컨텍스트 초과만은 예외로 두었습니다. 결과 한 라운드를 지키려다 대화 전체가 막히는 쪽이 더 비싸서, 이 경우 그 라운드의 도구 결과를 잃는 것을 받아들였습니다.

분기를 바꿔도 이미 일어난 일은 되돌리지 않습니다. 옛 분기에서 메일을 보냈거나 외부 시스템에 글을 썼다면 그 결과는 남습니다. 밖으로 무언가를 보내는 도구가 실행 전에 사용자 승인을 받는 이유 가운데 하나입니다(「확인하라고 일러도 모델은 메일을 보낸다, 실행 직전에 승인을 받는다」).

화면의 말풍선은 트리와 따로 저장합니다. 화면 표시와 모델 입력은 담는 것이 다릅니다. 말풍선은 도구 결과 원문이나 추론 블록을 들지 않고, 모델 입력은 화면에만 쓰는 차트 데이터를 들지 않습니다. 말풍선마다 자기가 속한 노드를 기록해 두고, 화면은 끝 노드에서 루트 노드로 가는 경로의 말풍선만 그립니다. 분기 표시도 이 기록에서 나옵니다. 저장소가 둘인 대가로, 대화를 비우는 기능은 두 쪽을 함께 지워야 합니다. 화면만 비우고 포인터를 남기면 다음 턴이 옛 맥락을 그대로 이어받고, 사용자에게는 「지웠는데 기억한다」로 보입니다.

모델 호출에 도구 결과와 추론 블록이 섞이고, 같은 맥락 위에서 질문 수정이나 플레이북 변환 같은 별도 호출, 압축을 해야 한다면 목록보다 호출마다 노드 하나인 트리가 다루기 쉽습니다. 도구 없이 메시지만 주고받고 고친 질문 뒤를 버려도 되는 채팅이라면 목록으로 충분합니다.