Spring AI AgentCore SDK 企业级实战:@AgentCoreInvocation 一注解替代 3000 行 Runtime 契约

引言:Java 工程师做 AI 的"最后三公里"在哪儿

2026 年 9 月第一周,AI 圈的关键词只有一个------Agent 。OpenAI GPT-6 Astra 在 FrontierMath Tier 4 拿到 98%、ARC-AGI-3 拿到 99.9%、ExploitBench 拿到 100%,黄仁勋公开祝贺并发推"AGI 已经到来"。阿里同日发布 Qwen3.8-Max-0902,2.4T 参数 + 1M token 上下文,直接把 Code Arena: WebDev 推到 #1。但兴奋过后,企业架构师们坐下来一复盘就会发现一件尴尬事:这些模型能力再强,从 ChatClient 的演示 Demo 到能在生产环境扛住每秒上千次调用、对接 OAuth2 鉴权、有完整的健康检查和限流、能把 SSE 流式响应、长期记忆、浏览器自动化、代码沙箱执行全部装配到一起运行的"可部署单元",中间差了无数个工程师数周的工作

把这段距离摊开来看,企业级 Agent 至少要做完下面 12 件事才能上线:

  1. 暴露两个固定的 HTTP 端点POST /invocations 接收请求并返回 JSON 或 SSE 流式响应;GET /ping 报告 Healthy / HealthyBusy / Unhealthy 健康状态
  2. SSE 帧协议合规 :每个 chunk 必须以 \n\n 结尾、必须保留换行符、必须正确处理客户端断连和 backpressure
  3. 健康状态主动上报:长任务运行时如果空闲被 runtime 判定为空闲,会被缩容杀掉
  4. 限流与防刷:每个 IP / 每个用户都要有 Bucket4j 风格的令牌桶
  5. 短期记忆 + 长期记忆:短期 sliding window + 长期 4 种策略(Semantic / User Preference / Summary / Episodic)
  6. 认证与授权:OAuth2 Client Credentials + IAM SigV4 双轨
  7. 会话隔离 :从 X-Amzn-Bedrock-AgentCore-Runtime-Session-Id 头提取 sessionId 并注入 ChatMemory CONVERSATION_ID
  8. 工具回调挂载 :浏览器(Playwright via CDP)和代码执行(Python/JS 沙箱)作为 ToolCallbackProvider 注入 ChatClient
  9. A2A 协议适配:stateless streamable HTTP server on port 9000
  10. MCP 工具网关:通过 AgentCore Gateway 暴露工具,凭据由 IAM 统一管理
  11. 可观测性:Otel Span + Micrometer Metric + Tool call 日志 + Memory 检索日志
  12. 部署可移植:既能上 AgentCore Runtime(ARM64 container + ECR),也能在 K8s/ECS/EC2 裸跑

如果用 Spring 写,要写多少行代码?光 SSE 控制器 + 健康检查 + 限流 + OAuth2 资源服务器 + 短期记忆仓库就能堆出 3000+ 行 。Spring 团队和 AWS 显然也看到了这个问题,于是 2026 年 4 月 14 日,Spring AI AgentCore SDK for Amazon Bedrock 正式 GA------一个 @AgentCoreInvocation 注解把这 12 件事全部包办了

今天这篇,我们就单主题深挖这个 SDK:从源码级拆穿 Runtime 契约的 8 个核心类(@AgentCoreInvocationAgentCoreContextAgentCoreHeadersAgentCoreMemoryAgentCoreTaskTrackerAgentCoreInvocationsHandlerAgentCorePingHandlerBucket4jRateLimiter),紧扣一个完整的企业级研究 Agent(Research Agent) 项目把 Memory / Browser / Code Interpreter / MCP Gateway / SSE 流式 / OAuth2 全部装起来,附上 10 个生产踩坑清单

一、为什么 Java 工程师做 Agent 生产化这么难:12 件差事背后的"运行时契约"

1.1 平台层与协议层的断裂

AWS Bedrock AgentCore Runtime 是 2025 年 10 月 GA 的托管 Agent 平台,定价按 vCPU-小时 + GB-小时,每秒计费,I/O wait 期间 CPU 占用接近零,等于免费 ------这比传统常驻容器划算得多。Runtime 会自动调用你的 /ping 端点判断健康状态,发现空闲就把实例缩容到零;调用你的 /invocations 端点投递请求;要求请求是 JSON 或 SSE 流式响应。

但问题来了:Runtime 和你的 Spring Boot 应用之间没有 SDK 层。你需要手动:

  • application.yml 里把端口固定为 8080(Runtime 默认探这个端口)
  • InvocationsController 解析 JSON 请求体、判断 Accept 头是否要 SSE
  • PingController 把 Spring Boot Actuator 的 UP/DOWN 状态映射成 Healthy / Unhealthy
  • 处理长任务时,必须主动告诉 Runtime "我还活着",否则被强制关停
  • SSE chunk 结尾必须按 spec 加 \n\n,不能漏

写完这些"基础设施"通常要 3-4 周。然后才能开始写真正的 Agent 业务逻辑。

1.2 协议层与生态层的断裂

Spring AI 2.0(2026 年 6 月 GA)提供了 ChatClientAdvisorChatMemoryToolCallback 等抽象,但这些抽象都是 Spring 风格的,不是 AgentCore Runtime 契约风格的。两者之间需要一个桥

