流式响应与进度通知:progress token、日志通知与背压处理

系统讲解 MCP 的流式与通知机制:progressToken 的传递与 notifications/progress 上报格式、日志通知与分级、资源变更通知的时序、工具无原生流式输出时的三种替代模式(进度上报、任务句柄轮询、分块资源)、取消语义、通知频率与背压控制,以及 Streamable HTTP 下的 SSE 多路复用陷阱。

MCP 的 tools/call 在协议上是一次请求、一次响应:模型发出调用,服务器返回结果。这个模型对「查一个表」够用,对「跑一次全量索引」「转码 500 个文件」「等一次部署完成」就完全不够——服务器可能跑 10 分钟,而客户端早就超时了;即便不超时,用户也看不到任何反馈,只能盯着转圈。

MCP 没有把「流式工具输出」写进核心协议,而是给了两套正交的机制:通知(服务器主动推送,单向、无 id、不期待响应)和 Streamable HTTP 的 SSE 通道(在传输层承载流)。本文要回答的是:progressToken 怎么传、进度通知该多频繁、工具没有原生流式输出时怎么做出「流式体验」、取消如何生效、以及通知洪水怎么变成事故。

1. 通知与请求的本质区别

1.1 三类消息回顾

类型有 id期待响应典型用途
Request有有tools/call、roots/list
Response有—请求的应答
Notification无无进度、日志、资源变更

关键约束:通知不能回执。服务器发了 notifications/progress 就结束了,它无法知道客户端是否收到、是否展示了。所有依赖「客户端一定看到」的逻辑都不能建立在通知上——这是设计进度语义时最容易越界的地方。

1.2 常用通知一览

通知方向用途
notifications/progress服务器 → 客户端长任务进度
notifications/message服务器 → 客户端结构化日志
notifications/resources/updated服务器 → 客户端已订阅资源内容变化
notifications/resources/list_changed服务器 → 客户端资源集合变化
notifications/tools/list_changed服务器 → 客户端工具列表变化
notifications/cancelled双向取消进行中的请求

协议层细节(方法命名空间、通知语义)见 https://plumephp.com/mcp-json-rpc-specification/。

2. progressToken:进度上报的钥匙

2.1 令牌从哪来

进度令牌由请求方在 params._meta 里给出:

{
  "method": "tools/call",
  "params": {
    "name": "reindex_corpus",
    "arguments": { "scope": "docs" },
    "_meta": { "progressToken": "reindex-1" }
  }
}

服务器在处理该请求期间,用同一个 token 推送进度:

{
  "method": "notifications/progress",
  "params": {
    "progressToken": "reindex-1",
    "progress": 4200,
    "total": 12000,
    "message": "已索引 docs/api 目录"
  }
}

2.2 语义约定

字段含义注意
progressToken与请求关联的标识原样回传,服务器不要改写
progress当前进度值单调不减(推荐),可为任意数值
total总量可省略,省略时客户端只显示「进行中」
message人类可读说明不要放敏感信息,它会进 UI

三个实践约定:

1. 没有 total 时也要发 —— "还在跑"本身就有价值
2. progress 单调递增,不要用"剩余量"这种递减语义
3. message 面向用户,不是面向日志(日志走 notifications/message)

2.3 服务器端实现

async function reindex(args: Args, extra: RequestHandlerExtra): Promise<CallToolResult> {
  const token = extra._meta?.progressToken;
  const files = await collectFiles(args.scope);
  let done = 0;

  for (const f of files) {
    await indexFile(f);
    done++;
    // 节流:每 1% 或每 200ms 上报一次,二者取先到
    if (token && shouldReport(done, files.length)) {
      await extra.sendNotification({
        method: "notifications/progress",
        params: { progressToken: token, progress: done, total: files.length, message: f },
      });
    }
  }

  return { content: [{ type: "text", text: `已索引 ${done} 个文件` }] };
}

2.4 令牌与请求生命周期绑定

请求结束(响应已发出)→ 该 token 立即失效,再发进度通知是协议违规
请求被取消          → 立即停止上报,并释放相关资源
无 token 的请求     → 服务器可以选择不上报(不能凭空造 token)

最后一条常被误解:客户端没给 token,说明它不关心进度。服务器此时应该干脆跳过上报,而不是自造一个 token 发出去——客户端会收到无法关联的通知,只能丢弃。

3. 工具没有原生流式输出,怎么做出流式体验

tools/call 的响应只有一次,所以「流式」要在应用层构造。三种模式:

