# MCP stderr 上限如何让代理运行持续进行

嘈杂的辅助程序可以让一次代理运行停下来，即使没有破坏任何一条 JSON-RPC 消息。它会把 stderr 变成没有上限的队列，然后让系统的其他部分承担后果：管道被填满，读取器保留数 MB 数据，关闭过程一直等待，或者用户已经不再信任这次运行后，审批卡片才姗姗来迟。

应把 stderr 当作带预算的不受信任输入。只有在你看到一个普通命令每次重试都输出一条诊断信息，而它原本要支持的操作早已完成、父进程却仍在收集这些噪音时，才会觉得这个要求有些琐碎。解决办法不是「关闭日志」，而是持续读取，只保留有限数量的数据，记录丢弃了多少内容，并让操作状态独立于文本输出。

## stderr 是一条背压路径

当父进程将 stderr 连接到管道，却没有足够快地读取时，辅助程序就可能被阻塞。操作系统为管道提供的缓冲区大小有限。一旦子进程填满缓冲区，下一次写入就必须等待读取器。如果子进程只有在写完这条诊断信息后才会进入成功路径，那么即使网络请求、SSH 连接或本地工作本身都没有问题，操作看起来仍会卡住。

另一种故障更安静，却往往代价更高。父进程可能及时读取 stderr，然后把每个字节都追加到字符串、事件缓冲区或 MCP 工具结果中。管道不会被填满，但话很多的子进程仍可能消耗足够多的内存，让主机变慢、触发内存压力，或导致后续调用失败。只保护管道的上限是不完整的，只保护内存的上限同样不完整。

代理系统更容易遇到这个问题，因为代理经常会同时启动多个辅助程序。一个任务可能启动好几个辅助程序，而重试循环还可能在第一个输出尚未排空前制造另一轮峰值。在交互式终端中看似无害的日志速率，一旦被多个会话同时捕获，就会变成资源问题。

需要限制的是三类不同的数据：

- 操作系统管道中等待读取的字节数。
- 桥接层为一次调用保存在内存中的字节数。
- 桥接层向代理、用户界面或日志投影暴露的字节数。

不要把行数上限和字节数上限混为一谈。一行可能包含很大的响应正文、证书链或压缩后的错误对象。也不要假设 UTF-8 总是以完整字符到达。流读取器按数据块接收字节，多字节字符跨越数据块边界时，上限仍必须正常工作。

实用的读取器会为整个流维护一个字节计数器，同时用独立的环形缓冲区保存需要保留的尾部。总量超过上限后，读取器仍会继续排空流，让子进程能够退出。它不再扩大保留缓冲区，并记录发生过截断。对于输出本身就具有攻击性的工具，在第一次超出上限时立即终止子进程有时是正确的做法，但这不应成为诊断信息的默认策略。通常你仍然需要实际的退出状态，以及能够解释失败原因的最后几行输出。

## MCP 让 stdout 保持在边界之外

MCP stdio 服务器必须将 stdout 视为协议区域。Model Context Protocol 规范关于 stdio 传输的说明要求，服务器写入 stdout 的内容必须全部是有效的 MCP 消息。这个规则看起来像是格式规范，容易被人忽略。实际上，它能阻止一类更糟糕的故障：辅助程序随手输出的一行进度信息，就可能让主机解析出无效 JSON，进而放弃一个原本运行正常的会话。

应将面向人的诊断信息放到 stderr，但不要因此认为它们已经无害。就协议而言，stderr 只是带外通道。进程主机仍然要决定是继承该流、通过管道连接、捕获它、写入终端，还是转发到结构化日志中。每种选择都会改变故障行为。

对于本地开发来说，继承流通常没有问题，因为终端会消耗输出，开发者也能看到它。但这不适合作为代理桥接的默认设置。它会把任意文本泄露到可能不会保留内容的地方，也可能让并发调用的输出交错在一起，还可能携带代理根本不应接收的细节。只有设置了预算，完整捕获才更适合诊断。

