别让工具吃掉上下文:MCP 工具的返回值预算设计

· 约 4 分钟阅读

我上周收拾了一个自己写的 MCP server。里面有个 list_issues 工具,照着后端 REST 接口的默认行为实现,把能查到的记录全量吐出来。调用成功,服务端日志干干净净,但客户端会话在下一步就断了。翻到 Runpod 的内部审计才确认这不是我一个人的手气问题:他们一次未分页的 list 调用,在真实账号上返回了约 15 倍 Claude Code 上下文窗口大小的数据。[4]

根子在于我把它当成 API 来设计。agent 的稀缺资源不是磁盘,是上下文。

agent 不会翻页,所以首块必须自包含#

这是今年最值得记住的一条实测结论。卢森堡大学的研究者拿到一个公开 MCP 中间件的生产会话日志,统计所有超出预算的工具响应,agent 主动请求第二块的次数是零。[3] 它不看截断标记,也不带 cursor 重试,被截断之后直接进入下一轮推理或编辑。单次 turn 返回 50 个以上文件路径、200 条以上 issue 属于常态。[3]

同一篇论文还给了个反直觉的负结果:他们花了力气把关键条目排到首块最前面,precision-at-1 提升了 10.8 个百分点,但下游准确率几乎没有变化,五个模型上的增量都落在正负三个百分点内,方向也不一致。[3] 结论很朴素。模型能在首块的任何位置捞到需要的信息,前提是那条信息真的进了首块。排序再讲究,也救不回压根没被塞进去的内容。

所以工具返回值的验收标准可以改写成一句话:假如这轮只读到第一块,agent 有没有足够信息做出下一个决定。

注册是税,返回值是账单#

上下文成本有两个付钱时点。一个是注册:每个工具的名字、描述和完整参数 schema 都会进模型上下文,模型挑选的时候还要把这些描述再读一遍。[4] MCP 客户端会同时挂上几十个 server、几百个工具,功能一重叠,agent 就开始选错。[1] Anthropic 的观察是工具并非越多越好,把上百个 API 端点直接包一层暴露出去是常见的错误做法。[1]

另一个是调用:返回值原样注入上下文。Anthropic 给的默认值很实在,Claude Code 把工具响应限制在 25000 token。[1] 关键动作是给可能膨胀的响应加上分页、范围选择、过滤和截断的组合,并且把默认值设小。[1]

命名空间值得单独提一句。按服务或资源给工具加前缀能划清边界,Anthropic 说前缀和后缀两种方案在他们自己的工具评测里差异不小。[1] MCP 规范从另一侧给了理由:server 应该以确定性顺序返回工具列表,这样客户端可以可靠缓存,并提高 prompt cache 命中率。[2] 顺序一变,缓存就白算了。规范还提醒,聚合多个 server 的客户端会遇到重名工具,应该加上 server 标识做消歧。[2]

快速上手:给工具加预算和截断标记#

下面是一个 MCP 工具返回值的改造模板,只动返回逻辑,不碰你的查询实现。默认 limit 设小、超限时在头部写明总量和续查方式、硬截断时留可见标记:

MAX_ITEMS = 20
MAX_CHARS = 8000

def list_issues(args):
    limit = max(1, min(int(args.get("limit", MAX_ITEMS)), 100))
    rows = fetch_issues(args)              # 你已有的查询,不要改
    page = rows[:limit]
    truncated = len(rows) > len(page)

    body = render_rows(page)               # 只渲染调用方需要的字段
    if len(body) > MAX_CHARS:
        body = body[:MAX_CHARS] + "\n… [TRUNCATED]"
        truncated = True

    header = f"returned={len(page)} of {len(rows)}"
    if truncated:
        header += "; PARTIAL=true; 续查: 提高 limit 或加 filter=status:open"
    return f"{header}\n{body}"

配套做两件事,成本都很低。

第一,给返回值加一个粒度开关。Anthropic 推荐在工具里暴露一个 response_format 枚举参数,让 agent 自己选 concise 还是 detailed。[1] 官方示例里,concise 版本的 token 消耗只有 detailed 的三分之一左右。

第二,把响应大小记进日志。记录每次 tool result 的字节数或 token 数,再按工具算百分位。撑爆会话的那个工具会作为离群值先冒出来,比等用户报障早得多。[4] 千万别静默丢弃超限内容,那比直接报错糟糕。错误是可见的,静默截断会让模型以为自己在完整信息上推理,后面的每个结论都继承这个缺口。[4] 正确做法是返回截断标记,并顺手把更省 token 的用法写进错误信息里,工具截断和错误响应都可以用来把 agent 推向更省上下文的用法。[1]

验收清单#

改完之后按这四条过一遍。任意 list 工具的默认返回控制在几百 token 量级;被截断时带有 PARTIAL 或 truncated 标记,并说明续查方式;工具描述只回答「这个任务该不该选这个工具」,不堆参数细节;工具数量做过一次减法,能把多个窄工具合并成一个带 filter 的宽工具就合并。

工具设计里最贵的错误是安静的那种。会话挂掉的时候日志是干净的,没有人会把它和一次成功的工具调用联系起来,除非你提前量过大小。

Sources#

[1] https://www.anthropic.com/engineering/writing-tools-for-agents — Writing effective tools for agents — with agents (Anthropic Engineering, 2025-09-11) [2] https://modelcontextprotocol.io/specification/2026-07-28/server/tools — MCP specification 2026-07-28 — Tools [3] https://arxiv.org/abs/2608.26130 — Agents Don’t Paginate: First-Chunk Selection for LLM Tool Responses (Petrova, Mazniak, State — University of Luxembourg, arXiv:2608.26130) [4] https://runpod.io/blog/designing-mcp-tools — Designing MCP tools that don’t blow up your agent’s context window (Runpod engineering)