把 3 个内部服务封装成 MCP Server 的工程复盘:进程模型、超时与错误契约

上个月我们组接了个需求:让 AI 客户端能直接查工单、看发布记录、读配置中心。听起来像是写几个 HTTP 客户端就完事,实际做下来,真正花时间的部分是"进程被谁启动、在什么环境下启动、失败了怎么告诉模型"这三件事。三个 Server 上线两周,日均调用 200 次左右,我把踩坑和最终的设计取舍整理成这篇复盘,重点放在工程侧而不是协议介绍。

一、MCP 的进程模型决定了大部分设计约束

MCP 是基于 JSON-RPC 2.0 的通信协议,客户端以子进程方式拉起 Server,通过 stdin/stdout 交换消息。这个模型有三个直接后果,每个都影响架构决策。

第一,stdout 被协议独占 。任何 print 都会污染消息流,导致客户端解析失败。我们统一在项目模板里加了一条 lint 规则,禁止在 server.py 及其依赖模块里出现裸 print,日志一律走 stderr:

python 复制代码
import sys

def log(level: str, msg: str) -> None:
    print(f"[{level}] {msg}", file=sys.stderr, flush=True)

第二,生命周期与客户端绑定。客户端退出,Server 进程就被回收。这意味着任何需要跨会话保持的状态(比如长轮询的构建任务)都不能放在进程内存里,必须落到外部存储。我们的构建触发工具最初把 task_id 存在一个模块级字典里,客户端重启后查询工具就找不到任务了,后来改成写 Redis,键的 TTL 设 24 小时。

第三,冷启动成本直接体现在用户等待上。Python Server 拉起加上依赖导入大概 800ms 到 1.5s,如果 import 了 pandas 这类重库能到 3s。我们把重依赖改成函数内延迟导入,冷启动压到 600ms 左右,用户感知明显。

二、工具的返回值是给模型看的,不是给人看的

这是整个项目里最重要的一条经验。工单详情接口原始响应 42KB,包含审批链路、附件列表、全部历史评论。第一版我们直接透传,结果模型收到后开始编造字段,回答里出现了根本不存在的"预计完成时间"。

问题在于上下文窗口被无关信息占满,模型抓不住重点。我们定义了一套裁剪规则,只保留决策相关字段:

python 复制代码
KEEP_FIELDS = ("id", "status", "assignee", "priority", "updated_at", "title")

def _brief(raw: dict) -> dict:
    brief = {k: raw.get(k) for k in KEEP_FIELDS}
    comments = raw.get("comments") or []
    brief["comment_count"] = len(comments)
    brief["last_comment"] = comments[-1]["content"][:200] if comments else None
    return brief

裁剪后单次返回约 600 字节,模型回答准确率从我们人工抽检的 61% 提升到 94%(抽检 50 条提问)。这个数字让我意识到,工具设计里返回值形状的重要性不亚于工具本身的功能。

配套的一条规则是:列表类工具必须有分页上限 。配置中心的"列出所有配置项"接口有 1200 多条,我们加了 limit 参数默认 20、上限 100,并在返回里带上 total,让模型能自己判断要不要继续翻。

三、超时:不要让单个工具调用超过 30 秒

掘金上很多 MCP 教程没提这点,但它是最容易在上线后暴露的问题。客户端默认超时通常在 60 秒量级,超时后连接断开,用户看到"工具调用失败",而实际的后台操作还在跑。

我们的处理分两层。对于 10 秒内能完成的,直接同步返回;对于构建触发这类分钟级操作,改成异步两段式:

python 复制代码
from mcp.server.fastmcp import FastMCP
import anyio

mcp = FastMCP("release-server")

@mcp.tool()
async def trigger_build(service: str, branch: str, ctx=None) -> dict:
    """提交构建任务,立即返回 task_id,用 get_build_result 查询结果。"""
    task_id = _submit(service, branch)
    for i in range(6):
        await anyio.sleep(2)
        if ctx:
            await ctx.report_progress(i + 1, 6)
        if _state(task_id) in ("success", "failed"):
            return {"task_id": task_id, "state": _state(task_id), "done": True}
    return {"task_id": task_id, "state": "running", "done": False}

report_progress 在支持进度回调的客户端上能让用户看到进度条,不支持也不会报错。关键是返回值里明确告诉模型"还没完,去查另一个工具",模型就会自然地继续调用 get_build_result,而不是编造一个"构建成功"。

四、错误契约:把异常翻译成模型能理解的结构

最初我们对内部接口的 401 直接 raise,客户端把 traceback 喂给模型,模型的反应是开始推测"可能是网络不稳定"。用户拿到的建议毫无用处。

