MCP返回50条,模型只看到20条:Agent静默截断排查与防御

有一类 Agent 问题特别难排查:不报错、不超时,日志里甚至全是成功,模型也正常返回了一段很完整的答案,但答案的基础其实少了一大截。

一篇社区复盘记录了这样一个案例:数据源有 50 条记录,真正进入模型上下文的只有前 20 条,剩下 30 条没有报错、没有空值,也没有显眼异常。模型基于自己看到的 20 条继续回答。如果只看最终文本,很难意识到它根本没看全。

说明:本文事实依据来自一篇社区复盘(community_signal),属于作者第一人称的排查经历与工程总结,并非官方文档、官方公告或官方一手资料。下文统一以“来源作者”“这篇复盘”归因,不将社区经验升级为官方结论。

问题不在模型,而在 LLM 之前

来源作者描述的链路是:

权威数据源
↓
读取 / 过滤 / 配额限制
↓
准备模型输入
↓
LLM 总结或转述
↓
最终回答

问题发生在 LLM 之前:中间层有一个条目上限,只取了 20 条。对模型来说,这 20 条就是它的全部世界。它既不知道后面还有 30 条,也没有理由主动说“我可能没看全”。

这类问题一开始很像 LLM 波动:同样一批资料,有时总结不错,有时明显漏掉后面的内容。自然反应是怀疑上下文太长、注意力不够、换模型、把 prompt 写得更明确。来源作者往前查后发现,模型根本没机会看到那些数据。

真正需要检查的是:源端这次有多少条、调用方希望处理多少条、真正进入模型输入的是多少条、有没有因字节限制被截断、有没有记录在解析或过滤时被跳过。

把“模型看到了多少”变成显式字段

来源作者给出的简化结构为:

{
  "requested": 50,
  "loaded": 20,
  "byteComplete": true,
  "filteredCount": 0,
  "inputComplete": false,
  "reason": "entry_limit"
}

重点不是字段名,而是系统能明确说:这次送给模型的输入不是完整集合。如果 inputComplete=false,后续回答不能继续假装基于全集。

但 requested=50 loaded=50 filtered=0 byteComplete=true 也不能证明一切。它最多证明:按当前系统记录,50 条目标记录都进入了模型输入,没有被已知的数量、字节或过滤规则截掉。它没有证明模型真的充分利用了 50 条,也没有证明最终结论正确。

来源作者把“完整”拆成三层:

  1. 源集合是否定义完整;
  2. 目标数据是否完整进入模型;
  3. 最终回答是否正确覆盖这些数据。

第一层如用户 37 个持仓、数据库 120 条、文档 16 章节、API 分页 8 页;第二层是本文主要问题,37 个持仓是否全部进入链路,120 条有没有只拿前 100 条,8 页是否第 7 页失败后仍当完成;第三层是模型质量,需要独立评测。不能用 inputComplete=true 给模型质量背书。

数量一致不等于身份一致

来源作者举了一个例子:源端目标是 A B C D E,中间层实际拿到 A B C D D。数量仍然是 5,requested==loaded,但 E 丢了,D 重复了一次。只看数量会误判为完整。

对有稳定身份的有限集合,应加一层 identity 对账,例如:

{
  "expectedCount": 5,
  "loadedCount": 5,
  "expectedIdsHash": "sha256:...",
  "loadedIdsHash": "sha256:...",
  "inputComplete": false,
  "reason": "identity_mismatch"
}

实际实现不一定非要 hash,也可以是 item ID 集合、page cursor、snapshot version、offset 范围、continuation token 或数据源自己的 revision。关键点是:数量一致是必要信号,不是充分证据。

三种静默丢数据

来源作者总结了三种静默丢数据的方式。

1. 条目数被截断

源端 50 条,配置只允许 20 条,剩下 30 条在进入模型之前就消失了。如果系统不记录原始数量,后面基本没人知道发生过什么。

2. 字节数被截断

条目数量没超过限制,但某几条特别长,累计输入超过 byte budget 后尾部被截掉。这时甚至可能看到 requested=20 loaded=20,数量完全正常,但第 20 条其实只剩一半。数量和字节要分开看。

3. 过滤阶段偷偷丢记录

解析和过滤阶段,某条数据可能格式异常、字段为空、schema 不兼容,或转换函数抛错后被 catch 掉。系统可能选择 skip 然后继续处理剩余内容,最终依然能产生漂亮的模型输入,只不过分母已经变了。

“读取成功”这种状态过于粗糙。读取成功,到底是请求成功、至少拿到一条、所有目标记录都拿到,还是所有字节都完整?这几个含义差得很远。

配额不能简单地一路调大

发现 50 条只进了 20 条以后,最直接的修复当然是把 20 改成 60。来源作者表示,在当前案例里这确实解决了问题。但如果把它总结成“上下文越大越好”,很快又会踩另一个坑。

输入越大,延迟越高、token 成本越高、模型注意力更分散、单次失败的重试成本更高,极端数据更容易把整个请求拖死。配额真正要解决的不是“尽可能塞进去”,而是:超出单次可靠处理范围时,系统应该知道自己没处理完。

