☰
Agent数据静默截断排查实录:从工具输出到上下文管理的完整避坑指南
2026/11/4 13:12:59 网站建设 项目流程

先说结论:如果你的 Agent 突然“少干活”了,而且日志干净得像什么都没发生过,先把注意力从模型身上移开,去查数据到底有没有原样到达模型。上周我就被这个问题折腾了整整一天——一个客户工单分类 Agent,源端明明查出 50 条待处理工单,模型执行完之后却只针对 20 条做了分类,剩下 30 条像是被人按了删除键,全程零报错、零告警、零异常提示。这类问题在 Agent 开发里有个很贴切的名字:静默截断(silent truncation)。它不报错,不崩溃,只是让你的结果在不知不觉中缩水。这篇文章就把完整的排查过程、根因分析和后续改造方案写出来,希望能帮你少走几小时弯路。

1. 现象:工单悄悄少了三十条,日志却一声不吭

1.1 事故现场:一个分类 Agent 的日常任务

先交代一下背景。我们有个内部系统,每天会有一批客户工单需要按紧急程度分类。早前是让人工在后台一条条看,后来接了个 Agent:程序从工单库查出待处理列表,交给大模型做批量分类,再回写结果。这个链路本身不复杂,架构可以用一句话讲完:

  1. 工单库查询接口返回待处理列表(源端)
  2. Agent 调用一个工具函数把列表交给大模型(模型视野)
  3. 模型对每条工单输出分类结果,程序回写数据库

上线初期跑得挺顺,直到某天下午,回写库里的数据出了问题。我看了一眼统计:待处理工单有 50 条,系统里被分类的只有 20 条。更诡异的是,任务状态显示“执行完成”,没有任何一行失败记录。

这其实就是静默截断最典型的特征:系统从行为上认定自己“做完了”,但做的事情被打过折扣。如果你遇到过程序返回成功但结果量明显不对的情况,大概率也是同一类问题。

1.2 三个早期线索,以及为什么容易被误判成“模型不行”

遇到这种事后第一反应往往是“模型这次怎么偷懒了”。但作为排查过不少 Agent 坑的人,我提醒自己先冷静收集线索。当时手头有三个可疑点:

  • 模型输出的一段话里出现了“根据你提供的 20 条工单,我完成了如下分类”这样的表述,等于它自己承认只看到了 20 条。
  • Agent 日志中模型那一轮的 token 消耗量不大,远小于“读 50 条工单并逐条分类”应有的量。
  • 回写数据库时,程序逻辑是按模型输出的结构化结果逐条写入的,没有循环中断、没有异常报错。

这三条线索放在一起,基本可以排除“模型输出了 50 条分类但回写环节丢了 30 条”的可能性,因为回写的地方我做过校验,写入失败会直接抛异常。问题大概率出在“模型根本没拿到那 30 条数据”。

这里想多说一句:Agent 开发里,数据从源端到模型视野之间要经历很多隐形环节,每个环节都可能对数据做压缩、裁剪、排序、去重。模型只是链条终点,它拿不到的东西,再聪明的模型也没办法处理。所以排查方向应该是“数据完整性”而不是“模型能力”。

2. 顺着数据流逐站排查:源端、工具层、模型入参

确定了方向之后,就得从输出端往输入端一步步倒查。我给自己列了一张表,把从工单库到模型输入之间的所有节点都写了下来,每个节点都单独验证,不跳步。

排查节点检查方法预期结果
源端数据库/接口直接调用原始查询,数返回条数50 条
工具函数返回值在工具函数出口打印长度50 条
Agent 框架的消息组装把最终发给模型的 messages 序列化后落盘应含 50 条
模型实际输入解析落盘内容,数有效工单对象待确认

这张表很重要。很多排查容易在“源端”和“模型”之间跳来跳去,但真相常常藏在中间某一层。逐站验证能帮你快速锁定范围。

2.1 第一站:源端数据是不是真的只有 20 条?

先排除最蠢的可能:源端是不是本身就只有 20 条?我直接连到工单库,手动执行了一遍 Agent 用的那条查询语句,再把结果导到本地数了一下,50 条,一条不少。连字段都仔细看了,没有过滤条件把 30 条“藏起来”。

这一步虽然简单,却非常关键。如果源端就有问题,后面一切排查都是白费。很多线上事故都源于上游数据就已经错了,只是大家默认源端不会错而已。

2.2 第二站:工具本身的返回值是否完整?

