단순히 LLM을 반복 호출하는 기본 에이전트 구조를 넘어, 실제 상용 환경에서 사용 가능한 고도화된 에이전트 하네스(Harness)를 구축하는 방법을 다룹니다. 잦은 에러와 컨텍스트 제한, 비용 문제 등을 해결하기 위해 타입 검사, 계획 그래프, 계층형 메모리, 예산 및 검증 계층 같은 필수적인 모듈들을 결합하는 원리를 설명합니다. AI 에이전트가 단순히 동작하는 것을 넘어 계획하고, 스스로 복구하며, 신뢰할 수 있는 결과를 증명하는 견고한 시스템을 설계하려는 실무자에게 매우 유용한 글입니다.
번역된 본문
기본 하네스(Harness) 루프는 맞지만, 너무 단순합니다. 잘 만들어진 제트기 조종사 한 명이 단독으로 공중전에서 승리할 수는 있겠지만, 아무도 그런 식으로 공군 작전을 운영하지 않습니다. 실제 작전에서는 누구도 이륙하기 전에 어떤 출격을 할지 결정하는 임무 계획자, 병렬로 독립적인 출격을 수행하는 비행대대, 연료가 떨어지기 전에 기지로 귀환하도록 강제하는 연료 예산 및 귀환 지시(Bingo calls), 사후 모든 임무를 재구성할 수 있게 해주는 비행 기록 장치, 그리고 임무가 실제로 성공했는지 결정하는 사후 분석(After-action reviews)이 추가됩니다. 이러한 것들은 조종사를 대체하지 않습니다. 오히려 전체 시스템이 빠르고, 안전하며, 디버깅 가능하고, 측정 가능하도록 유지하기 위해 조종사를 구조 안에 감싸는 역할을 합니다. Claude Code, Devin, Cursor, Hermes 및 기타 상용 에이전트들이 기본 루프에 대해 정확히 똑같은 일을 수행합니다. 이 글에서는 우리의 기본 하네스를 상용 형태로 업그레이드하는 각 조각들을 다루되, 어떠한 매커니즘도 프레임워크 뒤에 숨기지 않을 것입니다. 이 모든 과정에 대한 핵심 질문은 간단합니다. 어떻게 단일 LLM 호출을 계획하고, 행동하며, 복구하고, 올바른 일을 했다는 것을 증명할 수 있는 신뢰할 수 있는 시스템으로 바꿀 것인가? 입니다. 우리의 답은 '조합(Composition)'입니다. 우리는 타입이 지정된 도구(Typed tools), 계획 DAG(Directed Acyclic Graph), 계층화된 메모리, 검증 계층, 예산, 그리고 트레이서(Tracer)와 같은 작고 테스트 가능한 기본 요소들을 만들고, 이것들을 의도적으로 얇게 설계된 오케스트레이터(Orchestrator)로 연결합니다. 이러한 각 요소는 순진한 에이전트들이 특정하고 예측 가능한 방식으로 실패하기 때문에 존재합니다. LLM은 종종 잘못된 도구 인수를 지어내므로 Pydantic 검증이 적용된 타입 도구를 추가합니다. 모든 것이 순차적으로 실행되므로 종속성 그래프와 병렬 실행을 추가합니다. 컨텍스트 창이 쓸모없는 정보로 가득 차므로 검색 예산 내에서 관리되는 다계층 메모리를 추가합니다. 잘못된 출력이 조용히 전파되는 것을 막기 위해 검증 계층을 추가합니다. 하나의 프롬프트가 모든 것을 처리하려고 하면 문제가 발생하므로, 이를 Planner(계획자), Worker(작업자), Critic(평가자) 역할로 분리합니다. 비용이 통제 불능 상태가 되는 것을 막기 위해 점진적 성능 저하(Graceful degradation)를 포함한 다차원 예산 책정을 추가합니다. 평가 제품군, 검색 벤치마크, 전문 작업자 풀 등을 활용해 하네스가 제대로 작동함을 증명하는 방법에 대해서는 향후 게시물에서 자세히 다룰 것입니다.
계속되는 예시
이 글 전체를 통해 우리는 '도시 비교 에이전트'를 구축할 것입니다. 도시 목록이 주어지면, 인구, 시간대, 그리고 각 도시에 대한 짧은 서술적 요약을 비교하는 보고서를 생성합니다. 이 작업은 비웃음이 나올 정도로 단순해 보이지만, 신중하게 선택되었습니다. 각 도시의 속성 조회는 서로 독립적이며, 이는 3개 도시 요청이 자연스럽게 동시에 실행될 수 있는 9개의 도구 호출로 분해될 수 있음을 의미합니다. 반면 최종 보고서는 모든 조회가 먼저 완료되어야 하므로 단순한 평면적 단계 목록을 넘어서게 됩니다. 우리는 프로그래밍 방식으로 요청된 모든 도시가 실제로 보고서에 나타나는지 확인하여 결과를 검증할 수 있습니다. 그리고 도구들은 비용이 크게 다릅니다. 인구 및 시간대 조회는 인메모리 딕셔너리 읽기인 반면, 도시별 요약 및 최종 집계는 각각 LLM을 호출하므로 관리해야 할 현실적인 비용 압박을 줍니다. 재현성을 위해 조회 도구는 모의(Mock) 딕셔너리인 CITY_FACTS에서 데이터를 읽어오므로, 네트워크 연결 없이도 노트북 환경에서 완벽하게 재현할 수 있습니다. LLM 기반 부분은 실제 Anthropic 모델 또는 결정론적 모의 객체(Deterministic mock)에 대해 실행할 수 있으며, 이는 우리가 다룰 첫 번째 기본 요소로 이어집니다.
플러그 가능한 두뇌 (A pluggable brain)
우리가 앞으로 구축할 모든 구성 요소는 결국 LLM을 호출합니다. 계획자, 요약자, 집계자, 평가자 모두입니다. 만약 그 호출이 하나의 SDK에 하드코딩되어 있다면, 전체 하네스는 테스트할 수 없고 특정 업체에 종속(Vendor-locked)되게 됩니다. 따라서 무엇보다 먼저, 다양한 LLM 호출 API의 세부 사항을 추상화하는 기본 클래스를 정의합니다.
That Basic Harness loop is correct, but naive . A lone pilot in a well-built jet might win a dogfight, but nobody runs an air campaign that way. Real operations add mission planners who decide what sorties to fly before anyone takes off, squadrons that fly independent sorties in parallel, fuel budgets and bingo calls that force a return to base before the tanks run dry, flight recorders that make every mission reconstructible after the fact, and after-action reviews that decide whether the mission actually succeeded. None of these replace the pilot. They wrap the pilot in structure so that the whole system stays fast, safe, debuggable, and measurable. Claude Code, Devin, Cursor, Hermes, and other production agents do exactly the same thing to the basic loop. In this post we upgrade every piece of our basic harness toward that production shape, without hiding any of the mechanics behind a framework. The guiding question for the whole exercise is a simple one: How do you turn a single LLM call into a reliable system that can plan, act, recover, and prove it did the right thing? Our answer is composition. We build small, testable primitives: typed tools, a plan DAG , tiered memory, a verification hierarchy, budgets, and a tracer, and wire them together with a deliberately thin orchestrator. Each primitive exists because naive agents fail in a specific, predictable way. LLMs invent invalid tool arguments, so we add typed tools with Pydantic validation. Everything runs sequentially, so we add a dependency graph and parallel execution. The context window fills with junk, so we add multi-tier memory under a retrieval budget. Bad outputs propagate silently, so we add a verification hierarchy. One prompt tries to do everything, so we split it into Planner , Worker , and Critic roles. Costs run away, so we add multi-dimensional budgeting with graceful degradation. Proving the harness usually works, with an eval suite, retrieval benchmarks, and specialized worker pools will get a full fledge treatment in a future post. The running example Throughout the post we build a city comparison agent: given a list of cities, it produces a report comparing them on population, timezone, and a short narrative summary of each. The task looks almost insultingly simple, but it was chosen carefully. Each city-attribute lookup is independent of every other one, which means a three-city request naturally decomposes into nine tool calls that could all run at the same time. The final report, on the other hand, depends on all of the lookups finishing first, so we’re. well beyond a flat list of steps. We can programmatically check that every requested city actually appears in the report to verify the results. And the tools have wildly different costs: population and timezone lookups are in-memory dictionary reads, while the per-city summaries and the final aggregation each call the LLM, which gives us realistic budget pressure to manage. For the sake of reproducibility, lookup tools read from a small mocked dictionary, CITY_FACTS , so the notebook is fully reproducible without network access. The LLM-backed pieces can run against either a real Anthropic model or a deterministic mock, which brings us to the first primitive. A pluggable brain Every component we are about to build eventually calls an LLM: the planner, the summarizer, the aggregator, the critic. If that call is hard-wired to one SDK, the entire harness becomes untestable and vendor-locked. So before anything else, we define a base class that provides an abstraction over the details of the various LLM calling APIs class LLMProvider : """Shared interface. Subclass to plug in a different backend.""" def complete (self, system: str , user: str , role: str = “default”) -> str : raise NotImplementedError async def acomplete (self, system: str , user: str , role: str = “default”) -> str : # Wrap sync call in a thread; works for any SDK. return await asyncio.to_thread( self .complete, system, user, role) We also implement a MockProvider for testing and debugging purposes that returns deterministic, role-aware responses: a canonical plan when asked to plan, a templated one-line summary when asked to summarize, a rule-based pass/fail verdict when asked to judge. This allows us to separate “is my orchestration wrong?” from “is the model planning badly?” during development, and it is the reason every experiment in this post is reproducible on any machine. Typed tools In the basic harness we validated tool arguments by hand, an approach collapses quickly: every new tool duplicates validation logic, the LLM never sees a formal schema and just guesses argument shapes, and the resulting errors are ad hoc strings the model can’t self-correct from. The upgrade is to declare each tool’s arguments as a Pydantic model and let one definition drive everything: @dataclass class TypedTool : name: str description: str args_model: type[BaseModel] # Pydantic model defining the arg schema fn: Callable[ ... , Any] cost_hint: float = 0.0 # relative cost for budget accounting def schema (self) -> dict : """Shape expected by Anthropic/OpenAI tool-use APIs.""" return { "name" : self .name, "description" : self .description, "input_schema" : self .args_model.model_json_schema(), } def run (self, raw_args: dict ) -> Any: args, err = self .validate_args(raw_args) if err is not None : raise ValueError (err) return self .fn( ** args.model_dump()) This approach gets us runtime validation, a JSON Schema in exactly the shape that the Anthropic and OpenAI tool-use APIs expect, documentation (each Field( …, description =… ) becomes part of the catalog the planner reads), and a hook for cost accounting via cost_hint. Failing before execution allows us to avoid expensive tool calls with potential side effects. A bad plan should fail fast , at the validation layer, and not deep inside a database query. This approach is similar to what full fledge frameworks like LangChain tools, Anthropic tool use, and OpenAI function calling all converge on. Our registry holds four tools with three cost tiers: get_population and get_timezone() are essentially free dictionary lookups ( cost_hint =0.1 ), summarize_city() makes one LLM call per city ( cost_hint =1.0 ), and aggregate_report() makes the token-heavy synthesis call that produces the final markdown ( cost_hint =2.0 ). Note that the last two are tools that call the LLM internally. LLMs are just like any other tool. The worker sees a uniform tool interface, but some tools are wrappers around sub-prompts, which means you can cache, rate-limit, or swap the inner model independently of the harness. The plan is a Graph The basic harness executed one action per turn. That works when steps are strictly sequential, but our task has nine independent lookups feeding a single aggregation: A while-loop runs these one at a time. A Directed Acyclic Graph expresses the dependencies explicitly and lets an executor run everything that is ready right now, concurrently. So instead of asking the LLM for one action at a time, we ask the Planner for the whole graph up front. The LLM declares the structure before we execute anything. Since the planner is an LLM, it can hallucinate structure too: dependencies on node IDs that don’t exist, or circular dependencies that can never complete. So the very first thing we do with a plan is to validate it before possibly wasting tokens trying to execute a broken plan. def ready_nodes (self) -> list[PlanNode]: """Nodes whose deps are all DONE and are themselves PENDING.""" out = [] for n in self .nodes.values(): if n.status != NodeStatus. PENDING : continue if all ( self .nodes[d].status == NodeStatus. DONE for d in n.deps): out.append(n) return out ready_nodes() is the heart of the scheduler: at any moment, it returns the set of nodes whose dependencies are all satisfied. For our three-city goal, the planner emits ten nodes: nine fetches with empty dependency lists, all eligible to run in parallel, and on