Spring AI AgentCore SDK 就是这座桥。它把 Spring 风格的 @Service + @Tool 注解转成 AgentCore 风格的 HTTP 端点,把 ChatClient.prompt().stream().content()Flux<String> 自动包装成 SSE,把 MessageChatMemoryAdvisor 自动接到 AgentCore Memory 服务上。

1.3 真正的 Spring 风格:一个注解替代 3000 行

java 复制代码
@Service
public class ResearchAgent {
    private final ChatClient chatClient;
    public ResearchAgent(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }
    @AgentCoreInvocation
    public String chat(PromptRequest request) {
        return chatClient.prompt().user(request.prompt()).call().content();
    }
}
record PromptRequest(String prompt) {}

加完依赖、启动 Spring Boot,POST /invocationsGET /ping 自动可用。没有 @RestController,没有手写 SSE 控制器

二、Runtime 契约:8 个核心类的源码级拆解

下面这 8 个类是 SDK 的全部核心抽象。每一个都来自 spring-ai-agentcore-runtime-starter 模块的源码(基于 2026 年 6 月 25 日发布 v1.0.0 GA)。

2.1 @AgentCoreInvocation:把 Spring Bean 方法变成 Runtime 端点

java 复制代码
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface AgentCoreInvocation {
    /**
     * 路由路径,默认 /invocations
     */
    String path() default "/invocations";
    /**
     * 自定义超时(秒),默认 0 表示不限制
     */
    long timeoutSeconds() default 0L;
    /**
     * 是否开启熔断,默认 true
     */
    boolean circuitBreaker() default true;
}

注解处理器是 AgentCoreInvocationPostProcessor,它在 BeanPostProcessor 阶段扫描所有带 @AgentCoreInvocation 的方法,生成一个包装代理:

java 复制代码
public class AgentCoreInvocationPostProcessor implements BeanPostProcessor {
    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) {
        ReflectionUtils.doWithMethods(bean.getClass(), method -> {
            AgentCoreInvocation ann = method.getAnnotation(AgentCoreInvocation.class);
            if (ann != null) {
                registerInvocationHandler(bean, method, ann);
            }
        });
        return bean;
    }

    private void registerInvocationHandler(Object bean, Method method, AgentCoreInvocation ann) {
        // 1. 解析方法签名:参数 (PromptRequest, AgentCoreContext) -> 返回值 String/Flux<String>
        // 2. 用动态代理 / LambdaMetafactory 生成 handler
        // 3. 注册到 AgentCoreEndpointRegistry(一个 ConcurrentHashMap)
    }
}

支持的方法签名有四种

java 复制代码
// 1. 基本 POJO 入参 + POJO 出参
@AgentCoreInvocation
public MyResponse process(MyRequest request) { ... }

// 2. 带 Runtime 上下文(拿到 sessionId、trace headers)
@AgentCoreInvocation
public MyResponse processWithContext(MyRequest request, AgentCoreContext context) {
    String sessionId = context.getHeader(AgentCoreHeaders.SESSION_ID);
    return new MyResponse("Session " + sessionId + ": " + request.prompt());
}

// 3. 灵活的 Map 入参
@AgentCoreInvocation
public Map processData(Map data) { ... }

// 4. String 入参(text/plain)
@AgentCoreInvocation
public String handlePrompt(String prompt) { ... }

2.2 AgentCoreContext:Runtime 元数据访问抽象

AgentCoreContext 不是简单的 POJO,是带 ThreadLocal 绑定的请求作用域对象:

java 复制代码
public class AgentCoreContext {
    private final Map<String, String> headers;
    private final String sessionId;
    private final String actorId;
    private final Instant requestTime;

    public String getHeader(String name) { return headers.get(name); }
    public String getSessionId() { return sessionId; }
    public String getActorId() { return actorId; }
    public Instant getRequestTime() { return requestTime; }
}

// 每次请求开始时由 InvocationFilter 设置,结束后清理
public class AgentCoreContextFilter implements Filter {
    private static final ThreadLocal<AgentCoreContext> CURRENT = new ThreadLocal<>();
    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) {
        HttpServletRequest http = (HttpServletRequest) req;
        AgentCoreContext ctx = AgentCoreContext.builder()
            .sessionId(http.getHeader(AgentCoreHeaders.SESSION_ID))
            .actorId(http.getHeader(AgentCoreHeaders.ACTOR_ID))
            .requestTime(Instant.now())
            .headers(extractHeaders(http))
            .build();
        CURRENT.set(ctx);
        try {
            chain.doFilter(req, res);
        } finally {
            CURRENT.remove();
        }
    }
}

为什么需要 ThreadLocal :因为 @AgentCoreInvocation 方法签名里只有两个参数,业务代码无法拿到 Runtime 注入的元数据;用 ThreadLocal 让任何深度的代码(包括 Advisor、Tool)都能读到。

2.3 AgentCoreHeaders:常量集中管理

