阅读需 8 分钟

MCP stdio 背压会冻结你的 agent 吗?

MCP stdio 背压可能让 agent 因大型工具结果而阻塞。复现管道停滞,限制结果大小,安全读取数据,并测试取消后的恢复。

MCP stdio 背压会冻结你的 agent 吗?

大型 MCP 工具结果可能冻结 agent 进程,即使协议实现的每一行代码在技术上都有效。问题在于把 stdio 当成无限快速的消息总线。实际上,它是两个进程之间容量有限的字节流,只要任一端停止读取,两个方向都可能停滞。

相比普通命令行程序,这对 agent 工具的影响更大。工具可能生成巨大的搜索结果、编码后的文件、完整的 API 响应,或冗长的命令输出。接下来,agent 可能要对它进行分词、摘要,等待批准,或者只是判断当前上下文已经足够。如果主机在这些工作期间停止读取服务器的 stdout,服务器就可能在响应中途阻塞。阻塞后,它可能无法读取 stdin 中的取消通知或后续请求。

解决方案不是某一个设置。你需要一份让结果保持小巧的响应契约,一个独立于 agent 工作、持续读取 stdout 的传输循环,以及一条能够抵达实际生产数据的取消路径。要用刻意设计的恶意负载测试这三部分。

管道阻塞看起来可能像 agent 出错

stdio 管道阻塞会产生一些容易把团队带偏的症状。agent 在工具调用后像是冻结了。服务器进程仍然存在。CPU 使用率可能很低。超时触发了,但重试也会卡住。有人会怪罪模型运行时、MCP SDK,或者工具实现中的锁。

很多时候,工具其实已经完成了工作。它只是卡在 write() 上,试图发送一份主机已经不再读取的响应。管道容量有限,而且依赖平台。Node 的子进程文档明确说明了 shell 程序员几十年来都知道的事实:当子进程写入的数据超过管道容量,而父进程没有读取时,子进程会一直阻塞,直到管道再次接受更多字节。

这类事故中有两个不同的队列,把它们混为一谈会导致错误修复。

  • 操作系统管道保存 MCP 服务器与主机之间的原始 stdout 字节。
  • 主机应用队列保存已经解析、等待 agent 代码、UI 代码、日志或上下文组装处理的 JSON-RPC 消息。

增大应用队列无法解决没人读取管道的问题。增大管道或流缓冲区可以推迟阻塞,但也会给服务器更多空间去生成一份 agent 本来就不该收到的响应。结果预算决定哪些内容可以进入对话。背压处理决定当一方速度较慢时该怎么办。两者解决的是不同的问题。

官方 MCP 调试指南还给出了一个简单的诊断边界:本地 stdio 服务器必须让日志避开 stdout,把诊断信息写到 stderr。如果 stdout 中出现横幅、堆栈跟踪或不属于协议数据的进度行,那么在处理大型结果之前,消息帧就已经损坏了。

工作继续时,stdout 必须保持可读

主机必须遵守最重要的一条规则:在服务器进程的整个生命周期内,都要连接并持续运行 stdout 读取器。不要把它绑定到一个等待 agent 完成工具结果处理的 promise 上。不要在批准对话框期间暂停读取。不要等渲染器、数据库写入或模型请求完成后,才接收接下来的字节。

传输循环应当只承担清晰、有限的职责:

  1. 操作系统提供字节时,立即读取 stdout。
  2. 把这些字节交给协议帧解析器。
  3. 将格式错误或超大的帧作为传输故障拒绝。
  4. 把完整消息交给独立于读取器的有界分发器。
  5. 当分发器无法接受更多工作时,继续读取,或有意终止子进程。

关键是“独立”。如果读取器在线调用昂贵的消息处理逻辑,它最终一定会变成停止读取的读取器。消息过大时,JSON 解析本身确实可能造成问题,但更常见的错误更早发生:读取器等待一个正在执行无关工作的回调。

主机至少应为每个服务器会话记录这些值:stdout 接收字节数、最大的完整帧、解析失败次数、等待分发容量的时间、发送的取消请求数,以及进程退出情况。这些数字很快就能结束争论。如果 stdout 字节数在结果传输到一半时停止增长,而服务器仍然存活,应怀疑服务器的写入端。如果字节仍在到达,但完整消息的分发停止了,应怀疑主机队列或其消费者。

不要根据工具响应对模型是否有用来控制 stdout 流量。读取器必须接收完整的协议消息,之后才能安全地丢弃、报告或路由它。主机如果在帧尚未结束时判断消息太大,然后停止读取,就是自己制造了死锁。

