엔지니어링 노트대화 엔진

도구가 다 성공해도 같은 결과만 돌아오면 에이전트를 멈춘다

에이전트가 부르는 도구가 매번 성공하는데도 일이 나아가지 않을 때가 있습니다. 문서 검색 도구에 검색어를 조금씩 바꿔 넣으면 인자는 매번 다르지만 돌아오는 문서는 같습니다. 실운영 환경에서 관측한 한 실행은 이렇게 문서 검색을 21라운드, 19분 동안 되풀이했고, 그동안 어떤 상한에도 걸리지 않았습니다. 같은 도구가 연달아 실패하면 멈추는 실패 감지(「에이전트 루프는 하나만 두고, 기능별 동작은 훅으로 끼운다」)는 이 반복을 보지 못합니다. 실패한 호출이 하나도 없기 때문입니다.

그래서 라운드마다 새로 안 사실이 있는지를 따집니다. 라운드는 모델을 한 번 부르고 그 응답에 든 도구 호출을 실행하기까지입니다. 같은 인자로 다시 부른 호출은 실행 전에 거절하고 이유를 돌려주고, 돌아온 결과가 모두 이미 본 것인 라운드가 5번 이어지면 도구를 빼고 지금까지 모은 내용으로 답을 쓰게 합니다. 이 글은 무엇을 새 사실로 치는지, 판정이 애매할 때 어느 쪽으로 기울게 했는지를 다룹니다.

같은 호출이 이어지는지만 보면 검색어를 바꾸는 반복을 놓친다

반복을 잡는 흔한 방법은 같은 도구를 같은 인자로 연달아 부르는지 세는 것입니다. OwlAI의 채팅도 이 검사를 둡니다. 바로 앞 호출과 도구·인자가 같은 호출이 세 번째로 오면 실행 전에 막습니다(toolPolicy.loopDetectThreshold, 기본 3). 사이에 다른 도구가 끼면 처음부터 다시 셉니다. 페이지를 넘기며 읽는 호출은 인자가 달라 걸리지 않으므로 오탐이 거의 없습니다.

막은 호출에는 오류 코드와 함께 다음에 할 일을 돌려줍니다. 코드만 돌려주면 모델은 「막혔다」를 「할 수 없다」로 읽고 포기합니다. 실운영 환경에서는 커넥터의 사이트 ID를 이미 두 번 받아 둔 모델이, 세 번째 호출이 코드만 받고 막히자 그 값을 쓰지 않고 「연결 정보만 확인돼 이슈를 가져오기 어렵다」며 턴을 끝냈습니다. 그래서 거절 사유는 이미 받은 결과가 대화에 있으니 그 값을 쓰고, 다른 도구가 필요하면 그 도구를 켜서 이어 가라고 알려 줍니다.

한계는 인자만 본다는 데 있습니다. 앞의 문서 검색은 라운드마다 검색어가 달라서 이 검사에 한 번도 걸리지 않습니다. 같은 문서를 되받는 동안 다른 도구를 한 번 섞어 불러도 카운터가 0으로 돌아갑니다. 진전이 없다는 것은 인자가 아니라 결과에서 드러납니다.

그래서 딥 리서치의 조사원 루프는 두 가지를 더 봅니다. 같은 호출을 연달아 부르는지가 아니라 이번 실행에서 한 번이라도 부른 적이 있는지를 보고, 라운드마다 돌아온 결과가 이미 본 것인지를 봅니다.

한 번 실행한 호출은 다시 실행하지 않는다

조사원은 이번 실행에서 이미 실행한 호출과 도구·인자가 같은 호출을 실행하기 전에 거절합니다. 조사원은 승인이 필요 없는 도구만 받으므로, 같은 호출은 같은 결과를 돌려준다고 봅니다. 조사원의 시스템 프롬프트에도 같은 인자로 다시 부르지 말라고 적혀 있지만, 모델이 그 지시를 어겨도 이 검사가 막습니다.

인자는 글자가 아니라 값으로 비교합니다. JSON 키의 순서와 공백은 모델이 그때그때 다르게 씁니다. 같은 호출이 두 경로로 들어오기도 합니다. 모델이 쓴 인자 그대로 오는 경로와, 저장해 둔 파일을 인자로 펼치면서 텍스트를 다시 쓰는 경로입니다. 글자로 비교하면 두 경로를 번갈아 쓰는 반복을 놓칩니다.