让协议边界保持简单。MCP 服务器应在 stdout 上发送有效的 JSON-RPC 消息，在 stderr 上保留有上限的诊断信息，并以明确的方式控制辅助程序的各个流。辅助程序应通过预定通道返回结构化结果，不应把 JSON 数据块打印到 stderr，然后希望父进程以后能够认出它。

这种区分可以避免一个反复出现的错误：把 stderr 当作备用响应通道。它不是。stderr 没有可靠的分帧机制，取消时可能不完整，还可能包含对你的操作模型一无所知的库输出。如果调用方需要重试次数、远程错误代码或发生变更的文件列表，应将这些信息加入结构化结果。stderr 应保留给人在诊断故障时可能需要的证据。

## 捕获输出需要单独的内存预算

在后台任务中读取 stderr，并不会让捕获过程变得安全。它只是把瓶颈从管道移到了堆内存。我见过主机通过并发读取两个流来修复死锁，随后却发现有问题的辅助程序可以迫使主机在操作结束前保留每一个字节。进程确实退出了，但主机已经承受了代价。

应使用环形缓冲区保留诊断信息。环形缓冲区达到容量后会保留最新的字节，而有用的错误通常就在这些内容里。如果你的环境认为第一行很重要，例如其中包含命令调用或库版本，也可以额外保留一小段开头。但不要无限期同时保留开头和结尾。

下面的伪代码描述了值得实现的行为。它不依赖某种特定语言。

```text
on_stderr_chunk(bytes):
  stderr_seen += length(bytes)
  if stderr_seen <= capture_limit:
    append_tail(bytes)
  else:
    append_tail(bytes)       # ring buffer evicts older bytes
    stderr_truncated = true
  continue_reading()
```

其中的注释值得注意。`capture_limit` 应表示你要保留的数据量，而不是停止读取的阈值。严格的实现可以在达到上限后跳过 `append_tail`，改为保留开头的数据。我更倾向于保留尾部，因为错误信息经常出现在数页进度输出之后。无论选择哪种方式，都应在记录中说明，让后续调查人员知道看到的是开头还是结尾。

还应对将诊断信息带入代理上下文的对象设置第二道上限。代理不需要数 MB 的完整记录来决定是否重试。它需要简洁的错误信息、退出状态，以及最多一段经过选择的尾部。如果桥接层因为「模型可能需要」就直接传递全部辅助程序输出，那么每个辅助程序都能挤占任务上下文的其他内容。

不要为了限制文本长度，就对大流进行解码和重新编码。先在构建字符串之前统计原始字节数。对保留的数据片段进行解码，遇到无效序列时采用替换策略，然后将结果标记为捕获到的 stderr。这样既能避免浪费内存，也能避免辅助程序意外输出二进制数据时产生错误的安全感。

## 操作完成后，进程仍可能在关闭时失败

进程关闭阶段最容易让日志洪流变成误导性的事故报告。远程操作可能已经成功，辅助程序可能已经打印了最后一条诊断信息，但父进程仍可能因为等待错误的事件、顺序也不对，而无法生成结果。

常见的顺序如下：

1. 桥接层启动辅助程序并开始读取 stdout，但 stderr 在一轮突发输出期间读取滞后。
2. 辅助程序完成外部操作，然后写入足够多的诊断信息，填满 stderr 管道。
3. 父进程取消会话或达到截止时间，并发送终止信号。
4. 父进程在关闭或排空流读取器之前等待子进程退出。
5. 一个读取器等待文件结束，另一个任务等待读取器，最终会话永远无法写入最终记录。

外部影响可能已经发生。HTTP 请求可能已被接受，SSH 命令可能已经修改了远程文件。若把这次调用简单报告为「超时」，操作人员就会得到最糟糕的答案：他们不知道重试是否会再次执行同一项变更。