结果限制需要两道边界

同时设置传输限制和内容限制。单纯计算字符数无法保护进程,因为 JSON 转义、base64 编码和外围响应结构都会改变 stdout 上的字节数。

传输限制是单个完整序列化 JSON-RPC 消息的最大字节数。帧解析器应在解析任意 JSON 之前执行这项限制,以保护主机的内存和解析时间。内容限制是工具在 contentstructuredContent 中返回的最大有用内容量。工具处理器应在序列化结果之前执行这项限制,以保护 agent 上下文,并让响应保持有意义。

这两种限制都不能只写在工具描述中。模型偶尔还是会请求无界搜索、递归列表或完整文档。服务器必须以可预测的方式处理这种请求。

实用的工具响应应说明省略了什么,以及 agent 如何继续获取。这样比静默截断字符串更好,因为静默截断看起来像完整证据。

{
  "jsonrpc": "2.0",
  "id": 41,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "Returned 50 of 4,382 matching records. Results are sorted by updated time. Use cursor \"eyJvZmZzZXQiOjUwfQ\" to continue, or add a narrower path or query."
      }
    ],
    "structuredContent": {
      "items": [
        {"path": "src/auth.ts", "line": 18, "summary": "reads token from environment"}
      ],
      "nextCursor": "eyJvZmZzZXQiOjUwfQ",
      "truncated": true,
      "totalEstimate": 4382
    }
  }
}

文本为模型提供普通说明。结构化内容则为客户端提供稳定的继续获取令牌,以及机器可读的截断信号。如果统计所有记录的成本很高或根本无法做到,不要编造总数。只需写 truncated: true,省略计数。虚假的精确度比诚实地返回不完整结果更浪费时间。

不要照搬“只返回文件路径”这一流行建议。它只有在主机和服务器共享文件系统、路径经过授权、agent 能够读取路径,并且工件仍然存在时才有效。在远程或沙盒环境中,这是一项无法兑现的承诺。引用可以有用,但必须配套一个有自己限制的读取或导出操作。

返回决策所需的信息,而不是原始输出

大多数超大响应都来自输出模型照搬命令行的工具。grep -Rgit diff、云端列表 API 和数据库查询,对终端前的人类来说都有意义。但把它们包装成 JSON,并不会自动让它们成为好的 agent 接口。

agent 通常只需要足够的证据来选择下一步。给它一组有界的匹配项、相关字段,以及缩小请求范围的方法。完整工件应通过明确的导出或检索路径保存,调用方在那里主动选择分页或受限范围。

对于代码仓库搜索,返回文件路径、行号范围、简短摘录和使用的查询。不要返回大型单体仓库中的每一行匹配内容。对于 HTTP 客户端,返回状态、选定的请求头、有界的正文预览,以及产品能够安全保留时使用的响应句柄。不要把任意下载内容 base64 后放进 content。对于 SSH,返回 stdout 和 stderr 的有上限尾部内容,以及退出状态。命令打印了一个巨大的生成文件,只说明它生成了一个巨大文件。agent 很少需要在即时上下文中看到每个字节。

把限制放在内容膨胀的源头附近。调用远程 API 的服务器应把分页参数和字段选择器传给远程 API。运行进程的服务器应限制子进程捕获量,同时继续读取 stdout 和 stderr。搜索文件的服务器应在达到结果预算后停止,而不是收集所有匹配项再裁剪最终字符串。

这一点很重要,因为收集完成后再截断只能保护 MCP 传输,无法保护负责执行工作的机器。递归命令仍然可能先消耗内存、磁盘和 CPU,响应层之后才丢弃输出。

Sallyport 在这里很有用,因为它让 HTTP 和 SSH 凭据留在 agent 之外,操作通过本地网关执行。但这个边界不会让无界 API 正文或 shell 输出变得安全,因此工具作者仍然需要在操作边界设置明确的输出预算。

在声称修复之前,先复现阻塞

通过 Sallyport 处理 MCP 调用
Agent 通过内置的 sp mcp 连接,Sallyport 负责执行它们的 HTTP 和 SSH 操作。

调用工具并检查大型结果最终是否出现,不能测试这种故障。你需要建立一个故意停止读取 stdout 的测试工具,然后证明服务器确实进入阻塞状态。之后,再证明正常主机不会这样运行。

下面这个小型 Node 固件会写入一份有效的 JSON-RPC 响应,负载足够大,当父进程忽略 stdout 时可以超过普通管道容量。它从 stdin 接收一行请求,然后分块写入响应。等待 drain 就是证据:它表示运行时已经对服务器的 stdout 写入器施加了背压。

