본문 바로가기
프로그래밍/AI

AI가 만든 JSON이 계속 퇴짜맞을 때: LLM 호출을 75% 줄인 세 가지 처방

by me_in_sk 2026. 8. 23.
반응형
LLM 프로덕트 엔지니어링

AI가 만든 JSON이 계속 퇴짜맞을 때: LLM 호출을 75% 줄인 세 가지 처방

PydanticAI 구조화 출력 · 검증-재시도 루프 최적화 실측 기록
핵심 요약
  • 자연어를 복잡한 트레이딩 전략 JSON으로 바꿔주는 AI 기능이, 간단한 요청 하나에 131초 · LLM 호출 8회를 쓰고 있었다. 범인은 모델이 아니라 검증 실패 → 전체 재생성(retry) 루프였다.
  • 처방 세 가지: ① 프롬프트에 완전한 동작 예제 JSON(규칙 목록 10줄보다 효과적) ② 흔한 파라미터는 quick reference로 인라인(불필요한 도구 호출 제거) ③ 흔한 실수는 검증 전에 코드로 수리.
  • 결과: LLM 호출 8회 → 2~3회, 토큰 66K → 13~26K, 응답 시간 131초 → 37~63초, 첫 시도 성공률 ~25% → ~80%.
  • 이 최적화는 이후 모델을 통째로 교체(OpenAI → Claude)한 뒤에도 그대로 유효했다 — 모델보다 오래 사는 건 프롬프트 구조다.

1.무엇을 만들었나 — 자연어를 전략 JSON으로

트레이딩 백테스트 서비스에 코파일럿 기능이 있다. "BTC 1시간봉 RSI 과매도 전략 만들어줘" 같은 자연어를 받아서, 백테스트 엔진이 바로 실행할 수 있는 전략 문서(JSON)를 생성한다. 파이프라인은 이렇다.

사용자 프롬프트 ──▶ Intent Router (결정론적 키워드 매칭 — LLM 호출 없음) ──▶ PydanticAI Agent (도구 호출 + 구조화 출력) ──▶ Output Validator (도메인 검증) 실패 시 ModelRetry ──▶ LLM이 에러를 보고 전체 재생성 ──▶ 다시 검증 ──▶ 성공 시 SSE 스트리밍으로 프론트엔드에 전달

문제는 이 JSON이 만만치 않다는 것이다 — top-level 키 8개, 블록마다 5단계 중첩 설정, 진입/청산 규칙 4벌에 각각 재귀 트리. 그래서 출력을 Pydantic 모델로 강제하고, 도메인 검증기를 달았다. PydanticAI에서는 이렇게 생겼다.

validators/build_validator.py — 검증 실패는 ModelRetry로 LLM에 피드백Python · PydanticAI
async def validate_build_output(ctx, output: StrategyDocumentModel):
    validation = ctx.deps.validation_service.validate_strategy_document(doc_dict)
    if not validation.valid:
        raise ModelRetry(          # ← LLM이 에러 목록을 받고 전체 JSON을 재생성
            f"Validation errors:\n{errors_text}\n\n"
            "Fix ALL errors above. Common fixes:\n"
            "- signal_ref.signal_id must match an existing block id\n"
            "- Follow the COMPLETE WORKING EXAMPLE structure exactly")
    return output

검증기가 있으니 틀린 전략이 사용자에게 도달하는 일은 막았다. 대신 다른 비용이 생겼다 — 퇴짜를 맞을 때마다 LLM 호출이 한 번씩 늘어난다.

2.증상 — 간단한 요청에 131초

첫 실측 결과가 이랬다.

"BTC 1시간봉 RSI 전략" 요청 한 건의 측정값 (최적화 전)측정
⏱ 131.3s
Tokens: 66,207  |  Requests: 8

