Omnilude의 ai-service는 왜 Assistant / Thread / Run 구조를 택했을까
들어가며
이번 포스트에서는 Omnilude의 ai-service 구현을 공유해보려고 합니다. 언제고 한 번쯤은 들어봤을 유명한 격언이 있습니다. "바퀴를 새로 만들려 하지 말아라." 물론 저도 이 말에 공감합니다. 저는 ai-service의 AI 구동 방식을 설계할 때 이전 OpenAI의 Assistants API(Beta)를 사용해본 적이 있었고, 그 경험이 나쁘지 않았습니다. 마침 더 번뜩이는 아이디어도 떠오르지 않아, 초기 구조는 거의 모방에 가깝게 차용했습니다.
OpenAI의 Assistants API 구조는 꽤 단순했습니다. assistant / thread / message / run / run step이라는 뼈대가 있었고, Assistant는 프리셋이고, Thread는 대화 컨테이너이고, Run은 실제 실행이라는 식으로 이해할 수 있었습니다. OpenAI 공식 문서의 deep dive와 FAQ에서도 이 개념 축이 분명하게 드러납니다.
다만 Omnilude는 이 구조를 그대로 복제하지는 않았습니다. 우리는 OpenAI 하나만 바라보는 서비스가 아니었고, 로컬 모델, OpenAI 호환 서버, Anthropic, Ollama, LM Studio 같은 여러 실행 경로를 한 플랫폼 안에서 다뤄야 했기 때문입니다.
그래서 Omnilude의 ai-service는 OpenAI Assistants에서 개념을 빌려오되, 실제 구현은 더 분리된 방향으로 갔습니다. 이 글에서는 그 구조를 쉽게 설명해보겠습니다.
먼저, 무엇을 저장하고 있나
Omnilude의 ai-service에는 AI 관련 객체가 여러 개 있습니다. 이름만 보면 복잡해 보이지만, 역할을 나누면 생각보다 단순합니다.
AiProvider: 어떤 제공사인지 저장합니다. OpenAI, Anthropic, Ollama, LM Studio 같은 축입니다.AiApiKey: 그 제공사에 연결할 인증 키를 저장합니다.AiModel: 실제 모델 정보입니다. 모델명, reasoning 지원 여부, 가격, 컨텍스트 같은 성격이 들어갑니다.AiAssistant: 모델을 어떤 목적과 어떤 지시사항으로 쓸지 정의한 실행 프리셋입니다.AiThread,AiMessage,AiRun,AiRunStep: 대화와 실행 기록입니다.AiAgent: 여러 assistant를 조합해 워크플로우로 실행하는 상위 레이어입니다.
한 문장으로 줄이면 이렇습니다.
Provider와 Model은 재료이고, Assistant는 조리법이고, Thread와 Run은 실제 주문과 조리 기록입니다.
이 관계를 그림으로 보면 더 이해가 쉽습니다.
OpenAI의 과거 Assistants API와 닮은 부분도 바로 여기입니다. Assistant, Thread, Message, Run, Run Step이라는 뼈대 자체는 비슷합니다. 다만 Omnilude는 provider / model / apiKey를 Assistant 바깥으로 빼서 따로 관리합니다.
왜 굳이 이렇게 나눴을까
이유는 간단합니다. Omnilude에서는 Assistant를 프롬프트 한 덩어리로 보고 싶지 않았기 때문입니다.
예를 들어 어떤 assistant를 하나 만든다고 해보겠습니다. 우리가 정말로 저장하고 싶은 것은 단순한 instructions만이 아닙니다.
- 어떤 제공사를 쓰는가
- 어떤 모델을 쓰는가
- reasoning을 지원하는가
- response format은 text인지 json인지
- temperature와 top-p는 어떻게 둘 것인가
- 나중에 모델을 바꾸더라도 어떤 모델을 원래 기준점으로 삼았는가
실제 AiAssistant 엔티티를 보면 이런 생각이 그대로 들어가 있습니다. modelId와 primaryModelId가 따로 있고, apiKeyId와 primaryApiKeyId도 따로 있습니다. 그런데 이건 단순히 원래 기준 모델을 기록해두려는 목적만은 아닙니다. 더 중요한 이유는 폴백입니다. 특정 provider가 패닉 상태에 빠지거나, 특정 키가 막히거나, 현재 연결된 모델이 불안정해졌을 때 다른 모델과 키로 넘겨서 서비스를 계속 이어가야 하기 때문입니다. 즉, 지금 붙어 있는 모델과 키, 원래 메인으로 삼는 모델과 키를 분리해 둬야 장애 상황에서도 더 견고하게 운영할 수 있습니다.
이 구조 덕분에 assistant는 단순한 프롬프트 템플릿이 아니라, 운영 가능한 실행 프리셋이 됩니다.
이게 OpenAI Assistants와 비교했을 때 Omnilude 구현이 달라지는 핵심 지점입니다. OpenAI의 Assistant는 상대적으로 model + instructions + tools를 묶어 보는 인상이 강했습니다. 반면 Omnilude는 provider와 model, api key를 먼저 독립된 자산으로 두고, assistant는 그 위에 올라가는 조합 레이어로 설계했습니다.
Assistant는 DB 엔티티로 끝나지 않는다
여기서 중요한 한 단계가 더 있습니다. 저장된 AiAssistant가 바로 실행되는 것은 아닙니다.
실제 실행 직전에 AiAssistantService가 아래 정보를 한 번에 조합합니다.
- assistant 자체의 preset 값
- 연결된 model 정보
- 연결된 provider 정보
- 암호화되어 저장된 api key
그리고 이 결과를 RunnableAiAssistant라는 실행 객체로 내려줍니다.
이 이름이 꽤 중요합니다. Omnilude에서는 DB에 저장된 assistant와 실제로 돌릴 assistant를 분리해서 봅니다.
- DB의
AiAssistant: 설정을 저장하는 엔티티 - 실행용
RunnableAiAssistant: 모델 호출에 바로 쓸 수 있는 객체
이렇게 나누면 장점이 분명합니다.
첫째, 실행 계층은 더 이상 JPA 엔티티를 신경 쓸 필요가 없습니다.
둘째, provider별 차이는 RunnableAiModel 구현 안에서 감출 수 있습니다.
셋째, reasoning 옵션, response format, sampling 파라미터를 실행 시점에 한곳에서 해석할 수 있습니다.
즉, assistant를 데이터로 저장하는 일과 assistant를 실제로 실행하는 일을 분리한 것입니다.
실제 호출은 어떻게 흘러갈까
직접 assistant를 실행하는 가장 단순한 흐름은 대략 이렇습니다.
이 흐름에서 핵심은 LlmGateway입니다. Omnilude에서는 이제 대부분의 직접 실행이 이 게이트웨이를 통과합니다.
게이트웨이가 맡는 일은 단순하지 않습니다.
- provider별 Rate Limit 적용
- 실행 시작과 종료 기록
AiRun,AiRunStep생성 및 상태 관리- 요청/응답 payload 기록
- 토큰과 비용 수집
즉, assistant.chat()를 그냥 호출하는 것이 아니라, 실행 추적이 붙은 플랫폼 호출로 감싸는 구조입니다.
이 차이가 커집니다. AI 기능을 조금만 깊게 쓰기 시작하면, 나중에는 "무슨 프롬프트를 썼는가"보다 "어떤 assistant가 어떤 모델로 얼마를 써서 어떤 결과를 냈는가"가 더 중요해지기 때문입니다.
Thread, Message, Run을 왜 남겼을까
여기서 많은 분들이 한 번 의문을 가질 수 있습니다.
"어차피 assistant를 바로 실행할 수 있다면, 왜 굳이 Thread와 Message, Run까지 따로 두는 걸까?"
이 부분이 저는 Omnilude 구조에서 꽤 중요하다고 생각합니다.
Assistant는 프리셋입니다. 하지만 실제 사용자 경험은 프리셋 하나로 끝나지 않습니다. 사용자는 대화를 이어가고, 메시지를 쌓고, 특정 시점의 입력에 대해 실행을 요청하고, 결과를 다시 메시지로 받습니다.
Omnilude에서는 이 흐름을 다음처럼 분리합니다.
AiThread: 대화방AiMessage: 사용자와 assistant가 주고받는 턴AiRun: 특정 메시지를 기준으로 한 실행 요청AiRunStep: 그 실행 내부의 세부 추적
이건 OpenAI Assistants가 보여줬던 구조를 실제 제품 런타임에서 꽤 현실적으로 가져온 사례라고 생각합니다.
예를 들어 클라이언트는 먼저 thread를 만들고, 그 안에 user message를 쌓고, 그 다음 /ai-runs를 호출합니다. 그러면 이 시점부터는 단순한 chat completion 호출이 아니라, 대화 문맥을 가진 실행 단위가 됩니다.
이 차이가 왜 중요할까요.
첫째, 실행 기록을 대화와 연결해 볼 수 있습니다. 둘째, 같은 thread에서 어떤 시점부터 다시 시작할지 관리할 수 있습니다. 셋째, 제목 생성, 즐겨찾기, 리셋 같은 부가 기능을 thread 레벨에서 붙일 수 있습니다.
AI 서비스를 제품으로 만들수록, 단순 호출보다 이런 인터페이스가 더 오래 남습니다.
그런데 Run을 누르면 정말 Assistant 하나만 도는 건 아니다
여기서 Omnilude가 OpenAI Assistants와 또 달라집니다.
OpenAI Assistants의 인상은 비교적 분명했습니다. Assistant가 있고, Thread가 있고, Run이 그 assistant를 thread 위에서 실행하는 구조입니다.
하지만 Omnilude의 /ai-runs는 지금 더 workflow 쪽에 가깝습니다. 실제로 AiRunController는 바로 모델을 호출하지 않고 DTE 큐에 작업을 넣습니다. 그리고 ChatAgentTaskHandler가 이 작업을 받아 thread의 메시지를 읽고, AiAgent의 workflow를 실행합니다.
즉, 현재 Omnilude에서 Run은 "assistant 하나를 호출한다"에만 머물지 않습니다. 경우에 따라서는 assistant 여러 개가 들어 있는 workflow를 실행하는 시작점이 됩니다.
이렇게 보면 구조가 더 또렷해집니다.
- Assistant: 하나의 LLM 프리셋
- Agent: assistant들을 조합한 워크플로우
- Run: 그 워크플로우를 실제로 시작하는 실행 단위
이 차이 때문에 Omnilude의 ai-service는 단순한 assistant 저장소보다 한 단계 더 플랫폼에 가까워집니다.
모든 경로가 완전히 같은 것은 아니다
현실적인 얘기도 하나 남겨두고 싶습니다. 이 구조가 잘 정리되어 있다고 해도, 현재 모든 진입점이 똑같은 실행 경로를 타는 것은 아닙니다.
예를 들어 backoffice playground나 direct inference 쪽은 LlmGateway를 타면서 추적과 비용 기록이 비교적 잘 붙습니다. 반면 내부 시스템 채팅 API인 /system/ai/chat는 더 직접적인 경로를 사용합니다. 이 경로는 assistant를 로드한 뒤 rate limiter를 적용해 바로 chat을 호출하는 방식이라, gateway 기반 추적 구조와는 약간 결이 다릅니다.
저는 이런 부분도 오히려 좋다고 생각합니다. 실제 플랫폼은 처음부터 완벽하게 하나의 패턴으로 정리되기보다, 사용처가 늘어나면서 점점 공통 구조로 수렴해 가는 경우가 많기 때문입니다.
중요한 건 지금도 중심 축은 이미 잡혀 있다는 점입니다.
- 관리 객체는 분리되어 있고
- 실행 객체는
RunnableAiAssistant로 통일되고 있으며 - 대화와 실행 기록은
Thread / Message / Run / RunStep으로 남고 있고 - 상위 orchestration은
AiAgent가 맡고 있습니다.
저는 이 구조가 꽤 마음에 듭니다
이 구조를 다시 보면, Omnilude의 ai-service는 단순히 "모델을 잘 부르는 서비스"가 아닙니다.
이 서비스는 다음 네 가지를 동시에 하려고 합니다.
- 여러 provider와 model을 하나의 플랫폼에서 다루기
- assistant를 재사용 가능한 실행 프리셋으로 만들기
- 대화와 실행을 thread/message/run 구조로 남기기
- 나중에는 assistant를 조합해 agent workflow로 확장하기
저는 이 방향이 꽤 현실적이라고 생각합니다. AI 기능을 제품에 넣기 시작하면, 결국 필요한 것은 모델 호출 함수 몇 개가 아니라 실행 가능한 인터페이스이기 때문입니다.
그리고 그 인터페이스는 생각보다 OpenAI Assistants가 한때 보여줬던 개념 구조와 닮아 있습니다. 다만 Omnilude는 거기서 한 걸음 더 나아가, provider와 model을 분리하고, tracking과 workflow를 더 강하게 끼워 넣는 쪽으로 발전시킨 셈입니다.
마무리
Omnilude의 ai-service에서 AiAssistant는 단순한 프롬프트 저장소가 아닙니다. 어떤 제공사와 어떤 모델을 어떤 방식으로 호출할지, 그리고 그 결과를 어떤 형식으로 받아 어떤 런타임 위에서 기록할지를 묶어 둔 실행 프리셋에 가깝습니다.
그리고 AiThread, AiMessage, AiRun, AiRunStep은 그 프리셋을 실제 제품 흐름 안에서 굴리기 위한 런타임 인터페이스입니다. 여기에 AiAgent가 올라가면서, 이제 Omnilude의 ai-service는 assistant 몇 개를 관리하는 단계를 넘어 workflow를 운영하는 플랫폼으로 커지고 있습니다.
다음에는 이 이야기에서 한 걸음 더 나아가, assistant를 조합해서 agent를 만드는 과정을 들고 찾아뵙겠습니다.