
고급 에이전틱 하네스 (Advanced Agentic Harness) 구축하기
요약
단순한 LLM 루프를 넘어 프로덕션 환경에서 신뢰할 수 있는 에이전트 시스템을 구축하기 위한 '고급 에이전틱 하네스' 설계 방식을 다룹니다. 타입 지정 도구, 계획 DAG, 계층형 메모리, 검증 계층 등 구체적인 프리미티브를 통해 시스템의 안정성과 예측 가능성을 높이는 방법을 제안합니다.
핵심 포인트
- 단일 LLM 호출을 신뢰할 수 있는 시스템으로 전환하기 위한 구성(Composition) 전략 제시
- Pydantic 기반 타입 지정 도구와 계획 DAG를 통한 실행 안정성 확보
- 컨텍스트 관리를 위한 다계층 메모리와 비용 통제를 위한 다차원 예산 책정
- Planner, Worker, Critic 역할 분담을 통한 검증 계층 구축
그 기본적인 하네스 (Harness) 루프는 올바르지만, 순진합니다 (naive). 잘 만들어진 전투기에 탄 단 한 명의 조종사는 공중전에서 승리할 수 있을지 모르지만, 아무도 그런 방식으로 공중 작전을 수행하지는 않습니다. 실제 작전에는 누구도 이륙하기 전에 어떤 출격(sorties)을 할지 결정하는 임무 계획관(mission planners), 병렬로 독립적인 출격을 수행하는 비행대(squadrons), 연료가 바닥나기 전에 기지로 복귀하도록 강제하는 연료 예산과 빙고 콜(bingo calls), 사후에 모든 임무를 재구성할 수 있게 해주는 비행 기록 장치(flight recorders), 그리고 임무가 실제로 성공했는지 결정하는 사후 검토(after-action reviews)가 추가됩니다. 이 중 그 어떤 것도 조종사를 대체하지는 않습니다. 이들은 시스템 전체가 빠르고, 안전하며, 디버깅 가능하고, 측정 가능하도록 조종사를 구조 안에 감싸는 역할을 합니다.
Claude Code, Devin, Cursor, Hermes 및 기타 프로덕션 에이전트(production agents)들도 기본 루프에 정확히 똑같은 작업을 수행합니다. 이 포스트에서 우리는 프레임워크 뒤에 어떠한 메커니즘도 숨기지 않고, 우리의 기본 하네스의 모든 요소를 그러한 프로덕션 형태(production shape)로 업그레이드합니다. 이 모든 과정의 핵심 질문은 간단합니다:
단일 LLM 호출을 어떻게 계획하고, 행동하며, 회복하고, 옳은 일을 했다는 것을 증명할 수 있는 신뢰할 수 있는 시스템으로 바꿀 것인가?
우리의 답은 구성(composition)입니다. 우리는 타입이 지정된 도구(typed tools), 계획 DAG (plan DAG), 계층형 메모리(tiered memory), 검증 계층(verification hierarchy), 예산(budgets), 그리고 트레이서(tracer)와 같은 작고 테스트 가능한 프리미티브(primitives)를 구축하고, 이를 의도적으로 얇은 오케스트레이터(orchestrator)로 연결합니다. 각 프리미티브는 순진한 에이전트들이 특정한 방식으로 예측 가능하게 실패하기 때문에 존재합니다. LLM은 유효하지 않은 도구 인자(tool arguments)를 만들어내므로, 우리는 Pydantic 검증을 갖춘 타입 지정 도구(typed tools)를 추가합니다. 모든 것이 순차적으로 실행되므로, 의존성 그래프(dependency graph)와 병렬 실행(parallel execution)을 추가합니다. 컨텍스트 윈도우(context window)가 쓰레기로 가득 차므로, 검색 예산(retrieval budget) 하에 다계층 메모리(multi-tier memory)를 추가합니다. 잘못된 출력이 조용히 전파되므로, 검증 계층(verification hierarchy)을 추가합니다. 하나의 프롬프트가 모든 것을 하려고 하므로, 이를 Planner (계획자), Worker (작업자), Critic (비평가) 역할로 나눕니다. 비용이 통제 불능으로 치솟으므로, 우아한 성능 저하(graceful degradation)를 포함한 다차원 예산 책정(multi-dimensional budgeting)을 추가합니다.
평가 스위트 (eval suite), 검색 벤치마크 (retrieval benchmarks), 그리고 특화된 워커 풀 (specialized worker pools)을 사용하여 하네스가 보통 작동한다는 것을 증명하는 과정은 향후 포스트에서 본격적으로 다룰 예정입니다.
실행 예시
이 포스트 전반에 걸쳐 우리는 도시 비교 에이전트 (city comparison agent)를 구축합니다. 도시 목록이 주어지면, 인구, 시간대, 그리고 각 도시에 대한 짧은 서사적 요약을 비교하는 보고서를 생성합니다. 이 작업은 모욕적일 정도로 단순해 보일 수 있지만, 신중하게 선택되었습니다. 각 도시-속성 조회 (city-attribute lookup)는 다른 모든 조회와 독립적이므로, 세 도시 요청은 자연스럽게 동시에 실행될 수 있는 9개의 도구 호출 (tool calls)로 분해됩니다. 반면, 최종 보고서는 모든 조회가 먼저 완료되는 것에 의존하므로, 이는 단순한 단계별 목록을 훨씬 넘어섭니다. 우리는 요청된 모든 도시가 보고서에 실제로 나타나는지 프로그래밍 방식으로 확인하여 결과를 검증할 수 있습니다. 또한 도구들의 비용은 매우 다릅니다. 인구 및 시간대 조회는 인메모리 딕셔너리 (in-memory dictionary) 읽기인 반면, 도시별 요약과 최종 집계는 각각 LLM을 호출하므로, 우리가 관리해야 할 현실적인 예산 압박을 제공합니다.
재현성을 위해, 조회 도구들은 작은 모의 딕셔너리(mocked dictionary)인 CITY_FACTS 에서 읽어오므로, 네트워크 접속 없이도 노트북을 완전히 재현할 수 있습니다. LLM 기반 구성 요소들은 실제 Anthropic 모델 또는 결정론적 모의 객체 (deterministic mock)를 대상으로 실행할 수 있으며, 이는 첫 번째 기본 요소 (primitive)로 이어집니다.
플러그 가능한 브레인 (A pluggable brain)
우리가 구축하려는 모든 구성 요소는 결국 LLM을 호출합니다: 플래너 (planner), 요약기 (summarizer), 집계기 (aggregator), 비평가 (critic). 만약 이 호출이 하나의 SDK에 하드와이어링 (hard-wired) 되어 있다면, 전체 하네스는 테스트가 불가능해지고 특정 벤더에 종속 (vendor-locked) 됩니다.
따라서 다른 무엇보다도, 다양한 LLM 호출 API의 세부 사항에 대한 추상화를 제공하는 기본 클래스 (base class)를 정의합니다.
class LLMProvider:
"""공유 인터페이스. 다른 백엔드를 연결하려면 서브클래싱 하세요."""
...
또한 테스트 및 디버깅 목적으로 결정론적이고 역할 인지적인 (role-aware) 응답을 반환하는 MockProvider를 구현했습니다. 즉, 계획을 요청하면 표준적인 계획을, 요약을 요청하면 템플릿화된 한 줄 요약을, 판단을 요청하면 규칙 기반의 통과/실패 판정을 반환합니다. 이를 통해 개발 과정에서 "내 오케스트레이션 (orchestration)이 잘못되었는가?"와 "모델이 계획을 잘못 세우고 있는가?"를 분리할 수 있으며, 이 포스트의 모든 실험이 어떤 머신에서도 재현 가능한 이유이기도 합니다.
타입이 지정된 도구 (Typed tools)
기본적인 하네스 (harness)에서는 도구 인자 (tool arguments)를 수동으로 검증했는데, 이러한 방식은 금방 한계에 부딪힙니다. 새로운 도구가 추가될 때마다 검증 로직이 중복되고, LLM은 공식적인 스키마 (schema)를 보지 못한 채 인자의 형태를 추측하기만 하며, 결과적으로 발생하는 오류는 모델이 스스로 수정할 수 없는 임시방편적인 (ad hoc) 문자열이 됩니다.
업그레이드된 방식은 각 도구의 인자를 Pydantic 모델로 선언하고 하나의 정의가 모든 것을 구동하게 하는 것입니다:
@dataclass
class TypedTool:
name: str
...
이 접근 방식을 통해 런타임 검증 (runtime validation), Anthropic 및 OpenAI의 도구 사용 (tool-use) API가 기대하는 정확한 형태의 JSON 스키마 (JSON Schema), 문서화 (각 *Field(…, description =…**)***가 플래너 (planner)가 읽는 카탈로그의 일부가 됨), 그리고 cost_hint를 통한 비용 계산 (cost accounting)을 위한 훅 (hook)을 얻을 수 있습니다. 실행 전에 실패함으로써 잠재적인 부작용 (side effects)을 초래할 수 있는 비용이 많이 드는 도구 호출을 피할 수 있습니다. 잘못된 계획은 데이터베이스 쿼리 깊숙한 곳이 아니라, 검증 계층에서 빠르게 실패 (fail fast) 해야 합니다. 이 접근 방식은 LangChain 도구, Anthropic 도구 사용, OpenAI 함수 호출 (function calling)과 같은 본격적인 프레임워크들이 모두 수렴하는 방식과 유사합니다.
우리의 레지스트리(registry)는 세 가지 비용 티어(cost tiers)를 가진 네 가지 도구를 보유하고 있습니다: get_population과 get_timezone()은 본질적으로 무료 사전 조회이며 (cost_hint = 0.1), summarize_city()는 도시당 한 번의 LLM 호출을 수행하며 (cost_hint = 1.0), aggregate_report()는 최종 마크다운(markdown)을 생성하는 토큰 집약적인 합성(synthesis) 호출을 수행합니다 (cost_hint = 2.0). 마지막 두 가지는 내부적으로 LLM을 호출하는 도구라는 점에 유의하십시오. LLM은 다른 모든 도구와 마찬가지입니다. 워커(worker)는 통일된 도구 인터페이스를 보지만, 일부 도구는 서브 프롬프트(sub-prompts)를 감싸는 래퍼(wrapper)입니다. 이는 하네스(harness)와 독립적으로 내부 모델을 캐싱(cache), 속도 제한(rate-limit) 또는 교체(swap)할 수 있음을 의미합니다.
계획은 그래프(Graph)입니다
기본적인 하네스는 턴당 하나의 액션(action)을 실행했습니다. 이는 단계가 엄격하게 순차적일 때는 작동하지만, 우리의 작업은 단일 집계(aggregation)로 이어지는 9개의 독립적인 조회(lookups)를 가지고 있습니다:

