☰
多Agent协作协议设计:优雅关机、计划审批与标准化握手
2026/10/21 7:57:47 网站建设 项目流程

这个系列写到这里,前面几篇都在讲单个 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(): Promise<void> { this.listenForShutdownSignal(); await this.runMainLoop(); } async shutdown(reason: ShutdownReason): Promise<void> { 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_ok=True) fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(path)) with os.fdopen(fd, "w") as f: json.dump(snapshot, f, ensure_ascii=False, indent=2) 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 团队定协议的时候,别一次设计得太大。先把"关机、审批、握手"这三个最基本的环节定下来,跑通进生产,后面需要消息路由、任务分发这些高级能力的时候,再在协议里加扩展字段。协议设计的核心不是一步到位,而是留好扩展的口子。这个系列后面如果继续写,我可能会聊聊怎么在这些基础协议之上做任务编排和负载均衡,那会是更上一层楼的协作模式。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询