Published on

MCP返回了 50 条数据,模型只看到了 20 条:一次 Agent 静默截断排查

Authors
  • avatar
    Name
    hpoenixf
    Twitter

有一类 Agent 问题特别难排查。

它不报错,不超时,日志里甚至全是成功。

模型也正常返回了一段很完整的答案。

但答案的基础其实少了一大截。

我最近遇到的一个例子就是这样:源端明明有 50 条记录,真正进入模型上下文的只有前 20 条。剩下 30 条没有报错,没有空值,也没有任何显眼的异常。

模型只是基于自己看到的 20 条继续回答。

如果只看最终文本,很难意识到它根本没看全。

这类问题让我重新意识到一件事:

Agent 不一定是“看全了以后答错”。
也可能是系统从一开始就没有把完整数据交给它。

而后者通常更隐蔽。


最开始,我以为只是回答质量不稳定

这个问题一开始很像普通的 LLM 波动。

同样一批资料,有时候总结得不错,有时候明显漏掉后面的内容。

第一反应很自然:

  • 是不是上下文太长?
  • 是不是模型注意力不够?
  • 要不要换模型?
  • 要不要把 prompt 写得更明确一点,让它“不要遗漏任何条目”?

后来往前查,发现这些方向都不太对。

因为模型根本没有机会看到那些数据。

链路大概是:

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

问题发生在 LLM 之前。

源端有 50 条,但中间层有一个条目上限,只取了 20 条。

对模型来说,这 20 条就是它的全部世界。

它既不知道后面还有 30 条,也没有理由主动说“我可能没看全”。

所以最终得到的是一种很麻烦的结果:

文本是合法的,模型也没有明显幻觉,但系统对事实的覆盖已经不完整。

这和普通的“模型答错”不太一样。


真正需要检查的是:模型到底看到了多少

后来我们没有继续先调 prompt,而是把问题往前移了一层。

在让模型总结之前,系统先回答几个非常朴素的问题:

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

这些东西以前散落在实现细节里。

后来我们把它们变成了显式字段。

一个简化后的结构大概是这样:

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

这里最重要的不是字段名字,而是系统终于能明确说:

这次送给模型的输入不是完整集合。

这句话应该由代码判断,而不是让模型自己猜。

如果 inputComplete=false,后面的回答至少不能继续假装自己基于全集。


但“50 条进了 50 条”也不能证明一切

做到这里之后,还有一个很容易犯的错误。

假设现在看到:

requested = 50
loaded = 50
filtered = 0
byteComplete = true

是不是就可以说“数据完整,因此回答可信”?

不能。

这组信息最多证明:

按当前系统记录,这 50 条目标记录都进入了模型输入,没有被已知的数量、字节或过滤规则截掉。

它没有证明两件事。

第一,模型真的充分利用了这 50 条。

模型可能忽略第 47 条,也可能在总结时漏掉某个关键例外。

第二,模型最后的结论是对的。

即使所有输入都完整,推理本身仍然可能有问题。

所以后来我更愿意把“完整”拆成三层:

源集合是否定义完整
        ↓
目标数据是否完整进入模型
        ↓
最终回答是否正确覆盖这些数据

这三层不要混在一起。

第一层:源集合完整

例如:

  • 用户当前 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。

关键点只有一个:

数量一致是必要信号,不是充分证据。

这件事很像数据库分页。

“我拿到了 100 条”并不能证明这是“应该拿到的那 100 条”。


还有一类任务,根本不存在“完整全集”

这里也有一个需要特别限制的边界。

上面这套做法很适合:

  • 持仓列表;
  • 数据库查询;
  • 文件清单;
  • API 分页;
  • 指定文档;
  • 明确范围内的工具结果。

因为我们至少知道“完整”大概是什么意思。

但如果任务是:

搜索互联网上所有关于某公司的重要信息。

就不能套同一套逻辑,然后说:

returned = requested
=> complete

开放搜索没有一个天然可枚举的全集。

搜索引擎返回 20 条,不代表世界上只有 20 条相关信息。

这种场景下,更合适的说法是:

当前检索计划已经执行完成。

而不是:

相关信息已经完整覆盖。