거절은 실행만 막고 루프는 계속 돕니다. 거절 사유는 도구 결과로 돌아가고, 사유에는 다음에 할 일을 적습니다. 조사원이 받는 사유는 같은 인자로 이미 부른 호출이니 인자나 방법을 바꾸라는 것입니다. 어떤 호출이 실행 전에 거절되는지는 시스템 프롬프트 끝에 미리 적어 두어, 모델이 거절을 받기 전에 피할 수 있게 합니다.

같은 인자로 다시 부르는 것이 사용 규약인 도구는 이 검사에서 뺍니다. 서브에이전트가 끝나기를 기다리는 도구는 인자가 기다릴 대상 하나뿐이라, 다시 기다리면 인자가 늘 같습니다. 이 도구를 검사에 넣으면 한 번의 기다림이 끝난 뒤 다시 기다리는 호출이 계속 거절되고, 루프는 진전이 없다고 보고 멈추면서 아직 일하던 서브에이전트까지 취소합니다. 앞 절의 채팅 검사도 이 도구를 세지 않습니다. 딥 리서치의 총괄은 채팅 턴에서 조사원을 띄우므로, 이 도구를 세면 오래 일하는 조사원을 세 번째로 기다리는 호출이 막힙니다. 두 검사가 뺄 도구는 그 도구가 스스로 밝힙니다. 기능마다 뺄 도구 목록을 적게 하면 새 기능이 생길 때 목록이 빠집니다.

결과가 모두 이미 본 것인 라운드가 5번 이어지면 멈춘다

조사원 루프는 도구 결과마다 지문(fingerprint)을 만듭니다. 지문은 결과 내용으로 만든 해시이고, 실행이 끝날 때까지 쌓아 둡니다. 한 라운드의 결과 가운데 처음 보는 지문이 하나라도 있으면 그 라운드는 새 사실을 가져온 것입니다. 하나도 없으면 「이미 본 결과뿐인 라운드」로 세고, 이런 라운드가 5번 이어지면 도구를 빼고 답을 쓰게 합니다. 새 사실이 하나라도 나오면 0으로 돌아갑니다.

검색어는 바뀌어도 이미 본 결과뿐인 라운드가 5번 이어지면 멈춘다
그림 1. 검색어는 바뀌어도 이미 본 결과뿐인 라운드가 5번 이어지면 멈춘다

도구별이 아니라 라운드 단위로 셉니다. 조사원은 한 라운드에 도구를 여러 개 부릅니다. 한 도구가 같은 결과를 돌려줘도 같은 라운드의 다른 도구가 새 문서를 찾았다면 그 라운드는 나아간 것입니다.

0건과 형식을 모르는 결과는 새 사실로 친다

결과가 비어 있으면 지문을 만들지 않고 새 사실로 칩니다. 「0건」이 연달아 오는 것은 정상 탐색입니다. 다른 기간, 다른 키워드로 찾아도 없다는 것을 확인하는 과정인데, 빈 결과끼리 같다고 묶으면 5라운드 만에 이 확인이 끊깁니다.

빈 결과인지는 결과의 구조로 판정합니다. 0건을 나타내는 모양은 도구마다 다릅니다. {"items": [], "count": 0} 도 있고 {"rows": [], "rowCount": 0} 도 있습니다. 「이 키가 비면 0건」 같은 키 목록을 두면 도구가 늘 때마다 조용히 틀리기 시작합니다. 그래서 값 전체를 훑어 빈 컨테이너, 빈 문자열, 0, false, null 만 있으면 사실이 없는 결과로 봅니다.

새 사실로 치는 경우가 두 가지 더 있습니다.

  • 지문을 만들 수 없는 결과. 형식을 모르는 결과를 모두 「이미 봤다」로 묶으면 처음 쓰는 도구가 정상 실행을 끊습니다.
  • 승인이 필요한 도구의 결과. 같은 응답이 와도 메일 발송이나 티켓 생성 같은 일은 부를 때마다 새로 일어났습니다.

승인이 필요한지는 관리자가 도구 정책에 적은 값으로 가릅니다. 도구가 스스로 밝히는 「읽기 전용」 표시로 가르면, 그 표시는 선택 항목이라 표시를 달지 않은 커넥터의 조회 도구가 전부 예외가 됩니다. 실운영 환경의 도구 목록에서는 이런 조회 도구가 5종이었고, 그 도구들에서는 결과 비교가 아예 돌지 않게 됩니다.

