为什么开始看 AgentScope Java
我们团队的主力框架是 Spring AI 2.0,用了快一年。真正让我开始评估 AgentScope Java 2.0 的,是 6 月底一次多 Agent 协作的需求。
需求本身不复杂:一个"故障分析"场景,需要日志分析 Agent、代码检索 Agent、变更记录 Agent 三个家伙协作,最后汇总。用 Spring AI 也能写,但越写越别扭——三个 Agent 之间的消息传递、中间结果的拦截、某个 Agent 卡住之后的超时处理,这些框架不管,全得自己搭。
7 月初拿到 AgentScope Java 2.0 的早期版本,我们花了两周做了完整评估。这篇是评估结论和实测数据,不是软文,里面也有不少我们不满意的地方。
Hook 系统:能在"思考"中间插手
这是我看到 AgentScope Java 之后最兴奋的一个设计。
大部分 Java Agent 框架给你的是"调用前"和"调用后"两个扩展点。AgentScope 2.0 的 Hook 粒度细到可以在模型流式输出的每一个 chunk、每一次工具调用的入参和返回值上插入逻辑,而且可以修改,不只是观察。
AgentScope.builder()
.agent(diagnoseAgent)
.hook(Hooks.onPreReasoning((ctx, msgs) -> {
// 模型开始"思考"之前,注入当前系统的实时状态
msgs.add(SystemMessage.of(loadClusterState()));
return msgs;
}))
.hook(Hooks.onToolCall((ctx, call) -> {
// 工具调用前:参数校验 + 危险操作拦截
if (isDangerous(call.name()) && !ctx.hasHumanApproval()) {
return ToolCallResult.denied("需要人工确认");
}
return call.proceed();
}))
.hook(Hooks.onToolResult((ctx, call, result) -> {
// 工具返回后:脱敏 + 截断
return result.map(r -> r.truncate(4000).redact(PII_RULES));
}))
.hook(Hooks.onStreamingChunk((ctx, chunk) -> {
// 流式输出每一片都过一遍内容安全
return guard.check(chunk).isBlocked() ? chunk.replace("*") : chunk;
}))
.build();
我们拿这个做了件之前很难做的事:工具调用的结果截断。
日志分析 Agent 调 ES 查日志,有时候一次返回 8 万行。以前只能在工具内部截断,或者等上下文爆炸。现在在 onToolResult 里统一处理,所有工具一个策略。
数据:上线前工具返回值平均 4,180 token,P99 达到 31,000 token。加了截断 Hook(阈值 4,000)之后,平均降到 1,640 token,上下文整体的 P99 从 71,000 降到 18,400,长尾请求的成本降了 74%。
Hook 的性能开销我们压测过,单个 Hook 平均 0.8ms(我们的 Hook 逻辑本身有 Redis 查询)。空 Hook 的框架开销可以忽略,大概是每次 12 微秒。
但有个坑:Hook 里抛异常的行为不一致。onPreReasoning 里抛异常会中断整个 Agent,onToolResult 里抛异常会被吞掉并转成空结果。我们第一版在 onToolResult 里做长度校验失败就抛异常,结果 Agent 拿到空字符串还继续推理,最后给出了个莫名其妙的结论。文档里没写清楚,读源码才发现的。现在我们的约定是:Hook 里永不抛异常,一律返回降级结果 + 打标。
响应式架构:全链路 Reactor
AgentScope Java 2.0 的整个执行引擎是构建在 Project Reactor 上的,所有 Agent 的 reply 返回 Mono<Msg>,流式返回 Flux<Chunk>。
这个设计对多 Agent 编排的收益是实打实的:
Mono<Report> diagnose(String incidentId) {
Mono<LogResult> logs = logAgent.replyAsync(query(incidentId));
Mono<CodeResult> code = codeAgent.replyAsync(query(incidentId));
Mono<ChangeResult> change = changeAgent.replyAsync(query(incidentId));
return Mono.zip(logs, code, change)
.timeout(Duration.ofSeconds(12))
.flatMap(t -> summaryAgent.replyAsync(merge(t)))
.onErrorResume(TimeoutException.class,
ex -> summaryAgent.replyAsync(partial(tuple)))
.map(Report::from);
}
三个 Agent 真并发,总耗时取决于最慢的那个。我们实测并行化之后,一次诊断的 P99 从 21.4 秒降到 8.9 秒。
不过我要说句不讨喜的话:响应式编程的调试成本是真高。
7 月 12 日出过一次线上问题,某个 Agent 的执行莫名卡住 30 秒然后超时。查了四个小时,最后发现是下游一个返回 Flux 的工具在 flatMap 里被并发订阅,而那个 Flux 底层是个只能消费一次的 HTTP 流。堆栈长这样,基本看不出问题在哪:
reactor.core.Exceptions$ErrorCallbackNotImplemented:
java.lang.IllegalStateException: HTTP stream already consumed
at reactor.core.publisher.FluxFlatMap$FlatMapMain.onError(...)
at reactor.core.publisher.Operators.error(...)
at io.agentscope.core.agent.AgentBase.lambda$reply$12(AgentBase.java:412)
... 47 more common frames omitted
47 层框架栈。我们最后靠加 Hooks.onOperatorDebug() 才定位到。团队里两个同学明确表示"不喜欢这种代码",我也理解——在业务系统里,一个 try-catch 能说清楚的事,Reactor 里要写 onErrorResume + doOnError + 记日志三处。
我的态度是:编排层用响应式没问题,业务工具实现老老实实写同步代码,用 Mono.fromCallable() 包一层,别让 Reactor 渗透到工具内部。
GraalVM 原生镜像:冷启动实测
这块是我们评估的重点,因为有个场景是跑在 K8s 上按需拉起的分析 Agent,冷启动直接决定了用户体验。
原生镜像编译踩的坑主要在三处,都跟反射有关:
// 1. 工具的序列化:必须提前注册
@RegisterReflectionForBinding({
LogQueryRequest.class,
LogQueryResponse.class,
CodeSearchResult.class
})
public class NativeHints implements RuntimeHintsRegistrar {
public void registerHints(RuntimeHints h, ClassLoader cl) {
h.resources().registerPattern("agentscope/*.json");
h.resources().registerPattern("prompts/*.md");
h.proxies().register(JdkProxyHint.of(HttpClient.class, Closeable.class));
}
}
第二处是 HTTP 客户端。默认的 JDK HttpClient 在原生镜像里能用,但连接池的某些参数反射拿不到,要手动设。第三处是动态代理,我们的 MCP 客户端用了 JDK 动态代理,必须显式注册。
编译配置:
$ native-image -jar diag-agent.jar \
--enable-http --enable-https \
-H:ReflectionConfigurationResources=reflect-config.json \
-H:+ReportExceptionStackTraces \
--initialize-at-build-time=io.netty.util.internal.logging \
-J-Xmx6g -O2 --verbose
[1/8] Initializing... (4.2s @ 0.28GB)
[2/8] Performing analysis... (142.7s @ 3.10GB)
[3/8] Building universe... (18.4s @ 2.94GB)
...
Finished generating 'diag-agent' in 3m 41s.
编译 3 分 41 秒,这个时间对 CI 来说是能接受的(我们原来是 2 分 10 秒的普通构建)。
实测数据,K8s 环境,2 核 4G:
| 指标 | JVM 模式 | 原生镜像 | 变化 |
|---|---|---|---|
| 冷启动到可服务 | 2,417ms | 128ms | -95% |
| 镜像体积 | 78MB(JRE 层)+ 42MB | 46MB | -62% |
| 常驻内存(RSS) | 310MB | 96MB | -69% |
| 稳态吞吐(QPS) | 184 | 161 | -12.5% |
| P99 延迟(稳态) | 7.9s | 8.4s | +6% |
稳态吞吐下降 12.5% 是预期的——没有 JIT 的峰值优化。但对我们的场景(低频按需拉起)完全值得,因为 95% 的请求生命周期短于 30 秒,还没等到 JIT 预热就结束了。
内存降到 96MB 之后,我们把 Pod 的 limit 从 4G 调到 1G,单节点的调度密度上去了,整个集群的机器从 18 台降到 11 台,一个月省 ¥1.7 万。
要说缺点,除了编译慢,还有一个:排查问题变难了。原生镜像里拿不到运行时堆栈的类和行号(除非加 -H:+SourceLevelDebug,会让镜像大 40%),也不能动态 attach 做 profiling。我们现在的做法是灰度环境一律跑 JVM 模式,只有生产跑原生镜像。
A2A 通信:跨语言协作
A2A(Agent-to-Agent)这块我们测的是 Java 和 Python Agent 互通。场景是:算法团队用 Python 写了个异常检测 Agent,我们用 Java 写编排层。
Agent 卡片(Agent Card)是核心,本质是一份能力声明:
{
"name": "anomaly-detector",
"version": "2.1.0",
"url": "https://a2a.internal/agents/anomaly",
"capabilities": { "streaming": true, "pushNotifications": false },
"skills": [{
"id": "detect-timeseries",
"name": "时序异常检测",
"inputModes": ["application/json"],
"outputModes": ["application/json"],
"examples": ["检测 order-service 过去 1 小时的错误率是否异常"]
}]
}
Java 侧调用:
A2aClient client = A2aClient.discover("https://a2a.internal/.well-known/agent-card.json");
Flux<TaskUpdate> stream = client.sendTask(
TaskRequest.builder()
.skill("detect-timeseries")
.input(Map.of("service", "order-service", "window", "1h"))
.streaming(true)
.build());
stream.doOnNext(u -> log.info("progress: {}", u.state()))
.last()
.subscribe(u -> handle(u.result()));
实测:跨语言调用相对同进程调用,P99 增加 34ms,主要是序列化和 HTTP 开销。可以接受。
但 A2A 有个我认为比较严重的设计问题:它只定义了"任务"层面的协议,没有定义"上下文"层面。也就是说,两个 Agent 之间传递的是任务和结果,不是共享的对话上下文。这意味着如果你要让两个 Agent 真正"讨论",还得自己在业务层做。
我们的做法是在业务层维护一个共享的黑板(blackboard),A2A 只传引用:
// 黑板上存完整上下文,A2A 消息里只带黑板 ID 和指针
TaskRequest.builder()
.skill("detect-timeseries")
.input(Map.of("blackboardId", bb.id(), "ref", "log_summary#3"))
.build();
这个模式我们在三个协作场景里都用上了,还挺顺手。不过这是我们自己加的,不是框架能力。
我们最后怎么用的
结论是局部采用,不全量切换。
- 简单的单 Agent 场景继续用 Spring AI 2.0,团队熟,生态全,没必要动;
- 多 Agent 协作、需要细粒度干预的场景用 AgentScope Java 2.0,目前上了 2 个;
- 按需拉起的短生命周期 Agent 走 GraalVM 原生镜像,目前 4 个。
没有全切的原因有三个,都是实际顾虑:
版本稳定性。2.0 的 API 在我们评估的这两周里改过两次,其中一次是 Hook 接口签名变更。我们评估用的是早期版本,正式版可能还会变。生产系统经不起这个。
团队学习成本。Reactor + Hook + A2A 三套概念,我们 6 个人的团队,两个人完全适应,三个人半懂,一个人明确抵触。全切的话风险太大。
与 Spring 生态的整合度。AgentScope 有自己的配置、生命周期和依赖注入风格,跟 Spring 的 @Configuration、@Bean 那套不完全一致。我们做了一些适配,但谈不上优雅。相比之下 Spring AI 在这块的优势是压倒性的。
留个问题
关于《AgentScope Java 2.0:阿里的企业级多智能体底座》里这个坑,你当时是怎么处理的?欢迎在评论区聊聊你踩过的类似情况。