3.1 模式 A:进度 + 最终结果

适用:有明确总量的批处理(索引、批量转换、批量校验)
体验:进度条 + 结束时一次性给结果
限制:中途无法让模型看到部分结果

3.2 模式 B:任务句柄 + 轮询

服务器立即返回一个任务 id,真正的结果通过资源或后续工具调用获取:

// 第一次调用:立刻返回
{ "content": [{ "type": "text", "text": "任务已启动,task_id=job-8842,可用 get_job_result 查询" }] }
后续:
  Agent 调 get_job_result { task_id: "job-8842" }
    状态 running  → 返回 "已完成 40%"
    状态 done     → 返回完整结果

这种模式把「等待」变成了模型可控的多轮交互,好处是 Agent 可以在等待期间做别的事,坏处是轮询会消耗轮次与 token。轮询间隔建议做指数退避(1s → 2s → 4s,上限 15s),并在连续多次 running 后给出明确提示,避免模型陷入死循环。可靠性相关的退避与超时策略见 https://plumephp.com/mcp-tool-call-reliability/。

3.3 模式 C:分块资源

把产物写成一个可增量读取的资源,进度用 notifications/resources/updated 提示:

资源 URI:job://8842/partial
  每次写入新片段 → 服务器推送 notifications/resources/updated { uri: "job://8842/partial" }
  客户端(若已订阅)重新读取 → 拿到增量内容
{
  "method": "notifications/resources/updated",
  "params": { "uri": "job://8842/partial" }
}

注意:resources/updated 只对已订阅该资源的客户端有效。没订阅的客户端不会收到,也就不会去读。所以模式 C 必须配合客户端的订阅动作,服务器不能假设「推了就会有人读」。

3.4 三种模式的选择

场景推荐模式理由
批量处理、总量已知A实现最简,UI 体验够
耗时不可预测、结果较大B模型可控、无连接长期占用
需要模型看到中间产物C真正的增量可见
部署/CI 等待类B + A句柄轮询 + 阶段性进度

值得对照的是 gRPC 的服务端流式(Server Streaming):它把流式写进协议本身,一次调用可以持续推送多条消息,代价是必须维持长连接、需要更强的会话与重连管理。MCP 选择不把工具输出流式化,换来的是传输无关(stdio 也能跑)与实现简单,代价就是上面这三种模式要应用层自己搭。两种取向的取舍可参考 gRPC 流式通信 。

4. 日志通知:notifications/message

4.1 与进度通知的分工

progress → 给用户看:「正在处理第 42/100 个文件」
message  → 给开发者看:级别、logger 名、结构化 data
{
  "method": "notifications/message",
  "params": {
    "level": "warning",
    "logger": "indexer",
    "data": { "file": "docs/api/legacy.md", "reason": "encoding", "action": "skipped" }
  }
}

4.2 分级与客户端过滤

标准级别:debug / info / notice / warning / error / critical / alert / emergency。客户端可设置最小级别(logging/setLevel),服务器据此过滤。

建议默认:info
调试期:  debug(但要预期通知量剧增)
生产:    warning 起,避免日志洪水占用传输通道

4.3 日志不该进模型上下文

日志通知是给运维通道的,客户端不应把它注入 LLM 上下文。一个 debug 级别的循环日志(每次迭代一行)如果进了上下文,几轮就能吃掉几千 token,还会干扰模型对当前任务的判断。日志通道与上下文通道必须在客户端侧物理隔离,不要指望靠「级别过滤」在同一个管道里补救。

5. 取消、超时与背压

5.1 取消

{
  "method": "notifications/cancelled",
  "params": { "requestId": 42, "reason": "user aborted" }
}

服务器收到后应:停止计算、清理临时产物、停止后续通知。三个易错点:

1. 取消是"尽力而为"——请求可能已经完成,收到取消要容忍竞态
2. 取消后不要再发该 token 的进度通知
3. 清理要幂等:取消与正常结束可能同时触发清理逻辑

5.2 超时分层

层建议值作用
客户端单次请求超时60s(长任务用句柄模式规避)防 UI 卡死
服务器内部任务超时依业务防资源泄漏
进度通知静默超时30s无进度即视为卡死

「进度静默超时」是长任务专用的看门狗:如果 30 秒内既没有进度通知也没有结果,客户端应判定任务异常,而不是无限等待。这条规则要求服务器在长任务的每个阶段都上报,哪怕 progress 不变也要更新 message。

5.3 通知节流(背压)

