ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

两个AI Agent直连实战:配对码握手、端到端加密与MCP协议踩坑记

2026/10/7 23:36:11 拓冰建站 浏览量
两个AI Agent直连实战:配对码握手、端到端加密与MCP协议踩坑记 两个 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.randomIntPython 里是 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 模式还会生成一个认证标签接收方用这个标签验证消息是否被篡改过。为什么不用 RSARSA 的密钥交换需要对方用公钥加密一个随机数再传回来这个过程比 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( algorithmhashes.SHA256(), length64, saltbagent-bridge-salt-v1, infobagent-bridge-key-derivation, ) derived hkdf.derive(shared_secret) return { send_key: derived[:32], receive_key: derived[32:], }这里有个细节盐值和信息字符串必须两边完全一致包括大小写和标点。我一开始在 Node.js 端写的是 agent-bridge-salt-v1Python 端写成了 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 和 PythonJSON 序列化的行为有差异。具体表现是包含特殊 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 之间的协作任务完成得挺顺畅之前需要手动搬运数据的场景现在全自动了。如果你也在做类似的事情希望这些经验能帮你少走一些弯路。