ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Agent静默截断排查:源端50条数据为何模型只看到20条

Agent静默截断排查:源端50条数据为何模型只看到20条

做 Agent 的同学应该都有过这种经历:你明明在源端准备好了 50 条候选数据,模型回答里却只体现出了 20 条的信息。它不报错、不警告、不告诉你少了一半,就静悄悄地用剩下那部分内容给你交差。我第一次遇到这个问题是在调一个检索增强型 Agent 的时候,当时改完召回逻辑上线,线上反馈突然变差,我第一反应是召回排序出了问题,结果查了半天,问题根本不在召回,而在 prompt 组装阶段就被框架悄悄截断了。这种不响不叫、把数据偷摸丢掉的情况,就是典型的 Agent 静默截断。

这个坑最近在社群里被反复问到,很多做 Agent 开发的朋友都遇到过类似场景:工具返回 50 条记录,模型报告里只能数出 20 条;RAG 召回 30 个片段,模型回答时只引用了其中 8 个。多数人会在检索、排序、Embedding 这些环节反复折腾,最后才发现是模型上下文入口那一层出了问题。这篇文章就以"源端 50 条、模型看到 20 条"这次完整排查为例,把静默截断的成因、排查思路、定位技巧和底层原理全部拆开讲。不光是写结论,更会把我在这次排查里踩过的坑和验证过的方法一起放出来,给正在做 Agent 开发的你做个参考。

1. 现象先行:50 条进、20 条出,到底怪谁

1.1 这类事故的共同画风

Agent 静默截断最迷惑人的地方在于"一切都还正常"。你问它问题,它能答;你给它任务,它能跑;你检查日志,请求成功、响应成功、状态码 200。可一旦让它用到那 50 条完整数据,它就像得了选择性失忆,只拿其中 20 条当作上下文。

我当时遇到的场景是这样的:一个文档问答 Agent,源端从数据库里查出了 50 条相关片段,每条片段大概 100 到 200 个字符。我预期模型可以根据这 50 条片段做归纳,但实际测试时让它"列出你看到的所有片段标题",它只列出来 20 个,而且顺序变了。模型并不知道自己被截断,它只会基于自己实际看到的内容去回答。所以这种问题从"模型输出质量差"入手排查,往往会被带偏。

这类事故有几个共同画风:第一,数据在某个中间环节被丢弃,但没有任何日志提示;第二,业务层没有对消息条数做过校验,丢失无感知;第三,模型回答本身有一定的自洽性,不仔细对比源端数据根本发现不了。我后来复盘,发现这类问题在 Agent 项目里出现的概率远比想象中高,只要你的上下文组装逻辑里存在任何"数量限制"或"token 预算"配置,就有可能出现静默丢数据。

1.2 为什么"静默"比"报错"更危险

我们做排查时最怕的不是报错,而是不报错。报错至少给了你线索,状态码、错误信息、堆栈,照着查就行。静默截断则相当于有人在快递分拣中心把你的 50 件包裹扔掉了 30 件,还会重新封箱,收货人收到的依旧是一个完整箱子,只是里面重量不对。

有一个容易被忽略的点:LLM 服务商返回的错误通常长这样,"=== error report ===",里面有 message、error code 这些字段,Agent 框架也会把显式终止错误(比如 "agent execution terminated due to error")抛出来。但上下文超出限制时,很多模型服务商并不会报错,而是直接按自己的截断策略处理,或者在返回的 usage 字段里让你看到"prompt_tokens 被计了多少",但不会告诉你哪些内容没被送进去。

我用一句话总结静默截断的危险性:它把"数据缺失"伪装成了"模型能力不足"。一旦你误判方向,会花大量时间去调 prompt、换模型、改温度参数,结果全部无效。所以排查这件事的核心思路只有一条——不要盯着模型的输出去猜,要顺着数据流从源头到模型逐段验证,找到真正丢数据的那个节点。

1.3 排查前的心理建设与思路主线

如果你现在也遇到类似问题,先别急着改代码。我建议先想清楚三件事:数据是在哪一层丢的?是哪一行配置或代码导致的?为什么这个环节选择了"丢数据"而不是"报错误"?

