Skip to content

[Agent] LLM 토큰 계측 신설 + prompt caching·대화 윈도우로 요청 본문 82% 감소 [ #343 ] - #354

Merged
Danto7632 merged 4 commits into
developfrom
danto/perf-llm-cost
Sep 11, 2026
Merged

[Agent] LLM 토큰 계측 신설 + prompt caching·대화 윈도우로 요청 본문 82% 감소 [ #343 ]#354
Danto7632 merged 4 commits into
developfrom
danto/perf-llm-cost

Conversation

@Danto7632

Copy link
Copy Markdown
Member

요약

LLM 토큰을 얼마나 쓰는지 아무도 몰랐다 — 응답의 usage 를 파싱·저장하는 코드가 저장소에 하나도 없었다(#343). 계측을 먼저 넣고, 그 위에서 줄였다.

실측 (목 서버로 나가는 실제 요청 본문 측정)

8-2 CODE 10라운드 전송 96,290 B 중 다음 라운드에 캐시 접두로 들어가는 양 79,389 B (82.4%)
8-3 120턴 대화 요청 본문 41,571 → 7,131 B (82.8% 감소)
8-1 10라운드 usage 집계 목 응답 합계와 정확히 일치(172,000 토큰), DB 태스크 합산 쿼리도 검증

실제 LLM 키가 없어 실호출은 못 했다. 캐시 적중률 자체는 실키 환경에서 cache_read_input_tokens 로 확인해야 하고, 그 계측이 이번에 들어갔다.

8-1 저장 설계 — 신규 llm_usage 테이블(V63), 호출 한 건당 행 하나

agent_runs 누적 컬럼을 택하지 않은 이유 넷:

  1. 배포 실패 분석은 태스크 없이 돈다 — NOT NULL FK 면 기록 자체가 불가
  2. agent_runs 는 보존 스케줄러(Issue BF: [DB] 성능 U3 — 웹훅마다 projects 풀스캔, webhook_deliveries 무한 성장, 중복 인덱스 #338)가 지운다. 비용 이력이 함께 사라지면 "지난달 대비" 에 답할 수 없다
  3. 호출 단위라야 라운드별 캐시 적중이 보인다. 누적 컬럼은 "캐싱이 실제로 사는가" 에 답하지 못한다
  4. CODE 루프는 라운드마다 쓴다 — 누적 컬럼이면 워커도 쓰는 뜨거운 행을 40번 UPDATE 한다

FK 는 걸지 않았다(잠금 그래프 격리 + 2번).

귀속은 LlmPort 시그니처가 아니라 실행 스레드 스코프(LlmUsageScope)로 했다. 호출부가 전부 병렬 접촉 파일이고, 시그니처에 실으면 새 호출부가 빠뜨릴 수 있다. 스코프 없는 호출도 UNSCOPED 로 기록된다 — 귀속만 없고 합계에서 사라지지 않는다.

8-2 캐시가 실제로 사는 근거 — 접두가 고정이다

SYSTEM_PROMPT·TOOLSstatic final 상수이고 트랜스크립트는 덧붙기만 한다. 이걸 추측하지 않고 테스트로 못박았다 — 5라운드 동안 tools+system+model 직렬화 바이트가 동일함, 그리고 라운드 N+1 의 앞부분이 라운드 N 과 cache_control 마커를 빼고 완전 일치함(append-only).

배치는 tools 마지막 1 + system 1 + 굴러가는 user 2 = 정확히 상한 4개.

⚠️ 알아둘 것: 지금 설정된 claude-opus-4-5-20251101 은 최소 캐시 접두가 4096 토큰이다. CODE 시스템 프롬프트는 4,280자(≈1.1K 토큰), Decision 은 14,848자(≈3.8K 토큰) — 둘 다 그 미만이라 system/tools 단독 지점은 캐시 항목을 만들지 못한다(오류도 과금도 없다). 실제로 값을 하는 것은 굴러가는 user 지점이고, 트랜스크립트가 4096 토큰을 넘는 2~3라운드째부터 시스템·도구까지 함께 캐시된다(더 긴 접두가 앞을 포함하므로). 최소 접두가 낮은 모델(512)로 설정을 바꾸면 코드 변경 없이 1라운드부터 적중한다.

8-3 윈도우로 잃는 것 (동작 변경)

최근 20턴 + 24,000자. 창 밖 앞부분을 에이전트가 못 본다 — 긴 대화에서 초반에만 나온 전제(예: "이 앱은 사내용")는 사용자가 다시 말해야 할 수 있다. 마지막 턴은 상한을 넘어도 남긴다(그것이 지금 처리할 요청).

getUserIntentHistory표시를 먼저 붙이고 자른다 — 순서가 반대면 창 안 마지막 턴에 "[지금 처리할 요청]" 이 붙어 옛 요청이 새 요청으로 둔갑한다.

규칙은 새 클래스 ConversationWindow, 호출부는 AgentMessageService 한 곳만.

8-5 모델 고정 해제 · 되돌리는 법

배포 실패 분석이 ANTHROPIC + claude-opus-4-5 로 박혀 있었고 설정의 defaultProvider 는 GLM 이었다. 12,000자 로그를 한 번 요약하는 데 최상위 모델을 쓸 근거가 없다.

되돌리려면: qeploy.ai.failure-analysis.provider=ANTHROPIC, .model=claude-opus-4-5-20251101(또는 QEPLOY_AI_FAILURE_ANALYSIS_* 환경변수). 재배포 없이 설정만으로 복귀. 기본 동작과 되돌린 동작 양쪽을 테스트로 고정했다.

8-6 상한 초과 시 사용자가 보는 것

채팅에 TASK_FAILED 로 그대로 뜬다:

이 작업이 AI 토큰 예산 상한에 도달해 중단했습니다 (1,004,200 / 1,000,000 토큰). 요청을 더 작은 단위로 나눠 다시 시도하거나, 관리자에게 상한 조정을 요청해주세요.

catch-all 의 "작업 중 오류가 발생했습니다" 접두가 붙지 않도록 전용 분기를 뒀고, 빌드실패 복구(재시도) 경로로도 보내지 않는다. 누적은 llm_usage 에서 읽어 재시도를 건너 이어 세므로 재시도로 상한을 우회할 수 없다. 기본 1,000,000(0이면 끔).

계약·동작 변화

신규 설정 키 5개(ai.completion-max-tokens, ai.code-agent.max-task-tokens, ai.failure-analysis.provider/model, ai.anthropic.base-url), 신규 테이블 1개.

노출 API 스키마 변화 없음 — 다만 배포 실패 분석 응답의 provider/model 이 ANTHROPIC/claude-opus-4-5 → GLM/z-ai/glm-4.6 으로 바뀐다. 새 실패 사유 문구 1종(토큰 상한).

검증

  • 기준선 1488 전체 / 1476 통과 → 1534 전체 / 1522 통과 / 실패 0 / 스킵 12
  • 3차 웨이브 5개 통합 후 1678 전체 / 1666 통과 / 실패 0
  • Flyway V63, develop 최신 V62 확인 — 머지 직전 재확인 필요

안 한 것

  1. LlmPort.complete 반환형 변경 — 호출부 6곳이 전부 병렬 접촉 파일. 스코프 방식이 충돌도 적고 누락 위험도 낮다
  2. 요약으로 대화 접두 대체(8-3 대안) — 요약용 LLM 호출을 하나 더 늘리는 일이라 비용 작업에서 역행
  3. Anthropic Java SDK 전환 — 이 저장소는 3개 제공자를 RestClient 원시 HTTP 공유 추상화로 다룬다. 범위 밖 대공사
  4. llm_usage 보존 정책 — 지금 붙이면 비용 이력이 사라져 8-1 목적과 충돌. 증가량은 태스크당 수십 행. 후속 과제
  5. cache_control TTL 1h — 기본 5분 유지. CODE 라운드 간격은 수초~수십초라 충분하고 1h 는 쓰기 프리미엄이 더 비싸다
  6. 토큰 사용량 FE 노출api.md 계약 변경이라 별도 과제

Closes #343

https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr

응답의 usage 를 파싱·저장하는 코드가 저장소에 하나도 없었다. 토큰을 얼마나 쓰는지
아무도 몰랐고, 절감 작업의 효과를 잴 수단도 태스크당 예산 상한이 셀 대상도 없었다.

8-1 계측
- 호출 한 건마다 llm_usage 에 행 하나(V63). agent_runs 누적 컬럼이 아닌 이유는
  마이그레이션 주석에 적었다 — 태스크 밖 호출, 보존 정책에 의한 삭제, 라운드별 캐시
  적중 가시성, 뜨거운 행 UPDATE 회피.
- 제공자마다 다른 usage 필드를 한 모양으로 정규화한다. Anthropic 의 input_tokens 는
  캐시분 제외, OpenAI 의 prompt_tokens 는 포함이라 그대로 더하면 뜻이 섞인다.
- LlmPort 시그니처에 taskId 를 더하는 대신 실행 스레드의 스코프로 귀속한다. 호출부가
  전부 병렬 작업 접촉 파일이고, 시그니처에 실으면 새 호출부가 빠뜨릴 수 있다.
- 계측 실패는 삼킨다. 이미 성공한 LLM 호출이 계측 때문에 실패해선 안 된다.

8-2 prompt caching
- tools 마지막·system·최근 user 턴 둘에 cache_control(상한 4개를 정확히 채운다).
- 이 루프의 접두는 고정이다 — 시스템 프롬프트와 도구 정의가 상수이고 트랜스크립트는
  덧붙기만 한다. 그 사실을 테스트로 못 박았다(접두 바이트 동일·append-only).
- 지금 설정된 claude-opus-4-5 는 최소 캐시 접두가 4096 토큰이라 system(≈1.1K)·tools
  단독 지점은 항목을 만들지 못한다. 값을 하는 것은 굴러가는 user 지점이다.

8-4 출력 상한
- 도구 없는 완성 호출의 max_tokens 1024 → 설정 가능(기본 4096). 다단계 계획 JSON 이
  잘리면 교정 재시도가 전체 컨텍스트를 한 번 더 보냈다 — 아끼는 출력보다 치르는
  입력이 컸다.

곁들여: 파싱 실패 로그가 제공자 응답 전체를 찍던 것을 앞부분만 남기게 잘랐다.
Anthropic 엔드포인트를 설정으로 뺐다 — 나가는 요청 본문을 실제로 검사하는 테스트는
그래야 가능하다.

측정(목 서버로 실제 나가는 본문): CODE 10라운드 96,290 bytes 중 79,389 bytes(82.4%)가
다음 라운드에 브레이크포인트 뒤 접두로 들어간다.

Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr
8-3 대화 이력 윈도우
- 최근 20턴 + 24,000자 상한. 규칙은 새 클래스(ConversationWindow)에 두고 호출부는
  AgentMessageService 한 곳만 고친다 — 규칙이 흩어지면 곧 서로 다른 창이 된다.
- **동작 변경이다.** 창 밖으로 밀려난 앞부분을 에이전트는 못 본다. 긴 대화에서 초반에만
  나온 전제는 사용자가 다시 말해야 할 수 있다. 요약으로 접두를 대체하는 길도 있지만
  요약용 LLM 호출을 하나 더 늘리는 일이라, 비용을 줄이려는 이 작업에서는 택하지 않았다.
- 마지막 턴은 상한을 넘어도 남긴다. 그것이 지금 처리할 요청 그 자체다.
- getUserIntentHistory 는 표시를 먼저 붙이고 자른다. 순서가 반대면 창 안의 마지막 턴에
  "[지금 처리할 요청]" 이 붙어 이미 처리된 옛 요청이 새 요청으로 둔갑한다.

8-6 태스크당 누적 토큰 상한
- 기본 1,000,000 토큰(0 이면 없음). 재시도 곱셈(provider 3 × round 40 × task 3)에
  태스크 단위 상한이 없었다.
- 이전 실행이 쓴 양을 llm_usage 에서 읽어 이어 센다 — 실행마다 0 에서 다시 세면 상한이
  재시도 횟수만큼 곱해져 상한이 아니게 된다.
- 상한에 걸리면 사용자에게 사용량/상한이 그대로 보인다. catch-all 의 "작업 중 오류가
  발생했습니다" 접두가 붙지 않도록 전용 분기를 뒀고, 빌드실패 복구 경로로도 보내지
  않는다 — 누적이 이어지므로 재시도해도 첫 호출에서 같은 상한에 다시 걸린다.

8-5 배포 실패 분석
- 제공자 ANTHROPIC 고정 + 최상위 기본 모델을 걷어내고 default-provider(GLM)를 따른다.
  12,000자 로그를 한 번 요약하는 데 최상위 모델을 쓸 근거가 없었다.
- **되돌리는 법**: qeploy.ai.failure-analysis.provider=ANTHROPIC,
  .model=claude-opus-4-5-20251101 (또는 QEPLOY_AI_FAILURE_ANALYSIS_* 환경변수).
  분석 품질이 떨어지면 재배포 없이 설정만으로 돌아간다. 테스트로 양쪽을 고정했다.

측정: 120턴 대화 요청 본문 41,571 → 7,131 bytes (82.8% 감소).

Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr
8-7. LLM 원문 응답 전체를 INFO 로 찍고 있었다. 계획 JSON 에는 사용자가 무엇을 만들라고
했는지가 그대로 들어가고, 교정 재시도 로그에는 모델이 쓴 응답이 통째로 들어간다 —
운영 로그 수집기로 사용자 요청과 생성 코드가 흘러나가는 경로였다.

- INFO 에는 길이만 남긴다(응답이 잘렸는지 같은 운영 질문에는 답할 수 있어야 한다).
- 원문 미리보기는 DEBUG 로 내리고 300자로 자른다. 파싱 실패 경로가 원문을 가장 길게
  찍던 곳이라 그 두 곳도 같이 잘랐다.
- 로그를 실제로 가로채 검증한다. 무엇이 찍히는지는 그것 말고 확인할 방법이 없다.

Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr
#336 이 "키를 인스턴스에 고정하지 않는다" 를 못박는 테스트를 넣었고, 이 단위가 같은
클라이언트에 LlmUsageRecorder 를 주입하면서 생성자가 바뀌었다. 두 브랜치가 각각은
초록이지만 합치면 컴파일이 깨진다.

Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr
@Danto7632
Danto7632 merged commit d83921b into develop Sep 11, 2026
1 check passed
@Danto7632
Danto7632 deleted the danto/perf-llm-cost branch September 11, 2026 02:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant