엔지니어링 노트설계 원칙과 아키텍처

에이전트 루프는 하나만 두고, 기능별 동작은 훅으로 끼운다

OwlAI에서 모델이 도구를 부르며 일하는 기능은 다섯입니다. 채팅, 정해 둔 절차를 사람 없이 돌리는 플레이북, 연결한 DB의 테이블을 미리 읽어 두는 스키마 분석, 회의 중에 구간마다 요약을 쓰는 회의 요약, 딥 리서치에서 총괄이 띄우는 조사원입니다. 이 기능들을 돌리는 에이전트 루프는 도구를 실행해 결과를 돌려주면서, 최대 반복 횟수에 닿았을 때 빈 결과를 남기지 않는 규칙, 사용자가 취소하면 이미 보낸 DB 쿼리까지 멈추는 규칙처럼, 빠지면 오류 없이 결과가 틀어지는 규칙을 함께 지킵니다.

이 규칙은 어느 기능에서든 같아야 합니다. 그래서 다섯 기능이 모두 루프 하나를 돌고, 기능마다 다른 것은 시스템 프롬프트, 도구 목록, 훅, 대화 맥락을 두는 곳 네 가지로 정했습니다. 훅은 기능이 루프의 정해진 자리에 끼워 넣는 함수입니다. 이 글은 루프가 지키는 규칙과 기능별 차이를 훅으로 나타내는 방법, 그래프 프레임워크 대신 while 문 하나를 고른 이유를 다룹니다.

다섯 기능이 루프 하나를 쓰고, 기능마다 네 가지만 다르다
그림 1. 다섯 기능이 루프 하나를 쓰고, 기능마다 네 가지만 다르다

루프가 지키는 규칙은 기능마다 같아야 한다

아래에서 라운드는 모델을 한 번 부르고 그 응답에 든 도구 호출을 실행하기까지를 말합니다. 턴은 질문 하나를 받아 답을 내기까지의 한 번의 질의응답이고, 한 턴에 라운드가 여러 번 돕니다. 루프는 라운드마다 다음 규칙을 지킵니다.

규칙 빠지면 생기는 일
도구 호출마다 결과를 하나씩 붙인다. 실행을 거절한 호출에도 거절 사유를 결과로 붙인다 결과 없는 호출이 이력에 남으면 모델 프로바이더가 다음 요청 전체를 400으로 거절한다
최대 반복 횟수에 닿으면 도구를 빼고 모델을 한 번 더 불러, 지금까지 모은 내용으로 답을 쓰게 한다 그 시점의 응답은 보통 도구 호출만 있고 글이 없어서, 그대로 끝내면 빈 결과가 저장된다
같은 도구가 연달아 실패하면 멈추고 답을 쓰게 한다 진전 없이 같은 호출을 반복하며 실행 시간을 다 쓴다
결과에 실패를 뜻하는 표시가 있을 때만 실패로 센다 성공 여부 키를 싣지 않는 정상 결과가 실패로 세어져, 멀쩡한 작업이 끊긴다
사용자가 취소하면 이미 보낸 DB 쿼리까지 취소한다 쿼리가 시간 예산(timeouts.dbQuerySeconds, 기본 600초)이 다할 때까지 DB를 붙잡는다
도구 목록을 이름순으로 정렬해 보낸다 같은 도구들이 호출마다 다른 순서로 실리면 프롬프트 앞부분이 달라져 프롬프트 캐싱이 맞지 않는다

기능마다 맞춤 루프를 두면 다른 기능을 신경 쓰지 않고 빨리 내놓을 수 있습니다. 대가는 위 규칙이 루프마다 한 벌씩 생긴다는 것입니다. 최대 반복 횟수에서 답을 쓰게 하는 처리를 채팅 루프에만 넣으면, 분석 루프는 같은 자리에서 빈 결과를 저장합니다. 규칙의 뜻도 갈라집니다. 한 루프는 「결과의 ok 가 참이 아니면 실패」로 세고 다른 루프는 「오류 표시가 있으면 실패」로 세면, ok 키를 싣지 않는 정상 결과가 앞의 루프에서만 실패가 됩니다.