// oversized-server.mjs
import readline from "node:readline";
import { once } from "node:events";

const rl = readline.createInterface({ input: process.stdin });

for await (const line of rl) {
  const request = JSON.parse(line);
  const text = "x".repeat(8 * 1024 * 1024);
  const response = JSON.stringify({
    jsonrpc: "2.0",
    id: request.id,
    result: { content: [{ type: "text", text }] }
  }) + "\n";

  for (let start = 0; start < response.length; start += 16 * 1024) {
    const chunk = response.slice(start, start + 16 * 1024);
    if (!process.stdout.write(chunk)) {
      process.stderr.write("stdout backpressure observed\n");
      await once(process.stdout, "drain");
    }
  }
}

现在以管道方式连接 stdout 来启动它,并故意不读取 child.stdout。继续读取 stderr,这样就能看到背压标记。发送一个请求并短暂等待。子进程应该仍然存活,而且不会完成写入。这是预期行为,不是 Node 的缺陷。

// blocked-parent.mjs
import { spawn } from "node:child_process";

const child = spawn(process.execPath, ["oversized-server.mjs"], {
  stdio: ["pipe", "pipe", "pipe"]
});

child.stderr.setEncoding("utf8");
child.stderr.on("data", chunk => process.stderr.write(chunk));

child.stdin.write(JSON.stringify({
  jsonrpc: "2.0",
  id: 1,
  method: "tools/call",
  params: { name: "large", arguments: {} }
}) + "\n");

setTimeout(() => {
  console.error("child still running:", child.exitCode === null);
  child.kill("SIGTERM");
}, 1000);

不要把这个父进程模式复制到生产环境。它的作用只是让故障变得明显。用真实的帧解析器替代缺失的 stdout 消费者,再运行同一个固件。子进程应该完成响应,或者客户端应根据明确的限制拒绝响应,但不能因为父进程忽略 stdout 而一直卡住。

使用包含引号、多字节字符和很长连续字符串的负载运行测试。这些情况可以发现把 JavaScript 字符数当成 UTF-8 字节数的帧解析器,也能发现假设每次读取都恰好在消息边界结束的实现。

拒绝巨型帧时,不要重新造成阻塞

帧大小检查有一个陷阱:停止消费超大帧的客户端,会和从未连接读取器的客户端一样让服务器阻塞。客户端需要恢复规则。

如果帧协议提供了清晰的结束标记,就继续读取到该标记,同时丢弃违规帧,然后报告协议违规,并决定会话是否可以继续。如果协议编码无法让你在超过限制后安全恢复帧边界,就终止服务器进程,关闭 stdin,并启动新会话。听起来很严厉,但试图猜测损坏的 JSON 消息在哪里结束,后果更糟。

MCP stdio 在本地字节流上使用 JSON-RPC 消息。实现必须把帧处理当作传输代码,而不是收集无界文本后调用 split("\n") 的便利操作。累积候选消息时保持字节计数。每次收到数据块后,要么找到完整帧边界并分发完整帧,要么在候选帧超过最大值时失败。

不要为了确认消息很大,就先解析一条巨型消息。JSON.parse 需要完整的内存字符串,分配量可能超过输入大小。传输限制存在的目的,就是避免这项工作。

如果你控制两端并使用按换行分隔的 JSON,就应要求每行只有一个 JSON 对象,通过正常 JSON 编码拒绝字符串中的字面换行,并在每个完整序列化对象后准确写入一个分隔符。服务器绝不能把人工诊断信息写入 stdout。MCP 的调试文档也要求本地服务器把日志发送到 stderr,因为 stdout 专门用于协议数据。

读取器还应限制等待应用处理的完整消息数量。如果队列已满,不要让 stdout 永久停止读取。可以拒绝新请求、在支持的地方取消工作,或结束会话。具体的过载动作取决于主机,但让 stdout 一直无人读取绝不是安全的默认选项。

只有取消抵达生产者,取消才有效

离线验证 agent 活动
使用 sp audit verify 离线检查加密哈希链,无需保险库密钥。

agent UI 中的超时不等于取消。它只是改变了 UI 对请求的看法。服务器可能仍在查询数据库、下载响应、运行进程,并向 stdout 写入数 MB 数据。

