AgentScope第25式·众星协同

AgentScope第25式·异步与远程

同步 subagent 把三条并行查询串成了一条链。

[读者] 已经用 SubagentsMiddleware 拆出子 Agent 的 Java 后端
[痛点] 串行子 Agent 耗时相加,后台任务既查不到也取消不掉
[现在读] 第24式让子 Agent 跑起来了,这一式要让它并行、可取消、能跨副本找回
[读完] 能设计一套带任务状态、可取消、可远程、可恢复的异步 Subagent 架构
[旧方案] 主 Agent 依次同步调用子 Agent,等一个再发下一个
    |
    v
[新需求] 查知识 + 查客户 + 查订单 并行 fan-out,耗时报告丢后台生成
    |
    v
[冲突] 同步调用的任务状态活在调用栈里,栈一断,任务就再也找不回来
    |
    v
[后果] 耗时相加、线程被占、副本一重启后台任务永久失联

我是老李,一条线做了十几年 Java 后端,最近几式一直在同一个数字人 Demo 上演进,仓库在 https://github.com/lifuchun522/agentscopepractice ,教程基线锁定 AgentScope Java v2.0.3,第一手资料优先看 https://java.agentscope.io/ 与 https://github.com/agentscope-ai/agentscope-java 。

第24式结束时,主 Agent 已经能派子 Agent 出去干活:查内部知识库、查客户档案、查订单系统各一个。能跑通,但一次问答的总耗时是三次外部调用的时间之和,用户点一次「不用了」,服务端没有任何东西可以被取消。

这一式要动的是调用方式本身,而不是再写一个子 Agent。

同步调用明明更好写,为什么非要把任务状态从方法栈里搬出来?

并行发三路就够了,还是必须补一张任务表?

如果不把任务状态外置,第一次上多副本部署时崩掉的会是什么?

01、故事

数字人项目的运行页是 /run/{projectId}。一个用户在里面问:「客户星河科技上季度的订单怎么样,我们的售后政策怎么说的?」

主 Agent 要答这一句,得跑三件事:查内部知识库的售后政策、查客户系统里的星河科技档案、查订单系统里的上季度订单。

任务是:把这三件事并行 fan-out 出去,主 Agent 只负责汇总;同时把后面「生成一份耗时报告」的工作丢到后台,不让它堵住用户这一轮对话。

第24式的写法很直接:主 Agent 在推理循环里依次叫三个子 Agent。日志一眼能看出问题——三次调用首尾相接,像排队买饭。

更麻烦的是另外两件事。第一,用户在页面上等不到,刷新了一下,前一次请求的那三个子 Agent 还在跑,谁也不知道它们跑到哪了。第二,我们准备把这个服务上两个副本,于是第三个问题马上来了——如果用户刷新后请求被负载均衡甩到了另一个副本,那个副本根本不知道前一个副本上还有任务在跑。

同步调用能跑,是因为任务状态和调用栈是绑在一起的。栈在,状态在;栈没了,状态就没了。

一问三处查
一等三回响
一刷新成空
一换机即亡

02、问题

旧方案在第24式是成立的,但它的成立前提是「一次请求 = 一根调用栈」。

业务影响有三层。用户侧:一次问答要等三次外部查询相加,任何一路外部系统慢,整个人都慢。服务侧:每一路 fan-out 都占着一个线程在等,峰值时线程池先崩。运维侧:后台任务没有句柄,日志里只有一个 requestId,跨副本之后什么都查不到。

技术表现也很具体:没有任务 ID,只有一次调用返回的结果;没有取消点,最多能做到「不再等」,做不到「不再跑」;没有状态表,状态的唯一载体是 JVM 里的那个栈。

所以这一式的完成标准必须可以验证,而不是「感觉快了」:

  1. 同一个请求,并行 fan-out 的总耗时明显小于串行。
  2. 后台任务能按 taskId 查询状态,也能被取消。
  3. 换一个副本,凭 taskId 仍然能查到任务状态。