应为每个辅助程序指定一个统一的所有者，由它同时管理四件事：子进程句柄、stdout 读取器、stderr 读取器和取消操作。正常完成时，应等待进程退出，并让读取器排空到文件结束后再构造最终结果。取消时，应请求终止，继续读取两个流，等待一个有上限的宽限期；如果平台允许，之后再强制终止。最后等待读取器完成，并记录实际观察到的退出状态。

不要让流读取器只从属于请求处理器。客户端断开连接时，处理器可能会消失。辅助程序及其读取器需要由能够持续到清理完成并写入最终状态的所有者管理。否则代理进程可能退出，主机可能丢弃对读取器的最后引用，而子进程仍然活着，管道却无人读取。

进程组也需要同样谨慎。shell 包装器可能会生成继承 stderr 的后代进程，只终止包装器可能会让后代继续持有管道。能不用 shell 就不用。如果必须使用，应将它放入受控的进程组，并明确取消操作会影响哪些后代进程。然后测试包装器退出但孙进程仍继续写入的情况。

## 审批时间不应受日志量影响

审批时间应跟随操作状态，而不是辅助程序输出诊断信息的速度。如果界面只有在辅助程序生成预检记录后才显示审批，而预检过程又很嘈杂，用户被要求做决定的时间就会发生变化。这样一来，审批会显得毫无规律，人们也会逐渐习惯于不理解原因就批准耗时不同的卡片。

在编写界面代码之前，先定义状态转换。一次调用可以依次处于已接收、已验证、等待审批、已授权、已分发、已完成、已取消，或分发前失败等状态。stderr 可以附着在调用上，但不能决定发生哪种状态转换。桥接层应验证请求的目标和参数，创建调用记录，并在启动需要审批的操作前展示必要的审批界面。

有一个有用的例外。如果必须先运行辅助程序才能发现将要执行的操作，例如将本地配置名称解析为具体端点，那么应把发现过程视为独立的非操作流程，并为它设置单独的输出预算。不要把真正的操作藏在「预检」中，然后事后声称用户已经批准了它。

应为审批设置一个不会因新 stderr 到达而重置的实际时间截止点。审批卡片显示期间可以继续收集有限长度的尾部，但不要因为每一行日志都到达而重新绘制卡片。如果审批前出现重试警告或错误摘要，它们可以提供有用的上下文，但应以稳定的说明呈现，而不是变成不断滚动的日志视图。

在测试中分开验证同意与存活性。洪流测试应测量从收到有效调用请求到出现审批卡片的时间，并分别测试审批前后产生 stderr 的情况。慢读取器测试应确认桥接层排空流时卡片仍然可用。取消测试应确认关闭卡片后没有辅助程序继续运行，并且会生成最终生命周期记录。

对人的影响很直接：审批卡片必须在特定时刻描述一项具体操作。如果日志输出可以延迟、改变或超出这个时刻，界面报告的就会是进程的混乱，而不是让用户掌握控制权。

## 调用记录应先写生命周期事实，再写诊断信息

调用记录只要能说明操作状态，就已经完整，不必包含辅助程序输出的每一行。日志输出是证据，生命周期事件才是记录本身。

在分发操作前写入一次尝试。内容应包括稳定的调用标识符、会话标识符、请求的操作类型、批准或拒绝的决定，以及展示给用户的目标信息。分发开始时追加这一事实。分发结束时追加实际观察到的结果：成功、远程失败、本地失败、取消、强制终止，或者由于进程边界丢失而产生的未知结果。

然后附加诊断元数据。至少应记录看到的 stderr 总字节数、保留的字节数、是否发生截断、辅助程序的退出状态（如果可用），以及流是否正常结束。这样，有限长度的尾部才是可信的。之后查看记录的人可以区分「命令打印了这些内容」和「桥接层保留了命令输出的最后一部分」。