我觉得这是做 Agent 很容易混淆的地方。

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


我们遇到的三种静默丢数据

回头看,这类问题不只一种。

1. 条目数被截断

这是最直观的一种。

源端 50 条,配置只允许 20 条。

剩下 30 条在进入模型之前就消失了。

如果系统不记录原始数量,后面基本没人知道发生过什么。

2. 字节数被截断

还有一种更隐蔽。

条目数量没超过限制,但某几条特别长。

累计输入超过 byte budget 后,尾部被截掉。

这时候你甚至可能看到:

requested = 20
loaded = 20

数量完全正常。

但第 20 条其实只剩一半。

所以数量和字节要分开看。

3. 过滤阶段偷偷丢记录

第三种是解析和过滤。

比如某条数据:

  • 格式异常;
  • 某字段为空;
  • schema 不兼容;
  • 转换函数抛错后被 catch 掉。

系统可能选择 skip,然后继续处理剩余内容。

最终依然能产生漂亮的模型输入。

只不过分母已经变了。

这也是为什么后来我不太喜欢“读取成功”这种过于粗的状态。

读取成功,到底是:

请求成功?

至少拿到一条?

所有目标记录都拿到?

所有字节都完整?

这几个含义差得很远。


配额不能简单地一路调大

发现 50 条只进了 20 条以后,最直接的修复当然是:

把 20 改成 60。

在当前案例里,这确实解决了问题。

但如果把它总结成“上下文越大越好”,很快又会踩另一个坑。

Agent 的输入不能无限长。

输入越大:

  • 延迟越高;
  • 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 个持仓未读取,不能据此判断完整组合没有黄金敞口。

这里真正应该被限制的是断言范围。

不是看到 partial 就让模型什么都不能说。

所以我现在更喜欢这样的设计:

完整输入
→ 可以对完整目标范围作答

部分输入
→ 只能对已覆盖范围作答
→ 同时披露缺失范围
→ 禁止把局部结论升级成全集结论

这比简单切换“肯定语气 / 草稿语气”更准确。

系统真正约束的不是文风,而是 claim scope。


最后落下来的,是一个很小的检查层

做完这一轮之后,实际机制并没有想象中复杂。

对于有明确全集的读取任务,在进入模型前保留几类信息:

{
  "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"
}

后续逻辑就知道:

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

模型本身不需要理解这些状态是怎么计算的。

它只需要得到明确事实:

当前输入覆盖 60/65 条,缺少 5 条。

至于 60、65 是怎么来的,应该由系统负责。


这次改动证明了什么,又没有证明什么

在当时的受控窗口里,我们确实观察到:

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

这些结果说明:

我们把一种原本静默的数据丢失,变成了可以检测和测试的状态。

它没有说明:

  • 模型从此不会漏读;
  • 最终答案一定正确;
  • 所有线上输入规模都适合当前配额;
  • 开放搜索也能被证明“完整”;
  • 当前参数就是最佳生产参数。

当时还有线上质量样本和超大体积真实负载没有采集。

这些空白最好就继续写成空白。

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


写在最后

以前我更关注“模型答得对不对”。

现在做 Agent,我会往前多问一句:

它回答之前,到底看到了什么?

因为最终文本实在太容易骗人了。

一段语言完整、逻辑顺畅的回答,可能只是对一个残缺输入做出的合理总结。

这时候模型甚至没有做错什么。

真正的问题发生在更前面:系统丢了数据,却没有告诉任何人。

所以这一轮最后留下的原则其实很简单:

对有明确全集的数据任务,先证明输入覆盖,再讨论回答质量。

但也要记住另一半:

输入覆盖完整,只证明模型有机会看到全部,不证明它真的理解了全部,更不证明答案一定正确。

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


说明

本文中的数量来自受控工程验证窗口,用于说明静默截断、覆盖检查和测试方法,不代表长期生产性能或通用参数建议。

适用范围主要是具有明确目标集合的数据读取任务,例如持仓、数据库结果、文件列表、固定文档和 API 分页。对于开放互联网检索等不存在可枚举全集的任务,应使用“检索计划完成度”等其他语义,不应直接声称事实覆盖完整。