第3条是关键,它决定了第1条和第2条能不能在分布式环境下成立。

一栈担三事
一断皆成谜
一表方有据
一柄始可追

03、原理

先给一个反直觉判断:

反直觉判断:并行的收益不来自线程数,而来自任务状态的所有权。只要任务状态还挂在调用栈上,「并行」就只是把多个阻塞点叠在同一根栈上而已。

这句话能解释后面所有的架构选择。

几个名词先划边界,一句话就够:

  • Subagent:由主 Agent 派生出来、拥有独立上下文的子 Agent,它不是线程,也不是微服务。
  • Middleware:在 Agent 推理循环的固定切点插桩的组件,框架侧的挂载方式对照 v2.0.3 的 SubagentsMiddleware 与 DynamicSubagentsMiddleware。
  • A2A:Agent 之间的远程调用协议,让一个子 Agent 从「本进程里的一个对象」变成「另一台机器上的一个 Agent」。

同步 Subagent 和异步 Subagent 的差别,不在快慢,在返回什么。

同步返回结果:调用方拿到的是最终答案,代价是调用方必须一直等。异步返回句柄:调用方拿到的是一个 taskId,结果稍后再取,代价是必须有人保存这个 taskId 对应的状态。

任务状态一旦有了归属,四个能力才谈得上:并行,三路各自持有句柄,互不阻塞;取消,句柄指向的状态可以被改成 CANCELLING;后台,句柄可以先返回给用户,结果稍后落地;跨副本恢复,句柄落在共享存储里,任何副本都能接管。

反过来说,如果任务状态还在栈上,你只能实现「并行发出」,实现不了「并行等待」——因为等待语义本身就要求有个东西代表「这个任务」。

官方文档在 v2.0.3 的 subagent 一节里描述子 Agent 的组织方式,A2A 集成一节描述远程 Agent 的协议位置,两边都要看,因为它们分别解决「并行」和「远程」,而这两件事的公共前置条件是同一件事:任务状态必须离开调用栈。参考:https://github.com/agentscope-ai/agentscope-java/blob/v2.0.3/docs/v2/zh/docs/harness/subagent.md 与 https://github.com/agentscope-ai/agentscope-java/blob/v2.0.3/docs/v2/zh/integration/protocol/a2a.md 。具体签名与配置键以 v2.0.3 固定 tag 的源码与文档为准,本文只固定架构层面的判断。

状态不归栈
并行才成立
句柄先于果
取消才有处

04、架构

[输入] 用户在 /run/{projectId} 提一句业务问题
    |
    v
[模块] 主 Agent + 异步 Subagent 调度器 + 远程 Agent 客户端
    |
    v
[数据/状态] 任务表(taskId / projectId / kind / status / result / ownerNode)
    |
    v
[处理] fan-out 派发 -> 句柄立即返回 -> 并行等待 -> 主 Agent 汇总 -> 后台报告任务
    |
    v
[输出] 汇总答复 + 一批可查询的后台任务句柄

边界:主 Agent 只负责决定「要查哪几路」和「怎么汇总」,不负责执行外部查询;异步调度器只负责任务生命周期——创建、派发、等待、取消、状态落库;远程 Agent 客户端只负责协议适配,不承载业务逻辑。

收益:三路查询可以真正并发;后台任务有句柄;取消有落点;副本切换后任务能被接管。

代价:多了一张任务表和一套状态机,事务边界变复杂,失败语义也要重新定义——「失败」到底指哪一次调用失败,得提前说清楚。

适用条件:一次请求内需要聚合多个独立数据源,且这些数据源的耗时不可忽略。

不适用条件:只有一路查询、查询本身极快、或者下游系统不支持重复执行——这三种情况下,异步只会把简单问题复杂化,甚至制造出重复写入。

