跳到主要内容

分页是一项工具目录规范:为什么智能体在处理列表返回时会耗尽上下文

· 阅读需 12 分钟
Tian Pan
Software Engineer

在你的技术栈中,每一个设计良好的 HTTP API 都会返回分页结果。没有人会把一百万行数据加载进内存并祈祷一切顺利。然而,你的智能体(agent)所调用的工具却会返回整个列表,而智能体也会尽职尽责地阅读它,因为函数签名写的是 list_orders() -> Order[],且智能体不像人类用户那样拥有“滚动并加载更多”的协议。

智能体在原本可以跳过的行上浪费了 Token。拥有 50K 记录的长尾客户遇到了中等规模客户从未见过的上下文窗口失败。工具作者无法从追踪(trace)中判断智能体是需要所有这些行,还是仅仅因为无法请求更少的数据。而且,在你评估套件的某个地方,原本会标记这种退化的回归测试从未运行,因为每个测试固件(test fixture)的记录都少于 100 条。

分页不是一种 UI 交互功能。它是一种负载卸载(load-shedding)原语 —— 而在没有分页的情况下使用工具的智能体,正在重新犯下你们公司的 API 设计师们花了十年时间才学会避免的每一个 SELECT * FROM orders 错误。

函数签名在对模型撒谎

一个返回 Order[] 的工具描述是智能体必须凭信念去履行的契约。模型看到了一个无边界的数组类型和一行描述,并假设实现是合理的。模型的工作不是去询问“合理”是指 50 行还是 50,000 行。

当智能体调用一个返回列表的工具时,它实际收到的,是底层 API 决定返回的任何内容。对于大多数工具来说,那就是完整的结果集,因为封装 API 的工程师是在以集成开发者的思维思考,而不是以上下文预算控制者的思维。他们编写函数的方式与编写任何其他服务器端辅助函数的方式一样:获取输入,返回数据,让调用者去处理大小。

现在的调用者是一个概率系统,其注意力在长输入中间会下降,且其 Token 账单与工具决定发出的任何内容呈线性缩放。这是一个不同的调用者。它需要一个新的契约。

诚实的函数签名应该是类似 list_orders(cursor?: string, limit?: number) -> { items: Order[], next_cursor?: string, total?: number } 的样子。它表达的是:我会给你一个观察数据的窗口,你来选择大小,并且你可以回来获取更多。它将智能体视为流式消费者,而不是批处理接收者。

“工具返回完整列表”的实际代价

想象一个需要从租户最近的活动中查找特定退款的客户成功(customer-success)智能体。它调用 list_orders(tenant_id="acme-co")。对于 90% 的租户,这会返回 50–200 行,智能体通过一两个推理步骤就能找到退款。而对于排名前 1% 的租户,这会返回 50,000 行,工具响应超出了上下文预算,智能体要么截断数据并在中间丢失退款,要么遇到硬性的供应商错误并颗粒无收。

运行聚合评估指标的团队看到了 99% 的成功率并发布了。那 1% 的细分客户开启了工程团队无法复现的支持工单,因为测试环境的租户只有 12 个订单。回顾发现,这 1% 客户内部的失败率接近 80%,这种细分层级的灾难被完美地隐藏在全局平均值中。

即使响应放得下,你也在为此付出代价。当智能体只需要 200 个 Token 时,一个返回 20,000 Token JSON 的工具在每次调用时都产生了 100x 的加价,这还没算上更慢的首个 Token 响应时间、有用先前上下文的挤出,以及模型注意力落在错误行上的可能性增加。成本不仅仅是你购买的 Token;还有那些你本需要保留却无法保留的 Token。

Datadog 团队在撰写关于其 MCP 服务器的文章时发现,从 JSON 切换到 YAML 将表格化的工具输出削减了约 20%,而按 Token 预算而非记录数进行分页,使他们能在相同的上下文中容纳五倍以上的记录。这个故事有趣的地方不在于数字,而在于他们意识到:“页面大小”不是一个数量,而是一个预算。

工具目录的分页约定

如果分页要作为一种负载卸载原语发挥作用,它必须是一种约定,而不是每个工具各自的决定。返回列表的工具需要一个通用的协议,以便模型可以学习一次并应用到各处。这意味着具有文档化语义的 cursorlimit 参数、模型不会试图解码的模糊游标字符串,以及一个针对智能体典型任务(而非 API 最大值)调优的默认 limit

当缺失约定时,通常会出现以下问题。

每个工具的临时分页意味着模型必须为每个返回列表的函数逆向工程页面 Token 的语义。有些工具称之为 cursor,有些称之为 next_token,有些返回 page_id,而模型会在最尴尬的地方将它们混淆。MCP 规范选择标准化的模糊游标字符串,正是因为如果给模型选择的机会,它们会幻觉出游标格式 —— 它们会 Base64 解码一个模糊 Token,并尝试递增在其中发现的任何内容。

匹配 API 最大页面大小的默认值意味着对工具的第一次调用总是会耗尽预算。API 的默认值 1,000 对于将结果流式传输到磁盘的集成脚本来说是可以的。但对于典型任务只需要前 20 条记录的智能体来说,这是错误的。默认的 limit 应该设置为智能体通常需要的数值,并保留根据需求扩大的选项。

埋在项目内部的总计数意味着智能体无法规划。一个返回 50 个项目且带有 total: 50000 字段的响应,会在智能体再分页 999 次之前告诉它走错了路。而一个仅返回 50 个项目的响应,会让智能体自信地撞向南墙。

先汇总,后钻取