对应到排查主线,就是一条从源端到模型的链路审计:源端查询结果 → 检索/召回层 → prompt 组装层 → 模型请求体 → 模型响应。每一层都需要记录两样东西:数据条数和数据体积(字符数或 token 数)。不要把"条数一致"当作没问题的证据,还要对比"内容是否完整"。因为有些截断不是整条丢,而是每条内容被切断尾部,这种情况条数不变但信息量已经缩水了。

2. 先摸清作案手法:静默截断到底有几种

2.1 上下文窗口硬切:最直白的断头台

第一种最常见的情况就是上下文窗口(context window)上限。现在的模型虽然把窗口从 4K 扩到了 128K、200K,但窗口再大也有上限。Agent 每轮对话会把 system prompt、历史消息、工具返回结果、当前用户问题全拼在一起,一旦总 token 数超过模型窗口,框架或服务端就必须做出取舍。

取舍策略大部分是"保头弃尾"或"保尾弃头"。以我遇到的情况来说,50 条片段拼进去之后总 token 数已经贴近窗口上限,框架默认策略是保留最近的消息、丢弃较早的片段。所以模型看到的内容是"当前用户问题 + 最后拼接的 20 条片段",前面的 30 条被悄悄扔掉。你从模型的角度看,它确实只看到了 20 条,而且它自己毫不知情。

这种截断的显著特征是:被丢弃的数据往往是 prompt 中比较靠前的部分。如果你发现模型对后面的内容很熟悉,对前面的内容毫无反应,基本可以锁定这种硬切截断。

2.2 Token 计算器与字符数混淆

第二种情况出在配置层,而且特别坑。很多 Agent 框架在拼接消息前会做一次"预算检查",判断内容是否超出限制。问题在于这些框架用的计量单位到底是 token 还是字符,很多人根本没确认过。

字符和 token 的换算关系不是固定的。中文场景下,一个汉字大约等于 1 到 2 个 token;英文大概 4 个字符等于 1 个 token;JSON 格式里光引号、括号、逗号这些符号就会额外消耗大量 token。你用"字符数不超过 10000"去配置一个 token 上限为 8000 的模型,结果实际换算下来 token 数已经到 12000,就会触发截断逻辑。

这个坑的隐蔽之处在于框架不会主动报"计量单位不匹配",它只会默默执行截断策略。我那次排查时,发现配置参数写的明明是"max_context_length = 20000",以为很宽裕,后来查了框架源码才发现它这里是把字符数当作 token 数去匹配的,实际换算后早就超限了。

2.3 max_tokens 输出截断的兄弟问题

严格来说,max_tokens 截断截的是"输出"而不是"输入",但它对排查的干扰程度一点都不低。当模型开始生成回答,如果设置了 max_tokens 但设置过小,生成到一半就被强制停止,这种情况通常会在返回结果的 finish_reason 字段里标成 "length"。

问题在于很多 Agent 框架和业务代码根本不看 finish_reason。它拿到一段不完整的回答,照样当成功结果返回给上层。于是你会看到一个更诡异的现象:50 条源端数据只看到 20 条,而且模型回答连这 20 条都没归纳完,写到一半就断了。我第一次排查时,把输出截断和输入截断混在了一起,白折腾了一个多小时。后来把 finish_reason 打出来看,才意识到是两类问题叠加。所以排查时一定要先把"输入截断"和"输出截断"分开确认。

2.4 Agent 框架内部的轮次与工具返回截断

第三种情况藏在 Agent 框架的逻辑里。工具调用返回的内容是动态拼接的,框架出于节省 token、防止超长的考虑,往往会有一层"消息长度限制"或"历史轮次限制"。例如只保留最近 N 轮消息、工具结果只截取前 M 个字符,这些都是静默截断的高发区。

我记得有次看某个开源 Agent 框架的默认配置,它对 tool result 的默认限制是 2000 个字符。一个返回 5000 字符的 JSON 数组传进去,后面 3000 字符直接被框架切断,而 Agent 以为自己拿到了完整结果。这就是"源端数据明明有,模型看到的却是残缺版"的最典型案例。工具结果截断比上下文硬切更难发现,因为条数可能没变,每条内容却被砍了一截。