通知是「无回执」的,这意味着服务器感知不到客户端消费不过来——没有 TCP 那样的窗口机制。于是背压必须由服务器自己造:

class ThrottledProgress {
  private lastSent = 0;
  private lastValue = -1;
  constructor(private minIntervalMs = 200, private minDelta = 1) {}

  shouldSend(value: number, total?: number): boolean {
    const now = Date.now();
    if (now - this.lastSent < this.minIntervalMs) return false;
    if (total && value - this.lastValue < Math.ceil(total * 0.01)) return false;
    this.lastSent = now; this.lastValue = value;
    return true;
  }
}

节流的两条基准线:时间(≥200ms 一次)与增量(≥1% 总量)。两者取「都满足才发」,而不是任一满足——只按时间会在快任务里刷屏,只按增量会在慢任务里长时间静默。

反例:一个处理 10 万条记录的循环,每次迭代都发一条 progress,产生 10 万条 JSON-RPC 通知。stdio 传输下这会阻塞正常响应,SSE 下会撑爆缓冲区。这类「通知洪水」在压测里很常见,是长任务工具最容易引发的事故。

5.4 SSE 通道下的多路复用

Streamable HTTP 用 SSE 承载服务器 → 客户端的消息流。多个请求的进度通知会在同一条流上交错,所以客户端必须按 progressToken 分发,不能假设「流上的下一条就是我这次请求的进度」:

SSE 流:progress(reindex-1) → progress(other-7) → message(indexer)
        → progress(reindex-1) → response(id=42)

同时要注意:SSE 连接有中间代理的空闲超时。长时间无数据会被 nginx 之类断开,因此心跳是必需的——即使没有真实进度,也应在空闲期发送轻量通知或注释行保活。SSE 与 WebSocket 在保活、重连、多路复用上的差异可对照 实时通信:SSE 与 MQTT 中的长连接治理章节;远程部署下的会话与重连策略见 https://plumephp.com/mcp-remote-streamable-http/。

6. 观测与调试

6.1 该度量什么

指标含义告警阈值示例
通知速率每秒发出的通知数> 50/s 需关注
进度静默时长距上次进度的秒数> 30s
取消率被取消的请求占比> 10% 说明超时设置不当
长任务占比超过 60s 的调用比例上升则需引入句柄模式
通知/响应比通知数 ÷ 请求数> 100 说明节流失效

6.2 调试手法

1. 用 mcp-inspector 观察通知流,确认 token 与请求的关联
2. 打开 debug 级别日志,看是否有"发了通知但无对应 token"
3. 压测时把节流参数调低,观察客户端 UI 是否被拖慢
4. 模拟客户端不消费(不发响应)验证服务器是否仍持续推送

第 4 条是验证节流是否真的生效的唯一手段:如果客户端完全不读,服务器仍在刷通知,说明背压缺失。

7. 常见陷阱

陷阱症状解决
每迭代发一条进度通知洪水,响应被阻塞时间 + 增量双节流
无 token 也上报客户端收到无法关联的通知无 token 就不上报
响应后继续上报协议违规、客户端报错响应发出即失效 token
长任务无静默看门狗卡死无人发现30s 无进度判异常
依赖 resources/updated 被读客户端没订阅,静默无效果先确认订阅,或改用轮询
日志注入上下文token 被 debug 日志吃光日志走独立通道,不进上下文
SSE 无心跳代理空闲超时断流定期心跳保活
取消后继续清理两次产物被误删清理逻辑幂等

8. 小结

MCP 的流式能力可以概括为「协议只给通知,流式靠应用层构造」:

层面要点
令牌progressToken 由请求方给出,原样回传,随请求结束失效
上报progress 单调递增,message 面向用户,无 total 也要发
模式进度+结果、任务句柄轮询、分块资源三选一
日志notifications/message 分级,默认不进模型上下文
背压时间与增量双节流,静默看门狗兜底
传输SSE 多路复用按 token 分发,空闲心跳保活

把进度做对的关键认知是:通知是单向、无回执、可丢弃的。任何「客户端一定会看到」的假设都会在生产环境里碎掉。按这个前提设计——该节流就节流,该轮询就轮询,该保活就保活——长任务工具才既有反馈又不失控。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「AI工程」更多文章

  1. IDE 与编辑器集成:stdio 生命周期、工作区上下文与诊断回写
  2. 工具版本与兼容性治理:能力协商、Schema 演进与灰度下线
  3. 多模态工具设计:图像、音频与文档内容的返回契约