调试Agent应用时最折磨人的往往不是模型输出乱码而是一切看起来都很正常结果却是错的。前几天我就撞上了这么一档子事源端接口明明返回了50条业务记录Agent却只分析了前20条。整个流程没有任何报错输出格式标准、语气专业如果不做交叉验证根本发现不了数据在中途被吞了一半。这种Agent静默截断是LLM应用里最阴险的一类故障。报错的问题最好查报错信息会直接告诉你什么环节出了问题可截断不会开口模型只会基于它实际看到的内容老老实实工作。你看到的结果是错的但不知道断在哪一层甚至不知道截断这个行为的存在。这篇博文完整记录一次排查过程从怀疑提示词写得不好到最终定位到一行藏在token预算逻辑里的break再到落地修复方案和预防机制。如果你在做Agent开发、或者正在用大模型构建批量分析类工具这篇文章应该能帮你少踩几个同类坑。1. 现象数据没到模型手里输出却显得一切正常1.1 一个客户反馈分析Agent的诡异结果这次问题出在一个客户反馈批量分析模块上。这个Agent每天从工单系统拉取当日的客户反馈记录交给大模型逐条判断情感倾向、紧急程度和处理建议。正常情况下一天的数据量在50条左右模型返回一个有序的JSON数组每条记录对应一个分析结果上线以来一直很稳。运营第三天突然反馈结果不全。我打开日志一看模型确实正常返回了20条分析结果每条都有理有据。再对比源端数据50条里前20条的分析完全正确后面30条悄无声息地消失。最有迷惑性的一点是模型输出的JSON格式非常完整头尾、结构、字段名全对没有任何输出截断的痕迹。从输出看更像是输入从头就没那30条而不是模型生成了但被截断。1.2 静默截断为什么比显式报错更隐蔽这里的关键不是结果少了几条而是少了结果却不报错。传统软件的故障模型是要么成功要么失败失败一定留下日志和异常。Agent系统不一样LLM完全不知道自己没看到的数据它只会把你给的prompt当作全部真理认认真真基于残缺的输入给出自洽的完整输出。它甚至会天然地脑补出一种合理解释那30条可能本来就不需要分析或者被去重了或者超出统计范围。更麻烦的是这类故障往往出现在提示词工程做得比较到位、模型输出很稳定的阶段。到这个阶段人会对链路产生信任反而不容易怀疑数据被截。很多团队遇到类似情况第一反应是排查业务数据第二反应是往提示词里加惩罚性描述折腾一圈才发现问题根本不在这一层。这种没有异常信号的异常恰恰是排查中最消耗精力的部分。1.3 先排除模型幻觉的干扰排查开始时第一道坎是如何区分模型没看到数据和模型看到了但选择忽略。比如模型可能故意筛选了20条最有代表性的样本也可能在数十个候选中主动跳过了一些或者单纯地因为prompt措辞不当而没有逐条分析。这个区分很重要因为两种情况的修复方向完全不同。为了不掉进改提示词的兔子洞我先回答了一个问题模型看到的prompt里到底有哪些记录这一步直接决定了后续排查的方向。如果模型看到了50条却只输出20条那是提示词问题是模型行为不当如果模型本来就只收到20条那无论怎么优化提示词都没用问题必然在数据流上游。事实证明数据流上游的问题会伪装成提示词问题这也是我把它放在最前面的原因。2. 排查从让模型复述到每一层的数据计数器2.1 第一招让模型复述它真正看到的内容核心思路是回溯输入。既然模型不会主动报告缺失那就主动问它。我在临时调试prompt里加了一行指令请先输出你本次收到的全部工单ID列表再开始分析。结果模型老老实实列出了20个ID正好对应源端前20条记录。这里有个细节值得注意模型列出的ID顺序与源端完全一致说明它的输入是从开头按顺序截取的结果而不是调度乱序。这一步基本确认了模型侧接收到的输入确实只有20条。为了排除偶然性我连续换了好几种措辞重新调试验证结果始终稳定在20条排除了LLM随机漏读的可能性。这个技巧成本低、见效快建议所有Agent排查必用。你不需要看代码不需要打日志只需要让模型自报家门问题就能迅速收窄到输入侧。2.2 绕过Agent直接验证源端数据接下来要把问题从模型侧推到上游。我写了一个独立的小脚本绕过Agent框架直接调用工具函数拉取当天工单数据打印count()。结果显示源端确实返回了50条记录字段完整、类型正确。这一步消除了业务数据库来不及同步的可能性。顺手做的一件事是把50条记录全量序列化成JSON单独测了一下token量大约3000多token。这个数字后来成了定位关键。为什么要绕开Agent做这一步因为Agent框架在工具调用时可能有自己的序列化、缓存、改写逻辑直接在应用界面里看可能被框架层干扰。单独写脚本能保证拿到的是源端真实返回。这也是排查多环节Agent系统的一个通用办法每一层独立验证不让关联环节互相污染判断。很多人习惯在日志里看到什么就信什么但日志本身也可能被上层逻辑篡改过。2.3 给prompt组装链路装计数器确认源端50条、模型只看到20条之后问题范围就收窄到了从工具返回结果到拼接prompt这一段。大多数Agent框架在这里都会有一段业务代码负责把工具结果序列化、截断、拼系统提示词、拼对话历史。不少团队还会自己写一个上下文组装器来控制prompt体量我们的项目也不例外。我在组装层的入口和出口各加了一行日志入口日志记录工具返回了50条出口日志记录实际写入prompt的记录数。跑了一次之后出口日志显示20条。问题区间被锁定在这段代码里。接下来我打印了组装器中的一个中间变量这个变量保存的是待写入的记录列表。日志确认它被操作后只剩20条问题集中在那段列表处理逻辑上。2.4 元凶现身写在token预算逻辑里的break翻开代码真相其实非常朴素。这段代码的目标是控制prompt总token数不超过3000做法是逐条预估每条记录的token成本超出预算就break跳出循环然后把已经收集到的记录拼接进prompt。MAX_TOKENS 3000 def assemble_context(tool_results): used_tokens estimate_tokens(SYSTEM_PROMPT TASK_INSTRUCTION) kept [] for item in tool_results: # 源端实际有50条 cost estimate_tokens(json.dumps(item, ensure_asciiFalse)) if used_tokens cost MAX_TOKENS: break # 问题所在只有break没有日志没有标记 kept.append(item) used_tokens cost return build_prompt(kept)为什么源端50条、理论上限如果设40条实际却只进20条原因在于前20条记录是若干条很长的文本反馈内容信息量大token占比高实际到第21条时就超过了3000 token预算循环直接break。后面的30条连被截断的资格都没有它们只是从未被读取。更致命的是这段代码从始至终没有记录我跳过了哪些记录也没有往prompt里写任何与截断相关的提示。模型看到的prompt开头写着以下为全部客户反馈共50条实际却只附了20条。指令与数据不一致正是模型输出看起来完整、实则缺页的根本原因。3. 根因token预算背后的三个隐藏陷阱3.1 条数限制和token限制不是一回事很多团队在做上下文控制时喜欢在主逻辑里用最多保留N条这种直观条件同时在更外层用token预算兜底。两种限制混在一起很容易出现表面上允许40条、实际只够20条的错位。我们这个项目早期原型里确实有最多40条的常量后来改成纯token预算但这个40的预设还留在很多人的记忆里导致一开始对不上账。这条线索也说明了一个工程问题估算token的精度直接影响截断位置。estimate_tokens通常是根据字符数或经验除数粗略推算的中文字符、英文单词、标点、JSON转义字符各有不同的真实token占比。如果估算偏低真实长度就会提前触顶。尤其当system prompt和少量历史消息已经占掉500 token剩余预算被进一步压缩时实际可容纳的记录数会比想象中少很多。3.2 截断逻辑的位置决定了故障的隐蔽程度这次案例中最值得反思的是截断动作发生在工具结果到prompt组装这层但始终没有把截断这个事实传递给LLM。正确的做法有两种要么在截断发生时主动丢弃并重写说明要么干脆不做截断用分片方式保留完整数据。任何截断都应该是一种显式的、可观测的、带标记的事件。可实际代码里它只是一个安静的break连warning日志都没有。对比来看如果截断发生在工具返回阶段并且工具返回结构里带有一个truncated字段模型至少可以通过meta数据意识到数据不完整。可一旦截断发生在prompt组装阶段模型看到的唯一信息就是这里只有20条记录它无法感知应该还有30条。这是静默二字真正的含义截断行为本身没有任何痕迹也没有为下游留下任何解释。3.3 滑动窗口与上下文缓冲区的叠加影响在稍微复杂的Agent系统里还有另一种加速静默截断的机制——滑动窗口。不少框架为了维持长期记忆和对话连续性会把多轮对话历史塞进固定大小的上下文缓冲区超出部分被直接丢弃。假设模型调用时的上下文已经被历史对话加system prompt加任务说明占掉相当空间留给工具返回的记录名额就会所剩无几。工具明明返回了50条进LLM之前又被缓冲区的淘汰策略再挤掉一波。这类叠加问题在Agent加滑动窗口加多轮工具调用场景里特别明显。每次调用工具后返回结果都会写回上下文缓冲区下一次调用LLM时缓冲区里还残留上次的工具输出。如果调用轮次稍多前面轮次的数据会占据大部分预算导致当前轮次的工具返回被严重压缩。所以排查这类故障时不仅要看单环节还要观察整体缓冲区的占用情况。3.4 设计根因成本优化与正确性没有握手继续往下挖这类bug的本质其实是工程目标的冲突为了节省token成本加了预算限制为了结果正确期望数据完整。两个目标在没有足够约束的情况下碰撞就会产生这种便宜但有损的行为。作为开发者我们不能取消成本控制但可以把有损变成有明确代价且有明确信号的决策。我后来把token预算细分成两档硬预算和软预算。硬预算指单次模型调用必须保证不超过的上限软预算是一个弹性区间。当内容逼近软预算时系统不是默默break而是主动降级到分片处理或者返回本次输入不完整需要二次调用的标记。把决策权交还给调用链而不是让底层静默处理。这是这次事件给我最大的教训。4. 修复把默默丢弃改成分批递送4.1 方案一分批分析覆盖最广既然场景是以下所有记录都要分析第一反应自然是改成每次只喂一批分批分析再合并。经过验证这个思路对逐条输出结论的批量分析任务非常适用。核心流程是保留源端返回的完整列表按token预算自动切分成若干批每批单独构造prompt批次内标注当前是第几批、共几批、总记录数多少然后调用模型得到局部结果最后按原顺序合并。def analyze_in_batches(items, max_tokens2500): total len(items) batches split_by_token_budget(items, max_tokens) merged [] for idx, batch in enumerate(batches, start1): prompt ( f本次需要分析的记录共{total}条 f当前处理第{idx}/{len(batches)}批本批{len(batch)}条。 serialize(batch) ) result call_model(prompt) merged.extend(result) return merged这个方案的代价是模型调用次数从1次变成k次延迟明显增加但换来的是数据零丢失和每个批次内上下文更紧凑。对准确性敏感的任务这点成本完全值得。实际运行时50条记录通常被切成2到3批总耗时从原来的8秒增加到20秒左右但对分析结果来说完整比快重要得多。4.2 方案二先统计后分析适合聚合类场景如果50条记录不是每条都需要逐条分析而是需要整体趋势判断比如今天的满意度如何投诉集中在哪些产品就可以采用另一种思路先让模型对全量记录做结构化压缩再基于压缩结果分析。第一步让模型输出每条反馈的关键实体、情感标签和内容摘要到一张表里第二步把压缩表作为prompt输入。50条记录的总token从3000多降到几百token预算截断问题自然消失。这种方法的问题在于压缩摘要会丢失部分细粒度信息。如果下游任务对细节敏感比如需要引用原文中的具体表述就应结合检索按需取原文而不是只依赖一步压缩。本质上这是一种用信息密度换空间的策略适合统计派任务不适合逐条精读派任务。4.3 方案三把数据留在工具侧让模型按需取用更彻底的设计是改变数据夹带模式不要在prompt里塞全量记录而是把数据作为工具参数开放给模型。工具提供两个接口一个list返回ID列表和基础元信息比如每条记录的长度、标题、时间另一个get(id)返回某条记录的完整内容。模型第一次调用list时能感知总共有50条记录知道它们的分布特征再按需调用get逐条拉取。这种按需取数模式对摘要型分析尤其有效模型不会一上来就背着一大堆文本推理上下文预算的压力会小很多。缺点是对逐条分析型任务不友好如果50条都要看模型就要调用50次get加上中间的消息往返延迟会非常高。它更像一种架构思路适合从根源上规避上下文膨胀的项目。4.4 修复后的验证策略修复完成后不能只看一次结果就上线需要做几轮验证。第一轮是数字核对源端50条最终合并结果包含50条逐一比对ID确保无遗漏、无重复。第二轮是分批正确性检查每批prompt内部标注的批次信息与实际划分是否一致确保边界不丢数据。第三轮是随机抽查从源端尾部随机抽3条记录验证它们在合并结果中存在且分析内容与该记录的真实文本一致。第四轮是回归测试连续运行几天每天检查输出条数与源端条数差值是否为0。我这里做了一次完整的脚本回归把合并后的结果条数与源端逐条比对连续7天没有出现一条丢失。确认稳定后才重新上线。这个过程看着繁琐但能避免修复了一个问题又引入另一个问题的尴尬。5. 预防让Agent自带计数意识5.1 工具返回协议统一加完整性字段经历过这次事故后我给自己所有Agent相关工具返回定了两条规矩返回结构必须带meta描述块与数据相关的meta至少包含total、returned、truncated三个字段。工具层在返回数据前做一次自我检查并在meta中如实填写。{ meta: { total: 50, returned: 50, truncated: false, reason: }, items: [] }这样一来无论后续prompt组装层怎么操作至少有一次机会在数据源头感知全量还是截断。如果某个环节做了截断处理它必须同步修改meta更新truncated信息和reason。这个约定让一切截断行为显式化为排查和自动化告警留出明确入口。修改工具协议会有一定工作量但比起每次靠猜来处理这种问题收益明显更高。5.2 在prompt中强制声明数量匹配要求还有一个很便宜但很有效的招是在系统提示词里写清楚数量匹配检查规则。我在Agent里加了一段约束当你接收到结构化数据时先检查数据中声明的数量与实际条目数是否一致若不一致请输出数据完整性警告作为第一条信息并说明缺失情况。这次事故里如果当时就有这句话模型一开始就会发现宣称50条但实际只有20条哪怕组装层没有修正模型也会自己提出疑问而不是默默分析完20条了事。让模型主动感知数据完整性等于给链路加了一层免费守护。它不解决根本问题但能把静默变成出声给后续处理赢得时间。5.3 用结构化日志建立数据流观测排查这类问题最痛苦的就是不能从日志里看到每一层的输入输出数量。现在我要求所有上下文组装环节都打印结构化日志至少包含阶段名称、条目数、token估算值、是否触发降级策略。{ stage: prompt_assembler, event: assemble, input_items: 50, output_items: 50, input_tokens: 3400, output_tokens: 2800, degradation: false }有了这些日志再出现数据异常只需要在每个阶段做一次count()对比几分钟内就能锁定是哪一层吞了数据不用再像这次一样一层层写临时脚本探查。日志的价值不在于记录而在于对比。光记输出20条没用必须同时记输入50条差值才是故障信号。5.4 最后分享一个兜底技巧我一直建议Agent开发者在pipeline最终组装处无条件加上一行记录计数器。不管前面有多少逻辑组装完prompt之后紧跟着往里面追加一句本次输入共包含N条记录请先确认你是否完整读取了所有记录再进行下一步分析。这句成本极低但对排查静默截断极其有效。它相当于把数据条数这个信息硬编码进上下文让LLM在不一致时自己开口说话。我试过之后从原来的事后翻日志变成模型当场指出问题排查效率提升了一个量级。这种技巧放在任何Agent框架里都能用不受具体技术栈限制。这个方向的长期改进其实也清晰了把数据完整性断言沉淀为Agent框架的通用组件任何接入的工具结果都经过完整性检查不匹配时自动告警并阻塞下一步动作。让Agent系统从架构层面默认不接受静默亏损这比任何事后排查技巧都更根本。