while 루프는 이들을 한 번에 하나씩 실행합니다. 유향 비순환 그래프(Directed Acyclic Graph, DAG)는 의존성(dependencies)을 명시적으로 표현하며, 실행기(executor)가 현재 준비된 모든 것을 동시에(concurrently) 실행할 수 있게 해줍니다. 따라서 LLM에게 한 번에 하나의 액션만 묻는 대신, 우리는 **플래너(Planner)**에게 전체 그래프를 미리 요청합니다. LLM은 우리가 무엇인가를 실행하기 전에 구조를 선언합니다. 플래너가 LLM이기 때문에 구조를 환각(hallucinate)할 수도 있습니다. 즉, 존재하지 않는 노드 ID에 대한 의존성이나 결코 완료될 수 없는 순환 의존성(circular dependencies)이 발생할 수 있습니다. 따라서 계획을 가지고 우리가 하는 가장 첫 번째 일은, 망가진 계획을 실행하려고 토큰을 낭비하기 전에 이를 검증(validate)하는 것입니다.
def ready_nodes(self) -> list[PlanNode]:
"""의존성이 모두 완료(DONE)되었고, 자기 자신은 대기(PENDING) 상태인 노드들."""
...
ready_nodes()는 스케줄러(scheduler)의 핵심입니다. 어떤 순간이든 의존성이 모두 충족된 노드 집합을 반환합니다. 세 도시를 목표로 하는 우리의 경우, 플래너는 10개의 노드를 생성합니다: 병렬로 실행될 자격이 있는 의존성 목록이 비어 있는 9개의 페치(fetch)와, 나머지 9개 모두에 의존하는 하나의 aggregate_report 정점(capstone)입니다.
그래프를 병렬로 실행하기
executor는 레벨 동기식 (level-synchronous) DAG 워커입니다. 즉, 준비된 노드 집합 (ready set)을 계산하고, asyncio.gather를 사용하여 모든 준비된 노드를 동시에 실행하며, 각 노드가 완료되었거나 실패했음을 표시한 뒤, 남은 것이 없거나 더 이상의 전진이 불가능할 때까지 이 과정을 반복합니다.
MAX_CONCURRENT = 5 # 동시 도구/LLM 호출 수 제한
async def execute_dag(dag, tools, on_step=None):
...
여기서 두 가지 작은 결정이 가장 큰 비중을 차지합니다. 첫째, asyncio.to_thread는 우리의 동기식 (synchronous) 도구 함수들을 스레드 풀 (thread pool)에서 실행합니다. 이는 우리가 도구들을 async def로 다시 작성할 필요가 없으며, 하네스 (harness)를 비동기 네이티브 (async-native) SDK에 결합할 필요도 없음을 의미합니다. 둘째, 세마포어 (semaphore)가 동시성 (concurrency)을 제한합니다. 세마포어가 없다면 50개의 노드로 구성된 계획은 50개의 동시 LLM 호출을 발생시켜 즉시 속도 제한 (rate limits)에 걸리거나 비용이 급증할 것이기 때문입니다. 이는 워크 스틸링 (work-stealing)이나 우선순위 큐 (priority queues)를 갖춘 완전한 동적 스케줄러 (dynamic scheduler)로 의도적으로 만들지 않았습니다. 각 노드가 수백 밀리초에서 수 초 동안 지속되는 API 호출인 우리의 에이전트 워크로드 (agent workloads)에서는, 레벨 동기식 병렬성 (level-synchronous parallelism)만으로도 대부분의 이점을 얻을 수 있습니다. 순차적으로 실행할 경우 실제 소요 시간 (wall time)은 대략 데이터 호출 지연 시간 (fetch latencies)의 합이지만, 병렬로 실행할 경우 대략 그중 최댓값에 집계 (aggregation) 단계가 더해진 시간이 됩니다.
올바른 것들을 기억하기
단순한 (Naive) 에이전트들은 전체 채팅 기록, 모든 도구 출력, 모든 이전 작업 등 모든 것을 프롬프트 (prompt)에 쏟아붓습니다. 이는 두 가지 측면에서 실패합니다. 사용하지 않을 토큰에 대해 비용을 지불해야 하며, 무관한 텍스트가 목표를 희석할 때 모델의 성능이 눈에 띄게 저하됩니다. 대신 프로덕션 (production) 에이전트들은 인지 과학 (cognitive science)에서 영감을 얻은 계층형 메모리 (tiered memory)를 사용합니다. 작업 기억 (Working memory)은 항상 컨텍스트 (context) 내에 존재하는 스크래치패드 (scratchpad) 역할을 하며, 현재 목표, 계획 요약, 그리고 마지막 몇 개의 결과들을 포함합니다. **에피소드 기억 (Episodic memory)**은 과거 실행의 결과들을 저장하며, 과거의 작업이 현재 작업과 유사해 보일 때 검색됩니다. **의미 기억 (Semantic memory)**은 배경 지식을 보유하며, 동일한 방식으로 검색되지만 특정 실행에 묶여 있지 않습니다.
우리는 모든 것을 주입하지 않습니다. 현재 목표와의 유사성에 따라 상위 k개의 메모리 (top-k memories)를 추출한 다음, 엄격한 문자 수 예산 (character budget) 내에서 컨텍스트를 구성합니다.
에피소드 메모리(Episodic memories)는 시맨틱 메모리(semantic ones)보다 우선순위를 갖습니다. 왜냐하면 유사한 작업에서 발생한 과거의 실수는 일반적인 사실 정보보다 더 실행 가능한 조치를 제공하는 경우가 많고, 예산이 소진될 때도 잘림(truncation) 처리가 명시적이기 때문입니다. 컨텍스트는 수동적으로 축적되는 것이 아니라 능동적으로 조립되어야 합니다.
유사성 함수 자체에 대해서는, 스토어(store)가 두 가지 백엔드를 지원합니다. Jaccard 유사도는 비용이 들지 않아 학습용으로는 괜찮지만, 패러프레이즈(paraphrase)에는 실패합니다. 예를 들어, “프랑스의 유명한 랜드마크”와 “파리는 에펠탑으로 유명하다”는 거의 같은 단어를 공유하지 않습니다. 모든-MiniLM-L6-v2가 생성하는 384차원 벡터를 사용한 실제 문장 임베딩(sentence embeddings)은 패러프레이즈된 내용을 가까운 벡터로 매핑합니다. 저희 MemoryStore는 모델이 사용할 수 없을 경우 Jaccard를 백업으로 삼아 임베딩을 먼저 시도합니다. 이 업그레이드의 효과를 정량화하려면 적절한 벤치마크가 필요하며, 이는 다음 게시물에서 실행할 예정입니다.
신뢰하고 검증하기 (Trust, and verify)
에이전트(Agents)는 유창하고 자신감 있으며 잘못된 출력을 생성합니다. 검증 과정 없이 도시를 조용히 누락한 보고서가 사용자에게 전송되고, 회귀(regressions)는 사람이 우연히 출력을 읽을 때까지 감지되지 않습니다. 하지만 모든 검사가 같은 비용이 들지는 않으므로, 저희는 이를 계층 구조로 배열합니다. 본질적으로 무료인 결정론적 구조 검사(deterministic structural checks)와 주관적인 품질에 대한 LLM 심판(LLM judge)이 있으며, 후자는 실제 토큰을 소모합니다. 규칙은 항상 저비용 등급(cheap tier)을 먼저 실행하고 생존한 결과물만 승격시키는 것입니다.
def verify_report(report, goal, required_cities, provider) -> Verdict:
det = deterministic_check_report(report, required_cities)
...```
여기에 의도적으로 불완전한 보고서(예: 세 도시를 요청했는데 파리만 포함된 경우)를 입력하면, 결정론적 계층 (deterministic tier)에서 **reason=** "Missing cities: [’Tokyo’, ‘New York’]"라는 메시지와 함께 실패합니다. 판단을 위해 소모된 토큰은 0개이며, 사유 문자열(reason string)은 재계획 (replanner) 단계(또는 사람)가 무엇이 잘못되었는지 정확히 파악할 수 있을 만큼 실행 가능한 수준입니다. 이러한 2단계 게이트 (two-tier gate)는 대부분의 프로덕션 평가 (eval) 파이프라인 뒤에 숨겨진 견고한 패턴입니다. 즉, 저렴한 필터 (filters)를 먼저 적용하고, 살아남은 결과물에 대해서만 비용이 많이 드는 판단기 (judges)를 사용하는 것입니다. 그리고 이 계층 구조만큼 중요한 것은 그 이면에 있는 관심사의 분리 (separation of concerns)입니다. ***Worker***는 생성하고 ***Critic***은 평가하므로, 생성기 (generator)가 스스로의 숙제를 채점하는 일이 발생하지 않습니다.
## 구성원 소개: Planner, Worker, Critic
계획, 실행, 요약, 그리고 자기 비판 (self-critique)을 하나의 프롬프트로 수행하면 목표가 혼동되는 경향이 있으며 (계획 제약 조건이 문체에 영향을 미침), "계획 부분"을 분리할 수 없어 테스트와 교체 모두를 어렵게 만듭니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 HN AI Posts의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기