프로젝트에 하네스 붙히기 2차

한상우·2026년 7월 6일

AI

목록 보기
14/15

코딩 에이전트의 구조를 프로덕트 에이전트에 이식하기: 예산, 2단 라우팅, 중단-재개, 대화 메모리

이 글은 WelfareAI의 RAG 에이전트를 코딩 에이전트 하네스(Claude Code)의 구조와 나란히 놓고 비교한 뒤, 비어 있던 구조 4가지를 하루에 이식한 기록이다.

핵심 주장은 하나다. 프로덕트 에이전트와 코딩 에이전트는 도메인이 달라도 실패하는 방식이 같다. 루프가 폭주하고, 라우팅이 애매한 입력에서 무너지고, 중단됐다 재개될 때 상태를 잃고, 같은 걸 사용자에게 두 번 묻는다. 코딩 에이전트 하네스는 이 네 가지에 대한 구조적 답을 이미 갖고 있었다.

배경: 비교해 보니 절반은 이미 있었다

WelfareAI의 에이전트는 route별 전용 LangGraph 3개(SEARCH / ELIGIBILITY / APPLICATION_ASSIST)로 구성된다. 하네스 구조와 대조표를 그려 보니 의외로 절반은 이미 독립적으로 같은 결론에 도달해 있었다.

WelfareAI코딩 에이전트 하네스의 대응물
pre-route 정규식 → 매칭 시 LLM 생략결정적 제어 흐름 우선, 모델은 판단만
assess_retrieval 엔티티 부재 게이트adversarial verify 단계
routing/grounding 골든셋 + eval 러너스킬 eval 하네스
구조화 retrieval 계약 (LLM 경계에서만 직렬화)구조화 출력 스키마 강제

반대로 없는 것도 정확히 보였다. 툴 루프 예산, 애매한 입력의 라우팅 폴백, 중단-재개, 세션을 넘는 메모리. 넷 다 "언젠가 하면 좋은 것"이 아니라 이미 사용자가 겪고 있는 문제였다.

1. 툴 루프에 예산이 없었다

Search 그래프의 ReAct 루프는 이렇게 끝난다.

function shouldContinue(state) {
  if (state.answer) return 'save_message';
  if (lastMessage.tool_calls?.length) return 'tools';
  return 'save_message';
}

모델이 툴을 계속 부르면? LangGraph 기본 recursionLimit(25)까지 그냥 돈다. 질문 하나에 LLM 왕복 수십 번이 이론상 가능했고, 아무도 상한을 정한 적이 없었다.

코딩 에이전트 하네스는 에이전트 수·토큰에 명시적 예산을 걸고, 소진 시 "지금까지 결과로 마무리"를 강제한다. 같은 걸 이식했다.

const budgetExhausted = isToolBudgetExhausted(state.messages); // 4라운드
const model = budgetExhausted ? llm : llmWithTools;  // 도구를 떼어낸 LLM

소진 시 도구가 아예 바인딩되지 않은 LLM으로 전환하므로 모델은 물리적으로 툴을 못 부르고, 수집된 근거만으로 답변을 쓴다. trace에는 도구 호출 예산 소진 decision이 남아 옵저버빌리티 화면에서 보인다.

2. 라우팅의 "기본값"은 판단이 아니다

라우팅은 순수 정규식이었고, 골든셋 26케이스로 잠겨 있었다. 문제는 매칭 실패의 처리다. 아무 규칙에도 안 걸린 질문은 전부 SEARCH로 떨어졌다. "복지 혜택 더 받으려면 어떻게 해야 해?"는 신청 절차 질문이지만, 어떻게 신청이라는 표면형이 아니라서 검색 그래프로 갔다.

정규식을 더 정교하게 만드는 건 답이 아니다. 표면형 매칭의 한계는 표면형으로 못 넘는다. 대신 확신과 무지를 구분하는 2단 구조를 넣었다.

  1. 의도 정규식이나 pre-route가 매칭 → 기존 그대로 (fast path, LLM 0회)
  2. 매칭은 실패했지만 의도 힌트(신청|자격|가능|어떻게|...)가 보임 → 이때만 소형 모델(gpt-5-nano)에 3지선다 위임
  3. 폴백이 실패/타임아웃(2.5s) → 기존 SEARCH 기본값

설계 불변식은 하나다. 폴백 도입이 기존 라우팅을 후퇴시킬 수 없다. 확신 케이스는 폴백에 도달하지 않고, 폴백 실패는 곧 기존 동작이다. eval 하네스는 여전히 1단 정규식만 검증하므로 CI는 결정적으로 유지된다. trace에는 [regex] / [llm_fallback] / [regex_default] tier가 남아 폴백 트래픽을 측정할 수 있다.

3. 중단-재개: 가장 아픈 버그는 비교 중에 나왔다

HITL(추가 정보 요청) 흐름을 추적하다가 실제 버그를 찾았다. 프론트의 HITL 패널은 사용자의 선택을 구조화해 놓고도, 제출 시 합성 문장 하나로 뭉개서 일반 채팅 메시지로 재전송하고 있었다.

방금 확인한 정보로 다시 찾아주세요 — 지역: 서울, 나이대: 30대

이 문장이 오케스트레이터에 도착하면 어떻게 될까. 의도 키워드가 하나도 없으니 무조건 SEARCH로 재라우팅된다. 원래 질문이 자격 판정(ELIGIBILITY)이었다는 사실은 유실된다. 클래리피케이션에 성실히 답한 사용자일수록 엉뚱한 그래프의 답을 받는 구조였다.

