Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.example.dvely.agent.application.exception;

/**
* 한 태스크가 쓸 수 있는 누적 토큰 상한을 넘겼다.
*
* <p>상한이 없던 동안 곱셈이 그대로 열려 있었다 — 제공자 재시도({@code retry.maxAttempts} 3) ×
* 라운드({@code codeAgent.maxIterations} 40) × 태스크 재시도({@code maxAttempts} 3). 각 단계는
* 자기 한도를 지키지만 태스크 전체가 쓰는 양에는 아무 한도가 없었다.</p>
*
* <p>메시지는 <b>사용자에게 그대로 보인다.</b> 상한에 걸렸을 때 조용히 멈추면 사용자에게는
* "왜 안 되지" 로만 남으므로, 무엇이 일어났고 무엇을 하면 되는지를 한 문장으로 담는다.</p>
*/
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 제공자를
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ToolCall> toolCalls,
List<Map<String, Object>> contentBlocks,
String stopReason
String stopReason,
LlmUsage usage
) {
public LlmToolResponse {
usage = usage == null ? LlmUsage.NONE : usage;
}

/** 사용량을 모르는 호출부용(주로 테스트 더블). */
public LlmToolResponse(List<ToolCall> toolCalls,
List<Map<String, Object>> contentBlocks,
String stopReason) {
this(toolCalls, contentBlocks, stopReason, LlmUsage.NONE);
}

public boolean hasToolCalls() {
return toolCalls != null && !toolCalls.isEmpty();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,16 @@ public void appendAssistant(Long conversationId, String content, ChatMessageKind
*/
@Transactional(readOnly = true)
public List<LlmMessage> 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());
}

/**
Expand Down Expand Up @@ -112,12 +115,14 @@ public List<LlmMessage> 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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 에 실을 만큼만 잘라 낸다.
*
* <p>여기까지는 상한이 없었다. {@code findAllByConversationIdOrderByCreatedAtAsc} 가 돌려준
* 전량이 매 호출마다 그대로 실렸으므로, 오래 쓴 대화일수록 <b>모든</b> 요청이 비싸졌다 —
* 대화가 길어질수록 비용이 선형으로 늘고, 언젠가는 컨텍스트 상한에 닿는다.</p>
*
* <p><b>이것은 동작 변경이다.</b> 창 밖으로 밀려난 앞부분을 에이전트는 더 이상 보지 못한다.
* 긴 대화에서 초반에만 나온 사실(예: "이 앱은 사내용이야" 같은 전제)을 다시 말해야 할 수
* 있다. 요약으로 접두를 대체하는 방법도 있지만 그것은 요약을 만들기 위한 LLM 호출을 하나 더
* 늘리는 일이라, 비용을 줄이려는 이 작업에서는 택하지 않았다.</p>
*
* <p>두 가지 상한을 함께 건다. 턴 수만으로는 긴 코드 블록 하나가 창 전체를 삼키고, 글자 수만
* 으로는 짧은 턴이 수백 개 쌓인 대화를 막지 못한다.</p>
*
* <p><b>항상 뒤에서부터</b> 담는다. 대화에서 지금 처리해야 할 요청은 언제나 마지막 턴이므로,
* 넘칠 때 버려야 하는 것은 앞이다. 마지막 턴 하나는 글자 상한을 넘더라도 반드시 남긴다 —
* 그것을 버리면 무엇을 하라는 요청인지 자체가 사라진다.</p>
*
* <p>별도 클래스인 이유: 호출부가 여러 곳이고 전부 다른 작업자가 동시에 손대는 파일이라,
* 규칙이 그 파일들에 흩어지면 곧 서로 다른 창이 된다.</p>
*/
public final class ConversationWindow {

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

/**
* 남길 최대 글자 수.
*
* <p>24,000자면 대략 6K 토큰이다. 대화 이력은 요청 하나의 <i>배경</i>일 뿐이고, 실제 작업
* 맥락(파일 내용·빌드 로그)은 CODE 루프가 컨테이너에서 따로 읽는다.</p>
*/
public static final int MAX_CHARS = 24_000;

private ConversationWindow() {
}

public static List<LlmMessage> apply(List<LlmMessage> history) {
return apply(history, MAX_TURNS, MAX_CHARS);
}

static List<LlmMessage> apply(List<LlmMessage> history, int maxTurns, int maxChars) {
if (history == null || history.isEmpty()) {
return List.of();
}

List<LlmMessage> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ public class DecisionAgentService {
/** 교정 프롬프트에 되돌려 보여줄 직전 응답의 상한. 어디가 틀렸는지 보는 데는 앞부분이면 된다. */
private static final int MAX_REPAIR_ECHO_CHARS = 2000;

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

Expand All @@ -330,8 +341,8 @@ private AgentPlan retryOnce(List<LlmMessage> 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<LlmMessage> repairMessages = new ArrayList<>(messages);
repairMessages.add(new LlmMessage("user", repairPrompt(failedRaw, failure)));
Expand All @@ -343,8 +354,8 @@ private AgentPlan retryOnce(List<LlmMessage> 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);
}
}
Expand Down Expand Up @@ -487,6 +498,16 @@ private String readReasoning(Map<String, Object> 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 에서 첫 번째로 <b>완결된</b> JSON 객체만 잘라낸다.
*
Expand Down
Loading
Loading