이런 결함은 오류 없이 틀린 결과를 남겨서 찾기 어렵습니다. 그래서 규칙은 루프 한곳에만 둡니다.

기능마다 다른 것은 프롬프트·도구·훅·이력 위치 넷이다

시스템 프롬프트와 도구 목록은 기능이 루프를 부를 때 넘깁니다. 기능만의 동작은 훅으로 끼웁니다. 훅은 비동기 함수이고, 루프가 라운드마다 정해진 자리에서 부릅니다. 주로 쓰는 자리는 다섯입니다.

자리 언제 부르나
라운드 시작 밖에서 온 입력을 이력에 실은 뒤, 이 라운드의 모델 호출 전
사용자의 새 말 사용자가 턴 도중 새로 말했을 때
모델 응답 직후 모델이 응답한 뒤, 도구를 실행하기 전
도구 없는 답 모델이 도구를 부르지 않고 답했을 때
도구 결과 반영 뒤 도구 결과를 이력에 실은 뒤

훅은 루프에 할 일을 돌려주고, 이력은 루프가 고칩니다. 이력에 붙일 항목, 실행할 호출 목록의 교체, 이 글로 끝내기, 다음 라운드부터 도구 빼기, 다음 라운드에 쓸 도구 지정입니다. 루프는 호출마다 결과가 하나씩 붙는 순서를 지키며 그 값을 반영합니다. 훅이 이력을 직접 고치면 그 순서를 훅마다 따로 지켜야 합니다.

딥 리서치에서는 총괄이 범위를 파악하는 호출을 정해진 횟수만큼 쓰고 나면, 라운드 시작 훅이 다음 라운드에 조사 계획을 사용자에게 보여 주는 도구를 쓰게 지정합니다. 스키마 분석과 회의 요약은 아무것도 하지 않는 기본 훅으로 돕니다.

넷째 차이는 대화 맥락을 두는 곳입니다. 채팅은 라운드마다 대화 트리에 노드를 만들어 이력을 DB에 남깁니다. 스키마 분석, 회의 요약, 조사원은 호출한 쪽이 들고 있는 메모리 속 목록에 이력을 쌓습니다. 루프는 「이력을 달라, 이 항목을 붙여라, 이 요청으로 모델을 불러라」 세 가지만 요청하고, 이력이 어디에 있는지는 모릅니다. 그래서 두 저장 방식에서 실패 감지와 실행 전 확인이 같은 코드입니다.

플레이북은 실행마다 목록에 보이지 않는 대화를 하나 만들고, 그 위에서 채팅 턴 하나를 돌립니다. 이력은 채팅처럼 대화 트리에 남고, 도구를 찾는 방법과 권한 확인, 기본 프롬프트가 채팅과 같습니다. 단계가 끝났다는 것은 단계를 마치는 도구 하나로 알립니다. 단계마다 루프를 띄우고 DB 조회를 서브에이전트에 맡기는 구조는 모양이 더 깔끔하지만, 서브에이전트가 부모의 이력을 보지 못해 같은 작업에서 토큰을 더 쓰고 결과도 나빴습니다.

도구는 실행 전에 세 번 걸러진다

라운드가 시작되면 루프는 실행 시간 상한과 취소를 먼저 확인합니다. 그다음 밖에서 온 입력, 곧 서브에이전트의 결과나 사용자가 턴 도중 보낸 말을 이력에 싣습니다. 직전 라운드의 호출에 결과가 모두 붙은 자리라서, 여기서 실어야 호출과 결과 사이에 다른 항목이 끼지 않습니다. 이어서 도구 목록을 이름순으로 정렬해 모델을 부릅니다.

