两个 AI Agent 之间要直接对话,这件事听起来简单,做起来处处是坑。我最近花了大概两周时间,给两个跑在不同环境里的 Agent 搭了一条直连通道,中间用到了配对码握手、端到端加密、WebSocket 长连接,以及 MCP 协议做工具调用层的对接。整个过程踩了不少坑,有些是协议设计层面的,有些纯粹是自己想当然导致的。这篇文章把整套方案的设计思路、关键实现细节和踩坑记录完整梳理一遍,适合正在做 AI Agent 互联、多智能体协作、或者想了解 MCP 与 WebSocket 实战配合的开发者参考。不管你是刚接触 Agent 开发的新手,还是已经在做多 Agent 编排的老手,应该都能从里面找到一些有用的东西。
1. 为什么两个 Agent 不能直接调 API 就完事
1.1 从"各干各的"到"互相配合"的转折点
最开始我的两个 Agent 是独立工作的。一个负责本地文件处理和代码生成,跑在我的开发机上;另一个负责信息检索和任务调度,跑在一台常驻的服务器上。它们各自通过 MCP 协议调用工具,各自维护自己的上下文,互不干扰。这种模式跑了大概一个月,一直挺稳定。
转折点出现在我尝试让它们协作完成一个复合任务的时候。具体场景是这样的:本地 Agent 需要根据服务器 Agent 检索到的资料来生成代码,生成完的代码又要交给服务器 Agent 去做部署验证。如果走传统方式,我得在中间手动搬运数据,或者写一个中心化的调度服务来转发消息。手动搬运显然不现实,而中心化调度又引入了一个额外的单点,还得考虑这个调度服务本身的安全性和可用性。
于是我开始琢磨:能不能让两个 Agent 直接建立一条通道,互相发消息、传数据、调工具?这个想法一旦冒出来,就发现要解决的问题远比想象中多。
1.2 直连方案要解决的四个核心问题
我把需求拆了一下,发现至少有这么几件事必须搞定:
身份确认。两个 Agent 怎么知道对面是自己人?不能随便来个连接就接受,得有一个握手过程。这就是配对码要解决的问题。
传输安全。消息在网络上跑,中间可能经过多个节点,内容不能被第三方看到或篡改。端到端加密在这里不是可选项,是必选项。
连接保持。Agent 之间的通信不是一次性的请求-响应,而是持续的双向对话。有时候一方要主动推送消息给另一方,有时候要等对方处理完再继续。WebSocket 的长连接特性正好匹配这个需求。
能力协商。两个 Agent 各自能调哪些工具、支持哪些操作,需要在连接建立后互相告知。MCP 协议在这里扮演了关键角色,它定义了工具调用的标准接口。
把这四个问题想清楚之后,整个方案的骨架就出来了:配对码做身份握手,端到端加密保传输安全,WebSocket 维持长连接,MCP 做能力协商和工具调用。
1.3 一个容易忽略的前提:两个 Agent 的运行时差异
在动手之前,还有一个现实问题需要面对:两个 Agent 的运行时环境完全不同。本地那个跑在 Node.js 环境里,服务器那个跑在 Python 环境里。这意味着我不能假设两边有相同的库、相同的加密实现、相同的消息序列化方式。
这个差异在后面给我带来了不少麻烦。比如加密算法的选择,Node.js 的 crypto 模块和 Python 的 cryptography 库虽然都支持 AES-GCM,但默认参数和填充方式有细微差别。再比如消息序列化,JavaScript 的 JSON.stringify 和 Python 的 json.dumps 在处理特殊字符和 Unicode 时行为不完全一致。
所以整个方案在设计之初就必须考虑跨运行时的兼容性。我的做法是:所有跨网络传输的数据都先用统一的格式序列化,加密后再传输,接收方解密后按同样的格式反序列化。序列化格式选了 MessagePack 而不是 JSON,因为 MessagePack 对二进制数据更友好,而且两边都有成熟的实现库。
2. 配对码握手:从六位数字到可信连接
2.1 配对码的设计目标与生成策略
配对码的核心作用是让两个 Agent 在不预先共享密钥的情况下,建立一个可信的连接。这里的关键词是"不预先共享"——如果两个 Agent 已经共享了一个密钥,那直接用它做认证就行了,不需要配对码。但现实情况是,两个 Agent 可能分别由不同的人部署,或者部署在不同的时间点,事先并没有约定任何秘密。
我的配对码方案是这样的:服务器 Agent 在启动时生成一个六位数字的配对码,显示在日志里。本地 Agent 在连接时输入这个配对码,服务器验证通过后才允许建立正式连接。六位数字看起来很简单,但背后有一整套机制在支撑。
为什么选六位数字而不是更长的字符串?因为配对码是给人看的,人要在两个终端之间手动传递。六位数字在可读性和安全性之间取了一个平衡点。六位数字有 100 万种组合,配合下面要讲的速率限制和过期机制,暴力破解在实际中不可行。
配对码的生成不能用普通的随机数。我用的是加密安全的随机数生成器,Node.js 里是 crypto.randomInt,Python 里是 secrets.randbelow。普通的 Math.random 或者 random 模块生成的随机数可预测,不能用于安全场景。
// Node.js 端生成配对码 const crypto = require('crypto'); function generatePairingCode() { return crypto.randomInt(100000, 999999).toString(); }# Python 端生成配对码 import secrets def generate_pairing_code(): return str(secrets.randbelow(900000) + 100000)2.2 握手流程的完整时序
配对码生成之后,整个握手流程分这么几步走:
第一步,服务器 Agent 启动,生成配对码,同时在内存里记录配对码的创建时间和尝试次数。配对码的有效期设为 5 分钟,超过 5 分钟自动失效,需要重新生成。
第二步,本地 Agent 发起 WebSocket 连接,连接建立后发送一条配对请求消息,消息里包含配对码和本地 Agent 的公钥。
第三步,服务器 Agent 收到配对请求后,先检查配对码是否正确、是否过期、尝试次数是否超限。如果都通过,服务器生成自己的密钥对,把公钥回传给本地 Agent。
第四步,双方各自用自己的私钥和对方的公钥计算出共享密钥。这里用的是 ECDH 密钥交换算法,双方不需要传输共享密钥本身,只交换公钥就能各自算出相同的共享密钥。
第五步,双方用共享密钥派生出一对对称加密密钥,一个用于发送方向,一个用于接收方向。派生用的是 HKDF 算法,把 ECDH 算出的共享密钥作为输入,加上一个固定的盐值,输出两个方向各自的密钥。
第六步,握手完成,后续所有消息都用对称密钥加密传输。
这个流程里有一个细节值得注意:配对码验证通过后,服务器不应该立即信任对方。因为配对码可能在传输过程中被截获。所以我在握手完成后加了一步"确认消息"——双方各自发送一条用共享密钥加密的确认消息,对方能正确解密才认为握手真正完成。这一步能防止中间人攻击,因为中间人即使截获了配对码,也无法计算出正确的共享密钥。
2.3 配对码的防暴力破解机制
六位数字的配对码,如果不加限制,攻击者可以在几秒钟内穷举完所有组合。所以必须加限制。我用了三层防护:
速率限制。同一个 IP 地址在 1 分钟内最多尝试 5 次配对。超过 5 次,该 IP 被临时封禁 10 分钟。这个限制在 WebSocket 连接建立阶段就生效,不需要等到配对请求到达。
尝试次数上限。每个配对码最多允许 10 次尝试。超过 10 次,配对码立即失效,服务器生成新的配对码。这意味着攻击者即使换了 IP,也只能在一个配对码的有效期内尝试 10 次。
过期时间。配对码生成后 5 分钟内有效。5 分钟一到,无论是否被使用过,配对码都失效。这个时间窗口足够人工传递配对码,又不会给攻击者留下太长的攻击窗口。
三层防护叠加之后,攻击者在 5 分钟内最多尝试 10 次,成功率是 10/1000000,也就是十万分之一。这个概率在实际中是可以接受的。
注意:速率限制的计数器要存在服务器内存里,不要存在客户端。客户端传来的任何计数信息都不可信。
2.4 一个真实的坑:时间同步问题
配对码的过期检查依赖时间。我一开始用的是本地时间,结果发现服务器和本地机器的时钟有偏差,导致配对码在服务器看来已经过期,在本地看来还有效。这个问题在跨时区部署时更明显。
解决方案是:过期检查只用服务器的时间,客户端不参与过期判断。客户端只管发送配对码,是否过期由服务器单方面决定。这样虽然客户端可能觉得配对码还有效但服务器已经拒绝,但至少不会出现安全漏洞。
另外,服务器的时间最好用 NTP 同步。我在服务器上配了 systemd-timesyncd,确保时间偏差在秒级以内。如果时间偏差太大,不只是配对码,后续的消息时间戳验证也会出问题。
3. 端到端加密:不只是"加密一下"那么简单
3.1 为什么选 ECDH + AES-GCM 这套组合
端到端加密的方案有很多种,我最终选了 ECDH 做密钥交换、AES-GCM 做对称加密。这个选择基于几个考虑:
ECDH 的优势在于密钥长度短、计算速度快。我用的是 X25519 曲线,公钥只有 32 字节,比 RSA 的 2048 位公钥短得多。两个 Agent 交换公钥的开销很小,适合频繁重连的场景。
AES-GCM 的优势在于它同时提供加密和认证。普通的 AES-CBC 只加密不认证,需要额外加 HMAC 来防篡改。AES-GCM 把这两件事合在一起做,而且性能更好。GCM 模式还会生成一个认证标签,接收方用这个标签验证消息是否被篡改过。
为什么不用 RSA?RSA 的密钥交换需要对方用公钥加密一个随机数再传回来,这个过程比 ECDH 慢,而且 RSA 的公钥太长。在 Agent 这种需要频繁建立连接、交换消息的场景里,ECDH 更合适。
为什么不用 ChaCha20-Poly1305?这个算法在移动端和没有 AES 硬件加速的环境里性能更好。但我的两个 Agent 都跑在有 AES-NI 指令集的机器上,AES-GCM 的性能反而更好。如果你的 Agent 跑在嵌入式设备上,ChaCha20-Poly1305 可能是更好的选择。
3.2 密钥派生:从共享密钥到会话密钥
ECDH 算出来的共享密钥不能直接拿来加密消息。原因有两个:一是共享密钥的分布可能不均匀,直接用作 AES 密钥会降低安全性;二是同一个共享密钥可能被用于多个目的,比如既加密消息又做消息认证,这会导致密钥复用问题。
所以中间要加一步密钥派生。我用的是 HKDF-SHA256,把 ECDH 的共享密钥作为输入密钥材料,加上一个固定的盐值和一个信息字符串,输出两个 32 字节的密钥:一个用于本地 Agent 发送、服务器 Agent 接收,另一个用于服务器 Agent 发送、本地 Agent 接收。
// Node.js 端密钥派生 const hkdf = require('hkdf'); function deriveKeys(sharedSecret) { const salt = Buffer.from('agent-bridge-salt-v1'); const info = Buffer.from('agent-bridge-key-derivation'); const derived = hkdf(32 * 2, sharedSecret, salt, info); return { sendKey: derived.slice(0, 32), receiveKey: derived.slice(32, 64) }; }# Python 端密钥派生 from cryptography.hazmat.primitives.kdf.hkdf import HKDF from cryptography.hazmat.primitives import hashes def derive_keys(shared_secret): hkdf = HKDF( algorithm=hashes.SHA256(), length=64, salt=b'agent-bridge-salt-v1', info=b'agent-bridge-key-derivation', ) derived = hkdf.derive(shared_secret) return { 'send_key': derived[:32], 'receive_key': derived[32:], }这里有个细节:盐值和信息字符串必须两边完全一致,包括大小写和标点。我一开始在 Node.js 端写的是 'agent-bridge-salt-v1',Python 端写成了 'agent_bridge_salt_v1',结果派生出的密钥完全不同,握手一直失败。排查了半天才发现是下划线和连字符的区别。
3.3 消息加密的完整流程
每条消息的加密流程是这样的:
第一步,生成一个 12 字节的随机 nonce。AES-GCM 的 nonce 不需要保密,但必须唯一。同一个密钥下重复使用 nonce 会导致严重的安全问题,攻击者可以通过两次加密的结果推算出密钥。12 字节的随机 nonce 在同一个密钥下重复的概率极低,对于 Agent 之间的通信量来说完全够用。
第二步,用发送方向的密钥和 nonce 加密消息明文,得到密文和认证标签。AES-GCM 的认证标签默认是 16 字节。
第三步,把 nonce、密文、认证标签拼在一起,作为最终的消息体发送出去。接收方收到后,先拆出 nonce 和认证标签,用接收方向的密钥解密,解密时会自动验证认证标签。如果标签不匹配,说明消息被篡改过,直接丢弃。
// Node.js 端加密 function encryptMessage(plaintext, sendKey) { const nonce = crypto.randomBytes(12); const cipher = crypto.createCipheriv('aes-256-gcm', sendKey, nonce); const encrypted = Buffer.concat([ cipher.update(plaintext, 'utf8'), cipher.final() ]); const authTag = cipher.getAuthTag(); return Buffer.concat([nonce, authTag, encrypted]); }# Python 端解密 from cryptography.hazmat.primitives.ciphers.aead import AESGCM def decrypt_message(ciphertext, receive_key): nonce = ciphertext[:12] auth_tag = ciphertext[12:28] encrypted = ciphertext[28:] aesgcm = AESGCM(receive_key) return aesgcm.decrypt(nonce, encrypted + auth_tag, None)注意 Python 的 AESGCM 库把认证标签附在密文后面,而 Node.js 的 crypto 模块把认证标签单独返回。这个差异导致我在对接时多花了一个小时调试。两边必须约定好消息的字节布局,我的约定是:前 12 字节是 nonce,接下来 16 字节是认证标签,剩下的是密文。
3.4 密钥轮换:什么时候该换密钥
端到端加密不是一劳永逸的。长期使用同一对密钥会增加密钥泄露的风险。我的做法是每 24 小时轮换一次密钥,或者每传输 100 万条消息轮换一次,以先到者为准。
密钥轮换的过程不需要重新走配对码握手。双方在现有加密通道上协商新的密钥即可。具体做法是:一方生成新的 ECDH 密钥对,把公钥用旧密钥加密后发给对方,对方也用新的 ECDH 密钥对回应,双方用新的共享密钥派生新的会话密钥。旧密钥在确认新密钥可用后立即销毁。
这个过程中有一个短暂的双密钥窗口期:旧密钥还在用,新密钥已经生成但还没生效。我的处理方式是给每条消息加一个密钥版本号,接收方根据版本号选择对应的密钥解密。版本号切换完成后,旧密钥再保留一段时间用于解密延迟到达的消息,之后彻底删除。
4. WebSocket 长连接:心跳、重连与消息可靠性
4.1 为什么 WebSocket 比 HTTP 轮询更适合 Agent 通信
Agent 之间的通信模式有几个特点:消息频率不固定,有时候几秒钟一条,有时候几分钟一条;双向通信,双方都可能主动发起消息;消息需要低延迟,尤其是工具调用的请求和响应之间。
HTTP 轮询在这种场景下有两个问题。一是延迟高,轮询间隔决定了消息的最长延迟。如果间隔设成 1 秒,平均延迟就是 500 毫秒;如果间隔设成 100 毫秒,服务器压力又太大。二是开销大,每次轮询都要建立新的 TCP 连接(或者至少是新的 HTTP 请求),头部开销远大于实际消息内容。
WebSocket 建立一次连接后,后续所有消息都复用这个连接。没有重复的握手开销,延迟可以做到毫秒级。而且 WebSocket 是全双工的,服务器可以主动推送消息给客户端,不需要客户端先发起请求。
我用 WebSocket 还有一个原因:它和 MCP 的传输层设计很契合。MCP 协议本身不限定传输方式,但它的消息格式是 JSON-RPC 风格的请求-响应模式,天然适合跑在 WebSocket 上。
4.2 心跳机制:怎么判断对方还活着
WebSocket 连接建立后,如果双方都不发消息,连接可能被中间的网络设备(比如 NAT 网关、负载均衡器)静默断开。这种断开不会触发 close 事件,双方都以为连接还在,实际上消息已经发不出去了。
心跳机制就是解决这个问题的。我的做法是:客户端每 30 秒发送一个 ping 消息,服务器收到后立即回一个 pong 消息。如果客户端连续 3 次没有收到 pong,就认为连接已断开,主动关闭并重连。
心跳消息本身不加密,因为它不携带任何敏感信息。但心跳消息的格式要和普通消息区分开,避免和业务消息混淆。我用的是 WebSocket 协议层面的 ping/pong 帧,而不是应用层的自定义消息。协议层面的 ping/pong 由 WebSocket 库自动处理,不需要应用层介入,更可靠。
// Node.js 端心跳配置 const ws = new WebSocket(url); ws.on('open', () => { const heartbeat = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.ping(); } }, 30000); ws.on('pong', () => { missedHeartbeats = 0; }); ws.on('close', () => { clearInterval(heartbeat); }); });心跳间隔的选择需要权衡。太短会增加不必要的网络流量,太长则断开检测不及时。30 秒是一个比较通用的值。如果你的网络环境不稳定,可以缩短到 15 秒;如果网络很稳定,可以延长到 60 秒。
注意:心跳间隔要比中间设备的空闲超时时间短。大多数 NAT 网关的空闲超时是 60 秒到 5 分钟,30 秒的心跳间隔能覆盖绝大多数情况。
4.3 断线重连:指数退避与状态恢复
网络抖动、服务器重启、客户端休眠,都会导致 WebSocket 连接断开。断线之后要自动重连,但重连不能太频繁,否则会给服务器造成压力。
我用的是指数退避策略:第一次重连等待 1 秒,第二次等待 2 秒,第三次等待 4 秒,以此类推,最大等待 60 秒。每次重连成功后,等待时间重置为 1 秒。如果重连失败,等待时间翻倍。
let reconnectDelay = 1000; const maxDelay = 60000; function reconnect() { setTimeout(() => { connect().then(() => { reconnectDelay = 1000; }).catch(() => { reconnectDelay = Math.min(reconnectDelay * 2, maxDelay); reconnect(); }); }, reconnectDelay); }重连之后有一个关键问题:断开期间的消息怎么办?如果消息在断开期间产生,重连后需要补发。我的做法是给每条消息加一个递增的序列号,接收方记录已收到的最大序列号。重连后,发送方从接收方确认的最大序列号之后开始补发。
这个机制要求发送方缓存未确认的消息。缓存大小设了一个上限,比如 1000 条。超过上限后,最旧的消息被丢弃,同时记录一个"消息丢失"事件,通知上层应用。
4.4 消息顺序与去重
WebSocket 本身保证消息的顺序,但重连和补发可能打乱顺序。比如发送方发了消息 1、2、3,消息 2 在传输中丢失,接收方收到了 1 和 3。重连后发送方补发消息 2,接收方收到的顺序变成了 1、3、2。
我的处理方式是:接收方维护一个滑动窗口,窗口大小设为 100。收到消息后,如果序列号在窗口内且已经收到过,直接丢弃;如果序列号在窗口内但还没收到,缓存起来等待缺失的消息;如果序列号超出窗口,说明有大量消息丢失,触发全量同步。
去重和排序的逻辑看起来简单,但实现起来有不少边界情况。比如窗口滑动时,已经确认的消息要从缓存中移除;比如序列号回绕时(如果序列号是有限位数的整数),比较逻辑要特殊处理。我用的是 64 位整数做序列号,实际中不会回绕,省去了这个麻烦。
5. MCP 协议在 Agent 互联中的实际角色
5.1 MCP 解决的是"能力描述"问题
两个 Agent 建立连接之后,接下来要解决的问题是:我知道你能干什么,你也知道我能干什么。这就是 MCP 协议发挥作用的地方。
MCP 的核心是一套工具描述格式。每个 Agent 把自己能调用的工具列出来,包括工具名称、参数格式、返回值格式、功能描述。对方 Agent 收到这份描述后,就知道在需要的时候可以调用哪些工具。
比如本地 Agent 有一个"读取文件"的工具,它通过 MCP 协议把这个工具的描述发给服务器 Agent。服务器 Agent 在需要读取本地文件时,就通过连接发送一个工具调用请求,本地 Agent 执行后把结果返回。
这个机制的好处是解耦。服务器 Agent 不需要知道本地文件系统的具体结构,只需要知道"有一个读取文件的工具,参数是文件路径,返回值是文件内容"。具体的读取逻辑由本地 Agent 实现。
5.2 工具调用的请求-响应模式
MCP 的工具调用是请求-响应模式。调用方发送一个请求,包含工具名称和参数;被调用方执行工具,返回结果或错误。
请求和响应的格式都是 JSON-RPC 风格的。请求包含一个唯一的 ID,响应里带上同一个 ID,这样调用方就能把响应和请求对应起来。因为 WebSocket 是全双工的,多个请求可以并发发送,响应不要求按顺序返回。
{ "jsonrpc": "2.0", "id": "req-001", "method": "tools/call", "params": { "name": "read_file", "arguments": { "path": "/tmp/example.txt" } } }响应格式:
{ "jsonrpc": "2.0", "id": "req-001", "result": { "content": [ { "type": "text", "text": "文件内容..." } ] } }这里有个实际中会遇到的问题:工具执行时间可能很长。比如一个网络请求工具,可能要等几十秒才返回。如果调用方一直等着,会阻塞其他消息的处理。我的做法是给工具调用加超时,默认 30 秒。超时后返回一个错误响应,调用方决定是否重试。
5.3 能力协商的时机与内容
能力协商在握手完成后立即进行。双方各自发送一份工具列表给对方,对方收到后确认。这个过程只需要一次,后续如果工具列表有变化,再发送更新通知。
工具列表的内容包括:工具名称、描述、参数 schema、返回值 schema。参数 schema 用的是 JSON Schema 格式,这样两边都能用现成的库做校验。
{ "tools": [ { "name": "read_file", "description": "读取指定路径的文件内容", "inputSchema": { "type": "object", "properties": { "path": { "type": "string", "description": "文件路径" } }, "required": ["path"] } } ] }能力协商有一个容易忽略的点:版本兼容。如果两边的 MCP 协议版本不同,工具描述格式可能有差异。我的做法是在握手阶段交换协议版本号,如果版本不兼容,直接拒绝连接并提示升级。这比连接建立后再发现不兼容要好得多。
5.4 工具调用的安全边界
不是所有工具都应该暴露给对方 Agent。有些工具涉及敏感操作,比如删除文件、执行系统命令,这些工具不应该通过 Agent 连接暴露出去。
我的做法是在工具注册时加一个标记,标明这个工具是否允许远程调用。只有标记为允许的工具才会出现在能力协商的列表里。默认情况下,所有工具都不允许远程调用,需要显式开启。
另外,远程调用的工具要有参数校验。对方传来的参数不能直接信任,必须按照 schema 校验一遍。校验不通过的请求直接拒绝,不执行工具。
还有一个实际中遇到的问题:工具调用的权限控制。即使工具允许远程调用,也不是所有连接都能调用。我给每个连接分配一个权限级别,不同级别能调用的工具不同。比如只读连接只能调用读取类工具,不能调用写入类工具。
6. 踩过的坑与排查过程
6.1 加密消息长度不一致导致的解密失败
这个问题困扰了我整整一个下午。现象是:大部分消息能正常解密,但偶尔有几条消息解密失败,报"认证标签不匹配"。
排查过程是这样的:先确认密钥是否正确,打印了两边的密钥,发现完全一致。然后确认 nonce 是否重复,检查了最近 100 条消息的 nonce,没有重复。最后把失败的消息单独拿出来,对比加密前的明文和解密后的结果,发现解密后的数据比明文少了几个字节。
问题出在 Node.js 的 Buffer 拼接上。我用 Buffer.concat 拼接 nonce、认证标签和密文,但认证标签的长度我写成了 15 字节,实际是 16 字节。这导致拼接后的消息里,认证标签少了一个字节,密文的第一个字节被当成了认证标签的一部分。大部分消息碰巧能解密,是因为 AES-GCM 的认证标签有一定的容错性,但遇到特定内容时就会失败。
修复方法很简单:把认证标签的长度改成 16 字节。但这个问题的排查过程让我意识到,加密相关的代码必须严格按规范来,任何长度、偏移量的错误都可能导致难以定位的问题。
6.2 WebSocket 消息分片导致的大消息丢失
Agent 之间传输的消息有时候会很大,比如工具返回的文件内容可能有几兆。WebSocket 协议支持消息分片,但我在实现时没有正确处理分片。
现象是:小消息正常,大消息偶尔丢失或截断。排查时抓包发现,大消息被分成了多个 WebSocket 帧发送,但我的接收端只处理了第一个帧,后面的帧被丢弃了。
WebSocket 库通常会自动处理分片,把多个帧拼成一条完整消息。但我用的那个库在消息超过一定大小时,会把消息拆成多个事件触发,而不是拼成一条。我需要在接收端手动拼接。
修复方法是:在接收端维护一个缓冲区,收到分片消息时先缓存,收到最后一个分片时再拼接成完整消息。同时给消息加一个长度前缀,接收端根据长度判断消息是否完整。
这个问题的教训是:不要假设 WebSocket 库会自动处理所有情况。不同的库行为不同,必须仔细阅读文档,必要时自己处理分片。
6.3 配对码过期时间与重连的冲突
这个问题比较隐蔽。现象是:Agent 运行一段时间后,如果连接断开重连,配对码已经过期,重连失败。
原因是:配对码只在初始连接时使用,重连时不应该再要求配对码。但我一开始的实现是每次连接都走完整的握手流程,包括配对码验证。Agent 运行超过 5 分钟后,配对码过期,重连就失败了。
修复方法是:区分首次连接和重连。首次连接走完整握手,包括配对码验证。重连时跳过配对码验证,直接用之前协商的密钥重新建立加密通道。如果密钥也过期了(比如超过了 24 小时的轮换周期),则要求重新配对。
这个修复引入了一个新的问题:重连时如何确认对方身份?如果跳过配对码,攻击者是不是可以冒充?我的做法是:重连时用之前协商的会话密钥做认证。双方各自发送一条用会话密钥加密的挑战消息,对方能正确解密并回应,就确认身份。会话密钥只有双方知道,攻击者没有密钥就无法通过认证。
6.4 消息序列化格式不一致导致的解析错误
前面提到过,两个 Agent 分别用 Node.js 和 Python,JSON 序列化的行为有差异。具体表现是:包含特殊 Unicode 字符的消息,一边序列化后另一边解析失败。
比如一个包含 emoji 的字符串,Node.js 的 JSON.stringify 会把它转成代理对(surrogate pair),而 Python 的 json.dumps 默认会把它转成 \uXXXX 转义序列。两边解析时,如果编码方式不一致,就会得到不同的字符串。
解决方案是统一用 MessagePack 做序列化。MessagePack 对 Unicode 的处理是标准化的,两边库的实现也一致。切换到 MessagePack 后,这类问题再没出现过。
另外,MessagePack 对二进制数据更友好。加密后的消息本身就是二进制,用 MessagePack 打包不需要额外的 Base64 编码,省去了一层转换。
6.5 并发工具调用导致的资源竞争
最后一个坑是关于并发的。两个 Agent 同时调用对方的同一个工具,如果这个工具涉及共享资源(比如写同一个文件),就会出现资源竞争。
现象是:偶尔出现文件内容错乱,或者工具返回的结果和预期不符。排查后发现,两个工具调用几乎同时到达,被调用方并发执行,两个执行过程互相干扰。
解决方案是在被调用方加一个工具级别的锁。同一个工具在同一时间只能执行一个调用,其他调用排队等待。锁的粒度可以更细,比如按参数加锁,不同参数的工具调用可以并发执行。
import threading tool_locks = {} def call_tool(tool_name, arguments): lock = tool_locks.setdefault(tool_name, threading.Lock()) with lock: return execute_tool(tool_name, arguments)这个方案简单有效,但要注意锁的释放。如果工具执行过程中抛出异常,锁必须释放,否则后续调用会一直阻塞。Python 的 with 语句会自动释放锁,所以用 with 是最安全的写法。
7. 一些实测有效的经验参数
7.1 超时与重试的推荐值
经过一段时间的运行和调整,我总结出了一套比较稳定的参数:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 配对码有效期 | 5 分钟 | 足够人工传递,又不会留下太长的攻击窗口 |
| 配对码最大尝试次数 | 10 次 | 配合速率限制,暴力破解不可行 |
| 心跳间隔 | 30 秒 | 覆盖大多数 NAT 网关的空闲超时 |
| 心跳超时次数 | 3 次 | 连续 3 次没收到 pong 就重连 |
| 重连初始延迟 | 1 秒 | 第一次重连等待 1 秒 |
| 重连最大延迟 | 60 秒 | 避免无限增长 |
| 工具调用超时 | 30 秒 | 大多数工具能在 30 秒内完成 |
| 密钥轮换周期 | 24 小时 | 或 100 万条消息,先到者为准 |
| 消息缓存上限 | 1000 条 | 超过后丢弃最旧的消息 |
这些值不是绝对的,需要根据实际网络环境和业务特点调整。比如网络不稳定的环境,心跳间隔可以缩短到 15 秒;工具执行时间长的场景,工具调用超时可以延长到 60 秒。
7.2 日志与监控的关键指标
Agent 之间的通信出问题时,日志是排查的第一手资料。我记录了这些关键指标:
连接状态。每次连接建立、断开、重连都记一条日志,包括时间戳、对端标识、断开原因。
消息统计。每分钟统计发送和接收的消息数量、平均大小、加密解密耗时。这些指标能反映通信的健康状况。
错误计数。解密失败、认证失败、工具调用超时、序列号异常,这些错误都要计数。错误率突然上升通常意味着有问题。
延迟分布。记录每条消息从发送到收到响应的耗时,统计 P50、P95、P99 延迟。延迟的异常增长可能是网络问题或对方处理能力不足的信号。
这些指标我通过一个简单的 HTTP 接口暴露出来,用 Prometheus 抓取,Grafana 展示。虽然搭这套监控花了一些时间,但后面排查问题时省下的时间远超投入。
7.3 安全方面的额外建议
除了前面讲的加密和认证,还有几个安全方面的实践值得注意:
最小权限原则。每个 Agent 只暴露必要的工具,不需要的工具不要注册。远程可调用的工具要显式开启,默认关闭。
输入校验。对方传来的任何数据都不可信,必须校验。工具参数按 schema 校验,消息内容检查长度和格式,防止注入攻击。
审计日志。所有工具调用都记录审计日志,包括调用方、工具名称、参数摘要、执行结果、耗时。审计日志保留至少 30 天,便于事后追溯。
定期更新依赖。加密库、WebSocket 库、序列化库都要保持更新,及时修复已知漏洞。我用 Dependabot 自动检查依赖更新,有安全更新时自动创建 PR。
这套方案跑到现在大概两个月了,中间经历过几次网络抖动和一次服务器重启,都自动恢复了。两个 Agent 之间的协作任务完成得挺顺畅,之前需要手动搬运数据的场景现在全自动了。如果你也在做类似的事情,希望这些经验能帮你少走一些弯路。