多 Agent 协作中最常见的两个问题是"子 Agent 死了我却不知道"和"我说完就走了,没法等结果"——前者让主 Agent 空等超时,后者让本来能在一轮里完成的任务被迫拆分到多轮(用户体验下降、延迟加倍)。

本文介绍两个解法:给子 Agent 加上 lease/heartbeat(健康检查 + 失联兜底),以及通过 rendezvous 让主 Agent 在本轮内同步等待子 Agent 的结果。


1. 起点:fire-and-forget 的代价

升级前的协作模型是纯 fire-and-forget:主 Agent 调用 send_message 派任务给子 Agent,立即返回继续推理。子 Agent 的结果在下一次主 Agent 检查 inbox 时才被看到。

这个模型在"不需要结果"的场景工作得很好,但有两个硬伤:

场景 问题
子 Agent 崩溃 主 Agent 最多等 120s tail 硬上限后静默收尾,没有任何信号——不知道是它死了还是它在慢速工作
需要结果才能继续 模型必须故意拆成两轮——第一轮派单,第二轮看结果——而 LLM 可能"忘了"等第二轮去查,用户体验割裂

2. Lease/Heartbeat:给子 Agent 加 liveness probe

2.1 设计

给每个子 Agent 一个独立 daemon 虚拟线程,每 10s 向 Redis 写一条 lease 记录:

agent:lease:{sessionId}:{agentId} = {runnerId}:{epochMs}  TTL=30s
  • 续期频率 10s / TTL 30s:容忍连续 2 次心跳丢失(网络抖动、GC 停顿),第 3 次才判定失联
  • 主动删除:正常退出时 finallyDEL lease key——不等 TTL 自然过期
  • 心跳与主循环解耦:子 Agent 的 idle loop 可能阻塞在 LLM 调用中 60s+,心跳必须是独立线程
Thread heartbeat = Thread.ofVirtual().name("lease-hb-" + agentId)
    .start(() -> {
        while (!Thread.interrupted()) {
            redis.set(leaseKey, runnerId + ":" + now, Duration.ofSeconds(30));
            Thread.sleep(Duration.ofSeconds(10));
        }
    });

try {
    // ... 子 Agent 主循环 ...
} finally {
    heartbeat.interrupt();
    redis.delete(leaseKey);  // 主动清理
}

2.2 主 Agent 侧:LeaseGuard

主 Agent 在 tail 轮询体内(每 2s 一次)对每个已加载的子 Agent 做 lease 检查:

if lease == null (连续 10s):
    → 合成一条 result 消息(from=system,content="[子 Agent 失联] 心跳超时")
    → 子会话 status → TIMEOUT
    → 子 Agent 退出
    → 发 WorkLogEvent 告警
    → SSE ax 事件带 reason="lease_expired"

主 Agent 侧可以区分"正常完成"(ax reason=completed)和"失联"(ax reason=lease_expired),前端面板渲染不同的状态。

2.3 为什么不直接用 tcp keepalive 或进程管理

方案 为什么不选
tcp keepalive 子 Agent 和主 Agent 之间没有长连接——它们通过 Redis 解耦,tcp 管不了
进程管理/Supervisor 子 Agent 是虚拟线程,不是独立进程——同 JVM 内重启是另一个话题
Redis lease + 独立心跳线程 选了。 零新组件、TTL 天然兜底、DEL 主动退出——用最小的复杂度覆盖"它还活着吗"这个问题

3. Rendezvous:让呼叫方同步等待

3.1 问题

一个典型场景:主 Agent 需要先调研市场数据、再生成策略。用 fire-and-forget,流程是:

Round 1: 主 Agent → send_message(task="调研") → 继续瞎编其他的
Round 2: 主 Agent 收件 → 看到调研结果 → 生成策略

两轮 LLM 调用、两次用户等待。而且 LLM 在 Round 1 里"等结果"的指令可能在第二轮被遗忘。

3.2 设计

bt_send_message 新增参数:

参数 默认 说明
await_result false true 时阻塞等待目标 Agent 的 result
await_timeout_seconds 120(上限 300) 等待超时

约束:只允许主 Agent → 子 Agent 的 task/revision 消息使用——防止环形等待死锁。

3.3 执行流

SendMessageExecutor.execute(..., await_result=true):
  1. 正常 send(落 inbox + notify),拿到 taskMsgId
  2. 阻塞轮询 inbox 表(每 1s):
       SELECT WHERE in_reply_to = taskMsgId AND type = 'result'
  3. 命中 → ack + 返回结果给 LLM(ToolResult,原样传入)
     超时 → 返回结构化提示,消息保持 pending 走异步投递

轮询 inbox 表而不是等 Pub/Sub,因为:

  • notify payload 的 content 被截断到 200 字(只用于唤醒),完整消息必须从 inbox 读
  • 1s 轮询的数据库压力可忽略(会话级、最多 300s)

3.4 关联键:inReplyTo

Rendezvous 靠 in_reply_to 匹配结果。子 Agent 在处理 task 批次时,finally 中的最终 result 消息带上当前批首条 task 的 msgId 作为 inReplyTo——这条保底 result 是"无论 LLM 调不调 send_message(result)"都能发出去的,保证了即使子 Agent 不主动交付也能触发 rendezvous 命中。


4. 经验总结

  1. fire-and-forget 和 sync 是互补的,不是互斥的。 大多数派单只需 fire-and-forget(并行派多单、不需要结果)。但少数关键场景需要 "必须等这个结果"——await_result 让 LLM 自己决定什么时候等。

  2. lease 的频度和 TTL 是个工程 tradeoff。 10s/30s 的组合意味着"失联 30s 后才发现"——对秒级实时要求过高的场景不够快,但下次心跳前又可能误杀。这个值应该根据实际 LLM 调用时长分布来调。

  3. inReplyTo 是协作的消息协议基础。 不仅 rendezvous 靠它,SSE 前端面板的回复链展示、排查人工审计也靠它。每个 result 都应该知道自己在答复哪条任务——这个补充在整个系统中像是一条看不见但至关重要的线。

  4. 零新组件的代价是"自己造"。 不用 Kafka 不用 RabbitMQ,用 Redis + MySQL 实现 lease 和 rendezvous 的代码量不到 200 行,但需要清楚自己在做什么语义(at-least-once、幂等、超时兜底)。