feat(agent): implement Lane E — JDK 21 virtual threads, Spring AI observability, BeanOutputConverter

This commit is contained in:
matevip 2026-04-22 21:00:40 +08:00
parent 320e13b975
commit aed905efb7
6 changed files with 54 additions and 45 deletions

1
.gitignore vendored
View File

@ -92,6 +92,5 @@ deploy/nginx/ssl/*.pem
deploy/.env
# Claude Code local settings
CLAUDE.md
.claude/settings.local.json
.claude/plans/

View File

@ -67,6 +67,12 @@
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- ===== Actuator — exposes Spring AI observation metrics (gen_ai.*) ===== -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- ===== Spring AI Alibaba DashScope ===== -->
<!--
1.1.2.2 需单独指定版本,不在 BOM 中

View File

@ -670,6 +670,10 @@ public class NodeStreamingChatHelper {
}
// 4. 提取 token usage通常最后一个 chunk 携带完整 usage
// E-4 probe: log metadata keys to check for x-ratelimit-* headers
if (chatResponse.getMetadata() != null && log.isDebugEnabled()) {
log.debug("[E4-probe] metadata keys: {}", chatResponse.getMetadata().keySet());
}
if (chatResponse.getMetadata() != null && chatResponse.getMetadata().getUsage() != null) {
var usage = chatResponse.getMetadata().getUsage();
if (usage.getPromptTokens() != null && usage.getPromptTokens() > 0) {

View File

@ -2,14 +2,14 @@ package vip.mate.agent.graph.plan.node;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.converter.BeanOutputConverter;
import vip.mate.agent.AgentToolSet;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.graph.NodeStreamingChatHelper;
@ -52,7 +52,17 @@ public class PlanGenerationNode implements NodeAction {
private final NodeStreamingChatHelper streamingHelper;
private final ConversationWindowManager conversationWindowManager;
private final AgentToolSet toolSet;
private final ObjectMapper objectMapper = new ObjectMapper();
/**
* Structured triage result field names use @JsonProperty to match the
* snake_case keys the LLM is instructed to produce, so no prompt changes needed.
*/
record TriageResult(
@JsonProperty("needs_planning") boolean needsPlanning,
@JsonProperty("direct_answer") String directAnswer,
@JsonProperty("plan_type") String planType,
@JsonProperty("steps") List<String> steps
) {}
private static final String PLANNING_PROMPT = """
你是任务分流器不是聊天助手根据用户目标把请求分到三类之一并只输出一个 JSON 对象
@ -163,6 +173,11 @@ public class PlanGenerationNode implements NodeAction {
promptMessages.add(new UserMessage("用户目标:" + goal));
// Append JSON schema hint generated by BeanOutputConverter so the LLM
// knows the exact expected structure (replaces hand-written schema in PLANNING_PROMPT).
BeanOutputConverter<TriageResult> converter = new BeanOutputConverter<>(TriageResult.class);
promptMessages.add(new UserMessage(converter.getFormat()));
Prompt prompt = new Prompt(promptMessages);
// Broadcast a lightweight progress token so the frontend shows activity
@ -203,14 +218,13 @@ public class PlanGenerationNode implements NodeAction {
"completion_tokens", result.completionTokens()
)));
String cleanedJson = cleanJsonResponse(llmResponse);
Map<String, Object> parsed = objectMapper.readValue(cleanedJson, new TypeReference<>() {});
boolean needsPlanning = Boolean.TRUE.equals(parsed.get("needs_planning"));
TriageResult triage = converter.convert(llmResponse);
boolean needsPlanning = triage != null && triage.needsPlanning();
if (!needsPlanning) {
// Category (A): direct answer push to client and terminate via DirectAnswerNode.
String directAnswer = parsed.get("direct_answer") != null
? parsed.get("direct_answer").toString() : llmResponse;
String directAnswer = triage != null && triage.directAnswer() != null
? triage.directAnswer() : llmResponse;
log.info("[PlanGeneration] Direct-answer route taken (no tools, no planning)");
streamingHelper.broadcastContent(conversationId, directAnswer);
@ -227,8 +241,7 @@ public class PlanGenerationNode implements NodeAction {
}
// Categories (B) single-step or (C) multi-step: extract steps.
@SuppressWarnings("unchecked")
List<String> steps = (List<String>) parsed.get("steps");
List<String> steps = triage != null ? triage.steps() : null;
if (steps == null || steps.isEmpty()) {
// LLM asked for planning but produced no steps fall back to a
// synthetic 1-step plan using the user's goal so the executor
@ -287,25 +300,4 @@ public class PlanGenerationNode implements NodeAction {
}
}
/**
* Strip optional markdown code fences and isolate the first JSON object.
* Throws if no balanced JSON object is present; the caller treats that as
* a triage failure and falls back to a single-step plan.
*/
private String cleanJsonResponse(String response) {
if (response == null) {
throw new IllegalArgumentException("LLM returned null response");
}
String cleaned = response.trim();
if (cleaned.startsWith("```")) {
cleaned = cleaned.replaceAll("```json?\\n?", "").replaceAll("```", "").trim();
}
int start = cleaned.indexOf('{');
int end = cleaned.lastIndexOf('}');
if (start < 0 || end <= start) {
throw new IllegalArgumentException(
"LLM response does not contain a valid JSON object: " + cleaned.substring(0, Math.min(80, cleaned.length())));
}
return cleaned.substring(start, end + 1);
}
}

View File

@ -1,18 +1,19 @@
package vip.mate.config;
import org.springframework.boot.task.SimpleAsyncTaskExecutorBuilder;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.security.task.DelegatingSecurityContextAsyncTaskExecutor;
import org.springframework.security.task.DelegatingSecurityContextTaskExecutor;
import java.util.concurrent.Executor;
/**
* 全局异步线程池配置确保 SecurityContext 传播到 @Async 线程
* Global async executor config ensures SecurityContext propagation to @Async threads.
* <p>
* 使用 {@link DelegatingSecurityContextAsyncTaskExecutor} 包装线程池
* 使得审计记忆摘要等异步任务能正确获取调用线程的用户身份和权限上下文
* The inner delegate uses virtual threads (JDK 21); the outer
* {@link DelegatingSecurityContextTaskExecutor} wrapper propagates the caller's
* SecurityContext (JWT identity, audit permissions) to every @Async invocation.
*
* @author MateClaw Team
*/
@ -22,12 +23,13 @@ public class AsyncSecurityConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("async-sec-");
executor.initialize();
return new DelegatingSecurityContextAsyncTaskExecutor(executor);
// Keep DelegatingSecurityContextTaskExecutor so SecurityContext
// is propagated to every @Async invocation (JWT, audit, permission checks).
// Replace the inner platform-thread pool with a virtual-thread executor.
var delegate = new SimpleAsyncTaskExecutorBuilder()
.virtualThreads(true)
.threadNamePrefix("async-vt-")
.build();
return new DelegatingSecurityContextTaskExecutor(delegate);
}
}

View File

@ -13,6 +13,9 @@ spring:
multipart:
max-file-size: 100MB
max-request-size: 200MB
threads:
virtual:
enabled: true
profiles:
active: dev
@ -54,6 +57,9 @@ spring:
max-tokens: 4096
# Spring AI 1.1.x 会话记忆配置(使用内嵌 H2 时无需额外配置)
chat:
observations:
log-prompt: false
log-completion: false
memory:
repository:
jdbc:
@ -210,7 +216,7 @@ mate:
# Phase 2: SOUL auto-evolution and provider decorators
soul-update-interval: 20 # 20 writes trigger one SOUL.md LLM update (0 = off)
provider-retry-attempts: 1 # 1 = no retry (enable when external providers added)
provider-metrics-enabled: false # requires spring-boot-starter-actuator dependency
provider-metrics-enabled: false # actuator dependency now present; enable when external providers added
# Phase 3: fact projection
fact:
projection-enabled: true