모델이 도구를 부르면 실행 전에 세 가지를 이 순서로 확인합니다.

  1. 기능이 정한 도구 호출 총량(스키마 분석은 테이블 하나에 40회)을 넘는지 본다. 남은 만큼 앞에서부터 실행하고, 넘친 호출에는 실행하지 않은 이유를 결과로 붙인다.
  2. 다른 도구를 대신 불러 주는 호출을, 실제로 실행될 도구의 호출로 펼친다.
  3. 기능이 켠 검사가 같은 인자로 다시 부른 호출, 승인이 필요한 호출의 상한, 기능이 정한 규칙을 보고 실행 전에 거절한다.

2번은 반드시 3번보다 앞입니다. 3번과 그 뒤의 권한 확인·사용자 승인은 도구 이름과 인자를 보고 판정합니다. 펼치기 전에 판정하면 대신 불러 주는 도구만 보고 통과시킨 뒤, 승인이 필요한 실제 도구가 승인 없이 실행됩니다. 거절은 실행만 막고 루프는 계속 돕니다. 거절 사유가 도구 결과로 돌아가야 모델이 다음 라운드에 다른 방법을 고를 수 있습니다.

실행이 끝나면 실패 반복을 세고, 걸리면 도구를 빼고 답을 쓰게 합니다. 이때도 프롬프트 캐싱이 깨지지 않게 도구 목록은 그대로 보내고, 「이번에는 도구를 쓰지 말라」는 뜻만 넘깁니다.

라운드 한 번의 순서와, 도구를 빼고 답을 쓰게 하는 지점
그림 2. 라운드 한 번의 순서와, 도구를 빼고 답을 쓰게 하는 지점

반복 횟수 40은 안전장치이고, 멈추는 일은 실패 감지가 한다

최대 반복 횟수는 한 턴에 40 라운드입니다. 세는 대상은 도구 호출 수가 아니라 라운드 수입니다. 한 라운드에 도구 10개를 함께 불러도 1회로 셉니다. 플레이북은 40에 단계 수를 곱하고, 실행 전체에 따로 시간 상한(기본 2시간)을 둡니다. 사용자가 턴 도중 새로 말하면 새 요청으로 보고 횟수를 처음부터 다시 셉니다.

이 값은 실운영 환경에서 관측한 턴 2,906개로 정했습니다. 라운드 수는 p95가 14, p99가 22~26, 최대가 127이었습니다. 127 라운드를 돈 턴은 도구를 117번 불러 모두 성공한 정상적인 긴 작업이었습니다. 상한을 30으로 두면 이런 작업이 잘리고, 50으로 올려도 40보다 더 얻는 것이 없었습니다. 진전 없이 도는 턴은 라운드 수가 아니라 실패 반복에서 드러났습니다. 그래서 멈추는 일은 실패 감지가 맡고, 최대 반복 횟수는 마지막 안전장치로 둡니다.

실패 감지는 같은 도구가 5번 연달아 실패하면 멈춥니다. 인자가 달라도, 사이에 다른 도구가 끼어도 이어서 셉니다. 같은 관측에서 3번으로 잡으면 정상 턴 12개가 걸렸고, 5번 이상 연달아 실패한 턴은 5개였습니다.

무엇을 실패로 세는지와 임계 5는 모든 기능이 같은 코드를 쓰고, 감지한 뒤의 동작만 훅으로 다릅니다. 채팅과 플레이북은 도구 결과 반영 뒤 훅이 멈춘 이유를 알리는 안내와 함께 답을 쓰게 하고, 조사원은 루프가 곧바로 답을 쓰게 합니다. 조사원은 인자를 조금씩 바꿔 같은 검색을 되풀이하는 경우에도 멈춥니다. 인자만 봐서는 잡히지 않으므로, 이미 본 것과 같은 결과만 나오는 라운드가 이어지는지를 셉니다.

한 번 고치면 다섯 기능이 함께 바뀐다