记录结构可以很小：

```json
{
  "call_id": "c_7f2a",
  "state": "cancelled_after_dispatch",
  "stderr_bytes_seen": 184320,
  "stderr_bytes_retained": 16384,
  "stderr_truncated": true,
  "exit_status": null,
  "stream_end": "reader_completed_after_cancel"
}
```

不要因为父进程收到了成功的响应正文，就写入 `exit_status: 0`。响应正文和子进程退出是两种不同的观察结果。也不要在分发后发生取消时写入 `state: failed`，因为远程一侧可能已经执行了操作。实现时这似乎只是过于细致，凌晨两点进行调查时，它却可能决定你是在安全地查明情况，还是盲目重试。

NIST SP 800-92《计算机安全日志管理指南》提出了一个有用的观点：日志管理包括生成、传输、存储、分析和处置，而不只是收集文本。将这一思路应用到代理操作上。如果收集过程可能阻止操作完成，那么日志路径就已经成为执行路径的一部分。它和其他执行路径一样，需要上限、状态和故障处理。

Sallyport 从一个写入盲加密、哈希链式审计日志中生成 Sessions 和 Activity 日志，因此调用方可以区分代理运行与单独的操作记录，不必把辅助程序的记录当作历史记录。

## 在三个位置限制噪音

只在栈顶设置一个上限，仍会给意外留下太多空间。应在辅助程序、桥接层和诊断目的地分别设置限制。每项限制保护的是不同边界。

首先，默认让辅助程序少输出一些。将常规进度信息放在明确的调试设置后面，为一组重试输出一条摘要，并默认避免打印请求或响应正文。辅助程序绝不能将凭据、授权标头或私钥材料写入 stderr。捕获后再脱敏是有用的后备措施，但它无法挽回已经出现在终端、崩溃报告或无上限缓冲区中的秘密。

其次，让桥接层持续排空流，并保留有限长度的开头或尾部。每个流都应有最长持续时间和最大保留字节数。还应设置进程级诊断预算。没有最后这一层控制，50 个调用即使都没有超过各自的调用上限，同时进行时仍可能制造内存峰值。

第三，限制目的地。如果要将调用诊断信息导出到用户界面、代理响应或文件中，也应在那里设置另一道上限。日志可以保留结构化的生命周期字段和被丢弃输出的摘要，而不必保存每次重试都重复出现的堆栈跟踪。

应使用明确的配置结构。名称并不重要，分离不同限制才重要。

```yaml
helper_output:
  stderr_retained_per_call_bytes: 16384
  stderr_retained_process_bytes: 262144
  stderr_agent_excerpt_bytes: 4096
  shutdown_grace_seconds: 5
  retain: tail
```

这个配置可以防止一种常见故障：团队设置了代理响应上限，就以为主机已经受到保护。实际上，主机可能先读取并存储完整数据，最后才裁剪响应。每次调用的保留上限保护单次操作，进程上限保护并发操作，代理摘录上限保护模型必须与其他工作共享的上下文。

不要使用名为 `quiet` 的单一全局开关。它会让生产故障更难诊断，也会鼓励开发者在需要证据时重新启用无限日志。应让正常输出保持简洁，为短时间窗口提供受控的调试模式，并在调用记录中保留调试模式曾处于启用状态这一事实。

避免采用将 stderr 重定向到 `/dev/null` 的常见建议。人们推荐它，是因为这样可以消除眼前的停滞，让代理响应保持整洁。但这也会删除第一条有用线索，例如 SSH 辅助程序无法认证、证书检查失败，或远程命令返回意外错误时的线索。应排空、限制并标记这个流。

## 截断应当可见，而不是制造戏剧性

只要截断过程明确且系统仍在持续排空，截断就是安全的。如果后续查看者无法判断错误信息是否完整，或者桥接层停止读取并阻塞子进程，或者被丢弃的输出可能是某项没有其他记录的操作的唯一说明，那么截断就是不安全的。

