diff --git a/src/main/java/com/example/dvely/agent/application/exception/AgentTokenBudgetExceededException.java b/src/main/java/com/example/dvely/agent/application/exception/AgentTokenBudgetExceededException.java new file mode 100644 index 00000000..18ea2dbd --- /dev/null +++ b/src/main/java/com/example/dvely/agent/application/exception/AgentTokenBudgetExceededException.java @@ -0,0 +1,33 @@ +package com.example.dvely.agent.application.exception; + +/** + * 한 태스크가 쓸 수 있는 누적 토큰 상한을 넘겼다. + * + *

상한이 없던 동안 곱셈이 그대로 열려 있었다 — 제공자 재시도({@code retry.maxAttempts} 3) × + * 라운드({@code codeAgent.maxIterations} 40) × 태스크 재시도({@code maxAttempts} 3). 각 단계는 + * 자기 한도를 지키지만 태스크 전체가 쓰는 양에는 아무 한도가 없었다.

+ * + *

메시지는 사용자에게 그대로 보인다. 상한에 걸렸을 때 조용히 멈추면 사용자에게는 + * "왜 안 되지" 로만 남으므로, 무엇이 일어났고 무엇을 하면 되는지를 한 문장으로 담는다.

+ */ +public class AgentTokenBudgetExceededException extends RuntimeException { + + private final long usedTokens; + private final long budgetTokens; + + public AgentTokenBudgetExceededException(long usedTokens, long budgetTokens) { + super(("이 작업이 AI 토큰 예산 상한에 도달해 중단했습니다 (%,d / %,d 토큰). " + + "요청을 더 작은 단위로 나눠 다시 시도하거나, 관리자에게 상한 조정을 요청해주세요.") + .formatted(usedTokens, budgetTokens)); + this.usedTokens = usedTokens; + this.budgetTokens = budgetTokens; + } + + public long usedTokens() { + return usedTokens; + } + + public long budgetTokens() { + return budgetTokens; + } +} diff --git a/src/main/java/com/example/dvely/agent/application/orchestrator/AgentPlanExecutor.java b/src/main/java/com/example/dvely/agent/application/orchestrator/AgentPlanExecutor.java index 3d7309cc..84f692eb 100644 --- a/src/main/java/com/example/dvely/agent/application/orchestrator/AgentPlanExecutor.java +++ b/src/main/java/com/example/dvely/agent/application/orchestrator/AgentPlanExecutor.java @@ -5,6 +5,7 @@ import com.example.dvely.agent.application.dto.AgentStep; import com.example.dvely.agent.application.dto.AgentTask; import com.example.dvely.agent.application.exception.AgentInputRequiredException; +import com.example.dvely.agent.application.exception.AgentTokenBudgetExceededException; import com.example.dvely.agent.application.exception.CodeAgentExecutionException; import com.example.dvely.agent.application.service.BuildFailureRecoveryService; import com.example.dvely.agent.application.service.ChatAgentService; @@ -28,7 +29,10 @@ import java.util.List; import java.util.Optional; import com.example.dvely.agent.domain.value.AiModelOptions; +import com.example.dvely.agent.infrastructure.config.AiProperties; import com.example.dvely.agent.infrastructure.store.TaskStore; +import com.example.dvely.agent.infrastructure.usage.LlmUsageRecorder; +import com.example.dvely.agent.infrastructure.usage.LlmUsageScope; import com.example.dvely.agent.infrastructure.worker.AgentExecutionRegistry; import com.example.dvely.change.application.service.ChangeService; import com.example.dvely.common.exception.LlmProviderException; @@ -61,10 +65,15 @@ public class AgentPlanExecutor { private final DecisionAgentService decisionAgentService; // 되묻기 답 반영 재-decide private final InputWaitStore inputWaitStore; // CLARIFY 답 consume private final ObjectMapper objectMapper; // CLARIFY 구조화 질문 파싱 + private final LlmUsageRecorder llmUsageRecorder; // 태스크 토큰 계측·예산 스코프 + private final AiProperties aiProperties; // 태스크당 토큰 상한 @Async("agentExecutor") public void execute(AgentPlan plan, String taskId, Long userId) { - try { + // 이 실행 스레드에서 나는 모든 LLM 호출이 이 태스크에 귀속되고, 누적 토큰이 여기 상한에 + // 걸린다. 스코프는 스레드를 넘지 않으므로 실행 진입점인 여기가 유일하게 맞는 자리다. + try (LlmUsageScope ignored = llmUsageRecorder.openTaskScope( + taskId, userId, plan.projectId(), aiProperties.getCodeAgent().getMaxTaskTokens())) { doExecute(plan, taskId, userId); } finally { // The only unregister site for a task that made it onto an executor thread — covers @@ -166,6 +175,26 @@ private void doExecute(AgentPlan plan, String taskId, Long userId) { } buildFailureRecoveryService.handle(taskId, exception); log.warn("=== AgentPlan build 실패 및 복구 대기: taskId={} ===", taskId); + } catch (AgentTokenBudgetExceededException exception) { + // 상한에 걸린 태스크가 조용히 멈추면 사용자에게는 "왜 안 되지" 로만 남는다. 아래 + // catch-all 로 흘리면 "작업 중 오류가 발생했습니다" 가 앞에 붙어, 사용자가 읽어야 할 + // 단 하나의 문장(무엇에 걸렸고 무엇을 하면 되는지)이 묻힌다. 그래서 전용 분기다. + // + // 재시도로 흘리지 않는 것도 의도다 — 누적은 태스크 단위로 이어 세므로, 재시도해도 + // 첫 호출에서 곧바로 같은 상한에 다시 걸린다. + if (taskStore.isCancelled(taskId)) { + return; + } + taskStore.markFailed(taskId, exception.getMessage()); + AgentTask task = taskStore.get(taskId); + agentMessageService.appendAssistant( + task == null ? null : task.conversationId(), + exception.getMessage(), + ChatMessageKind.TASK_FAILED, + taskId + ); + log.warn("=== AgentPlan 토큰 예산 초과로 중단: taskId={} used={} budget={} ===", + taskId, exception.usedTokens(), exception.budgetTokens()); } catch (LlmProviderException exception) { // Separated from the catch-all below only for the chat reply: the provider message is // already a complete, actionable sentence ("... 크레딧이 부족해 ... 다른 AI 제공자를 diff --git a/src/main/java/com/example/dvely/agent/application/port/out/LlmToolResponse.java b/src/main/java/com/example/dvely/agent/application/port/out/LlmToolResponse.java index 0e1f3ef4..421e5359 100644 --- a/src/main/java/com/example/dvely/agent/application/port/out/LlmToolResponse.java +++ b/src/main/java/com/example/dvely/agent/application/port/out/LlmToolResponse.java @@ -1,13 +1,31 @@ package com.example.dvely.agent.application.port.out; +import com.example.dvely.agent.domain.value.LlmUsage; import java.util.List; import java.util.Map; +/** + * @param usage 이 호출이 쓴 토큰. 제공자가 {@code usage} 를 주지 않으면 {@link LlmUsage#NONE}. + * 반환값에 실어 두는 이유는 호출부(CODE 루프)가 라운드별로 무엇이 캐시에서 읽혔는지 + * 볼 수 있게 하기 위해서다 — 영속화 자체는 제공자 클라이언트가 이미 끝낸다. + */ public record LlmToolResponse( List toolCalls, List> contentBlocks, - String stopReason + String stopReason, + LlmUsage usage ) { + public LlmToolResponse { + usage = usage == null ? LlmUsage.NONE : usage; + } + + /** 사용량을 모르는 호출부용(주로 테스트 더블). */ + public LlmToolResponse(List toolCalls, + List> contentBlocks, + String stopReason) { + this(toolCalls, contentBlocks, stopReason, LlmUsage.NONE); + } + public boolean hasToolCalls() { return toolCalls != null && !toolCalls.isEmpty(); } diff --git a/src/main/java/com/example/dvely/agent/application/service/AgentMessageService.java b/src/main/java/com/example/dvely/agent/application/service/AgentMessageService.java index 89890d5a..454ab92f 100644 --- a/src/main/java/com/example/dvely/agent/application/service/AgentMessageService.java +++ b/src/main/java/com/example/dvely/agent/application/service/AgentMessageService.java @@ -70,13 +70,16 @@ public void appendAssistant(Long conversationId, String content, ChatMessageKind */ @Transactional(readOnly = true) public List getConversationContext(Long conversationId) { - return chatMessageRepository.findAllByConversationIdOrderByCreatedAtAsc(conversationId) - .stream() - .map(message -> new LlmMessage( - message.getRole().toStorage(), - message.getContent() - )) - .toList(); + // 전량을 싣던 자리다. 오래 쓴 대화일수록 모든 요청이 비싸졌고 언젠가는 컨텍스트 상한에 + // 닿았다 — 무엇을 잃는지는 ConversationWindow 참고. + return ConversationWindow.apply( + chatMessageRepository.findAllByConversationIdOrderByCreatedAtAsc(conversationId) + .stream() + .map(message -> new LlmMessage( + message.getRole().toStorage(), + message.getContent() + )) + .toList()); } /** @@ -112,12 +115,14 @@ public List getUserIntentHistory(Long conversationId) { } int last = userMessages.size() - 1; - return java.util.stream.IntStream.range(0, userMessages.size()) + // 표시를 먼저 붙이고 그다음에 자른다. 순서가 반대면 창 안의 마지막 턴에 [지금 처리할 + // 요청] 이 붙어, 이미 처리된 옛 요청이 새 요청으로 둔갑한다. + return ConversationWindow.apply(java.util.stream.IntStream.range(0, userMessages.size()) .mapToObj(index -> new LlmMessage( ChatRole.USER.toStorage(), (index == last ? CURRENT_REQUEST_LABEL : PAST_REQUEST_LABEL) + userMessages.get(index).getContent() )) - .toList(); + .toList()); } } diff --git a/src/main/java/com/example/dvely/agent/application/service/CodeAgentService.java b/src/main/java/com/example/dvely/agent/application/service/CodeAgentService.java index fc054f6c..06d30e6b 100644 --- a/src/main/java/com/example/dvely/agent/application/service/CodeAgentService.java +++ b/src/main/java/com/example/dvely/agent/application/service/CodeAgentService.java @@ -2,6 +2,7 @@ import com.example.dvely.agent.application.dto.AgentStep; import com.example.dvely.agent.application.exception.AgentIterationLimitException; +import com.example.dvely.agent.application.exception.AgentTokenBudgetExceededException; import com.example.dvely.agent.application.exception.CodeAgentExecutionException; import com.example.dvely.agent.application.port.out.LlmToolPort; import com.example.dvely.agent.application.port.out.LlmToolResponse; @@ -253,6 +254,13 @@ public CodeResult execute(AgentStep step, log.error("[CodeAgent] AI 제공자 호출 실패 | userId={} provider={} reason={}", userId, e.providerName(), e.reason()); throw e; + } catch (AgentTokenBudgetExceededException e) { + // 아래 빌드실패 경로로 흘리면 안 된다. 빌드 로그를 분석해 "프로젝트 빌드가 완료되지 + // 않았습니다" 로 닫히는데, 빌드는 실패하지 않았고 재시도해도 같은 상한에 곧바로 다시 + // 걸린다 — 사용자에게는 원인이 안 보이는 실패 두 번이 된다. + log.warn("[CodeAgent] 토큰 예산 초과로 중단 | userId={} containerId={} used={} budget={}", + userId, containerId, e.usedTokens(), e.budgetTokens()); + throw e; } catch (AgentIterationLimitException e) { // Deliberately not routed through BuildFailureAnalyzer like the branch below: nothing // here says the *build* failed — the run simply did not reach the end of its work — so diff --git a/src/main/java/com/example/dvely/agent/application/service/ConversationWindow.java b/src/main/java/com/example/dvely/agent/application/service/ConversationWindow.java new file mode 100644 index 00000000..ec9f0fb2 --- /dev/null +++ b/src/main/java/com/example/dvely/agent/application/service/ConversationWindow.java @@ -0,0 +1,76 @@ +package com.example.dvely.agent.application.service; + +import com.example.dvely.agent.application.port.out.LlmMessage; +import java.util.ArrayList; +import java.util.List; + +/** + * 대화 이력을 LLM 에 실을 만큼만 잘라 낸다. + * + *

여기까지는 상한이 없었다. {@code findAllByConversationIdOrderByCreatedAtAsc} 가 돌려준 + * 전량이 매 호출마다 그대로 실렸으므로, 오래 쓴 대화일수록 모든 요청이 비싸졌다 — + * 대화가 길어질수록 비용이 선형으로 늘고, 언젠가는 컨텍스트 상한에 닿는다.

+ * + *

이것은 동작 변경이다. 창 밖으로 밀려난 앞부분을 에이전트는 더 이상 보지 못한다. + * 긴 대화에서 초반에만 나온 사실(예: "이 앱은 사내용이야" 같은 전제)을 다시 말해야 할 수 + * 있다. 요약으로 접두를 대체하는 방법도 있지만 그것은 요약을 만들기 위한 LLM 호출을 하나 더 + * 늘리는 일이라, 비용을 줄이려는 이 작업에서는 택하지 않았다.

+ * + *

두 가지 상한을 함께 건다. 턴 수만으로는 긴 코드 블록 하나가 창 전체를 삼키고, 글자 수만 + * 으로는 짧은 턴이 수백 개 쌓인 대화를 막지 못한다.

+ * + *

항상 뒤에서부터 담는다. 대화에서 지금 처리해야 할 요청은 언제나 마지막 턴이므로, + * 넘칠 때 버려야 하는 것은 앞이다. 마지막 턴 하나는 글자 상한을 넘더라도 반드시 남긴다 — + * 그것을 버리면 무엇을 하라는 요청인지 자체가 사라진다.

+ * + *

별도 클래스인 이유: 호출부가 여러 곳이고 전부 다른 작업자가 동시에 손대는 파일이라, + * 규칙이 그 파일들에 흩어지면 곧 서로 다른 창이 된다.

+ */ +public final class ConversationWindow { + + /** + * 남길 최근 턴 수. + * + *

20 은 사용자·어시스턴트가 번갈아 말하는 대화에서 주고받기 10회다. 실제로 한 요청이 + * 참조하는 맥락(직전 요청과 그 결과, 되묻기와 답)은 그보다 훨씬 짧고, 계획 수립 경로는 + * 사용자 발화만 싣기 때문에 같은 20이 사용자 발화 20개를 뜻한다.

+ */ + public static final int MAX_TURNS = 20; + + /** + * 남길 최대 글자 수. + * + *

24,000자면 대략 6K 토큰이다. 대화 이력은 요청 하나의 배경일 뿐이고, 실제 작업 + * 맥락(파일 내용·빌드 로그)은 CODE 루프가 컨테이너에서 따로 읽는다.

+ */ + public static final int MAX_CHARS = 24_000; + + private ConversationWindow() { + } + + public static List apply(List history) { + return apply(history, MAX_TURNS, MAX_CHARS); + } + + static List apply(List history, int maxTurns, int maxChars) { + if (history == null || history.isEmpty()) { + return List.of(); + } + + List kept = new ArrayList<>(); + int chars = 0; + for (int i = history.size() - 1; i >= 0 && kept.size() < maxTurns; i--) { + LlmMessage message = history.get(i); + int length = message.content() == null ? 0 : message.content().length(); + // 마지막 한 턴은 길어도 남긴다 — 그것이 지금 처리할 요청이다. + if (!kept.isEmpty() && chars + length > maxChars) { + break; + } + kept.add(message); + chars += length; + } + + java.util.Collections.reverse(kept); + return List.copyOf(kept); + } +} diff --git a/src/main/java/com/example/dvely/agent/application/service/DecisionAgentService.java b/src/main/java/com/example/dvely/agent/application/service/DecisionAgentService.java index 72b0c04d..a568613e 100644 --- a/src/main/java/com/example/dvely/agent/application/service/DecisionAgentService.java +++ b/src/main/java/com/example/dvely/agent/application/service/DecisionAgentService.java @@ -34,6 +34,16 @@ public class DecisionAgentService { /** 교정 프롬프트에 되돌려 보여줄 직전 응답의 상한. 어디가 틀렸는지 보는 데는 앞부분이면 된다. */ private static final int MAX_REPAIR_ECHO_CHARS = 2000; + /** + * 로그에 남길 LLM 원문의 상한. + * + *

원문 전체를 INFO 로 찍던 자리가 있었다. 계획 JSON 에는 사용자가 무엇을 만들라고 했는지가 + * 그대로 들어가고, 교정 재시도 로그에는 모델이 쓴 응답이 통째로 들어간다 — 운영 로그 수집기로 + * 사용자 요청과 생성 코드 조각이 흘러나가는 경로였다. 어디가 어긋났는지 보는 데는 앞부분이면 + * 충분하므로 자르고, 평시에는 아예 남기지 않는다(DEBUG).

+ */ + private static final int MAX_LOGGED_RAW_CHARS = 300; + private static final String SYSTEM_PROMPT = """ You are a decision-making agent for Qeploy, an automated web project deployment platform. Analyze the user's message, identify ALL intents, and return them as an ordered list of steps. @@ -306,8 +316,9 @@ private String complete(AiProvider provider, AiModelOptions modelOptions, Long projectId) { String raw = llmRouter.route(provider).complete(SYSTEM_PROMPT, messages, modelOptions); - log.info("의사결정 완료: provider={}, model={}, projectId={}, raw={}", - provider, modelOptions.model(), projectId, raw); + log.info("의사결정 완료: provider={}, model={}, projectId={}, rawLength={}", + provider, modelOptions.model(), projectId, raw == null ? 0 : raw.length()); + log.debug("의사결정 응답 미리보기: {}", preview(raw)); return raw; } @@ -330,8 +341,8 @@ private AgentPlan retryOnce(List messages, AiProvider provider, Long projectId, AiModelOptions modelOptions) { - log.warn("의사결정 응답 파싱 실패 — 형식 교정을 요청해 1회 재시도합니다. provider={} projectId={} raw={}", - provider, projectId, failedRaw, failure); + log.warn("의사결정 응답 파싱 실패 — 형식 교정을 요청해 1회 재시도합니다. provider={} projectId={} rawPreview={}", + provider, projectId, preview(failedRaw), failure); List repairMessages = new ArrayList<>(messages); repairMessages.add(new LlmMessage("user", repairPrompt(failedRaw, failure))); @@ -343,8 +354,8 @@ private AgentPlan retryOnce(List messages, return plan; } catch (RuntimeException retryFailure) { retryFailure.addSuppressed(failure); - log.warn("의사결정 응답 재시도도 파싱 실패 — 요청을 실패로 닫습니다. provider={} projectId={} raw={}", - provider, projectId, raw, retryFailure); + log.warn("의사결정 응답 재시도도 파싱 실패 — 요청을 실패로 닫습니다. provider={} projectId={} rawPreview={}", + provider, projectId, preview(raw), retryFailure); throw new LlmProviderException(provider.name(), Reason.MALFORMED_RESPONSE, retryFailure); } } @@ -487,6 +498,16 @@ private String readReasoning(Map map) { return reasoning == null ? "" : String.valueOf(reasoning); } + /** 로그에 실을 만큼만 자른 원문. 잘렸다는 사실을 남겨 "이게 전부인가" 를 묻지 않게 한다. */ + private String preview(String raw) { + if (raw == null) { + return "(없음)"; + } + return raw.length() <= MAX_LOGGED_RAW_CHARS + ? raw + : raw.substring(0, MAX_LOGGED_RAW_CHARS) + "…(" + raw.length() + "자 중 앞부분)"; + } + /** * raw 에서 첫 번째로 완결된 JSON 객체만 잘라낸다. * diff --git a/src/main/java/com/example/dvely/agent/domain/value/LlmUsage.java b/src/main/java/com/example/dvely/agent/domain/value/LlmUsage.java new file mode 100644 index 00000000..217cdb9e --- /dev/null +++ b/src/main/java/com/example/dvely/agent/domain/value/LlmUsage.java @@ -0,0 +1,58 @@ +package com.example.dvely.agent.domain.value; + +/** + * What one LLM call actually consumed. + * + *

이 값이 없던 동안에는 토큰을 얼마나 쓰는지 아무도 몰랐다 — 프롬프트 캐싱이나 대화 윈도우 + * 같은 절감 작업의 효과를 "줄었을 것" 이상으로 말할 수 없었고, 태스크당 예산 상한은 셀 것이 + * 없어 구현 자체가 불가능했다. 그래서 제공자마다 다른 응답 필드를 여기 한 모양으로 모은다.

+ * + *

정규화 규칙이 하나 있다. Anthropic 의 {@code input_tokens} 는 캐시 읽기/쓰기 토큰을 + * 제외한 수인 반면, OpenAI 호환 제공자의 {@code prompt_tokens} 는 캐시 적중분을 + * 포함한 수다. 그대로 더하면 같은 이름의 칸에 다른 뜻이 섞이므로, OpenAI 쪽은 캐시 + * 적중분을 빼서 담는다({@link com.example.dvely.agent.infrastructure.llm.LlmUsageParser}). + * 그 결과 {@link #billedInputTokens()} 는 어느 제공자든 "이번 호출이 읽은 입력 전체"다.

+ */ +public record LlmUsage( + long inputTokens, + long outputTokens, + long cacheCreationInputTokens, + long cacheReadInputTokens +) { + + public static final LlmUsage NONE = new LlmUsage(0, 0, 0, 0); + + public LlmUsage { + // 음수는 제공자 응답이 어긋났다는 뜻이다. 합계를 오염시키느니 0 으로 떨어뜨린다 — + // 계측이 태스크를 망가뜨리는 일은 없어야 한다. + inputTokens = Math.max(0, inputTokens); + outputTokens = Math.max(0, outputTokens); + cacheCreationInputTokens = Math.max(0, cacheCreationInputTokens); + cacheReadInputTokens = Math.max(0, cacheReadInputTokens); + } + + /** 이번 호출이 읽은 입력 전체(캐시 쓰기·읽기 포함). 제공자 간 비교가 되는 유일한 입력 수치다. */ + public long billedInputTokens() { + return inputTokens + cacheCreationInputTokens + cacheReadInputTokens; + } + + public long totalTokens() { + return billedInputTokens() + outputTokens; + } + + public boolean isEmpty() { + return totalTokens() == 0; + } + + public LlmUsage plus(LlmUsage other) { + if (other == null) { + return this; + } + return new LlmUsage( + inputTokens + other.inputTokens, + outputTokens + other.outputTokens, + cacheCreationInputTokens + other.cacheCreationInputTokens, + cacheReadInputTokens + other.cacheReadInputTokens + ); + } +} diff --git a/src/main/java/com/example/dvely/agent/infrastructure/config/AiProperties.java b/src/main/java/com/example/dvely/agent/infrastructure/config/AiProperties.java index 9d75ccf1..47f15ac4 100644 --- a/src/main/java/com/example/dvely/agent/infrastructure/config/AiProperties.java +++ b/src/main/java/com/example/dvely/agent/infrastructure/config/AiProperties.java @@ -16,6 +16,19 @@ public class AiProperties { private Glm glm = new Glm(); private Retry retry = new Retry(); private CodeAgent codeAgent = new CodeAgent(); + private FailureAnalysis failureAnalysis = new FailureAnalysis(); + + /** + * 도구를 쓰지 않는 한 번짜리 완성 호출의 출력 상한(Anthropic {@code max_tokens}). + * + *

예전 값 1024 는 계획 수립에서 실제로 짧았다 — 다단계 계획 JSON 이 중간에서 잘리면 + * {@code DecisionAgentService#retryOnce} 가 전체 컨텍스트에 실패 응답 에코(최대 2KB)까지 + * 얹어 한 번 더 호출한다. 즉 짧은 상한이 아끼는 것은 출력 토큰 몇백이고, 치르는 것은 + * 입력 전체를 한 번 더 보내는 비용이다.

+ * + *

상한이지 할당량이 아니다 — 모델이 짧게 답하면 그만큼만 과금된다.

+ */ + private int completionMaxTokens = 4096; /** * 요청이 제공자를 지정하지 않을 때 쓰는 기본 제공자. 배포가 유효한 키를 가진 것으로 맞춘다 — @@ -69,6 +82,17 @@ public boolean supportsThinking(String candidateModel) { @Getter @Setter public static class Anthropic extends Provider { + + /** + * Messages API 전체 URL(호스트 접두사가 아니다 — 클라이언트가 이 값에 그대로 POST 한다). + * + *

{@link Glm#getBaseUrl()} 와 같은 이유로 설정 가능하다: 사내 프록시나 게이트웨이를 + * 거치도록 배포를 바꿀 수 있어야 하고, 무엇보다 나가는 요청 본문을 실제로 검사하는 + * 테스트가 가능해진다. 캐시 브레이크포인트 위치처럼 조용히 틀려도 오류가 나지 않는 + * 것(요청은 통과하고 캐시만 안 걸린다)은 본문을 직접 보는 것 말고 검증할 방법이 없다.

+ */ + private String baseUrl = "https://api.anthropic.com/v1/messages"; + public Anthropic() { super("claude-opus-4-5-20251101"); } @@ -169,5 +193,67 @@ public static class CodeAgent { * this value times the task's retry budget, not this value alone.

*/ private int maxIterations = 40; + + /** + * 태스크 하나가 쓸 수 있는 누적 토큰 상한(입력 + 출력 + 캐시). 0 이면 상한 없음. + * + *

이 값이 없던 동안 곱셈이 그대로 열려 있었다 — 제공자 재시도({@code retry.maxAttempts} + * 3) × 라운드({@link #maxIterations} 40) × 태스크 재시도(3). 각 단계는 자기 한도를 지키지만 + * 태스크 전체가 쓰는 양에는 아무 한도가 없었고, 한 번 헤매기 시작한 태스크가 얼마까지 + * 쓸 수 있는지 아무도 답할 수 없었다.

+ * + *

기본값은 정상 완주를 막지 않는 선이다. 라운드마다 트랜스크립트 전체가 다시 실리므로 + * 성공하는 CODE 태스크도 누적 수십만 토큰을 쓴다 — 여유를 세 배쯤 둔 값이고, 걸리는 것은 + * 끝나지 않고 도는 태스크다. 상한에 걸리면 태스크는 사유가 보이는 실패로 닫힌다 + * ({@link com.example.dvely.agent.application.exception.AgentTokenBudgetExceededException}).

+ */ + private long maxTaskTokens = 1_000_000; + } + + /** + * 배포 실패 로그 요약 전용 설정. + * + *

이 호출은 제공자가 {@code ANTHROPIC} 으로, 모델이 그 제공자의 기본값(최상위 모델)로 + * 하드코딩돼 있었다. {@link #defaultProvider} 는 GLM 인데 이 한 경로만 그것을 무시했고, + * 12,000자 로그를 한 번 요약하는 데 최상위 모델을 쓸 근거도 없었다.

+ * + *

다만 품질이 떨어지면 실패 분석 자체가 쓸모없어진다. 그래서 코드에 새 값을 박는 + * 대신 설정으로 뺐다 — 분석이 나빠지면 {@code provider: ANTHROPIC} 과 그 모델명을 도로 적어 + * 배포만으로 되돌릴 수 있다.

+ */ + @Getter + @Setter + public static class FailureAnalysis { + + /** 비우면 {@link AiProperties#defaultProvider} 를 따른다. */ + private AiProvider provider; + + /** 비우면 제공자의 기본 모델을 쓴다. */ + private String model = ""; + + public AiProvider providerOr(AiProvider fallback) { + return provider == null ? fallback : provider; + } + + /** {@code AiModelOptions.model} 에 그대로 들어간다 — null 이면 제공자 기본 모델이 선택된다. */ + public String modelOrNull() { + return model == null || model.isBlank() ? null : model.trim(); + } + } + + /** 실패 분석이 실제로 쓸 제공자. 전용 설정이 없으면 배포의 기본 제공자를 따른다. */ + public AiProvider failureAnalysisProvider() { + return failureAnalysis.providerOr(defaultProvider); + } + + /** 제공자별 설정 블록. 코딩 에이전트는 자체 설정이 없으므로 조용히 돌려주지 않고 던진다. */ + public Provider providerConfig(AiProvider provider) { + return switch (provider) { + case ANTHROPIC -> anthropic; + case OPENAI -> openai; + case GLM -> glm; + case CLAUDE_CODE, CODEX -> throw new IllegalArgumentException( + "코딩 에이전트 제공자는 qeploy.ai.* 설정을 갖지 않습니다: " + provider); + }; } } diff --git a/src/main/java/com/example/dvely/agent/infrastructure/llm/AnthropicPromptCache.java b/src/main/java/com/example/dvely/agent/infrastructure/llm/AnthropicPromptCache.java new file mode 100644 index 00000000..a1ec98f3 --- /dev/null +++ b/src/main/java/com/example/dvely/agent/infrastructure/llm/AnthropicPromptCache.java @@ -0,0 +1,130 @@ +package com.example.dvely.agent.infrastructure.llm; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Anthropic 요청에 {@code cache_control} 브레이크포인트를 얹는다. + * + *

캐싱은 접두 일치다. 렌더 순서는 {@code tools → system → messages} 이고, 접두의 한 + * 바이트라도 달라지면 그 뒤 전부가 무효가 된다. 그래서 마커를 붙이기 전에 접두가 실제로 고정인지가 + * 먼저인데, CODE 루프는 그 조건을 만족한다 — 시스템 프롬프트와 도구 정의가 {@code static final} + * 상수이고, 트랜스크립트는 덧붙이기만 한다(앞부분을 다시 쓰거나 지우는 곳이 없다).

+ * + *

브레이크포인트 배치(요청당 최대 4개):

+ *
    + *
  1. 도구 정의 마지막 블록 — 도구 목록만 덮는다
  2. + *
  3. 시스템 프롬프트 — 도구 + 시스템을 함께 덮는다
  4. + *
  5. 직전 user 턴, 4. 마지막 user 턴 — 굴러가는 두 지점. 라운드마다 한 칸씩 뒤로 밀리므로, + * 다음 라운드의 접두는 직전 라운드가 써 둔 지점에서 그대로 적중한다
  6. + *
+ * + *

짧은 접두는 조용히 캐시되지 않는다. 최소 캐시 가능 접두는 모델마다 다르다(최신 모델 + * 512 토큰, {@code claude-opus-4-5} 는 4096 토큰). 지금 설정된 기본 모델 기준으로 CODE 의 + * 시스템 프롬프트(4,280자 ≈ 1.1K 토큰)와 도구 정의는 그 최소에 못 미치므로 1·2 번 지점은 항목을 + * 만들지 않는다 — 오류도 나지 않고 과금도 없다. 실제로 값을 하는 것은 3·4 번이고, 트랜스크립트가 + * 최소를 넘는 2~3 라운드째부터 시스템·도구까지 함께 캐시된다(더 긴 접두가 그 앞을 모두 + * 포함하기 때문이다). 1·2 번을 그래도 붙여 두는 이유는 최소 접두가 낮은 모델로 설정을 바꾸면 + * 코드 변경 없이 첫 라운드부터 적중하기 때문이다.

+ */ +final class AnthropicPromptCache { + + /** 굴러가는 브레이크포인트 개수. tools 1 + system 1 과 합쳐 상한 4를 정확히 채운다. */ + private static final int ROLLING_USER_BREAKPOINTS = 2; + + private static final Map EPHEMERAL = Map.of("type", "ephemeral"); + + private AnthropicPromptCache() { + } + + /** 시스템 프롬프트를 마커 달린 단일 텍스트 블록으로. */ + static List> systemBlocks(String systemPrompt) { + return List.of(withCacheControl(Map.of( + "type", "text", + "text", systemPrompt == null ? "" : systemPrompt + ))); + } + + /** 도구 목록의 마지막 정의에만 마커를 단다. 목록 전체가 하나의 캐시 단위다. */ + static List> toolsWithCacheControl(List> tools) { + if (tools == null || tools.isEmpty()) { + return tools; + } + List> marked = new ArrayList<>(tools); + int last = marked.size() - 1; + marked.set(last, withCacheControl(marked.get(last))); + return List.copyOf(marked); + } + + /** + * 마지막 user 턴 두 개의 마지막 콘텐츠 블록에 마커를 단다. + * + *

원본은 건드리지 않는다. 호출부의 트랜스크립트에 마커가 쌓이면 라운드마다 마커 위치가 + * 늘어나 상한 4개를 곧 넘기고, 그 뒤로는 요청 자체가 거절된다.

+ * + *

문자열 콘텐츠는 항상 블록 배열로 정규화한다. 마커가 붙는 턴만 블록으로 바꾸면 + * 같은 턴의 모양이 라운드마다 문자열↔배열로 오가는데, 접두 일치에 그런 흔들림을 남길 이유가 + * 없다.

+ */ + static List> messagesWithRollingCacheControl(List> messages) { + if (messages == null || messages.isEmpty()) { + return messages; + } + + List> normalized = new ArrayList<>(messages.size()); + for (Map message : messages) { + normalized.add(normalizeContent(message)); + } + + int remaining = ROLLING_USER_BREAKPOINTS; + for (int i = normalized.size() - 1; i >= 0 && remaining > 0; i--) { + Map message = normalized.get(i); + if (!"user".equals(message.get("role"))) { + continue; + } + Map marked = markLastContentBlock(message); + if (marked != null) { + normalized.set(i, marked); + remaining--; + } + } + return List.copyOf(normalized); + } + + @SuppressWarnings("unchecked") + private static Map normalizeContent(Map message) { + if (!(message.get("content") instanceof String text)) { + return message; + } + Map normalized = new LinkedHashMap<>(message); + normalized.put("content", List.of(Map.of("type", "text", "text", text))); + return normalized; + } + + /** 마커를 붙일 블록이 없으면(빈 콘텐츠) null — 브레이크포인트 하나를 헛되이 쓰지 않는다. */ + @SuppressWarnings("unchecked") + private static Map markLastContentBlock(Map message) { + if (!(message.get("content") instanceof List rawBlocks) || rawBlocks.isEmpty()) { + return null; + } + Object lastBlock = rawBlocks.get(rawBlocks.size() - 1); + if (!(lastBlock instanceof Map block)) { + return null; + } + + List blocks = new ArrayList<>(rawBlocks); + blocks.set(blocks.size() - 1, withCacheControl((Map) block)); + + Map marked = new LinkedHashMap<>(message); + marked.put("content", List.copyOf(blocks)); + return marked; + } + + private static Map withCacheControl(Map block) { + Map copy = new LinkedHashMap<>(block); + copy.put("cache_control", EPHEMERAL); + return copy; + } +} diff --git a/src/main/java/com/example/dvely/agent/infrastructure/llm/ClaudeClient.java b/src/main/java/com/example/dvely/agent/infrastructure/llm/ClaudeClient.java index abb04150..84a60f60 100644 --- a/src/main/java/com/example/dvely/agent/infrastructure/llm/ClaudeClient.java +++ b/src/main/java/com/example/dvely/agent/infrastructure/llm/ClaudeClient.java @@ -3,7 +3,9 @@ import com.example.dvely.agent.application.port.out.LlmMessage; import com.example.dvely.agent.application.port.out.LlmPort; import com.example.dvely.agent.domain.value.AiModelOptions; +import com.example.dvely.agent.domain.value.AiProvider; import com.example.dvely.agent.infrastructure.config.AiProperties; +import com.example.dvely.agent.infrastructure.usage.LlmUsageRecorder; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.RequiredArgsConstructor; @@ -19,30 +21,31 @@ @RequiredArgsConstructor public class ClaudeClient implements LlmPort { - private static final String API_URL = "https://api.anthropic.com/v1/messages"; private static final String API_VERSION = "2023-06-01"; - private static final int MAX_TOKENS = 1024; static final String PROVIDER_NAME = "Anthropic"; private final AiProperties aiProperties; + private final LlmUsageRecorder llmUsageRecorder; @Override public String complete(String systemPrompt, List messages, AiModelOptions modelOptions) { LlmProviderErrors.requireApiKey(PROVIDER_NAME, aiProperties.getAnthropic().getApiKey()); - List> apiMessages = messages.stream() - .map(m -> Map.of("role", m.role(), "content", m.content())) + List> apiMessages = messages.stream() + .map(m -> Map.of("role", m.role(), "content", m.content())) .toList(); + String model = modelOptions.modelOr(aiProperties.getAnthropic().getModel()); Map body = new HashMap<>(); - body.put("model", modelOptions.modelOr(aiProperties.getAnthropic().getModel())); - body.put("system", systemPrompt); - body.put("messages", apiMessages); - LlmRequestOptions.applyAnthropic(body, modelOptions, MAX_TOKENS); + body.put("model", model); + // 시스템 프롬프트는 블록 배열로 보낸다 — 문자열 형태로는 cache_control 을 달 수 없다. + body.put("system", AnthropicPromptCache.systemBlocks(systemPrompt)); + body.put("messages", AnthropicPromptCache.messagesWithRollingCacheControl(apiMessages)); + LlmRequestOptions.applyAnthropic(body, modelOptions, aiProperties.getCompletionMaxTokens()); ClaudeResponse response = LlmProviderErrors.translate(PROVIDER_NAME, aiProperties.getRetry(), () -> LlmHttp.client() .post() - .uri(API_URL) + .uri(aiProperties.getAnthropic().getBaseUrl()) // 키는 호출마다 싣는다 — 공용 클라이언트의 기본 헤더로 박으면 인스턴스에 고정된다. .header("x-api-key", aiProperties.getAnthropic().getApiKey()) .header("anthropic-version", API_VERSION) @@ -54,13 +57,20 @@ public String complete(String systemPrompt, List messages, AiModelOp throw new IllegalStateException("Claude API 응답이 비어있습니다"); } - log.debug("Claude 응답 수신: model={}", body.get("model")); + llmUsageRecorder.record( + AiProvider.ANTHROPIC, + response.model() == null ? model : response.model(), + LlmUsageParser.anthropicUsage(response.usage())); + + log.debug("Claude 응답 수신: model={}", model); return response.content().get(0).text(); } @JsonIgnoreProperties(ignoreUnknown = true) private record ClaudeResponse( - @JsonProperty("content") List content + @JsonProperty("content") List content, + @JsonProperty("model") String model, + @JsonProperty("usage") Map usage ) {} @JsonIgnoreProperties(ignoreUnknown = true) diff --git a/src/main/java/com/example/dvely/agent/infrastructure/llm/ClaudeToolClient.java b/src/main/java/com/example/dvely/agent/infrastructure/llm/ClaudeToolClient.java index ab79852f..b5890ff2 100644 --- a/src/main/java/com/example/dvely/agent/infrastructure/llm/ClaudeToolClient.java +++ b/src/main/java/com/example/dvely/agent/infrastructure/llm/ClaudeToolClient.java @@ -5,7 +5,10 @@ import com.example.dvely.agent.application.port.out.ToolDefinition; import com.example.dvely.agent.application.port.out.LlmToolResponse; import com.example.dvely.agent.domain.value.AiModelOptions; +import com.example.dvely.agent.domain.value.AiProvider; +import com.example.dvely.agent.domain.value.LlmUsage; import com.example.dvely.agent.infrastructure.config.AiProperties; +import com.example.dvely.agent.infrastructure.usage.LlmUsageRecorder; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -21,7 +24,6 @@ @RequiredArgsConstructor public class ClaudeToolClient implements LlmToolPort { - private static final String API_URL = "https://api.anthropic.com/v1/messages"; private static final String API_VERSION = "2023-06-01"; // Output budget per round. 4096 was not enough to emit one real source file in a single // write_file call, so generation stopped mid-arguments (stop_reason=max_tokens) on ordinary @@ -31,6 +33,7 @@ public class ClaudeToolClient implements LlmToolPort { private static final int MAX_TOKENS = 8192; private final AiProperties aiProperties; + private final LlmUsageRecorder llmUsageRecorder; private final ObjectMapper objectMapper = new ObjectMapper(); @SuppressWarnings("unchecked") @@ -50,7 +53,7 @@ public LlmToolResponse completeWithTools( AiModelOptions modelOptions) { List> toolsPayload = tools.stream() - .map(t -> Map.of( + .map(t -> Map.of( "name", t.name(), "description", t.description(), "input_schema", t.inputSchema() @@ -59,16 +62,20 @@ public LlmToolResponse completeWithTools( LlmProviderErrors.requireApiKey(ClaudeClient.PROVIDER_NAME, aiProperties.getAnthropic().getApiKey()); + String model = modelOptions.modelOr(aiProperties.getAnthropic().getModel()); Map body = new HashMap<>(); - body.put("model", modelOptions.modelOr(aiProperties.getAnthropic().getModel())); - body.put("system", systemPrompt); - body.put("tools", toolsPayload); - body.put("messages", messages); + body.put("model", model); + // 캐시 브레이크포인트는 tools → system → messages 순으로 렌더된다. 이 루프의 접두는 + // 고정이다 — 시스템 프롬프트와 도구 정의가 상수이고 트랜스크립트는 덧붙이기만 한다 — + // 그래서 마커가 실제로 값을 한다. 자세한 배치 근거는 AnthropicPromptCache 참고. + body.put("system", AnthropicPromptCache.systemBlocks(systemPrompt)); + body.put("tools", AnthropicPromptCache.toolsWithCacheControl(toolsPayload)); + body.put("messages", AnthropicPromptCache.messagesWithRollingCacheControl(messages)); LlmRequestOptions.applyAnthropic(body, modelOptions, MAX_TOKENS); String raw = LlmProviderErrors.translate(ClaudeClient.PROVIDER_NAME, aiProperties.getRetry(), () -> LlmHttp.client() .post() - .uri(API_URL) + .uri(aiProperties.getAnthropic().getBaseUrl()) // 키는 호출마다 싣는다 — 공용 클라이언트의 기본 헤더로 박으면 인스턴스에 고정된다. .header("x-api-key", aiProperties.getAnthropic().getApiKey()) .header("anthropic-version", API_VERSION) @@ -77,11 +84,22 @@ public LlmToolResponse completeWithTools( .body(String.class)); log.debug("Claude Tool API 응답 수신"); - return parse(raw); + ParsedResponse response = parse(raw, model); + // 기록은 parse 밖이다. 안에서 하면 예산 초과 예외가 parse 의 catch(Exception) 에 걸려 + // "Tool API 응답 파싱 실패" 로 둔갑한다 — 사용자는 무엇에 걸렸는지 못 보게 된다. + llmUsageRecorder.record(AiProvider.ANTHROPIC, response.model(), response.usage()); + return response.response(); + } + + /** 응답과, 그것을 실제로 답한 모델명을 함께 들고 나온다(기록은 호출부에서 한다). */ + private record ParsedResponse(LlmToolResponse response, String model) { + LlmUsage usage() { + return response.usage(); + } } @SuppressWarnings("unchecked") - private LlmToolResponse parse(String raw) { + private ParsedResponse parse(String raw, String requestedModel) { try { Map response = objectMapper.readValue(raw, Map.class); String stopReason = (String) response.getOrDefault("stop_reason", "end_turn"); @@ -99,9 +117,13 @@ private LlmToolResponse parse(String raw) { } } - return new LlmToolResponse(toolCalls, contentBlocks, stopReason); + LlmUsage usage = LlmUsageParser.anthropic(response); + Object answeredModel = response.get("model"); + return new ParsedResponse( + new LlmToolResponse(toolCalls, contentBlocks, stopReason, usage), + answeredModel instanceof String named ? named : requestedModel); } catch (Exception e) { - log.error("Claude Tool 응답 파싱 실패: {}", raw, e); + log.error("Claude Tool 응답 파싱 실패: {}", LlmLogPreview.of(raw), e); throw new RuntimeException("Claude Tool API 응답 파싱 실패", e); } } diff --git a/src/main/java/com/example/dvely/agent/infrastructure/llm/GlmClient.java b/src/main/java/com/example/dvely/agent/infrastructure/llm/GlmClient.java index 62f1708e..8f426dc8 100644 --- a/src/main/java/com/example/dvely/agent/infrastructure/llm/GlmClient.java +++ b/src/main/java/com/example/dvely/agent/infrastructure/llm/GlmClient.java @@ -3,7 +3,9 @@ import com.example.dvely.agent.application.port.out.LlmMessage; import com.example.dvely.agent.application.port.out.LlmPort; import com.example.dvely.agent.domain.value.AiModelOptions; +import com.example.dvely.agent.domain.value.AiProvider; import com.example.dvely.agent.infrastructure.config.AiProperties; +import com.example.dvely.agent.infrastructure.usage.LlmUsageRecorder; import java.net.URI; import java.util.LinkedHashMap; import java.util.List; @@ -28,10 +30,14 @@ public class GlmClient implements LlmPort { static final String PROVIDER_NAME = "GLM"; private final AiProperties aiProperties; + private final LlmUsageRecorder llmUsageRecorder; @Override public String complete(String systemPrompt, List messages, AiModelOptions modelOptions) { - return OpenAiCompatibleChat.complete(endpoint(aiProperties), systemPrompt, messages, modelOptions); + OpenAiCompatibleChat.Completion completion = + OpenAiCompatibleChat.complete(endpoint(aiProperties), systemPrompt, messages, modelOptions); + llmUsageRecorder.record(AiProvider.GLM, completion.model(), completion.usage()); + return completion.content(); } /** diff --git a/src/main/java/com/example/dvely/agent/infrastructure/llm/GlmToolClient.java b/src/main/java/com/example/dvely/agent/infrastructure/llm/GlmToolClient.java index d9652bef..a753feef 100644 --- a/src/main/java/com/example/dvely/agent/infrastructure/llm/GlmToolClient.java +++ b/src/main/java/com/example/dvely/agent/infrastructure/llm/GlmToolClient.java @@ -4,7 +4,9 @@ import com.example.dvely.agent.application.port.out.LlmToolResponse; import com.example.dvely.agent.application.port.out.ToolDefinition; import com.example.dvely.agent.domain.value.AiModelOptions; +import com.example.dvely.agent.domain.value.AiProvider; import com.example.dvely.agent.infrastructure.config.AiProperties; +import com.example.dvely.agent.infrastructure.usage.LlmUsageRecorder; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; @@ -24,6 +26,7 @@ public class GlmToolClient implements LlmToolPort { private final AiProperties aiProperties; + private final LlmUsageRecorder llmUsageRecorder; public LlmToolResponse completeWithTools( String systemPrompt, @@ -38,7 +41,10 @@ public LlmToolResponse completeWithTools( List> messages, List tools, AiModelOptions modelOptions) { - return OpenAiCompatibleChat.completeWithTools( + LlmToolResponse response = OpenAiCompatibleChat.completeWithTools( GlmClient.endpoint(aiProperties), systemPrompt, messages, tools, modelOptions); + llmUsageRecorder.record( + AiProvider.GLM, modelOptions.modelOr(aiProperties.getGlm().getModel()), response.usage()); + return response; } } diff --git a/src/main/java/com/example/dvely/agent/infrastructure/llm/LlmLogPreview.java b/src/main/java/com/example/dvely/agent/infrastructure/llm/LlmLogPreview.java new file mode 100644 index 00000000..38faebc7 --- /dev/null +++ b/src/main/java/com/example/dvely/agent/infrastructure/llm/LlmLogPreview.java @@ -0,0 +1,25 @@ +package com.example.dvely.agent.infrastructure.llm; + +/** + * 제공자 응답 원문을 로그에 실을 만큼만 자른다. + * + *

파싱 실패 로그는 응답 전체를 찍고 있었다. 그 안에는 모델이 방금 쓴 소스 코드와 + * 사용자가 무엇을 만들라고 했는지가 그대로 들어 있어서, 파싱이 한 번 어긋날 때마다 사용자의 + * 코드와 요청이 운영 로그 수집기로 흘러나갔다. 무엇이 어긋났는지 보는 데는 앞부분이면 된다.

+ */ +final class LlmLogPreview { + + private static final int MAX_CHARS = 300; + + private LlmLogPreview() { + } + + static String of(String raw) { + if (raw == null) { + return "(없음)"; + } + return raw.length() <= MAX_CHARS + ? raw + : raw.substring(0, MAX_CHARS) + "…(" + raw.length() + "자 중 앞부분)"; + } +} diff --git a/src/main/java/com/example/dvely/agent/infrastructure/llm/LlmUsageParser.java b/src/main/java/com/example/dvely/agent/infrastructure/llm/LlmUsageParser.java new file mode 100644 index 00000000..f01f88c0 --- /dev/null +++ b/src/main/java/com/example/dvely/agent/infrastructure/llm/LlmUsageParser.java @@ -0,0 +1,79 @@ +package com.example.dvely.agent.infrastructure.llm; + +import com.example.dvely.agent.domain.value.LlmUsage; +import java.util.Map; + +/** + * 제공자 응답의 {@code usage} 블록을 {@link LlmUsage} 로 읽는다. + * + *

두 가지 형태만 존재한다. Anthropic 은 {@code input_tokens / output_tokens / + * cache_creation_input_tokens / cache_read_input_tokens}, OpenAI 챗 컴플리션 형식(OpenAI 본체와 + * OpenRouter 경유 GLM)은 {@code prompt_tokens / completion_tokens} 에 캐시 적중분을 + * {@code prompt_tokens_details.cached_tokens} 로 덧붙인다.

+ * + *

파싱이 실패하거나 {@code usage} 가 없어도 절대 던지지 않는다. 계측이 빠지는 것은 + * 수치 하나를 잃는 일이지만, 계측이 던지면 이미 성공한 LLM 호출이 통째로 실패한다.

+ */ +final class LlmUsageParser { + + private LlmUsageParser() { + } + + /** Anthropic Messages API 응답 전체에서 {@code usage} 를 꺼내 읽는다. */ + static LlmUsage anthropic(Map response) { + return anthropicUsage(usageBlock(response)); + } + + /** 이미 꺼내 둔 Anthropic {@code usage} 블록. */ + static LlmUsage anthropicUsage(Map usage) { + if (usage == null) { + return LlmUsage.NONE; + } + return new LlmUsage( + number(usage, "input_tokens"), + number(usage, "output_tokens"), + number(usage, "cache_creation_input_tokens"), + number(usage, "cache_read_input_tokens") + ); + } + + /** + * OpenAI 챗 컴플리션 형식의 {@code usage}. + * + *

{@code prompt_tokens} 는 캐시 적중분을 포함한 수라서, 캐시 적중분을 빼서 담는다 — + * 그래야 {@link LlmUsage#billedInputTokens()} 가 Anthropic 쪽과 같은 뜻이 된다.

+ */ + static LlmUsage openAiCompatible(Map response) { + Map usage = usageBlock(response); + if (usage == null) { + return LlmUsage.NONE; + } + long promptTokens = number(usage, "prompt_tokens"); + long cachedTokens = nestedNumber(usage, "prompt_tokens_details", "cached_tokens"); + return new LlmUsage( + promptTokens - Math.min(promptTokens, cachedTokens), + number(usage, "completion_tokens"), + 0, + cachedTokens + ); + } + + @SuppressWarnings("unchecked") + private static Map usageBlock(Map response) { + if (response == null) { + return null; + } + return response.get("usage") instanceof Map usage ? (Map) usage : null; + } + + @SuppressWarnings("unchecked") + private static long nestedNumber(Map usage, String outerKey, String key) { + return usage.get(outerKey) instanceof Map nested + ? number((Map) nested, key) + : 0; + } + + private static long number(Map source, String key) { + return source.get(key) instanceof Number value ? value.longValue() : 0; + } +} diff --git a/src/main/java/com/example/dvely/agent/infrastructure/llm/OpenAiClient.java b/src/main/java/com/example/dvely/agent/infrastructure/llm/OpenAiClient.java index d902b050..b7c12a25 100644 --- a/src/main/java/com/example/dvely/agent/infrastructure/llm/OpenAiClient.java +++ b/src/main/java/com/example/dvely/agent/infrastructure/llm/OpenAiClient.java @@ -3,7 +3,9 @@ import com.example.dvely.agent.application.port.out.LlmMessage; import com.example.dvely.agent.application.port.out.LlmPort; import com.example.dvely.agent.domain.value.AiModelOptions; +import com.example.dvely.agent.domain.value.AiProvider; import com.example.dvely.agent.infrastructure.config.AiProperties; +import com.example.dvely.agent.infrastructure.usage.LlmUsageRecorder; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; @@ -19,10 +21,14 @@ public class OpenAiClient implements LlmPort { static final String PROVIDER_NAME = "OpenAI"; private final AiProperties aiProperties; + private final LlmUsageRecorder llmUsageRecorder; @Override public String complete(String systemPrompt, List messages, AiModelOptions modelOptions) { - return OpenAiCompatibleChat.complete(endpoint(), systemPrompt, messages, modelOptions); + OpenAiCompatibleChat.Completion completion = + OpenAiCompatibleChat.complete(endpoint(), systemPrompt, messages, modelOptions); + llmUsageRecorder.record(AiProvider.OPENAI, completion.model(), completion.usage()); + return completion.content(); } private OpenAiCompatibleChat.Endpoint endpoint() { diff --git a/src/main/java/com/example/dvely/agent/infrastructure/llm/OpenAiCompatibleChat.java b/src/main/java/com/example/dvely/agent/infrastructure/llm/OpenAiCompatibleChat.java index ba3043a3..54e9654d 100644 --- a/src/main/java/com/example/dvely/agent/infrastructure/llm/OpenAiCompatibleChat.java +++ b/src/main/java/com/example/dvely/agent/infrastructure/llm/OpenAiCompatibleChat.java @@ -5,6 +5,8 @@ import com.example.dvely.agent.application.port.out.ToolCall; import com.example.dvely.agent.application.port.out.ToolDefinition; import com.example.dvely.agent.domain.value.AiModelOptions; +import com.example.dvely.agent.domain.value.AiProvider; +import com.example.dvely.agent.domain.value.LlmUsage; import com.example.dvely.agent.infrastructure.config.AiProperties; import com.fasterxml.jackson.databind.ObjectMapper; import java.util.ArrayList; @@ -52,11 +54,23 @@ record Endpoint( AiProperties.Retry retry ) {} + /** + * 한 번의 완성 호출 결과. + * + *

본문만 돌려주던 예전 모양으로는 응답의 {@code usage} 가 파싱되자마자 버려졌다 — 그래서 + * 토큰을 얼마나 쓰는지 아무도 몰랐다. 기록은 제공자 빈이 하고(어느 {@link AiProvider} 로 + * 집계할지는 이 정적 클래스가 알 수 없다), 이 record 가 그 사이를 잇는다.

+ * + * @param model 제공자가 실제로 답한 모델명. 요청이 슬러그를 생략했거나 게이트웨이가 다른 + * 모델로 라우팅했을 수 있으므로 요청값이 아니라 응답값을 남긴다 + */ + record Completion(String content, String model, LlmUsage usage) {} + /** One-shot completion: a system prompt and a transcript in, the assistant's text out. */ - static String complete(Endpoint endpoint, - String systemPrompt, - List messages, - AiModelOptions modelOptions) { + static Completion complete(Endpoint endpoint, + String systemPrompt, + List messages, + AiModelOptions modelOptions) { LlmProviderErrors.requireApiKey(endpoint.providerName(), endpoint.config().getApiKey()); List> apiMessages = new ArrayList<>(); @@ -72,7 +86,10 @@ static String complete(Endpoint endpoint, String content = firstMessageContent(endpoint, raw); log.debug("{} 응답 수신: model={}", endpoint.providerName(), body.get("model")); - return content; + return new Completion( + content, + answeredModel(endpoint, raw, String.valueOf(body.get("model"))), + readUsage(endpoint, raw)); } /** Tool-calling completion: the calls the model wants run, plus its raw assistant message. */ @@ -107,7 +124,7 @@ static LlmToolResponse completeWithTools(Endpoint endpoint, .body(String.class)); log.debug("{} Tool API 응답 수신", endpoint.providerName()); - return parseToolResponse(endpoint, raw); + return parseToolResponse(endpoint, raw, readUsage(endpoint, raw)); } private static Map baseBody(Endpoint endpoint, @@ -137,13 +154,13 @@ private static String firstMessageContent(Endpoint endpoint, String raw) { } catch (IllegalStateException e) { throw e; } catch (Exception e) { - log.error("{} 응답 파싱 실패: {}", endpoint.providerName(), raw, e); + log.error("{} 응답 파싱 실패: {}", endpoint.providerName(), LlmLogPreview.of(raw), e); throw new IllegalStateException(endpoint.providerName() + " API 응답 파싱 실패", e); } } @SuppressWarnings("unchecked") - private static LlmToolResponse parseToolResponse(Endpoint endpoint, String raw) { + private static LlmToolResponse parseToolResponse(Endpoint endpoint, String raw, LlmUsage usage) { try { Map response = OBJECT_MAPPER.readValue(raw, Map.class); List> choices = (List>) response.get("choices"); @@ -166,10 +183,10 @@ private static LlmToolResponse parseToolResponse(Endpoint endpoint, String raw) } // contentBlocks = [assistantMessage] — 호출 측 루프에서 그대로 messages에 추가 - return new LlmToolResponse(toolCalls, List.of(message), finishReason); + return new LlmToolResponse(toolCalls, List.of(message), finishReason, usage); } catch (Exception e) { - log.error("{} Tool 응답 파싱 실패: {}", endpoint.providerName(), raw, e); + log.error("{} Tool 응답 파싱 실패: {}", endpoint.providerName(), LlmLogPreview.of(raw), e); throw new RuntimeException(endpoint.providerName() + " Tool API 응답 파싱 실패", e); } } @@ -212,4 +229,33 @@ private static RestClient.RequestBodySpec post(Endpoint endpoint) { endpoint.extraHeaders().forEach(request::header); return request; } + + /** + * 응답의 {@code usage}. + * + *

파싱이 어긋나도 던지지 않는다. 이 시점의 호출은 이미 성공했고, 계측 하나를 잃는 것이 + * 성공한 호출을 실패로 만드는 것보다 낫다.

+ */ + @SuppressWarnings("unchecked") + private static LlmUsage readUsage(Endpoint endpoint, String raw) { + try { + return LlmUsageParser.openAiCompatible(OBJECT_MAPPER.readValue(raw, Map.class)); + } catch (Exception exception) { + log.debug("{} 사용량 파싱 실패 — 계측만 건너뜁니다: {}", + endpoint.providerName(), exception.getClass().getSimpleName()); + return LlmUsage.NONE; + } + } + + @SuppressWarnings("unchecked") + private static String answeredModel(Endpoint endpoint, String raw, String requestedModel) { + try { + Map response = OBJECT_MAPPER.readValue(raw, Map.class); + return response.get("model") instanceof String named && !named.isBlank() + ? named + : requestedModel; + } catch (Exception exception) { + return requestedModel; + } + } } diff --git a/src/main/java/com/example/dvely/agent/infrastructure/llm/OpenAiToolClient.java b/src/main/java/com/example/dvely/agent/infrastructure/llm/OpenAiToolClient.java index d61cc21b..8ea603cc 100644 --- a/src/main/java/com/example/dvely/agent/infrastructure/llm/OpenAiToolClient.java +++ b/src/main/java/com/example/dvely/agent/infrastructure/llm/OpenAiToolClient.java @@ -4,7 +4,9 @@ import com.example.dvely.agent.application.port.out.LlmToolResponse; import com.example.dvely.agent.application.port.out.ToolDefinition; import com.example.dvely.agent.domain.value.AiModelOptions; +import com.example.dvely.agent.domain.value.AiProvider; import com.example.dvely.agent.infrastructure.config.AiProperties; +import com.example.dvely.agent.infrastructure.usage.LlmUsageRecorder; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; @@ -19,6 +21,7 @@ public class OpenAiToolClient implements LlmToolPort { private static final String API_URL = "https://api.openai.com/v1/chat/completions"; private final AiProperties aiProperties; + private final LlmUsageRecorder llmUsageRecorder; public LlmToolResponse completeWithTools( String systemPrompt, @@ -33,8 +36,11 @@ public LlmToolResponse completeWithTools( List> messages, List tools, AiModelOptions modelOptions) { - return OpenAiCompatibleChat.completeWithTools( + LlmToolResponse response = OpenAiCompatibleChat.completeWithTools( endpoint(), systemPrompt, messages, tools, modelOptions); + llmUsageRecorder.record( + AiProvider.OPENAI, modelOptions.modelOr(aiProperties.getOpenai().getModel()), response.usage()); + return response; } private OpenAiCompatibleChat.Endpoint endpoint() { diff --git a/src/main/java/com/example/dvely/agent/infrastructure/persistence/entity/LlmUsageEntity.java b/src/main/java/com/example/dvely/agent/infrastructure/persistence/entity/LlmUsageEntity.java new file mode 100644 index 00000000..4aa3b68f --- /dev/null +++ b/src/main/java/com/example/dvely/agent/infrastructure/persistence/entity/LlmUsageEntity.java @@ -0,0 +1,88 @@ +package com.example.dvely.agent.infrastructure.persistence.entity; + +import com.example.dvely.agent.domain.value.LlmUsage; +import com.example.dvely.agent.infrastructure.usage.LlmUsagePhase; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; + +/** + * LLM 호출 한 건의 토큰 사용량. + * + *

비밀은 담지 않는다 — 프롬프트도 응답도 여기 오지 않고, 어느 제공자의 어느 모델이 몇 토큰을 + * 읽고 썼는지만 남는다. 그래서 {@code toString} 을 따로 두지 않아도 위험하지 않지만, 이 도메인의 + * 규칙대로 Lombok {@code @ToString}/{@code @Data} 는 쓰지 않는다.

+ */ +@Entity +@Table(name = "llm_usage") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class LlmUsageEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "llm_usage_id") + private Long id; + + /** 스코프 없이 난 호출은 null — 귀속은 없어도 합계에서 빠지지는 않는다. */ + @Column(name = "task_id", length = 64) + private String taskId; + + @Column(name = "user_id") + private Long userId; + + @Column(name = "project_id") + private Long projectId; + + @Column(name = "phase", nullable = false, length = 30) + private String phase; + + @Column(name = "provider", nullable = false, length = 30) + private String provider; + + @Column(name = "model", nullable = false, length = 120) + private String model; + + @Column(name = "input_tokens", nullable = false) + private long inputTokens; + + @Column(name = "output_tokens", nullable = false) + private long outputTokens; + + @Column(name = "cache_creation_input_tokens", nullable = false) + private long cacheCreationInputTokens; + + @Column(name = "cache_read_input_tokens", nullable = false) + private long cacheReadInputTokens; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + public LlmUsageEntity(String taskId, + Long userId, + Long projectId, + LlmUsagePhase phase, + String provider, + String model, + LlmUsage usage) { + this.taskId = taskId; + this.userId = userId; + this.projectId = projectId; + this.phase = phase.name(); + this.provider = provider; + this.model = model; + this.inputTokens = usage.inputTokens(); + this.outputTokens = usage.outputTokens(); + this.cacheCreationInputTokens = usage.cacheCreationInputTokens(); + this.cacheReadInputTokens = usage.cacheReadInputTokens(); + } +} diff --git a/src/main/java/com/example/dvely/agent/infrastructure/persistence/repository/SpringDataLlmUsageRepository.java b/src/main/java/com/example/dvely/agent/infrastructure/persistence/repository/SpringDataLlmUsageRepository.java new file mode 100644 index 00000000..d8cb310a --- /dev/null +++ b/src/main/java/com/example/dvely/agent/infrastructure/persistence/repository/SpringDataLlmUsageRepository.java @@ -0,0 +1,23 @@ +package com.example.dvely.agent.infrastructure.persistence.repository; + +import com.example.dvely.agent.infrastructure.persistence.entity.LlmUsageEntity; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface SpringDataLlmUsageRepository extends JpaRepository { + + List findAllByTaskIdOrderByIdAsc(String taskId); + + /** + * 이 태스크가 지금까지 쓴 토큰 합계. + * + *

예산 상한이 재시도를 건너 유효하려면 필요하다. 실행마다 0 에서 다시 세면 상한은 + * 태스크 재시도 횟수만큼 곱해지는데, 그 곱셈을 닫는 것이 이 상한의 목적이다.

+ */ + @Query("select coalesce(sum(u.inputTokens + u.outputTokens" + + " + u.cacheCreationInputTokens + u.cacheReadInputTokens), 0)" + + " from LlmUsageEntity u where u.taskId = :taskId") + long sumTotalTokensByTaskId(@Param("taskId") String taskId); +} diff --git a/src/main/java/com/example/dvely/agent/infrastructure/usage/LlmUsagePhase.java b/src/main/java/com/example/dvely/agent/infrastructure/usage/LlmUsagePhase.java new file mode 100644 index 00000000..10370cde --- /dev/null +++ b/src/main/java/com/example/dvely/agent/infrastructure/usage/LlmUsagePhase.java @@ -0,0 +1,17 @@ +package com.example.dvely.agent.infrastructure.usage; + +/** 한 LLM 호출이 파이프라인의 어느 구간에서 났는가. 어디에 돈이 쓰이는지 가르는 축이다. */ +public enum LlmUsagePhase { + + /** 계획 수립(DecisionAgentService) — 교정 재시도 호출도 여기 들어간다. */ + DECISION, + + /** 계획 실행(CODE 툴 루프·CHAT·DEPLOY 등). 태스크당 토큰 예산이 걸리는 구간이다. */ + AGENT_RUN, + + /** 배포 실패 로그 요약 1회. */ + DEPLOY_FAILURE_ANALYSIS, + + /** 열린 스코프 없이 난 호출. 태스크에 귀속되지 않지만 합계에서 빠지지는 않는다. */ + UNSCOPED +} diff --git a/src/main/java/com/example/dvely/agent/infrastructure/usage/LlmUsageRecorder.java b/src/main/java/com/example/dvely/agent/infrastructure/usage/LlmUsageRecorder.java new file mode 100644 index 00000000..691b9c7a --- /dev/null +++ b/src/main/java/com/example/dvely/agent/infrastructure/usage/LlmUsageRecorder.java @@ -0,0 +1,57 @@ +package com.example.dvely.agent.infrastructure.usage; + +import com.example.dvely.agent.domain.value.AiProvider; +import com.example.dvely.agent.domain.value.LlmUsage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 제공자 클라이언트가 호출 한 건을 끝낼 때마다 여기로 사용량을 넘긴다. + * + *

두 가지를 한다. (1) 행 하나를 남겨 같은 시나리오의 전/후 토큰 합계를 비교할 수 있게 하고, + * (2) 열려 있는 {@link LlmUsageScope} 에 누적해 태스크당 예산 상한을 건다.

+ * + *

기록 실패는 {@link LlmUsageStore} 가 삼킨다. 반대로 예산 초과는 삼키지 않고 그대로 올린다 — + * 그것이 이 기능의 목적이다.

+ */ +@Slf4j +@Component +@RequiredArgsConstructor +public class LlmUsageRecorder { + + private final LlmUsageStore store; + + /** + * 에이전트 태스크 하나의 실행 구간을 연다. + * + *

이전 실행이 쓴 양을 읽어 이어서 센다 — 그래야 상한이 태스크 재시도를 건너 유효하다. + * 상한이 꺼져 있으면(0) 조회도 하지 않는다.

+ */ + public LlmUsageScope openTaskScope(String taskId, Long userId, Long projectId, long budgetTokens) { + long alreadyUsed = budgetTokens > 0 ? store.tokensAlreadyUsedBy(taskId) : 0; + return LlmUsageScope.open( + taskId, userId, projectId, LlmUsagePhase.AGENT_RUN, budgetTokens, alreadyUsed); + } + + public void record(AiProvider provider, String model, LlmUsage usage) { + if (usage == null || usage.isEmpty()) { + // 응답에 usage 가 없었다는 뜻이다. 0 행을 쌓아 합계를 희석시키느니 남기지 않는다. + return; + } + LlmUsageScope scope = LlmUsageScope.current(); + store.save( + scope == null ? null : scope.taskId(), + scope == null ? null : scope.userId(), + scope == null ? null : scope.projectId(), + scope == null ? LlmUsagePhase.UNSCOPED : scope.phase(), + provider.name(), + model == null || model.isBlank() ? "unknown" : model, + usage + ); + if (scope != null) { + // save 다음이다 — 상한을 터뜨린 호출도 이미 과금됐으므로 기록에는 남아야 한다. + scope.accumulate(usage); + } + } +} diff --git a/src/main/java/com/example/dvely/agent/infrastructure/usage/LlmUsageScope.java b/src/main/java/com/example/dvely/agent/infrastructure/usage/LlmUsageScope.java new file mode 100644 index 00000000..e48fdc16 --- /dev/null +++ b/src/main/java/com/example/dvely/agent/infrastructure/usage/LlmUsageScope.java @@ -0,0 +1,133 @@ +package com.example.dvely.agent.infrastructure.usage; + +import com.example.dvely.agent.application.exception.AgentTokenBudgetExceededException; +import com.example.dvely.agent.domain.value.LlmUsage; + +/** + * "지금 도는 이 LLM 호출은 누구의 어느 작업 것인가" 를 실행 스레드에 붙여 두는 자리. + * + *

대안은 {@code LlmPort}/{@code LlmToolPort} 시그니처에 taskId 를 더하는 것이었다. 그러면 + * 호출부가 전부 바뀌는데, 그 파일들은 다른 작업자가 동시에 손대는 파일이라 충돌 비용이 계측 + * 가치보다 커진다. 게다가 시그니처에 실으면 새로 생기는 호출부가 빠뜨릴 수 있다 — + * 스코프 방식은 호출부가 무엇을 하든 제공자 클라이언트가 알아서 센다.

+ * + *

스코프가 열려 있지 않은 호출도 기록은 된다({@link LlmUsagePhase#UNSCOPED}). 귀속만 없을 + * 뿐 합계에서 사라지지 않아야, "합계가 맞는가" 를 나중에 의심하지 않아도 된다.

+ * + *

스레드를 넘지 않는다. {@code @Async} 로 다른 스레드에 넘어가는 구간은 그 스레드에서 + * 다시 열어야 한다 — 상속되는 ThreadLocal 로 만들면 스레드풀이 스코프를 재사용해 남의 작업 + * 토큰이 엉뚱한 태스크에 붙는다.

+ */ +public final class LlmUsageScope implements AutoCloseable { + + private static final ThreadLocal CURRENT = new ThreadLocal<>(); + + private final LlmUsageScope previous; + private final String taskId; + private final Long userId; + private final Long projectId; + private final LlmUsagePhase phase; + /** 0 이면 상한 없음. */ + private final long budgetTokens; + + private long usedTokens; + private boolean closed; + + private LlmUsageScope(LlmUsageScope previous, + String taskId, + Long userId, + Long projectId, + LlmUsagePhase phase, + long budgetTokens) { + this.previous = previous; + this.taskId = taskId; + this.userId = userId; + this.projectId = projectId; + this.phase = phase == null ? LlmUsagePhase.UNSCOPED : phase; + this.budgetTokens = Math.max(0, budgetTokens); + } + + /** 상한 없는 스코프. 계획 수립·실패 분석처럼 호출이 유계인 구간에 쓴다. */ + public static LlmUsageScope open(String taskId, Long userId, Long projectId, LlmUsagePhase phase) { + return open(taskId, userId, projectId, phase, 0); + } + + public static LlmUsageScope open(String taskId, + Long userId, + Long projectId, + LlmUsagePhase phase, + long budgetTokens) { + return open(taskId, userId, projectId, phase, budgetTokens, 0); + } + + /** + * @param alreadyUsedTokens 이 태스크가 이전 실행(재시도 전)에 이미 쓴 토큰. 0 에서 다시 세면 + * 상한이 태스크 재시도 횟수만큼 곱해져 상한이 아니게 된다 + */ + public static LlmUsageScope open(String taskId, + Long userId, + Long projectId, + LlmUsagePhase phase, + long budgetTokens, + long alreadyUsedTokens) { + LlmUsageScope scope = + new LlmUsageScope(CURRENT.get(), taskId, userId, projectId, phase, budgetTokens); + scope.usedTokens = Math.max(0, alreadyUsedTokens); + CURRENT.set(scope); + return scope; + } + + public static LlmUsageScope current() { + return CURRENT.get(); + } + + @Override + public void close() { + if (closed) { + return; + } + closed = true; + if (previous == null) { + CURRENT.remove(); + } else { + CURRENT.set(previous); + } + } + + public String taskId() { + return taskId; + } + + public Long userId() { + return userId; + } + + public Long projectId() { + return projectId; + } + + public LlmUsagePhase phase() { + return phase; + } + + public long usedTokens() { + return usedTokens; + } + + public long budgetTokens() { + return budgetTokens; + } + + /** + * 이번 호출분을 누적하고, 상한을 넘었으면 던진다. + * + *

던지기 전에 누적한다. 상한을 터뜨린 그 호출도 이미 과금됐으므로 합계에 들어가야 + * 하고, 그래야 사용자에게 보이는 수치가 실제 청구와 어긋나지 않는다.

+ */ + synchronized void accumulate(LlmUsage usage) { + usedTokens += usage.totalTokens(); + if (budgetTokens > 0 && usedTokens > budgetTokens) { + throw new AgentTokenBudgetExceededException(usedTokens, budgetTokens); + } + } +} diff --git a/src/main/java/com/example/dvely/agent/infrastructure/usage/LlmUsageStore.java b/src/main/java/com/example/dvely/agent/infrastructure/usage/LlmUsageStore.java new file mode 100644 index 00000000..93ca3c9a --- /dev/null +++ b/src/main/java/com/example/dvely/agent/infrastructure/usage/LlmUsageStore.java @@ -0,0 +1,61 @@ +package com.example.dvely.agent.infrastructure.usage; + +import com.example.dvely.agent.domain.value.LlmUsage; +import com.example.dvely.agent.infrastructure.persistence.entity.LlmUsageEntity; +import com.example.dvely.agent.infrastructure.persistence.repository.SpringDataLlmUsageRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** + * 사용량 행 하나를 쓰는 것만 하는 별도 빈. + * + *

{@link LlmUsageRecorder} 안의 private 메서드가 아니라 빈으로 분리한 이유는 하나다 — + * 자기 호출(self-invocation)은 프록시를 타지 않아 {@code REQUIRES_NEW} 가 걸리지 않는다. + * 별도 빈이라야 호출자의 트랜잭션과 실제로 끊긴다.

+ * + *

끊어야 하는 이유는 양방향이다. 계측 INSERT 실패가 진행 중인 작업을 되돌려서는 안 되고, + * 반대로 작업이 롤백되더라도 이미 쓴 토큰은 실제로 쓴 것이라 기록에 남아야 한다.

+ */ +@Slf4j +@Component +@RequiredArgsConstructor +public class LlmUsageStore { + + private final SpringDataLlmUsageRepository repository; + + /** 이 태스크가 이전 실행까지 이미 쓴 토큰. 조회에 실패하면 0 — 계측이 작업을 막지 않는다. */ + @Transactional(readOnly = true) + public long tokensAlreadyUsedBy(String taskId) { + if (taskId == null) { + return 0; + } + try { + return repository.sumTotalTokensByTaskId(taskId); + } catch (RuntimeException exception) { + log.warn("[LlmUsage] 누적 토큰 조회 실패 — 이번 실행은 0 에서 셉니다. taskId={} exceptionType={}", + taskId, exception.getClass().getSimpleName()); + return 0; + } + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void save(String taskId, + Long userId, + Long projectId, + LlmUsagePhase phase, + String provider, + String model, + LlmUsage usage) { + try { + repository.save(new LlmUsageEntity(taskId, userId, projectId, phase, provider, model, usage)); + } catch (RuntimeException exception) { + // 계측이 태스크를 죽이는 일은 없어야 한다. 예외 타입만 남긴다 — 제공자 응답 본문이 + // 섞여 들어올 여지를 두지 않는다. + log.warn("[LlmUsage] 사용량 기록 실패 — 작업은 그대로 진행합니다. provider={} exceptionType={}", + provider, exception.getClass().getSimpleName()); + } + } +} diff --git a/src/main/java/com/example/dvely/chat/application/command/AsyncDecisionRunner.java b/src/main/java/com/example/dvely/chat/application/command/AsyncDecisionRunner.java index 5c24c544..342196a3 100644 --- a/src/main/java/com/example/dvely/chat/application/command/AsyncDecisionRunner.java +++ b/src/main/java/com/example/dvely/chat/application/command/AsyncDecisionRunner.java @@ -6,6 +6,8 @@ import com.example.dvely.agent.application.service.AgentMessageService; import com.example.dvely.agent.application.service.DecisionAgentService; import com.example.dvely.agent.domain.value.AiProvider; +import com.example.dvely.agent.infrastructure.usage.LlmUsagePhase; +import com.example.dvely.agent.infrastructure.usage.LlmUsageScope; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -49,7 +51,11 @@ public void decideAndSubmit(String taskId, Long conversationId, Long projectId, AiProvider provider) { - try { + // 계획 수립의 토큰도 이 태스크의 것이다. 여기서 스코프를 열지 않으면 Decision 이 쓴 + // 토큰만 태스크에 귀속되지 않아, 태스크 단위 합계가 실제보다 작게 나온다. + // 상한은 걸지 않는다 — 이 구간의 호출 수는 최대 2회(본 호출 + 교정 1회)로 이미 유계다. + try (LlmUsageScope ignored = + LlmUsageScope.open(taskId, userId, projectId, LlmUsagePhase.DECISION)) { // 계획 수립에는 사용자 발화만 넘긴다. 우리가 쓴 운영 안내가 섞이면 모델이 그 문장을 // 흉내 내다 JSON 을 내지 못한다 — AgentMessageService#getUserIntentHistory 참고. List history = agentMessageService.getUserIntentHistory(conversationId); diff --git a/src/main/java/com/example/dvely/deployment/application/service/DeploymentFailureAnalysisService.java b/src/main/java/com/example/dvely/deployment/application/service/DeploymentFailureAnalysisService.java index ca52d40b..a3892b56 100644 --- a/src/main/java/com/example/dvely/deployment/application/service/DeploymentFailureAnalysisService.java +++ b/src/main/java/com/example/dvely/deployment/application/service/DeploymentFailureAnalysisService.java @@ -2,9 +2,13 @@ import com.example.dvely.agent.application.port.out.LlmMessage; import com.example.dvely.agent.application.service.BuildFailureAnalyzer; +import com.example.dvely.agent.domain.value.AiModelOptions; import com.example.dvely.agent.domain.value.AiProvider; +import com.example.dvely.agent.domain.value.ThinkingLevel; import com.example.dvely.agent.infrastructure.config.AiProperties; import com.example.dvely.agent.infrastructure.llm.LlmRouter; +import com.example.dvely.agent.infrastructure.usage.LlmUsagePhase; +import com.example.dvely.agent.infrastructure.usage.LlmUsageScope; import com.example.dvely.auth.application.command.AuthCommandService; import com.example.dvely.auth.domain.model.User; import com.example.dvely.auth.domain.repository.UserRepository; @@ -342,11 +346,29 @@ private AnalysisOutcome runAnalysis(String excerpt) { // stuck upstream connection could otherwise block this call forever and never reach the // rule-based fallback below. CompletableFuture#orTimeout enforces a caller-side cutoff // without touching ClaudeClient itself. + // 제공자와 모델은 설정에서 온다. 예전에는 여기가 ANTHROPIC + 그 제공자의 최상위 기본 + // 모델로 박혀 있었다 — 12,000자 로그를 한 번 요약하는 데 최상위 모델을 쓸 근거가 없었고, + // 배포의 defaultProvider(GLM)와도 어긋났다. 분석 품질이 떨어지면 설정만으로 되돌린다 + // (AiProperties.FailureAnalysis). + AiProvider provider = aiProperties.failureAnalysisProvider(); + String configuredModel = aiProperties.getFailureAnalysis().modelOrNull(); + AiModelOptions modelOptions = new AiModelOptions(configuredModel, ThinkingLevel.OFF); + try { String raw = CompletableFuture .supplyAsync( - () -> llmRouter.route(AiProvider.ANTHROPIC) - .complete(SYSTEM_PROMPT, List.of(new LlmMessage("user", excerpt))), + () -> { + // 스코프는 스레드를 넘지 않으므로 이 안에서 연다 — 여기서 열지 + // 않으면 이 호출의 토큰이 어느 구간 것인지 남지 않는다. + try (LlmUsageScope ignored = LlmUsageScope.open( + null, null, null, LlmUsagePhase.DEPLOY_FAILURE_ANALYSIS)) { + return llmRouter.route(provider).complete( + SYSTEM_PROMPT, + List.of(new LlmMessage("user", excerpt)), + modelOptions); + } + }, + // #336: 호출마다 executor 를 새로 만들지 않는다(필드의 공용 가상 스레드 executor). llmCallExecutor ) .orTimeout(llmTimeoutSeconds, TimeUnit.SECONDS) @@ -356,8 +378,8 @@ private AnalysisOutcome runAnalysis(String excerpt) { AnalysisSource.LLM, parsed.summary(), parsed.suggestedFix(), - AiProvider.ANTHROPIC.name(), - aiProperties.getAnthropic().getModel() + provider.name(), + modelOptions.modelOr(aiProperties.providerConfig(provider).getModel()) ); } catch (RuntimeException exception) { // Any LLM transport failure, timeout, or unparseable response falls back to the diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index f93c896e..21158f35 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -64,6 +64,9 @@ qeploy: anthropic: api-key: ${QEPLOY_AI_ANTHROPIC_API_KEY:${ANTHROPIC_API_KEY:}} model: ${QEPLOY_AI_ANTHROPIC_MODEL:claude-opus-4-5-20251101} + # Messages API 전체 URL. 사내 프록시를 거치게 하거나, 나가는 요청 본문을 실제로 검사하는 + # 테스트에서 로컬 서버로 돌릴 때 쓴다. + base-url: ${QEPLOY_AI_ANTHROPIC_BASE_URL:https://api.anthropic.com/v1/messages} # 요청(DecisionRequest.model)이 지정할 수 있는 추가 모델. 비우면 위 model만 허용된다 — # 넓히는 것은 비용 결정이므로 기본값은 최소 권한이다. allowed-models: ${QEPLOY_AI_ANTHROPIC_ALLOWED_MODELS:} @@ -113,6 +116,22 @@ qeploy: # CODE 스텝 1회 실행에서 허용하는 LLM 왕복 횟수. 소진되면 성공이 아니라 실패로 처리되고 # 같은 컨테이너에서 이어서 재시도된다(AgentIterationLimitException). max-iterations: ${QEPLOY_AI_CODE_AGENT_MAX_ITERATIONS:40} + # 태스크 하나가 쓸 수 있는 누적 토큰(입력+출력+캐시). 0 이면 상한 없음. + # 위 max-iterations 와 retry.max-attempts, 그리고 태스크 재시도는 서로 곱해지는데 그 곱에는 + # 아무 상한이 없었다. 재시도를 건너 이어 세므로 재시도로 상한을 우회하지 못한다. + # 걸리면 태스크는 사유가 보이는 실패로 닫힌다(사용자에게 사용량/상한이 그대로 보인다). + max-task-tokens: ${QEPLOY_AI_CODE_AGENT_MAX_TASK_TOKENS:1000000} + # 도구를 쓰지 않는 한 번짜리 완성 호출의 출력 상한(Anthropic max_tokens). + # 1024 였을 때 다단계 계획 JSON 이 중간에서 잘렸고, 그때마다 교정 재시도가 전체 컨텍스트를 + # 한 번 더 보냈다 — 아끼는 출력 토큰보다 치르는 입력 토큰이 컸다. 상한이지 할당량이 아니다. + completion-max-tokens: ${QEPLOY_AI_COMPLETION_MAX_TOKENS:4096} + # 배포 실패 로그 요약 전용. 비워 두면 위 default-provider 와 그 제공자의 기본 모델을 따른다. + # 예전에는 이 한 경로만 ANTHROPIC + 최상위 모델로 코드에 박혀 있었다 — 12,000자 로그를 한 번 + # 요약하는 데 쓸 근거가 없었고 default-provider 와도 어긋났다. + # 되돌리는 법: provider 에 ANTHROPIC, model 에 claude-opus-4-5-20251101 을 적고 재배포한다. + failure-analysis: + provider: ${QEPLOY_AI_FAILURE_ANALYSIS_PROVIDER:} + model: ${QEPLOY_AI_FAILURE_ANALYSIS_MODEL:} # BYOK 코딩 에이전트(사용자 본인 공식 API 키로 벤더 CLI 를 헤드리스 실행). # qeploy.ai.code-agent(위, CODE 스텝의 LLM 왕복 예산)와는 다른 축이다 — 이름이 비슷하니 주의. diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 837f15bd..16dca325 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -57,6 +57,9 @@ qeploy: anthropic: api-key: ${QEPLOY_AI_ANTHROPIC_API_KEY:${ANTHROPIC_API_KEY:}} model: ${QEPLOY_AI_ANTHROPIC_MODEL:claude-opus-4-5-20251101} + # Messages API 전체 URL. 사내 프록시를 거치게 하거나, 나가는 요청 본문을 실제로 검사하는 + # 테스트에서 로컬 서버로 돌릴 때 쓴다. + base-url: ${QEPLOY_AI_ANTHROPIC_BASE_URL:https://api.anthropic.com/v1/messages} # 요청(DecisionRequest.model)이 지정할 수 있는 추가 모델. 비우면 위 model만 허용된다 — # 넓히는 것은 비용 결정이므로 기본값은 최소 권한이다. allowed-models: ${QEPLOY_AI_ANTHROPIC_ALLOWED_MODELS:} @@ -106,6 +109,22 @@ qeploy: # CODE 스텝 1회 실행에서 허용하는 LLM 왕복 횟수. 소진되면 성공이 아니라 실패로 처리되고 # 같은 컨테이너에서 이어서 재시도된다(AgentIterationLimitException). max-iterations: ${QEPLOY_AI_CODE_AGENT_MAX_ITERATIONS:40} + # 태스크 하나가 쓸 수 있는 누적 토큰(입력+출력+캐시). 0 이면 상한 없음. + # 위 max-iterations 와 retry.max-attempts, 그리고 태스크 재시도는 서로 곱해지는데 그 곱에는 + # 아무 상한이 없었다. 재시도를 건너 이어 세므로 재시도로 상한을 우회하지 못한다. + # 걸리면 태스크는 사유가 보이는 실패로 닫힌다(사용자에게 사용량/상한이 그대로 보인다). + max-task-tokens: ${QEPLOY_AI_CODE_AGENT_MAX_TASK_TOKENS:1000000} + # 도구를 쓰지 않는 한 번짜리 완성 호출의 출력 상한(Anthropic max_tokens). + # 1024 였을 때 다단계 계획 JSON 이 중간에서 잘렸고, 그때마다 교정 재시도가 전체 컨텍스트를 + # 한 번 더 보냈다 — 아끼는 출력 토큰보다 치르는 입력 토큰이 컸다. 상한이지 할당량이 아니다. + completion-max-tokens: ${QEPLOY_AI_COMPLETION_MAX_TOKENS:4096} + # 배포 실패 로그 요약 전용. 비워 두면 위 default-provider 와 그 제공자의 기본 모델을 따른다. + # 예전에는 이 한 경로만 ANTHROPIC + 최상위 모델로 코드에 박혀 있었다 — 12,000자 로그를 한 번 + # 요약하는 데 쓸 근거가 없었고 default-provider 와도 어긋났다. + # 되돌리는 법: provider 에 ANTHROPIC, model 에 claude-opus-4-5-20251101 을 적고 재배포한다. + failure-analysis: + provider: ${QEPLOY_AI_FAILURE_ANALYSIS_PROVIDER:} + model: ${QEPLOY_AI_FAILURE_ANALYSIS_MODEL:} # BYOK 코딩 에이전트(사용자 본인 공식 API 키로 벤더 CLI 를 헤드리스 실행). # qeploy.ai.code-agent(위, CODE 스텝의 LLM 왕복 예산)와는 다른 축이다 — 이름이 비슷하니 주의. diff --git a/src/main/resources/db/migration/V63__add_llm_usage.sql b/src/main/resources/db/migration/V63__add_llm_usage.sql new file mode 100644 index 00000000..62bb40ca --- /dev/null +++ b/src/main/resources/db/migration/V63__add_llm_usage.sql @@ -0,0 +1,36 @@ +-- U8 8-1: LLM 토큰 사용량 계측. +-- +-- 이 테이블이 생기기 전까지 응답의 usage 를 읽는 코드가 한 줄도 없었다 — 토큰을 얼마나 쓰는지 +-- 아무도 몰랐고, 프롬프트 캐싱·대화 윈도우 같은 절감 작업의 효과를 잴 수단도, 태스크당 예산 +-- 상한이 셀 대상도 없었다. +-- +-- agent_runs 에 누적 컬럼을 더하지 않고 별도 테이블로 둔 이유: +-- 1. 모든 LLM 호출이 agent_run 에 속하지 않는다. 배포 실패 분석은 태스크 없이 돈다. +-- 2. agent_runs 는 보존 정책으로 삭제된다(AgentRunRetentionScheduler). 비용 이력이 함께 +-- 사라지면 "지난달 대비" 같은 질문에 답할 수 없다. +-- 3. 호출 한 건 단위라야 라운드별 캐시 적중을 볼 수 있다. 누적 컬럼은 "캐싱이 실제로 +-- 살아 있는가" 에 답하지 못한다. +-- 4. CODE 루프는 라운드마다 쓴다. 누적 컬럼이면 실행 중인 태스크 행을 40 번 UPDATE 하게 +-- 되는데, 그 행은 워커도 쓰는 뜨거운 행이다. +-- +-- FK 를 걸지 않는다. 계측 행이 agent_runs 의 잠금 그래프에 끌려 들어가면 안 되고, 태스크 행이 +-- 생기기 전이나 지워진 뒤에도 기록이 남아야 한다(위 2번). + +CREATE TABLE llm_usage ( + llm_usage_id BIGINT NOT NULL AUTO_INCREMENT, + task_id VARCHAR(64) NULL COMMENT '귀속된 에이전트 태스크. 스코프 밖 호출은 NULL', + user_id BIGINT NULL, + project_id BIGINT NULL, + phase VARCHAR(30) NOT NULL COMMENT 'DECISION / AGENT_RUN / DEPLOY_FAILURE_ANALYSIS / UNSCOPED', + provider VARCHAR(30) NOT NULL, + model VARCHAR(120) NOT NULL, + input_tokens BIGINT NOT NULL DEFAULT 0 COMMENT '캐시 읽기/쓰기를 제외한 입력', + output_tokens BIGINT NOT NULL DEFAULT 0, + cache_creation_input_tokens BIGINT NOT NULL DEFAULT 0, + cache_read_input_tokens BIGINT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (llm_usage_id), + KEY idx_llm_usage_task (task_id), + KEY idx_llm_usage_created (created_at), + KEY idx_llm_usage_user_created (user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/main/resources/db/schema.sql b/src/main/resources/db/schema.sql index 88b0887b..294699c9 100644 --- a/src/main/resources/db/schema.sql +++ b/src/main/resources/db/schema.sql @@ -464,3 +464,25 @@ CREATE TABLE project_changes ( FOREIGN KEY (preview_session_id) REFERENCES preview_sessions (preview_session_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- U8 8-1 (V63): LLM 호출 한 건의 토큰 사용량. agent_runs 누적 컬럼이 아니라 별도 테이블인 +-- 이유(보존 정책·태스크 밖 호출·라운드별 캐시 적중·뜨거운 행 UPDATE 회피)는 V63 마이그레이션 +-- 주석 참고. FK 없음 — 계측 행이 agent_runs 의 잠금 그래프에 끌려 들어가면 안 된다. +CREATE TABLE llm_usage ( + llm_usage_id BIGINT NOT NULL AUTO_INCREMENT, + task_id VARCHAR(64) NULL COMMENT '귀속된 에이전트 태스크. 스코프 밖 호출은 NULL', + user_id BIGINT NULL, + project_id BIGINT NULL, + phase VARCHAR(30) NOT NULL COMMENT 'DECISION / AGENT_RUN / DEPLOY_FAILURE_ANALYSIS / UNSCOPED', + provider VARCHAR(30) NOT NULL, + model VARCHAR(120) NOT NULL, + input_tokens BIGINT NOT NULL DEFAULT 0 COMMENT '캐시 읽기/쓰기를 제외한 입력', + output_tokens BIGINT NOT NULL DEFAULT 0, + cache_creation_input_tokens BIGINT NOT NULL DEFAULT 0, + cache_read_input_tokens BIGINT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (llm_usage_id), + KEY idx_llm_usage_task (task_id), + KEY idx_llm_usage_created (created_at), + KEY idx_llm_usage_user_created (user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/test/java/com/example/dvely/agent/application/orchestrator/AgentPlanExecutorTest.java b/src/test/java/com/example/dvely/agent/application/orchestrator/AgentPlanExecutorTest.java index ea8820f0..ffe78074 100644 --- a/src/test/java/com/example/dvely/agent/application/orchestrator/AgentPlanExecutorTest.java +++ b/src/test/java/com/example/dvely/agent/application/orchestrator/AgentPlanExecutorTest.java @@ -1,6 +1,7 @@ package com.example.dvely.agent.application.orchestrator; import com.example.dvely.chat.domain.value.ChatMessageKind; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; @@ -24,7 +25,11 @@ import com.example.dvely.agent.application.service.BackendDeployAgentService; import com.example.dvely.agent.application.dto.ClarificationRequest; import com.example.dvely.agent.application.service.DecisionAgentService; +import com.example.dvely.agent.application.exception.AgentTokenBudgetExceededException; +import com.example.dvely.agent.infrastructure.config.AiProperties; import com.example.dvely.agent.infrastructure.store.InputWaitStore; +import com.example.dvely.agent.infrastructure.usage.LlmUsageRecorder; +import com.example.dvely.agent.infrastructure.usage.LlmUsageStore; import com.fasterxml.jackson.databind.ObjectMapper; import com.example.dvely.agent.application.service.RepositoryBindingGate; import com.example.dvely.agent.application.service.ResultApprovalGate; @@ -86,7 +91,9 @@ void unregistersFromExecutionRegistryAfterSuccessfulCompletion() { registry, mock(DecisionAgentService.class), mock(InputWaitStore.class), - new ObjectMapper() + new ObjectMapper(), + usageRecorder(), + new AiProperties() ); AgentStep step = new AgentStep(AgentType.CODE, Map.of("instruction", "수정")); when(codeService.execute(eq(step), eq(AiProvider.OPENAI), eq(1L), eq(11L), eq("task-1"), any())) @@ -123,7 +130,9 @@ void unregistersFromExecutionRegistryEvenWhenTheStepThrows() { registry, mock(DecisionAgentService.class), mock(InputWaitStore.class), - new ObjectMapper() + new ObjectMapper(), + usageRecorder(), + new AiProperties() ); AgentStep step = new AgentStep(AgentType.CODE, Map.of("instruction", "수정")); when(codeService.execute(any(), any(), any(), any(), any(), any())) @@ -161,7 +170,9 @@ void stopsAfterLastCodeStepWithoutMarkingDoneWhenResultApprovalGateFires() { mock(AgentExecutionRegistry.class), mock(DecisionAgentService.class), mock(InputWaitStore.class), - new ObjectMapper() + new ObjectMapper(), + usageRecorder(), + new AiProperties() ); AgentStep step = new AgentStep(AgentType.CODE, Map.of("instruction", "수정")); AgentPlan plan = new AgentPlan(List.of(step), "reason", AiProvider.OPENAI, 11L); @@ -205,7 +216,9 @@ void stopsAfterLastCodeStepWithoutMarkingDoneWhenRepositoryBindingGateFires() { mock(AgentExecutionRegistry.class), mock(DecisionAgentService.class), mock(InputWaitStore.class), - new ObjectMapper() + new ObjectMapper(), + usageRecorder(), + new AiProperties() ); AgentStep step = new AgentStep(AgentType.CODE, Map.of("instruction", "수정")); AgentPlan plan = new AgentPlan(List.of(step), "reason", AiProvider.OPENAI, 11L); @@ -252,7 +265,9 @@ void skipsRepositoryBindingGateEntirelyWhenTheResultGateAlreadyFired() { mock(AgentExecutionRegistry.class), mock(DecisionAgentService.class), mock(InputWaitStore.class), - new ObjectMapper() + new ObjectMapper(), + usageRecorder(), + new AiProperties() ); AgentStep step = new AgentStep(AgentType.CODE, Map.of("instruction", "수정")); AgentPlan plan = new AgentPlan(List.of(step), "reason", AiProvider.OPENAI, 11L); @@ -288,7 +303,9 @@ void continuesNormallyWhenResultApprovalGateDoesNotFire() { mock(AgentExecutionRegistry.class), mock(DecisionAgentService.class), mock(InputWaitStore.class), - new ObjectMapper() + new ObjectMapper(), + usageRecorder(), + new AiProperties() ); AgentStep step = new AgentStep(AgentType.CODE, Map.of("instruction", "수정")); AgentPlan plan = new AgentPlan(List.of(step), "reason", AiProvider.OPENAI, 11L); @@ -366,7 +383,9 @@ void waitingInputPersistsQuestionWithoutFailingTask() { mock(AgentExecutionRegistry.class), mock(DecisionAgentService.class), mock(InputWaitStore.class), - new ObjectMapper() + new ObjectMapper(), + usageRecorder(), + new AiProperties() ); AgentStep step = new AgentStep(AgentType.DOMAIN_BIND, Map.of()); @@ -450,7 +469,7 @@ private AgentPlanExecutor clarifyExecutor(TaskStore taskStore, DecisionAgentServ mock(BackendDeployAgentService.class), taskStore, messageService, mock(BuildFailureRecoveryService.class), mock(ChangeService.class), mock(ResultApprovalGate.class), mock(RepositoryBindingGate.class), mock(AgentExecutionRegistry.class), - decision, inputWaitStore, new ObjectMapper()); + decision, inputWaitStore, new ObjectMapper(), usageRecorder(), new AiProperties()); } @Test @@ -475,7 +494,9 @@ void dispatchesRuntimeSetupStepToRuntimeSetupAgentService() { mock(AgentExecutionRegistry.class), mock(DecisionAgentService.class), mock(InputWaitStore.class), - new ObjectMapper() + new ObjectMapper(), + usageRecorder(), + new AiProperties() ); AgentStep step = new AgentStep(AgentType.RUNTIME_SETUP, Map.of("runtimeType", "NODE_SERVER", "dbEngine", "MYSQL")); @@ -509,7 +530,9 @@ void dispatchesInfraOperateStepToInfraOpsAgentService() { mock(AgentExecutionRegistry.class), mock(DecisionAgentService.class), mock(InputWaitStore.class), - new ObjectMapper() + new ObjectMapper(), + usageRecorder(), + new AiProperties() ); AgentStep step = new AgentStep(AgentType.INFRA_OPERATE, Map.of("operation", "STATUS_CHECK")); when(infraOpsAgentService.execute(step, 1L, "task-1", 11L)) @@ -552,7 +575,9 @@ void reportsAnAiProviderFailureWithTheProvidersOwnMessageAndWithoutBuildRecovery mock(AgentExecutionRegistry.class), mock(DecisionAgentService.class), mock(InputWaitStore.class), - new ObjectMapper() + new ObjectMapper(), + usageRecorder(), + new AiProperties() ); AgentStep step = new AgentStep(AgentType.CODE, Map.of("instruction", "수정")); LlmProviderException failure = new LlmProviderException( @@ -577,6 +602,77 @@ private AgentPlanExecutor executor(CodeAgentService codeService, return executor(codeService, mock(ChatAgentService.class), taskStore, messageService); } + // ── U8 8-6: 태스크당 토큰 예산 상한 ──────────────────────────────────────────────────── + + @Test + void tellsTheUserWhyTheTaskStoppedWhenItHitsTheTokenBudget() { + // 상한에 걸린 태스크가 조용히 멈추면 사용자에게는 "왜 안 되지" 로만 남는다. 사유가 + // 그대로 보여야 하고, catch-all 의 "작업 중 오류가 발생했습니다" 접두가 붙으면 안 된다. + CodeAgentService codeService = mock(CodeAgentService.class); + TaskStore taskStore = taskStore(); + AgentMessageService messageService = mock(AgentMessageService.class); + AgentPlanExecutor executor = executor(codeService, taskStore, messageService); + AgentStep step = new AgentStep(AgentType.CODE, Map.of("instruction", "수정")); + AgentTokenBudgetExceededException budgetExceeded = + new AgentTokenBudgetExceededException(1_004_200, 1_000_000); + when(codeService.execute(any(), any(), any(), any(), any(), any())).thenThrow(budgetExceeded); + + executor.execute(new AgentPlan(List.of(step), "reason", AiProvider.OPENAI, 11L), "task-1", 1L); + + verify(taskStore).markFailed("task-1", budgetExceeded.getMessage()); + verify(messageService).appendAssistant( + 21L, budgetExceeded.getMessage(), ChatMessageKind.TASK_FAILED, "task-1"); + assertThat(budgetExceeded.getMessage()) + .contains("AI 토큰 예산 상한") + .contains("1,004,200") + .contains("1,000,000"); + } + + @Test + void doesNotSendABudgetExceededTaskDownTheBuildFailureRetryPath() { + // 누적은 태스크 단위로 이어 세므로 재시도해도 첫 호출에서 같은 상한에 다시 걸린다 — + // 복구 경로로 흘리면 사용자는 원인이 안 보이는 실패를 두 번 보게 된다. + CodeAgentService codeService = mock(CodeAgentService.class); + TaskStore taskStore = taskStore(); + BuildFailureRecoveryService recovery = mock(BuildFailureRecoveryService.class); + AgentPlanExecutor executor = new AgentPlanExecutor( + codeService, + mock(DeployAgentService.class), + mock(DomainBindAgentService.class), + mock(ChatAgentService.class), + mock(InfraOpsAgentService.class), + mock(RuntimeSetupAgentService.class), + mock(BackendDeployAgentService.class), + taskStore, + mock(AgentMessageService.class), + recovery, + mock(ChangeService.class), + mock(ResultApprovalGate.class), + mock(RepositoryBindingGate.class), + mock(AgentExecutionRegistry.class), + mock(DecisionAgentService.class), + mock(InputWaitStore.class), + new ObjectMapper(), + usageRecorder(), + new AiProperties() + ); + AgentStep step = new AgentStep(AgentType.CODE, Map.of("instruction", "수정")); + when(codeService.execute(any(), any(), any(), any(), any(), any())) + .thenThrow(new AgentTokenBudgetExceededException(1_004_200, 1_000_000)); + + executor.execute(new AgentPlan(List.of(step), "reason", AiProvider.OPENAI, 11L), "task-1", 1L); + + verify(recovery, never()).handle(any(), any()); + } + + /** + * 진짜 recorder 를 쓴다. mock 은 {@code openTaskScope} 에서 null 을 돌려주고, 실행 진입점의 + * try-with-resources 가 그대로 NPE 가 된다 — 저장만 mock 으로 끊는다. + */ + private LlmUsageRecorder usageRecorder() { + return new LlmUsageRecorder(mock(LlmUsageStore.class)); + } + private AgentPlanExecutor executor(CodeAgentService codeService, ChatAgentService chatService, TaskStore taskStore, @@ -598,7 +694,9 @@ private AgentPlanExecutor executor(CodeAgentService codeService, mock(AgentExecutionRegistry.class), mock(DecisionAgentService.class), mock(InputWaitStore.class), - new ObjectMapper() + new ObjectMapper(), + usageRecorder(), + new AiProperties() ); } diff --git a/src/test/java/com/example/dvely/agent/application/service/ConversationWindowTest.java b/src/test/java/com/example/dvely/agent/application/service/ConversationWindowTest.java new file mode 100644 index 00000000..b3d23554 --- /dev/null +++ b/src/test/java/com/example/dvely/agent/application/service/ConversationWindowTest.java @@ -0,0 +1,77 @@ +package com.example.dvely.agent.application.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.example.dvely.agent.application.port.out.LlmMessage; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class ConversationWindowTest { + + @Test + void keepsTheMostRecentTurnsAndDropsTheOldest() { + List history = turns(50); + + List windowed = ConversationWindow.apply(history); + + assertThat(windowed).hasSize(ConversationWindow.MAX_TURNS); + assertThat(windowed.get(windowed.size() - 1).content()).isEqualTo("turn-49"); + assertThat(windowed.get(0).content()).isEqualTo("turn-30"); + } + + @Test + void alsoStopsOnTheCharacterBudgetSoOneHugeTurnCannotFillTheWindow() { + List history = new ArrayList<>(); + history.add(new LlmMessage("user", "x".repeat(30_000))); + history.add(new LlmMessage("assistant", "y".repeat(30_000))); + history.add(new LlmMessage("user", "지금 처리할 요청")); + + List windowed = ConversationWindow.apply(history); + + assertThat(windowed).hasSize(1); + assertThat(windowed.get(0).content()).isEqualTo("지금 처리할 요청"); + } + + @Test + void neverDropsTheLastTurnEvenWhenItAloneExceedsTheBudget() { + // 마지막 턴은 지금 처리할 요청 그 자체다. 버리면 무엇을 하라는 것인지가 사라진다. + List history = List.of( + new LlmMessage("user", "옛 요청"), + new LlmMessage("user", "z".repeat(ConversationWindow.MAX_CHARS * 2))); + + List windowed = ConversationWindow.apply(history); + + assertThat(windowed).hasSize(1); + assertThat(windowed.get(0).content()).hasSize(ConversationWindow.MAX_CHARS * 2); + } + + @Test + void keepsShortConversationsExactlyAsTheyWere() { + List history = turns(5); + + assertThat(ConversationWindow.apply(history)).isEqualTo(history); + } + + @Test + void preservesChronologicalOrder() { + List windowed = ConversationWindow.apply(turns(40), 3, 10_000); + + assertThat(windowed.stream().map(LlmMessage::content)) + .containsExactly("turn-37", "turn-38", "turn-39"); + } + + @Test + void handlesAnEmptyOrNullHistory() { + assertThat(ConversationWindow.apply(List.of())).isEmpty(); + assertThat(ConversationWindow.apply(null)).isEmpty(); + } + + private static List turns(int count) { + List history = new ArrayList<>(); + for (int i = 0; i < count; i++) { + history.add(new LlmMessage(i % 2 == 0 ? "user" : "assistant", "turn-" + i)); + } + return List.copyOf(history); + } +} diff --git a/src/test/java/com/example/dvely/agent/application/service/DecisionAgentLoggingTest.java b/src/test/java/com/example/dvely/agent/application/service/DecisionAgentLoggingTest.java new file mode 100644 index 00000000..6ba354c4 --- /dev/null +++ b/src/test/java/com/example/dvely/agent/application/service/DecisionAgentLoggingTest.java @@ -0,0 +1,117 @@ +package com.example.dvely.agent.application.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.example.dvely.agent.application.port.out.LlmMessage; +import com.example.dvely.agent.application.port.out.LlmPort; +import com.example.dvely.agent.domain.value.AiProvider; +import com.example.dvely.agent.infrastructure.llm.LlmRouter; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +/** + * U8 8-7: 계획 응답 원문을 INFO 로 통째로 찍던 자리를 막았는지 본다. + * + *

계획 JSON 에는 사용자가 무엇을 만들라고 했는지가 그대로 들어가고, 교정 재시도 로그에는 + * 모델이 쓴 응답이 통째로 들어간다 — 운영 로그 수집기로 사용자 요청과 생성 코드가 흘러나가는 + * 경로였다. 실제로 찍히는 것을 보는 것 말고 검증할 방법이 없어 로그를 가로챈다.

+ */ +class DecisionAgentLoggingTest { + + /** 로그에 절대 통째로 나오면 안 되는, 사용자 요청과 코드가 섞인 응답. */ + private static final String SECRET_LOOKING_PLAN = """ + {"reasoning":"사내 재고 관리 앱을 만든다", + "steps":[{"agentType":"CODE","parameters":{ + "instruction":"%s", + "userSummary":"재고 페이지를 만듭니다"}}]} + """.formatted("const INTERNAL_ENDPOINT = 'https://inventory.corp.example/api'; " + .repeat(20)); + + private final LlmRouter llmRouter = mock(LlmRouter.class); + private final LlmPort llmPort = mock(LlmPort.class); + private final ProjectDecisionContextResolver contextResolver = + mock(ProjectDecisionContextResolver.class); + private final DecisionAgentService service = + new DecisionAgentService(llmRouter, contextResolver); + + private Logger logger; + private ListAppender appender; + + @BeforeEach + void captureLogs() { + logger = (Logger) LoggerFactory.getLogger(DecisionAgentService.class); + logger.setLevel(Level.DEBUG); + appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + } + + @AfterEach + void releaseLogs() { + logger.detachAppender(appender); + } + + @Test + void neverLogsTheWholeResponseAtInfo() { + when(llmRouter.route(AiProvider.GLM)).thenReturn(llmPort); + when(llmPort.complete(any(), anyList(), any())).thenReturn(SECRET_LOOKING_PLAN); + + service.decide(List.of(new LlmMessage("user", "재고 앱 만들어줘")), AiProvider.GLM, null); + + List infoAndAbove = appender.list.stream() + .filter(event -> event.getLevel().isGreaterOrEqual(Level.INFO)) + .toList(); + + assertThat(infoAndAbove).isNotEmpty(); + assertThat(infoAndAbove).noneSatisfy(event -> + assertThat(event.getFormattedMessage()).contains("INTERNAL_ENDPOINT")); + // 대신 길이는 남는다 — 응답이 잘렸는지 같은 운영 질문에는 답할 수 있어야 한다. + assertThat(infoAndAbove).anySatisfy(event -> + assertThat(event.getFormattedMessage()) + .contains("rawLength=" + SECRET_LOOKING_PLAN.length())); + } + + @Test + void truncatesTheResponsePreviewEvenAtDebug() { + when(llmRouter.route(AiProvider.GLM)).thenReturn(llmPort); + when(llmPort.complete(any(), anyList(), any())).thenReturn(SECRET_LOOKING_PLAN); + + service.decide(List.of(new LlmMessage("user", "재고 앱 만들어줘")), AiProvider.GLM, null); + + assertThat(appender.list).anySatisfy(event -> { + assertThat(event.getLevel()).isEqualTo(Level.DEBUG); + assertThat(event.getFormattedMessage()) + .contains("자 중 앞부분") + .hasSizeLessThan(SECRET_LOOKING_PLAN.length()); + }); + } + + @Test + void alsoTruncatesTheFailedResponseEchoedInTheRepairRetryWarning() { + // 파싱 실패 경로가 원문을 가장 길게 찍던 곳이다 — 실패한 응답과 재시도 응답 둘 다. + when(llmRouter.route(AiProvider.GLM)).thenReturn(llmPort); + when(llmPort.complete(any(), anyList(), any())) + .thenReturn("이건 JSON 이 아니다 " + "INTERNAL_ENDPOINT ".repeat(50)); + + try { + service.decide(List.of(new LlmMessage("user", "재고 앱")), AiProvider.GLM, null); + } catch (RuntimeException expected) { + // 재시도까지 실패하면 던지는 것이 정상이다(조용한 CHAT 폴백은 이미 걷어냈다). + } + + assertThat(appender.list).filteredOn(event -> event.getLevel() == Level.WARN) + .isNotEmpty() + .allSatisfy(event -> assertThat(event.getFormattedMessage()).hasSizeLessThan(600)); + } +} diff --git a/src/test/java/com/example/dvely/agent/infrastructure/llm/AnthropicPromptCacheTest.java b/src/test/java/com/example/dvely/agent/infrastructure/llm/AnthropicPromptCacheTest.java new file mode 100644 index 00000000..50c44222 --- /dev/null +++ b/src/test/java/com/example/dvely/agent/infrastructure/llm/AnthropicPromptCacheTest.java @@ -0,0 +1,142 @@ +package com.example.dvely.agent.infrastructure.llm; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * 캐시 브레이크포인트는 조용히 틀린다 — 위치가 어긋나도 요청은 그대로 통과하고 캐시만 안 + * 걸린다. 오류가 나지 않으므로 위치를 못 박는 것 말고는 검증할 방법이 없다. + */ +class AnthropicPromptCacheTest { + + private static final Map EPHEMERAL = Map.of("type", "ephemeral"); + + @Test + void putsOneBreakpointOnTheSystemPrompt() { + List> blocks = AnthropicPromptCache.systemBlocks("시스템"); + + assertThat(blocks).hasSize(1); + assertThat(blocks.get(0)).containsEntry("type", "text") + .containsEntry("text", "시스템") + .containsEntry("cache_control", EPHEMERAL); + } + + @Test + void marksOnlyTheLastToolDefinition() { + // 도구 목록 전체가 하나의 캐시 단위다. 앞쪽 정의에도 마커를 달면 상한 4개를 도구만으로 + // 써 버려 정작 값을 하는 트랜스크립트 쪽 지점이 남지 않는다. + List> tools = AnthropicPromptCache.toolsWithCacheControl(List.of( + Map.of("name", "execute_command"), + Map.of("name", "write_file"), + Map.of("name", "read_file"))); + + assertThat(tools.get(0)).doesNotContainKey("cache_control"); + assertThat(tools.get(1)).doesNotContainKey("cache_control"); + assertThat(tools.get(2)).containsEntry("cache_control", EPHEMERAL); + } + + @Test + void marksTheLastTwoUserTurnsAndNothingElse() { + List> messages = AnthropicPromptCache.messagesWithRollingCacheControl(List.of( + userText("요청"), + assistantBlocks(), + toolResults("결과 1"), + assistantBlocks(), + toolResults("결과 2"))); + + assertThat(breakpointCount(messages)).isEqualTo(2); + assertThat(lastBlockOf(messages.get(4))).containsEntry("cache_control", EPHEMERAL); + assertThat(lastBlockOf(messages.get(2))).containsEntry("cache_control", EPHEMERAL); + assertThat(lastBlockOf(messages.get(0))).doesNotContainKey("cache_control"); + assertThat(messages.get(1)).doesNotContainKey("cache_control"); + } + + @Test + void keepsTotalBreakpointsWithinAnthropicsLimitOfFour() { + // tools 1 + system 1 + 굴러가는 user 2 = 4. 하나라도 늘면 요청 자체가 거절된다. + int tools = breakpointCount(AnthropicPromptCache.toolsWithCacheControl( + List.of(Map.of("name", "a"), Map.of("name", "b")))); + int system = AnthropicPromptCache.systemBlocks("긴 시스템 프롬프트").size(); + int rolling = breakpointCount(AnthropicPromptCache.messagesWithRollingCacheControl(List.of( + userText("1"), assistantBlocks(), toolResults("2"), + assistantBlocks(), toolResults("3"), assistantBlocks(), toolResults("4")))); + + assertThat(tools + system + rolling).isEqualTo(4); + } + + @Test + void normalizesEveryTextTurnToBlocksSoTheShapeNeverFlipsBetweenRounds() { + // 마커가 붙는 턴만 배열로 바꾸면, 같은 턴이 라운드마다 문자열↔배열로 오간다. 접두 일치에 + // 그런 흔들림을 남길 이유가 없다. + List> messages = AnthropicPromptCache.messagesWithRollingCacheControl(List.of( + userText("맨 앞"), assistantBlocks(), toolResults("1"), + assistantBlocks(), toolResults("2"), assistantBlocks(), toolResults("3"))); + + assertThat(messages.get(0).get("content")).isInstanceOf(List.class); + assertThat(lastBlockOf(messages.get(0))).doesNotContainKey("cache_control"); + } + + @Test + void leavesTheCallersTranscriptUntouched() { + // 호출부 트랜스크립트에 마커가 쌓이면 라운드마다 마커가 늘어 곧 상한 4개를 넘긴다. + List> original = new ArrayList<>(List.of(userText("요청"))); + Map before = original.get(0); + + AnthropicPromptCache.messagesWithRollingCacheControl(original); + + assertThat(original.get(0)).isSameAs(before); + assertThat(before.get("content")).isEqualTo("요청"); + } + + @Test + void spendsNoBreakpointOnAnEmptyTurn() { + List> messages = AnthropicPromptCache.messagesWithRollingCacheControl(List.of( + userText("실제 내용"), + assistantBlocks(), + Map.of("role", "user", "content", List.of()))); + + assertThat(breakpointCount(messages)).isEqualTo(1); + assertThat(lastBlockOf(messages.get(0))).containsEntry("cache_control", EPHEMERAL); + } + + private static Map userText(String text) { + return Map.of("role", "user", "content", text); + } + + private static Map assistantBlocks() { + return Map.of("role", "assistant", "content", List.of(Map.of("type", "text", "text", "ok"))); + } + + private static Map toolResults(String content) { + return Map.of("role", "user", "content", + List.of(Map.of("type", "tool_result", "tool_use_id", "t", "content", content))); + } + + @SuppressWarnings("unchecked") + private static Map lastBlockOf(Map message) { + List> blocks = (List>) message.get("content"); + return blocks.get(blocks.size() - 1); + } + + @SuppressWarnings("unchecked") + private static int breakpointCount(List> items) { + int count = 0; + for (Map item : items) { + if (item.containsKey("cache_control")) { + count++; + } + if (item.get("content") instanceof List blocks) { + for (Object block : blocks) { + if (block instanceof Map map && map.containsKey("cache_control")) { + count++; + } + } + } + } + return count; + } +} diff --git a/src/test/java/com/example/dvely/agent/infrastructure/llm/AnthropicRequestBodyTest.java b/src/test/java/com/example/dvely/agent/infrastructure/llm/AnthropicRequestBodyTest.java new file mode 100644 index 00000000..d808b077 --- /dev/null +++ b/src/test/java/com/example/dvely/agent/infrastructure/llm/AnthropicRequestBodyTest.java @@ -0,0 +1,333 @@ +package com.example.dvely.agent.infrastructure.llm; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import com.example.dvely.agent.application.port.out.LlmMessage; +import com.example.dvely.agent.application.port.out.LlmToolResponse; +import com.example.dvely.agent.application.port.out.ToolDefinition; +import com.example.dvely.agent.application.service.ConversationWindow; +import com.example.dvely.agent.domain.value.AiModelOptions; +import com.example.dvely.agent.domain.value.LlmUsage; +import com.example.dvely.agent.infrastructure.config.AiProperties; +import com.example.dvely.agent.infrastructure.usage.LlmUsageRecorder; +import com.example.dvely.agent.infrastructure.usage.LlmUsageStore; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * 실제로 나가는 요청 본문을 본다. + * + *

이 단위에서 바꾼 것들(캐시 브레이크포인트 위치, 대화 윈도우, {@code max_tokens})은 전부 + * 조용히 틀리는 종류다 — 위치가 어긋나거나 윈도우가 안 걸려도 요청은 그대로 200 으로 + * 통과하고, 달라지는 것은 청구서뿐이다. 그래서 목 서버를 세워 본문을 직접 읽는다.

+ * + *

토큰 절감 폭도 여기서 잰다. 목 응답의 토큰 수는 우리가 적은 값이라 그것으로는 아무것도 + * 증명하지 못하므로, 나가는 본문의 실제 바이트를 센다.

+ */ +class AnthropicRequestBodyTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** CodeAgentService 의 도구 정의와 같은 모양(개수와 순서만 같으면 배치 검증에는 충분하다). */ + private static final List TOOLS = List.of( + new ToolDefinition("execute_command", "Execute a shell command.", + Map.of("type", "object", "properties", Map.of("command", Map.of("type", "string")))), + new ToolDefinition("write_file", "Write a file.", + Map.of("type", "object", "properties", Map.of("path", Map.of("type", "string")))), + new ToolDefinition("read_file", "Read a file.", + Map.of("type", "object", "properties", Map.of("path", Map.of("type", "string"))))); + + private HttpServer server; + private final List capturedBodies = new CopyOnWriteArrayList<>(); + private AiProperties aiProperties; + private LlmUsageRecorder recorder; + + @BeforeEach + void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/v1/messages", exchange -> { + capturedBodies.add(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + byte[] response = cannedResponse().getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, response.length); + exchange.getResponseBody().write(response); + exchange.close(); + }); + server.start(); + + aiProperties = new AiProperties(); + aiProperties.getAnthropic().setApiKey("test-key-not-a-real-credential"); + aiProperties.getAnthropic().setBaseUrl( + "http://127.0.0.1:" + server.getAddress().getPort() + "/v1/messages"); + recorder = new LlmUsageRecorder(mock(LlmUsageStore.class)); + } + + @AfterEach + void stopServer() { + server.stop(0); + } + + // ── 8-2 캐시 브레이크포인트 ──────────────────────────────────────────────── + + @Test + void sendsExactlyFourCacheBreakpointsInTheDocumentedPlaces() throws Exception { + new ClaudeToolClient(aiProperties, recorder) + .completeWithTools("SYSTEM", transcriptAfterRounds(3), TOOLS, AiModelOptions.defaults()); + + Map body = lastBody(); + assertThat(countBreakpoints(body)).isEqualTo(4); + assertThat(lastOf(list(body, "tools"))).containsKey("cache_control"); + assertThat(lastOf(list(body, "system"))).containsKey("cache_control"); + // 3 라운드 → messages 7개(요청 1 + 라운드마다 assistant/tool_result 2). user 턴은 + // 0·2·4·6 이고, 굴러가는 두 지점은 그중 마지막 둘이다. + assertThat(markedUserTurnIndexes(body)).containsExactly(4, 6); + } + + @Test + void keepsTheStablePrefixByteIdenticalAsTheTranscriptGrows() throws Exception { + // 캐싱은 접두 일치다. 접두가 라운드마다 한 바이트라도 달라지면 마커를 아무리 잘 놓아도 + // 적중률은 0 이다. tools 와 system 은 상수이므로 직렬화 결과가 같아야 한다. + ClaudeToolClient client = new ClaudeToolClient(aiProperties, recorder); + for (int round = 1; round <= 5; round++) { + client.completeWithTools("SYSTEM", transcriptAfterRounds(round), TOOLS, AiModelOptions.defaults()); + } + + List prefixes = new ArrayList<>(); + for (String raw : capturedBodies) { + Map body = MAPPER.readValue(raw, Map.class); + prefixes.add(MAPPER.writeValueAsString(body.get("tools")) + + MAPPER.writeValueAsString(body.get("system")) + + body.get("model")); + } + + assertThat(prefixes).containsOnly(prefixes.get(0)); + } + + @Test + void growsOnlyByAppendingSoEachRoundsPrefixIsThePreviousRoundsRequest() throws Exception { + // 마커가 값을 하려면 접두가 "덧붙기만" 해야 한다. 앞부분을 다시 쓰는 순간 캐시는 깨진다. + ClaudeToolClient client = new ClaudeToolClient(aiProperties, recorder); + for (int round = 1; round <= 4; round++) { + client.completeWithTools("SYSTEM", transcriptAfterRounds(round), TOOLS, AiModelOptions.defaults()); + } + + for (int i = 1; i < capturedBodies.size(); i++) { + List previous = list(MAPPER.readValue(capturedBodies.get(i - 1), Map.class), "messages"); + List current = list(MAPPER.readValue(capturedBodies.get(i), Map.class), "messages"); + + assertThat(current.size()).isGreaterThan(previous.size()); + // cache_control 마커 자리만 굴러가고 내용은 그대로여야 한다. + assertThat(stripCacheControl(current.subList(0, previous.size()))) + .isEqualTo(stripCacheControl(previous)); + } + } + + /** + * 캐시가 덮는 몫을 실제 바이트로 잰다. + * + *

라운드 N+1 의 접두는 라운드 N 이 마커로 닫아 둔 지점까지다. 그 지점 이후만 새 입력이고 + * 나머지는 캐시 읽기가 되므로, 이 비율이 곧 이 변경이 건드리는 몫이다.

+ */ + @Test + void measuresHowMuchOfEachRoundIsAlreadyBehindABreakpoint() throws Exception { + ClaudeToolClient client = new ClaudeToolClient(aiProperties, recorder); + int rounds = 10; + for (int round = 1; round <= rounds; round++) { + client.completeWithTools("SYSTEM", transcriptAfterRounds(round), TOOLS, AiModelOptions.defaults()); + } + + long totalSent = 0; + long behindBreakpoint = 0; + for (int i = 0; i < capturedBodies.size(); i++) { + long size = capturedBodies.get(i).getBytes(StandardCharsets.UTF_8).length; + totalSent += size; + if (i > 0) { + // 직전 라운드 요청 전체가 이번 라운드의 접두다(내용이 덧붙기만 하므로). + behindBreakpoint += capturedBodies.get(i - 1).getBytes(StandardCharsets.UTF_8).length; + } + } + + System.out.printf("[U8 8-2] %d 라운드 전송 %,d bytes 중 브레이크포인트 뒤 접두 %,d bytes (%.1f%%)%n", + rounds, totalSent, behindBreakpoint, 100.0 * behindBreakpoint / totalSent); + + // 라운드가 쌓일수록 새 내용보다 재전송이 압도적으로 많다 — 캐싱이 겨냥하는 것이 이 몫이다. + assertThat(behindBreakpoint).isGreaterThan(totalSent / 2); + } + + // ── 8-4 max_tokens ──────────────────────────────────────────────────────── + + @Test + void sendsTheToolLoopsOwnOutputBudget() throws Exception { + new ClaudeToolClient(aiProperties, recorder) + .completeWithTools("SYSTEM", transcriptAfterRounds(1), TOOLS, AiModelOptions.defaults()); + + assertThat(lastBody().get("max_tokens")).isEqualTo(8_192); + } + + @Test + void sendsTheConfiguredCompletionBudgetForPlainCompletions() throws Exception { + // 1024 였을 때 다단계 계획 JSON 이 잘렸고, 그때마다 교정 재시도가 전체 컨텍스트를 한 번 더 + // 보냈다. 설정 가능해야 운영에서 재배포 없이 올릴 수 있다. + aiProperties.setCompletionMaxTokens(2_048); + + new ClaudeClient(aiProperties, recorder) + .complete("SYSTEM", List.of(new LlmMessage("user", "안녕")), AiModelOptions.defaults()); + + assertThat(lastBody().get("max_tokens")).isEqualTo(2_048); + } + + // ── 8-3 대화 윈도우 ─────────────────────────────────────────────────────── + + @Test + void windowedHistorySendsDramaticallyFewerBytesThanTheWholeConversation() throws Exception { + List whole = longConversation(120); + List windowed = ConversationWindow.apply(whole); + ClaudeClient client = new ClaudeClient(aiProperties, recorder); + + client.complete("SYSTEM", whole, AiModelOptions.defaults()); + long wholeBytes = capturedBodies.get(0).getBytes(StandardCharsets.UTF_8).length; + + client.complete("SYSTEM", windowed, AiModelOptions.defaults()); + long windowedBytes = capturedBodies.get(1).getBytes(StandardCharsets.UTF_8).length; + + System.out.printf("[U8 8-3] 120턴 대화 요청 본문: 전량 %,d bytes → 윈도우 %,d bytes (%.1f%% 감소)%n", + wholeBytes, windowedBytes, 100.0 * (wholeBytes - windowedBytes) / wholeBytes); + + assertThat(windowedBytes).isLessThan(wholeBytes / 4); + // 지금 처리할 요청(마지막 턴)은 반드시 남아 있어야 한다. + assertThat(capturedBodies.get(1)).contains("turn-119"); + } + + // ── 8-1 사용량 집계 ─────────────────────────────────────────────────────── + + @Test + void reportsTheUsageOfEveryRoundSoTotalsCanBeCompared() throws Exception { + ClaudeToolClient client = new ClaudeToolClient(aiProperties, recorder); + LlmUsage total = LlmUsage.NONE; + for (int round = 1; round <= 10; round++) { + LlmToolResponse response = client.completeWithTools( + "SYSTEM", transcriptAfterRounds(round), TOOLS, AiModelOptions.defaults()); + total = total.plus(response.usage()); + } + + // 목 응답 한 건: input 1000 / output 200 / cache write 4000 / cache read 12000. + assertThat(total.inputTokens()).isEqualTo(10_000); + assertThat(total.outputTokens()).isEqualTo(2_000); + assertThat(total.cacheCreationInputTokens()).isEqualTo(40_000); + assertThat(total.cacheReadInputTokens()).isEqualTo(120_000); + assertThat(total.totalTokens()).isEqualTo(172_000); + } + + // ── 도우미 ──────────────────────────────────────────────────────────────── + + /** CodeAgentService 의 Claude 루프가 N 라운드 뒤 갖게 되는 트랜스크립트와 같은 모양. */ + private static List> transcriptAfterRounds(int rounds) { + List> messages = new ArrayList<>(); + messages.add(Map.of("role", "user", "content", "할 일 앱을 만들어줘")); + for (int i = 0; i < rounds; i++) { + messages.add(Map.of("role", "assistant", "content", + List.of(Map.of("type", "tool_use", "id", "call-" + i, "name", "execute_command", + "input", Map.of("command", "ls /workspace"))))); + messages.add(Map.of("role", "user", "content", + List.of(Map.of("type", "tool_result", "tool_use_id", "call-" + i, + "content", "출력 ".repeat(200) + i)))); + } + return messages; + } + + private static List longConversation(int turns) { + List history = new ArrayList<>(); + for (int i = 0; i < turns; i++) { + history.add(new LlmMessage( + i % 2 == 0 ? "user" : "assistant", + "turn-" + i + " " + "내용 ".repeat(40))); + } + return List.copyOf(history); + } + + private static String cannedResponse() { + return """ + {"id":"msg_1","model":"claude-opus-4-5-20251101","stop_reason":"end_turn", + "content":[{"type":"text","text":"완료"}], + "usage":{"input_tokens":1000,"output_tokens":200, + "cache_creation_input_tokens":4000,"cache_read_input_tokens":12000}} + """; + } + + @SuppressWarnings("unchecked") + private Map lastBody() throws Exception { + return MAPPER.readValue(capturedBodies.get(capturedBodies.size() - 1), Map.class); + } + + @SuppressWarnings("unchecked") + private static List list(Map body, String key) { + return (List) body.get(key); + } + + @SuppressWarnings("unchecked") + private static Map lastOf(List items) { + return (Map) items.get(items.size() - 1); + } + + @SuppressWarnings("unchecked") + private static List markedUserTurnIndexes(Map body) { + List marked = new ArrayList<>(); + List messages = list(body, "messages"); + for (int i = 0; i < messages.size(); i++) { + Map message = (Map) messages.get(i); + if (message.get("content") instanceof List blocks && !blocks.isEmpty() + && blocks.get(blocks.size() - 1) instanceof Map block + && block.containsKey("cache_control")) { + marked.add(i); + } + } + return marked; + } + + private static int countBreakpoints(Object node) { + if (node instanceof Map map) { + int count = map.containsKey("cache_control") ? 1 : 0; + for (Map.Entry entry : map.entrySet()) { + if (!"cache_control".equals(entry.getKey())) { + count += countBreakpoints(entry.getValue()); + } + } + return count; + } + if (node instanceof List items) { + int count = 0; + for (Object item : items) { + count += countBreakpoints(item); + } + return count; + } + return 0; + } + + private static Object stripCacheControl(Object node) { + if (node instanceof Map map) { + Map copy = new java.util.LinkedHashMap<>(); + map.forEach((key, value) -> { + if (!"cache_control".equals(key)) { + copy.put(key, stripCacheControl(value)); + } + }); + return copy; + } + if (node instanceof List items) { + return items.stream().map(AnthropicRequestBodyTest::stripCacheControl).toList(); + } + return node; + } +} diff --git a/src/test/java/com/example/dvely/agent/infrastructure/llm/LlmApiKeyIsolationTest.java b/src/test/java/com/example/dvely/agent/infrastructure/llm/LlmApiKeyIsolationTest.java index eeb76673..e4884c5b 100644 --- a/src/test/java/com/example/dvely/agent/infrastructure/llm/LlmApiKeyIsolationTest.java +++ b/src/test/java/com/example/dvely/agent/infrastructure/llm/LlmApiKeyIsolationTest.java @@ -1,7 +1,11 @@ package com.example.dvely.agent.infrastructure.llm; +import static org.mockito.Mockito.mock; + import static org.assertj.core.api.Assertions.assertThat; +import com.example.dvely.agent.infrastructure.usage.LlmUsageRecorder; +import com.example.dvely.agent.infrastructure.usage.LlmUsageStore; import com.example.dvely.agent.application.port.out.LlmMessage; import com.example.dvely.agent.domain.value.AiModelOptions; import com.example.dvely.agent.infrastructure.config.AiProperties; @@ -67,7 +71,7 @@ private String endpointUrl() { void 키를_바꾸면_다음_요청은_바뀐_키로_나간다() { AiProperties properties = new AiProperties(); properties.getGlm().setBaseUrl(endpointUrl()); - GlmClient client = new GlmClient(properties); + GlmClient client = new GlmClient(properties, new LlmUsageRecorder(mock(LlmUsageStore.class))); properties.getGlm().setApiKey("key-of-user-a"); client.complete("system", List.of(new LlmMessage("user", "hi")), AiModelOptions.defaults()); diff --git a/src/test/java/com/example/dvely/agent/infrastructure/llm/LlmUsageParserTest.java b/src/test/java/com/example/dvely/agent/infrastructure/llm/LlmUsageParserTest.java new file mode 100644 index 00000000..5a70cd67 --- /dev/null +++ b/src/test/java/com/example/dvely/agent/infrastructure/llm/LlmUsageParserTest.java @@ -0,0 +1,68 @@ +package com.example.dvely.agent.infrastructure.llm; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.example.dvely.agent.domain.value.LlmUsage; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class LlmUsageParserTest { + + @Test + void readsAnthropicsFourTokenCounts() { + LlmUsage usage = LlmUsageParser.anthropic(Map.of("usage", Map.of( + "input_tokens", 120, + "output_tokens", 45, + "cache_creation_input_tokens", 4_000, + "cache_read_input_tokens", 12_000))); + + assertThat(usage.inputTokens()).isEqualTo(120); + assertThat(usage.outputTokens()).isEqualTo(45); + assertThat(usage.cacheCreationInputTokens()).isEqualTo(4_000); + assertThat(usage.cacheReadInputTokens()).isEqualTo(12_000); + assertThat(usage.billedInputTokens()).isEqualTo(16_120); + assertThat(usage.totalTokens()).isEqualTo(16_165); + } + + @Test + void subtractsCachedTokensFromOpenAisPromptTokens() { + // prompt_tokens 는 캐시 적중분을 포함한 수이고 Anthropic 의 input_tokens 는 제외한 수다. + // 그대로 담으면 같은 이름의 칸에 다른 뜻이 섞여 제공자 간 합계가 어긋난다. + LlmUsage usage = LlmUsageParser.openAiCompatible(Map.of("usage", Map.of( + "prompt_tokens", 10_000, + "completion_tokens", 300, + "prompt_tokens_details", Map.of("cached_tokens", 8_000)))); + + assertThat(usage.inputTokens()).isEqualTo(2_000); + assertThat(usage.cacheReadInputTokens()).isEqualTo(8_000); + assertThat(usage.billedInputTokens()).isEqualTo(10_000); + assertThat(usage.totalTokens()).isEqualTo(10_300); + } + + @Test + void treatsAMissingUsageBlockAsZeroRatherThanFailing() { + // 계측이 던지면 이미 성공한 LLM 호출이 통째로 실패한다. + assertThat(LlmUsageParser.anthropic(Map.of())).isEqualTo(LlmUsage.NONE); + assertThat(LlmUsageParser.openAiCompatible(Map.of())).isEqualTo(LlmUsage.NONE); + assertThat(LlmUsageParser.anthropic(Map.of("usage", "이건 객체가 아니다"))).isEqualTo(LlmUsage.NONE); + } + + @Test + void survivesCachedTokensLargerThanPromptTokens() { + // 제공자가 어긋난 수를 줘도 입력이 음수가 되어 합계를 오염시키면 안 된다. + LlmUsage usage = LlmUsageParser.openAiCompatible(Map.of("usage", Map.of( + "prompt_tokens", 100, + "prompt_tokens_details", Map.of("cached_tokens", 999)))); + + assertThat(usage.inputTokens()).isZero(); + } + + @Test + void sumsAcrossCalls() { + LlmUsage total = LlmUsage.NONE + .plus(new LlmUsage(10, 1, 0, 0)) + .plus(new LlmUsage(0, 2, 0, 500)); + + assertThat(total.totalTokens()).isEqualTo(513); + } +} diff --git a/src/test/java/com/example/dvely/agent/infrastructure/usage/LlmUsageBudgetTest.java b/src/test/java/com/example/dvely/agent/infrastructure/usage/LlmUsageBudgetTest.java new file mode 100644 index 00000000..0a24fa25 --- /dev/null +++ b/src/test/java/com/example/dvely/agent/infrastructure/usage/LlmUsageBudgetTest.java @@ -0,0 +1,112 @@ +package com.example.dvely.agent.infrastructure.usage; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.example.dvely.agent.application.exception.AgentTokenBudgetExceededException; +import com.example.dvely.agent.domain.value.AiProvider; +import com.example.dvely.agent.domain.value.LlmUsage; +import org.junit.jupiter.api.Test; + +class LlmUsageBudgetTest { + + private final LlmUsageStore store = mock(LlmUsageStore.class); + private final LlmUsageRecorder recorder = new LlmUsageRecorder(store); + + @Test + void stopsTheTaskOnceItsCumulativeTokensPassTheBudget() { + try (LlmUsageScope scope = recorder.openTaskScope("task-1", 1L, 11L, 1_000)) { + recorder.record(AiProvider.GLM, "z-ai/glm-4.6", new LlmUsage(400, 100, 0, 0)); + assertThat(scope.usedTokens()).isEqualTo(500); + + assertThatThrownBy(() -> + recorder.record(AiProvider.GLM, "z-ai/glm-4.6", new LlmUsage(600, 50, 0, 0))) + .isInstanceOf(AgentTokenBudgetExceededException.class) + .hasMessageContaining("AI 토큰 예산 상한"); + } + } + + @Test + void stillRecordsTheCallThatBustTheBudget() { + // 상한을 터뜨린 호출도 이미 과금됐다. 기록에서 빠지면 사용자에게 보이는 수치가 실제 + // 청구와 어긋난다. + try (LlmUsageScope ignored = recorder.openTaskScope("task-1", 1L, 11L, 100)) { + assertThatThrownBy(() -> + recorder.record(AiProvider.ANTHROPIC, "claude", new LlmUsage(500, 0, 0, 0))) + .isInstanceOf(AgentTokenBudgetExceededException.class); + } + + verify(store).save(eq("task-1"), eq(1L), eq(11L), eq(LlmUsagePhase.AGENT_RUN), + eq("ANTHROPIC"), eq("claude"), any(LlmUsage.class)); + } + + @Test + void carriesThePreviousAttemptsTokensSoARetryCannotResetTheBudget() { + // 실행마다 0 에서 다시 세면 상한이 태스크 재시도 횟수만큼 곱해진다 — 닫으려던 곱셈이 + // 그대로 남는다. + when(store.tokensAlreadyUsedBy("task-1")).thenReturn(990L); + + try (LlmUsageScope scope = recorder.openTaskScope("task-1", 1L, 11L, 1_000)) { + assertThat(scope.usedTokens()).isEqualTo(990); + assertThatThrownBy(() -> + recorder.record(AiProvider.GLM, "glm", new LlmUsage(20, 0, 0, 0))) + .isInstanceOf(AgentTokenBudgetExceededException.class); + } + } + + @Test + void doesNotQueryPreviousUsageWhenTheBudgetIsDisabled() { + try (LlmUsageScope scope = recorder.openTaskScope("task-1", 1L, 11L, 0)) { + recorder.record(AiProvider.GLM, "glm", new LlmUsage(9_999_999, 0, 0, 0)); + assertThat(scope.usedTokens()).isEqualTo(9_999_999); + } + + verify(store, never()).tokensAlreadyUsedBy(anyString()); + } + + @Test + void attributesCallsMadeWithNoOpenScopeRatherThanDroppingThem() { + // 귀속이 없어도 합계에서 사라지면 안 된다 — "합계가 맞는가" 를 의심하게 만드는 순간 + // 계측 전체가 쓸모없어진다. + recorder.record(AiProvider.OPENAI, "gpt-4o", new LlmUsage(10, 5, 0, 0)); + + verify(store).save(eq(null), eq(null), eq(null), eq(LlmUsagePhase.UNSCOPED), + eq("OPENAI"), eq("gpt-4o"), any(LlmUsage.class)); + } + + @Test + void recordsNothingWhenTheProviderReturnedNoUsage() { + recorder.record(AiProvider.OPENAI, "gpt-4o", LlmUsage.NONE); + + verify(store, never()).save(any(), any(), any(), any(), anyString(), anyString(), any()); + } + + @Test + void restoresThePreviousScopeOnClose() { + try (LlmUsageScope outer = LlmUsageScope.open("outer", 1L, 11L, LlmUsagePhase.DECISION)) { + try (LlmUsageScope inner = + LlmUsageScope.open("inner", 1L, 11L, LlmUsagePhase.AGENT_RUN)) { + assertThat(LlmUsageScope.current()).isSameAs(inner); + } + assertThat(LlmUsageScope.current()).isSameAs(outer); + } + assertThat(LlmUsageScope.current()).isNull(); + } + + @Test + void neverLeavesAScopeBehindForTheNextTaskOnTheSameThread() { + // 스레드풀이 스레드를 재사용하므로, 닫히지 않은 스코프는 남의 작업 토큰을 엉뚱한 태스크에 + // 붙인다. + try (LlmUsageScope ignored = recorder.openTaskScope("task-1", 1L, 11L, 10_000)) { + assertThat(LlmUsageScope.current()).isNotNull(); + } + assertThat(LlmUsageScope.current()).isNull(); + } +} diff --git a/src/test/java/com/example/dvely/agent/usage/LlmUsageSchemaTest.java b/src/test/java/com/example/dvely/agent/usage/LlmUsageSchemaTest.java new file mode 100644 index 00000000..313d62ba --- /dev/null +++ b/src/test/java/com/example/dvely/agent/usage/LlmUsageSchemaTest.java @@ -0,0 +1,108 @@ +package com.example.dvely.agent.usage; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.example.dvely.agent.domain.value.LlmUsage; +import com.example.dvely.agent.infrastructure.persistence.entity.LlmUsageEntity; +import com.example.dvely.agent.infrastructure.persistence.repository.SpringDataLlmUsageRepository; +import com.example.dvely.agent.infrastructure.usage.LlmUsagePhase; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * {@code ddl-auto: validate} 는 nullable 여부도 FK 부재도 보지 않는다. V63 이 설계대로 내려앉았는지는 + * 실제 스키마를 직접 물어보는 것 말고 확인할 방법이 없다. + * + *

이 단위의 완료 기준("같은 시나리오의 전/후 토큰 합계를 비교할 수 있다")이 실제로 성립하는지도 + * 여기서 확인한다 — 행을 쓰고 태스크 단위로 합산해 본다.

+ */ +@SpringBootTest +class LlmUsageSchemaTest { + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private SpringDataLlmUsageRepository repository; + + @Test + void v63MigrationApplied() { + String applied = jdbcTemplate.queryForObject( + "select coalesce(max(success), 0) from flyway_schema_history where version = '63'", + String.class); + + assertThat("1".equals(applied) || "true".equalsIgnoreCase(applied)).isTrue(); + } + + @Test + void attributionColumnsAreNullableSoACallOutsideAnyTaskIsStillCounted() { + // 배포 실패 분석처럼 태스크 없이 도는 호출이 있다. NOT NULL 이면 그 호출은 기록될 수 + // 없고, 합계가 조용히 작아진다. + Map nullability = jdbcTemplate.queryForList( + """ + select column_name, is_nullable + from information_schema.columns + where table_schema = database() and table_name = 'llm_usage' + """) + .stream() + .collect(Collectors.toMap( + row -> String.valueOf(row.get("COLUMN_NAME")).toLowerCase(), + row -> String.valueOf(row.get("IS_NULLABLE")).toUpperCase())); + + assertThat(nullability).containsEntry("task_id", "YES") + .containsEntry("user_id", "YES") + .containsEntry("project_id", "YES") + .containsEntry("phase", "NO") + .containsEntry("provider", "NO") + .containsEntry("input_tokens", "NO") + .containsEntry("cache_read_input_tokens", "NO"); + } + + @Test + void hasNoForeignKeysSoItNeverJoinsAnotherTablesLockGraph() { + // agent_runs 는 보존 정책으로 삭제된다. FK 가 있으면 비용 이력이 함께 사라지고, 계측 + // INSERT 가 실행 중인 태스크 행의 잠금을 기다리게 된다. + Integer foreignKeys = jdbcTemplate.queryForObject( + """ + select count(*) + from information_schema.table_constraints + where table_schema = database() + and table_name = 'llm_usage' + and constraint_type = 'FOREIGN KEY' + """, + Integer.class); + + assertThat(foreignKeys).isZero(); + } + + @Test + void sumsATasksTokensAcrossEveryRoundItSpent() { + String taskId = "usage-schema-test-" + System.nanoTime(); + repository.save(new LlmUsageEntity(taskId, 1L, 11L, LlmUsagePhase.DECISION, + "GLM", "z-ai/glm-4.6", new LlmUsage(3_000, 400, 0, 0))); + repository.save(new LlmUsageEntity(taskId, 1L, 11L, LlmUsagePhase.AGENT_RUN, + "ANTHROPIC", "claude-opus-4-5-20251101", new LlmUsage(1_000, 200, 4_000, 12_000))); + repository.save(new LlmUsageEntity(taskId, 1L, 11L, LlmUsagePhase.AGENT_RUN, + "ANTHROPIC", "claude-opus-4-5-20251101", new LlmUsage(900, 150, 0, 16_000))); + + List rounds = repository.findAllByTaskIdOrderByIdAsc(taskId); + + assertThat(rounds).hasSize(3); + // 3,400 + 17,200 + 17,050 — 이 합계를 전/후로 비교하는 것이 이 단위의 완료 기준이다. + assertThat(repository.sumTotalTokensByTaskId(taskId)).isEqualTo(37_650); + // 라운드별로 남기 때문에 "캐시가 실제로 살아 있는가" 도 읽을 수 있다. + assertThat(rounds.get(2).getCacheReadInputTokens()).isEqualTo(16_000); + + repository.deleteAll(rounds); + } + + @Test + void countsZeroForATaskThatHasNotSpentAnything() { + assertThat(repository.sumTotalTokensByTaskId("task-that-never-ran")).isZero(); + } +} diff --git a/src/test/java/com/example/dvely/deployment/application/service/DeploymentFailureAnalysisServiceTest.java b/src/test/java/com/example/dvely/deployment/application/service/DeploymentFailureAnalysisServiceTest.java index 821257f3..7ecc0ba7 100644 --- a/src/test/java/com/example/dvely/deployment/application/service/DeploymentFailureAnalysisServiceTest.java +++ b/src/test/java/com/example/dvely/deployment/application/service/DeploymentFailureAnalysisServiceTest.java @@ -124,8 +124,8 @@ void analyzeSucceedsWithLlmJsonAndRecordsProviderAndModel() { when(githubActionsPort.getJobLogs("user-token", "octo/repo", 901L)).thenReturn( new GithubActionsPort.DeploymentLogs(901L, List.of(), "npm ERR! missing script: build") ); - when(llmRouter.route(AiProvider.ANTHROPIC)).thenReturn(llmPort); - when(llmPort.complete(any(), anyList())).thenReturn( + when(llmRouter.route(aiProperties.failureAnalysisProvider())).thenReturn(llmPort); + when(llmPort.complete(any(), anyList(), any())).thenReturn( "{\"summary\": \"빌드 스크립트가 없습니다.\", \"suggestedFix\": \"package.json에 build 스크립트를 추가하세요.\"}" ); when(analysisRepository.save(any(DeploymentFailureAnalysis.class))) @@ -139,6 +139,54 @@ void analyzeSucceedsWithLlmJsonAndRecordsProviderAndModel() { verifyNoInteractions(buildFailureAnalyzer); } + // ── U8 8-5: 제공자·모델을 코드에 박지 않는다 ────────────────────────────────────────── + + @Test + void followsTheDeploymentsDefaultProviderInsteadOfPinningAnthropic() { + // 이 한 경로만 ANTHROPIC + 그 제공자의 최상위 모델로 박혀 있었다. 12,000자 로그를 한 번 + // 요약하는 데 쓸 근거가 없었고, default-provider(GLM)와도 어긋났다. + stubLlmAnalysis("{\"summary\":\"요약\",\"suggestedFix\":\"수정\"}"); + ArgumentCaptor saved = + ArgumentCaptor.forClass(DeploymentFailureAnalysis.class); + + service.analyze(1L, 51L); + + verify(analysisRepository).save(saved.capture()); + assertThat(saved.getValue().getProvider()).isEqualTo("GLM"); + assertThat(saved.getValue().getModel()).isEqualTo("z-ai/glm-4.6"); + verify(llmRouter).route(AiProvider.GLM); + } + + @Test + void canBePinnedBackToAnthropicByConfigurationWhenAnalysisQualityDrops() { + // 품질이 떨어지면 실패 분석 자체가 쓸모없어진다. 되돌리는 길은 재배포가 아니라 설정이다. + aiProperties.getFailureAnalysis().setProvider(AiProvider.ANTHROPIC); + aiProperties.getFailureAnalysis().setModel("claude-opus-4-5-20251101"); + stubLlmAnalysis("{\"summary\":\"요약\",\"suggestedFix\":\"수정\"}"); + ArgumentCaptor saved = + ArgumentCaptor.forClass(DeploymentFailureAnalysis.class); + + service.analyze(1L, 51L); + + verify(analysisRepository).save(saved.capture()); + assertThat(saved.getValue().getProvider()).isEqualTo("ANTHROPIC"); + assertThat(saved.getValue().getModel()).isEqualTo("claude-opus-4-5-20251101"); + verify(llmRouter).route(AiProvider.ANTHROPIC); + } + + private void stubLlmAnalysis(String json) { + stubOwnedHistory(failedHistoryWithRunId()); + when(analysisRepository.findByHistoryId(51L)).thenReturn(Optional.empty()); + when(userRepository.findById(1L)).thenReturn(Optional.of(activeUser())); + when(githubActionsPort.getJobLogs("user-token", "octo/repo", 901L)).thenReturn( + new GithubActionsPort.DeploymentLogs(901L, List.of(), "npm ERR! build failed") + ); + when(llmRouter.route(aiProperties.failureAnalysisProvider())).thenReturn(llmPort); + when(llmPort.complete(any(), anyList(), any())).thenReturn(json); + when(analysisRepository.save(any(DeploymentFailureAnalysis.class))) + .thenAnswer(invocation -> withId(invocation.getArgument(0), 1L)); + } + @Test void analyzeFallsBackToRuleBasedWhenLlmThrows() { stubOwnedHistory(failedHistoryWithRunId()); @@ -147,8 +195,8 @@ void analyzeFallsBackToRuleBasedWhenLlmThrows() { when(githubActionsPort.getJobLogs("user-token", "octo/repo", 901L)).thenReturn( new GithubActionsPort.DeploymentLogs(901L, List.of(), "cannot find module 'react'") ); - when(llmRouter.route(AiProvider.ANTHROPIC)).thenReturn(llmPort); - when(llmPort.complete(any(), anyList())).thenThrow(new IllegalStateException("Claude API 응답이 비어있습니다")); + when(llmRouter.route(aiProperties.failureAnalysisProvider())).thenReturn(llmPort); + when(llmPort.complete(any(), anyList(), any())).thenThrow(new IllegalStateException("Claude API 응답이 비어있습니다")); when(buildFailureAnalyzer.analyze(any())).thenReturn(new BuildFailureAnalyzer.Analysis( "빌드에 필요한 모듈을 찾지 못했습니다.", "cannot find module 'react'", "dependency를 다시 설치하세요." )); @@ -170,8 +218,8 @@ void analyzeFallsBackToRuleBasedWhenLlmResponseIsNotParseableJson() { when(githubActionsPort.getJobLogs("user-token", "octo/repo", 901L)).thenReturn( new GithubActionsPort.DeploymentLogs(901L, List.of(), "some log text") ); - when(llmRouter.route(AiProvider.ANTHROPIC)).thenReturn(llmPort); - when(llmPort.complete(any(), anyList())).thenReturn("this is not json at all"); + when(llmRouter.route(aiProperties.failureAnalysisProvider())).thenReturn(llmPort); + when(llmPort.complete(any(), anyList(), any())).thenReturn("this is not json at all"); when(buildFailureAnalyzer.analyze(any())).thenReturn(new BuildFailureAnalyzer.Analysis( "프로젝트 빌드가 완료되지 않았습니다.", "some log text", "로그를 확인하세요." )); @@ -192,8 +240,8 @@ void analyzeSkipsGithubCallWhenWorkflowRunIdIsNullAndUsesErrorMessage() { ); stubOwnedHistory(history); when(analysisRepository.findByHistoryId(51L)).thenReturn(Optional.empty()); - when(llmRouter.route(AiProvider.ANTHROPIC)).thenReturn(llmPort); - when(llmPort.complete(any(), anyList())).thenReturn( + when(llmRouter.route(aiProperties.failureAnalysisProvider())).thenReturn(llmPort); + when(llmPort.complete(any(), anyList(), any())).thenReturn( "{\"summary\": \"트리거 실패\", \"suggestedFix\": \"다시 시도하세요.\"}" ); when(analysisRepository.save(any(DeploymentFailureAnalysis.class))) @@ -216,8 +264,8 @@ void excerptBudgetTruncatesOversizedLogsAndPrioritizesErrorLines() { String logText = noise + "npm ERR! critical failure marker\n" + noise; when(githubActionsPort.getJobLogs("user-token", "octo/repo", 901L)) .thenReturn(new GithubActionsPort.DeploymentLogs(901L, List.of(), logText)); - when(llmRouter.route(AiProvider.ANTHROPIC)).thenReturn(llmPort); - when(llmPort.complete(any(), anyList())).thenReturn( + when(llmRouter.route(aiProperties.failureAnalysisProvider())).thenReturn(llmPort); + when(llmPort.complete(any(), anyList(), any())).thenReturn( "{\"summary\": \"실패\", \"suggestedFix\": \"수정\"}" ); when(analysisRepository.save(any(DeploymentFailureAnalysis.class))) @@ -245,8 +293,8 @@ void concurrentAnalysisRaceReFetchesAndReturnsTheOtherRequestsResult() { when(githubActionsPort.getJobLogs("user-token", "octo/repo", 901L)).thenReturn( new GithubActionsPort.DeploymentLogs(901L, List.of(), "some log") ); - when(llmRouter.route(AiProvider.ANTHROPIC)).thenReturn(llmPort); - when(llmPort.complete(any(), anyList())).thenReturn( + when(llmRouter.route(aiProperties.failureAnalysisProvider())).thenReturn(llmPort); + when(llmPort.complete(any(), anyList(), any())).thenReturn( "{\"summary\": \"이 요청의 요약\", \"suggestedFix\": \"이 요청의 수정안\"}" ); when(analysisRepository.save(any(DeploymentFailureAnalysis.class))) @@ -291,7 +339,7 @@ void concurrentAnalyzeCallsForTheSameHistoryOnlyInvokeTheLlmOnce() throws Except when(githubActionsPort.getJobLogs("user-token", "octo/repo", 901L)).thenReturn( new GithubActionsPort.DeploymentLogs(901L, List.of(), "some log") ); - when(llmRouter.route(AiProvider.ANTHROPIC)).thenReturn(llmPort); + when(llmRouter.route(aiProperties.failureAnalysisProvider())).thenReturn(llmPort); // Stateful fake instead of a one-shot stub: the whole point is to prove the *second* // caller sees the *first* caller's saved row via the double-checked cache, so @@ -307,7 +355,7 @@ void concurrentAnalyzeCallsForTheSameHistoryOnlyInvokeTheLlmOnce() throws Except AtomicInteger llmCallCount = new AtomicInteger(); CountDownLatch llmEntered = new CountDownLatch(1); CountDownLatch releaseLlm = new CountDownLatch(1); - when(llmPort.complete(any(), anyList())).thenAnswer(invocation -> { + when(llmPort.complete(any(), anyList(), any())).thenAnswer(invocation -> { llmCallCount.incrementAndGet(); llmEntered.countDown(); assertThat(releaseLlm.await(5, TimeUnit.SECONDS)).isTrue(); @@ -360,9 +408,9 @@ void secretsInLogsAreRedactedBeforeStorageAndBeforeReachingTheLlm() { when(githubActionsPort.getJobLogs("user-token", "octo/repo", 901L)).thenReturn( new GithubActionsPort.DeploymentLogs(901L, List.of(), secretLaden) ); - when(llmRouter.route(AiProvider.ANTHROPIC)).thenReturn(llmPort); + when(llmRouter.route(aiProperties.failureAnalysisProvider())).thenReturn(llmPort); ArgumentCaptor> messagesCaptor = ArgumentCaptor.forClass(List.class); - when(llmPort.complete(any(), messagesCaptor.capture())).thenReturn( + when(llmPort.complete(any(), messagesCaptor.capture(), any())).thenReturn( "{\"summary\": \"실패\", \"suggestedFix\": \"수정\"}" ); when(analysisRepository.save(any(DeploymentFailureAnalysis.class))) @@ -398,8 +446,8 @@ void analyzeDegradesToErrorMessageBasedAnalysisWhenGithubLogFetchFails() { when(userRepository.findById(1L)).thenReturn(Optional.of(activeUser())); when(githubActionsPort.getJobLogs("user-token", "octo/repo", 901L)) .thenThrow(new RuntimeException("GitHub API rate limit exceeded")); - when(llmRouter.route(AiProvider.ANTHROPIC)).thenReturn(llmPort); - when(llmPort.complete(any(), anyList())).thenReturn( + when(llmRouter.route(aiProperties.failureAnalysisProvider())).thenReturn(llmPort); + when(llmPort.complete(any(), anyList(), any())).thenReturn( "{\"summary\": \"요약\", \"suggestedFix\": \"수정\"}" ); when(analysisRepository.save(any(DeploymentFailureAnalysis.class))) @@ -422,8 +470,8 @@ void analyzeFallsBackToRuleBasedWhenLlmCallExceedsTheTimeout() { when(githubActionsPort.getJobLogs("user-token", "octo/repo", 901L)).thenReturn( new GithubActionsPort.DeploymentLogs(901L, List.of(), "some log") ); - when(llmRouter.route(AiProvider.ANTHROPIC)).thenReturn(llmPort); - when(llmPort.complete(any(), anyList())).thenAnswer(invocation -> { + when(llmRouter.route(aiProperties.failureAnalysisProvider())).thenReturn(llmPort); + when(llmPort.complete(any(), anyList(), any())).thenAnswer(invocation -> { // Sleeps far longer than the 1-second test timeout above; orTimeout must win the // race and hand control to the rule-based fallback rather than waiting on this. Thread.sleep(3000);