多Agent协作协议设计:优雅关机、计划审批与标准化握手
这个系列写到这里前面几篇都在讲单个 Agent 怎么把任务做好——怎么拆解需求、怎么选工具、怎么管理上下文。但一旦开始真正做多 Agent 协作你会发现一个尴尬的事实单个 Agent 再聪明协作不上价值也是零。这期内容就聚焦这个层面团队成员之间怎么定规矩。我会用优雅关机 计划审批 标准化握手这三个关键词把一套完整的团队协作协议拆开讲透。这套东西是我在给某跨平台系统搭建 Agent 协作层时反复迭代出来的踩了不少坑也参考了不少业界常见的协议设计思路。无论你是用 Claude Code 做自动化编码助手还是在构建自己的一套多 Agent 编排框架这篇文章里的协议思路都能直接拿过去用。1. 多 Agent 协作为什么协议比智能更重要1.1 从单 Agent 到多 Agent问题变了我之前做单个 Claude Code Agent 的时候所有精力都花在让这个 Agent 更聪明上更好的系统提示词、更精准的工具调用、更长的上下文规划。这就像训练一个全能选手他一个人从头到尾把活干完不需要跟别人对齐。但到了真正的多 Agent 场景事情完全不一样了。我当时的架构里有三个角色一个负责代码生成一个负责测试执行一个负责文档整理。这三个 Agent 各自独立跑起来都挺正常一旦放到同一个工作流里问题就全冒出来了。代码生成 Agent 还没写完测试 Agent 就已经开始跑旧代码文档 Agent 改了一半进程被别人重启了所有进度全部丢失两个 Agent 同时在改同一个配置文件互相覆盖。这些问题没有一个是智能能解决的它们全属于协作层面的问题——谁先谁后、怎么交接、怎么避免踩踏、怎么安全退出。所以说多 Agent 系统的瓶颈往往不在模型的智商而在协作协议的设计。1.2 协作中的三类核心问题我把多 Agent 协作中的坑归纳成三类正好对应这期标题里的三个关键词第一类是生命周期问题。一个 Agent 什么时候开始干活、什么时候算干完、被中断时怎么收尾这就是优雅关机要解决的。很多人觉得关机不就是 kill 进程嘛但真实场景里Agent 关机意味着要保存对话状态、要标记任务进度、要通知协作方、要释放占用的资源。这些不处理好轻则任务重跑重则留下脏数据污染整个项目。第二类是变更治理问题。Agent 在执行有副作用的操作之前比如改文件、装依赖、执行命令必须有一个审批环节。这就是计划审批。没有审批环节Agent 就是脱缰野马你根本不知道它在你的代码库里干了什么。第三类是通信标准问题。多个 Agent 之间怎么打招呼、怎么声明自己会什么、怎么确认双方匹配。这就是标准化握手。没有握手协议两个 Agent 的通信就是鸡同鸭讲。1.3 协议设计的三条原则在动手设计之前我先定了三条原则整个实现都围绕它们展开默认失败安全Fail-safe系统状态不确定的时候宁可停下来等着也不要继续执行。审批超时那就挂起握手失败那就重来绝不假设对方没问题。状态显式化Explicit state每个 Agent 的当前状态、任务进度、资源占用情况必须是可查询的不能存在某个 Agent 自己的脑子里。关机时要能把这些状态交出来。人机共识Human-in-the-loop凡是不可逆的操作必须过一道人审或主控审的关卡。这不是不信任 Agent而是给整个流程加一道保险丝。这三条原则在后面每个环节的实现里都会反复出现。你设计自己协议的时候也可以按这三条去卡能省掉很多将来返工的麻烦。2. 优雅关机让 Agent 体面退场的完整流程2.1 关机信号的两种来源与统一抽象优雅关机的第一步是回答一个问题Agent 到底因为什么被关掉在我实际的系统里关机信号主要有两个来源。第一个来源是外部强制信号比如主控进程要批量缩容、机器要重启、或者人工介入决定终止这个 Agent。这种信号通常以操作系统信号或一条显式的 shutdown 命令到达。第二个来源是任务自然完成。这是容易忽略的场景——Agent 把活干完了它自己会想退出。但干完不等于可以退出它还要完成一系列收尾动作把结果写回任务队列、更新自己的状态、清理临时文件。我的做法是不管关机来源是什么统一抽象成一个 shutdown() 方法。外部信号也好、任务完成也罢最终都走同一条收尾流程。这样处理逻辑就只有一份不容易出现强制关机走一套、自然结束走另一套的分叉 bug。// 统一关机入口的核心思路TypeScript 风格 class BaseAgent { async start(): Promisevoid { this.listenForShutdownSignal(); await this.runMainLoop(); } async shutdown(reason: ShutdownReason): Promisevoid { if (this.isShuttingDown) return; // 防止重复关机 this.isShuttingDown true; await this.persistState(); // 1. 状态持久化 await this.releaseResources(); // 2. 资源释放 await this.notifyCoordinator(); // 3. 通知协作方 process.exit(0); } }这里有个细节值得说为什么要加 isShuttingDown 这个防重入标志。真实环境里关机信号可能同时从两个路径传来比如任务完成回调刚触发 shutdown外部 SIGTERM 又来了。没有防重入机制两条路径会同时执行状态保存写文件直接冲突。2.2 状态持久化关机后还能接着干优雅关机最核心的一件事就是把当前进度完整地交出来。我称之为状态检查点checkpoint。这里要保存的不是对话历史那么简单而是三类信息任务级状态当前任务 ID、已完成到第几步、未完成的剩余步骤、关键中间产物。环境级状态占用了哪些文件路径、临时目录在哪、是否持有了锁。通信级状态跟哪些协作 Agent 建立了会话、会话 ID 是什么、消息队列里还有没有未确认的消息。我保存状态用的是一个简单的 JSON 快照文件路径按 Agent ID 和任务 ID 组织。为什么不用数据库因为这个系统里 Agent 数量级不大JSON 文件足够稳定而且恢复时直接读文件就行不需要额外的基础设施。等 Agent 规模上来了再迁移到对象存储也不迟。写入快照的时候要遵循先写临时文件、再原子重命名的做法防止写了一半系统崩溃导致快照损坏# Python 风格的原子写快照 import json, os, tempfile def save_checkpoint(agent_id, task_id, snapshot): path f/data/checkpoints/{agent_id}/{task_id}.json os.makedirs(os.path.dirname(path), exist_okTrue) fd, tmp_path tempfile.mkstemp(diros.path.dirname(path)) with os.fdopen(fd, w) as f: json.dump(snapshot, f, ensure_asciiFalse, indent2) f.flush() os.fsync(f.fileno()) os.replace(tmp_path, path)这块我踩过一个大坑第一次做恢复模块的时候只恢复了任务进度没有恢复文件锁状态。结果 Agent 重启后认为自己还持有某个文件的写锁但其实锁在关机时已经随进程消失了。下次启动直接往那个文件里写另一个 Agent 也在写两台一起写导致配置文件内容错乱。所以恢复逻辑里锁的检查必须重做不能直接信任快照里的已持有标记。2.3 资源清理与上下文交接资源清理看上去简单实际是最容易出事故的环节。缓存文件删除顺序错了、信号监听器没摘除、子进程没收干净都是常见问题。我的经验是做一个逆序清理后申请的资源先释放。这个思路跟数据库事务的回滚顺序是一致的。因为后申请的资源往往依赖先申请的资源你先把后面的释放了前面的才能安全释放。典型清理清单包括摘除信号监听器避免后续误触发。关闭打开的文件句柄特别是日志文件和锁文件。删除临时目录和中间产物。终止自己派生的子进程避免僵尸进程残留。关闭与协作 Agent 的长连接并发送最后的 farewell 消息。上下文交接是另一个容易漏的点。Agent A 干活干到一半被关机它手里有一堆对 Agent B 有用的上下文已经确认的接口定义、尚未执行的下一步计划、当前项目的关键背景。这些都应该写进快照并且发送给相关的协作方。我在协议里定义了一个 handoff 消息关机时如果 Agent 已经有活跃的协作会话就主动推送一条 handoff 消息给对方告诉对方我要走了我的状态在哪个快照文件里剩下的任务由谁接手。这样协作链不会因为一个 Agent 退出而断裂。2.4 实现一个可复用的优雅关机基座上面这些逻辑如果每次都写在业务 Agent 里那就是没完没了的重复劳动。我把优雅关机做成了一个基座类所有 Agent 都继承它只需要覆写几个钩子方法。public abstract class GracefulAgentBase { private boolean shuttingDown false; public final void run() { Runtime.getRuntime().addShutdownHook(new Thread(this::shutdown)); registerSignalHandler(TERM, this::shutdown); registerSignalHandler(INT, this::shutdown); execute(); } protected abstract void execute(); private void shutdown() { if (shuttingDown) return; shuttingDown true; onBeforeShutdown(); // 钩子1业务自定义收尾 saveCheckpoint(); // 钩子2基座实现状态保存 cleanupResources(); // 钩子3基座实现资源清理 sendHandoffMessages(); // 钩子4协议层实现交接 onAfterShutdown(); // 钩子5业务自定义出口 } }这样一个基座类配合上单元测试就能保证所有 Agent 的关机行为是一致的。测试也很简单启动一个测试 Agent让它跑到一半主动触发 shutdown检查快照文件是否生成、清理函数是否被调用、退出码是否正确。3. 计划审批执行前的最后一道闸门3.1 为什么必须引入计划审批讲个真实翻车案例。早期我让一个 Agent 自动修复测试失败它分析了一会儿然后开始自己改源代码连着改了好几个函数最后测试还是没过但代码已经被改得面目全非。整个操作完全不可控等我发现的时候代码已经回不去了。这个教训让我明白Agent 的分析能力越强它搞破坏的能力也越强。没有审批环节Agent 会在你没注意的时候执行任何它认为合理的操作。所以后来我定了一条铁律所有会产生副作用的操作必须先提交计划审批通过后才能执行。什么是会产生副作用我列了一个清单修改或删除项目文件安装、升级、卸载依赖执行可能影响系统状态的命令对外发起网络请求向协作 Agent 发出指令读文件、计算、分析这类无副作用的操作不需要审批可以直接执行。这个区分很重要如果每个动作都要审批Agent 的自主性就没了跑起来跟机器人一样僵硬。3.2 计划的标准格式让机器和人都能看懂审批要顺畅先得定义计划长什么样。我见过太多团队在审批上扯皮本质原因就是计划格式不统一有的人写一段话有的人列个清单主控 Agent 根本没法程序化判断。我采用的计划格式是一个结构化对象核心字段如下字段类型说明plan_idstring计划唯一 ID用于关联审批结果intentstring一句话说明目标stepsStep[]有序的执行步骤rollbackstring如果中途失败如何回滚estimated_costnumber预估的 Token 消耗或时间成本risk_levellow/mid/high风险评级其中每个 Step 又包含 action、target、expected_output、is_reversible 四个字段。action 是操作类型比如 edit_file、run_command、install_package。is_reversible 标记这个操作是否可逆这个字段直接决定了审批的严苛程度。举一个实际的计划例子。当时我让 Agent 重构一个模块的日志逻辑它提交的计划大概是这样的{ plan_id: plan_20250120_001, intent: 将日志模块从 console.log 迁移到统一 logger, steps: [ { action: edit_file, target: src/utils/logger.ts, expected_output: 新增 logger 封装函数, is_reversible: true }, { action: edit_file, target: src/app.ts, expected_output: 替换 3 处 console.log 调用, is_reversible: true }, { action: run_command, target: npm run test, expected_output: 全部测试通过, is_reversible: false } ], rollback: git checkout -- src/, estimated_cost: 4000 tokens, risk_level: mid }这个格式的好处在于它既给人类审批者看意图明确、步骤清晰、有回滚方案也适合主控 Agent 程序化校验字段固定、可以自动检查是否有高风险操作。3.3 审批状态机与超时策略计划提交之后会经历一系列状态变化。我定义了一个简单的状态机pending已提交待审→ approved已批准→ executing执行中→ completed已完成 pending → rejected已拒绝 pending → needs_revision需修改 needs_revision → pending修改后重新提交这个状态机不复杂但有个关键点执行过程中计划不允许变更。如果 Agent 在审批通过后发现原计划有问题必须生成一个新的计划重新走审批流程。这不是刻板而是为了审计留痕。你将来回看日志的时候能清楚地知道每一个操作的依据是哪份已批准的计划。超时策略也是我踩过坑的地方。最开始我给审批设置了 30 秒超时超时就自动拒绝。后来发现任务一个接一个被拒原因是审批人在开会没看消息。后来改成超时挂起并提醒而不是自动拒绝。状态机里加了一个 holding 状态超时后进入 holding 并通知审批人直到审批人明确操作。在 Agent 那一侧等待审批的时候不能傻等。我让 Agent 在提交计划后做两件事一是把计划的执行上下文整理好审批一通过就能立即执行二是继续做无副作用的前置准备工作比如读取相关文件内容、预计算参数。3.4 审批集成到 Agent 主循环说了这么多审批到底怎么嵌入 Agent 的执行流程这要回到 Agent 的主循环设计。我这边每个 Claude Code Agent 的主循环大致是接收任务 → 分析拆解 → 制定计划 → 审批 → 执行 → 汇报结果。审批不是额外加的一步而是主循环里的一个固定环节。主循环里核心的调用逻辑是这样async def execute_task(task): plan await agent.plan(task) approval await approval_service.submit(plan) if approval.status rejected: return {status: rejected, feedback: approval.feedback} if approval.status needs_revision: revised_plan await agent.revise_plan(plan, approval.feedback) approval await approval_service.submit(revised_plan) if approval.status approved: result await agent.execute(plan) await approval_service.confirm_completed(plan.plan_id) return result这段逻辑里有个容易忽略的点rejected 之后要不要允许无条件重试我的策略是被拒绝的计划只能基于反馈修订不能原样重复提交。程序上要强制带上 approval.feedback 作为上下文。这样能防止 Agent 钻空子被拒了就换个姿势提同一个高风险操作。4. 标准化握手团队协作的通信规约4.1 握手协议要解决什么问题多 Agent 协作的起点是两个 Agent 第一次见面时的相互确认。TCP 协议有三次握手应用层同样需要握手。我这里说的握手协议处理三件事能力声明、版本兼容、会话参数协商。能力声明解决的是你会什么。我的协议里定义了 capabilities 数组Agent 可以声明自己执行哪些任务类别、支持哪些工具、是否有权限操作文件系统。主控 Agent 在分配任务时先看能力再分配避免把文档任务派给只会写代码的 Agent。版本兼容解决的是咱俩能配合吗。协议版本不一致的两个 Agent不能直接建立协作会话。版本检查要放在握手阶段而不是等到通信时才发现消息格式对不上。会话参数协商解决的是咱们用什么方式干活。包括通信消息的格式、超时时间、并发上限、上下文窗口大小等。这些参数在握手时一次性确定之后整个会话期内不变。4.2 消息帧与握手流程设计我定义的握手流程是标准的四步。第一步是 INIT连接发起方发送握手请求包含协议版本、Agent 身份、能力列表、期望的会话参数。第二步是 ACK接收方校验版本和能力后返回确认消息附带自己支持的能力和参数调整。第三步是 READY发起方收到 ACK 后最终确认参数进入就绪状态。第四步是 GO接收方发出 GO 消息会话正式建立。这里借鉴了一点 TCP 的设计思想但简化了因为我们不需要处理拥塞控制这些传输层的事情只需要确认双方就绪。消息帧格式我采用 JSON 包装。每个消息都有公共字段type、message_id、sender、timestamp、payload。type 区分握手消息和业务消息message_id 用于消息去重和超时匹配sender 标识发送方身份payload 放具体内容。协议状态可以画成这样用文字描述IDLE → WAIT_ACK → WAIT_READY → ESTABLISHED → CLOSED。每个状态都有对应的超时机制超时回到 IDLE整个握手重新来。4.3 版本协商与能力声明版本协商这块我采用的是严格版本匹配 能力子集策略。协议版本号必须完全一致不允许跨版本通信。原因很简单应用层协议不像网络协议有那么强的演进压力我们完全可以要求所有 Agent 升级到同一版本省去兼容逻辑的复杂度。能力声明走的是子集协商。发起方声明自己需要的对方能力接收方从自己的完整能力列表里挑出能匹配的部分返回。比如生成代码的 Agent 需要测试 Agent 的 run_tests 能力测试 Agent 返回的能力列表里如果没有这一项发起方就知道不该依赖它执行测试。# 握手消息示例 handshake_init { type: INIT, message_id: msg_001, sender: agent_codegen, timestamp: 2025-01-20T10:00:00Z, payload: { protocol_version: 2.1.0, capabilities: [write_code, edit_file, run_command], session_params: { timeout_seconds: 30, max_concurrent_tasks: 3, message_format: json }, requires: [run_tests, report_result] } }协商完成之前双方只能交换握手阶段允许的消息类型不能发业务消息。这个限制在实现上就是在消息路由层做个过滤type 不在白名单里的直接丢弃。4.4 完整的握手实现示例我写了一个完整的握手实现片段用状态机的方式管理握手流程这样逻辑清晰也容易测试// 握手状态机TypeScript 风格 export class HandshakeStateMachine { private state: HandshakeState IDLE; constructor(private selfAgentId: string) {} onMessage(msg: HandshakeMessage): HandshakeReply | null { switch (this.state) { case IDLE: if (msg.type INIT) { if (!isVersionCompatible(msg.payload.protocol_version)) { return this.reply(msg, VERSION_MISMATCH); } this.state WAIT_READY; return this.reply(msg, ACK, { capabilities: intersectCapabilities( msg.payload.capabilities, this.myCapabilities() ), }); } break; case WAIT_READY: if (msg.type READY) { this.state ESTABLISHED; return this.reply(msg, GO); } break; } return null; } isEstablished(): boolean { return this.state ESTABLISHED; } }这个实现里有一个细节我很看重握手消息处理必须是无副作用的纯函数。也就是说onMessage 方法里不写文件、不改外部状态只做逻辑判断并返回消息。这样当握手失败需要重来的时候直接重置状态机就行不会有残留的副作用污染系统。5. 常见问题与排查技巧实录5.1 高频问题速查表把这段时间遇到的高频问题整理成一个速查表方便排查的时候对照现象可能原因排查方向Agent 关机后状态丢失快照没写成功或路径不统一检查 checkpoint 文件是否生成、恢复路径是否一致两个 Agent 互相覆盖文件没有负载文件锁或锁没生效检查锁机制确认 Agent 是否真正持有锁计划审批后 Agent 不执行状态机没收到 approved 回调检查消息队列确认审批结果是否正确投递握手反复失败协议版本不一致检查双方 protocol_version手动比对握手成功但业务消息丢失消息 ID 未做去重和确认增加消息确认和超时重发机制Agent 退出后留下僵尸进程子进程未在清理阶段终止检查 cleanup 逻辑确认 kill 子进程组审批超时后任务被拒超时策略太激进调整超时策略为挂起提醒而非自动拒绝5.2 我在实战中踩过的坑第一个坑是快照恢复后锁状态不可信。前面提过文件锁是进程级的进程死了锁自然没了。但快照里记录的已持有锁会让恢复后的 Agent 以为锁还在。我的解决办法是快照里记录的是需要恢复的锁而不是已持有的锁启动时重新申请。第二个坑是握手超时设置得过小。最初我把握手超时设为 5 秒本地测试没问题。部署到分布式环境后网络抖动一下握手就失败。后来改成了 30 秒并且加了从 5 秒开始递增重试的策略。重试的时候要带退避不然两个 Agent 同时重启会形成握手风暴。第三个坑是审批消息和业务消息共用一条消息通道导致审批结果被业务消息淹没。后来我把审批通道单独拆出来用独立的 topic 或者队列承载确保审批消息的可靠性和优先级。第四个坑是没有日志审计。最初协议都通了但出问题的时候根本没法追踪。后来我养成了一个习惯每个状态变更都打结构化日志带上 message_id、agent_id、状态值。排查问题的时候直接按 message_id 拉全部处理链路。5.3 协议测试的三种手段协议这种东西不测就是在赌运气。我这边有三种测试手段一是单元测试状态机。每个协议状态机的所有状态转换路径都写成测试用例特别是异常路径版本不匹配、能力缺失、重复消息、超时重置。二是混沌测试。我写了一个脚本随机向握手成功后的会话注入乱序消息、重复消息、延迟消息、甚至伪造身份的假消息看系统能否正确处理。这套测试帮我发现过一个安全漏洞伪造的 farewell 消息能让 Agent 提前退出任务。三是端到端演练。启动两个真实 Agent让它们完整跑一遍握手 → 协作 → 优雅关机的流程重点检查关机时 handoff 消息是否准确送达快照恢复后能否续跑。我个人在实际操作中的体会是协议测试的投入产出比非常高。前期多花一天写测试后期能省下一周排查线上问题的时间。最后再分享一个小技巧给 Agent 团队定协议的时候别一次设计得太大。先把关机、审批、握手这三个最基本的环节定下来跑通进生产后面需要消息路由、任务分发这些高级能力的时候再在协议里加扩展字段。协议设计的核心不是一步到位而是留好扩展的口子。这个系列后面如果继续写我可能会聊聊怎么在这些基础协议之上做任务编排和负载均衡那会是更上一层楼的协作模式。