在 cursor-and-limit 之下的深层模式,被一些团队称为“先汇总,后钻取(summarize-then-drill-in)”。针对返回列表的工具的第一次调用不应返回任何行。它应该返回计数、聚合和形态:存在多少条记录,它们在 Agent 可能过滤的维度上的分布情况,以及最小的有用切片是什么。第二次调用才检索 Agent 实际选定的行。

这与资深工程师在面对陌生表时使用的“先 EXPLAINSELECT”的原则如出一辙。你不会从读取每一行开始;你先了解数据的形态,然后提出一个足够小且有用的问题。

对于 Agent 来说,这演变成了一个分层的工具目录。count_orders(filters) 返回基数。summarize_orders(filters, group_by) 返回分桶。list_orders(filters, cursor, limit) 返回行,且仅在 Agent 充分缩小过滤器范围以确保结果有用之后才调用。Datadog 团队指出,添加类似 SQL 的查询接口——让 Agent 请求特定的字段和行,而不是拉取样本并推断趋势——使评估成本降低了约 40%,因为 Agent 停止了盲目打捞,开始精准提问。

这种模式之所以值得增加额外的接口复杂度,是因为它在 Agent 消耗上下文预算之前,给了它推理的依据。Agent 可以说“这个过滤器有 50,000 个匹配项,太多了,让我进一步缩小范围”,然后再为其中任何一项付费。如果没有这个中间步骤,Agent 只有两种状态:盲目或全满。

预算检查属于运行时

即使有了分页约定和分层目录,单次工具调用仍可能超支。具有超长显示名称的客户、带有 200KB 备注字段的订单、带有深层嵌套错误负载的追踪(trace)——所有这些都能将“给我 20 行”的调用变成一个 30,000 token 的响应。约定限制了行数,但没有限制单行的大小。

位于模型和工具之间的运行时(Runtime)是强制执行字节预算的正确场所。一个简单的防护机制——如果工具的响应超过剩余上下文的 N%,则拒绝该请求并返回一个结构化的错误,告知 Agent 缩小请求范围——可以防止最坏的情况泄露给模型。错误消息是关键部分。“工具响应为 28K token,超过了单次调用 8K 的预算。请尝试将 limit 减少到 5,或通过 status='refunded' 进行过滤”可以让 Agent 恢复。而简单的“超出上下文”只会迫使 Agent 瞎猜。

内存指针模式(Memory Pointer pattern)是一个值得了解的相关概念:当工具确实需要返回一个大型 blob 时,它将 blob 存储在外部状态中并返回一个短引用键。后续工具可以对该指针进行操作——搜索、汇总、提取——而无需将完整的 blob 放入模型的上下文中。Blob 存在于运行时中,模型通过引用进行导航。这就是流式消费者的行为方式;而不是 list_orders() -> Order[] 消费者的行为方式。

运行时也是你放置工具结果清理逻辑的地方。一个在 12 轮对话前返回了 5,000 token 搜索结果的工具调用,对于 Agent 当前的决策几乎肯定不起支撑作用。用一个存根(stub)替换它——“list_orders 返回了 200 个项目,保留摘要”——从而释放 token。Anthropic 发布的上下文工程指南将此视为一种默认准则,而非一种优化。

尚无人拥有的评估切片

如果你的评估套件(Eval suite)没有“长尾输入”切片,分页问题就不会在 CI 中显现。它会出现在支持工单中,就在那个拥有 5 万条记录的客户签约三个月后,届时团队将花费整个迭代周期尝试在只有 12 条记录的预发布租户中复现它。

修复方法并不光鲜。构建一组测试数据桩(Fixture set),包含你 Agent 涉及的每个列表维度在第 50、95 和 99.9 百分位的租户:每个租户的订单数、每个仓库的文件数、每个会话的消息数、每个表的行数。在每个版本发布时针对所有这些场景运行 Agent。跟踪每个百分位的成功率,而不仅仅是全局成功率。当 99.9 百分位的指标下降了 30 点而全局平均值几乎没有变化时,你就发现了一个分页回归——这种问题在以前往往直到上线前都没有人注意到。

另一半工作是追踪(Tracing)。工具调用的追踪需要分别记录返回的项目数量和消耗的字节数,这样你就能看到 Agent 究竟是在为大小付费还是为数量付费。一个返回 10 个项目但消耗 40,000 token 的工具是在做错误的事情,而在 Agent 集群中发现这种问题的唯一方法就是拥有追踪数据并对其进行告警。

分页是契约,而非功能

团队需要内化的总结是:一个在没有分页协议的情况下使用返回列表工具的 Agent,正在使用一个不安全的 API。它在预发布环境中能运行并不能证明它是安全的;这只证明预发布环境恰好规模很小。全局准确率指标很高也不能证明它是可靠的;这只证明平均客户规模较小。

分页是一项契约,它规定“工具将交给你一个窗口,由你决定窗口的大小,并且你可以回来获取更多”。这是一种你公司的 API 设计师们早已熟知的负载卸载(Load-shedding)原语。你的任务是将这种准则扩展到 Agent 调用的工具目录中——如今这个目录里塞满了返回无界数组的 list_* 函数,因为团队中还没有人曾为那个拥有 5 万条记录的客户担任过值班工程师。

正确处理分页的 Agent 可以扩展到任何出现的细分市场。而没处理好的 Agent,其产品可靠性取决于其客户的规模,这等同于说其可靠性取决于谁恰好提交了下一个工单。

References:Let's stay in touch and Follow me for more thoughts and updates