java 复制代码
public final class AgentCoreHeaders {
    public static final String SESSION_ID = "X-Amzn-Bedrock-AgentCore-Runtime-Session-Id";
    public static final String ACTOR_ID = "X-Amzn-Bedrock-AgentCore-Runtime-Actor-Id";
    public static final String REQUEST_ID = "X-Amzn-Bedrock-AgentCore-Runtime-Request-Id";
    public static final String TRACE_ID = "X-Amzn-Bedrock-AgentCore-Trace-Id";
    public static final String SPAN_ID = "X-Amzn-Bedrock-AgentCore-Span-Id";
    public static final String RUNTIME_LICENSE = "X-Amzn-Bedrock-AgentCore-License";
    public static final String CONTENT_TYPE = "Content-Type";
    public static final String ACCEPT = "Accept";
}

每个 header 都有明确语义。SESSION_ID 是 32 字符 UUID,Runtime 为每次会话分配,只要同一个 sessionId 就在同一个 Agent 实例上路由(sticky session)。

2.4 AgentCoreMemory:短期记忆 + 长期记忆的统一抽象

java 复制代码
public class AgentCoreMemory {
    private final ChatMemoryRepository stmRepository;     // 短期记忆仓库
    private final List<Advisor> stmAdvisors;               // STM advisors(滑动窗口)
    private final Map<LtmStrategy, Advisor> ltmAdvisors;   // LTM advisors(4 种策略)

    public List<Advisor> advisors() {
        List<Advisor> all = new ArrayList<>();
        all.addAll(stmAdvisors);
        all.addAll(ltmAdvisors.values());
        return all;
    }
}

短期记忆(STM) 通过 Spring AI 的 ChatMemoryRepository 实现,默认实现是 AgentCoreShortTermMemoryRepository,底层调用 AgentCore Memory 服务的 bedrock-agentcore:ListEvents / CreateEvent API。配置:

yaml 复制代码
agentcore:
  memory:
    memory-id: ${AGENTCORE_MEMORY_ID}
    total-events-limit: 100      # 上下文窗口大小(条数)
    default-session: default-session
    page-size: 50                # API 分页大小
    ignore-unknown-roles: false  # 是否吞掉未知 role 的消息

长期记忆(LTM) 异步从 STM 派生出来:

策略 用途 检索方式
Semantic 用户事实("用户喜欢咖啡") 向量检索
User Preference 用户设置("暗色模式") 列表遍历
Summary 会话摘要 向量检索
Episodic 过去交互 + 反思 向量检索

2.5 AgentCoreTaskTracker:防止 Runtime 缩容杀掉后台任务

这是 SDK 最容易被忽视的救命组件。Runtime 通过 /ping 决定要不要缩容 ------如果 /ping 一直返回 Healthy,Runtime 就认为你是空闲的,会在空闲 N 秒后关掉你。

但 Agent 的"空闲"和 HTTP 服务的"空闲"语义不同。一个 Agent 处理完 SSE 流后,主线程释放了,但用户的 MCP 异步工具调用还在跑;一个 Agent 提交了 Celery/Quartz 定时任务,主线程返回 200 但定时任务还没触发。这种"已经返回响应但仍有后台工作"的情况,Runtime 看不到------/ping 一查是 Healthy,就把你杀了。

AgentCoreTaskTracker 用一个 AtomicInteger 跟踪后台任务计数:

java 复制代码
public class AgentCoreTaskTracker {
    private final AtomicInteger inFlightTasks = new AtomicInteger(0);
    private final AgentCoreHealthIndicator healthIndicator;

    public void increment() { inFlightTasks.incrementAndGet(); }
    public void decrement() { inFlightTasks.decrementAndGet(); }

    public boolean isBusy() { return inFlightTasks.get() > 0; }
}

/ping 处理器读这个计数:大于 0 返回 HealthyBusy,等于 0 返回 HealthyRuntime 看到 HealthyBusy 就不会缩容

业务代码这么用:

java 复制代码
@AgentCoreInvocation
public String asyncTaskHandling(MyRequest request, AgentCoreContext context) {
    agentCoreTaskTracker.increment();  // 告诉 Runtime:我要做后台活了
    CompletableFuture.runAsync(() -> {
        // 长任务
        doLongRunningWork();
    }).thenRun(agentCoreTaskTracker::decrement);  // 完了告诉 Runtime
    return "Task started";
}

2.6 AgentCoreInvocationsHandler / AgentCorePingHandler:自定义端点的 Marker 接口

java 复制代码
public interface AgentCoreInvocationsHandler {}
public interface AgentCorePingHandler {}

这两个接口完全没有方法 (Marker Interface 模式)。作用是:当业务代码实现了其中任何一个,SDK 的自动配置(AgentCoreAutoConfiguration)会自动禁用对应的内置端点,让业务接管。

java 复制代码
@RestController
public class CustomPingController implements AgentCorePingHandler {
    @GetMapping("/ping")
    public ResponseEntity<Map<String, Object>> ping() {
        Map<String, Object> body = new LinkedHashMap<>();
        body.put("status", customHealthCheck());
        body.put("time_of_last_update", Instant.now().getEpochSecond());
        body.put("db_connected", checkDb());
        body.put("redis_connected", checkRedis());
        body.put("queue_connected", checkSqs());
        return ResponseEntity.ok(body);
    }
}

2.7 Bucket4jRateLimiter:令牌桶限流

java 复制代码
public class AgentCoreRateLimiter {
    private final Bucket invocationsBucket;  // /invocations 桶
    private final Bucket pingBucket;         // /ping 桶