源端没问题,那问题会不会出在 Agent 调用的工具函数上?我们在工具函数出口处临时加了一行打印,把返回数据的长度打出来:

tickets = await ticket_repo.fetch_pending(limit=50) logging.info("fetch_pending returned %d tickets", len(tickets)) logger.debug("payload preview: %s", json.dumps(tickets[:3], ensure_ascii=False)[:500])

重启任务后看日志,fetch_pending returned 50 tickets,说明工具函数本身拿到了完整的 50 条。数据从源端到工具这一层,没有问题。

这里也要提个醒:别只看你“以为”会打印日志的地方,要去看真正调用工具的那个 Agent 运行时日志。有些框架会包装工具调用,你写的返回值未必等于传给模型的内容。

2.3 第三站:实际拼进 messages 的内容到底长什么样?

前两站都通过了,接下来的动作是压轴戏:把 Agent 真正发给模型的 messages 结构完整地序列化出来,落到一个 json 文件里,然后用肉眼和脚本双重检查。

我当时的做法是在模型调用入口加了个拦截,所有请求在发出前先复制一份:

def dump_request_payload(messages, tag): with open(f"/tmp/agent_messages_{tag}.json", "w") as f: json.dump(messages, f, ensure_ascii=False, indent=2)

然后跑任务,打开这个 json 文件,直接搜索工单数据的数组。果然,工具返回的内容里只包含 20 个工单对象,而且这 20 个对象排在最前面,后面的 30 条彻底消失。数据被截断的时间点,就发生在“工具函数返回之后”到“消息被送给模型之前”这一段——也就是 Agent 框架的某个中间处理环节。

2.4 定位结论:截断发生在“工具输出→上下文组装”之间

到了这一步,范围已经非常清晰:不是模型的问题,也不是源端的问题,是 Agent 框架在处理工具输出时,对数据做了某种我不知道的裁剪。

回过头看,最初那个“模型说自己只看到 20 条”的线索,其实是救命稻草。很多模型在数据不够时会通过自然语言暴露出来,如果你让 Agent 做的是“纯分类”任务,模型可能根本不会主动说“我漏了 30 条”,它只会默默输出 20 条分类结果。所以这类问题真正可怕的地方不在于排查难,而在于大多数情况下你连察觉都察觉不到。

3. 第一层根因:工具输出被“按条数”静默截断

3.1 框架默认配置里,藏着一条看不见的“条数上限”

找到截断点之后,我开始翻 Agent 框架的配置文档和相关源码。翻了一圈,终于在一个不太起眼的位置发现了问题。

我们用的框架在消息组装阶段,默认会对工具输出做一个“安全裁剪”:目的是防止工具返回数据过大,把模型的上下文窗口撑爆。这个裁剪不是按字符硬切,而是把工具返回的 JSON 数组解析出来,只保留前 N 个元素,然后重新序列化成一个正常数组。N 的默认值恰好是 20。

你可以理解成框架这么设计的初衷是好的:给工具输出设一个保险丝,避免一次调用就把好几万 token 灌进上下文。但问题在于,这个保险丝是默认开启的,而且没有任何地方告诉你“你有一批数据被裁剪了”。我当时配 Agent 时没有留意这个参数,结果就是保险丝默默替你做了决定。

类似的参数很多 Agent 框架都有,只是名字不同,常见的有max_tool_output_items、tool_result_limit、max_result_rows。不管叫什么,本质都是“工具结果只保留前 N 条”。

当时框架里隐藏的默认逻辑,简化后大概长这样:

def limit_tool_output(data: list, max_items: int = 20) -> list: if len(data) <= max_items: return data # 静默截断:只保留前 max_items 个元素 return data[:max_items]

想象一下,一个 50 条工单的数组,到这里咔一下变成 20 条,后面的 30 条直接蒸发,没有任何异常抛出。这就是所谓“静默截断”的典型场景。

3.2 为什么截断做得如此隐蔽:降级策略 + 低级别日志

看到这里你可能会问:框架截断数据,难道不应该打一条 error 或者 warning 吗?

问题就出在日志级别上。我翻了框架源码,发现它在截断时记录的是logger.debug(...),而生产环境的日志级别默认是INFO,debug 信息根本不会输出。也就是说,框架确实“告知”了截断这件事,只是它选择了最低调的方式,而你的日志系统默认把这条信息屏蔽掉了。