对付这种问题必须读一下你正在用的 Agent 框架的源码,把 message 历史长度、tool result 长度、上下文预算这几个参数的默认值查清楚。一般都在 configuration 或 constants 文件里,花十分钟翻一翻,能省下后面几个小时的排查时间。

2.5 检索 Top-K 软截断:披着"设计"外衣的数据消失

最后一种我要特别提一下:检索层的 Top-K 截断。它不叫"截断",在系统设计里叫"召回数量限制"。RAG 场景下,索引里明明有 50 条相关片段,检索器默认只取 Top 20 返回,那么后端的模型自然只看到 20 条。

这种"截断"可能是刻意设计的,也可能纯粹是配置遗漏。我就见过有人把向量库的 top_k 参数写死为 20,忘记调大,然后四处排查为什么模型用不到剩下的数据。如果你排查到检索层,一定要先问一个问题:源端返回 50 条是哪个环节的数据?是数据库查出来的 50 条,还是检索器过滤后的 50 条?这两个数字含义完全不同。数据流的每一个"节点输出"都要单独打日志,才能对得上账。

我把这几类截断整理成了一张对照表,排查时可以快速定位怀疑方向:

截断类型典型表现排查入口判断方法
上下文窗口硬切靠前的数据全没了prompt 组装层打印完整请求体,数消息条数
token/字符混淆配置值没超但实际超了框架配置与 tokenizer用真实 tokenizer 换算请求体
max_tokens 输出截断回答写到一半就停模型返回的 finish_reason检查是否等于 length
框架内部轮次/工具截断条数不变但每条内容残缺Agent 框架源码与配置对比工具原始返回与拼接后内容
检索 Top-K 截断源端候选集大于模型所见检索/召回层代码对比数据库查询结果与检索器输出

3. 实操过程:从源端到模型,逐环节打点验证

3.1 先复现,再冻结现场

排查静默截断的第一步不是看代码,而是先复现。如果问题不能稳定复现,后面所有定位都可能是白费。我当时做的第一件事是把线上出问题的那个用户输入缓存下来,然后用完全相同的输入、相同的数据源、相同的模型参数去重放 bug。连续压测三次都能复现,才敢确定这不是偶发问题。

复现出来后,立刻做一次"现场冻结":把当时的源端数据、检索结果、组装后的 prompt、模型原始响应全部导出来存好。这一步很重要,因为后面每做一次改动,都需要拿这个固定样本来验证。没有固定样本,你无法判断到底是改好了还是数据本身变了。我还建议把 temperature 和随机参数固定住,否则模型输出本身的随机性会干扰判断。

3.2 三个关键环节:入口、出口和"嘴替"

我的排查习惯是在数据流三个关键位置埋点:入口打点、出口打点、模型响应打点。

先说入口打点。在源端查询函数返回结果之后,立即记录数据条数和总字符数。比如查出 50 条、累计 9000 字符,这就是源头基线。

再说出口打点。在组装完 prompt、发请求之前,记录最终 messages 结构里每个 role 的消息数和总字符数。这一步如果能做,直接就能发现组装层有没有把 50 条变成 20 条。

最后是模型响应打点。记录返回结果里的 finish_reason、usage 里的 prompt_tokens 和 completion_tokens。这三个字段能帮你判断模型到底"接收了多少输入"、"输出有没有被切断"。

实际操作时,不需要开发很复杂的链路追踪,在关键函数里加三五行日志就够了。我当时写了个简单的 Python 脚本来做检查:

def count_messages(messages): total_chars = 0 role_count = {} for msg in messages: role = msg.get("role", "unknown") role_count[role] = role_count.get(role, 0) + 1 content = msg.get("content", "") if isinstance(content, list): for block in content: text = block.get("text", "") total_chars += len(text) else: total_chars += len(str(content)) return role_count, total_chars # 调用点示例 role_count, total_chars = count_messages(built_messages) print(f"debug: role_count={role_count}, total_chars={total_chars}")

把这行 debug 输出加在请求发送之前,你会立刻看到组装后的消息分布。这个方法工具量小、见效快,是我现在所有 Agent 项目的标准配置。

3.3 用"简化二进制法"快速缩小丢失区间