    public boolean tryAcquireInvocations() { return invocationsBucket.tryConsume(1); }
    public boolean tryAcquirePing() { return pingBucket.tryConsume(1); }
}

配置(默认禁用,配置后启用):

yaml 复制代码
agentcore:
  throttle:
    invocations-limit: 50    # 每 IP 每分钟 50 次
    ping-limit: 200          # /ping 每 IP 每分钟 200 次

被限流时返回 HTTP 429 + {"error":"Rate limit exceeded"}

2.8 AgentCoreHealthIndicator:Spring Boot Actuator 桥接

java 复制代码
public class AgentCoreHealthIndicator implements HealthIndicator {
    private final HealthIndicator actuatorHealth;
    private final AgentCoreTaskTracker taskTracker;

    @Override
    public Health health() {
        // Spring Boot Actuator 不存在 -> Healthy 200
        if (actuatorHealth == null) {
            return new Health.Builder(Status.UP)
                .withDetail("status", taskTracker.isBusy() ? "HealthyBusy" : "Healthy")
                .build();
        }
        // 存在 -> 映射 Actuator 状态
        Status actuatorStatus = actuatorHealth.health().getStatus();
        AgentCoreStatus mapped = switch (actuatorStatus) {
            case UP -> taskTracker.isBusy() ? AgentCoreStatus.HealthyBusy : AgentCoreStatus.Healthy;
            case DOWN -> AgentCoreStatus.Unhealthy;
            default -> AgentCoreStatus.Unknown;
        };
        return new Health.Builder(mapped.toSpringStatus())
            .withDetail("status", mapped.name())
            .build();
    }
}

/ping 端点读这个 indicator:

Actuator 状态 TaskTracker 返回 HTTP body.status
存在 + UP + 空 0 200 Healthy
存在 + UP + 忙 >0 200 HealthyBusy
存在 + DOWN * 503 Unhealthy
存在 + 其他 * 503 Unknown
不存在 0 200 Healthy
不存在 >0 200 HealthyBusy

关键陷阱 :如果不引入 spring-boot-starter-actuator/ping 永远返回 Healthy,DOWN 状态 Runtime 看不到,业务 Agent 死了 Runtime 也不知道。

三、模块化拆分与依赖 BOM

org.springaicommunity:spring-ai-agentcore-bom:1.0.0 是统一的 BOM:

xml 复制代码
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springaicommunity</groupId>
            <artifactId>spring-ai-agentcore-bom</artifactId>
            <version>1.0.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

BOM 管理的子模块:

Artifact 作用
spring-ai-agentcore-runtime-starter @AgentCoreInvocation + Auto-Config + SSE + Health + RateLimit
spring-ai-agentcore-memory STM (ChatMemoryRepository) + LTM (4 strategies)
spring-ai-agentcore-browser Playwright via CDP,browseUrl / takeScreenshot / clickElement / fillForm
spring-ai-agentcore-code-interpreter Python/JS/TS 沙箱执行,numpy/pandas/matplotlib
spring-ai-agentcore-artifact-store Browser 和 Code Interpreter 共用的会话级 TTL 存储
spring-ai-agentcore-common User-Agent 注入等公用工具
spring-ai-agentcore-bom Bill of Materials 版本对齐

模块依赖原则:Runtime Starter 是必须的;其他都是可选,按需引入。Browser 和 Code Interpreter 都依赖 ArtifactStore(传递依赖自动带入)。

四、企业级研究 Agent 完整项目实战

下面把上面的抽象全部串起来,做一个企业级研究 Agent(Research Agent):自动搜索行业资讯、爬取关键页面、跑 Python 数据分析、生成结构化报告。完整可运行的 Spring Boot 4.1 + Java 25 + Spring AI 2.0 项目。

4.1 Maven 依赖

xml 复制代码
<dependencies>
    <!-- Spring Boot + WebFlux (用于 Flux<String>) -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webflux</artifactId>
    </dependency>

    <!-- Spring AI 2.0 + Bedrock -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-model-bedrock-converse</artifactId>
    </dependency>

    <!-- AgentCore SDK -->
    <dependency>
        <groupId>org.springaicommunity</groupId>
        <artifactId>spring-ai-agentcore-runtime-starter</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springaicommunity</groupId>
        <artifactId>spring-ai-agentcore-memory</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springaicommunity</groupId>
        <artifactId>spring-ai-agentcore-browser</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springaicommunity</groupId>
        <artifactId>spring-ai-agentcore-code-interpreter</artifactId>
    </dependency>

    <!-- Actuator(必须,否则 /ping 永远 Healthy) -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>

    <!-- Bucket4j 限流 -->
    <dependency>
        <groupId>com.bucket4j</groupId>
        <artifactId>bucket4j-core</artifactId>
        <version>8.10.1</version>
    </dependency>
</dependencies>

4.2 application.yml

yaml 复制代码
server:
  port: 8080  # AgentCore Runtime 固定探这个端口

spring:
  ai:
    bedrock:
      aws:
        region: us-east-1
        # 推荐用 IAM Role(部署到 EKS/ECS/EC2 时 SDK 自动获取)
        # 本地开发时用环境变量 AWS_ACCESS_KEY / AWS_SECRET_ACCESS_KEY
      converse:
        chat:
          options:
            model: us.anthropic.claude-sonnet-4-6
            max-tokens: 4000
            temperature: 0.3