LLM 호출 8회를 분해하면 이렇다.

  • 도구 호출 3~4회 — RSI처럼 뻔한 인디케이터도 매번 검색 도구 → 상세 조회 도구 → 규칙 검색 도구를 순서대로 불렀다. 도구 호출 하나가 LLM 왕복 한 번이다.
  • 구조화 출력 생성 1회 — 본론.
  • 검증 실패 → 재생성 3~4회 — 사소한 실수 하나로 퇴짜 → 에러 메시지를 받고 전체 JSON을 처음부터 다시 생성 → 또 다른 사소한 실수 → 반복.
요청 한 건이 쓰는 LLM 왕복 — 실제 일은 1회다 "BTC 1시간봉 RSI 전략" 한 건의 실측. 두 줄은 같은 축이다 도구 왕복 3~4 생성 1 재시도 3~4 개선 전 8회 부대비용 7회 — 실제 일의 7배 66,207 토큰 · 131초 생성 1 나머지 1 때때로 +1 개선 후 2~3회 13K~26K 토큰 · 37~63초 사라진 5~6회
도구 왕복과 재시도가 실제 생성의 7배였다. 더 큰 모델을 사는 것으로는 이 비율이 바뀌지 않는다.

즉 실제 일(생성 1회)보다 부대비용(도구 왕복 + 재시도)이 7배였다. 사용자 입장에선 2분 넘게 스피너를 보는 기능이고, 운영 입장에선 요청당 토큰 66K짜리 기능이다. 규모를 붙이면 심각해진다 — 하루 5,000 요청이면 재시도 비용만으로 월 수천 달러 단위가 낭비된다.

3.원인 — 퇴짜의 순환

재시도 로그를 모아 보니, LLM이 틀리는 지점은 놀랍도록 반복적이었다. 전략을 잘못 이해한 게 아니라 스키마의 관례를 어기는, 사람이라면 "아 그거"라고 할 사소한 실수들이었다.

흔한 실수빈도
config.indicator에 실제 인디케이터명 기입 (스키마 관례상 항상 "comparator")매우 높음
트리 노드에 "type" 사용 ("node_type"이어야 함)높음
비교 조건의 타임프레임이 블록 타임프레임과 불일치중간
edges 키 자체를 누락중간
포지션 사이징 설정 누락중간

여기서 뼈아픈 사실 하나. 프롬프트에는 이미 "Critical Rules" 10개 항목이 있었다 — "config.indicator는 항상 comparator다" 같은 규칙이 글로는 다 적혀 있었다. 그런데도 LLM은 계속 어겼다. 없던 것은 규칙이 아니라 완전한 예제였다.

4.처방 1 — 규칙 말고 예제 (가장 큰 임팩트)

그래서 프롬프트에 실제로 동작하는 완전한 입출력 예제를 통째로 넣었다 — 사용자 요청 한 줄과, 그에 대응하는 전략 JSON 전문을.

prompts/shared_instructions.py — COMPLETE WORKING EXAMPLE (발췌)프롬프트
## COMPLETE WORKING EXAMPLE

User: "BTC 1시간봉 RSI 과매도 전략, TP 3% SL 1.5%"

{
  "schema_version": "v2",
  "blocks": [{
    "id": "rsi_oversold",
    "timeframe": "1h",
    "config": {
      "indicator": "comparator",   // ← 규칙으로 10번 말한 것보다 여기 1번이 잘 듣는다
      "params": {
        "left":  { "indicator_id": "rsi", "output_key": "value", "params": { "period": 14 } },
        "operator": "lt",
        "right": { "type": "constant", "constant": 30 }
      }
    }
  }, …블록·로직트리·진입청산 규칙 4벌·리스크 설정까지 전문 포함… ]
}
LLM은 규칙 목록보다 예제에서 패턴을 학습한다

예제를 넣은 뒤 "comparator"·node_type 계열 실수가 거의 사라졌다. 구조가 복잡할수록 "무엇을 하지 마라" 목록보다 "이 모양을 그대로 복제하라"가 강하다 — 모델은 지시를 추론하는 것보다 앞에 보이는 패턴을 이어 쓰는 데 훨씬 능하기 때문이다.