保留的尾部应以桥接层生成的标记开头，而不是由辅助程序生成。例如：

```text
[stderr truncated: kept last 16384 of 184320 bytes]
connection retry 18 failed: remote side closed the channel
```

这个标记是记录的一部分，而不是装饰。它告诉用户为什么第一行可见内容看起来突然开始，也能防止代理把不完整的堆栈跟踪当作完整解释。如果同时保留开头和尾部，应说明两部分的字节数。绝不要静默地把它们拼接在一起。

应根据并发量倒推上限，而不是照搬其他项目的数字。要考虑一次代理运行最多会同时执行多少项操作，主机接受多少次运行，在糟糕的一分钟内诊断信息最多可以消耗多少内存，以及人真正能有效检查多少文字。最后一个数字通常比人们想象的小得多。

让操作结果与摘录分开。即使 stderr 达到上限，成功的 HTTP 操作仍应返回它原本的结构化结果，除非辅助程序本身将输出超限视为失败信号。反过来，stderr 流干净也不能证明操作成功。应把诊断信息视为多项观察结果中的一个字段。

## 洪流测试应与普通集成测试并列

输出过多 stderr 的辅助程序并不是一种罕见的安全测试场景，而是基本的可靠性测试。库在版本变更后可能变得更加啰嗦，远程客户端会在重试时反复输出警告，格式错误的输入也可能触发错误循环。如果桥接层在测试中只接触安静且顺利的辅助程序，就无法证明进程处理在压力下仍然有效。

先准备一个测试装置，让它输出固定数量的 stderr，使用指定的状态退出，并且不执行外部操作。在类 Unix 系统上，下面的命令可以为本地测试框架制造有意的洪流：

```sh
yes helper-diagnostic 1>&2
```

在较短的截止时间下运行它。预期结果不只是截止时间触发。还要确认桥接层保留的数据不超过配置值、标记了截断、终止了子进程、排空流直到它们关闭，并写入最终生命周期事件。

然后加入能够暴露顺序错误的场景：

- 辅助程序在等待审批前写入 stderr。
- 辅助程序在审批期间写入 stderr，并在获批后立即退出。
- 辅助程序在外部操作成功后向 stderr 大量写入，并在清理期间收到取消信号。
- 包装器退出，但后代进程仍保持 stderr 打开。
- 辅助程序写入无效字节序列，并输出一行超过保留上限的内容。

为每个场景测量一组小而明确的事实：保留的诊断信息峰值字节数、审批耗时、从取消到进程退出的时间、最终状态，以及预期调用记录是否存在。不要满足于只断言错误字符串中包含「已截断」。读取器任务可能仍然被阻塞，最终记录也可能根本没有写入存储，而这段字符串仍然可能出现。

使用并发调用运行相同的测试装置。每次调用的上限单独看起来正确，但所有调用同时达到上限时仍可能失败。还要测试客户端断开连接的情况。代理进程可以离开，但主机仍需要以明确的方式完成或取消操作。

最后一步是离线验证审计链，并与测试装置预期的生命周期进行比较。验证可以告诉你存储的条目是否被修改，测试装置比较可以告诉你桥接层是否一开始就写入了应写入的条目。两者都需要。

## 让日志保持有用，但不要让它们控制操作

规则很简单：stdout 承载 MCP 协议消息，结构化结果承载操作结果，stderr 承载有限长度的诊断信息。当这些通道各自承担不同职责时，审批时间、关闭流程、内存统计和审计记录就不会再争夺同一段非结构化文本。

从那个曾让你头疼的辅助程序开始：它可能重试时输出过多，取消后一直挂起，或者失败时把完整的远程响应打印出来。为它套上有上限的读取器，然后运行洪流测试，直到操作记录始终如实反映情况。安静的日志令人舒服，有上限的日志才是运维控制。