这背后其实是一组很现实的设计取舍:

  • 对框架来说,减少上下文容量占用是“正常优化”,不是“异常”,所以错误级别不会很高。
  • 对系统来说,Agent 执行不中断、不抛异常,任务正常结束,所以监控也不会报警。
  • 对你来说,如果你没有主动去验证数据完整性,这条截断就永远不会进入你的视野。

三方各觉得“没出问题”,结果就是问题被悄悄掩盖。这也是我在前文反复强调日志重要性的原因。

3.3 修复动作:显式关闭/提高上限并加 warning 日志

定位到截断逻辑后,修复本身并不复杂。我做了两件事。

第一,把框架配置里的工具输出条数上限从默认的 20 调整到足够大。因为我们的业务模型一次最多处理 50~100 条,把这个值设成 200 就够了。这里注意不要直接关掉限制,万一哪个工具真的返回几十万条数据,没有保险丝会直接把下游打爆。

第二,在框架代码外面包了一层适配器,把所有工具返回值在进入消息组装前先过一遍我的检查函数:

def assert_output_complete(data: list, expected_min: int = 50): if len(data) < expected_min: logging.warning("tool output may be truncated: got %d, expected at least %d", len(data), expected_min) return data

这层检查会在截断发生时至少给你一条 warning 日志,而不是什么都看不到。

改完配置重新跑任务,模型终于看到了完整的 50 条工单,分类结果也一条不少。当时我长舒一口气,觉得问题解决了。但实际上,这件事才做了一半。

4. 第二次回归又踩坑:消息历史管理在入口处裁掉了上下文

4.1 修复后再跑,数据从 20 条变成了“还是不够”

修好第一层截断后,我按惯例做了一次回归测试。这次用的测试数据是 50 条长度中等偏长的工单,每条工单的正文有 200~300 字,加上工具返回的结构化字段,单条工单大约 1500~2000 token。

结果让我愣住了:模型这次承认自己看到的条数确实比 20 条多了,但离 50 条还是有距离,而且它看到的序列顺序也变了——不是从第 1 条开始看,而是从中间的某条开始。数据的“头和尾”都缺了,像有人从中间给你撕了一道口子。

这跟第一次的现象有明显的差异:第一次是“前 20 条完整、后面 30 条消失”,这次是“开头一段没了,结尾一段也没了”。看到这个特征,我马上意识到,出问题的不是工具输出截断,因为工具的日志里清清楚楚写着返回了 50 条。

4.2 为什么这次连工具日志都是完整,模型却还是看不全

问题到了更深处:消息历史管理。

Agent 在执行过程中不止调用一次模型。第一次调用让模型读取工单并输出分类结果,之后你可能还要让模型把结果转成结构化格式、或者对结果做一轮校验,这时候 Agent 会带着前面几轮的 messages 再一次调用模型。框架在组装这第二次请求时,会先检查整个消息列表的 token 总量是否超过模型上下文窗口。如果超过,它不会直接报错,而是按照“保留最近内容,丢弃较早内容”的策略,把消息列表前面的一部分裁掉。

我们的情况就是:50 条工单的 JSON 数组加上系统提示词、用户指令、模型第一轮回复,总 token 数把上下文窗口挤满了。框架为了让第二轮请求能发出去,就把最早塞进去的那批工单内容丢了一部分。

这个机制我再简化一下给你看:

def trim_to_fit(messages, max_context_tokens): total_tokens = sum(count_tokens(m) for m in messages) while total_tokens > max_context_tokens: dropped = messages.pop(0) # 从最早的消息开始丢 total_tokens -= count_tokens(dropped) return messages

这个逻辑最坑的地方在于:它丢消息是一整段一整段丢的,而工具输出往往就是一大段,丢一次就是几十条数据。但丢的时候循环只保证“总 token 数降到窗口内”,至于丢了之后模型还能不能拿到完整信息,它不关心。

翻译成人话就是:你以为你在和模型对话时把所有数据都摆上了桌,结果桌子太小,服务员默默撤走了你的前菜,你没注意,但模型确实没吃到。

4.3 上下文窗口不是无限容器:如何正确地算 token 账

第二层问题的核心,其实是我们对 token 预算的估算太乐观了。

我给当时的场景算了一笔账。模型上下文窗口按 128K token 算,看起来很大对吧?但一套 Agent 的请求会被拆成很多部分占用:

  • 系统提示词和工具描述,约 2K~4K token
  • 用户指令,约 1K token
  • 第一轮的工具输出,50 条工单,约 75K~100K token
  • 第一轮的模型回复,约 2K~5K token
  • 第二轮请求需要附带的历史消息,包含以上全部内容

