多 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 次才判定失联
- 主动删除:正常退出时
finally中DELlease 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. 经验总结
fire-and-forget 和 sync 是互补的,不是互斥的。 大多数派单只需 fire-and-forget(并行派多单、不需要结果)。但少数关键场景需要 "必须等这个结果"——
await_result让 LLM 自己决定什么时候等。lease 的频度和 TTL 是个工程 tradeoff。 10s/30s 的组合意味着"失联 30s 后才发现"——对秒级实时要求过高的场景不够快,但下次心跳前又可能误杀。这个值应该根据实际 LLM 调用时长分布来调。
inReplyTo 是协作的消息协议基础。 不仅 rendezvous 靠它,SSE 前端面板的回复链展示、排查人工审计也靠它。每个 result 都应该知道自己在答复哪条任务——这个补充在整个系统中像是一条看不见但至关重要的线。
零新组件的代价是"自己造"。 不用 Kafka 不用 RabbitMQ,用 Redis + MySQL 实现 lease 和 rendezvous 的代码量不到 200 行,但需要清楚自己在做什么语义(at-least-once、幂等、超时兜底)。