프레임워크가 실제로 제거하는 것이 무엇인지 확인하기 위해 동일한 에이전트를 두 번 구축해 보았습니다
요약
본 글은 에이전트 프레임워크의 '자동 루프' 주장을 검증하기 위해, 동일한 작업을 수동으로 구현한 방식(Path A)과 프레임워크를 사용한 방식(Path B)을 비교 분석했습니다. 그 결과, 프레임워크가 제공하는 추상화는 편리하지만, 관찰 가능성 확보나 세부 로직 제어 측면에서는 추가적인 코드가 필요하여 오버헤드를 발생시킬 수 있음을 보여줍니다.
핵심 포인트
- 프레임워크의 자동 루프 구조를 직접 구현한 방식과 비교 분석함.
- 추적 기록(traces), 토큰 수, 지연 시간 등 측정값에서 차이가 발견됨.
- 관찰 가능성 확보는 훅 이벤트(Hook events)에 의존하며 추가 코드가 필요함.
- 도구 정의 시 Zod 외 일반 JSON Schema를 허용하는 것이 중요함.
relay: Strands Agents TypeScript 1.x 에이전트 옆에서 수동으로 작성된 도구 루프, 동일한 작업을 수행합니다.
모든 에이전트 프레임워크는 같은 약속을 합니다. 즉, “도구와 프롬프트만 작성하면, 나머지 루프(loop)는 저희가 처리해 드리겠습니다”라는 것입니다. 그리고 그 약속에 대한 모든 데모는 그것을 주장하는 블로그 게시물로 존재합니다. 저는 이 주장이 검증 가능하기를 원했기 때문에 에이전트 하나를 두 번 구축하고 두 구현체를 나란히 놓고, 동일한 세 가지 작업을 수행하게 하여 실제 도구 호출 추적(tool-call traces), 토큰 수(token counts), 지연 시간(latencies), 그리고 코드 라인 수를 보여주었습니다.
제가 스스로에게 설정한 하나의 제약 조건이 있었습니다. 바로 미화된 측정값은 없어야 한다는 것입니다. 만약 프레임워크 경로가 동일한 관찰 가능성(observability)을 생성하기 위해 추가 코드가 필요하다면, 그 추가 코드는 계산에 포함됩니다.
두 개의 파일
Path A는 수동으로 작성된 에이전트에서 볼 수 있는 루프입니다:
while (iterations < MAX_TURNS) {
const response = await fetch(`${config.baseUrl}/chat/completions`, {
method: 'POST',
...
Path B는 전체 에이전트입니다:
export function createAgent(config: ModelConfig): Agent {
return new Agent({
model: new OpenAIModel({
...
while 루프도 없고, 메시지 배열도 없습니다. tool_calls 파싱도 없습니다. 중단 조건도 없습니다. 호출은 await agent.invoke(prompt, { limits: { turns: 8 } })이며, 여기서 limits.turns는 프레임워크가 제공하는 저의 가드(guard) 버전입니다.
Strands Agents TypeScript 1.x의 실제 모습
@strands-agents/sdk는 1.20.0 버전입니다 (1.0 라인은 4월에 출시되었습니다). 문서가 Python SDK보다 TS SDK에 대해 더 적기 때문에, 제가 확인한 것은 배포된 .d.ts 파일들에서 가져온 내용입니다:
피어 의존성(Peer dependencies)은 실제입니다. 이 라이브러리는 zod@^4와 openai@^6을 요구합니다. npm install @strands-agents/sdk만 실행해도 두 이름을 모두 포함하는 ERESOLVE 오류가 발생합니다.
도구는 Zod뿐만 아니라 일반 JSON Schema를 허용합니다. 이는 두 구현체가 하나의 도구 정의를 공유해야 할 때 중요합니다:
export const strandsTools = TOOLS.map((t) =>
tool({ name: t.name, description: t.description, inputSchema: t.inputSchema, callback: (input) => t.run(input) }),
);
동일한 TOOLS 배열이 Path A의 전송(wire) 상 tools: 필드와 Path B의 프레임워크 디스패치에 모두 공급됩니다. 따라서 추적 기록(traces)에서 발견되는 모든 차이는 도구 자체의 차이가 아니라 루프 구조의 차이입니다.
관찰 가능성(observability)을 얻는 방법은 훅 이벤트(Hook events)를 이용하는 것입니다. agent.addHook(EventClass, callback)을 사용하여 BeforeModelCallEvent, AfterModelCallEvent, BeforeToolCallEvent, ToolResultEvent와 같은 이벤트를 처리할 수 있습니다. 이것이 Path B가 Path A가 무료로 작성하는 것과 동일한 단계별 추적 기록을 방출할 수 있게 하는 방법입니다.
메트릭은 결과값으로 반환되며, 제가 손으로 센 값보다 더 풍부합니다:
const result = await agent.invoke(prompt, { limits: { turns: 8 } });
result.metrics?.cycleCount; // 프레임워크 자체의 루프 카운트
result.metrics?.latestAgentInvocation?.usage; // 입력/출력/총 토큰 수
...
라인 수 비교 (The line counts, both ways)
loc은 실제 파일을 읽어 주석이나 공백이 아닌 내용을 담고 있는 줄을 세는 방식으로 요청 시 계산됩니다. 하드코딩된 것이 아니며, UI의 코드 뷰어도 디스크에서 동일한 파일을 가져오기 때문에 스크린샷으로는 속일 수 없습니다.
| 파일 | 코드 라인 수 |
|---|---|
server/handrolled.ts (Path A) | 146 |
| ... | |
24와 146이 핵심 비교 지점입니다. 하지만 이것이 전체 진실은 아니며, 대부분의 데모가 건너뛰는 부분이 있습니다. 프레임워크 경로(framework path)가 _동일한 추적 형식_을 방출하도록 만들기 위해 저는 147줄의 훅 브릿지(hook bridge)를 작성했습니다. Path A에는 그러한 파일이 없는데, 그 이유는 Path A의 루프 자체가 자체적인 계측(instrumentation)이기 때문입니다. 즉, executeTool을 호출하는 단계가 이미 executeTool을 호출했다는 것을 기록하는 단계인 것입니다. |
따라서 앱은 매력적인 숫자만 고르기보다는 24 (+147 trace bridge = 171)로 표시하며, 정직한 문장은 다음과 같습니다. 프레임워크가 루프 자체를 삭제한 것이 아니라, 루프에 대한 이해(understanding)를 삭제한 것입니다. 만약 답변만 필요하다면 Path B는 24줄이고 Path A는 146줄입니다. 하지만 루프 과정을 보여주어야 한다면 양쪽 모두 비용을 지불해야 합니다. Path A는 거의 무료로, Path B는 별도의 파일에 비용을 지불합니다.
공정성 제어(Fairness controls), 왜냐하면 비교 자체가 이것 없이는 가치가 없기 때문입니다
- 시스템 프롬프트, 도구(tools),
MAX_TURNS = 8,max_tokens, 추가 본문 매개변수(extra body params)는 모두server/agent-spec.ts에 있으며, 두 경로에서 가져와 사용됩니다. - Path B의
maxRetries: 0설정. Strands 내부의 OpenAI 클라이언트는 기본적으로 두 번 재시도합니다. 반면 Path A의 순수(raw)fetch는 절대 재시도하지 않습니다. 이대로 두면, "한 번의 모델 호출"이 각 측에서 서로 다른 HTTP 요청 수로 해석될 것입니다. (Strands 자체의DefaultModelRetryStrategy는ModelThrottledError발생 시에만 작동하므로, 클라이언트의 재시도를 고정하는 것은 거의 비용이 들지 않습니다.) - 두 경로 모두에서
reasoning_effort: "none"설정. 기본 게이트웨이에 사용되는deepseek-v4.1-flash는 전체 완료 예산(completion budget)을 숨겨진 추론 토큰에 소모하고content: ""를 반환합니다. 수동으로 작성된 루프는 이를 "모델이 아무것도 반환하지 않았다"고 해석하여 모델 탓을 합니다. 두 경로 모두 동일한 필드를 받기 때문에 비교가 대칭적으로 유지되며, 미스터리하게 작동하는 대신 트랩(trap) 자체가 문서화됩니다. /run/both는 두 경로를 동시에 실행하므로, 각ms는 오직 자체 루프만을 커버하고 양쪽 모두 상위 지연 시간(upstream latency)을 공유합니다. 격리된 타이밍은 한 경로씩 개별적으로 실행하여 얻을 수 있습니다.
모델을 단 하나도 실행하기 전에 차이점
두 경로가 동일한 답변을 생성할 때조차 두 가지 구조적 차이가 나타납니다:
다음 단계를 결정하는 주체. Path A의 중지 조건은 읽고 수정할 수 있는 코드 라인입니다.
Path B의 중지 조건은 프레임워크가 보고하는 stopReason입니다.
실패의 의미. Path A는 비(非) 2xx 응답 코드가 발생하면 원본 상위 본문(raw upstream body)을 첨부하여 오류를 발생시킵니다. 이것이 UI가 게이트웨이의 실제 오류 텍스트를 출력할 수 있는 이유입니다. Path B는 이를 분류합니다: ModelError, ModelThrottledError, ContextWindowOverflowError. 실패 모델은 다르지만, 근본적인 HTTP 통신 방식은 같습니다.
보고할 가치가 있는 두 가지 버그
컴파일된 빌드가 자체 소스를 찾지 못했습니다. serverFile()은 실행되는 모듈을 기준으로 경로를 해석하는데, 이는 tsx 환경에서는 server/ 아래의 모듈이지만 tsc 이후에는 dist/server/가 됩니다. 이 때문에 개발 환경에서는 완벽하게 작동했던 /source와 LOC 카운터가 프로덕션 환경에서 오작동했습니다. 문제는 실제로 handrolled.ts를 포함하는 디렉토리를 탐색(probing)하여 해결되었습니다. 일반적인 교훈은 다음과 같습니다: 런타임에 레포지토리(repo)를 읽는 모든 기능은 개발 환경뿐만 아니라 빌드 환경에서도 테스트되어야 합니다.
주소(URL) 개수를 잘못 세는 주석 제거기(comment stripper). 단순하게 " //부터 줄 끝까지 제거"하는 스캐너는 문자열 리터럴 내에 있는 https://api…를 주석으로 간주하여 라인 수를 조용히 과소 계산합니다. 카운터가 신뢰할 수 있는 숫자 전체를 추적해야 하므로, 이 카운터는 문자 단위로 문자열(string), 템플릿(template), 그리고 주석 상태를 추적합니다. npm run check는 이 경우를 특별히 검증합니다.
사용해 보기
npm install && npm run dev # :5173 클라이언트, :3001 서버
작업 하나를 선택하고 **둘 다 실행(Run both)**을 누른 다음, 두 트레이스(trace)를 비교하며 읽어보세요. 설정 패널은 OpenAI와 호환되는 모든 기본 URL을 지원합니다—기본값은 Particle.ai이며, 클릭 한 번으로 LM Studio / Ollama / Gemini 또는 사용자 지정 설정을 할 수 있습니다. 서버 측에서 제공자별로 하드코딩된 것은 아무것도 없으며, 설정은 요청과 함께 이동합니다.
npm run check는 모델 없이 도구와 라인 카운터를 검증합니다. npm run verify는 HTTP API를 통해 이 세 가지 작업을 두 경로 모두에서 실행하고 증거 파일(evidence file)을 작성합니다.
토큰을 단 하나도 쓰기 전에 스텁이 잡아낸 것들
제공자 할당량(provider quota)을 소모하지 않고 배관(plumbing)을 확인하기 위해, 저는 스크립트화된 OpenAI와 호환되는 서버(scripts/stub-provider.ts)를 작성했습니다. 모델의 응답은 하드코딩되어 있지만, 도구들은 여전히 실제로 실행되므로 calculator는 진정으로 계산하고 live_fetch는 진정으로 네트워크에 접속합니다.
가장 먼저 발견한 것은 내 가정의 버그였습니다: Strands는 항상 스트리밍된다(always streams). chat 어댑터는 stream: true와 stream_options: { include_usage: true }를 하드코딩하기 때문에, 비스트리밍 JSON 스텁을 사용했을 때 Path B가 ModelError: Stream ended without completing a message 오류로 실패했습니다. 반면, 순수(plain) fetch, 순수 JSON을 사용하는 Path A는 정상적으로 작동했습니다. 제가 관심을 갖는 모든 제공업체(provider)는 스트리밍을 지원하지만, 이는 실제적인 비대칭성입니다. 수동으로 작성된 루프가 더 단순한 서버를 허용하기 때문입니다.
두 번째로 발견한 것은 흥미로운 내용이었습니다.
발견: 병렬 도구 호출은 한 경로에서는 작동하고 다른 경로에서는 작동하지 않는다
Task 1의 예시는 "1873 * 42는 무엇이며, 현재 UTC 시간은 얼마인가?"입니다. 이 질문에 대한 모델의 자연스러운 응답은 하나의 턴(turn)에서 두 개의 tool_calls를 사용하는 것입니다. 실제로 그렇게 하는 스트림을 대상으로 했을 때:
| Path A | Path B | |
|---|---|---|
| 실행된 tool calls 수 | 2 | 1 |
| 답변 | 78666 및 시간 | 시간 |
이는 고정된 결과물(fixture artifact)이 아닙니다. OpenAI 어댑터는 도구 호출(tool call)마다 toolUseStart를 방출하지만, 콘텐츠 블록 정지(content-block stops)는 finish_reason이 도착했을 때만 발생합니다 (chat-adapter.js:370). 반면, 공유된 조립기(shared assembler)는 단일한 toolName / toolUseId / accumulatedToolInput 슬롯을 유지하며, 블록 시작 시마다 이를 초기화합니다 (model.js:199-209). 두 번째 시작이 첫 번째 것을 덮어쓰기 때문에, 해당 호출은 실행기(executor)에 도달하지 못합니다.
경계: 한 턴당 하나의 도구 호출만 하는 것은 괜찮습니다. 두 경로 모두 fetch 작업(모델 호출 2회, 도구 1개)과 fetch-then-sum 작업(모델 호출 3회, 도구 2개, 동일한 계산 결과)에서 정확히 일치했습니다.
시스템 프롬프트에 "턴당 최대 하나의 도구 호출만 하라"는 내용을 추가할 유혹이 있었습니다. 하지만 저는 그렇게 하지 않았습니다. 왜냐하면 이는 한 경로의 제한 사항을 숨기기 위해 두 경로 모두가 따라야 하는 규칙 자체를 변경하기 때문에, 전체 빌드 목적을 훼손하기 때문입니다.
코드 및 더 많은 내용: https://www.dailybuild.xyz/project/280-relay
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기