最近在调一个 Agent 项目的时候,遇到一个特别隐蔽的问题:源端明明有 50 条数据,模型却只看到了 20 条,而且整个过程没有任何报错。这个“静默截断”问题让我排查了很久。最后的根因并不在模型本身,而是出在中间的消息传递链路里——而且这类问题在 Agent 开发里真的非常常见。尤其当你开始让 Agent 接工具、接数据库、接 RAG 检索结果之后,上下文就不再是“用户 Question + 模型 Answer”那么简单了,任何一个中间层都可能悄悄动你的上下文。
这篇文章不聊概念,记录一次完整的真实排查经过,把现象、根因、定位方法和工程化防坑方案都拆开讲清楚。正好最近很多做 Agent 开发的同学都在问上下文管理的问题,这篇也可以当做一个实战案例来参考。
1. 从现象开始:明明发了 50 条,模型却只见 20 条
1.1 复现路径:这个问题是怎么冒出来的
我当时的场景是在做一个批量工单分类 Agent。流程很简单:先从数据库里查出一批待分类工单,通过工具函数把工单列表转换成 JSON 塞给模型,让模型做分类统计,最后输出每个分类的数量和清单。
第一次跑完整流程,模型给出来的报告里写了这么一句:“基于你提供的 20 条工单,我完成了分类统计。”我当时一愣——数据库里明明有 50 条工单,怎么模型看到的只有 20 条?
我第一反应是怀疑自己的 SQL 是不是漏了条件,于是直接在数据库客户端里执行了一遍查询逻辑,count(*)返回 50,一条不少。然后我单独调用了工具函数,把返回的 JSON 打印出来,解析之后len(list)也确实是 50。所以问题不在数据获取这层。
更关键的一个细节是:这个“20 条”是稳定的。我连续跑了三次,模型都说是 20 条,不是 19 也不是 21,而是稳定地少 30 条。这说明一定不是网络抖动或者并发引起的随机丢数据,而是某个机制在稳定地截断,只是这个机制藏在暗处。
1.2 第一轮排查:从源端到模型,一步一步对账
确认完源端和工具函数没有问题时,我开始一级一级往下查。我的调用链路大致长这样:
业务代码 → Agent 框架 → 网关/中转服务 → 模型 API我分别做了四件事来对账:
- 源端确认:数据库查询无过滤条件,
count(*)为 50,拿到全部数据。 - 工具函数确认:直接调用工具函数并打印返回字符串,JSON 解析后确实是 50 条。
- 消息拼装确认:在调用模型之前打印
messages数组里最后一条消息的字符数,并检查里面能否找到第 21 条、第 30 条、第 50 条工单的关键字——结果都能找到。 - 请求体确认:我在网关层抓了一下实际发出去的 HTTP 请求体,然后用脚本解析,发现请求体里实际发给模型的工单条数已经只剩 20 条了。
到这里,问题范围缩小了一大截:业务代码和工具函数都正常,但网关层发出的请求体已经被截断过。也就是说,截断发生在消息拼装完成之后、HTTP 请求真正到达模型之前。
这个发现让我意识到一个很重要的排查原则:模型看到的到底是什么,不能靠猜,必须以“实际发出的请求体”为准。你本地打印出来的日志是完整的,不代表线上链路处理完之后还是完整的。
1.3 为什么“静默”最坑人
很多人遇到这种问题会问:为什么模型不报错?为什么链路不给个警告?这恰恰是“静默截断”最恶心的地方。
如果是显式报错,比如请求超长、某层直接返回 400,那很好排查——报错信息会告诉你哪一环不接受这个输入。但静默截断是:你的程序觉得自己把 50 条都发出去了,模型那端也只认为自己收到了它应该收到的东西,然后基于不完整的信息继续正常工作。
更麻烦的是 LLM 的一个特性:它不会主动怀疑上下文不完整。模型的任务是基于当前可见的上下文做推理和补全,而不是审计数据完整性。所以当你只给了它 20 条数据时,它不会说“咦,怎么只有 20 条,是不是还有 30 条丢了”,它只会把这 20 条当作全部事实,然后自信地做出一份看起来完全合理的分类报告。
这其实就是“乐观补全”。所有做 Agent 的人都要记住这句话:模型的输出里说“我看到了什么”,必须以程序侧实际发送了什么为基准,不能以模型自己复述了什么为基准。程序侧没有留痕,这种问题就永远查不出来。
2. 为什么会被静默截断:三层截断的根因剖析
把问题定位到网关层之后,我开始深入想这个问题的底层机制。截断其实可能发生在三个不同的层级,如果你不理解这三层,即使这次修好了,下次换个场景还会再踩一遍。
2.1 第一层:框架层的上下文裁剪
现在的 Agent 框架基本都内置了上下文管理能力,最常见的是滑动窗口机制:只保留最近 N 轮对话,或者只保留最后若干个消息,超出窗口的历史消息直接丢弃。
还有一些框架会对工具返回内容做长度限制。我在查资料时看到有些框架默认配置max_tool_response_length,比如默认 8000 字符或者 2000 token。如果你的工具函数一次性返回了 15000 字符的 JSON,框架会“按照预期”把它截到 8000,而且不会报错——因为在框架看来,这是它正常工作的方式。
这个问题的核心在于:框架的默认配置是为“通用对话”设计的,不是为“工具密集型 Agent”设计的。通用对话中一条消息很少会超过几千字符,但工具返回的 JSON 一旦带上明细数据,很容易就破万。框架层面的裁剪非常隐蔽,因为它发生在你自己的代码和模型之间,不深入看框架源码根本不会注意到。
我第一次遇到这种情况时,框架日志里完全看不出裁剪痕迹,最后是查看框架上下文管理模块的 debug 日志才发现,某条工具消息被标记为“超出长度,已截断”。
2.2 第二层:模型侧的上下文窗口与 token 计算
第二层截断发生在模型服务端。很多人对上下文窗口有一个误区,觉得窗口多大就能传多大。实际上:
- 上下文窗口是一个总量,要同时装下 system 提示词、历史对话、工具返回、用户的当前指令,还有模型输出的预留空间。
- 如果你把
max_tokens设成了 4096,而上下文窗口是 8192,那实际能用的输入空间只有 4096 左右。 - 输入部分的可用空间,永远比你想象的要小。
更隐蔽的是 token 的计算方式。模型的输入长度限制是按 token 算的,不是按字符数算的。中文字符在主流分词器下,一个汉字大约占 1~2 个 token;如果你返回的 JSON 里有大量英文字段名、引号、缩进、转义符,这些都会额外消耗 token。
我当时那 50 条工单记录,每条大约 300 字中文,加上 JSON 字段名、换行符、缩进,50 条合计可能有 18000~20000 token。一旦超过模型服务的输入上限,服务端在进模型前就会自动做截断。有的服务商是截掉尾部,有的服务商是“从左截断”,还有的会丢掉中间段——关键是,很多服务商并不会在响应里告诉你输入被截断了。它们默认这种处理是“优化”。
所以排查这类问题时,不要用“字符数”去估量,要按 token 估算。而且我现在的习惯是:中文场景下,预估 token 数量后至少再留 1.5 倍余量,因为 JSON 的格式开销往往比你想的更大。
2.3 第三层:业务层的“假截断”与指令冲突
还有一种情况是数据确实完整到了模型,但模型“假装”只看到了一部分。这种我称之为“假截断”,常见原因有两种。
第一种是 prompt 里的指令冲突。比如你在 system 里写“基于工单列表逐条分类”,但后面某个地方又有句“为了效率,总结时优先选取前 20 条”,模型是有可能直接听从更具体的那条指令,只处理前 20 条,并且它不认为自己在“截断”——它觉得自己是在执行你给的要求。
第二种是超长上下文中的注意力分布问题。模型在处理超长输入时,对开头和结尾的内容关注度往往明显高于中间段。当你有 50 条数据,其中存在于中间位置的某些条目可能被模型“漏看”了——严格来说,数据在上下文里,但模型的注意力没有覆盖到它们。表现在输出端,就是某些条目完全没被提到,好像它们不存在一样。
这两种情况最麻烦的地方在于,它们无法通过检查 HTTP 请求体来发现,因为请求体确实是完整的。要确认是不是这类问题,需要用后面讲到的方法,让模型自己“复述”它看到的内容来反推。
3. 定位方法:把看不见的截断变成看得见的日志
这次排查让我养成了一个习惯:所有喂给模型的内容,程序侧必须留痕。下面这套定位方法,我后来已经沉淀成了固定的排查套路,分享出来供参考。
3.1 插桩三探针:在关键节点记录上下文长度
我在调用链路上加了三个探针:
- 探针 A(工具函数返回前):记录
len(data)、len(json_string)、预估 token 数,再截取字符串开头和结尾各 200 字符记录到日志。 - 探针 B(消息拼装完成后):记录
messages数组的消息总数,以及每条消息(尤其是工具返回那条)的字符数,同样记录首尾内容。 - 探针 C(HTTP 请求发出前):在网关或拦截器层记录请求体的字节数,并记录解析后的消息数量和首尾内容。
三个探针都打完之后,对比它们的记录,问题层级一眼就能看出来:
- 探针 A 有 50 条,探针 B 有 50 条,探针 C 只剩 20 条,说明截断发生在 B 和 C 之间,通常是框架层或网关层。
- 探针 B 就只有 20 条,说明消息拼装之前就被裁了,检查框架上下文管理模块的配置。
- 三个探针都有 50 条,但模型输出仍然说只见 20 条,那问题出在模型服务端内部,或者在 prompt 指令冲突层。
这里有一个性能上的小建议:探针日志不要每个请求都打全量内容,线上环境只记录长度、哈希、首尾片段就够了,出问题时再临时开启全量日志。这样既能及时发现问题,又不至于日志量爆炸。
3.2 指纹校验法:让模型复述 ID,而不是让它总结
写完探针之后,我设计了一个更直接的验证方式:给每条数据生成一个唯一的短 ID,比如TR-2025-001到TR-2025-050,拼接进工具返回的内容里。然后调整 Prompt,要求模型在开始分类前先列出“它在列表中能找到的最后一条记录的 ID”。
为什么要这样做?因为如果你直接问模型“你看到了几条”,模型在回答概数时很容易脑补——它可能只看到了 20 条,但回答时因为它“觉得应该有更多”,就直接给你编一个 50 出来。但如果你让它逐个列出 ID,它只能列出它真正在上下文里见过的内容,漏一条你都能立刻发现。
我这个测试跑出来的结果是:模型列出的最后一个 ID 是TR-2025-020。也就是说,模型实际看到的确实是前 20 条。这个结果和网关层请求体的分析完全一致,基本坐实了“截断发生在请求体送出前”。
这套指纹校验法的好处是精确。它能告诉你模型“实际看到”的数据范围是前缀、后缀,还是中间采样。如果模型列出的 ID 是 001、002、003、021、022、023,说明可能是中间段被丢弃;如果列出的是 030 以后的 ID,说明可能是从右截断。
3.3 二分法定位层级:逐层绕过,找到截断点
探针定位到是 B 和 C 之间出的问题后,我又做了更细的二分法测试,逐层绕过,找到具体是哪一层的配置出了问题。
具体做法是保持完全相同的 Prompt 和数据,依次测试:
- 绕过 Agent 框架:直接从自己的代码里调模型 API,不经过框架。如果能看到 50 条,说明框架层有问题;如果还是 20 条,继续往下测。
- 绕过网关/中转服务:直接调用模型供应商的原生接口。如果能看到 50 条,说明网关配置有问题;如果还是 20 条,继续往下测。
- 换模型验证:换一个不同供应商的模型,同样的数据再发一遍。如果新模型能看到 50 条,说明原模型服务端的输入截断策略或上下文上限有问题。
我当时测试到第二步就出结果了:绕过网关直接调原生接口时,模型能正确看到 50 条。所以问题锁定在网关服务上——它给请求体设置了一个默认的输入长度上限,超过上限的输入会被静默截断。
这里有一个坑必须提醒:如果你用的是第三方中转服务,绕过它之后可能会走到不同供应商的模型,导致结果不可比。二分法测试时尽量保证最终调用的模型一致,或者至少是同一个系列、同样上下文窗口大小的模型,否则你很难判断差异到底是链路哪一层造成的。
3.4 最小复现:把数据切成几份分别验证
除了二分法,我还会做一组最小复现测试。把 50 条数据按不同方式切分发给模型:
- 只发前 10 条,问模型看到了几条;
- 只发后 10 条,问模型看到了几条;
- 发前 20 条加一个“注意:这里一共有 50 条”的提示,问模型看到了几条;
- 完整发 50 条,再问模型看到了几条。
这组测试的价值在于确认截断是不是和总数有关。如果你只发后 10 条时模型能正常识别,而发 50 条时模型只看到 20 条,那就说明问题就是“数量多了之后被截断”。这可以帮你把排查重点聚焦到长度限制相关配置上,而不是去翻别的原因。
同时,最小复现测试跑完之后,你会得到一个非常干净的复现脚本,以后每次修改上下文管理配置都可以拿来回归验证。
4. 方案选择与工程化加固:怎么让它再也不静默
找到根因之后,修复只是第一步。更关键的是把整个链路设计成“不会再静默”的结构。下面这些方案,有些是我这次排查后直接落地的,有些是我后来在别的项目中持续沉淀的经验。
4.1 治本:控制单次工具返回的数据规模
这次问题的直接原因是工具函数一次性返回了 50 条完整明细,信息量太大,触发了链路层的截断。治本方案是:不要让工具一次性返回这么多明细数据。
我后来把工具函数的调用模式改成了“分页+下钻”:
- 第一次调用,工具只返回一个摘要:总共有 50 条工单,每个状态的计数、最近的几条样本。
- 模型如果需要看某个分类下的完整明细,再调用
get_items(page=2)这类工具去取具体页。 - 即使模型不主动调用分页工具,任务也能基于摘要完成大部分工作。
配套做的是设置工具返回值的硬上限。我在工具函数的末尾统一加了一个检查:如果序列化后的字符串超过 6000 token(大约 1~2 万中文字符),就自动截断并附上截断标记,而不是把超长内容原样塞进上下文。
很多做 Agent 的同学有一种执念:既然模型需要数据,那就把所有数据都塞给它。但模型并不是“数据越多越好”,超长内容会稀释注意力、增加 token 成本、提高被静默截断的概率。工程上,我们要做的不是“让模型看到所有数据”,而是“让模型看到它当前这一步足够用的数据”。
4.2 强制可见:加结束标记和截断标记
治本方案解决的是“不要传这么多”,但总有场景你必须把一个大列表完整传给模型。这种情况下就要用“强制可见”策略。
我的具体做法是:程序侧在拼接工具返回时先判断长度。如果发现内容超过阈值,就在内容末尾追加一行标记:
[TRUNCATED at item 20, total 50]然后在 system Prompt 里写死一条约定:如果你看到了截断标记,你必须输出ERROR: CONTEXT_INCOMPLETE并停止当前任务,禁止基于不完整信息继续推理。
这一步的本质,是把“静默截断”改造成“显式截断”。程序侧明确告诉模型:你看到的内容不完整,不要硬撑。模型收到这样的指令后,就会乖乖地拒绝回答而不是“乐观补全”。
我当时给工具函数的 description 里也加了一句话:本工具每次最多返回 20 条,返回数据末尾包含总条数和是否截断的标记。这样模型在调用工具之前就已经知道了边界,基于边界做判断,比把一堆数据砸给它再赌它不乱想要靠谱得多。
4.3 上下文管理:摘要不替代,但要有“丢弃记录”
Agent 的多轮对话一定会遇到上下文窗口不够用的问题,必须丢弃部分历史。但丢弃不能“无痕丢弃”。
我现在的做法是:
- 每一轮对话结束后,用一个小模型或规则对历史对话生成摘要。
- 把摘要作为新的 system 消息写入下一轮对话。
- 在摘要末尾明确写上
[DROPPED_CONVERSATION_BEFORE: 第 3 轮到第 7 轮],告诉模型“这之间的历史已经被丢弃”。 - 如果被丢弃的对话里有未完成的任务关键数据,把任务状态单独保留在摘要里,而不是保留原文。
这样做的好处是,即使滑动窗口把历史清掉了,模型也知道自己遗漏了什么。很多情况下,“模型知道自己缺什么”比“模型拥有所有数据”更安全。一个知道自己缺失信息的 Agent,至少不会假装自己全知全能。
4.4 哨兵测试:让回归自动化
修复完问题之后,我把这次事故沉淀成了一个自动化回归用例,我叫它“哨兵测试”。
做法很简单:准备一组固定的测试数据,一共 50 条“假工单”,每条有一个唯一的编号TR-2025-001到TR-2025-050。每次调用 Agent 后,在测试里断言模型输出中必须出现编号 001 和编号 050 的内容。如果模型输出里没有 001 或没有 050,说明某个中间环节又开始截断了,测试直接失败,红灯亮起。
这个测试的成本很低,但价值极高。它等于给你的 Agent 项目装了一个“安全带”。每次你升级框架、修改上下文管理配置、切换模型供应商、调整工具返回逻辑,都跑一遍哨兵测试。只要截断问题再次出现,第一时间就能发现,而不是等到线上用户反馈问题。
有一件事需要强调:哨兵测试的数据不要用真实业务数据,专门用伪造的测试数据。这样断言逻辑简单干净,不会被真实数据里的相似编号干扰。
5. 常见问题速查与避坑清单
最后整理一份速查表和几条避坑经验,这些是我这几年做 Agent 项目过程中反复踩坑总结出来的,希望能帮你省点时间。
5.1 静默截断问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 模型少看到 N 条数据 | 工具返回被框架或网关截断 | 对比三个探针日志 | 限制工具返回长度 + 加截断标记 |
| 模型说没看到,但请求体里有 | Prompt 太长被模型服务端截断 | 检查请求体实际字节数 | 压缩内容/分批发送/换大窗口模型 |
| 模型输出突然变短 | max_tokens设置太小 | 查看响应里的finish_reason | 调大max_tokens或启用流式输出 |
| 同一数据不同轮次行为不一致 | 滑动窗口丢弃了历史关键信息 | 记录历史消息总数与丢弃日志 | 摘要 + 记录丢弃范围 |
| 数据完整但模型漏看中间段 | 超长上下文注意力分散 | 指纹校验法让模型列 ID | 拆分批次处理,减少单次数据量 |
| 模型自信地“假装”看到全部 | LLM 乐观补全 | Prompt 强制显式截断标记 | 约定遇到不完整信息必须报告 |
5.2 避坑经验:几条用钱换来的教训
第一,永远不要把工具返回直接拼进 Prompt 而不做长度保护。JSON 的格式开销比你想象的大,字段名、缩进、转义符都在吃上下文窗口。我建议所有工具函数统一做一层长度检查,超过阈值就触发分页或截断标记,而不是直接返回。
第二,排查截断问题不要只测头和尾。很多人测试时只检查第 1 条和第 50 条在不在,但中间段丢失才是最难察觉的。用指纹校验法让模型列出它看到的 ID 范围,覆盖头、中、尾三个位置,才能确认完整情况。
第三,中文场景下 token 预估要留 1.5 倍余量。我见过很多次“我算过大概 8000 token,怎么就不够了”的情况——因为中文的分词粒度在不同模型上差异很大。如果你用的是开源模型或本地部署模型,分词器可能和商业 API 不一样,最好用同款分词器实际算一遍。
第四,模型输出里出现“根据你提供的信息”这句话,不代表它真的完整看到了你的数据。LLM 生成这类衔接语非常自然,但它的判断依据可能只是你传入内容的前半段。真正靠得住的,是程序侧的实际留痕和模型对具体 ID 的复述。
第五,finish_reason: length这个字段一定要看。它表示输出是因为达到长度上限而终止,而不是自然结束。如果你在 Agent 链路里忽略这个字段,截断后的输出会直接作为工具结果传回给下一轮 Prompt,相当于把“输出截断”变成了“输入污染”,会形成连锁反应。
5.3 给框架选型的一个建议
在自己搭建 Agent 链路时,优先选择上下文管理策略可以显式配置(比如可以关闭自动裁剪、可以设置工具返回上限开关)的框架。如果框架的上下文管理是“魔法”级别的,即你根本不知道它在什么时候会裁剪、以什么策略裁剪,那后面排查问题的成本会非常高。
这一点在选型阶段很难注意到,因为几乎所有框架的介绍都在讲“自动管理上下文”有多方便。但“自动”这个词在 Agent 场景里,意味着你放弃了对自己输入内容的控制权。我现在更倾向于选择那些允许我显式传递“哪些内容必须保留、哪些可以丢弃”的框架,哪怕配置上多写几行代码也值得。
写在最后
这次排查前前后后花了大半天时间,大部分时间都耗在“我以为我发了 50 条,模型却说只看到 20 条”这个循环里。经历过这次之后,我给自己定下了两个做 Agent 的基本原则,分享给大家。
第一个原则:所有喂给模型的内容,程序侧必须留痕。模型看到什么、看不到什么,日志里必须能复现。不能复现的链路,出了问题你连定位的入口都没有。
第二个原则:模型能说“我不知道”,比它自信地给出一个错误答案有用一万倍。所以我现在做 Agent 项目,一定会用相当篇幅的 Prompt 去鼓励模型“发现信息不完整时主动报告”,并且在程序侧用截断标记、指纹校验等手段配合。
最后一个实用小技巧:在 system Prompt 的最后加一句这样的话——“如果传入的数据带有截断标记,或者你怀疑数据完整性时,请直接说明缺少了什么,不要继续执行任务。”这比调任何参数都管用。它不保证模型每次都会遵守,但只要你配合程序侧的截断标记一起用,大部分静默截断问题都会变成显式报错,你再也不用靠肉眼去比对模型输出和源端数据了。