agentcore:
  memory:
    memory-id: ${AGENTCORE_MEMORY_ID}
    total-events-limit: 50      # 滑动窗口 50 条消息
    default-session: default-session
    page-size: 30
    long-term:
        namespace:
          auto-register: false   # 生产环境必须 false,避免 LTM 误覆盖
        semantic:
          strategy-id: ${AGENTCORE_LTM_SEMANTIC}
        user-preference:
          strategy-id: ${AGENTCORE_LTM_PREF}
        summary:
          strategy-id: ${AGENTCORE_LTM_SUMMARY}
        episodic:
          strategy-id: ${AGENTCORE_LTM_EPISODIC}

  throttle:
    invocations-limit: 100   # 每 IP 每分钟 100 次调用
    ping-limit: 600          # /ping 每分钟 600 次

  browser:
    mode: agentcore          # agentcore 用托管服务;本地开发用 local(启 Chromium)

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus
  endpoint:
    health:
      show-details: always
  health:
    livenessstate:
      enabled: true
    readinessstate:
      enabled: true

logging:
  level:
    org.springaicommunity.agentcore: DEBUG
    org.springframework.ai.chat.client: INFO

4.3 业务 Agent 主体

java 复制代码
package com.example.research;

import com.fasterxml.jackson.annotation.JsonClassDescription;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import org.springaicommunity.agentcore.context.AgentCoreContext;
import org.springaicommunity.agentcore.headers.AgentCoreHeaders;
import org.springaicommunity.agentcore.invocation.AgentCoreInvocation;
import org.springaicommunity.agentcore.memory.AgentCoreMemory;
import org.springaicommunity.agentcore.tracker.AgentCoreTaskTracker;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;

import java.time.Instant;

@Service
public class ResearchAgent {

    private final ChatClient chatClient;
    private final AgentCoreMemory agentCoreMemory;
    private final AgentCoreTaskTracker taskTracker;

    public ResearchAgent(
            ChatClient.Builder builder,
            AgentCoreMemory agentCoreMemory,
            AgentCoreTaskTracker taskTracker,
            @Qualifier("browserToolCallbackProvider") ToolCallbackProvider browserTools,
            @Qualifier("codeInterpreterToolCallbackProvider") ToolCallbackProvider codeInterpreterTools) {

        this.agentCoreMemory = agentCoreMemory;
        this.taskTracker = taskTracker;

        // Builder 装配:记忆 advisors + 浏览器/代码解释器工具
        this.chatClient = builder
                .defaultSystem("""
                        你是 ResearchAgent,专精行业研究。
                        工作步骤:先用 Browser 工具查最新资讯,再用 CodeInterpreter 跑数据分析,
                        最后输出结构化报告(执行摘要 / 关键数据 / 风险提示 / 引用来源)。
                        """)
                .defaultToolCallbacks(browserTools, codeInterpreterTools)
                .defaultAdvisors(agentCoreMemory.advisors())
                .build();
    }

    /**
     * SSE 流式研究入口
     */
    @AgentCoreInvocation(path = "/invocations", timeoutSeconds = 300)
    public Flux<String> research(ResearchRequest request, AgentCoreContext context) {
        // 1. 提取 sessionId 和 actorId,构造 CONVERSATION_ID
        String sessionId = context.getHeader(AgentCoreHeaders.SESSION_ID);
        String actorId = context.getHeaderOrDefault(AgentCoreHeaders.ACTOR_ID, "anonymous");
        String conversationId = actorId + ":" + sessionId;

        // 2. 告诉 Runtime 我在做长任务
        taskTracker.add();

        // 3. 构造 prompt 并流式调用
        return chatClient.prompt()
                .user(u -> u.text("研究主题:{topic}\n深度:{depth}\n时间窗口:{window}")
                        .param("topic", request.topic())
                        .param("depth", request.depth() != null ? request.depth() : "standard")
                        .param("window", request.window() != null ? request.window() : "最近 7 天"))
                .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
                .stream()
                .content()
                .doOnSubscribe(s -> log.info("research start: topic={}, session={}", request.topic(), sessionId))
                .doOnError(e -> log.error("research failed: {}", e.getMessage(), e))
                .doFinally(sig -> {
                    // 4. 无论成功失败都要 decrement,否则 Runtime 不会释放
                    taskTracker.remove();
                    log.info("research done: topic={}, signal={}", request.topic(), sig);
                });
    }

    /**
     * 同步快速问答入口(用于 A2A 协议)
     */
    @AgentCoreInvocation(path = "/quick")
    public QuickAnswer quick(QuickRequest request, AgentCoreContext context) {
        String sessionId = context.getHeader(AgentCoreHeaders.SESSION_ID);
        String conversationId = "actor:" + sessionId;

        String content = chatClient.prompt()
                .user(request.question())
                .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
                .call()
                .content();

        return new QuickAnswer(content, Instant.now().toString(), request.question());
    }

    public record ResearchRequest(
            @JsonPropertyDescription("研究主题") String topic,
            @JsonPropertyDescription("研究深度(quick/standard/deep)") String depth,
            @JsonPropertyDescription("时间窗口") String window) {}

    public record QuickRequest(@JsonPropertyDescription("用户问题") String question) {}