后来我们定义了统一的错误返回结构,包含机器可读的 error 和给人看的 hint:

python 复制代码
@mcp.tool()
def get_ticket(ticket_id: str) -> dict:
    """按工单号查询工单状态。ticket_id 格式 TK-YYYYMMDD-NNNN。"""
    try:
        return _brief(_fetch(ticket_id))
    except AuthError:
        return {"error": "auth_failed",
                "hint": "内部接口 token 过期,请更新 TICKET_TOKEN 环境变量后重启客户端"}
    except NotFound:
        return {"error": "not_found",
                "hint": f"工单 {ticket_id} 不存在,确认编号格式为 TK-YYYYMMDD-NNNN"}
    except Timeout:
        return {"error": "timeout", "hint": "内部接口 10 秒未响应,稍后重试"}

hint 里写清楚"下一步该做什么",模型就会照着说,而不是自己发挥。上线后用户反馈的无效回答明显减少,我们统计的"答非所问"比例从 23% 降到 7%。

五、工具描述要写到模型不需要猜

我一开始把描述写成"查询工单",测试时模型直接回"我没有这个能力"。原因是模型只能靠 description 判断工具能干什么、参数长什么样。

现在的模板要求描述里必须包含三件事:功能一句话、每个参数的格式和示例、返回值的字段含义。比如工单号必须写明 TK-YYYYMMDD-NNNN 并给一个真实格式的例子。改完之后,参数格式错误导致的调用失败从每天十几次降到几乎为零。

还有个小技巧:工具名用动词开头加名词,get_ticket、trigger_build、list_configs,比 ticketService 这种命名让模型更容易选对工具。我们有个工具叫 query,模型经常在三个场景里乱选,改名成 search_release_records 之后就准了。

六、配置文件的几个坑

Windows 环境下配置文件里的路径要转义反斜杠,这个 JSON 语法错误我们犯了两次,客户端都是静默失败,没有任何提示。另外客户端的默认工作目录不是项目目录,args 里必须用绝对路径。

依赖环境错配也很常见:VSCode 里调试用 venv,配置文件写的是系统 Python,客户端报 ModuleNotFoundError 但界面上看不到。排查方法是手动用配置里那条命令跑一次,能起来说明代码没问题,问题在环境变量或 cwd。这个二分法帮我们节省了大量时间。

七、两周后的实际使用情况

三个 Server 里,工单查询用得最多,日均 140 次左右,主要场景是"这个工单卡在哪一步"和"最后一条评论说了什么"。发布记录 Server 日均 40 次,配合"最近一次发布失败的原因"这类提问。配置中心用得最少,日均 20 次,因为大部分配置查询还是习惯直接看控制台。

回头看,协议本身的学习成本很低,一晚上就能跑通 hello world。真正决定这套东西好不好用的是工程细节:进程怎么起、日志往哪写、返回多大、超时多久、错误怎么表达。这些点没有一条写在协议文档里,但每一条都直接影响模型能不能正确使用你的工具。

如果让我给正在做类似事情的人一句建议,那就是:先把一个工具做到"模型不用猜",再去做第二个。我们前两个 Server 返工了一次,就是因为一开始想着赶紧把接口都包出来,结果每个工具都是半成品。

相关推荐
律宏阔11 分钟前
微信小程序 + Taro + TDesign 去掉 [&&&],让普通 Tailwind 类接管局部样式
前端·微信小程序·taro
Captaincc13 分钟前
转译:《软件工程师现在该怎么办》
前端·后端·ai编程
天才熊猫君16 分钟前
给 Vue 的 reactive 套 Proxy,为什么响应式会失灵?
前端·javascript
htzyl20619 分钟前
别急着上微前端:从 6 个场景倒推 Module Federation 2.0 与 qiankun 2.x 的选型
前端·微服务
verohh19 分钟前
Cesium 多业务页面切换的资源隔离与架构设计:单 Viewer 复用还是多实例隔离
前端
律宏阔20 分钟前
微信小程序 + Taro 从 ESLint 迁移到 Oxlint 让项目代码检查速度提升近 12 倍
前端·typescript·eslint
用户308169987648523 分钟前
复制的代码为什么报错?BOM 等 10 种隐形字符 40 组实测
前端
Swot25 分钟前
SvelteKit 踩坑记:+page.js 里忘了 ...data,服务端数据全没了
前端·后端·svelte
律宏阔30 分钟前
微信小程序 + Taro + 多语言实现与国际化检查实录
前端·微信小程序·taro