Google ADK Java 1.13.0 已经能把整张工作流图跑完:并行分支、汇合、按路由分流,一次 runAsync 走到底。实测里两条并行分支的 runNode 都在调用线程上被依次调起,节点返回异步的 Flowable 时两条分支确实重叠,总耗时 306ms;节点里直接 Thread.sleep 时变成 609ms,两条分支在排队。同一版本里 ADK 工具转成 MCP 工具时的 inputSchema 改成了合法的 JSON Schema,开发服务器也默认拒绝跨域请求,这两处我顺带对比了 1.12.0 和 1.13.0。
整个示例没有调用任何模型 API。
这次发布了什么
1.13.0 的 GitHub release 标注日期是 2026-10-21,Maven Central 上 com.google.adk:google-adk 和 google-adk-dev 的 1.13.0 都已可下载。和工作流相关的三条是 run a workflow graph、run a workflow node as the invocation root、run a single workflow node,另有一个构建边的 EdgesBuilder。安全相关的两条修在开发服务器上:CORS 和 /run_live 默认只允许同源,以及拒绝经回环地址进来的 DNS rebinding 请求。还有一条修复让 ADK 工具转成 MCP 工具时输出合法的 JSON Schema。
之前那篇讲 ADK Java 1.12.0 工作流图的文章里,Workflow.runNode 还直接抛 UnsupportedOperationException,只能单独跑某个节点。1.13.0 的源码里,Workflow.runNode 改为交给新增的 Scheduler 从起点调度整张图。按它的 Javadoc,图里只有一个终点节点产出结果时,这个结果就是工作流的输出;多于一个终点产出结果,返回的 Flowable 会以 WorkflowConfigurationException 失败。Workflow 和相关类型仍然标着 @Experimental。
验证项目
环境是 JDK 21.0.12.1、Maven 3.9.9,Spring Boot 4.0.5 作父 POM。ADK 版本用一个 Maven profile 切换,1.12.0 的构建把只有 1.13 才能编译的工作流代码排除掉。JSON Schema 元模式校验用的是 networknt 的 json-schema-validator。两个版本的 ADK 都依赖 MCP Java SDK 2.0.0,这一点在打出来的 jar 里核对过。
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.5</version>
</parent>
<dependency>
<groupId>com.google.adk</groupId>
<artifactId>google-adk</artifactId>
<version>${adk.version}</version>
</dependency>
<dependency>
<groupId>com.google.adk</groupId>
<artifactId>google-adk-dev</artifactId>
<version>${adk.version}</version>
</dependency>
<dependency>
<groupId>com.networknt</groupId>
<artifactId>json-schema-validator</artifactId>
<version>1.5.6</version>
</dependency>
mvn -q clean package -DskipTests -Padk112 # adk.version=1.12.0
mvn -q clean package -DskipTests # adk.version=1.13.0
java -jar jars/adk-demo-1.13.0.jar --demo.mode=workflow
java -jar jars/adk-demo-1.12.0.jar --demo.mode=mcp
java -jar jars/adk-demo-1.13.0.jar --demo.mode=mcp
整张图跑起来了
图的结构沿用 1.12 那篇的客服场景:起点同时触发查订单和查用户,JoinNode 等两边都完成后汇合,triage 节点看订单状态,已发货就选 auto 路由,否则不选路由,交给 Route.Default 那条边转人工。1.12 那篇里有一个 LlmAgent 节点,这次换成了普通节点,因为跑 LlmAgent 就要真的请求模型。
/** 查本地数据的节点,不调模型。blocking=true 时在节点里直接 sleep,否则用 delay 异步等待。 */
static final class LookupNode extends BaseNode {
private final Map<String, String> data;
private final boolean blocking;
LookupNode(String name, Map<String, String> data, boolean blocking) {
super(name, "local lookup: " + name);
this.data = data;
this.blocking = blocking;
}
@Override
public Flowable<?> runNode(Context context, @Nullable Object nodeInput) {
String key = textOf(nodeInput);
System.out.println(" [node] " + name() + " runNode on " + Thread.currentThread().getName());
String value = data.getOrDefault(key, "unknown");
if (blocking) {
sleep(300);
return Flowable.just(value);
}
return Flowable.just(value).delay(300, TimeUnit.MILLISECONDS);
}
}
/** 已发货走 auto 路由,否则不选路由,交给 Default 边。 */
static final class TriageNode extends BaseNode {
TriageNode() {
super("triage", "pick a route");
}
@Override
public Flowable<?> runNode(Context context, @Nullable Object nodeInput) {
Map<?, ?> joined = (Map<?, ?>) nodeInput;
if ("shipped".equals(joined.get("fetch_order"))) {
context.routes(List.of(new Route.Tag("auto")));
}
return Flowable.just(joined);
}
}
static Workflow supportFlow(boolean blocking) {
LookupNode order = new LookupNode("fetch_order", Map.of("A-1001", "shipped", "A-1002", "pending"), blocking);
LookupNode user = new LookupNode("fetch_user", Map.of("A-1001", "alice", "A-1002", "bob"), blocking);
JoinNode join = JoinNode.builder().name("merge").build();
TriageNode triage = new TriageNode();
return Workflow.builder()
.name("support_flow")
.edges(
new Edge(Start.INSTANCE, order),
new Edge(Start.INSTANCE, user),
new Edge(order, join),
new Edge(user, join),
new Edge(join, triage),
new Edge(triage, new ReplyNode("auto_reply", "AUTO"), new Route.Tag("auto")),
new Edge(triage, new ReplyNode("human_review", "HUMAN"), Route.Default.INSTANCE))
.build();
}
工作流通过 App.builder().rootNode(...) 交给 Runner,用户消息就是订单号,起点把它原样交给两个后继节点:
private void run(String label, Workflow workflow, String orderId) {
App app = App.builder().name("support_app").rootNode(workflow).build();
Runner runner = Runner.builder().app(app).build();
Session session = runner.sessionService().createSession("support_app", "u1").blockingGet();
Content message = Content.fromParts(Part.fromText(orderId));
long start = System.nanoTime();
List<Event> events = runner.runAsync("u1", session.id(), message).toList().blockingGet();
long ms = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);
if (label.equals("warmup")) {
System.out.println("[warmup] events=" + events.size() + " elapsed=" + ms + "ms");
return;
}
for (Event e : events) {
System.out.println("[" + label + "] " + e.nodeInfo().map(n -> n.path()).orElse("-")
+ " author=" + e.author() + " output=" + e.output().orElse(null)
+ e.actions().route().map(r -> " route=" + r).orElse(""));
}
System.out.println("[" + label + "] " + orderId + " events=" + events.size() + " elapsed=" + ms + "ms");
}
最后再放一个两个终点都会产出结果的图,看 Javadoc 说的异常:
Workflow twoOutputs = Workflow.builder()
.name("two_outputs")
.edges(
new Edge(Start.INSTANCE, new ConstNode("left")),
new Edge(Start.INSTANCE, new ConstNode("right")))
.build();
try {
run("two_outputs", twoOutputs, "A-1001");
} catch (RuntimeException ex) {
System.out.println("[two_outputs] " + ex.getClass().getSimpleName() + ": " + ex.getMessage());
}
完整输出:
[node] fetch_order runNode on main
[node] fetch_user runNode on main
[warmup] events=6 elapsed=557ms
[node] fetch_order runNode on main
[node] fetch_user runNode on main
[support_flow] support_flow@1/fetch_order@1 author=support_flow output=shipped
[support_flow] support_flow@1/fetch_user@1 author=support_flow output=alice
[support_flow] support_flow@1/merge@1 author=support_flow output={fetch_order=shipped, fetch_user=alice}
[support_flow] support_flow@1/triage@1 author=support_flow output={fetch_order=shipped, fetch_user=alice}
[support_flow] support_flow@1/triage@1 author=support_flow output=null route=[Tag[value=auto]]
[support_flow] support_flow@1/auto_reply@1 author=support_flow output=AUTO user=alice order=shipped
[support_flow] A-1001 events=6 elapsed=306ms
[node] fetch_order runNode on main
[node] fetch_user runNode on main
[support_flow] support_flow@1/fetch_order@1 author=support_flow output=pending
[support_flow] support_flow@1/fetch_user@1 author=support_flow output=bob
[support_flow] support_flow@1/merge@1 author=support_flow output={fetch_order=pending, fetch_user=bob}
[support_flow] support_flow@1/triage@1 author=support_flow output={fetch_order=pending, fetch_user=bob}
[support_flow] support_flow@1/human_review@1 author=support_flow output=HUMAN user=bob order=pending
[support_flow] A-1002 events=5 elapsed=309ms
[node] fetch_order runNode on main
[node] fetch_user runNode on main
[support_flow(blocking)] support_flow@1/fetch_order@1 author=support_flow output=shipped
[support_flow(blocking)] support_flow@1/fetch_user@1 author=support_flow output=alice
[support_flow(blocking)] support_flow@1/merge@1 author=support_flow output={fetch_order=shipped, fetch_user=alice}
[support_flow(blocking)] support_flow@1/triage@1 author=support_flow output={fetch_order=shipped, fetch_user=alice}
[support_flow(blocking)] support_flow@1/triage@1 author=support_flow output=null route=[Tag[value=auto]]
[support_flow(blocking)] support_flow@1/auto_reply@1 author=support_flow output=AUTO user=alice order=shipped
[support_flow(blocking)] A-1001 events=6 elapsed=609ms
[two_outputs] WorkflowConfigurationException: Workflow 'two_outputs': multiple terminal nodes produced output (2). A workflow must have at most one terminal output.
exit=0
A-1001 走了 auto_reply,A-1002 没有选路由,落到了 human_review。选了路由的 triage 多出一条事件,output 为空,actions().route() 里带着 [Tag[value=auto]],所以 A-1001 是 6 条事件,A-1002 是 5 条。所有事件的 author 都是 support_flow,这和 Workflow.runNode 里“内部节点以工作流作为事件作者,Agent 节点保留自己”的注释一致。两个终点都产出结果时,runAsync 直接以 WorkflowConfigurationException 失败。
并行分支和线程
日志里每次运行,fetch_order 和 fetch_user 的 runNode 都打印在 main 线程上。异步版本里每个节点用 delay 等 300ms,预热之后整次运行 306ms 和 309ms,两条分支是重叠的。阻塞版本在 runNode 里直接睡 300ms,整次 609ms,正好是两段相加。
原因是我读 1.13.0 源码得出的结论,release notes 和文档都没有这样写:Scheduler 启动节点时直接订阅 NodeRunner 的结果,没有指定 subscribeOn 之类的调度器,整个 workflow 包里也找不到 subscribeOn 或 observeOn,所以节点不会被切到别的线程,runNode 就在调度循环所在的线程上执行。节点返回的 Flowable 自己切线程(比如 delay 默认用 computation 调度器)时分支才会重叠,节点里同步阻塞会把后面的分支一起挡住。Workflow.Builder 上的 maxConcurrency 只限制同时在跑的节点数量,这次的示例没有设置它。
ADK 工具转成 MCP 工具的 inputSchema
ConversionUtils.adkToMcpToolType 在 ADK 把自己的工具通过 MCP 暴露出去时使用。我准备了三个工具:一个 FunctionTool 从静态方法生成;一个自定义工具的声明里只有 parametersJsonSchema;一个用 genai Schema 声明,其中 note 字段标了 nullable。转出来的 inputSchema 用 networknt 校验器按 JSON Schema 2020-12 的元模式校验一遍。只有 parametersJsonSchema 的那个工具是这样声明的:
/** 声明里只有 parametersJsonSchema,常见于从外部 OpenAPI 或 MCP 定义转过来的工具。 */
static final class RefundTool extends BaseTool {
RefundTool() {
super("refund", "按订单号退款");
}
@Override
public Optional<FunctionDeclaration> declaration() {
return Optional.of(FunctionDeclaration.builder()
.name(name())
.description(description())
.parametersJsonSchema(Map.of(
"type", "object",
"properties", Map.of(
"orderId", Map.of("type", "string"),
"amount", Map.of("type", "number", "minimum", 0)),
"required", List.of("orderId", "amount")))
.build());
}
}
### google-adk 1.12.0
[mcp] lookupOrder inputSchema={"type":"OBJECT","properties":{"orderId":{"description":"订单号","type":"STRING"},"includeItems":{"description":"是否带明细","type":"BOOLEAN"}},"required":["orderId"]}
[mcp] lookupOrder metaschema errors=6 first=$.properties.orderId.type: does not have a value in the enumeration ["array", "boolean", "integer", "null", "number", "object", "string"]
[mcp] refund inputSchema={"type":"object"}
[mcp] refund metaschema errors=0
[mcp] add_note inputSchema={"type":"OBJECT","properties":{"note":{"nullable":true,"type":"STRING"},"orderId":{"type":"STRING"}},"required":["orderId"]}
[mcp] add_note metaschema errors=6 first=$.properties.note.type: does not have a value in the enumeration ["array", "boolean", "integer", "null", "number", "object", "string"]
exit=0
### google-adk 1.13.0
[mcp] lookupOrder inputSchema={"properties":{"orderId":{"description":"订单号","type":"string"},"includeItems":{"description":"是否带明细","type":"boolean"}},"required":["orderId"],"type":"object"}
[mcp] lookupOrder metaschema errors=0
[mcp] refund inputSchema={"type":"object","required":["orderId","amount"],"properties":{"amount":{"type":"number","minimum":0},"orderId":{"type":"string"}}}
[mcp] refund metaschema errors=0
[mcp] add_note inputSchema={"properties":{"orderId":{"type":"string"},"note":{"type":["string","null"]}},"required":["orderId"],"type":"object"}
[mcp] add_note metaschema errors=0
exit=0
1.12.0 有两类问题。genai Schema 里大写的 OBJECT、STRING 和 JSON Schema 里没有的 nullable 原样进了 inputSchema,两个工具各报 6 个错误。只声明了 parametersJsonSchema 的 refund 校验能过,但参数定义整个丢了,只剩 {"type":"object"},MCP 客户端看到的是一个不需要参数的工具。
1.13.0 里三个 schema 都通过了校验,类型改成小写,note 变成 ["string","null"],refund 的参数定义也保留了。按 1.13.0 的源码,转换时优先使用非空的 parametersJsonSchema,没有才转换 genai Schema,两者都没有时给 {"type":"object"}。
开发服务器默认拒绝跨域请求
开发服务器用 AdkWebServer.start(agent) 挂一个不会被调用的 LlmAgent 启动,默认配置下用 curl 带 Origin: https://evil.example 请求 /list-apps:
### google-adk 1.12.0 port=8091 jvm=[]
foreign origin Origin: https://evil.example -> HTTP/1.1 200 | Access-Control-Allow-Origin: * | body=["support_agent"]
### google-adk 1.13.0 port=8092 jvm=[]
foreign origin Origin: https://evil.example -> HTTP/1.1 403 | body=Invalid CORS request
1.12.0 对任何来源都放行并返回 Access-Control-Allow-Origin: *,1.13.0 默认只放行同源,同一个跨域请求变成 403。
一起交流
分享你的思考,让讨论更进一步。