如果不想一开始就翻源码,我推荐一个非常实用的排查方法:把 50 条数据分成几组做递减对比。它有点像二分查找的思路,我这里叫它"数据区间二分法"。

第一次请求只放前 5 条,看模型是不是能完整看到 5 条;第二次放 10 条、第三次放 15 条,逐步加量。每次让模型回答"你看到几条",同时人工检查源端条数。你会发现有一个临界点,跨过它之后模型看到的条数不再增长。我当时的测试结果是:20 条时全看到,25 条时只看到 20 条。这说明截断阈值在 20 到 25 条之间。

锁定区间后再看这 25 条到底占了多少 token,就能和一个具体的 token 阈值对上账。这个方法最大的优点是不需要借助额外工具,用最原始的方式就能把问题范围缩小一大半。缺点是步骤稍慢,适合复现稳定的场景。

3.4 最关键的一步:在请求体里抓到"现场"

有时候框架的封装很重,日志打了半天看不到真实请求。这时候不要犹豫,直接在请求发出那一刻截获请求体。最简单的方式是用 curl 手动重放同一个请求,把完整 messages 打印到本地文件。

我那次的做法是把组装后的 messages 序列化成 JSON 存到文件,然后数里面的消息条数。结果发现实际发出去的 prompt 里确实只有 20 条片段,另外 30 条在组装阶段就没了。这个"现场"直接锁定了问题层,后面再去查框架配置就很快了。

如果你用的是某个云厂商的模型,并且带了代理网关,还可以在网关层记录请求体和响应体。网关是你和模型服务商之间的最后一个观察点,在这里能看到最真实的进出流量。有的模型服务商返回的 usage 字段里 prompt_tokens 会大于实际内容量,这时你还需要结合"模型复述结果"来验证——让模型用自己的话列出它看到的内容,这是最终确认手段。

4. 根因定位与解决:80% 是没算清 token 账

4.1 把原始 50 条数据换算成一个数字

到了根因定位阶段,就要开始算账了。把源端 50 条片段全部拿出来,统计总 token 数。我当时的 50 条片段,每条大概 150 到 220 字符,总计约 9500 字符,用对应模型 tokenizer 换算后大约是 5200 个 token。再加上 system prompt 的 1200 token、工具定义和用户问题的 600 token,整个请求体接近 7000 token。

当时我以为用的模型 context window 是 8000,应该能装下,但忽略了一个关键点:窗口里还要预留输出空间。如果 max_tokens 设置成了 2000,实际可用于输入的上限就是 8000 - 2000 = 6000。7000 token 的输入直接超限,触发截断也就在所难免了。

很多人看到这里会问:那为什么框架不报错?因为模型服务商把"输入超过限制"处理成了一种可选行为,有的服务商直接报错,有的服务商选择丢弃超出的部分。而 Agent 框架更多是"宁可截断也不把请求打断",它默认丢数据比报错更体面。这就是静默截断的根源——系统设计者把"数据完整性"让位给了"请求可用性"。

4.2 检查框架的超参默认值

算完账以后,我直接去翻了正在用的 Agent 框架的源码,找到上下文拼接那一段。果然,框架在组装 prompt 之前会检查总 token 数,超过设定的 max_context_tokens 就会截取消息列表尾部的内容。问题里恰恰有个很隐蔽的参数:max_context_tokens 实际上换算错了单位,它按字符数计算的,而模型服务商按 token 数计算。

这里要提醒所有做 Agent 开发的朋友:框架默认值不等于合理值。任何一个 agent 框架给你提供的默认参数,都需要结合你的真实场景重新校准。常见的需要重点关注的有几个:message history 长度限制、tool result 长度限制、context window 上限、max_tokens。把这些参数统一改成"按 token 计量",并且预留 20% 的安全余量,能避开绝大多数静默截断问题。

4.3 解决方案复盘:配置 + 代码 + 观测三件套

修这个问题我从三个层面同时下手,缺一不可。

配置层面:把框架的 max_context_tokens 改成 6000,同时将 max_tokens 从 2000 调到 1500,给输入留出更多空间。再把工具返回结果的单条长度限制从 2000 字符提上去,确保单条 JSON 不被砍断。