源码位置:并行 fan-out 与动态子 Agent 的中间件实现,官方固定在 agentscope-harness 模块的 io.agentscope.harness.agent.middleware 包下,分别是 SubagentsMiddleware 与 DynamicSubagentsMiddleware;远程部分看 A2A 集成文档。需要判断某个行为到底属于哪一层时,回到对应文件的方法入口看调用链,不要靠类名猜。

主脑只汇总
调度管生死
协议作薄壳
任务落一表

05、实战一次

环境与版本:JDK 17、Maven、Spring Boot 3、教程基线 AgentScope Java v2.0.3。

分支从第24式继续创建:

git checkout chapter/24-subagent
git checkout -b chapter/25-async-remote-agent

依赖沿用第24式的构建配置,只在业务模块内新增 JdbcTemplate 用于任务表。AgentScope 相关坐标以官方仓库 pom 为准,本文不假设 groupId,避免写错坐标。

先建任务表:

CREATE TABLE agent_subagent_task (
    task_id     VARCHAR(64)  NOT NULL PRIMARY KEY,
    project_id  VARCHAR(64)  NOT NULL,
    kind        VARCHAR(32)  NOT NULL,
    status      VARCHAR(16)  NOT NULL,
    result_json TEXT         NULL,
    owner_node  VARCHAR(64)  NULL,
    created_at  DATETIME(3)  NOT NULL,
    updated_at  DATETIME(3)  NOT NULL,
    KEY idx_project_status (project_id, status)
);

任务记录:

public record SubagentTask(
        String taskId,
        String projectId,
        String kind,
        String status,
        String resultJson,
        String ownerNode,
        Instant createdAt,
        Instant updatedAt) {
}

仓储接口与实现:

public interface SubagentTaskRepository {
    void insert(SubagentTask task);
    Optional<SubagentTask> find(String taskId);
    void updateStatus(String taskId, String status, String resultJson, String ownerNode);
    List<SubagentTask> findByProject(String projectId);
}
@Repository
public class JdbcSubagentTaskRepository implements SubagentTaskRepository {

    private final JdbcTemplate jdbcTemplate;

    public JdbcSubagentTaskRepository(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    @Override
    public void insert(SubagentTask task) {
        jdbcTemplate.update(
                "INSERT INTO agent_subagent_task(task_id, project_id, kind, status, result_json, owner_node, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)",
                task.taskId(), task.projectId(), task.kind(), task.status(),
                task.resultJson(), task.ownerNode(),
                Timestamp.from(task.createdAt()), Timestamp.from(task.updatedAt()));
    }

    @Override
    public Optional<SubagentTask> find(String taskId) {
        List<SubagentTask> rows = jdbcTemplate.query(
                "SELECT * FROM agent_subagent_task WHERE task_id = ?",
                (rs, i) -> mapRow(rs), taskId);
        return rows.stream().findFirst();
    }

    @Override
    public void updateStatus(String taskId, String status, String resultJson, String ownerNode) {
        jdbcTemplate.update(
                "UPDATE agent_subagent_task SET status = ?, result_json = ?, owner_node = ?, updated_at = ? WHERE task_id = ?",
                status, resultJson, ownerNode, Timestamp.from(Instant.now()), taskId);
    }

    @Override
    public List<SubagentTask> findByProject(String projectId) {
        return jdbcTemplate.query(
                "SELECT * FROM agent_subagent_task WHERE project_id = ? ORDER BY created_at DESC",
                (rs, i) -> mapRow(rs), projectId);
    }

    private SubagentTask mapRow(ResultSet rs) throws SQLException {
        return new SubagentTask(
                rs.getString("task_id"),
                rs.getString("project_id"),
                rs.getString("kind"),
                rs.getString("status"),
                rs.getString("result_json"),
                rs.getString("owner_node"),
                rs.getTimestamp("created_at").toInstant(),
                rs.getTimestamp("updated_at").toInstant());
    }
}

与框架对接的端口由本项目侧定义,实现类再对接 AgentScope 的 Subagent 能力,挂载点对照 v2.0.3 的 SubagentsMiddleware 与 DynamicSubagentsMiddleware:

public interface SubagentRuntimePort {
    String call(String agentName, String prompt);
    void cancel(String taskId);
}

调度器:

@Component
public class AsyncSubagentCoordinator {

