Omnilude의 ai-service에서 Agent는 이렇게 동작합니다
들어가며
이 글은 이전에 작성한 Omnilude의 ai-service는 왜 Assistant / Thread / Run 구조를 택했을까의 다음 이야기입니다. 지난 글에서는 assistant, thread, run이 어떤 역할을 하는지 설명했다면, 이번에는 그 위에서 agent가 실제로 어떻게 동작하는지를 이야기해보려고 합니다.
Omnilude의 AiAgent는 어떤 거대한 마법 상자가 아닙니다. assistant를 완전히 다른 무언가로 바꾸는 계층도 아닙니다. 오히려 반대에 가깝습니다. AiAgent는 여러 assistant를 노드 단위로 묶고, 그 실행 순서를 workflow로 저장한 구조입니다.
조금 더 쉽게 말하면 이렇습니다.
AiAssistant가 개별 LLM 프리셋이라면,AiAgent는 그 프리셋들을 연결한 작업 흐름입니다.
이 글에서는 그 흐름이 코드에서 어떻게 구현되어 있는지, 가능한 한 어렵지 않게 설명해보겠습니다.
Agent는 별도 AI가 아니라 workflow입니다
가장 먼저 봐야 하는 건 AiAgent 엔티티입니다. 여기에는 이름, 설명, 타입도 들어가지만, 핵심은 workflow 필드입니다. 이 값은 별도 테이블이 아니라 JSONB로 저장됩니다.
즉, Omnilude에서 agent는 "모델 하나를 더 똑똑하게 부르는 객체"라기보다, 노드와 엣지로 표현된 실행 그래프에 가깝습니다.
예를 들어 어떤 agent는 이렇게 생길 수 있습니다.
- 시작 노드에서 입력을 받습니다.
generate-text노드에서 assistant 하나를 사용해 텍스트를 생성합니다.router노드에서 다음 경로를 결정합니다.- 마지막
finish노드에서 결과를 정리합니다.
이걸 보면 assistant와 agent의 차이가 조금 선명해집니다.
- assistant는 한 번의 추론을 위한 프리셋입니다.
- agent는 그 추론들을 어떤 순서로, 어떤 조건으로 연결할지를 담은 workflow입니다.
Agent는 별도 AI가 아니라 workflow입니다 (텍스트 참조)
AiAssistant
- 개별 LLM 호출 프리셋
- 어떤 model/provider/instructions를 쓸지 정의
AiAgent
- 여러 노드를 엮은 workflow
- 각 노드가 assistant를 사용할 수도 있고, tool처럼 동작할 수도 있음
- 핵심은 "한 번의 호출"이 아니라 "실행 순서와 연결"임
요청이 오면 바로 실행되지 않습니다
Omnilude에서 AiAgent 실행은 대부분 동기 함수 호출로 끝나지 않습니다. 여기서 중요한 역할을 하는 것이 DTE(Distributed Task Executor)입니다.
흐름은 대략 이렇습니다.
- 클라이언트나 백오피스가 agent 실행을 요청합니다.
- 서버는 그 요청을 바로 처리하지 않고 DTE job으로 바꿉니다.
- 큐에 들어간 job을
WorkflowTaskHandler가 가져갑니다. - 그다음
SingleWorkflowExecutor가 실제 workflow 실행을 맡습니다. - 내부에서
BasicWorkflowEngine이 노드를 하나씩 진행합니다.
이 구조가 중요한 이유는 단순합니다. agent 실행은 생각보다 길고, 스트리밍이 붙고, 중간 상태를 남겨야 하고, 경우에 따라 백그라운드로도 돌릴 수 있어야 하기 때문입니다. 이걸 모두 일반 요청-응답 안에 욱여넣기보다는, 아예 실행 자체를 job으로 다루는 편이 훨씬 안정적입니다.
요청이 오면 바로 실행되지 않습니다 (텍스트 참조)
Controller
-> AiAgentExecutor
-> DistributedTaskQueue
-> WorkflowTaskHandler
-> SingleWorkflowExecutor
-> BasicWorkflowEngine
핵심 포인트
- 실행을 job으로 다룬다
- 스트리밍과 백그라운드 처리가 쉬워진다
- 긴 실행도 한 구조 안에서 관리할 수 있다
시작점은 start node 하나입니다
workflow가 있다고 해서 엔진이 처음부터 끝까지 목록을 순서대로 훑는 것은 아닙니다. 실제 BasicWorkflowEngine은 먼저 startNode=true이면서 -input으로 끝나는 노드를 찾습니다. 그리고 그 노드에 초기 입력을 넣고 실행을 시작합니다.
이 부분이 저는 꽤 마음에 듭니다. agent를 "명령어 한 줄"로 보는 것이 아니라, 입력점이 분명한 실행 그래프로 보고 있기 때문입니다.
예를 들어 text-input 노드는 사용자가 넣은 문자열을 준비하고, 그 값을 다음 노드로 넘깁니다. 그다음 generate-text 노드가 이 값을 받아 LLM 호출을 일으킵니다. 그러고 나서 output은 edge를 따라 다음 노드로 전파됩니다.
즉, agent는 거대한 함수 하나가 아니라, 작은 실행 단위들이 연결된 흐름입니다.
노드를 실제로 실행하는 건 NodeExecutor입니다
여기서 진짜 중요한 계층이 NodeExecutor입니다. 각 노드는 타입 문자열을 가지고 있고, NodeExecutorFactory가 그 타입에 맞는 실행기를 찾아줍니다. 이때 NodeExecutorRegistry가 @NodeType 어노테이션을 스캔해서 어떤 실행기가 어떤 타입을 처리할지 자동으로 등록해 둡니다.
이 구조 덕분에 새 노드를 추가할 때 전체 엔진을 뜯어고칠 필요가 없습니다. 노드 하나를 만들고, 타입을 선언하고, provider를 붙이면 런타임에 편입됩니다.
대표적으로 이번 글에서 이해하면 좋은 노드는 네 개입니다.
TextInputNode: 시작 입력을 준비합니다.GenerateTextNode: assistant를 사용해 실제 텍스트를 생성합니다.RouterNode: 다음 경로를 LLM으로 판단합니다.FinishNode: 최종 결과를 모으고 실행을 끝냅니다.
이 네 개만 이해해도 Omnilude의 agent 구조가 대략 어떻게 굴러가는지 감이 옵니다.
Agent 안에서도 실제 추론은 assistant가 합니다
여기서 지난 글과 이번 글이 다시 연결됩니다.
AiAgent가 있다고 해서 agent 자체가 직접 모델을 부르는 것은 아닙니다. 실제 LLM 호출은 노드 내부에서 일어나고, 그때 다시 AiAssistantService가 등장합니다.
예를 들어 GenerateTextNode는 설정값에서 aiAssistantId를 읽습니다. 그리고 그 id로 assistant를 조회한 뒤, AiModel, AiProvider, AiApiKey까지 조합해서 RunnableAiAssistant를 만듭니다. 그다음에서야 실제 chat model 호출이 일어납니다.
즉, 구조는 이렇게 읽는 편이 맞습니다.
- assistant는 추론을 위한 부품입니다.
- agent는 그 부품들을 어떤 순서로 돌릴지 정한 workflow입니다.
이걸 이해하고 나면 assistant와 agent를 경쟁하는 개념으로 볼 필요가 없다는 점도 자연스럽게 보입니다. 둘은 대체 관계가 아니라 계층 관계입니다.
Agent 안에서도 실제 추론은 assistant가 합니다 (텍스트 참조)
AiAgent
- workflow를 가진 상위 실행 단위
Node
- 필요할 때 assistant를 선택해 호출
RunnableAiAssistant
- assistant + model + provider + key가 조합된 실제 실행 객체
/ai-runs 경로에서는 agent가 대화 문맥 위에 올라갑니다
하나 더 흥미로운 점은 /ai-runs 경로입니다. 이 경로는 단순히 stored agent를 직접 실행하는 API가 아닙니다. 먼저 thread의 message들을 읽고, 그 문맥을 바탕으로 특정 AiAgent workflow를 실행합니다.
현재 구현에서는 ChatAgentTaskHandler가 AiAgentType.USING_WEB_SEARCH_TOOL 같은 타입의 agent를 골라서 SingleWorkflowExecutor에 넘깁니다. 즉, run은 이 시점에서 단순한 LLM 호출이 아니라, 대화 문맥 위에서 agent workflow를 시작하는 명령이 됩니다.
이 지점이 Omnilude의 ai-service가 단순 모델 래퍼보다 플랫폼처럼 느껴지는 이유 중 하나입니다. 같은 추론 구조라도, thread/message/run 인터페이스 위에 얹히는 순간 제품적인 의미가 달라지기 때문입니다.
multiagent는 일부러 이번 글에서 빼겠습니다
코드를 읽다 보면 multiagent 패키지도 보입니다. 하지만 이건 지금 설명한 AiAgent workflow와는 결이 조금 다릅니다. 여러 agent를 오케스트레이션하고, 동적 라우팅과 재시도, human review 같은 더 큰 문제를 다루는 별도 레이어에 가깝습니다.
무엇보다 현재는 실제 AiAgent runtime과 완전히 같은 층위로 설명하기에는 아직 진행 중인 부분이 있습니다. 그래서 이번 글에서는 일부러 넣지 않았습니다. 이번 글의 범위는 어디까지나 assistant를 조합해 하나의 agent workflow를 실행하는 구조입니다.
이렇게 범위를 좁혀야 독자도 훨씬 이해하기 쉽고, 설명도 덜 흐려집니다.
저는 이 구조가 꽤 현실적이라고 생각합니다
다시 보면 ai-service에서 agent는 거창한 이름의 다른 AI가 아닙니다. assistant를 감싼 또 다른 wrapper도 아닙니다. 오히려 더 현실적입니다.
- 실행할 흐름은
AiAgent.workflow에 저장하고 - 실제 실행은 DTE job으로 넘기고
- 엔진은 시작 노드부터 edge를 따라 움직이고
- 각 노드는 필요할 때 assistant를 불러 LLM을 호출합니다
이 구조가 좋은 이유는 확장성이 과장되지 않았기 때문입니다. assistant, thread, run, agent가 모두 서로 다른 역할을 갖고 있고, 각각이 어디까지 책임지는지가 비교적 분명합니다.
저는 AI 기능을 제품 안에 넣을수록 이런 구조가 중요해진다고 생각합니다. 모델을 한 번 잘 부르는 것보다, 어떤 단위로 저장하고, 어떤 인터페이스로 실행하고, 어떤 흐름으로 연결할지가 더 오래 남기 때문입니다.
마무리
지난 글에서 assistant / thread / run이 Omnilude의 실행 플랫폼을 이루는 기본 개념이었다면, 이번 글의 agent는 그 위에 놓인 조합 레이어입니다. AiAgent는 별도 AI가 아니라 workflow를 저장하는 엔티티이고, 그 실행은 DTE -> WorkflowExecutor -> NodeExecutor 구조를 통해 흘러갑니다.
그리고 실제 추론은 여전히 assistant가 담당합니다. 결국 Omnilude의 ai-service는 assistant와 agent를 대립시키지 않고, assistant를 부품으로 삼아 agent를 구성하는 방식으로 커지고 있습니다.
다음에는 이 구조를 한 단계 더 실전적으로 가져가서, 실제 workflow JSON과 노드 구성을 어떻게 설계하고 있는지 예시 중심으로 풀어보겠습니다.