물론 공짜는 아니다. 예제 전문은 입력 토큰을 약 3K 늘린다. 하지만 재시도와 비교하면 계산이 명확하다.

토큰 트레이드오프 — 예제 3K는 투자, 재시도는 낭비계산
예제 없이 재시도 3회:  (11K input + 4K output) × 4회 = 60K 토큰
예제 넣고 재시도 0회:  (14K input + 4K output) × 1회 = 18K 토큰
# 입력 3K를 투자해서 총 42K를 절감 — 게다가 응답 시간은 왕복 횟수에 비례한다

5.처방 2 — 아는 건 묻지 않게 하라

다음은 도구 왕복. 원래 지시문은 "검색 도구로 인디케이터를 찾고, 상세 조회로 파라미터를 확인한 뒤 생성하라"였다 — 신중해 보이지만, RSI·MACD처럼 모델이 이미 아는 것까지 매번 조회하게 만들었다. 그래서 흔한 인디케이터의 파라미터 스펙을 프롬프트에 직접 박고, 도구는 예외로 돌렸다.

prompts/shared_instructions.py — quick reference + 첫 시도 생성 지시 (발췌)프롬프트
- Generate the strategy document on first attempt —
  follow the COMPLETE WORKING EXAMPLE structure exactly.
- Use get_indicator_detail ONLY when unsure about output_keys or params.

Common indicator quick reference (no tool call needed):
- rsi:  output_key="value", params={period: int}
- macd: output_key="histogram"|"macd_line"|"signal_line", params={fast, slow, signal}
- ema/sma: output_key="value", params={period: int}
# …bollinger_bands, adx, stoch_rsi, supertrend, ichimoku 등 계속

효과는 즉각적이었다 — 흔한 요청에서 도구 왕복 2~3회가 그냥 사라졌다. 도구는 "모르는 걸 조회하는 수단"이지 "아는 걸 확인받는 절차"가 아니어야 한다.

6.처방 3 — 흔한 실수는 코드로 고친다

그래도 남는 실수가 있다. 그런데 §3의 표를 다시 보면, 남는 실수 대부분은 기계적으로 고칠 수 있는 것들이다 — edges가 빠졌으면 빈 배열을 넣으면 되고, "type""node_type"으로 바꾸면 된다. 이런 걸 굳이 LLM에게 돌려보내 전체를 재생성시킬 이유가 없다. 그래서 검증 전에 결정론적 수리 단계를 넣었다.

사전 수리(pre-repair) — 도입 당시 형태 (발췌)Python
def _pre_repair(doc: dict) -> dict:
    """LLM이 흔히 만드는 구조적 실수를 검증 전에 코드로 보정한다."""
    doc.setdefault("edges", [])                      # ① 자주 빠뜨리는 키
    for block in doc.get("blocks", []):
        cfg = block["config"]
        cfg["indicator"] = "comparator"             # ② 최빈 실수를 무조건 정규화
        left = cfg["params"]["left"]
        left["timeframe"] = block["timeframe"]       # ③ 블록과 어긋난 타임프레임 동기화
    _fix_node_type_recursive(doc["logic_tree"])      # ④ "type" → "node_type" 재귀 치환
    return doc

이러면 ModelRetry는 정말로 LLM의 판단이 필요한 실수(존재하지 않는 인디케이터, 깨진 참조)에만 발생한다. 그 피드백 메시지도 다듬었다 — 에러 나열에 그치지 않고 "Common fixes" 목록으로 고치는 방법까지 같이 보낸다(§1 코드의 문자열이 그것이다). 참고로 이 수리 계층은 이후 시스템이 개편되면서 파서 단의 정규화(마크다운 코드펜스 벗기기, 관대한 JSON 파싱, 키 정규화)로 흡수됐지만 원리는 그대로다 — 반복되는 실수를 발견하면 프롬프트에 규칙을 한 줄 더 얹는 게 아니라 코드로 승격한다. 이 원칙을 일반화한 이야기는 별도 글에서 다뤘다.