代码层面:在源端查询后和 prompt 组装前各加一道显示校验。如果预计算 token 数超过输入预算,优先触发"显式截断逻辑",并返回截断提示给上层,而不是让框架内部悄悄丢弃。这样至少保证"丢数据"这件事变成"被记录的事件"。

观测层面:项目里加了 metrics 上报,每次请求记录 messages 总数、总 token 估算值、实际 usage.prompt_tokens。设置告警规则,当"源端条数"和"请求体条数"不一致时直接告警。这条规则不一定能立刻抓到所有问题,但至少让静默截断失去了"静默"这个属性。

整个修复上线后的验证也很关键。我重新用最初的固定样本重放,模型终于能数出 50 条片段了。再让模型复述每条片段的标题,50 条全部对上。从"只能说出 20 条"到"完整 50 条",问题彻底解决。

5. 常见问题速查表与避坑清单

5.1 排查口径速查表

在实际排查过程中,建议按下面这张表逐项对照,可以覆盖 90% 的 Agent 静默截断场景:

症状优先怀疑环节快速验证方法解决方案
模型看不到靠前数据prompt 组装层打印请求体,对比消息顺序调整上下文截断策略为"保留头部"或扩充预算
数据条数没变但每条内容残缺工具返回截断对比原始工具结果与框架内对象调大 tool result 长度限制
回答写到一半就停max_tokens检查 finish_reason 是否等于 length增大输出预算或精简 prompt
模型复述内容比源端少多个环节都可能逐级打点,确认哪一层开始少按上文三件套方式修复
检索结果多但模型用的少检索 Top-K 配置查检索器配置参数调大 top_k 或改为动态设置

这张表的核心是:不要猜测,用日志和字段数据说话。尤其是 finish_reason、usage.prompt_tokens、role_count 这三个字段,建议在日志里固定输出。

5.2 日常开发中的防静默截断规范

踩过这次坑之后,我把几条经验固化成了自己项目的开发规范,也分享给你参考。

第一条:所有 Agent 项目的请求发送前,必须记录"输入消息条数 + 估算 token 数"。这两条数据能保证出问题时,你至少知道模型入口看到的是什么。

第二条:不要相信字符数,所有上下文预算一律按 token 算。项目里保留一个统一的 tokenizer 工具类,所有预估都走同一个换算逻辑,避免不同模块各算各的。

第三条:对模型返回的 usage 字段做对账。理想情况下,估算 token 和实际 usage.prompt_tokens 的偏差应该在 10% 以内。如果偏差超过 30%,说明某个环节的计量方式不对,需要检查。

第四条:截断必须是显式的。任何框架配置的截断行为都要能通过日志看到,最好有专门的告警。走一个原则:允许丢数据,但不能"静默"地丢。

第五条:凡是在 prompt 里用了列表序号、编号、标题这类内容,建议做一个"完整性自检"提示。让模型在回答时引用编号而不是笼统概括,能显著提高截断问题的暴露率。

我在这次排查后还在项目里加了一个小功能:把源端数据条数作为一条独立 metric 上报,和请求体条数一同展示在监控面板上。有没有问题一眼就能看出来,不用等用户反馈"模型回答变差了"才发现。

结尾:一点实在体会

这次排查到最后一共花了大半天时间,真正定位到根因只用了不到一小时,剩下时间都在"检查检索层"和"怀疑模型能力"上兜圈子。如果把排查顺序反过来,先看数据流和请求体,可能 20 分钟就能锁死问题。现在我养成了一个习惯:任何 Agent 项目上线前,先跑一次"数据完整性冒烟测试",让模型主动汇报自己看到了多少条、哪些编号,用最笨的方式验证最基础的事实。这个习惯帮我避开了很多潜在的数据丢失事故。

最后再分享一个小技巧:排查静默截断时,不妨把问题交给模型本身。你可以构造一个请求,明确告诉模型"请完整列出你收到的所有条目编号,不要省略任何一项",拿它的输出和你预期对比。模型不会同情你的数据集,但它至少能诚实地告诉你它看到了什么。信息时代,数据和模型之间的每一次交接,都值得多留一双眼睛。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进