
一个 Agent 调用工具生成报表,网关在 60 秒时返回超时。此时最危险的处理不是报错,而是客户端立刻重试:前一次报表可能仍在后台生成,第二次请求又启动了一份;如果工具带有扣款、发信或写状态副作用,重复执行会更麻烦。
据 MCP 官方数据,2026 年 7 月 28 日发布的新规格把协议核心改为无状态,并明确说明:取消协议层 session 不等于应用不能保存状态。需要跨调用延续的工作,应由工具签发显式 handle,再由模型或客户端在后续调用中传回。Tasks 也移入 io.modelcontextprotocol/tasks 扩展,提供轮询式 tasks/get 和新的 tasks/update。
这次变化值得关注的不是"又多了一版协议",而是它迫使长任务把状态从隐蔽连接里拿出来。
HTTP 请求结束,不代表业务任务结束
长任务至少同时存在三个状态层:
| 状态层 | 典型状态 | 回答的问题 |
|---|---|---|
| 传输层 | connected / timeout / closed | 这次网络请求怎样结束 |
| 任务层 | queued / running / input_required / completed / failed / cancelled | 后台工作走到哪里 |
| 业务层 | not_applied / applied / unknown / compensated | 外部系统到底有没有改变 |
HTTP timeout 只说明客户端没有按时拿到响应,不能推出后台任务失败,更不能推出业务动作没有发生。把三层压成一个 success: boolean,重试策略一定会选错。
工具先返回显式任务句柄
一个适合长任务的初始响应可以只确认受理:
json
{
"task_id": "task_7f3a...",
"status": "queued",
"status_url": "/tasks/task_7f3a...",
"cancel_supported": true,
"expires_at": "<iso-time>",
"idempotency_key": "report:month:account"
}
task_id 用于查询进度,idempotency_key 用于阻止相同业务请求创建两个任务。两者不能互相替代:前者标识已经存在的运行实例,后者描述哪些创建请求应被视为同一次业务意图。
服务端创建任务时,应先检查幂等记录,再写入任务:
typescript
async function createTask(input: CreateInput) {
const old = await taskStore.findByIdempotencyKey(input.idempotencyKey);
if (old) return old;
return taskStore.insert({
taskId: crypto.randomUUID(),
idempotencyKey: input.idempotencyKey,
status: "queued",
businessState: "not_applied",
createdAt: new Date().toISOString(),
});
}
如果你的 Agent 正在处理会产生副作用的长任务,可以先用回滚与补偿检查项核对幂等键、未知状态和人工接管是否闭合,再决定是否允许模型自动重试。
input_required 不能伪装成失败
MCP 新规格中的 Multi Round-Trip Requests 允许工具在执行中途返回 resultType: "input_required",客户端补齐确认或缺失参数后,再把答案附到原调用中。
这类状态经常被通用重试器误伤:它不是网络异常,也不是业务失败,而是"任务暂停,等待特定输入"。建议把等待内容写成结构化请求:
json
{
"task_id": "task_7f3a...",
"status": "input_required",
"requests": [
{
"request_id": "confirm_recipient",
"kind": "confirmation",
"prompt": "确认收件人和附件哈希后继续",
"expires_at": "<iso-time>"
}
]
}
客户端必须把 request_id 与回答绑定,不能只传一句"确认"。任务恢复前还要重新检查关键参数是否变化;如果附件已经换过,旧确认不能继续使用。
轮询要处理版本和终态
最小轮询器至少要避免三个坑:无限轮询、旧状态覆盖新状态、终态后继续更新。
typescript
const TERMINAL = new Set(["completed", "failed", "cancelled"]);
async function waitForTask(taskId: string, deadlineMs: number) {
let version = 0;
while (Date.now() < deadlineMs) {
const task = await getTask(taskId);
if (task.version < version) continue;
version = task.version;
if (TERMINAL.has(task.status)) return task;
if (task.status === "input_required") return task;
await delay(task.retry_after_ms ?? 1500);
}
return { task_id: taskId, status: "unknown", last_version: version };
}
超出客户端等待期限时返回 unknown,不要擅自写成 failed。下一步应该保留任务句柄并继续查询,或交给人工处理。
取消请求也需要业务回执
cancelled 容易被误解为"所有副作用都撤销了"。实际系统里,取消可能只停止后续步骤,已经发出的邮件、已经写入的记录或已经提交的作业不会自动消失。
取消接口可以返回更明确的结果:
json
{
"task_id": "task_7f3a...",
"task_status": "cancelled",
"business_state": "partially_applied",
"completed_steps": ["render_report"],
"pending_compensations": ["delete_temp_artifact"],
"operator_action": "review_required"
}
这让调用方知道"任务停了"和"业务恢复了"是两件事。补偿成功后再单独写回 compensated,不要覆盖原始执行轨迹。
用故障用例验收,不用正常演示验收
长任务上线前至少跑以下场景:
- 首次创建成功但响应丢失,使用同一幂等键重试只能得到原任务;
- 工具执行中要求确认,未提供
inputResponses时不能继续; - 客户端超时后任务仍完成,后续查询必须返回同一结果;
- 取消发生在副作用之后,结果必须显示
partially_applied; - 服务实例切换后,另一实例仍能按
task_id查询; - 任务过期后,审计记录仍能说明最终业务状态和处理人。
MCP 的无状态核心解决的是传输和部署问题,不会替应用自动设计业务状态。真正稳妥的改造,是让任务句柄、幂等键、等待输入、取消结果和业务回执都成为可查询的数据,而不是藏在一条长连接和一段模型上下文里。