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

- Name
- hpoenixf
有一类 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"
}
后续逻辑就知道:
- 不能把当前输入描述成全集;
- 回答中的断言范围要缩小;
- UI 可以展示缺失;
- Runtime 可以选择补拉、分页或停止;
- 测试和日志能准确知道丢在了哪里。
模型本身不需要理解这些状态是怎么计算的。
它只需要得到明确事实:
当前输入覆盖 60/65 条,缺少 5 条。
至于 60、65 是怎么来的,应该由系统负责。
这次改动证明了什么,又没有证明什么
在当时的受控窗口里,我们确实观察到:
- 50 条源数据只进入 20 条的静默截断被复现;
- 65 条尾部丢失用例能稳定触发不完整分支;
- 截断专项从初次 6 失败、2 通过变成当次 8 项全部通过;
- 相关回归测试当次通过。
这些结果说明:
我们把一种原本静默的数据丢失,变成了可以检测和测试的状态。
它没有说明:
- 模型从此不会漏读;
- 最终答案一定正确;
- 所有线上输入规模都适合当前配额;
- 开放搜索也能被证明“完整”;
- 当前参数就是最佳生产参数。
当时还有线上质量样本和超大体积真实负载没有采集。
这些空白最好就继续写成空白。
工程复盘最容易犯的错误之一,就是修了一个确定的问题,顺手把旁边五个没验证的问题也一起宣布解决。
写在最后
以前我更关注“模型答得对不对”。
现在做 Agent,我会往前多问一句:
它回答之前,到底看到了什么?
因为最终文本实在太容易骗人了。
一段语言完整、逻辑顺畅的回答,可能只是对一个残缺输入做出的合理总结。
这时候模型甚至没有做错什么。
真正的问题发生在更前面:系统丢了数据,却没有告诉任何人。
所以这一轮最后留下的原则其实很简单:
对有明确全集的数据任务,先证明输入覆盖,再讨论回答质量。
但也要记住另一半:
输入覆盖完整,只证明模型有机会看到全部,不证明它真的理解了全部,更不证明答案一定正确。
把这两个边界同时守住,才不会从一种“假完整”,走到另一种“假确定”。
说明
本文中的数量来自受控工程验证窗口,用于说明静默截断、覆盖检查和测试方法,不代表长期生产性能或通用参数建议。
适用范围主要是具有明确目标集合的数据读取任务,例如持仓、数据库结果、文件列表、固定文档和 API 分页。对于开放互联网检索等不存在可枚举全集的任务,应使用“检索计划完成度”等其他语义,不应直接声称事实覆盖完整。