引言: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 件事才能上线:
- 暴露两个固定的 HTTP 端点 :
POST /invocations接收请求并返回 JSON 或 SSE 流式响应;GET /ping报告Healthy/HealthyBusy/Unhealthy健康状态 - SSE 帧协议合规 :每个 chunk 必须以
\n\n结尾、必须保留换行符、必须正确处理客户端断连和 backpressure - 健康状态主动上报:长任务运行时如果空闲被 runtime 判定为空闲,会被缩容杀掉
- 限流与防刷:每个 IP / 每个用户都要有 Bucket4j 风格的令牌桶
- 短期记忆 + 长期记忆:短期 sliding window + 长期 4 种策略(Semantic / User Preference / Summary / Episodic)
- 认证与授权:OAuth2 Client Credentials + IAM SigV4 双轨
- 会话隔离 :从
X-Amzn-Bedrock-AgentCore-Runtime-Session-Id头提取 sessionId 并注入 ChatMemoryCONVERSATION_ID - 工具回调挂载 :浏览器(Playwright via CDP)和代码执行(Python/JS 沙箱)作为
ToolCallbackProvider注入 ChatClient - A2A 协议适配:stateless streamable HTTP server on port 9000
- MCP 工具网关:通过 AgentCore Gateway 暴露工具,凭据由 IAM 统一管理
- 可观测性:Otel Span + Micrometer Metric + Tool call 日志 + Memory 检索日志
- 部署可移植:既能上 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 个核心类(@AgentCoreInvocation、AgentCoreContext、AgentCoreHeaders、AgentCoreMemory、AgentCoreTaskTracker、AgentCoreInvocationsHandler、AgentCorePingHandler、Bucket4jRateLimiter),紧扣一个完整的企业级研究 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)提供了 ChatClient、Advisor、ChatMemory、ToolCallback 等抽象,但这些抽象都是 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 /invocations 和 GET /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 返回 Healthy。Runtime 看到 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 实例,可以和 MessageChatMemoryAdvisor、QuestionAnswerAdvisor、ToolCallingAdvisor、SafeGuardAdvisor 等任意组合。建议顺序:
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 个核心类(@AgentCoreInvocation、AgentCoreContext、AgentCoreHeaders、AgentCoreMemory、AgentCoreTaskTracker、AgentCoreInvocationsHandler、AgentCorePingHandler、Bucket4jRateLimiter),从原理到实战踩了 10 个坑,覆盖了 AgentCore Runtime 契约的全部边界条件。
最后一句话:Java 工程师做 AI,工程化能力才是护城河。AgentCore SDK 证明 Java 生态已经在把"工具链"补齐------剩下的事,就是 Java 工程师发挥熟悉的工程化优势,把它落地到生产环境。
参考:
-
AWS Machine Learning Blog: Spring AI SDK for Amazon Bedrock AgentCore is now Generally Available
-
Spring AI Community: spring-ai-bedrock-agentcore
-
Spring 官方文档: Spring AI Reference