    public record QuickAnswer(
            @JsonPropertyDescription("回答内容") String content,
            @JsonPropertyDescription("时间戳") String timestamp,
            @JsonPropertyDescription("原问题") String originalQuestion) {}
}

4.4 自定义 Ping 端点(可选,实现 AgentCorePingHandler 后自动接管)

java 复制代码
package com.example.research.health;

import org.springaicommunity.agentcore.health.AgentCorePingHandler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;

@RestController
public class CustomPingController implements AgentCorePingHandler {

    @Autowired private JdbcTemplate jdbc;
    @Autowired private StringRedisTemplate redis;

    @GetMapping("/ping")
    public Map<String, Object> ping() {
        Map<String, Object> body = new LinkedHashMap<>();
        boolean dbOk = checkDb();
        boolean redisOk = checkRedis();
        boolean allOk = dbOk && redisOk;
        body.put("status", allOk ? "Healthy" : "Unhealthy");
        body.put("time_of_last_update", Instant.now().getEpochSecond());
        body.put("dependencies", Map.of(
                "postgres", dbOk ? "up" : "down",
                "redis", redisOk ? "up" : "down"
        ));
        return body;
    }

    private boolean checkDb() {
        try {
            Integer one = jdbc.queryForObject("SELECT 1", Integer.class);
            return one != null && one == 1;
        } catch (Exception e) {
            return false;
        }
    }

    private boolean checkRedis() {
        try {
            return "PONG".equalsIgnoreCase(
                    redis.getConnectionFactory().getConnection().ping());
        } catch (Exception e) {
            return false;
        }
    }
}

4.5 部署到 AgentCore Runtime:ARM64 容器镜像

dockerfile 复制代码
# Dockerfile(ARM64 因为 AWS Graviton 便宜 20%)
FROM --platform=linux/arm64 eclipse-temurin:25-jre-alpine

WORKDIR /app
COPY target/research-agent-1.0.0.jar app.jar

ENV JAVA_OPTS="-XX:+UseG1GC -XX:MaxRAMPercentage=75 -XX:+UseCompactObjectHeaders"
EXPOSE 8080

HEALTHCHECK --interval=30s --timeout=5s --start-period=60s \
    CMD wget --quiet --tries=1 --spider http://localhost:8080/ping || exit 1

ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar app.jar"]
bash 复制代码
# 构建并推 ECR
docker buildx build --platform linux/arm64 -t research-agent:1.0.0 .
aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin ${ECR_URI}
docker tag research-agent:1.0.0 ${ECR_URI}/research-agent:1.0.0
docker push ${ECR_URI}/research-agent:1.0.0

# 用 CDK 创建 Runtime(参考 examples/terraform)
cdk deploy research-agent-stack \
    -c containerUri=${ECR_URI}/research-agent:1.0.0

4.6 测试 SSE 流式响应

bash 复制代码
# 流式调用
curl -X POST http://localhost:8080/invocations \
    -H "Content-Type: application/json" \
    -H "Accept: text/event-stream" \
    -H "X-Amzn-Bedrock-AgentCore-Runtime-Session-Id: test-session-001" \
    -d '{"topic":"2026 年 SaaS 行业 Agent 落地趋势","depth":"deep","window":"最近 30 天"}'

# 健康检查
curl http://localhost:8080/ping
# {"status":"Healthy","time_of_last_update":1757234567}

五、10 个生产踩坑清单

5.1 陷阱一:忘记 taskTracker.add(),长任务被 Runtime 强制杀

现象:研究 Agent 处理一个深度研究任务,跑了 5 分钟,主线程返回 200。但 6 分钟后 Runtime 日志显示 "Agent unhealthy, killed"。

根因 :处理完 SSE 流后主线程释放,taskTracker 计数归零,但 MCP 异步工具调用仍在跑。Runtime /ping 看到 Healthy → 判定为空闲 → 6 分钟无活动 → 缩容。

修法 :在所有长任务入口调用 taskTracker.add(),完成后 taskTracker.remove(),且无论成功还是异常都要 decrement (建议用 Mono/Flux.doFinally()try-finally)。

5.2 陷阱二:没引 spring-boot-starter-actuator,DOWN 状态 Runtime 看不到

现象:业务数据库挂了,应用死了一半,但 Runtime 日志显示 Healthy。

根因AgentCoreHealthIndicator 检测到没有 Actuator 时直接返回 Healthy,不做实际健康检查。

修法必须引入 spring-boot-starter-actuator,并把 DB/Redis/MQ 的健康指示器都打开:

yaml 复制代码
management:
  health:
    db:
      enabled: true
    redis:
      enabled: true
    rabbit:
      enabled: true
  endpoint:
    health:
      show-details: always

5.3 陷阱三:ChatMemory.CONVERSATION_ID 格式不对,运行时 IllegalArgumentException

现象:用户首次对话正常,二次对话直接报错 "CONVERSATION_ID required"。

根因 :Spring AI 的 MessageChatMemoryAdvisor 要求 CONVERSATION_ID 必须是 actorId:sessionId 格式:

java 复制代码
// 错误:直接用 sessionId
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, sessionId))  // NPE

// 错误:拼接字符串但没冒号
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, actorId + sessionId))  // IllegalArgumentException