MCP 为同一方向上已经发出的请求定义了 notifications/cancelled。通知包含原始请求 ID,也可以包含原因。MCP schema 要求接收方停止关联处理、释放资源,并把结果视为不再使用。同时它也提醒,取消可能与完成发生竞争。因此,客户端必须能够处理延迟响应,服务器也必须能够处理已经完成请求的取消通知。

对于 tools/call,客户端可以通过同一个 stdio 会话发送如下通知:

{
  "jsonrpc": "2.0",
  "method": "notifications/cancelled",
  "params": {
    "requestId": 41,
    "reason": "result exceeded the client budget"
  }
}

服务器需要维护一个从请求 ID 到活动任务的映射。每个条目应包含 abort controller 或语言中的等效机制、完成状态,以及它拥有的子进程、HTTP 请求或游标。取消通知到达时,中止任务,停止生成结果内容,清理映射条目。如果正常响应尚未开始写入,就不要发送它。

这里有一个容易被忽略的错误。团队给工具处理器加入了 abort signal,但处理器等待的子进程却忽略了这个信号来读取 stdout。或者他们取消了 HTTP fetch,却留下一个仍在序列化巨大内存数组的转换过程。只有当取消抵达每一个生产者和每一个等待中的操作时,取消才是真实的。

把服务器开始写响应之后视为另一种状态。取消可能在字节已经进入管道后到达,而这些字节无法收回。主机应继续读取足够的数据来保持会话健康,然后忽略与已取消 ID 对应的延迟响应。服务器看到取消后应尽快停止额外工作,但不能保证不会出现延迟字节。

取消测试需要第二个请求

优秀的取消测试证明的是恢复能力,而不只是某个计时器触发了。启动一个输出速度足够慢的工具,让客户端能够在任务活动期间取消它。发送取消通知,然后在同一个 MCP 会话中提交一个小型、无关的请求。第二个请求必须及时完成。

下面的测试流程可以发现最重要的故障:

  1. 启动一个 tools/call,让处理器分块生成大型响应,或调用一个刻意放慢的生产者。
  2. 等待测试工具观察到足够的 stdout 字节,确认响应已经开始。
  3. 针对该请求 ID 发送 notifications/cancelled
  4. 确认生产者在规定期限内退出,或报告其中止路径。
  5. 使用新的 ID 发送一个小请求,例如健康检查工具或有界 echo 工具。

最后一个请求才是测试重点。它能揭示服务器是否仍然卡在写入操作中,stdin 读取器是否被饿死,被取消的任务是否持有全局锁,或者主机是否在决定取消后停止读取 stdout。

还要测试工作开始前、远程 I/O 进行到一半、子进程运行期间,以及最终响应已经开始写入之后的取消。这些是不同的状态。能处理好其中一个状态的实现,可能会在另一个状态失败。

不要断言取消一定会阻止响应。MCP 规范允许竞争条件。应断言的是:响应在取消后到达时,主机仍然保持正确;如果取消及时到达,服务器工作会停止。

进程输出需要独立的读取路径

让 API 密钥远离 agent
Sallyport 注入 bearer、Basic 或自定义请求头凭据,因此 MCP agent 无需持有这些凭据。

MCP 服务器经常调用命令行工具。这会在服务器内部增加第二对管道:服务器执行 MCP 工作时,必须消费子进程的 stdout 和 stderr。如果它读取子进程流到达限制后就停止,子进程可能在退出前阻塞。外层 MCP 服务器随后看起来像是忽略了取消,其实它是在等待一个无法继续运行的子进程。

捕获有界预览,但超过预览限制后仍要继续读取。标记输出已截断,并丢弃剩余字节。如果命令支持自己的结果限制,应在启动命令前传入该限制。例如,让搜索工具最多返回指定数量的匹配项,让数据库返回有限页面,或让日志命令只返回尾部内容。达到上限后继续读取是安全网,不是主要的结果策略。

让 stderr 与 stdout 分开。工具可能向 stderr 写入有用诊断,同时返回正常退出状态。分别限制并读取两个流。绝不要把任意进程输出合并到 MCP 服务器的 stdout。外层 stdout 只有一个任务:序列化 MCP 消息。

SSH 命令也遵守同样的规则。远程命令可以持续输出,直到其通道阻塞。应捕获有界数据,持续消费远程流直到完成或取消,并诚实说明丢弃了哪些内容。完整输出属于专门的工件流程,不应放进普通工具结果。

把故障加入发布门禁

结果限制和取消机制很容易在重构中被意外移除。有人可能把流式读取器换成 readFile,把分页 API 调用改成无界调用,或把解析移进 UI 回调。应把大型结果固件保留在测试套件中。

