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 分发,空闲心跳保活 |
把进度做对的关键认知是:通知是单向、无回执、可丢弃的。任何「客户端一定会看到」的假设都会在生产环境里碎掉。按这个前提设计——该节流就节流,该轮询就轮询,该保活就保活——长任务工具才既有反馈又不失控。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。