也就是说,光是第一轮的工具输出就占了窗口的大半。一旦第二轮回调用模型,总 token 数就会非常轻松地顶到上限。这个账如果你不提前算,等上线了迟早会踩雷。

正确做法是在开发阶段就把单次工具输出可能达到的最大 token 数估算出来,和模型窗口预留 30% 余量做对比。用tiktoken之类工具可以精确计算:

import tiktoken enc = tiktoken.encoding_for_model("gpt-4o") tokens_per_ticket = len(enc.encode(one_ticket_text)) max_tickets = 50 estimated_output_tokens = tokens_per_ticket * max_tickets + 2000 # 加上 JSON 包装等开销 print(f"estimated tool output tokens: {estimated_output_tokens}")

如果估算值已经逼近窗口的一半,那就别再把 50 条一次性塞进去了,老老实实分批处理。这不是模型能力问题,这是工程上的资源规划问题。

解决第二层问题,我当时做了两处调整:

  1. 在 Agent 的消息历史管理配置里,把裁剪阈值调得更保守,同时开启了一个“裁剪前告警”的选项,确保每次裁剪都会在日志里留下明确记录。
  2. 对 50 条工单做了分批处理,每批 10 条,跑 5 次独立分类,而不是一次性灌给模型。消息历史的压力瞬间消失,模型每轮都能拿到完整数据。

5. 把“数据完整性”变成可观测:我给项目做的防御性改造

两次截断发生在不同环节,但暴露的是同一个短板:数据在 Agent 链条里是否完整,几乎不可感知。所以我后来在项目里做了一套系统性的防御改造,核心思路是让“数据少了一部分”这件事不再沉默。

5.1 日志分层:截断必须走 warning,绝不进 debug

第一件事是制定日志规范。Agent 链路里凡是发生“数据被裁剪、条目被丢弃、上下文超限被处理”的地方,一律使用warning级别,禁止用debug级别带过。

我自己写了一个统一入口:

def safe_truncate(data, limit, item_name="item"): if len(data) > limit: logging.warning( "truncating %s from %d to %d (origin=%s)", item_name, len(data), limit, TracebackDetails.current() ) return data[:limit]

同时把框架的日志级别在生产环境调到至少INFO,并针对框架代码里已有的 debug 级截断日志做了一层reconfigure,把它们提升为 warning。改完之后,任何一次截断都会出现在日志系统里,再也不可能无声无息。

5.2 对账机制:让模型自报“我看到了哪些数据”

第二个改造是对账。既然模型自己知道它看到了多少数据,那就让它在回答中把它看到的工单 ID 列表一并输出,程序再做一次集合差比较。

我在提示词里加了一句硬性要求:所有分类结果必须以 JSON 数组输出,数组里每个元素必须包含ticket_id字段,并且必须覆盖全部可见工单。模型输出后再用脚本把 ID 列表提出来,和源端 50 个 ID 做差异比对:

source_ids = {t["id"] for t in all_tickets} seen_ids = extract_ticket_ids(model_output_result) missing = source_ids - seen_ids if missing: raise SystemAlert(f"model did not see tickets: {missing}")

这套对账机制上线后一周内就帮我们抓到了两个类似的隐患:一个来自另一个工具的隐式分页,另一个来自第三方 SDK 对返回字段数量做了默认限制。如果没有对账,这两处又会在日志里安静躺很久。

5.3 分批小步走:把截断风险消灭在源头

最后一个改造,是彻底改变“一股脑全塞给模型”的习惯。

现在我们的 Agent 凡是遇到批量数据任务,默认采用分批模式:数据按每批 5~15 条切分,逐批交给模型处理,处理完一批回写一批。这样做的直接好处有两个:单次工具输出 token 量小,不会触发任何上下文裁剪;单次请求时间也短,整体失败率反而更低。

代价是会多几次模型调用、多消耗一点 token,但换来的是数据完整性和可观测性,这笔账非常划算。毕竟一次静默截断导致的数据丢失,修起数据来的人工成本可比多花的几毛钱 token 高多了。

最后再分享一个我在这次排查中学到的小习惯:任何 Agent 任务,只要模型消费的是外部数据,我都会在任务入口加一条“数据量自报”——让模型在回答开头写一句“我看到了 N 条待处理记录”。如果这个 N 和你预期的不一样,不管日志多干净,都值得去查一遍。很多时候,模型比你的日志老实多了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询