Administrator
发布于 2026-07-25 / 189 阅读
2

AgentScope Java 2.0:阿里的企业级多智能体底座

为什么开始看 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,417ms128ms-95%
镜像体积78MB(JRE 层)+ 42MB46MB-62%
常驻内存(RSS)310MB96MB-69%
稳态吞吐(QPS)184161-12.5%
P99 延迟(稳态)7.9s8.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:阿里的企业级多智能体底座》里这个坑,你当时是怎么处理的?欢迎在评论区聊聊你踩过的类似情况。

参考