最新的 Model Context Protocol(MCP)规范取消了协议层会话。对远程 MCP 服务来说,这意味着负载均衡器不再需要把同一客户端持续绑定到同一个实例,服务端也不必为了协议本身维护会话存储。
这项变化直接简化了 AWS 上的横向扩缩容:ALB、ECS、EKS 或其他计算平台可以把每个请求路由到任意健康实例。不过,“无状态”并不等于系统没有状态。业务上下文、重试、幂等性和可观测性仍然存在,只是需要由更合适的应用层与基础设施层负责。
去掉会话亲和性后,部署模型发生了什么
传统的有状态远程服务通常依赖这样的请求链路:
Client -> Load Balancer -> 固定实例 A -> 实例内会话状态
一旦实例 A 重启、扩容或被替换,客户端上下文就可能丢失。为了避免这种情况,团队往往启用 sticky session,并额外处理会话复制、过期和恢复。
无状态 MCP 允许链路变成:
Request 1 -> Load Balancer -> Instance A
Request 2 -> Load Balancer -> Instance C
Request 3 -> Load Balancer -> Instance B
每个实例都能独立处理请求,不依赖前一个请求落在哪台机器上。这会带来几个直接收益:
- 实例可以随时启动、停止或替换,滚动发布更简单。
- 扩容后,新实例无需接管协议会话即可接收流量。
- 负载均衡器不再需要维护客户端与实例之间的绑定关系。
- 服务端无需为协议会话设计本地缓存或共享会话数据库。
- 单个实例故障不会因为会话绑定而持续影响同一批客户端。
但必须注意:协议无状态只说明 MCP 请求不依赖协议层会话,并不保证工具调用本身没有副作用。创建工单、发送邮件、提交付款等操作仍然需要业务级幂等控制。
状态没有消失,而是需要重新归位
一个实用的划分方式是把状态分成四类:
| 状态类型 | 推荐位置 | 例子 |
|---|---|---|
| 请求上下文 | 当前请求 | 参数、认证信息、租户 ID、追踪 ID |
| 持久业务状态 | 外部数据库或对象存储 | 工单、任务进度、用户配置 |
| 短期协调状态 | Redis、DynamoDB 等外部系统 | 幂等键、分布式锁、限流计数器 |
| 诊断信息 | 日志、指标和追踪平台 | 请求耗时、错误码、工具名称 |
不应继续放在实例内存中的内容包括“当前客户端进行到第几步”、尚未持久化的任务结果,以及决定重复请求是否应该再次执行的记录。实例内缓存可以存在,但它应该是可丢弃、可重建的性能优化,而不能成为正确性的前提。
重试策略也需要明确分层:客户端或网关可以重试网络错误,服务端则应通过幂等键判断同一个有副作用的操作是否已经执行。JSON-RPC 请求 ID、追踪 ID 和幂等键用途不同,不应混为一谈:
- 请求 ID 用于匹配请求与响应。
- 追踪 ID 用于串联日志和分布式调用。
- 幂等键用于识别同一次业务操作的重复提交。
一个最小的无状态请求处理示例
下面的 FastAPI 示例不是完整的 MCP SDK 实现,而是一个可运行的“无状态 MCP 风格边界”示例,用来展示关键约束:每个请求携带完整参数,进程不保存客户端会话,并为日志生成关联 ID。接入生产环境时,应替换为符合目标 MCP 规范版本的 SDK 和传输实现。
创建 requirements.txt:
fastapi==0.115.12
uvicorn[standard]==0.34.2
创建 app.py:
import logging
import uuid
from typing import Any
from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel, Field
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
app = FastAPI()
class RpcRequest(BaseModel):
jsonrpc: str = "2.0"
id: str | int
method: str
params: dict[str, Any] = Field(default_factory=dict)
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.post("/mcp")
def handle_request(
request: RpcRequest,
x_request_id: str | None = Header(default=None),
) -> dict[str, Any]:
correlation_id = x_request_id or str(uuid.uuid4())
logging.info(
"request_id=%s rpc_id=%s method=%s",
correlation_id,
request.id,
request.method,
)
if request.method == "tools/call":
tool_name = request.params.get("name")
arguments = request.params.get("arguments", {})
if tool_name == "add":
try:
result = float(arguments["a"]) + float(arguments["b"])
except (KeyError, TypeError, ValueError) as exc:
raise HTTPException(
status_code=400,
detail="add requires numeric arguments a and b",
) from exc
return {
"jsonrpc": "2.0",
"id": request.id,
"result": {
"content": [{"type": "text", "text": str(result)}],
"requestId": correlation_id,
},
}
return {
"jsonrpc": "2.0",
"id": request.id,
"error": {"code": -32601, "message": "Method or tool not found"},
}
启动服务并发送请求:
python -m venv .venv
. .venv/bin/activate
pip install -r requirements.txt
uvicorn app:app --host 0.0.0.0 --port 8000
在另一个终端执行:
curl -s http://localhost:8000/mcp \
-H 'Content-Type: application/json' \
-H 'X-Request-ID: demo-request-001' \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "add",
"arguments": {"a": 12, "b": 30}
}
}'
这个进程可以被任意副本替代,因为处理结果只取决于当前请求。若工具需要读取业务数据,应从数据库读取;若任务耗时较长,可以把任务写入队列并返回任务标识,而不是把进度留在某个 Web 实例的内存中。
在 EKS 或 ALB 上明确关闭粘性路由
在 Kubernetes 中,可以显式声明 Service 不使用会话亲和性。运行前需要把镜像地址改成自己的 MCP 服务镜像:
apiVersion: apps/v1
kind: Deployment
metadata:
name: mcp-server
spec:
replicas: 3
selector:
matchLabels:
app: mcp-server
template:
metadata:
labels:
app: mcp-server
spec:
containers:
- name: mcp-server
image: 123456789012.dkr.ecr.us-east-1.amazonaws.com/mcp-server:1.0.0
ports:
- containerPort: 8000
readinessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 3
periodSeconds: 5
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
---
apiVersion: v1
kind: Service
metadata:
name: mcp-server
spec:
sessionAffinity: None
selector:
app: mcp-server
ports:
- port: 80
targetPort: 8000
应用并检查副本:
kubectl apply -f mcp-server.yaml
kubectl rollout status deployment/mcp-server
kubectl get pods -l app=mcp-server -o wide
kubectl get service mcp-server
如果使用 AWS Application Load Balancer,也应检查目标组是否启用了 stickiness。可以先导出目标组 ARN,再关闭粘性:
export TARGET_GROUP_ARN='arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/mcp-server/replace-me'
aws elbv2 modify-target-group-attributes \
--target-group-arn "$TARGET_GROUP_ARN" \
--attributes Key=stickiness.enabled,Value=false
关闭粘性前要先确认应用确实不依赖实例内状态。否则,这个配置变化只会更快暴露原有的状态耦合问题。
上线前需要补齐的工程边界
无状态协议降低了基础设施复杂度,却把一些隐含问题变成了显式设计任务。上线前可以按下面的清单检查:
- 认证完整性:每个请求都能独立完成身份验证与授权,不依赖前一次请求写入的内存状态。
- 幂等性:有副作用的工具接受业务幂等键,并在 DynamoDB、Redis 或数据库中记录执行结果。
- 重试边界:明确哪些错误可以重试,设置退避与最大次数,避免网关和客户端叠加重试形成放大效应。
- 超时管理:为负载均衡器、应用、下游 API 和模型调用设置协调一致的超时。
- 可观测性:记录请求 ID、工具名称、延迟、结果状态和错误类别,但不要把令牌或敏感参数直接写入日志。
- 扩缩容指标:除 CPU 和内存外,关注并发请求、排队时间、下游限流和工具调用延迟。
- 故障测试:在请求期间主动终止实例,验证后续请求能由其他副本继续处理。
无状态 MCP 最有价值的地方,并不是少维护一张会话表,而是让远程 MCP 服务回到标准的云原生扩缩容模型。采用时应先移除对实例内存的正确性依赖,再关闭粘性路由,最后通过故障注入验证任意请求都能由任意健康实例处理。