판정이 애매한 경우는 모두 새 사실 쪽으로 기울였습니다. 잘못 판정하면 루프는 멈추지 않고 계속 돕니다. 계속 도는 실행은 실패 감지, 라운드 상한, 실행 시간 상한이 끊지만, 잘못 끊긴 정상 실행은 되살릴 수 없습니다.

거절만 받은 라운드는 따로 센다

한 라운드의 호출이 전부 거절되면 아무것도 실행되지 않고, 모델은 거절 사유만 받습니다. 이런 라운드도 5번 이어지면 멈춥니다. 모델이 사유를 읽고도 같은 호출을 되풀이하는 동안 남는 것은 모델 호출 비용뿐이기 때문입니다.

이 카운터는 이미 본 결과를 세는 카운터와 따로 둡니다. 거절 카운터는 호출이 하나라도 실행되면 0으로 돌아가고, 결과 카운터는 새 사실이 나와야 0으로 돌아갑니다. 하나로 합치면 「실행은 되지만 결과는 이미 본 것」인 라운드가 거절 쪽 리셋에 지워져, 결과 쪽 반복을 끝내 세지 못합니다.

거절도 도구별이 아니라 라운드 단위로 셉니다. 도구별로 세면 생산적인 라운드 사이사이에 흩어진 거절이 그 도구의 카운터에 쌓입니다. 그 카운터는 같은 도구가 성공해야만 0으로 돌아가므로, 다른 도구로 나아가던 실행도 끊깁니다. 실측에서는 다른 도구가 5번 성공하며 나아가던 실행이 이 방식으로 11라운드에서 끊겼습니다. 라운드 단위로 세면 무엇이든 실행된 순간 0으로 돌아가므로, 정말 아무것도 못 하는 경우에만 걸립니다.

과부하로 실패한 호출은 예산에서 빼되 진전으로 치지 않는다

조사원은 총괄이 정해 준 도구 호출 예산 안에서 일합니다. 도구 서버가 요청 한도에 걸려 실패한 호출은 아무것도 돌려주지 않았는데 이 예산을 깎습니다. 그대로 두면 혼잡이 지나간 뒤 쓸 호출이 남지 않습니다. 그래서 과부하로 실패한 호출은 예산 하나당 agentLoop.overloadedToolCallsExemptMax(기본 3)건까지 호출 수에서 빼고, 그런 호출이 있었던 라운드 수만큼 라운드 상한도 늘립니다. 조사원의 라운드 상한은 예산에 맞춰 잡혀 있어서, 라운드가 늘지 않으면 돌려받은 호출을 쓸 수 없습니다.

예산에서 빼도 진전으로 치지는 않습니다. 과부하 실패는 실패 감지에서 그대로 실패로 세고, 매번 같은 오류가 오므로 결과 비교에서도 이미 본 결과입니다. 과부하에 걸린 도구를 끝없이 다시 부르는 일은 이 두 판정이 막습니다.

설계에서 고른 것

연속 실패, 거절만 받은 라운드, 이미 본 결과뿐인 라운드는 모두 같은 임계값 5를 씁니다. 셋 다 「다섯 번 해도 나아가지 않으면 그만둔다」는 판단이고, 값을 달리 줄 근거를 찾지 못했습니다. 조절할 값을 셋으로 나누면 조합마다 검증할 것만 늘어납니다. 대가로 셋을 따로 조절할 수 없습니다.

판정은 멈추지 않는 쪽으로 기울였습니다. 0건, 형식을 모르는 결과, 승인이 필요한 도구의 결과는 모두 새 사실입니다. 대가로 응답마다 조회 시각처럼 조금씩 다른 값이 실리는 도구는 내용이 같아도 늘 새 결과로 보입니다. 이런 반복은 실패 감지와 라운드 상한, 실행 시간 상한이 끊습니다.

결과 비교와 실행 전체를 보는 중복 호출 거절은 조사원 루프에 겁니다. 채팅 턴은 연이은 같은 호출 거절, 실패 감지, 라운드 상한 40(「에이전트 루프는 하나만 두고, 기능별 동작은 훅으로 끼운다」)으로 멈춥니다. 채팅에서는 같은 호출을 고집하면 거절이 그 도구의 실패로 쌓여 실패 감지에 닿습니다.

검색처럼 인자를 바꿔 가며 같은 것을 다시 받아 올 수 있는 도구를 쓰는 에이전트라면, 실패 횟수만으로는 반복이 멈추지 않습니다. 라운드마다 결과를 이미 본 것과 비교하되, 빈 결과와 모르는 결과는 새 사실로 치는 것부터 시작할 수 있습니다.