에이전트를 직접 만들면서 겪은 최신 시행착오 정리: SDK 추상화의 한계, 캐시 전략, 에이전트 루프에서의 강화, 실패 격리, 파일시스템 기반 공유 상태, 출력 툴 설계, 모델 선택, 테스트와 평가 문제까지.
2025년 11월 21일에 씀
최근에 새로 배운 것들을 한 번 정리해 두는 게 좋겠다는 생각이 들었다. 대부분은 에이전트를 직접 만드는 얘기이고, 약간은 에이전트형 코딩 도구를 쓰는 경험에 대한 이야기다.
요약: 에이전트를 만들기는 여전히 지저분하다. SDK 추상화는 실제 툴 사용이 복잡해지는 순간부터 깨지기 시작한다. 캐싱은 직접 관리할 때 더 잘 동작하지만, 모델마다 방식이 다르다. 강화(reinforcement)가 생각보다 훨씬 많은 일을 해 주고, 실패는 루프 전체를 탈선시키지 않도록 엄격하게 격리해야 한다. 파일 시스템 같은 레이어를 통한 공유 상태는 중요한 빌딩 블록이다. 출력(output) 도구 설계는 의외로 까다롭고, 모델 선택은 여전히 작업 종류에 따라 달라진다.
직접 에이전트를 만들 때는, OpenAI SDK나 Anthropic SDK 같은 "기반" SDK 위에 올릴지, 아니면 Vercel AI SDK나 Pydantic 같이 더 높은 수준의 추상화를 쓸지를 고르게 된다. 우리는 한동안 Vercel AI SDK를 채택했는데, 그 중에서도 provider 추상화만 쓰고, 실제 에이전트 루프는 직접 돌리는 방식을 택했다. 지금 시점에서 우리는 그 선택을 다시 하진 않을 것 같다. Vercel AI SDK 자체에는 아무 문제가 없지만, 에이전트를 만들다 보면 처음에 예상하지 못했던 두 가지 일이 벌어진다.
첫 번째는 모델 간 차이가 충분히 크기 때문에, 결국 자기만의 에이전트 추상화를 만들어야 한다는 점이다. 우리는 지금까지 써 본 어떤 SDK도 에이전트에 딱 맞는 추상화를 제공한다고 느끼지 못했다. 부분적으로는, 기본적인 에이전트 디자인이 단순히 "루프"처럼 보이긴 하지만, 제공하는 도구 세트에 따라 미묘한 차이가 생기기 때문이라고 본다. 이 차이들은 적절한 추상화를 찾는 난이도에 직접적인 영향을 준다(캐시 제어, 강화에 대한 요구 사항 차이, 툴 프롬프트, provider 쪽 툴, 등등). 무엇이 "올바른 추상화"인지가 아직 분명하지 않은 상황이라, 각 플랫폼이 제공하는 오리지널 SDK를 직접 쓰는 편이 훨씬 많은 통제권을 준다. 일부 상위 수준 SDK를 쓰면, 그들이 정의한 추상화 위에 다시 쌓아야 하는데, 그 추상화가 결국 내가 진짜로 원하는 모양이 아닐 수도 있다.
또 하나, provider 측 툴을 다룰 때 Vercel SDK와 함께 작업하는 게 엄청 어렵다는 점도 알았다. 메시지 포맷을 통합하려는 시도가 그리 잘 들어맞지 않는다. 예를 들어, Anthropic의 웹 검색 툴은 Vercel SDK를 통해 쓰면 메시지 히스토리를 주기적으로 박살 내는데, 왜 그런지 아직 완전히 원인을 파악하지 못했다. 또 Anthropic의 경우, 캐시 관리는 Vercel SDK를 경유하지 않고 자기 SDK를 직접 타깃으로 할 때 훨씬 쉽다. 뭔가 설정을 잘못 했을 때 나오는 에러 메시지도 훨씬 명료하다.
이 상황은 앞으로 바뀔 수도 있겠지만, 지금 시점에서 우리가 에이전트를 만든다면, 최소한 상황이 조금 정리될 때까지는 추상화 레이어를 쓰지 않을 것 같다. 지금은 이득보다 비용이 더 크다고 느낀다.
누군가는 이미 이 문제를 잘 풀었을 수도 있다. 이 글을 읽는 분 중에 내가 틀렸다고 생각하신다면 메일을 보내 달라. 나도 배우고 싶다.
플랫폼마다 캐싱 접근 방식이 상당히 다르다. 이 부분에 대해서는 이미 말이 많았지만, Anthropic은 캐시에 요금을 매긴다. 그리고 캐시 포인트를 명시적으로 관리하게 만든다. 이게 에이전트 엔지니어링 레벨에서 플랫폼과 상호작용하는 방식을 완전히 바꾼다. 처음에는 이 수동 관리 방식이 꽤 어리석어 보였다. "그냥 플랫폼이 알아서 해 주면 안 되나?"라는 생각이었다. 그런데 지금은 완전히 생각이 바뀌어서, 오히려 명시적인 캐시 관리 방식을 훨씬 선호하게 됐다. 비용과 캐시 활용도를 훨씬 예측 가능하게 만들어 준다.
명시적 캐싱을 하면, 그렇지 않으면 거의 불가능한 일들이 가능해진다. 예를 들어, 대화를 갈라서 두 방향으로 동시에 진행시키는 게 가능하다. 또 컨텍스트 편집(context editing)도 할 수 있다. 최적의 전략이 무엇인지는 아직 분명하지 않지만, 확실한 건 훨씬 더 많은 제어 권한을 갖게 된다는 점이고, 나는 그 점이 무척 마음에 든다. 또한, 기본 에이전트의 비용 구조를 이해하는 것도 훨씬 쉬워진다. 캐시가 얼마나 잘 활용될지에 대해 훨씬 많은 가정을 세울 수 있는데, 다른 플랫폼에서는 이게 일종의 도박처럼 느껴지는 경우가 많았다.
우리가 Anthropic 기반 에이전트에서 캐싱을 하는 방식은 꽤 직관적이다. 캐시 포인트 하나는 시스템 프롬프트 뒤에 둔다. 그리고 대화의 앞부분에 두 개의 캐시 포인트를 두는데, 그 중 마지막 포인트 하나는 대화 꼬리를 따라 위로 올라간다. 그 과정에서 추가로 할 수 있는 최적화도 조금 있다.
시스템 프롬프트와 툴 선택은 대부분 정적이어야 하므로, 현재 시각 같은 동적인 정보는 나중에 별도의 동적 메시지로 넣는다. 그렇지 않으면 캐시가 다 날아간다. 또한 루프 도중에 강화를 훨씬 더 적극적으로 활용하게 된다.
에이전트가 툴을 한 번 호출할 때마다, 단순히 툴이 생성한 데이터만 되돌려 보내는 것이 아니라, 루프에 더 많은 정보를 집어넣을 기회가 생긴다. 예를 들어, 전체 목표와 개별 작업들의 상태를 계속 상기시키는 식으로 할 수 있다. 또 툴이 실패했을 때, 그 툴 호출이 어떻게 하면 성공할 수 있는지에 대한 힌트를 줄 수도 있다. 다른 강화 사용 예시는, 백그라운드에서 일어난 상태 변화에 대해 시스템에 알려 주는 것이다. 만약 병렬 처리하는 에이전트가 있다면, 툴 호출 후 상태가 바뀌었고 그게 작업 완료에 중요하다면, 그때마다 정보를 주입할 수 있다.
가끔은 에이전트가 자기 자신을 강화하는 것만으로도 충분하다. 예를 들어 Claude Code에서는 todo write 툴이 자기 강화용 툴이다. 하는 일은 단순하다. 에이전트가 해야 한다고 생각하는 작업 목록을 받아서 그대로 다시 내보내는 것뿐이다. 사실상 그냥 에코(echo) 툴이고, 그 이상 아무것도 하지 않는다. 그런데 이것만으로도, 처음에 컨텍스트에 한 번만 주어졌던 작업/하위 작업 정보에 비해, 시간이 지나 많은 일이 일어난 이후에도 에이전트가 훨씬 잘 앞으로 나아가게 만든다.
우리는 또 한 가지로, 실행 도중 환경이 에이전트에게 불리하게 바뀌었을 때 그 사실을 시스템에 알리는 데도 강화를 쓴다. 예를 들어, 에이전트가 어떤 단계에서 실패한 뒤 그 시점부터 재시도를 하는데, 복구 로직이 깨진 데이터를 기준으로 동작한다면, 몇 단계를 되돌아가 다시 시도해야 할 수 있다. 이럴 땐 "몇 단계를 물러나서 더 앞 단계부터 다시 하라"는 메시지를 루프에 주입한다.
코드 실행 중에 많은 실패가 발생할 것으로 예상된다면, 그 실패들을 컨텍스트에서 감추는 것도 하나의 선택지가 된다. 이건 두 가지 방식으로 가능하다. 하나는 반복이 필요할 만한 작업을 개별로 떼어내서 수행하는 것이다. 예를 들어, 어떤 작업을 서브에이전트 안에서 성공할 때까지 돌리고, 최종 성공 결과(그리고 필요하다면 실패 시도 요약 정도만)를 상위 에이전트에 넘기는 방식이다. 서브태스크에서 무엇이 실패했는지 에이전트가 아는 건 도움이 된다. 그래야 다음 태스크로 넘어갈 때 그 정보를 활용해서 같은 실패 패턴을 피할 수 있다.
두 번째 옵션은 모든 에이전트나 파운데이션 모델에서 제공되는 기능은 아니지만, Anthropic에서는 컨텍스트 편집(context editing)이 가능하다는 점이다. 지금까지 컨텍스트 편집에서 획기적인 성과를 거두지는 못했지만, 꽤 흥미로워서 더 탐구해 보고 싶은 영역이다. 이미 이걸로 성과를 낸 사람이 있다면 경험을 들어 보고 싶다.
컨텍스트 편집의 흥미로운 점은, 이론적으로는 반복 루프의 후반부를 위해 토큰을 보존할 수 있다는 점이다. 반복 과정 중 성공으로 이어지지 않은 실패 시도들의 흔적을 컨텍스트에서 제거해서, 전체 루프 완료에 부정적 영향만 끼쳤던 출력들을 치울 수 있는 것이다. 다만 앞에서 말했듯, 서브태스크에서 무엇이 안 먹혔는지 아는 건 유익하기도 하다. 전체 실패 상태/출력을 다 남겨 둘 필요는 없지만, 그 요지 정도는 알고 있는 편이 좋을 수 있다.
불행히도 컨텍스트 편집은 자동으로 캐시를 무효화한다. 이건 피할 수 있는 게 아니다. 그래서 컨텍스트 편집으로 얻는 이득이, 캐시를 버리면서 발생하는 추가 비용을 상쇄할 수 있는지 판단하기가 애매하다.
이 블로그에서 여러 번 언급했듯이, 우리가 만드는 에이전트 대부분은 코드 실행과 코드 생성에 기반한다. 이 방식은 에이전트가 데이터를 저장할 공통 장소를 필요로 한다. 우리가 선택한 건 파일 시스템이다. 실제로는 가상 파일 시스템이지만, 어쨌든 거기에 접근하는 여러 개의 다른 툴이 필요하다. 특히 서브에이전트나 서브 추론(sub inference) 같은 걸 쓸 때 중요해진다.
에이전트를 설계할 때는 막다른 골목(dead end)이 없도록 해야 한다. 막다른 골목이란, 어떤 작업이 당신이 만든 서브툴 내부에서만 계속 진행될 수 있는 상황을 말한다. 예를 들어, 이미지를 생성하는 툴을 만들었는데, 그 이미지가 또 다른 특정 툴 하나에게만 입력으로 전달될 수 있다고 하자. 이건 문제다. 왜냐하면 나중에는 그 이미지를 코드 실행 툴로 넘겨서 zip 아카이브에 넣고 싶을 수도 있기 때문이다. 그러려면 이미지 생성 툴이 이미지를 쓰는 위치와 코드 실행 툴이 이미지를 읽는 위치가 같아야 한다. 본질적으로 이게 바로 파일 시스템이다.
물론 반대 방향도 되어야 한다. 코드 실행 툴로 zip 아카이브를 풀고, 다시 추론(inference) 단계로 돌아가서 모든 이미지에 대한 설명을 만들고, 그 다음 단계에서 또 다시 코드 실행으로 돌아가서 후처리를 할 수도 있다. 우리가 이걸 위해 사용하는 메커니즘이 파일 시스템이다. 다만 이를 위해서는 툴들이 이 가상 파일 시스템의 파일 경로를 인자로 받아들일 수 있는 형태로 설계되어야 한다.
즉, ExecuteCode 툴과 RunInference 툴이 동일한 파일 시스템에 접근할 수 있어야 하고, RunInference 툴은 같은 가상 파일 시스템 상의 파일을 가리키는 path를 파라미터로 받을 수 있어야 한다.
우리가 설계한 에이전트 중 한 가지 흥미로운 점은, 이것이 채팅 세션 자체를 나타내지는 않는다는 것이다. 언젠가 사용자나 바깥 세계에 무언가를 커뮤니케이트하긴 하지만, 그 사이에 주고받는 메시지 대부분은 드러나지 않는다. 그렇다면 최종 메시지는 어떻게 만들까? 우리는 하나의 툴을 두는데, 이게 바로 출력 도구다. 에이전트는 사람과 소통해야 할 때 이 출력 도구를 명시적으로 호출한다. 그리고 언제 그 툴을 사용해야 하는지에 대해서는 프롬프트로 지시한다. 우리 경우 출력 도구는 이메일을 보낸다.
그런데 이게 의외로 몇 가지 난점을 낳는다. 그 중 하나는, 메인 에이전트 루프의 텍스트 출력만 곧장 사용자에게 보여주는 방식과 비교하면, 출력 툴의 문구와 톤(tone)을 원하는 방향으로 조정하는 일이 놀라울 정도로 어렵다는 점이다. 왜 그런지는 정확히 말하기 어렵지만, 아마도 모델이 어떻게 학습되었는지와 관련이 있을 것 같다.
잘 안 먹힌 시도 중 하나는, 출력 도구 안에서 Gemini 2.5 Flash 같은 가벼운 LLM을 한 번 더 태워서 톤을 조정하는 방식이었다. 하지만 이건 레이턴시를 늘릴 뿐 아니라, 실제 출력 품질을 떨어뜨렸다. 모델이 문장을 써내는 방식이 미묘하게 원하는 것과 다르기도 했고, 서브툴이 가진 컨텍스트가 충분하지도 않았다. 메인 에이전트 컨텍스트에서 일부를 잘라 서브툴 쪽으로 넘겨 보는 식으로도 시도했지만, 비싸기만 했고 완전히 문제를 해결하지도 못했다. 게다가 가끔은 최종 출력에 우리가 드러내고 싶지 않았던 정보(예: 결과에 이르기까지 거친 세부 단계들)가 그대로 노출되기도 했다.
출력 도구의 또 다른 문제는, 에이전트가 아예 그 툴을 호출하지 않는다는 점이다. 이걸 강제하기 위해, 우리는 출력 도구가 한 번이라도 호출되었는지 상태를 기억한다. 만약 루프가 끝날 때까지 출력 도구가 호출되지 않았다면, "출력 도구를 사용하라"는 강화 메시지를 주입해서 툴 사용을 유도한다.
전반적으로 모델을 고르는 기준은 지금까지 크게 바뀌지 않았다. Haiku와 Sonnet은 여전히 최고의 툴 콜러(tool caller)라고 생각하고, 그래서 에이전트 루프에 쓰기 좋은 선택지다. 또 이 모델들은 RL이 어떤 모양을 하고 있는지에 대해 어느 정도 투명한 편이기도 하다. 그 밖의 명백한 선택지는 Gemini 계열이다. 메인 루프 용도로는 GPT 계열에서 큰 성공을 거두지 못했다.
개별 서브툴들, 특히 추가적인 추론이 필요한 경우, 지금 우리가 쓰는 기본 선택지는 Gemini 2.5다. 큰 문서를 요약하거나 PDF를 다루는 등의 작업에 좋다. 이미지에서 정보를 추출할 때도 꽤 쓸 만한데, 그 이유 중 하나는 Sonnet 계열 모델들이 안전 필터(safety filter)에 자주 걸리는 경향이 있기 때문이다. 그게 꽤 성가시다.
또 하나 자명한 깨달음은, 토큰 단가만으로는 에이전트의 비용을 설명할 수 없다는 점이다. 더 나은 툴 콜러는 더 적은 토큰으로 같은 일을 해낸다. 오늘날 Sonnet보다 싼 모델도 여럿 있지만, 루프 전체 관점에서는 반드시 더 싸다고 보장할 수는 없다.
그래도 전체적으로 보면, 지난 몇 주 사이에 크게 달라진 건 없다.
이 영역에서 테스트와 평가는 우리가 보기엔 가장 어려운 문제다. 어느 정도는 예상된 일이지만, 에이전트라는 특성 때문에 난이도가 더 올라간다. 일반 프롬프트와는 달리, 외부 시스템에서 단순히 프롬프트/응답만 넣고 뽑는 방식으로는 평가를 하기 어렵다. 에이전트에 넣어야 할 정보가 너무 많기 때문이다. 그래서 실제 관찰 가능성(observability) 데이터나, 실제 테스트 실행에 계측을 넣는 방식으로 평가를 해야 한다.
지금까지 여러 솔루션을 시도해 봤지만, 이 문제에 대해 "바른 접근"을 찾았다고 확신을 주는 건 아직 보지 못했다. 불행하게도, 이 부분에서는 아직 만족스러운 해답을 찾지 못했다고 말해야 할 것 같다. 언젠가 이 문제에 대한 좋은 해결책을 찾기를 바란다. 에이전트를 만드는 작업에서 점점 더 짜증나는 요소로 커지고 있기 때문이다.
코딩 에이전트 경험 측면에서는, 크게 달라진 건 별로 없다. 새로 바뀐 점이라면 Amp를 더 많이 써 보고 있다는 정도다. 혹시 궁금하다면: Amp가 내가 쓰던 다른 에이전트들보다 "객관적으로" 뛰어나서 그런 건 아니다. 다만 그들이 블로그 등에 올리는 글들을 보면, 에이전트를 바라보는 관점이 무척 마음에 든다. Oracle 같은 서브 에이전트와 메인 루프의 상호작용 방식이 매우 아름답게 짜여 있고, 이런 식의 하네스를 제공하는 곳은 아직 많지 않다. 그리고 나로서도 다양한 에이전트 설계가 실제로 어떻게 작동하는지 검증해 볼 좋은 수단이다.
Amp는 Claude Code와 비슷하게, 이 도구를 만든 사람들이 자기 도구를 실제로 쓰면서 제품을 만들었다는 느낌이 강하게 든다. 업계의 모든 에이전트가 그렇다고 느끼지는 않는다.
그냥 같이 공유하면 좋을 것 같은, 아무렇게나 모아 본 링크 몇 개:
이 글의 태그: ai