200 条订单全塞进上下文,只为回答 12 个字:MCP 输出端的「过滤—聚合—分页—缓存」四件套
前几篇里,第三篇讲过
.github/copilot-instructions.md这个「固定税」怎么瘦;这一篇把视线移到更贵的地方——MCP 工具的响应体。一个数据库查询 MCP 返回 200 条订单的完整字段,5 分钟后模型又要重新问同样的问题——钱就是这么烧的。
开篇:被 MCP 响应拖死的 Agent
如果你做过 Agent + MCP 的项目,大概率见过这种场景:
用户问“昨天哪些订单报错了?”
→ Agent 调用db_query工具 → MCP Server 老老实实把200 条完整订单(id/user_id/product_id/price/discount/tax/shipping/created_at/updated_at/status/payment_id/coupon_id/logistics_id/customer_note/internal_note/… 共 50+ 字段)全量返回
→ 模型被一万多 token 的表格糊脸,吭哧吭哧读了半天,回答一句“有 12 条报错”
→ 用户紧接着问“这些报错里最贵的三单是哪几个”
→ Agent又调用一次db_query,又把 200 条订单完整返回一次
→ ……
一次查询、两次响应、合计 2 万多 token,只为回答 12 个字。这就是 MCP 工具最典型的浪费——工具的“全量返回”模式,把服务端已经知道的答案,用最笨的方式倒进模型上下文。
在 2026 年 7 月 28 日发布的 MCP v5(无状态版)规范里,Anthropic 直接把「结果可缓存」写进了协议:MCP Server 可以在响应里带ttlMs和cacheScope字段,告诉客户端“这个结果多少毫秒内可以直接用”。这不是锦上添花,是协议层在承认:MCP 输出的裁剪与缓存,本就是基础设施该做的事。(来源:MCP 2026-07-28 规范,Anthropic)
这一篇就讲 MCP 输出端的四个动作:过滤、聚合、分页、缓存。它们合在一起,能把典型查询类 MCP 的响应 token 压到原来的 5% 以内。
一、四件套做的是什么
四个字就够:在进模型之前,把响应尽可能变瘦。
- 过滤(Filter):50 个字段只取模型这次真用得上的几个;
- 聚合(Aggregate):统计、排序、Top-K、去重——能算在服务端的,就别让模型在上下文里算;
- 分页(Paginate):1000 条结果只先给 20 条,要更多再翻页;
- 缓存(Cache):相同参数的查询在 TTL 内直接复用,别再打一次库、再喂一次模型。
做对了,数据在进入上下文之前就完成瘦身。下面是一个数据库查询 MCP 在流水线上的典型压缩效果:
原始响应 12,000 Token,经过四层处理后,真正进入模型上下文的只有42 Token——就是一句“12 条报错,Top3 是订单 #2103/#2087/#1994,详情见游标 next_cursor=xxx”。
二、① 过滤(Field Projection):50 个字段只返回 3 个
核心原则:让 MCP Server 支持字段选择参数,只返回调用方点名的字段。默认永远是“全量”是错的,默认应该是“最小集”。
坏例子:默认全量返回
@mcp.tool()defquery_orders(date:str)->dict:rows=db.execute("SELECT * FROM orders WHERE date = ?",[date])return{"rows":[dict(r)forrinrows]}# 50 个字段全吐这样一个函数,一次 200 行的订单查询大约返回12,000~18,000 Token。而模型 80% 的情况下只需要id、amount、status三个字段。
好例子:显式 fields 参数
@mcp.tool()defquery_orders(date:str,fields:list[str]=["id","status","amount"],# 默认最小集filters:dict|None=None,)->dict:allowed={"id","user_id","amount","status","created_at","error_msg"}cols=", ".join(cforcinfieldsifcinallowed)where=build_where(filters)rows=db.execute(f"SELECT{cols}FROM orders WHERE date = ?{where}",[date])return{"rows":[dict(r)forrinrows],"fields_used":cols}效果:典型三字段查询响应约2,000~3,000 Token,比全量省 75% 左右。
要做的两件事
- 白名单校验:
fields必须走白名单(allowed集合),防止 SQL 注入与意外字段泄漏; - 默认最小集:不要默认
*,默认给最常用的 3~5 个字段,需要更多字段让模型/调用方显式请求。
进阶:Schema 瘦身
不止返回值,工具的输入/输出 Schema 描述本身也占 Token。参考实战案例:一个带几十个枚举值的参数字段,光 Schema 就能多吃几百 Token。做法:
- 枚举值超过 10 个时,改成“示例 + 校验”,不要把全部枚举列在 JSON Schema 里;
description控制在一句话内,不要在 Schema 里写小作文。(来源:CSDN《MCP上下文爆仓?按需加载与工具瘦身》实战整理,2026-10)
三、② 聚合(Server-side Aggregation):能算在服务端的,别让模型算
模型做统计、排序、分组的能力很有限,而且贵。让模型在 200 行 JSON 里“找最贵的三单”、“按状态分组计数”,本质上是让它用最贵的算力做 SQL 就能搞定的事。
典型可下沉到服务端的计算
| 任务 | 错做法 | 对做法 | Token 节省 |
|---|---|---|---|
| 找最贵 N 单 | 返回全量让模型排序 | 服务端ORDER BY amount DESC LIMIT N | ~85% |
| 按状态计数 | 返回全量让模型数 | 服务端GROUP BY status | ~90% |
| 错误 Top-K | 返回全量让模型分组 | 服务端GROUP BY error ORDER BY cnt DESC LIMIT K | ~90% |
| 合计/平均 | 返回全量让模型算 | 服务端SUM/AVG | ~90% |
| 时间段趋势 | 返回分钟级全量数据 | 服务端按小时/天聚合 | ~95% |
好例子:把“统计”做成独立工具
不要只暴露一个query_orders,再额外暴露聚合类工具:
@mcp.tool()deforders_summary(date:str,group_by:str="status")->dict:"""订单聚合统计。group_by 可选 status/error/hour。"""# 直接在 SQL 层聚合,返回的是几行统计结果,不是明细rows=db.execute(f"SELECT{group_by}, COUNT(*) as cnt, SUM(amount) as total "f"FROM orders WHERE date = ? GROUP BY{group_by}",[date])return{"summary":[dict(r)forrinrows]}关键:把“要明细”和“要统计”拆成两个工具,让模型自己选。描述写清楚:当你要统计/汇总/找最大值/找 Top-K 时,请优先用orders_summary,不要先拉全量明细。这样模型自己就能做出正确的工具选择。
四、③ 分页(Cursor-based Pagination):先给 20 条,剩下的按需取
MCP Server 一次吐 1000 条记录,90% 的场景下模型看完前 20 条就够回答问题了——剩下的 980 条全是白烧。
不要做 offset 分页
page=3&page_size=20的 offset 分页在 DB 层会越来越慢,而且在无状态的 MCP v5 下也不利于负载均衡。用游标(cursor)分页:
好例子:游标分页
@mcp.tool()defquery_orders(date:str,fields:list[str]=["id","status","amount"],cursor:str|None=None,# 上一页返回的 next_cursorlimit:int=20,# 硬性上限,防止模型一次拉 10000)->dict:limit=min(limit,100)# 服务端兜底rows,next_cur=db.query_page(date=date,fields=fields,cursor=cursor,limit=limit+1)has_more=len(rows)>limitreturn{"rows":rows[:limit],"next_cursor":next_curifhas_moreelseNone,"has_more":has_more,"count_returned":len(rows[:limit]),}三件事:
- 默认 limit = 20:模型要更多,必须显式传更大的 limit 或翻页;
- 服务端硬性上限:加一个
min(limit, 100),防止模型或恶意调用一次拉爆; - 返回
has_more+next_cursor:明确告诉模型“还有更多,需要的话继续调”。
一个 200 条的订单查询,默认分页后第一页只返回~500 Token,比全量返回 12,000 Token 省了95%+。只有当模型真的需要翻页时,才继续付出后续页面的 Token 成本。
五、④ 缓存(Response Cache):同样的问题别让工具跑两次
这是四件套里回报最高、却最容易被忽略的一件。MCP 协议在 2026-07-28 修订版中正式引入了结果缓存语义:tools/list、prompts/list、resources/list、resources/read的响应可以带ttlMs和cacheScope(public/private),客户端据此决定缓存多久。(来源:MCP 2026-07-28 规范,SEP-2549 “可缓存结果”提案)
这意味着:客户端(Agent 框架)原生支持 MCP 响应缓存,Server 端只需要正确返回这两个字段。
三层缓存
- 工具列表缓存(tools/list):一个 MCP Server 的工具清单几小时甚至几天都不会变。
ttlMs = 3600_000(1 小时)起步。这一项能省掉每次重连时“重新发现工具、重新把工具 Schema 注入上下文”的开销。实测:每次重连省3,000~10,000 Token(视工具数量而定)。 - 资源/查询结果缓存:对于“查配置”、“查字典”、“查昨天的订单”这类结果稳定的查询,按参数做 key,设置合理 TTL(例如 60 秒~5 分钟)。
- 提示模板缓存(prompts/list):如果你的 MCP Server 暴露了 prompt 模板,同样可以缓存。
好例子:Python 版简单响应缓存
importtime,hashlib,json cache:dict[str,tuple[float,dict]]={}defcached_call(key:str,fn,ttl_ms:int=60_000,scope:str="private"):now=time.time()*1000ifkeyincache:ts,val=cache[key]ifnow-ts<ttl_ms:return{**val,"_meta":{"cacheHit":True}}result=fn()cache[key]=(now,result)return{**result,"_meta":{"ttlMs":ttl_ms,"cacheScope":scope,"cacheHit":False}}@mcp.tool()defquery_orders(date:str,fields:list[str]=["id","status","amount"]):key=hashlib.sha256(json.dumps([date,sorted(fields)]).encode()).hexdigest()defdo_query():rows=db.execute(f"SELECT{', '.join(fields)}FROM orders WHERE date = ?",[date])return{"rows":[dict(r)forrinrows]}returncached_call(key,do_query,ttl_ms=120_000)# 2 分钟内相同查询走缓存关键:缓存命中率曲线
下图是缓存命中率对 MCP 工具调用平均成本的影响(模型推理 + 工具执行 + 响应 Token 传输全部算入)。70% 命中率就能把平均成本压到原来的 1/3。
实战里几个能拉高命中率的小技巧:
- 参数归一化:
fields=["id","status"]和fields=["status","id"]是同一个查询,缓存 key 要排序后再 hash; - 易变字段不要放进 key:时间戳、traceId、
request_id之类的字段单独剥离,否则缓存永远不命中; - 主动失效:当发生写操作(
create_order、update_status)时,主动清空和该资源相关的缓存 key,避免脏数据。
六、单项效果与叠加
下图是四个动作在典型 MCP 查询场景下的典型节省比例(基于多个实战案例整理,具体数字因场景而异):
- 过滤:典型 -75%(50 字段 → 3 字段)
- 聚合:典型 -55%(明细 → 统计行)
- 分页:典型 -70%(200 行 → 20 行第一页)
- 缓存:典型 -90%(命中即省,省的不只是 Token,还省一次 DB 查询和一次模型推理)
- 四件套叠加:典型 -95%(叠加乘算,而非加算)
真实案例:日志查询 MCP
把一个日志查询 MCP 从“全量返回 200 条完整日志”一步步改造成四件套版本,响应 Token 变化如下:
18,500 → 4,600 → 1,850 → 580,总压缩率96.9%。而模型得到的回答质量没有下降——因为它关心的“错误 Top-K 是什么”这个问题,580 Token 的聚合结果反而比 18,500 Token 的原始日志更直接。
来源:CSDN《MCP工具调用性能优化实战:从11秒到1.8秒的调优之路》(字段裁剪 + 结果分页是核心优化项之一),2026-10。
七、按顺序落地:建议的改造优先级
不要一上来把四件事都做了。按 ROI 排序:
| 顺序 | 动作 | 工作量 | 典型收益 | 什么时候必须做 |
|---|---|---|---|---|
| 1 | 过滤(默认最小集 fields) | 0.5~1 天 | -60~80% | 只要 MCP 返回列表数据 |
| 2 | 分页(默认 limit=20 + cursor) | 0.5 天 | -70~80%(多页场景) | 返回可能超过 50 条时 |
| 3 | 缓存(ttlMs + 简单 key-value) | 0.5~1 天 | 命中率相关,最高 -90% | 查询频次 ≥ 1次/分钟 |
| 4 | 聚合(独立 summary 工具) | 1~2 天 | -80~90%(统计类问题) | 频繁出现“统计/Top-K/分组”提问 |
一句话:先加fields和limit,这两个改动最小、收益最大;再补缓存;聚合看业务需要再加。
八、四个反模式,别踩
- Schema 写小作文:工具描述/参数描述/返回描述里塞满使用说明,光 tools/list 一次就吃 1 万 Token。说明写进独立文档或 Skill 文件(下一篇就讲这个),Schema 里只留必要的一句话。
- 返回原始日志/HTML/JSON 字符串:尤其是把整段 HTML 或 base64 内容塞进 MCP 响应。能解析成结构化字段就解析;必须返回原文时,截断到关键片段并给出“原文路径/链接”,模型需要再去读。
- 分页 limit 没硬上限:模型为了“保险”传
limit=10000,你也真给。服务端必须兜一个上限(建议 100)。 - 缓存 key 里带时间戳/随机数:缓存命中率直接归零还查不出来。缓存 key 只用“真正影响结果的参数”构造。
结尾:MCP 输出端是最被低估的省钱环节
Agent 的 Token 开销里,写代码的输入/输出只是小头。真正大口吞 Token 的,是工具调用之后、喂回模型之前的那一段响应体。
MCP Server 作者往往把注意力放在“工具能不能调通”,却忘了“返回什么”。但模型看什么、不看什么,决定了每一次调用花多少钱——能算在服务端的,就不要让模型算;能少返回一行,就少花一份钱。这和数据库查询里“不要 SELECT *”是同一个道理,只是把字段裁剪从 SQL 层延伸到了模型上下文层。
下一篇,我们把视线从“输出端”再往外挪一点:如果你发现自己在每一轮对话里都要重新解释“这个 MCP 工具怎么用、什么时候用、输出怎么读”,那就该把这些重复说明封装成 Skill——让规则按需加载,而不是每轮重说。
下一篇预告:《把重复的 MCP 用法封装成 Skill:别再每一轮都重新解释“这个工具怎么用”》。