MCP的七宗罪:运营罪

发布: (2026年3月31日 GMT+8 07:13)
13 分钟阅读
原文: Dev.to

Source: Dev.to

请提供您希望翻译的正文内容,我将按照要求将其翻译为简体中文并保留原有的格式、Markdown 语法以及技术术语。谢谢!

运营罪恶:懒惰(Sloth)愤怒(Wrath)

这些罪恶属于此类,因为它们决定了实时 MCP 系统在压力下的行为方式:是诚实地失败、理智地恢复,还是在故障期间让操作员能够信任他们所看到的内容。

两者都会在系统受压时出现。

  • 懒惰(Sloth) 隐藏问题,使用模糊错误、薄弱的校验或马虎的传输处理。
  • 愤怒(Wrath) 将本可存活的问题通过盲目重试、重连风暴以及对不确定性的强硬反应放大。

当访问边界更严格时,下一个问题是系统在出错时的表现。这正是这两种罪恶接管的地方。

在 MCP 中,运营层尤其明显,因为传输和协议行为是产品的一部分。标准输入/输出(Std‑io)卫生、重连行为、可恢复性、通知以及会话处理,一旦模型对接接口上线,就不再是旁枝末节。


懒惰(Sloth)

懒惰 是指回避精确校验、具体错误以及基本的运营卫生。这种罪恶在代码审查时很少显得戏剧化;它通常看起来无害:

  • 捕获块隐藏了细节。
  • 校验规则被推迟。
  • 调试打印输出到错误的流。

没有人认为自己在做危险的选择——他们认为自己在省时间。但 MCP 对马虎的边界容忍度极低。

如何发现它

  • 当某些东西失败时,日志模糊、重复或毫无用处。
  • 操作员不断看到诸如 “MCP error” 之类的通用错误,而不是实际的故障原因。
  • 标准输入/输出集成神秘地中断,因为非协议输出泄漏到了 stdout
  • 团队花更多时间复现故障,而不是修复它们。

示例

这类情况经常出现在支持和运维队列中。客服代表在聊天中等待客户时请求助手获取客户 cus_1234,或工程师在排查时按 ID 请求最新的事件。在那一刻,错误输入未找到依赖宕机 是三种截然不同的情形,且对应不同的后续步骤。如果工具把它们合并为一个模糊的失败,用户就失去了正确响应所需的上下文。

改进前

server.tool("get_customer", async ({ id }) => {
  try {
    return await db.customers.findById(id);
  } catch {
    throw new Error("MCP error");
  }
});

改进后

class ToolError extends Error {
  constructor(
    public code: "invalid_input" | "not_found" | "dependency_unavailable",
    message: string,
    public retryable: boolean
  ) {
    super(message);
  }
}

server.tool("get_customer", async ({ id }) => {
  if (typeof id !== "string" || id.trim() === "") {
    throw new ToolError(
      "invalid_input",
      "id must be a non‑empty string",
      false
    );
  }

  try {
    const customer = await db.customers.findById(id);
    if (!customer) {
      throw new ToolError("not_found", `customer ${id} not found`, false);
    }
    return customer;
  } catch (error) {
    console.error("get_customer failed", { id, error });
    if (error instanceof ToolError) {
      throw error;
    }

    throw new ToolError(
      "dependency_unavailable",
      "customer lookup is temporarily unavailable",
      true
    );
  }
});

同时,修复你的传输卫生

// 对于 stdio 服务器来说是错误的写法
console.log("server started");

// 对于 stdio 服务器来说是正确的写法
console.error("server started");

在协议边缘诚实地呈现失败

// `code` 和 `retryable` 是该服务器错误合约的一部分,
// 不是 MCP 自动为你生成的字段。
function toMcpErrorResult(error: ToolError) {
  return {
    isError: true,
    code: error.code,
    retryable: error.retryable,
    content: [{ type: "text", text: error.message }],
  };
}

如何修复

修复应从边界开始。对工具入口处的输入进行校验,而不是在请求已经变得难以推理的调用栈深处进行。当某些…

Source:

g 确实会失败,请在可能的情况下保留真实的失败模式。运维人员需要有用的错误信息,而不是模糊的戏剧化错误;调用方需要了解 未找到 结果与依赖损坏之间的区别。

在实践中,这通常意味着:

  • 稳定的错误码。
  • 清晰的人类可读信息。
  • 为内部诊断细节提供单独的存放位置。

将运维卫生视为合同的一部分。保持协议流量与诊断信息分离,尤其是在标准输入输出上,stdout 为数据,stderr 为日志。 在远程 HTTP 传输中,等效的规范是会话生命周期、重新连接行为以及可恢复性:如果这些不一致,即使处理程序本身是正确的,系统也会变得难以推理。