취소가 한 예입니다. 루프는 자기 취소 신호를 실행 중인 도구가 읽을 수 있게 넘기고, SQL 실행 도구는 그 신호를 DB 쪽 쿼리 취소로 잇습니다. 이 연결이 루프에 있어서 채팅, 플레이북, 스키마 분석, 조사원 어디서 취소해도 이미 보낸 쿼리까지 멈춥니다(「읽기 전용과 쿼리 취소는 DB 서버에 닿아야 한다」).

큰 도구 결과의 원본을 파일로 떼어 두고 모델이 다시 읽게 하는 장치도 같습니다(「조회 결과는 모델이 다시 받아 적지 않게 파일 경로로 넘긴다」). 루프는 도구를 하나라도 싣는 실행이면 파일을 다시 읽는 도구를 기본으로 붙입니다. 그래서 스키마 분석과 회의 요약에서도 상한을 넘긴 결과를 파일에서 다시 읽을 수 있습니다.

분기는 그래프의 엣지 대신 훅이 돌려주는 값으로 쓴다

에이전트를 만들 때 LangGraph 같은 그래프 프레임워크부터 고르는 경우가 많습니다. 흐름이 한눈에 보이고, 중간 상태 저장과 중단·재개를 프레임워크가 맡아 줍니다.

도구 호출 루프는 「모델이 도구를 부르는 동안 돈다」는 while 문 하나입니다. 갈림길은 많지만 거절할지, 도구를 뺄지, 다음 라운드에 어떤 도구를 쓰게 할지처럼 모두 한 라운드 안에서 내리는 결정입니다. 이런 결정은 엣지로 그리기보다 훅이 돌려주는 값으로 쓰는 편이 읽기 쉽습니다.

스키마 분석의 두 파이프라인(테이블 분석, DB 요약)에 필요한 것도 「노드를 순서대로 돌리고, 오류가 나면 멈춘다」뿐입니다. 순환, 중간 상태 저장, 중단·재개는 쓰지 않고, 조건 분기는 모두 같은 오류 검사입니다. 이것만 쓰려고 LangGraph를 넣으면 의존 패키지 15MB와 0.58초의 불러오기 시간이 붙고, 설치형 제품 이미지에 맞지 않는 외부 SaaS 텔레메트리 SDK가 함께 따라옵니다. StateGraph는 상태 타입에 선언하지 않은 키를 노드 사이에서 버리므로, 선언을 빠뜨린 값은 오류 없이 사라집니다. 그래서 두 파이프라인은 100줄 남짓한 실행 함수로 돌고, 분기는 「다음에 어디로 가나」 대신 「이 노드를 돌리나」라는 조건 하나로 씁니다.

설계에서 고른 것

루프를 하나로 둔 것은 고친 것과 결함이 모든 기능에 함께 닿게 하려는 선택입니다. 대가로 루프를 고칠 때마다 다섯 기능의 회귀를 함께 봐야 합니다.

기능만의 동작은 정해진 훅 자리에만 두고, 새 요구가 기존 자리에 맞지 않을 때만 자리를 더합니다. 자리를 쉽게 늘리면 루프 본문이 기능별 분기로 다시 채워지기 때문입니다.

스키마 분석, 회의 요약, 조사원은 이력을 메모리에 두고 중간 지점을 저장하지 않습니다. 짧게 끝나는 작업이라 저장 구조를 단순하게 두었고, 대가로 중간에 멈춘 작업은 이어서 돌릴 지점이 없습니다.

서브에이전트를 띄우는 기능은 딥 리서치 하나입니다. 같은 루프라서 어디에나 열 수 있지만, 부모의 이력을 보지 못하는 비용보다 여러 조사를 동시에 돌리는 이득이 큰 곳은 딥 리서치뿐이었습니다.

도구를 부르는 기능이 둘 이상이고 기능 사이의 차이를 시스템 프롬프트, 도구 목록, 훅, 이력 위치로 설명할 수 있다면 루프는 하나로 충분합니다. 이미 루프가 여럿이라면 위 표의 규칙이 루프마다 어떻게 구현돼 있는지부터 대조해 봅니다. 숨어 있던 차이가 거기서 드러납니다.