1. 从"50 条变 20 条"说起:这个 bug 为什么让人后背发凉
先说结论:源端明明有 50 条数据,模型只看到 20 条,中间没有任何报错、没有任何 warning、日志里连一行异常都没有。这不是模型"理解错了",而是数据在进入模型之前就已经被悄悄砍掉了。这类问题我把它统称为"静默截断"——它最恶心的地方不在于截断本身,而在于它不告诉你。
做过 Agent 开发的人应该都有体会:模型输出不对,你第一反应是去调 prompt、换模型、加 few-shot,折腾半天发现根本不是模型的问题,是喂进去的上下文压根就不完整。而"静默截断"比普通的报错难查十倍,因为报错至少会给你一个堆栈,静默截断只给你一个"看起来很正常"的结果。
这篇内容适合三类人看:一是正在做 Agent 或 LLM 应用、被上下文问题折磨过的开发者;二是刚接触大模型上下文工程、还没踩过这个坑的新手;三是负责排查线上 LLM 服务异常、需要一套可复现排查链路的人。我会把这次排查的完整过程拆开讲——从现象、到怀疑方向、到逐层定位、到最终根因,再到怎么在架构上防住它。中间会穿插几个我实际踩过的坑,这些坑在官方文档里基本不会写。
先给一个直觉性的类比:你让助理去档案室取 50 份文件,助理回来只拿了 20 份,还一脸平静地跟你说"文件都在这了"。你不会先怀疑助理的智商,你会先怀疑:是档案室只放了 20 份?是取件单只写了 20 个编号?还是助理的包只能装 20 份?Agent 的上下文截断排查,本质上就是回答这三个问题。
2. 先搞清楚"截断"可能发生在哪几层
在动手排查之前,必须先在脑子里建立一张"数据流地图"。很多人一上来就盯着模型看,这是最大的误区。从源端数据到模型真正"看到"的内容,中间至少隔着五到六层,任何一层都可能动手脚。
2.1 一条数据从源端到模型的完整链路
我习惯把这条链路拆成下面这几段,每一段都是一个独立的"嫌疑层":
| 层级 | 环节 | 典型截断行为 |
|---|---|---|
| L1 | 数据源查询 | SQL 的 LIMIT、分页参数写错、默认返回条数 |
| L2 | 应用层组装 | 循环里 break、切片[:20]、去重逻辑误杀 |
| L3 | 上下文拼接 | 模板占位符只渲染了部分、字符串拼接被覆盖 |
| L4 | Token 预算裁剪 | 按 token 数截断、保留头部丢弃尾部 |
| L5 | 序列化/传输 | JSON 截断、字段被 schema 过滤、payload 超限 |
| L6 | 模型侧窗口 | 上下文窗口上限、服务端静默丢弃超长部分 |
这张表建议你直接抄进自己的排查笔记。90% 的"模型只看到 N 条"问题,根因都在 L1 到 L4,而不是 L6。因为 L6 的截断通常会伴随明显的 token 超限提示,反而是前面几层最擅长"无声作案"。
2.2 为什么"静默"比"报错"更危险
报错是好事,它把问题暴露在明面上。静默截断的可怕之处在于三点:
第一,它污染的是模型的输入,而不是输出。模型基于残缺的上下文做出的推理,看起来逻辑自洽,你很难从输出反推输入有问题。比如你问"这 50 条里哪条金额最高",模型基于 20 条给出了答案,答案本身"没错",只是不完整。
第二,它往往和业务逻辑耦合。截断点常常藏在某个"看起来无害"的工具函数里,比如一个truncate_context()或者一个max_items配置项,平时不触发,数据量一上来就触发。
第三,它难以复现。本地测试数据少,永远不触发;线上数据一多就出问题。等你发现时,可能已经跑了几百次错误推理。
提示:排查这类问题的第一原则是——不要相信任何"看起来正常"的中间结果,要逐层打印实际数据量。日志里没有异常,不代表数据没被改过。
3. 我的排查链路:从 50 到 20 是怎么一步步锁定的
这次排查我没有一上来就翻代码,而是先做了一件更重要的事:在链路的每一层都插一个"计数器",把每层实际拿到的条数打出来。这一步花了我大概二十分钟,但省下了后面可能几小时的瞎猜。
3.1 第一步:确认源端到底有没有 50 条
很多人会跳过这一步,直接相信"源端有 50 条"这个前提。但前提本身可能就是错的。我先在数据源侧直接执行了原始查询,确认返回确实是 50 条,并且打印了每条的 ID。
这一步的关键是绕开所有应用层封装,用最原始的方式查。因为如果应用层的查询构造器有问题,你在应用里查永远只能看到被处理过的结果。我用的是最朴素的查询语句,直接对着数据源跑,结果 50 条,ID 连续,没问题。
到这里,L1 层基本排除。但注意,排除 L1 不等于排除查询构造,因为应用层可能用了不同的查询参数。所以我又做了一次对照:用应用层实际使用的查询参数再查一遍,还是 50 条。L1 彻底排除。
3.2 第二步:在应用层组装前后各打一个点
这是最关键的一步。我在"从数据源拿到数据"和"把数据交给上下文拼接"这两个位置各加了一行日志,打印len(data)。
结果很有意思:数据源返回 50 条,但进入上下文拼接时只剩 20 条。也就是说,问题出在 L2 或 L3,范围一下子缩小了一半。
这里有个经验:日志要打"条数"而不是"内容"。打内容会让日志爆炸,而且你很难一眼看出少了哪些。打条数最直观,50 变 20 一眼就能看出来。如果条数对得上但内容不对,那才需要进一步打 ID 列表做 diff。
3.3 第三步:二分法定位到具体函数
确定了问题在 L2/L3 之后,我用了一个笨但极其有效的方法:在组装流程的中间位置再插计数器,二分定位。
组装流程大概是这样:查询 → 过滤 → 排序 → 去重 → 格式化 → 拼接。我在"过滤后"和"去重后"各插了一个计数器。结果是:过滤后 50 条,去重后 20 条。
根因锁定在去重逻辑。这个去重函数本意是去掉重复项,但它用的 key 选错了——用了一个在业务上并不唯一的字段做去重键,导致 30 条"看起来重复"的数据被误杀。这就是典型的"静默截断":去重函数不会报错,它只是"尽职尽责"地把它认为重复的删掉了。
3.4 第四步:验证根因并评估影响面
找到根因后,我没有立刻改代码,而是先做了一件事:评估这个 bug 影响了多少历史请求。因为如果它已经跑了一段时间,那之前所有基于残缺上下文的推理结果都不可信。
我写了个脚本,把最近一段时间的请求日志捞出来,统计"源端条数"和"实际进入模型条数"的分布。结果发现,只要源端条数超过某个阈值,就会触发误去重。这意味着数据量越大,被误杀的比例越高,这是个随规模放大的 bug,非常危险。
注意:定位到根因只是完成了一半,评估影响面同样重要。很多团队改完 bug 就上线,结果历史脏数据还在污染下游,问题会以另一种形式复发。
4. 那些藏在"正常代码"里的截断陷阱
这次排查让我重新审视了项目里所有可能"静默截断"的地方。我把常见的陷阱整理成下面几类,你可以对照自己的代码库过一遍。
4.1 去重逻辑:最隐蔽的杀手
去重是重灾区。问题往往出在去重键的选择上。开发者为了"保险",选了一个自以为唯一的字段,但这个字段在实际数据里可能大量重复。
举个我见过的真实例子:用"创建时间戳(精确到秒)"做去重键,结果同一秒内创建的多条数据被当成重复项删掉。这种 bug 在测试环境几乎不可能发现,因为测试数据都是手动造的,时间戳天然分散。
防御方法:去重键必须是业务上真正唯一的字段(比如主键 ID)。如果确实需要按业务字段去重,一定要在去重前后打条数日志,并且对"被删掉的项"做采样记录,方便事后审计。
4.2 切片与分页:[:20]这种写法要警惕
代码里出现data[:20]、items[0:20]这种硬编码切片,基本可以判定是隐患。它可能来自早期的"先限制一下防止太多"的临时方案,然后就被遗忘了。
分页参数同理。page_size=20如果被错误地当成"总数上限"而不是"每页大小",就会导致只取第一页。这类问题的特征是:条数永远卡在一个固定值,比如永远是 20、50、100。如果你发现模型看到的条数是个"整数",优先怀疑切片或分页。
4.3 Token 预算裁剪:按 token 截断的副作用
现在很多框架会自动做 token 预算管理:当上下文超过模型窗口时,自动裁剪。裁剪策略通常是"保留头部、丢弃尾部"或者"保留最近 N 轮"。
问题在于,裁剪是按 token 算的,不是按条数算的。同样 50 条数据,如果每条很长,可能只装得下 20 条;如果每条很短,可能 50 条全装得下。这就解释了为什么"有时候 50 条都在,有时候只剩 20 条"——它取决于数据长度,而不是数据条数。
排查这类问题,要打印的是token 数而不是条数。我一般会在裁剪前后各打一个 token 计数,看看是不是撞到了预算上限。
4.4 序列化与 schema 过滤:字段级的静默丢失
还有一种更隐蔽的:数据条数没少,但每条数据里的字段少了。比如 JSON 序列化时,某个字段因为类型不匹配被静默丢弃;或者 schema 校验时,未定义的字段被过滤掉。
这种问题从"条数"上看不出来,需要做字段级 diff。我的做法是:取一条源端原始数据,取一条模型实际看到的数据,逐字段对比,看哪些字段消失了。
| 陷阱类型 | 典型特征 | 排查手段 |
|---|---|---|
| 去重误杀 | 条数随数据量放大而减少 | 去重前后打条数 + 采样被删项 |
| 硬编码切片 | 条数卡在固定整数 | 全局搜索[:N]、limit |
| Token 裁剪 | 条数随数据长度波动 | 打印 token 计数 |
| Schema 过滤 | 条数不变但字段缺失 | 字段级 diff |
5. 一套可复用的"静默截断"排查方法论
踩过这次坑之后,我总结了一套固定的排查流程。它的核心思想是:把"黑盒"变成"白盒",在每一层都留下可观测的痕迹。
5.1 建立"数据量基线":每层都要有计数器
不要等到出问题才加日志。在架构设计阶段,就应该在数据流的关键节点埋计数器。我一般会在这几个位置埋点:
- 数据源查询返回后
- 应用层过滤/排序/去重后
- 上下文拼接完成后
- 序列化完成后
- 发送给模型前
每个点打印(stage_name, count, token_estimate)。这样一旦出现"模型只看到 N 条",你只需要看日志就能定位到是哪一层开始变少的。
这套埋点的成本很低,但收益极高。它不仅能排查截断,还能帮你发现性能瓶颈(比如某一层耗时特别长)。
5.2 用"对照实验"缩小范围
如果日志不全,或者问题只在线上出现,可以用对照实验法:
- 构造一个最小复现用例,数据量刚好卡在临界点(比如 21 条)
- 逐步增加数据量,观察从第几条开始出现截断
- 对比"能复现"和"不能复现"两组数据的差异(长度、字段、内容特征)
这次排查中,我就是通过"20 条正常、21 条开始丢"这个临界点,快速锁定了去重逻辑——因为去重键的冲突概率随条数增加而上升。
5.3 关键:区分"条数截断"和"内容截断"
这两种截断的排查方向完全不同:
- 条数截断:模型看到的条数少于源端。排查重点在 L1-L4 的过滤、切片、去重、裁剪。
- 内容截断:条数对得上,但每条内容不完整。排查重点在序列化、字段映射、字符串截断。
我见过有人把这两种混为一谈,结果在错误的方向上浪费了大量时间。先确定是哪一种,再决定往哪查。
提示:一个快速判断方法是——让模型"数一数它看到了几条"。如果模型说 20 条,那就是条数截断;如果模型说 50 条但内容不全,那就是内容截断。当然,模型自己数数也不完全可靠,最好还是靠日志。
6. 从根上防住它:架构层面的几个硬约束
排查是事后补救,真正省心的是在设计阶段就把这类问题堵死。下面几条是我现在做 Agent 项目时的"硬约束",供你参考。
6.1 上下文组装必须"可审计"
我要求所有上下文组装逻辑都必须满足一个条件:给定相同的输入,能完整还原出模型实际看到的上下文。具体做法是:
- 每次请求都持久化一份"实际发送给模型的完整 payload"(可以采样存储,不必全存)
- payload 里带上元信息:源端条数、实际条数、token 数、裁剪策略
- 出问题时,直接拿这份 payload 复现,不用猜
这个做法的代价是存储成本,但相比排查静默截断的时间成本,完全值得。我一般只存最近 N 天的,或者按比例采样。
6.2 截断必须"显式且可观测"
任何截断行为都不允许静默发生。具体规则:
- 如果因为 token 预算要裁剪,必须记录"裁剪了多少条、裁剪策略是什么"
- 如果因为去重删了数据,必须记录"删了多少条、删除原因"
- 如果因为 schema 过滤丢了字段,必须记录"丢了哪些字段"
这些记录不一定要报错,但一定要进日志或监控。"静默"是万恶之源,"可观测"是解药。
6.3 给关键路径加"完整性断言"
在上下文拼接完成后、发送给模型前,加一个断言:实际条数是否等于预期条数。如果不等,要么报错,要么打 warning。
这个断言在测试环境可以设成硬失败,在线上可以设成告警。它能第一时间抓住截断,而不是等模型输出错了才发现。
# 伪代码示例:上下文完整性断言 expected_count = len(source_data) actual_count = len(context_items) if actual_count != expected_count: logger.warning( "context_truncation_detected", extra={ "expected": expected_count, "actual": actual_count, "stage": "before_llm_call", "request_id": request_id, } ) # 线上告警,测试环境直接抛异常 if IS_TEST: raise ContextTruncationError(expected_count, actual_count)6.4 数据量大的场景,优先用"引用"而非"全量塞入"
如果源端数据经常有几十上百条,全量塞进上下文本身就是个坏主意——不仅容易触发截断,还会稀释模型的注意力。更好的做法是:
- 先用检索或规则筛出最相关的 N 条
- 把筛选逻辑也做成可观测的(记录筛掉了什么、为什么筛)
- 让模型基于筛选后的结果推理,必要时再让它"请求更多数据"
这样既避免了截断,又提升了推理质量。上下文工程的核心不是"塞得多",而是"塞得准"。
7. 几个我踩过的坑和对应的经验
最后分享几个具体的、文档里不会写的坑,都是我在实际项目里真金白银换来的。
第一个坑:以为"日志没报错"就等于"数据没问题"。静默截断最大的迷惑性就在这。后来我养成了一个习惯:任何涉及数据条数变化的操作,都必须打日志,哪怕它"逻辑上不可能出错"。因为"逻辑上不可能"往往就是 bug 的藏身之处。
第二个坑:在错误的层级排查。我一开始盯着模型侧看,怀疑是上下文窗口的问题,浪费了不少时间。后来才意识到,模型侧通常是最后才需要怀疑的,因为它的行为最"诚实"——给多少看多少。真正会"撒谎"的是中间的应用层。
第三个坑:改完 bug 不复盘影响面。第一次修完去重逻辑后,我以为万事大吉,结果下游有个缓存还在用旧的残缺数据,问题又冒出来了。从那以后,我改任何涉及数据流的 bug,都会问一句:这个数据还被谁用了?缓存要不要清?历史数据要不要重跑?
第四个坑:测试数据太"干净"。测试环境的数据都是精心构造的,去重键天然不冲突,永远触发不了误去重。后来我强制要求:测试数据必须包含"边界样本",比如重复字段、超长内容、临界条数。这些样本才是真正能暴露问题的。
第五个坑:把 token 预算设得太紧。为了省钱把上下文预算压得很低,结果频繁触发裁剪,模型看到的永远是残缺数据。后来我调整了策略:预算要留足余量,宁可多花点 token,也不要让模型在残缺信息上做推理。因为一次错误推理的代价,远高于多花的那点 token。
这套排查思路后来我又用在好几个项目上,包括一些非 LLM 的数据管道场景,发现同样适用——只要数据在多层之间流转,"静默丢失"就是个通用问题。核心永远是那三件事:每层留痕、显式截断、完整性断言。把这三件事做到位,90% 的"数据凭空消失"问题都能在几分钟内定位,而不是耗上几天。