如果数据太多,更合理的选择可能是分批读取、每批记录覆盖信息、中间结果结构化、最后汇总,而不是发现会截断就把限制继续调大,直到某天再次截断。

配额本身不是问题,静默配额才是问题。

尾部丢失测试

来源作者提到,后来专门写了一个“尾部丢失”测试:上限是 60,就准备 65 条记录,并把真正关键的数据放在最后几条,这样旧链路一定会暴露问题。

测试不再只检查函数返回了结果,而是检查:

requested = 65
loaded = 60
inputComplete = false
reason = entry_limit

如果是字节超限,则应该得到类似:

byteComplete = false
inputComplete = false
reason = byte_truncated

如果有过滤:

filteredCount > 0
inputComplete = false
reason = filtered

正常的小数据很难覆盖“不完整”分支。如果一直只用 5 条、10 条数据测,系统可能几年都不会真正走一次截断路径,然后第一次走就是线上。

部分结果不等于不能回答

来源作者早期做法比较保守:只要输入不完整,回答就只能是草稿,不能给肯定结论。方向没错,但写得太死也会有问题,因为部分数据并不意味着所有结论都失效。

例如 100 个持仓里只拿到了 80 个。当然不能说“这就是你的完整持仓分布”。但如果问题只是“这 80 个已读取持仓里有没有黄金 ETF”,那么在明确范围之后,仍然可以回答:在当前已读取的 80 个持仓里,没有发现黄金 ETF;另有 20 个持仓未读取,不能据此判断完整组合没有黄金敞口。

真正应该被限制的是断言范围,不是文风。完整输入可以对完整目标范围作答;部分输入只能对已覆盖范围作答,同时披露缺失范围,禁止把局部结论升级成全集结论。

一个很小的检查层

来源作者最后落下来的机制并不复杂。对于有明确全集的读取任务,在进入模型前保留几类信息:

{
  "sourceSnapshot": "snapshot-id",
  "requestedCount": 50,
  "loadedCount": 50,
  "filteredCount": 0,
  "byteComplete": true,
  "identityComplete": true,
  "inputComplete": true
}

如果其中任何关键项不成立:

{
  "requestedCount": 65,
  "loadedCount": 60,
  "filteredCount": 0,
  "byteComplete": true,
  "identityComplete": false,
  "inputComplete": false,
  "reason": "entry_limit"
}

后续逻辑就知道:不能把当前输入描述成全集;回答中的断言范围要缩小;UI 可以展示缺失;Runtime 可以选择补拉、分页或停止;测试和日志能准确知道丢在了哪里。

模型本身不需要理解这些状态是怎么计算的,它只需要得到明确事实:当前输入覆盖 60/65 条,缺少 5 条。

边界:开放搜索不存在天然全集

上面这套做法很适合持仓列表、数据库查询、文件清单、API 分页、指定文档、明确范围内的工具结果,因为我们至少知道“完整”大概是什么意思。

但如果任务是“搜索互联网上所有关于某公司的重要信息”,就不能套同一套逻辑然后说 returned = requested => complete。开放搜索没有一个天然可枚举的全集,搜索引擎返回 20 条,不代表世界上只有 20 条相关信息。这种场景下,更合适的说法是“当前检索计划已经执行完成”,而不是“相关信息已经完整覆盖”。

流程完成、输入完整、世界知识完整,是三件完全不同的事。

来源作者自述的受控验证结果

来源作者在受控窗口里自述观察到:

  • 50 条源数据只进入 20 条的静默截断被复现;
  • 65 条尾部丢失用例能稳定触发不完整分支;
  • 截断专项从初次 6 失败、2 通过变成当次 8 项全部通过;
  • 相关回归测试当次通过。

这些结果说明:把一种原本静默的数据丢失,变成了可以检测和测试的状态。

它没有说明:模型从此不会漏读;最终答案一定正确;所有线上输入规模都适合当前配额;开放搜索也能被证明“完整”;当前参数就是最佳生产参数。来源作者也提到,当时还有线上质量样本和超大体积真实负载没有采集。

工程复盘最容易犯的错误之一,就是修了一个确定的问题,顺手把旁边五个没验证的问题也一起宣布解决。

写在最后

以前更关注“模型答得对不对”。现在做 Agent,可以往前多问一句:它回答之前,到底看到了什么?

一段语言完整、逻辑顺畅的回答,可能只是对一个残缺输入做出的合理总结。这时候模型甚至没有做错什么,真正的问题发生在更前面:系统丢了数据,却没有告诉任何人。

这篇社区复盘最后留下的原则是:对有明确全集的数据任务,先证明输入覆盖,再讨论回答质量。但也要记住另一半:输入覆盖完整,只证明模型有机会看到全部,不证明它真的理解了全部,更不证明答案一定正确。

把这两个边界同时守住,才不会从一种“假完整”,走到另一种“假确定”。