为格式错误的输入、缺失字段、下游超时以及未找到的情况添加负面测试,然后在服务器端统一错误结构,避免每个工具自行创造混乱的私有版本。关键在于,使类型化的失败能够穿过 MCP 边界而不被在输出时合并为单一的通用错误。MCP 为你提供传输层和结果通道;使失败可操作的稳定字段仍需作为你自己的服务器合同的一部分。


来自实战的经验

该模式出现在 modelcontextprotocol/typescript-sdk #699 中,真实的工具异常被误导性的 -32602 结构化内容错误所取代。 一旦系统开始对失败原因说谎,所有下游的调试步骤都会变得更加昂贵。 模糊的错误不再是有用的信号——它会成为一种负担。

Source:

愤怒

愤怒 是对不确定性或失败的反应,以强硬而非控制的方式表现出来。你通常会在设计讨论中先听到愤怒的声音,再在代码中看到它的实现:

  • “如果失败,就重试。”
  • “如果慢,就加快轮询频率。”
  • “如果流断开,立即重新连接。”

这就是失去耐心的运维版表现。

如何识别

  • 在故障期间,日志中会出现重试循环或重新连接风暴。
  • 单个失效的依赖可能突然导致请求重复、作业重复或服务器启动重复。
  • 客户端不断敲击已经降级的端点。
  • 超时图请求量图 同时上升。

示例

在一次真实的故障中,内部应用或助手在有人已经在进行事件响应时失去了 MCP 连接。用户会看到一次掉线或一个转圈加载时间过长。实际上,一个不耐烦的客户端会把这一次中断转化为重复的进程启动、重复请求,进而给已经出现故障的系统增加更多负载。

之前

async function ensureConnection(client: McpClient, serverCommand: string) {
  while (true) {
    try {
      await client.connect(new StdioTransport(serverCommand));
      return;
    } catch {
      await sleep(100);
    }
  }
}

之后

function sleepWithAbort(ms: number, signal: AbortSignal) {
  return new Promise((resolve, reject) => {
    const timeout = setTimeout(() => {
      signal.removeEventListener("abort", onAbort);
      resolve();
    }, ms);

    const onAbort = () => {
      clearTimeout(timeout);
      signal.removeEventListener("abort", onAbort);
      reject(new Error("connection cancelled"));
    };

    if (signal.aborted) {
      onAbort();
      return;
    }

    signal.addEventListener("abort", onAbort, { once: true });
  });
}

async function ensureConnection(
  client: McpClient,
  serverCommand: string,
  abortSignal: AbortSignal
) {
  for (let attempt = 1; attempt <= 5; attempt += 1) {
    try {
      if (abortSignal.aborted) throw new Error("connection cancelled");
      await client.connect(new StdioTransport(serverCommand));
      return;
    } catch (error) {
      if (abortSignal.aborted) throw error;
      if (attempt === 5) throw error;

      const backoffMs = attempt * 1000 + Math.floor(Math.random() * 250);
      await sleepWithAbort(backoffMs, abortSignal);
    }
  }
}

如何修复

  1. 学会停下来。 对重试次数设定硬性上限,并加入带抖动的渐进式退避,以防所有客户端同步重新连接。
  2. 线程取消。 将 abort 信号传播到每一次外发请求和长时间运行的操作,使系统能够在必要时停下来,而不是盲目升级。
  3. 决定哪些操作可以安全重试。
    • 幂等的读取和重新连接尝试通常是安全的。
    • 有副作用的操作应避免自动重试,除非你拥有显式的幂等键或其他去重机制。
  4. 让重试可见。 为重试次数、退避延迟和重新连接风暴添加监控和度量。如果不对它们进行测量,就只能等到生产环境硬核报错才发现问题。

同样的警告也适用于受管边缘和网关。限流、代理重试以及策略强制可以减轻损害,但它们并不能修复后端非幂等、对失败描述模糊或不安全的重复操作。

来自实战的教训

教训很简单:重试是系统设计的一部分,而不是你在边缘随意贴上的创可贴。如果你的重试策略不够完善……

explicit,你实际上并没有一个。

为什么运营罪难以修复

运营罪通常需要共享基础设施,而不是孤立的补丁。

  • 懒惰(Sloth)修复 通常意味着构建验证层、错误策略、结构化日志以及针对不良路径的测试框架。
  • 愤怒(Wrath)修复 往往涉及传输客户端、作业运行器、后台工作者和 UI 状态处理等多个层面。你可能需要:
    • 重试助手,
    • 退避策略,
    • 取消模型,
    • 幂等性保护,以及
    • 显示重试和重新连接的仪表盘。

这类工作容易被推迟,因为它们不容易演示。但一旦实现,所有后续工具的运行、调试和可信度都会变得更低成本。

0 浏览
Back to Blog

相关文章

阅读更多 »