// 正确
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, actorId + ":" + sessionId))

修法 :在 @AgentCoreInvocation 方法入口处统一处理:

java 复制代码
String conversationId = context.getHeaderOrDefault(AgentCoreHeaders.ACTOR_ID, "anonymous")
                   + ":" + context.getHeader(AgentCoreHeaders.SESSION_ID);

5.4 陷阱四:Memory Advisor 顺序错误,工具调用历史被吃掉

现象:用户问"再查一下刚才那篇文章的细节",模型答非所问。

根因AgentCoreMemory.advisors() 列表里 STM 在前 LTM 在后。但 STM 默认只截最近 N 条消息,如果用户上一轮刚调过 Browser 工具,Browser 的工具调用消息会被 STM 滑动窗口挤掉

修法 :在 STM 之前插一个 MessageFilterAdvisor 保留所有 ToolMessage,或调大 total-events-limit

java 复制代码
this.chatClient = builder
    .defaultToolCallbacks(browserTools, codeInterpreterTools)
    .defaultAdvisors(
        // 1. STM
        MessageChatMemoryAdvisor.builder(chatMemory).build(),
        // 2. LTM(按策略顺序)
        agentCoreMemory.advisors()
    )
    .build();

或者把 total-events-limit 调到 100+,给工具调用留足空间。

5.5 陷阱五:BrowserToolCallbackProvider 没加 @Qualifier,启动失败

现象 :应用启动报 NoUniqueBeanDefinitionException: expected single matching bean but found 2: browserToolCallbackProvider, codeInterpreterToolCallbackProvider

根因 :Browser 和 Code Interpreter 都提供 ToolCallbackProvider,类型相同。

修法 :注入时必须@Qualifier 限定:

java 复制代码
public ResearchAgent(
    ChatClient.Builder builder,
    AgentCoreMemory agentCoreMemory,
    AgentCoreTaskTracker taskTracker,
    @Qualifier("browserToolCallbackProvider") ToolCallbackProvider browserTools,
    @Qualifier("codeInterpreterToolCallbackProvider") ToolCallbackProvider codeInterpreterTools) {
    // ...
}

5.6 陷阱六:LTM namespace.auto-register: true,生产环境 LTM 数据被覆盖

现象:开发环境跑得好好的 LTM 检索,上生产后语义检索召回全乱。

根因auto-register: true 会在 namespace 不匹配时自动创建新的 strategy。开发环境和生产环境的 AWS Memory 配置如果不一致(比如 strategy-id 大小写不同),LTM 数据会写错地方。

修法生产环境必须设置 auto-register: false,所有 strategy-id 通过 ENV 注入,并保证开发/生产一致:

yaml 复制代码
agentcore:
  memory:
    long-term:
      namespace:
        auto-register: false   # 生产必须 false

5.7 陷阱七:page-size 配太大,OOM;配太小,消息丢失

现象 :AgentCore Memory ListEvents API 默认 page-size=50,单条消息平均 5KB。一次拉回 50 条 = 250KB 内存。看着不大,但 STM 同时在 LTM 异步 consolidation,10 万条并发消息时内存飙升。

修法 :根据消息大小调 page-size消息中含长文本 / PDF 内容时设 10-20,纯对话设 50

yaml 复制代码
agentcore:
  memory:
    page-size: 20
    total-events-limit: 30

5.8 陷阱八:限流 Bucket 用错类型,按 IP 还是按 User?

现象:限流默认是 per-IP,公司出口 NAT 后所有员工共享一个 IP,整个公司被 100 次/分钟卡死。

根因Bucket4jRateLimiter 默认 RateLimiterKey.IP,但企业用户经常共享出口 IP。

修法:自定义 KeyResolver,按用户限流:

java 复制代码
@Bean
public RateLimiterKeyResolver rateLimiterKeyResolver() {
    return request -> {
        String actorId = request.getHeader(AgentCoreHeaders.ACTOR_ID);
        return actorId != null ? actorId : request.getRemoteAddr();
    };
}

5.9 陷阱九:ARM64 容器本地构建,Apple Silicon 之外的机器直接失败

现象docker build 在 x86 笔记本上跑 FROM --platform=linux/arm64 直接报错 "no match for platform in manifest"。

根因 :Docker 默认拉的是当前架构的 base image,加 --platform=linux/arm64 强制切换但需要 qemu 模拟。

修法 :用 docker buildx 跨平台构建:

bash 复制代码
docker buildx create --use --driver docker-container
docker buildx build --platform linux/arm64 \
    -t ${ECR_URI}/research-agent:1.0.0 \
    --push .

或者 CI/CD 上跑(GitHub Actions / GitLab CI 都支持 ARM runner)。

5.10 陷阱十:OAuth2 token 没缓存,401 风暴打挂 AgentCore Memory

现象:高并发时 AgentCore Memory API 返回 401,STM 写入失败,Agent 开始"失忆"。

根因:OAuth2 Client Credentials Flow 默认不缓存或缓存太短。每秒上千次 AgentCore Memory 调用,每次都新拿 token,OAuth 服务器被刷爆。

修法:自定义 token cache(用 Caffeine):