    private static final Set<String> TERMINAL =
            Set.of("SUCCEEDED", "FAILED", "CANCELLED");

    private final SubagentRuntimePort runtimePort;
    private final SubagentTaskRepository repository;
    private final ExecutorService fanOutPool;

    public AsyncSubagentCoordinator(SubagentRuntimePort runtimePort,
                                    SubagentTaskRepository repository) {
        this.runtimePort = runtimePort;
        this.repository = repository;
        this.fanOutPool = Executors.newFixedThreadPool(16);
    }

    public List<String> dispatch(String projectId, List<SubagentRequest> requests) {
        List<String> taskIds = new ArrayList<>();
        for (SubagentRequest request : requests) {
            String taskId = UUID.randomUUID().toString();
            repository.insert(new SubagentTask(taskId, projectId, request.kind(),
                    "PENDING", null, nodeId(), Instant.now(), Instant.now()));
            taskIds.add(taskId);
        }
        for (int i = 0; i < requests.size(); i++) {
            String taskId = taskIds.get(i);
            SubagentRequest request = requests.get(i);
            fanOutPool.submit(() -> runOne(taskId, request));
        }
        return taskIds;
    }

    private void runOne(String taskId, SubagentRequest request) {
        String current = repository.find(taskId)
                .map(SubagentTask::status).orElse("CANCELLED");
        if ("CANCELLING".equals(current)) {
            repository.updateStatus(taskId, "CANCELLED", null, nodeId());
            return;
        }
        repository.updateStatus(taskId, "RUNNING", null, nodeId());
        try {
            String result = runtimePort.call(request.agentName(), request.prompt());
            repository.updateStatus(taskId, "SUCCEEDED", result, nodeId());
        } catch (RuntimeException ex) {
            repository.updateStatus(taskId, "FAILED", ex.getMessage(), nodeId());
        }
    }

    public Map<String, String> awaitAll(List<String> taskIds, Duration timeout) {
        long deadline = System.nanoTime() + timeout.toNanos();
        Map<String, String> results = new LinkedHashMap<>();
        for (String taskId : taskIds) {
            results.put(taskId, awaitOne(taskId, deadline).resultJson());
        }
        return results;
    }

    private SubagentTask awaitOne(String taskId, long deadlineNanos) {
        while (System.nanoTime() < deadlineNanos) {
            SubagentTask task = repository.find(taskId)
                    .orElseThrow(() -> new IllegalStateException("unknown task: " + taskId));
            if (TERMINAL.contains(task.status())) {
                return task;
            }
            sleepQuietly(50L);
        }
        throw new IllegalStateException("timeout waiting " + taskId);
    }

    public void cancel(String taskId) {
        SubagentTask task = repository.find(taskId)
                .orElseThrow(() -> new IllegalStateException("unknown task: " + taskId));
        if (!TERMINAL.contains(task.status())) {
            repository.updateStatus(taskId, "CANCELLING", task.resultJson(), task.ownerNode());
            runtimePort.cancel(taskId);
        }
    }

    public List<SubagentTask> listByProject(String projectId) {
        return repository.findByProject(projectId);
    }

    private static void sleepQuietly(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException ex) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("interrupted while waiting", ex);
        }
    }

    public static String nodeId() {
        return System.getenv().getOrDefault("HOSTNAME", "local");
    }
}

控制器:

@RestController
@RequestMapping("/api/projects/{projectId}/subagent-tasks")
public class SubagentTaskController {

    private final AsyncSubagentCoordinator coordinator;

    public SubagentTaskController(AsyncSubagentCoordinator coordinator) {
        this.coordinator = coordinator;
    }