发布门禁应覆盖这些情况:刚好低于传输上限的有效响应、刚好超过上限的响应、巨大的单字段、许多小型内容块、格式错误的 JSON、嘈杂的 stderr、意外的 stdout 日志,以及输出期间的取消。测试必须通过真实的进程启动路径,而不只是内存传输。内存测试无法体现管道背压。

用通俗语言记录预期行为:客户端持续读取 stdout;不会解析超过配置帧限制的内容;会报告有界结果错误;取消能够抵达活动任务;恢复后另一个请求可以继续处理。这样工程师可以改变实现细节,却不会削弱这些保证。

如果只能做一项改动,就做这一项:把 stdout 读取器与 agent 处理分开,然后用一个写入量远超任何合理工具返回量的服务器进行测试。这个测试会把模糊的“agent 冻结”报告变成可以复现、测量,并能阻止其进入下一版本的故障。

常见问题

为什么 MCP 服务器返回大型工具结果后会冻结?

当服务器写入数据的速度超过主机读取 stdout 的速度,或者主机在解析、缓冲或转发结果时阻塞,就会出现这种情况。服务器随后会等待管道获得空间,因此也可能停止读取 stdin。后续请求,包括取消请求,都可能排在这个阻塞之后。

MCP 工具结果的安全最大大小是多少?

MCP 没有一个适用于所有情况的安全数值,因为合适的限制取决于客户端、模型上下文预算、结果编码方式以及具体工作内容。应为完整序列化 JSON-RPC 响应设置字节限制,并为工具内容设置更小的语义限制。截断必须明确说明,同时向调用方提供游标、路径、查询或后续工具,以便获取剩余内容。

增大 stdout 缓冲区能解决 MCP 背压吗?

不能。增大内存缓冲区只会推迟故障,还可能把输出阻塞变成内存暴增。主机必须持续读取管道,服务器也必须避免一开始就构造不合理的大型响应对象。

MCP 工具如何通过 stdio 取消?

MCP 的取消通知是 notifications/cancelled,其中包含原始请求 ID,也可以带上原因。它只是一个提示,并且可能与请求完成发生竞争,因此服务器必须把它连接到真正的中止信号,客户端也必须能够处理已经写出的响应。

agent 忙碌时,MCP 主机是否应该继续读取 stdout?

持续读取 stdout,并把数据送入帧解析器;在字节到达时执行最大帧大小限制,再把完整消息交给独立的、有界工作队列。不要因为 agent 正在处理前一个工具结果,就停止读取 stdout。如果队列已满,应执行明确的过载处理,而不是让传输通道一直无人读取。

MCP 服务器可以把调试输出写入 stdout 吗?

把运行日志写到 stderr,而不是 stdout。stdout 只承载 MCP 协议消息,哪怕一行意外日志也可能破坏消息帧。官方 MCP 调试文档也明确要求本地 stdio 服务器这样处理。

MCP 响应超过限制时,客户端应该怎么做?

达到限制后不要停止读取。继续读取并丢弃数据,直到读完违规帧;如果无法安全恢复帧边界,就终止服务器进程并重新建立会话。仅仅在达到限制时停止读取,会重新造成你原本想避免的管道阻塞。

如何测试 MCP 取消,而不是只测试超时?

分别测试三件事:服务器注意到了取消,子任务停止了,以及客户端在取消请求后仍然可用。仅仅在日志中看到取消通知,几乎不能证明什么。你需要在取消大型请求后,再发送一个小请求,并确认它能及时完成。

MCP 工具应该如何返回大型文件或 API 响应?

使用分页、过滤、摘要、稳定引用和明确的导出工具。返回目录树、代码仓库差异、查询结果或 HTTP 正文的工具,应返回当前决策所需的部分,然后提供继续获取内容的方式。仅仅因为整个文件容易序列化,就把完整工件发送出去,是糟糕的工具设计。

使用操作网关后,还会有 MCP stdio 背压风险吗?

Sallyport 通过本地应用和 sp mcp shim 处理 HTTP 与 SSH 操作,因此 agent 不会收到底层凭据。这能保护机密的使用,但不会改变 stdout 的物理限制。工具和客户端仍然需要结果限制、取消机制,以及能证明大型响应不会卡死会话的测试。

Sallyport

Sallyport 替你的 AI 智能体执行 API 调用和 SSH 命令。密钥留在你 Mac 上的本地密钥库里;每次运行由你批准,每个操作都落入一份密封的审计日志。

© 2026 Sallyport · 依据 Apache-2.0 开源 · Oleg Sotnikov