Claude Code 수준의 Agent 정확도 달성하기: 12대 개선사항¶
문제 인식¶
Agent 시스템의 기본 아키텍처를 구축한 후 실사이트 테스트에 들어갔다. Nexacro 기반 이커머스 관리자 페이지에서 "상품 등록" 같은 작업을 시도하자 다양한 문제가 쏟아졌다:
- Agent가 클릭해야 할 요소를 못 찾는다
- 팝업이 뜨면 멈춘다
- 텍스트만 보고하고 실제 액션을 안 한다
- 같은 동작을 무한 반복한다
- 메뉴 트리가 snapshot을 가득 채워 정작 중요한 폼 요소를 못 본다
이 문제들을 2026년 2월 8일 하루 동안 12가지 개선사항으로 일괄 해결했다.
12대 개선사항 전체 목록¶
flowchart LR
subgraph Input["입력 최적화"]
S1[1. Human-in-the-Loop<br/>NEED_HELP 마커]
S2[2. smartTruncateSnapshot<br/>메뉴 블록 접기]
S3[3. removeIrrelevantBlocks<br/>불필요 영역 제거]
S4[4. annotateImgButtons<br/>이미지 버튼 라벨링]
end
subgraph Context["컨텍스트 관리"]
S5[5. 컨텍스트 슬라이딩<br/>tool 결과 요약]
S6[6. 보고서 모드 방지<br/>nudge 메시지]
end
subgraph Output["출력 개선"]
S7[7. screenshot 대체<br/>evaluate 텍스트 추출]
S8[8. focused_snapshot<br/>특정 영역 추출]
end
subgraph Error["에러 처리"]
S9[9. 클릭 실패 감지<br/>팝업 오버레이 안내]
S10[10. submit 후 alert<br/>에러 자동 감지]
end
subgraph Prompt["프롬프트 강화"]
S11[11. SYSTEM_PROMPT<br/>패턴 추가]
S12[12. cleanupActions<br/>액션 정리 보강]
end
개선사항 1: Human-in-the-Loop ([NEED_HELP] 마커)¶
Agent가 스스로 해결할 수 없는 상황(CAPTCHA, 2FA, 파일 선택 다이얼로그 등)을 만나면 [NEED_HELP] 마커로 사용자에게 수동 조작을 요청한다.
// Agent가 도움이 필요한 상황을 감지하면
if (text.includes(NEED_HELP_MARKER)) {
const helpText = text.replace(NEED_HELP_MARKER, '').trim();
const helpMsg: ChatMessage = {
id: `msg_${Date.now()}_help_${round}`,
role: 'assistant',
content: helpText || '도움이 필요합니다. 브라우저에서 직접 작업해주세요.',
timestamp: new Date(),
};
conversationRef.current = [...conversationRef.current, helpMsg];
// 사용자 수동 조작 대기
const userResponse = await pauseForHuman('need_help');
if (stopRef.current === 'stop') break;
// 재개: 사용자가 한 작업을 캡처 스크립트로 수집
const resumeMsg = buildResumeContext(userResponse);
conversationRef.current = [...conversationRef.current, resumeMsg];
continue;
}
사용자가 수동으로 조작하는 동안 캡처 스크립트가 click, input, change, Enter 이벤트를 감지하여 기록한다. 재개 시 이 정보를 LLM에게 전달하여 작업 맥락을 유지한다.
개선사항 2: smartTruncateSnapshot (메뉴 블록 접기)¶
Nexacro 사이트의 접근성 트리에는 좌측 메뉴 트리가 수천 줄을 차지한다. 정작 중요한 폼 요소가 밀려나서 LLM이 보지 못한다.
smartTruncateSnapshot은 반복되는 메뉴 블록을 접고, 폼 요소(input, select, button, textarea)를 보충하는 FORM_ELEMENTS_SCRIPT를 추가한다.
단, 이 접근법은 나중에 근본적으로 다른 해결책으로 대체되었다. snapshot 원본 전달 방식으로 전환하면서 이 코드는 삭제되었다 (개선사항 13 참조).
개선사항 3: removeIrrelevantBlocks (불필요 영역 자동 제거)¶
로그인 폼, 공지사항 배너, 푸터 영역 등 작업과 무관한 영역을 자동으로 식별하여 제거한다. 실사이트 테스트에서 이것만으로 약 8,000자를 절약했다.
개선사항 4: annotateImgButtons (이미지 버튼 라벨링)¶
이커머스 관리자 페이지에서 흔한 패턴: 검색 필드 옆에 btn_popup* 이미지 버튼이 있고, 이 버튼을 클릭해야 검색 팝업이 열린다. 접근성 트리에서는 이미지 버튼의 용도를 알 수 없으므로, 옆에 있는 textbox의 라벨을 자동으로 연결해서 표시한다.
개선사항 5: LLM 컨텍스트 슬라이딩 윈도우¶
Agent가 오래 실행되면 대화 히스토리가 계속 쌓인다. snapshot 하나가 수천 줄이므로 금방 토큰 한도에 도달한다.
// 대화 컨텍스트 슬라이딩 윈도우
const KEEP_FULL_TOOL_RESULTS = 2;
const toolMsgIndices: number[] = [];
for (let i = 0; i < conversation.length; i++) {
if (conversation[i].role === 'tool') toolMsgIndices.push(i);
}
const oldToolIndices = new Set(toolMsgIndices.slice(0, -KEEP_FULL_TOOL_RESULTS));
for (let i = 0; i < conversation.length; i++) {
const msg = conversation[i];
if (msg.role === 'tool' && oldToolIndices.has(i)) {
// 오래된 tool 결과는 3줄 요약으로 축약
const summary = extractToolSummary(msg.content);
parts.push(`[도구 결과 요약] ${summary}`);
} else {
parts.push(msg.content);
}
}
최근 2개의 tool 결과만 전체를 유지하고(이전 snapshot의 ref 번호를 참조할 수 있도록), 나머지는 3줄 요약으로 축약한다.
개선사항 6: 보고서 모드 방지 (nudge 메시지)¶
LLM이 <action> 태그 없이 텍스트로만 "이제 이걸 해야 합니다"라고 보고만 하는 문제. Agent인데 보고서를 쓰고 있으면 안 된다.
if (browserActions.length === 0 && round > 0 && textOnlyRetries < 1) {
textOnlyRetries++;
const nudgeMsg: ChatMessage = {
id: `msg_${Date.now()}_nudge`,
role: 'user',
content: '작업이 아직 남아있다면 <action> 태그로 다음 작업을 실행하세요. '
+ '모든 작업이 완료되었다면 간단히 완료 보고만 하세요.',
timestamp: new Date(),
};
conversationRef.current = [...conversationRef.current, partialMsg, nudgeMsg];
continue;
}
텍스트만 응답이 오면 1~2번 "넛지" 메시지를 보내 액션을 유도한다. 그래도 텍스트만 오면 human-in-the-loop로 전환한다.
개선사항 7: screenshot을 evaluate 기반 텍스트 추출로 대체¶
LLM은 이미지를 볼 수 없다(text-only 모드). Agent가 screenshot을 찍어도 활용하지 못한다. browser_evaluate로 페이지 정보를 텍스트로 추출하는 방식으로 교체했다.
개선사항 8: focused_snapshot (특정 영역 폼 요소 추출)¶
전체 접근성 트리가 너무 길면 특정 CSS 선택자 영역만 추출하는 focused_snapshot 도구를 추가했다.
Nexacro의 복잡한 페이지에서 iframe 내부나 특정 폼 영역만 볼 때 유용하다. 전체 snapshot이 12,000자를 넘어갈 때 이 도구로 핵심 영역만 집중한다.
개선사항 9: 클릭 실패 시 팝업 오버레이 감지¶
클릭이 실패하면 팝업이나 오버레이가 가로막고 있을 가능성이 높다. 실패 시 자동으로 오버레이를 감지하고, 닫기 버튼의 위치를 안내한다.
개선사항 10: submit 후 alert/에러 메시지 자동 감지¶
폼을 제출한 후 발생하는 alert 다이얼로그나 에러 메시지를 자동으로 감지하여 LLM에게 피드백한다. 이를 통해 Agent가 에러를 인식하고 다른 접근법을 시도할 수 있다.
개선사항 11: SYSTEM_PROMPT 패턴 추가¶
실사이트 테스트에서 발견한 구체적인 UI 패턴을 시스템 프롬프트에 추가했다:
- 검색 선택 필드: 돋보기 버튼(
img "btn_popup*") 클릭 → 팝업에서 선택.type + Enter는 동작하지 않는다 - 팝업/오버레이 처리:
press_key Escape→ 안 되면snapshot으로 닫기 버튼 찾기 - 동적 UI: Nexacro/SAP에서 좌측 메뉴가 트리를 차지하면
focused_snapshot사용 - iframe:
focused_snapshot("iframe")으로 iframe 내부 추출
개선사항 12: cleanupActions 보강¶
시나리오 녹화 후 불필요한 액션을 자동으로 정리한다.
const cleanupActions = useCallback(() => {
setActions(prev => {
const cleaned: Partial<RecordedAction>[] = [];
for (let i = 0; i < prev.length; i++) {
const action = prev[i];
// 탐색 전용 액션 제거 (시나리오 재생에 불필요)
const exploratoryTypes = [
'snapshot', 'screenshot', 'focused_snapshot', 'tab_list', 'hover'
];
if (exploratoryTypes.includes(action.type as string)) continue;
// 짧은 wait 제거 (1초 이하)
if (action.type === 'wait' && parseInt(action.value) <= 1000) continue;
// 연속 중복 navigate/click 제거
if (action.type === 'navigate' && prev[i + 1]?.type === 'navigate') continue;
if (action.type === 'click' && prev[i + 1]?.type === 'click'
&& action.selector === prev[i + 1].selector) continue;
// 같은 필드에 연속 type → 마지막 값만 유지
if (action.type === 'type' && prev[i + 1]?.type === 'type'
&& action.selector === prev[i + 1].selector) continue;
// press_key "Escape" 제거
if (action.type === 'press_key' && action.value === 'Escape') continue;
cleaned.push(action);
}
return cleaned;
});
}, []);
결정적 전환점: snapshot 원본 전달¶
12대 개선사항을 적용한 후에도 정확도가 만족스럽지 않았다. 근본 원인은 snapshot 가공 과정에서 ref 번호가 손실되는 것이었다.
smartTruncateSnapshot, removeIrrelevantBlocks, annotateImgButtons, collapseRepeatingBlocks, FORM_ELEMENTS_SCRIPT -- 이 모든 가공 로직이 snapshot의 구조를 변형하면서 ref 번호 매핑이 깨졌다.
해결책은 놀랍도록 단순했다: Playwright snapshot 원본을 그대로 LLM에 전달한다.
// 이전: 가공된 snapshot (ref 번호 손실 위험)
const processed = smartTruncateSnapshot(snapshot, 12000);
// 이후: 원본 그대로 전달 (ref 번호 100% 보존)
lastSnapshotRef.current = snapshot;
이 전환으로 393줄의 가공 코드를 삭제했다. 대신 컨텍스트 관리를 강화했다:
- callLLM: 최근 1개(후에 2개) tool 결과만 전체 유지, 나머지 3줄 요약
- assistant 메시지: 최근 2개만 전체, 나머지 3000자 초과 시 1500자로 축약
- input_data 80000자 초과 시 공격적 축약 (최근 6개 메시지만 유지)
- 네트워크 에러(Load failed) 시 2초 후 1회 재시도
snapshot을 가공하는 대신 컨텍스트 관리로 토큰 문제를 해결한 것이 핵심이다. Claude Code도 같은 원리로 동작한다: 도구 결과를 그대로 전달하되, 오래된 것은 요약한다.
Human-in-the-Loop 확장: 일시정지와 MAX_ROUNDS¶
정확도가 올라가면서 Agent가 더 오래 자율적으로 실행되었다. 이를 지원하기 위한 제어 메커니즘을 추가했다.
// stopRef: boolean → 'stop' | 'pause' | false 유니온 타입
const stopRef = useRef<'stop' | 'pause' | false>(false);
// 일시정지: 현재 라운드 완료 후 human-in-the-loop 진입
if (stopRef.current === 'pause') {
stopRef.current = false;
const pauseMsg: ChatMessage = {
role: 'assistant',
content: '일시정지되었습니다. 브라우저에서 직접 작업한 후 계속 버튼을 눌러주세요.',
};
const userResponse = await pauseForHuman('paused');
continue;
}
// MAX_ROUNDS 도달 시 완전 종료 대신 human-in-the-loop
const maxRoundMsg: ChatMessage = {
role: 'assistant',
content: `${MAX_ROUNDS}라운드가 완료되었습니다. 추가 작업이 필요하면 계속 버튼을 누르세요.`,
};
const userResponse = await pauseForHuman('max_rounds');
MAX_ROUNDS는 8 → 15 → 50으로 증가시켰다. 50라운드에 도달하면 자동 종료가 아닌 human-in-the-loop로 전환하여 사용자가 계속 진행할지 결정한다.
Text-only 응답도 human-in-the-loop로¶
# 커밋: fix: prevent agent from stopping mid-task - use human-in-the-loop instead
# 날짜: 2026-02-09 01:14
Agent가 텍스트만 응답하면 이전에는 루프를 종료했다. 하지만 실제로는 Agent가 "중간 보고"를 하는 경우가 많았다. 종료 대신 human-in-the-loop로 전환하여 사용자가 "계속해"를 눌러 작업을 이어갈 수 있게 했다.
시나리오 검증과 엑셀 루프 변환¶
Agent가 생성한 시나리오의 품질을 LLM으로 검증하는 기능도 추가했다.
const validateActions = useCallback(async () => {
// LLM에게 시나리오와 원래 요청을 비교 분석 요청
const validatePrompt = `
사용자 원래 요청: "${originalRequest}"
기록된 시나리오: ${JSON.stringify(actions)}
다음을 분석하세요:
1. changes: 실패/불필요한 액션 제거
2. missing_steps: 누락된 단계
3. summary: 전체 평가
`;
const response = await callLLM(validateConversation);
// JSON 파싱 → 실패 액션 자동 제거, 누락 단계 안내
}, []);
검증 결과에 따라: - 실패/재시도 기록이 있는 액션 자동 제거 - 탐색/시행착오 액션 정리 - 누락된 단계를 사용자에게 안내
실행 중 메시지 전송 (Status Bar)¶
Agent가 실행되는 동안에도 사용자가 추가 지시를 전송할 수 있다. 메시지 큐에 넣으면 다음 라운드에 자동으로 주입된다.
const queueMessage = useCallback((msg: string) => {
queuedMessageRef.current = msg;
}, []);
// 각 라운드 시작 시 큐 확인
if (queuedMessageRef.current) {
const injectedMsg: ChatMessage = {
role: 'user',
content: queuedMessageRef.current,
timestamp: new Date(),
};
conversationRef.current = [...conversationRef.current, injectedMsg];
queuedMessageRef.current = null;
}
Status Bar에는 현재 URL, 라운드(n/50), 기록된 액션 수, 진행 바, 상태 표시가 실시간으로 업데이트된다.
개선 전후 비교¶
| 항목 | 개선 전 | 개선 후 |
|---|---|---|
| snapshot 처리 | 가공(접기, 제거, 라벨링) | 원본 그대로 전달 |
| ref 정확도 | 가공 시 손실 가능 | 100% 보존 |
| 가공 코드 | 393줄 | 0줄 (삭제) |
| 컨텍스트 관리 | 없음 | 슬라이딩 윈도우 + 요약 |
| 텍스트 응답 처리 | 루프 종료 | human-in-the-loop |
| MAX_ROUNDS | 8 (고정 종료) | 50 (human-in-the-loop) |
| 무한 루프 감지 | 없음 | 동일 시그니처 3회 → 일시정지 |
| 시나리오 정리 | 수동 | cleanupActions 자동 |
개선 타임라인¶
전체 개선은 약 20시간 동안 이루어졌다:
- 2/8 03:54 -- Agent Tool Ecosystem 초기 구축 (+5,391줄)
- 2/8 04:01~06:16 -- Playwright MCP 통합 안정화 (evaluate 파라미터, 메타데이터 strip, 컨텍스트 불일치)
- 2/8 11:28~15:34 -- 시나리오 레코더 안정화 (네비게이션 생존, 탭 감지, 셀렉터 fallback)
- 2/8 17:53 -- 검색엔진 탈출 방지
- 2/8 19:20 -- Agent 대폭 개선 v1 (+317줄)
- 2/8 21:40 -- 12대 개선사항 (+1,247줄)
- 2/8 23:56 -- snapshot 원본 전달 전환 (-393줄, 정확도 대폭 향상)
- 2/9 01:01~01:57 -- Human-in-the-loop 확장, MAX_ROUNDS 증가, CSS 셀렉터 기록
- 2/9 08:30 -- 시나리오 검증 + 엑셀 루프 변환
핵심 교훈은 "가공보다 원본이 낫다"는 것이다. snapshot을 가공해서 토큰을 아끼려 했지만, 정확도 손실이 더 컸다. 원본을 전달하고 컨텍스트 관리로 토큰 문제를 해결하는 것이 정답이었다.
관련 글
- CSS 셀렉터 대체 전략: selector_alternatives로 안정성 확보
AI AgentBrowser AutomationCSS Selector - Human-in-the-Loop: AI Agent에 사람 개입 지점 설계하기
AI AgentBrowser AutomationHuman-in-the-Loop - 새 탭 감지 및 자동 전환: 브라우저 자동화의 까다로운 문제
AI AgentBrowser AutomationPlaywright - 브라우저 자동화 시 페이지 네비게이션 생존 전략
AI AgentBrowser AutomationNavigation - LLM 텍스트 우선 표시: Agent UX에서의 응답 순서 최적화
AI AgentLLMPlaywright