    @PostMapping("/dispatch")
    public List<String> dispatch(@PathVariable String projectId,
                                 @RequestBody List<SubagentRequest> requests) {
        return coordinator.dispatch(projectId, requests);
    }

    @GetMapping
    public List<SubagentTask> list(@PathVariable String projectId) {
        return coordinator.listByProject(projectId);
    }

    @PostMapping("/{taskId}/cancel")
    public void cancel(@PathVariable String projectId, @PathVariable String taskId) {
        coordinator.cancel(taskId);
    }
}

主 Agent 侧只做三件事:算出要查哪几路、dispatch、汇总。报告生成走同一个 dispatch,只是不再 awaitAll,把 taskId 直接返回给前端。

启动与请求:

mvn -q -pl digital-human-web -am spring-boot:run
curl -s -X POST http://localhost:8080/api/projects/demo/subagent-tasks/dispatch \
  -H 'Content-Type: application/json' \
  -d '[{"kind":"knowledge","agentName":"knowledgeAgent","prompt":"售后政策"},{"kind":"customer","agentName":"customerAgent","prompt":"星河科技"},{"kind":"order","agentName":"orderAgent","prompt":"上季度订单"}]'

验证(未在当前环境实测,以下为预期结果):三条任务在任务表里的 created_at 相差在毫秒级,而不是依次相差一个查询耗时;awaitAll 的墙钟耗时接近三者中的最大值,而不是三者之和;调用一次 cancel 后,任务状态从 RUNNING 变到 CANCELLING,再到 CANCELLED;换一个副本用同一个 taskId 查询,能查到状态。第2条和第4条是这一式的验收点。

先建一张表
再发三路兵
句柄即时返
终态统一收

06、排查

证据链一:并行发出去了,总耗时没降下来。

现象:日志里三条子 Agent 几乎同时开始,但主 Agent 的整轮响应时间仍然约等于三个查询时间之和。

怀疑:线程池太小,任务在排队。

检查:把 fan-out 日志和 await 日志分开打时间戳,看「最后一个任务开始」和「第一个任务结束」之间的间隔。

证据:三条任务都在开始后几百毫秒内启动,说明并发度没问题;真正的时间花在汇总前的等待上,而那段等待是顺序遍历 taskId 逐个阻塞等。

根因:fan-out 做对了,await 写成了串行等待语义——第一个任务等完才去看第二个,虽然后面两个其实早就跑完了。总耗时被第一个任务的最大耗时加上后续任务的状态检查延迟拖着。

修复:把等待改成「先把全部句柄注册进来,再统一等到全部终态」,而不是逐个阻塞。

错误尝试: 一开始把线程池从 4 提到 64,耗时几乎没变。为什么错——瓶颈在等待语义,不在并发度。线程池再大,也只是让「发出」更快,不改变「等待」是一根栈上顺序等的事实。这个错误很典型,因为它把「并行」理解成了「并发数」。

证据链二:后台任务换一个副本就查不到。

现象:后台报告任务在副本 A 上提交成功,前端拿得到 taskId;把请求打到副本 B 查询,返回未找到。

怀疑:负载均衡粘性配置有问题,或者网络分区。

检查:先不碰负载均衡,直接连数据库看有没有这条记录。

证据:数据库里没有这条任务。任务记录原来存在进程内的一个 ConcurrentHashMap 里。

根因:任务状态被进程独占。第 03 章的判断在这里得到验证——状态不属于任何一个进程,它必须属于一个共享存储,否则跨副本恢复从第一行代码起就不成立。

修复:任务表外置到 MySQL,插入和状态更新都走事务。

上游贡献判断(本节不新增标题):是否可在最小 Demo 复现,可以在 chapter/25-async-remote-agent 上用两副本加共享 DB 复现;v2.0.3 是否存在该现象,需要先在 v2.0.3 上核验「异步子 Agent 的任务状态默认存放位置」这一点,本文未在官方源码中确认,需按当前官方文档核验;main 是否仍存在,未核验;是否已有 Issue/PR,需要在官方 Issues 与 Discussions 中先检索,检索前不下结论。当前结论:只能记录为候选,不构成真实 Issue。

