系列写到第 10 篇,前面聊了工具调用、上下文管理、路由分发,今天聊团队协作。在 Claude Code 这种 Agent 开发环境里,单机跑通只是开始,真正让它能进生产、能跟别的 Agent 一起干活,有两件小事绕不过去:一是优雅关机,意思是 Agent 被中断或退出时,要给协作方一个体面的交代;二是计划审批,意思是 Agent 动手之前,要把计划亮出来,等一个批准。这两件事做成标准化协议,就是一次扎实的握手。这篇把这个主题拆开,从原理讲到落地代码,适合正在做多 Agent 协作、或者准备把 Agent 接进团队工作流的人。
1. 为什么团队协作协议是 Agent 开发的分水岭
1.1 单机 Agent 与团队 Agent 到底差在哪
单独一个 Agent,状态只有它自己关心。它跑着跑着崩了、卡了、资源泄漏了,影响的只是自己。但一个 Agent 进入协作网络就不一样了:它的状态会变成公共事实,它会向别人发送消息、会被别人调度、还要承接别人的请求。一旦它在某个状态中消失,所有依赖它的 Agent 都会陷入等待或误判。
这也是我个人判断一个 Agent 系统有没有走到生产级的分水岭:不是模型多强、提示词多漂亮,而是它有没有讲清楚“我什么时候走、我走之前留给同伴什么、我动手之前谁说了算”。很多团队第一版多 Agent 系统跑得挺欢,一到上线就崩,崩的原因往往不是模型答案质量问题,而是节点之间谁也等不到谁。
如果你用过一版没有协议的多 Agent 系统,大概率见过这种场面:A Agent 给 B Agent 发了任务,B 跑了一半因为超时退出,A 还在傻等;或者 C Agent 觉得自己资历老,直接改了共享配置,结果所有 Agent 的下一次请求全部失败。人之间协作靠默契,Agent 之间没有默契这种东西。你不把消息结构、状态流转、失败策略写清楚,它就只会靠概率去猜,概率的代价就是极低的可重复性和极难排查的故障。
1.2 协议不是限制,是给 Agent 一套共同常识
很多人一听“协议”就头疼,觉得是给自己上枷锁。你换个角度想:分布式系统里那么多复杂的协议,归根到底是在解决一个问题——参与者之间如何建立对世界的一致看法。Agent 协作也一样。
协议的本质不是限制,而是给每个 Agent 一套共同常识。有了共同常识,一个 Agent 才知道对方发来这条消息到底是什么意思、自己该以什么状态去响应、失败时该走哪条分支。没有共同常识,你只能用提示词在里面硬塞“你是个协作友好的 Agent,收到别人消息时要礼貌回应”,结果它礼貌归礼貌,该等还是等,该误判还是误判。
我见过一版系统,两个 Agent 之间传消息用的是自然语言字符串,A 在等“好的”,B 回复“好哒”。看着像段子,真实系统里真会出现这种不可控。最后还是退回结构化协议,才把这两个 Agent 稳定地拧在一起。
1.3 握手不是打招呼,是同步状态
标题里的“握手”,不是让 Agent 见面说声 Hello。真正的握手协议包含三层信息:我是谁、我听懂你在说什么、我们当前的状态是否一致。具体到本文场景,就是关机握手和审批握手。
关机时,一个 Agent 说“我要退场了,这是我的最终状态”,对方必须回一声“我收到了,后续我看不到你会知道原因”。审批时,一个 Agent 说“我打算这么干,给你看一眼”,对方回一声“批准”或“拒绝”,双方在“当前任务能否执行”这件事上达成一致。有了这两类握手,多 Agent 系统才算有了最基本的秩序。
打个比方:两个人合搬一件大家具,动手前肯定要先确认各自站位和节奏,“我数到三一起抬”就是一种握手。如果一个直接松手,另一个就会闪到腰。Agent 之间的协作比这更没有默契,所以更需要这种明确到字段级别的“站位确认”。
2. 优雅关机:让 Agent 体面退场
2.1 关机场景远比你想的多
很多人一听优雅关机,第一反应是“那我按 Ctrl+C 的时候别丢进度”。这只是最基础的场景。在真实系统里,Agent 被中止的触发源多到离谱:
- 主控进程滚动重启,旧进程被摘掉
- 超时保护强制回收一个卡住的任务
- 宿主资源紧张时被调度器挤掉
- 发布上线时旧版本为给新版本腾位置被终止
最容易被忽略的是:一套长期运行的 Agent 服务,几乎每天都会遇到至少一次“被系统请退”。如果你的 Agent 只会硬退,它可能把任务列表、文件锁、审批中的请求、已发出但未确认的通知全部留在半空中。等主控回来重新拉起,整个协作图就脏成了一团。
脏意味着什么?意味着别的 Agent 不知道这个节点到底算没算完一个任务,会重试;意味着有些锁永远不会释放;意味着审批请求被搁置,等原进程回来的时候,坐在审批位上的人已经把相关内容忘了。这些问题的共同来源,都是 Agent 在退场时没有留下一句“遗言”。
2.2 优雅关机的三个核心环节
要处理好关机,三个环节缺一不可。
第一步是感知信号。Agent 必须对系统信号有响应。在 Python 里就是 SIGINT 和 SIGTERM。光捕获还不够,得把它转成一个全局的“将退场”事件,让主循环在安全的位置检查这个事件。如果你只是把信号处理函数里打印一行日志,然后什么都不干,那不叫优雅关机。
第二步是保存进度。退场前把当前任务状态落盘,写到检查点文件或持久化 KV。最关键的是确定检查点的粒度:不是每一行输出都要存,而是存“能恢复的最小任务单元”。我习惯的做法是每条消息处理完就写一次检查点,这样重启后最多重放最后一个任务。
第三步是通知同伴。这是最容易被跳过的。Agent 要走,一定得让协作方知道,最好附带一个简短的原因和状态摘要。这样对方收到 shutdown_notice 后可以立即释放资源、标记任务悬挂,而不是傻等到超时。这条消息本身不需要多复杂,但必须成为 Agent 关机流程里不可跳过的一环。
2.3 一个最小实现示例
下面用 Python 演示一个异步 Agent 工位的最小优雅关机框架。你不用照搬,重点是看信号的传递路径和关机顺序。
import asyncio import signal import json class AgentWorker: def __init__(self): self._shutdown_event = asyncio.Event() self._checkpoint_file = "checkpoint.json" def install_signal_handlers(self): loop = asyncio.get_event_loop() for sig in (signal.SIGINT, signal.SIGTERM): loop.add_signal_handler(sig, self.request_shutdown) def request_shutdown(self): self._shutdown_event.set() async def run(self): self.install_signal_handlers() await self.announce_online() while not self._shutdown_event.is_set(): await self.process_one_task() await self.shutdown() async def process_one_task(self): # 每个任务单元都必须能被超时打断,不能无限阻塞 try: await asyncio.wait_for(self._do_work(), timeout=30) self.save_checkpoint() except asyncio.TimeoutError: # 任务超时,标记为悬挂,通知协作方 self.send_notice("task_suspended", {}) async def shutdown(self): self.save_checkpoint() self.send_notice("shutdown_notice", {"reason": "signal"}) await self.close_resources() def save_checkpoint(self): # 先写临时文件再重命名,避免写一半被杀 tmp = self._checkpoint_file + ".tmp" with open(tmp, "w") as f: json.dump(self._state, f) import os os.replace(tmp, self._checkpoint_file) def send_notice(self, msg_type, payload): # 这里接实际的消息通道 pass几个细节值得说一下。
install_signal_handlers 用的是 asyncio 的 add_signal_handler,而不是直接用 signal.signal。因为在事件循环里用后者会打断底层轮询,导致关机和协程调度相互打架。shutdown 流程有严格顺序:先阻止新任务进来,再完成手中任务,再保存状态,再通知同伴,最后关资源。顺序反了就容易出现“状态保存了但消息没发出去”或者“消息发出去了但状态是旧的”。
save_checkpoint 里先写临时文件再 os.replace 重命名,这一步不是可有可无。进程被强杀时,写一半的文件会让下一次启动读到半截 JSON,直接崩溃。原子重命名能避免大多数这类问题。
另外补一个只在生产环境会遇到的经验:如果你的协作中间件是 MQTT 或 Redis Stream 这类支持遗嘱消息的,优先用它的 Last Will 机制。Agent 注册心跳时同步注册一条“如果我的连接异常断了,请替我广播我失联了”,这样就算 Agent 来不及发关机通知,协作方也知道它不是正常退场。成本极低,但能在异常崩溃时把协调器从“傻等”状态里救出来。我强推这个机制。
3. 计划审批:把“我马上干”变成“我打算这样干”
3.1 为什么 Agent 不能直接动手
这可能是整个协作体系里最反“AI 效率”直觉的设计。Agent 的底层是大模型,它的输出本质上是对词表的概率采样,不是逻辑推演。它很自然地会在自信状态下给出一个“可能不对但语气坚定”的方案。
尤其当任务涉及生产配置、数据库变更、发送外部请求时,一次直接执行,代价可能是不可逆的。我讲一个实际踩过的场景:某个数据清洗 Agent 觉得自己可以清空一张临时表,但那其实是另一个 Agent 正在读的共享结果。没有审批的直接执行,导致整个任务链重跑了一遍,还算不上事故,如果再放大到“删库”或“上线错误配置”,就是災难了。
审批不是不信任 Agent,而是信任体系里的一个兜底闸门。它给人类或主控 Agent 一个检查点,强制把“执行前”和“执行后”分开。没有这个闸门,一切自动化都建立在“模型永远正确”的假设上,这个假设在关键操作面前太危险了。
3.2 一份计划至少该包含这些内容
要让审批有意义,计划对象就不能只有一个“目的”字符串。至少要包含几个字段:
goal 是一句话:说清要达成什么。steps 是关键:每一步的动作、参数、执行顺序,粒度要细到让人能一眼看出将要发生什么副作用。est_cost 用来估算耗时或资源配额,帮助审批者判断值不值得。rollback 是应急预案:如果执行到一半失败,怎么退回去。risks 用来显式列出风险点,这等于逼 Agent 在做计划时做一次自检。
我在初版设计里吃过亏:让 Agent 只提交“我要部署新版本”这种一句话计划,审批人根本没法判断要不要批,最后全凭感觉。后来把计划强制结构化,审批效率反而提高了,因为审批者只需要核对清单,不需要理解 Agent 的抽象意图。
3.3 审批不是一次问答,是状态机
审批不能用一次消息来理解,它是一个状态机。最常用的一条链路是这样的:
draft(草稿)→ pending(等待审批)→ approved(通过)或 rejected(拒绝),必要的时候加一个 needs_revision(打回修改)。
执行方只有在看到 approved 之后才能启动,而且每进入一个新的执行阶段,都要能对应到原始计划里的 step id。如果执行路径对不上,说明 Agent 已经偏离了审批通过的内容,这时候应该中止而不是继续。
审批有两个容易被忽视的策略:超时和撤回。
如果计划等审批等到超时,默认行为应该是自动拒绝并通知发起方,不要默认通过。安全闸门只能往保守方向走。审批人如果发现计划有误,可以在 approved 发出之前撤回,让状态回到 pending 或转为 rejected。审批请求里必须带一个 ttl_seconds 字段,这个字段的作用是给审批这件事本身设一个寿命,避免一个计划请求永远挂在队列里。
4. 标准化握手:把协议变成代码
4.1 消息协议这些基础字段必须有
到这里,前面两个概念可以合并成一套协议了。消息是 Agent 之间协作的最小单元,不管是什么类型的握手,都建议统一带一套基础字段:
message_type 标记消息类型,message_id 是这个消息的唯一 ID,correlation_id 则把“请求、响应、取消、完成”这一整串交互串起来。sender 和 target 明确谁发给谁,timestamp 用来做时序判断,ttl_seconds 则用作超时兜底。
其中 correlation_id 是握手能否配对的命根子。举个例子:PlanRequest 发出时生成一个 plan_abc_001,后续所有关于这个计划的决策、撤回、复核,都必须携带这个 id。没有它,系统完全无法把“某审批人的批准”和“某个待执行的计划”对上。我见过太多代码,因为响应里不带相关性 id,只能靠“最近那条审批”来猜,结果高并发下一错错一片。
4.2 三类核心消息的字段设计
第一类是计划请求。Agent 把完整的计划抛给审批方,消息体里把前面 3.2 节的计划字段嵌进去。
{ "message_type": "plan_request", "message_id": "req_20240815_001", "correlation_id": "plan_abc_001", "sender": "agent-builder-01", "target": "team-lead", "ttl_seconds": 300, "plan": { "goal": "上线用户画像接口", "steps": [ {"id": 1, "action": "api_deploy", "params": {"name": "user-profile"}}, {"id": 2, "action": "write_config", "params": {"key": "feature_flag"}} ], "estimated_cost": "2.5 quota", "rollback": "回滚到上一版本配置", "risks": ["新接口依赖未验证的数据源"] } }第二类是审批决定。注意 correlation_id 必须原样带回,decision 字段只取 approved、rejected、needs_revision 三个值,别用自然语言飘。
{ "message_type": "plan_decision", "message_id": "res_20240815_002", "correlation_id": "plan_abc_001", "decision": "approved", "comment": "允许执行,注意限制并发" }第三类是关机通知。它不属于审批链路,但同样要带全套消息元数据,方便对方在日志里做关联分析。
4.3 完整握手的执行过程
不用时序图,我用文字把一次审批握手完整走一遍。
一个 Agent 要执行需要审批的任务,先构建 plan_request,带着 plan 和 correlation_id,发送到审批通道。审批方解析后,把该计划的本地状态置为 pending,展示内容给审批者。审批者通过后,构造 plan_decision,decision 设置为 approved,correlation_id 原样带回。发起方收到后,先校验 correlation_id 是否与自己内存中待确认的计划匹配,匹配成功才真正启动执行。执行完成后,给审批方回一条 completion 消息,同样带上 correlation_id。
如果发起方等了超过 ttl_seconds 还没收到 plan_decision,就主动进入超时分支,把计划标记为 failed_by_timeout,清理本地资源,并通知审批方“我已经放弃等你的批准了”。反过来,审批方也可能因为调度问题没收到 completion,所以 completion 消息里同样要有 correlation_id。这套流程看起来细碎,缺一环就很容易变成“前面点头了,后面没人知道”。
4.4 幂等、超时与重试的硬性规则
多 Agent 系统一定会出现重复消息。原因可能是网络抖动重发、主控重试、两个节点同时发了同样的消息。所以协议里必须保证幂等。最简单的手段就是以 message_id 加 correlation_id 作为去重键,在接收方做一次内存或存储层面的去重,重复消息直接返回已记录的处理结果。
审批尤其要注意:同一个计划如果被审批两次,绝不能执行两次。可以在执行入口根据 correlation_id 加一个“已执行”标记,用 KV 存储做幂等控制。这是我在多次踩坑之后总结出的红线。
超时策略要区分两类:一类是审批等待超时,默认拒绝;另一类是执行超时,默认标记失败并进入 rollback。重试只针对可重试的错误,比如网络瞬时不可达;对于“审批被拒绝”这种业务结果,永远不要重试同一个计划 id,要重试就生成新 id。这个规则写死在协议层,不要在 Agent 的提示词里靠自觉遵守。
5. 常见问题与排查技巧实录
5.1 优雅关机相关的典型故障
第一个高频问题是:信号明明捕获了,Agent 还是硬退。原因多半是捕获信号后没有把主循环里的阻塞调用打断,主循环还在等待某个长任务结束,等它结束,关机的意义早就没了。解决方式是给每一步任务设置硬性 deadline,用 asyncio.wait_for 包住,让主循环能周期性地检查 shutdown 标记。
第二个高频问题是:检查点写到一半被中断。这不是玄学,进程被强杀时,写入指针就停在半路。解决方式是写检查点时分两步,先写临时文件再原子重命名,或者直接写到 WAL。另一个容易被忽视的是:状态里带了不可序列化的对象,比如打开的 socket 句柄,导致 save_checkpoint 抛异常。检查点里只放能恢复的最小业务状态,不要放运行时对象。
5.2 计划审批相关的典型故障
最常见的场景是审批通过但执行方没收到。排查方向不是去盯着审批方有没有发,而是去检查执行方有没有用 correlation_id 做匹配。八成是响应体和请求体里的 correlation_id 对不上,或者消息经过某个组件时字段被丢了。我建议统一用一个 schema 校验库,所有消息在收发两侧都过一遍校验,字段对不上立刻报错,而不是等流程走一半炸掉。
另一个场景是审批由人工处理,但审批结果没有回写队列。原因经常是审批界面里把“批准”按钮映射成了另一种消息类型,字段名约定不一致。这类问题很难靠肉眼发现,但用 schema 强制约束后会立刻暴露。所以我对团队的硬性要求是:所有跨 Agent 消息必须定义 JSON Schema,任何一侧收发时都要校验。
5.3 问题速查表
| 问题现象 | 可能的根因 | 排查手段 | 解决方向 |
|---|---|---|---|
| 捕获信号后进程仍不退 | 主循环被长任务阻塞 | 检查任务是否有 deadline | 用 wait_for 包裹任务,周期性检查 shutdown 标记 |
| 重启后状态回退到很久之前 | 检查点写入粒度太粗 | 查看检查点时间戳 | 每条消息处理完写一次检查点 |
| 审批通过但执行方没动 | correlation_id 丢失或对不上 | 在两侧日志中搜索 correlation_id | 统一 schema 校验,强制携带 correlation_id |
| 同一个计划被执行了两次 | 缺少幂等保护 | 观察执行入口日志 | 用 correlation_id 做“已执行”标记 |
| 审批请求永远挂在队列里 | 没有 TTL 超时 | 查看队列积压时间 | 增加 ttl_seconds,超时自动拒绝 |
| Agent 退场后资源锁未释放 | 关机通知未发出或未被处理 | 检查 shutdown_notice 日志 | 把发通知纳入关机固定流程,配合 Last Will 兜底 |
最后再说几句实际的
我个人从单 Agent 一路做到多 Agent,最直观的体会是:优雅关机解决的是信任,计划审批解决的是风险,但真正让我觉得“这套系统可以交给别人用了”的那一刻,是所有这些小协议凑在一起,形成了一个完整的协作闭环。关机通知让退场变得透明,审批状态机让每一步执行都有据可查,correlation_id 让一切碎片都能拼回同一张拼图。
如果你现在正要开始做,我建议先别一次上全套。挑一个最危险的场景,比如生产配置变更,从“强制计划加人工审批”开始。跑稳了,再加自动化的关机通知、重试策略和遗嘱消息,一点点把标准补齐。不要一上来就搞一个庞大的协议框架,协议只有在被人真正用起来的时候才有价值。