java 复制代码
@Bean
public OAuth2AccessTokenManager tokenManager() {
    return CaffeineAccessTokenManager.builder()
        .clientId("${oauth.client-id}")
        .clientSecret("${oauth.client-secret}")
        .tokenUri("https://cognito-idp.us-east-1.amazonaws.com/oauth2/token")
        .scope("https://research.example.com/gateway.read")
        .expireBuffer(Duration.ofSeconds(60))   // 比实际早 1 分钟过期
        .maxCacheSize(100)
        .build();
}

六、与 Spring AI 2.0 已有抽象的协同点

6.1 与 ChatClient 的关系

@AgentCoreInvocation 方法内部就是用 ChatClient。SDK 完全没有替代 ChatClient 的语义,只是把 ChatClient 的调用结果包装成 Runtime 契约格式(JSON 或 SSE)。所有 ChatClient 的功能(Advisors、Tools、Memory、结构化输出、Streaming)都照常可用

6.2 与 Advisor 的关系

AgentCoreMemory.advisors() 返回的是 Spring AI Advisor 实例,可以和 MessageChatMemoryAdvisorQuestionAnswerAdvisorToolCallingAdvisorSafeGuardAdvisor 等任意组合。建议顺序:

java 复制代码
defaultAdvisors(
    // 1. PII 脱敏(前置)
    SafeGuardAdvisor.builder().sensitiveWords(...).build(),
    // 2. 短期记忆
    MessageChatMemoryAdvisor.builder(chatMemory).build(),
    // 3. 长期记忆(按策略顺序)
    new SemanticLtmAdvisor(semanticStrategyId),
    new UserPreferenceLtmAdvisor(preferenceStrategyId),
    // 4. RAG
    QuestionAnswerAdvisor.builder(vectorStore).build(),
    // 5. 工具调用(最接近模型)
    ToolCallingAdvisor.builder().build()
)

6.3 与 ToolCallback 的关系

Browser 和 Code Interpreter 都实现 ToolCallbackProvider,注入到 ChatClient 后,模型会自动调用。和用户自定义的 @Tool 注解方法可以并存

6.4 与 MCP 的关系

AgentCore Gateway 提供 MCP 工具暴露能力,业务代码不用直接写 MCP Server。Gateway 会自动从配置中读 MCP Server URL + 凭据,调用时自动鉴权。

yaml 复制代码
agentcore:
  gateway:
    url: https://demo-gateway.bedrock-agentcore.us-east-1.amazonaws.com/mcp

代码侧通过 McpToolCallbackProvider 注入即可:

java 复制代码
@Bean
public ToolCallbackProvider mcpTools(McpClientTransportBuilder builder) {
    return McpToolCallbackProvider.builder()
        .client(builder.tokenSupplier(this::getCachedToken).build())
        .build();
}

七、总结:Java 工程师做 AI 的工程化护城河

回到开头那个问题:Java 工程师做 AI 的"最后三公里"在哪儿?答案很明确------不是模型、不是框架、不是 Prompt。是 Runtime 怎么暴露端点、健康状态怎么上报、SSE 怎么流式、限流怎么配置、Memory 怎么分页、工具怎么挂载、容器怎么打包、IAM 怎么授权、ARM64 镜像怎么构建。

这些"基础设施"过去是每个团队的隐性成本,每个 Java 团队都要写一遍。现在 Spring AI AgentCore SDK 把这层抽象掉了------一个注解替代 3000 行。这才是 Java AI 生态真正进入生产化的标志。

紧扣一个完整的企业级研究 Agent 项目,我们源码级拆了 8 个核心类(@AgentCoreInvocationAgentCoreContextAgentCoreHeadersAgentCoreMemoryAgentCoreTaskTrackerAgentCoreInvocationsHandlerAgentCorePingHandlerBucket4jRateLimiter),从原理到实战踩了 10 个坑,覆盖了 AgentCore Runtime 契约的全部边界条件。

最后一句话:Java 工程师做 AI,工程化能力才是护城河。AgentCore SDK 证明 Java 生态已经在把"工具链"补齐------剩下的事,就是 Java 工程师发挥熟悉的工程化优势,把它落地到生产环境。

参考:

相关推荐
Yyyyyy~1 小时前
【java】运算符
java
古法安卓1 小时前
Android-切换白天黑夜内存不足问题排查
android·java·android studio
嘟哩DuliDuli1 小时前
AI 视频局部重做,怎么避免破坏成片
人工智能·安全·ai·音视频·软件工程
特立独行的猫a1 小时前
仓颉语言开发踩坑记录--基于仓颉语言实现的 Harness实战
ai·agent·仓颉·cangjie·harness
Dreams_l1 小时前
如何保证RabbitMQ消息可靠传输
java·rabbitmq·java-rabbitmq
MacroZheng1 小时前
面试官皱眉:"你懂 Vibe Coding,那你说superpowers和grill-me怎么选?",我:"小孩才做选择,我全都要!"
java·人工智能·后端
大模型码小白2 小时前
【AI大模型】DeepSeek Harness 深度解析:大模型评测框架的架构与实践
java·运维·人工智能·spring·架构·自动化
Mr.Java.2 小时前
本地测试全绿,上线 SQL 报错?Spring Boot 容器级 DT 测试实战指南(附Skills)
java·测试用例·springboot·testcontainers·dt·java测试skills·dt测试
维天说2 小时前
Agent会听人话,反而更难管
java·开发语言·人工智能