并发非瓶颈
等待才是根
内存在进程
换机即失魂

07、优化

基于第06章的两条证据,V2 做三处修改。

修改一:等待语义从「逐个阻塞」改成「注册全部句柄后统一等待」。

public Map<String, String> awaitAll(List<String> taskIds, Duration timeout) {
    long deadline = System.nanoTime() + timeout.toNanos();
    List<String> pending = new ArrayList<>(taskIds);
    Map<String, String> done = new LinkedHashMap<>();
    while (!pending.isEmpty()) {
        Iterator<String> it = pending.iterator();
        while (it.hasNext()) {
            String taskId = it.next();
            SubagentTask task = repository.find(taskId)
                    .orElseThrow(() -> new IllegalStateException("unknown task: " + taskId));
            if (TERMINAL.contains(task.status())) {
                done.put(taskId, task.resultJson());
                it.remove();
            }
        }
        if (!pending.isEmpty()) {
            if (System.nanoTime() > deadline) {
                throw new IllegalStateException("timeout waiting " + pending);
            }
            sleepQuietly(50L);
        }
    }
    Map<String, String> ordered = new LinkedHashMap<>();
    for (String taskId : taskIds) {
        ordered.put(taskId, done.get(taskId));
    }
    return ordered;
}

原因:等待是「所有句柄的集合状态」,不是一个循环。把它写成循环,就退化成串行。

新行为:墙钟耗时由最长的那一路决定,而不是由三者之和决定。

修改二:取消语义补上状态机,用行内代码表达迁移关系:PENDING -> RUNNING -> SUCCEEDED,RUNNING -> FAILED,以及 RUNNING -> CANCELLING -> CANCELLED。

原因:只写「取消」一个动词,落到多副本上就变成两个副本对同一个任务各说各话。状态机让取消变成一次可观察的状态迁移。

修改三:远程 Agent 与本地子 Agent 统一为「任务句柄」。

# 以下键名属于本章教程仓库的自定义约定,不是 AgentScope 官方配置项
digitalhuman:
  subagent:
    remote:
      order-agent:
        protocol: a2a
        endpoint: https://order-agent.internal/a2a
        timeout: 5s

原因:不管子 Agent 跑在本进程、另一个副本,还是另一个团队的服务上,主 Agent 看到的都应该是同一个东西——一个能查、能取消的 taskId。协议差异被适配层吃掉。

验证方法(未在当前环境实测,以下为验证方案,不是已验证结果):用同一组三路查询,分别跑串行版和并行版,比较墙钟耗时,要求并行明显小于串行;提交一个后台报告任务,立刻用一个不存在的 taskId 和真实的 taskId 各查一次,前者报错,后者返回状态;在两副本部署下,A 提交、B 查询,验证任务状态可见。

等待改集合
取消立状态
远近同句柄
协议在壳中

08、演进

[同一输入:客户星河科技上季度订单与售后政策]
    |
    +--[V1] 同步串行子 Agent:实现最简单,耗时相加,状态活在调用栈
    |
    +--[V2] 异步 fan-out + 任务表 + 远程 Agent:可并行、可取消、可跨副本
    |
[Trade-off]
得到:并行收益、后台任务句柄、取消落点、跨副本恢复
失去:一次调用的简单性,换来一张表和一套状态机
适用边界:多数据源聚合 + 外部调用不可忽略 + 需要人工取消或后台执行

逐项比较。正确性:V1 在单副本下正确,多副本下任务不可查;V2 把正确性建立在共享状态上。稳定性:V1 的失败是「栈断了」,V2 的失败是「状态机卡住了」,后者可观察、可补偿。复杂度:V2 多一张表、一个状态机、一个适配层,代码量明显上升。成本:V2 需要一次额外的数据库写入和轮询,换来的是不占线程的等待。适用范围:V2 适合聚合型问答,单路查询场景用 V1 反而更干净。遗留问题:轮询式等待在任务量上来后会给数据库压力,后续可以换成事件通知;跨副本的「接管」本文只做到「能查到」,没有做到「能接管执行」。