함께 읽기

프롬프트를 고치지 말고 시스템을 고쳐라

실수를 재발 불가능하게 만드는 하네스 설계 — 지침은 확률적으로 유도하고, 코드는 기계적으로 강제한다.

7.결과 — 그리고 덤으로 잡힌 함정 두 개

세 처방을 합친 실측 결과다 (단일 서비스의 사례 측정이지 통제 실험은 아니다).

지표개선 전개선 후변화
LLM 요청 수8회2~3회-62~75%
토큰 사용량66,20713K~26K-60~80%
응답 시간131초37~63초-52~72%
첫 시도 성공률~25%~80%+55%p

이 과정에서 구조화 출력의 프레임워크 함정도 두 개 잡았다. PydanticAI를 쓴다면 그대로 만날 가능성이 높다.

  • 구조화 출력이면 사용자용 텍스트가 없다output_type이 Pydantic 모델이면 LLM은 JSON만 만들고 설명 텍스트(TextPart)를 생성하지 않는다. "텍스트도 같이 생성하라"고 지시하면 토큰이 30%쯤 늘고, 별도 LLM 호출은 비용·지연이 추가된다. 우리는 코드에서 결정론적으로 요약을 생성하는 쪽을 택했다 — 생성된 JSON에서 자산·타임프레임·시그널·리스크를 뽑아 조립하면 무비용·즉시다.
  • 스트리밍에서 도구 인자와 최종 출력이 같은 이벤트로 온다 — 도구 호출의 args도, 구조화 출력 JSON도 똑같이 ToolCallPartDelta로 스트리밍된다. 구분하지 않으면 검색 쿼리 문자열이 "전략 생성 중" 화면에 그대로 흘러나온다. 도구 시작/종료 이벤트 사이인지를 상태로 추적해야 한다.

8.트레이드오프 — 그리고 모델을 바꿔도 남은 것

  • 속도 최적화가 다음 병목을 드러낸다. 단독 요청은 40초 안팎으로 빨라졌지만, 연속 실행하면 API의 분당 토큰 한도(TPM)에 걸려 후속 요청이 대기열에서 타임아웃됐다. 토큰 절감이 이 압박도 함께 줄이긴 하지만, 처리량이 목표라면 별도 설계(캐싱·한도 상향)가 필요하다.
  • 예제는 두 번째 스키마다. 프롬프트 속 예제 JSON은 사실상 스키마의 복사본이라, 스키마가 바뀌면 예제도 함께 갱신해야 한다. 안 하면 예제가 낡은 구조를 가르치는 역효과가 난다 — 예제를 스키마 옆에 두고 같은 PR에서 고치는 규율이 필요하다.
  • 수리 로직은 관례를 강제한다. "indicator는 무조건 comparator로 덮어쓴다" 같은 보정은 스키마 관례가 안정적일 때만 안전하다. 관례가 바뀌면 수리 로직이 조용히 데이터를 오염시킬 수 있으니, 수리 항목마다 근거가 되는 스키마 규칙과 짝을 지어 관리해야 한다.

마지막으로 이 이야기의 후일담. 최적화 몇 달 뒤 이 시스템의 LLM을 OpenAI에서 Claude로 통째로 교체했다 — 설정 한 줄이었다. 그리고 예제 프롬프트, quick reference, 수리 파이프라인은 그대로 남아 그대로 일한다. 모델은 갈아탔지만 "첫 시도를 성공시키는 구조"는 모델 중립적이었던 것이다.

결국 구조화 출력이 느리고 비싸다면 세어 볼 것은 모델 크기가 아니라 왕복 횟수다. 더 큰 모델로 갈아타기 전에 두 가지를 먼저 넣는다 — 첫 시도를 성공시키는 예제와, 재시도를 애초에 만들지 않는 수리 코드. 둘 다 모델을 바꿔도 살아남는다.

LLM 프로덕트 엔지니어링 · 구조화 출력과 재시도

반응형

댓글