코딩 에이전트 하네스의 resume은 "완료된 결정은 캐시하고, 바뀐 지점부터만 재실행"이다. 같은 원리를 적용했다. 클래리피케이션으로 턴이 끝날 때 assistant 메시지의 ragContext.hitl원래 질문과 라우트를 실어 두고, 다음 메시지가 보충 답변이면:

  1. 재라우팅 없이 원래 라우트 복원
  2. 원래 질문 + [사용자 보충 정보] ...로 병합해 그래프 실행
  3. hitlResumed 플래그로 이번 턴의 재질문을 전부 억제 (ask-at-most-once)

LangGraph의 checkpointer + interrupt()를 안 쓴 이유도 기록해 둔다. 그래프 state에 스트리밍 콜백 함수가 들어 있어 체크포인트 직렬화가 안 되고, 중단 깊이가 1이라 "결정만 보존, 검색은 재실행"으로 충분하다. 중단 전 검색 결과는 부족 판정을 받은 것이라 보존할 가치도 없다. 구조를 이식할 때는 메커니즘이 아니라 불변식을 이식해야 한다.

4. 답을 받았으면 기억해야 한다

재개까지 만들고 나니 마지막 구멍이 보였다. 사용자가 알려준 나이대·지역·소득은 그 턴에서만 쓰이고 버려졌다. 다음 세션에서 똑같은 질문을 하면 똑같은 걸 또 묻는다. 코딩 에이전트 하네스에는 세션을 넘는 파일 기반 메모리가 있다 — 프로덕트에는 대응물이 없었다.

정형 프로필(user_profiles)에 우겨넣는 대신 별도 테이블을 만들었다. "30대"라는 나이대 답변은 birthDate 컬럼에 넣을 수 없다. 대화에서 나온 값은 원문 그대로 보관하는 게 맞다.

user_profile_facts (user_id, field, value, source, session_id)
  → ('...', 'age', '30대', 'hitl', '...')

보충 답변에서 파싱된 사실은 여기 저장되고, getProfile()hitlFacts로 얹는다. 소비 지점은 둘이다.

  • getClarificationRequest: 이미 답한 필드는 "있음"으로 인정 → 세션이 바뀌어도 재질문 없음
  • 세 그래프의 LLM 컨텍스트: - 이전 대화에서 확인한 정보: 지역 서울 · 나이대 30대 한 줄 추가

검증: 스텁으로 잠그고, 실기로 확인한다

세 층으로 검증했다.

  1. 단위 스펙 (91개) — 예산 카운팅, 폴백 분기, 재개 판정, 사실 파싱을 순수 함수로 떼어내 잠갔다. 파싱 스펙은 실제 버그도 잡았다. 정책명: 청년월세라는 미지원 라벨의 값에서 "월세"가 주거 상태로 오인되는 케이스다.
  2. eval 하네스 (41/41, 회귀 0) — 2단 라우팅을 넣고도 1단 골든셋이 전부 통과함을 확인.
  3. 실기 E2E — 새 코드로 별도 포트에 API를 띄우고 실제 시나리오를 돌렸다.

E2E 세 턴의 trace가 그대로 증거다.

route_type   | question                     | decisions
-------------+------------------------------+------------------------------------------
ELIGIBILITY  | 청년 지원 자격이 되나…       | 라우트 결정 | 추가 정보 요청
ELIGIBILITY  | 방금 확인한 정보로… 20대…    | HITL 재개 | 자격확인 workflow 실행
ELIGIBILITY  | 청년 지원 자격이 되나… (새 세션) | 라우트 결정 | 자격확인 workflow 실행

턴2에서 라우트가 ELIGIBILITY로 복원됐고(수정 전에는 SEARCH로 유실), 턴3(새 세션)에서는 추가 정보 요청이 사라졌다. user_profile_facts에는 age=20대, income=중위소득 80% 이하가 남았다.

덤: 타입체크가 못 돌던 이유

작업 중 nest build가 8GB 힙에서도 OOM으로 죽는 걸 발견했다. 원인은 typescript ^5.0.0이 5.9.3으로 풀리면서 LangGraph StateGraph 제네릭 전개가 폭발한 것. 5.8은 TS2589(과도한 타입 전개), 5.9는 OOM, 5.7.3은 클린. 버전을 고정하자 그동안 타입체크가 아예 못 돌아 숨어 있던 실제 타입 에러 7개가 드러나 같이 수정했다. dev 서버가 transpile-only로 돌고 있어서 아무도 눈치채지 못했던 문제다.

배운 것

  1. 다른 에이전트 시스템과의 구조 대조는 싼 감사(audit)다. 코드를 한 줄도 읽기 전에 "루프 예산 있나? 재개 되나? 기억하나?"라는 질문 목록이 나온다.
  2. 구조 이식의 단위는 메커니즘이 아니라 불변식이다. checkpointer를 복사하는 게 아니라 "결정은 보존, 재실행은 싸게"를 복사한다. 폴백을 붙이는 게 아니라 "폴백은 기존을 후퇴시킬 수 없다"를 붙인다.
  3. 애매함의 기본값은 판단이 아니다. 매칭 실패를 SEARCH로 보내는 건 라우팅이 아니라 포기다. 확신과 무지를 구분하는 순간, 무지 케이스만 비싼 판단(LLM)에 위임할 수 있게 된다.
  4. HITL은 묻는 기능이 아니라 묻고-받고-기억하는 파이프라인이다. 묻기만 구현하면 라우트 유실과 반복 질문이 따라온다. 재개와 메모리까지가 한 세트다.
profile
안녕하세요

0개의 댓글