并行省时间
任务得可查
代价一张表
边界三类人

09、洞见

9.1 并行的收益来自状态归属,不来自并发数

反直觉判断:把线程池从 4 提到 64,往往一点都不会变快。因为你的瓶颈从来不是「能不能同时发出去」,而是「任务状态归谁管」。状态在栈上,就永远只能顺序等。

这条洞见解释了第06章那条错误尝试。它也解释了一个常见现象:很多项目「加了并行」,只是把同步调用包进了 CompletableFuture,然后在同一个方法里 join 三次。

9.2 后台任务的真正门槛是「可查询」,不是「异步」

反直觉判断:一个任务只要没有可查询的句柄,它就还不是后台任务,只是「不等待的调用」。

真正让后台任务成立的是 taskId 加任务表。没有这两样,用户问「刚才那个报告好了吗」,你只能回答「不知道」。

9.3 远程 Agent 是部署手段,不是性能手段

反直觉判断:把子 Agent 拆到另一台机器上,通常不会更快,反而会多一次网络往返。

A2A 的价值在于:让子 Agent 可以按团队、按权限、按资源边界独立部署。它解决的是「谁维护、谁能访问、在哪运行」,不是「跑得多快」。把这个动机搞错了,就会为了性能去做微服务化,最后得到的是一次请求跳三次网络。

9.4 什么时候不应该用本章能力

如果你的主 Agent 一轮只查一个数据源,或者下游查询只要几十毫秒,那么本章引入的任务表、状态机、适配层全是纯成本。

更具体地说,下面三种情况不要用:只有一路查询,没有并行可言,异步只会多一次状态写入;下游系统不支持重复执行或幂等,任务重试和跨副本接管都会让你写出重复订单;团队还没有共享存储,跨副本恢复会变成一句写在文档里、跑不起来的话。

归属定并行
句柄定后台
远程非为快
简单勿强求

10、系统落地

原来有什么:第24式的数字人 Demo 已经能给项目配 agentName、model、systemPrompt,并且主 Agent 可以派子 Agent 出去查知识库、客户、订单。

本篇新增什么:一层异步调度(任务表 + 状态机 + 取消)、一个统一的任务句柄抽象,以及远程 Agent 的适配位置。

现在能做什么:用户一句提问,三路查询并行 fan-out,主 Agent 汇总;耗时报告生成丢后台,返回 taskId 供前端轮询;任务能被取消;副本切换后任务状态能查到。

还缺什么:轮询式等待没有换成事件通知,任务多起来会打数据库;跨副本只做到「能查到」,没做到「能接管执行」,节点挂了 RUNNING 的任务会一直是 RUNNING;远程 Agent 的鉴权、限流、超时策略还是占位级别;任务表没有清理策略,会一直长。

下一步如何演进:把轮询换成事件,把「接管」做成带租约的抢占,把远程 Agent 的鉴权提到配置中心。这些都要等前面的能力稳定之后再动。

开源参与点:发现可作为 Issue/PR 候选的问题——官方 subagent 文档对「异步子 Agent 的任务状态默认归属」描述偏少,跨副本场景下的行为需要读源码确认。这类问题属于文档缺口,在动手之前必须先检索官方 Issues、Discussions 与 PR,确认是否已有讨论;只有在检索确认没有重复、并且在 chapter/25-async-remote-agent 上做出最小复现之后,才考虑以个人 Fork 的主题分支发起,不在教程 chapter 分支上直接改官方。

底座已可用
新增可取消
尚缺接管法
参与先检索

11、小结

