这篇接着 选型总览 讲。总览里那个”能力适配器”——把企业 IM 消息、用户记忆、时间工具这些现成能力标准化成 MCP 工具的 Python 服务——从框架到部署的全链路选型,都在这里。
它的特点是”翻译型”:能力是现成的,核心是把它们包成 MCP 协议。这个定位,几乎决定了下面每一项的选择——优先高抽象、优先复用 Python AI 生态、优先快速接入。
框架:为什么是 FastMCP
我们没有直接使用官方 mcp SDK 的低层接口,而是用了独立维护的 FastMCP 2.x 高级框架。FastMCP 1.x 的核心曾并入官方 Python SDK,但本文代码里的 from fastmcp import FastMCP 来自独立项目。
对一个”翻译型”服务,裸 SDK 要手写太多协议层样板:工具注册、参数校验、JSON Schema 生成、结果序列化、传输管理。FastMCP 把这些全包了,装饰器一行注册工具:
from fastmcp import FastMCP
mcp = FastMCP("企业内 MCP Server")
@mcp.tool()
def memory_search(query: str, user_id: str, limit: int = 20):
"""搜索用户记忆。"""
return memory.search(query=query, user_id=user_id, limit=limit)
函数签名和 docstring 直接变成 JSON Schema,Agent 自动就能发现这个工具、知道参数长什么样。这类项目要接十几个工具,装饰器模式省下的样板非常可观。
FastMCP 还顺手解决了几个我们本来要自己造轮子的事:传输层(SSE / Streamable-HTTP / stdio 一行切换)、认证(内置 JWTVerifier、StaticTokenVerifier、OAuthProxy)、ASGI 原生(能直接挂到现有 Starlette 应用)。
选型取向:当一个 MCP Server 的核心是”把现成能力翻译成协议”,高级框架的收益远大于它带来的抽象成本。反过来,如果你要深度嵌入一个已有平台(见 Node.js 栈那篇),裸 SDK 的控制力反而更重要。
版本:稳定优先,不追 v3
FastMCP 我们用的是 2.12,但当时已经出到 3.x 了。3.x 引入了 Apps 架构、交互式 UI、FormInput、GenerativeUI 一堆新东西。
没跟。原因很简单:对这个服务,那些全是锦上添花。它不需要 UI,不需要交互式表单,它只需要稳定地把工具暴露出去。升 v3 要重新验证一遍协议兼容性、要踩早期坑,收益却几乎为零。
这条规律在选型总览里讲过,这里再强调一次:
MCP 生态迭代很快,SDK/框架版本”稳定优先”。新版本的新特性,只有在它真的解决你当前问题时才上。
记忆系统:Mem0 + Redis
记忆是这个服务里的重头戏之一。我们没有自己从零拼”提取 → 向量化 → 存储 → 检索”这一套,而是用了 Mem0——它把记忆的生命周期封装好了,我们只管 memory.search / memory.add。
真正花心思选的是向量存储。候选有 Redis(redisvl)、Chroma、Qdrant、Milvus:
| 对比项 | Redis(redisvl) | Chroma | Qdrant | Milvus |
|---|---|---|---|---|
| 部署复杂度 | 低,复用现有 Redis | 低,嵌入式 | 中,独立服务 | 高,集群 |
| 运维成本 | 最低,无额外组件 | 低,本地文件 | 中 | 高 |
| 向量搜索能力 | 支持多种索引,适合复用 Redis | 适合本地开发与轻量部署 | 专用向量检索能力完整 | 面向分布式向量检索 |
| 容量判断 | 取决于内存、索引和查询负载 | 取决于部署模式与并发 | 取决于节点配置和索引 | 取决于集群配置和索引 |
| 本项目适配 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ |
最后选 Redis,三条理由,按重要性排:
- 基础设施复用:内网已经有 Redis 集群,零额外运维成本。为一个记忆功能单独拉一套 Qdrant/Milvus,不值。
- Mem0 原生支持:redisvl 是 Mem0 一级支持的向量后端,接起来没坑。
- 规模匹配:用本项目的数据量、向量维度、过滤条件和并发做过容量测试后,现有 Redis 能满足目标延迟和内存预算,不需要额外引入专用向量数据库。
向量库选型最容易踩的坑,是”为了用上更强的引擎而引入额外基建”。先看你的数据规模和现有基础设施,够用就不折腾。
Mem0 的配置(脱敏后)大概长这样,重点看它怎么把 LLM、Embedding、向量库三段都声明出来:
config = {
"llm": {"provider": "openai", "config": {
"model": os.environ["MEM_MODEL_NAME"],
"openai_base_url": os.environ["OPENAI_BASE_URL"],
"temperature": 0.2,
}},
"embedder": {"provider": "openai", "config": {
"model": os.environ["MEM_EMBED_MODEL_NAME"],
"embedding_dims": int(os.environ["MEM_EMBED_DIMS"]),
}},
"vector_store": {"provider": "redis", "config": {
"collection_name": "mem",
"embedding_model_dims": int(os.environ["MEM_EMBED_DIMS"]),
"redis_url": os.environ["REDIS_URL"],
}},
}
memory = Memory.from_config(config)
temperature 设 0.2 是这个模型和评测集上的起始配置,用于减少采样随机性;它不能保证抽取正确,也不是所有模型都支持。上线前仍要用记忆召回率、误写率和重复率做回归评测。
LLM 与 Embedding:OpenAI 兼容才是关键
LLM 用的是百度千帆 + Qwen 系列(Qwen3-Coder 做提取,Qwen3-Embedding 做向量)。
选千帆的首要原因是合规——这是境内要用的服务。但选型里真正有长期价值的,不是千帆本身,而是它走的是 OpenAI 兼容接口。
这意味着 HTTP 客户端层通常可以先通过环境变量切换端点和模型名:
# 千帆
OPENAI_BASE_URL=https://qianfan.example.com/v2
MEM_MODEL_NAME=qwen3-coder-instruct
# 换成 OpenAI
OPENAI_BASE_URL=https://api.openai.com/v1
MEM_MODEL_NAME=gpt-4o
# 换成本地 Ollama
OPENAI_BASE_URL=http://localhost:11434/v1
MEM_MODEL_NAME=qwen3:32b
这一点在记忆系统里尤其重要:Mem0 的 provider 可以继续使用 "openai" 适配器,再通过 base URL 指向兼容服务。但“接口兼容”只减少传输层改动,不代表模型可以无验证替换。不同 Provider 可能在模型名、认证、参数支持、工具调用、限流和错误格式上有差异;Embedding 模型切换还必须同步核对向量维度,并重建不兼容的向量索引。
OpenAI 兼容接口可以降低客户端迁移成本,但不能消除 Provider 锁定。切换前要跑契约测试和任务评测,并把 Embedding 维度、向量库 Schema 与迁移方案一并纳入变更。
HTTP 客户端:httpx
调企业内部那些 HTTP 接口(消息推送等),用的是 httpx,不是 requests 也不是 aiohttp。
| 对比项 | httpx | aiohttp | requests |
|---|---|---|---|
| 异步 | 原生 async/await | 原生 | 仅同步 |
| HTTP/2 | 支持 | 不支持 | 不支持 |
| SSE | httpx-sse 扩展 | 需自实现 | 不支持 |
| 与异步工具协同 | 原生异步 | 原生异步 | 直接调用会阻塞事件循环 |
选 httpx 最直接的理由,是它同时提供同步和异步 API,适合在异步工具中复用连接池。它的 API 风格和 requests 相近,但并不相同;迁移时仍要处理超时、流式响应、连接复用和异常类型差异。
部署:Docker + uv
部署用了 Docker 容器化,依赖管理从老的 pip + requirements.txt 迁到了 uv + pyproject.toml。这一步的收益是实打实的:
- 依赖解析:uv.lock 精确锁定,不再有”本地能跑线上炸”;
- 安装速度:在本项目 CI 和镜像构建基准中更快;实际收益取决于缓存、网络和依赖规模,应以自己的流水线数据为准;
- Python 版本:顺手从 3.11 升到 3.12。
镜像里还做了两件事:非 root 用户运行(安全基线),以及用 uv 直接跑(uv run --no-dev python -m ...),不污染系统 Python。
传输层走 SSE 时,Nginx 反向代理有几条必须的配置(关 buffering、拉长超时),这块坑比较多,我放到 传输演进那篇 统一讲。
风险与改进:把”配了没开”讲清楚
诚实地说,这个服务现在背着几笔债,列出来也是选型的一部分——知道哪里留了缺口,比假装没有强:
| 风险 | 等级 | 说明 | 建议 |
|---|---|---|---|
| 认证未启用 | 🔴 高 | 生产无认证,谁都能调所有工具 | 启用 JWTVerifier |
| SSE 是 legacy | 🟡 中 | 官方已标记为旧版 | 规划迁 Streamable-HTTP |
| FastMCP 版本滞后 | 🟡 中 | 2.12 vs 3.x | 评估升级路径 |
| JWT 密钥硬编码 | 🟡 中 | 写在源码里 | 迁到环境变量 |
| Redis 无高可用 | 🟡 中 | 单节点连接 | 接 Sentinel/Cluster |
| 无监控可观测性 | 🟡 中 | 缺 metrics/tracing | 接 OpenTelemetry |
其中最该拿出来说的是第一条:JWT 配了,但没开。
源码里 JWTVerifier 是写好的,issuer、audience、algorithm 都配齐了,唯独 auth=verifier 那行被注释掉了,服务实际跑在无认证状态。这是个典型的反例——内部服务、调用方受控,于是图省事先不开,结果认证能力形同虚设。
别学我们这一步。网络隔离只能减少暴露面,不能证明调用者身份。即使全部调用方都是内部 Agent,也应启用服务身份认证、最小权限和密钥轮换;在完成这些基线前,不应把服务视为可安全上线。
动手验证
这篇的载体是脱敏的最小片段(agent-runtime-lab 是 Node 栈,Python 部分用最小可复现示例)。三段就够了,感受一下这套栈的”薄”:
# 1. FastMCP 装饰器注册(上面已贴)
# 2. Mem0 最小配置(上面已贴)
# 3. 传输一行切换
mcp.run(transport="sse", host="0.0.0.0", port=8000)
# 想换 Streamable-HTTP,把 "sse" 改成 "http" 即可
整个服务的核心代码量,比想象中少得多——这正是 FastMCP + Mem0 这套组合的价值:把协议层和记忆工程都吃掉,业务只管”接工具”。