Q1 -> 同步调用非要把状态搬出方法栈,因为栈一断,取消、后台、跨副本三件事同时失去落点
Q2 -> 并行发三路不够,必须补任务表:句柄是并行等待、取消和恢复的共同前置
Q3 -> 状态不外置,第一次上多副本部署时崩掉的就是后台任务,因为它的状态被某个进程独占了
状态 -> chapter/25-async-remote-agent 可跑通并行 fan-out + 后台任务查询/取消 + 跨副本状态可见

一答三路并
一报后台跑
一问可取消
一换能找回

12、作业

12.1 理解题:SubagentsMiddleware 与 DynamicSubagentsMiddleware 在职责上的分界是什么?

参考答案:从包路径可以看出两者都属于 agentscope-harness 的中间件,但成立时机不同。SubagentsMiddleware 关注的是「有哪些子 Agent 可用」,也就是把子 Agent 注册进主 Agent 的能力集合;DynamicSubagentsMiddleware 关注的是「这一次要不要派生、派生哪些」,也就是把子 Agent 的形成时机推迟到运行时。分界的判断标准是:静态配置能决定的归前者,依赖本轮输入才能决定的归后者。

做题方式:打开 https://github.com/agentscope-ai/agentscope-java/blob/v2.0.3/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/SubagentsMiddleware.java 与同目录的 DynamicSubagentsMiddleware.java,对照类里的字段和入口方法,确认这个分界是否成立;再和 https://github.com/agentscope-ai/agentscope-java/blob/v2.0.3/docs/v2/zh/docs/harness/subagent.md 的描述交叉验证。如果文档与源码不一致,以固定 tag 的源码为准,并把差异记下来。

12.2 实战题:在 chapter/25-async-remote-agent 上,让三路查询的串行版和并行版各跑一次,比较墙钟耗时。

参考答案:串行版按 knowledge、customer、order 顺序调用,记录一次请求的开始与结束时间;并行版先 dispatch 出三个 taskId,再用统一等待拿到全部结果,记录同样的两个时间点。验证点不是「快了多少」,而是「并行版的耗时是不是接近三者中的最大值」。如果并行版耗时仍然接近三者之和,先回到第06章的证据链一,检查等待语义写成了什么。

12.3 排障题:任务在副本 A 提交后状态正常,切到副本 B 查询返回未找到,最先应该看哪里?

参考答案:不要先怀疑负载均衡和网络。第一步直接连数据库,看这条 taskId 在任务表里有没有记录。如果没有,说明任务状态根本没有落库,问题在写入路径,和副本无关;如果有记录但 B 查不到,再检查 B 读的是不是同一个库、事务是否提交。这个顺序的原因在第06章已经验证过:跨副本症状的根因,绝大多数是状态被进程独占,而不是网络。

12.4 架构判断题:把三个子 Agent 全部改成远程 A2A 调用,是不是就解决了并行和恢复问题?

参考答案:没有。A2A 解决的是部署边界,不解决任务状态归属。如果本地仍然用同步调用去调远程 Agent,那么远程化只是把「顺序等三次本地调用」变成「顺序等三次网络调用」,并行和恢复一个都没解决,反而多了网络往返。正确顺序是先把任务状态外置、把等待语义并行化,再考虑哪些子 Agent 值得被拆到远端。

源码第一条
实战第二条
排障查落库
架构看归属

13、思考

回到这一式的核心冲突:同步调用简单,但它把任务状态绑死在一根调用栈上;异步和远程让系统能并行、能取消、能恢复,代价是引入了一张任务表和一套状态机。

工程判断可以收成一句话:先想清楚任务状态归谁,再决定用不用异步。如果状态还留在栈上,你写出来的只是「不等待的调用」,不是后台任务。

「会用框架」和「能参与框架演进」的差别,往往就在这一步——会不会在写之前先问一句:这个行为的证据在哪,源码和测试里能不能找到它。

一问知归属
一等见并行
一表解跨栈
一议定取舍

posted @ 2026-10-11 11:11  李福春  阅读(2)  评论(0)    收藏  举报