如果你的 Agent 项目里,源端明明有 50 条数据,最后模型却只看到了其中的 20 条,而且整个过程一点报错都没有,你会怎么排查?我前两天就撞上了这个典型的静默截断问题,硬着头皮从日志一路翻到框架源码,最后发现坑比想象中深得多。这期内容不绕弯子,直接把排查链路、根因定位和防复发方案都摊开讲清楚。
先说结论:这类问题八成不是模型“变傻”,而是 Agent 框架在组装请求之前就已经把数据偷偷裁剪掉了。你看到的现象是模型输出质量下降、漏掉关键数据,但真正动手的却是上下文管理模块。
1. 现象复盘:源端 50 条,模型只看见 20 条
1.1 一次本来很常规的 Agent 任务
当时我在调一个数据分析型 Agent,逻辑非常简单:Agent 先从业务库读取最近产生的一批工单记录,然后把记录拼进 prompt,让大模型帮忙做分类和摘要。源端查询结果我手动数过,整整 50 条,每条都有编号、负责人、状态、描述,结构化程度很高。
第一批测试结果出来,模型对前 20 条的处理完全正确,可从第 21 条开始就像完全没见过一样。比如第 25 条明明有一条“接口超时”的异常描述,模型却在摘要里写“近期无异常”;第 40 条的状态是“待复审”,模型给出的分类是“已关闭”。刚开始我以为是大模型偷懒,把输出结果对齐到源数据一查,整个人就不好了。
然后我去翻 Agent 侧打印的请求日志。日志里清清楚楚记录着发给模型的 prompt 上屏内容——只有 20 条工单,第 21 到第 50 条消失得无影无踪。源端有 50 条,日志里只剩 20 条,这就说明截断根本没发生在模型推理阶段,而是早在我们把数据交给模型之前就发生了。
1.2 “静默”这个词刺在哪
我之所以要把这起事故定义成“静默截断”,是因为整个链路没有任何一个人告诉我数据少了:
- Agent 进程没有抛异常,没有 timeout,没有 ValueError;
- 调用模型接口时 HTTP 状态码正常,token 数量也没超过模型上下文窗口;
- 日志打印的 prompt 看起来结构完整,如果不去数记录条数,根本看不出缺了什么;
- 模型输出结果也“像模像样”,只是对缺失的数据视而不见。
换句话说,这个故障不会让系统崩掉,只会让系统在某个特定输入规模下“悄悄变蠢”。这类问题最可怕的地方在于:它不打断你的开发节奏,而是污染你的业务结果。如果你没有建立在源端、Agent 层、模型层之间的计数对比机制,它甚至能带着错误结论上线跑一周都没人发现。
2. 从日志到源码:把截断点一个个揪出来
2.1 第一步先怀疑日志欺骗
排查这类问题,我的建议永远是:先不要急着怀疑模型,先确认你看到的日志是“事实”还是“加工的成品”。
有些日志框架打印长文本时会默认截断,比如 Python 的 logging 对超长字符串显示省略号,又比如某些 Agent 框架为了降低日志体积,只记录消息的前 N 个字符。我一开始就担心是不是日志系统“表演”给我看了一份不完整的 prompt。
验证方法很简单:在 Agent 发送请求的必经之路上挂一个独立的监控探针,用tcpdump或者直接写个中间件把出站请求体原样保存。我用的是这样一段最小化代码:
import json from openai import OpenAI def send_with_dump(client, messages, dump_path="request_dump.jsonl"): with open(dump_path, "a", encoding="utf-8") as f: f.write(json.dumps({"message_count": len(messages), "messages": messages}, ensure_ascii=False) + "\n") return client.chat.completions.create(model="gpt-4o", messages=messages)我在真实请求打出前做了一次len(messages)和“所有 content 字段里工单编号的个数”统计。结果非常明确:发出去的 messages 里就只有 20 条工单记录。日志没有骗我,它是如实记录了 Agent 层已经裁剪后的状态。
这一步最核心的收获是:日志里看到的 prompt 内容,代表的是“Agent 组装完的真实请求”,而不是“业务源端原始数据”。要排查静默截断,必须从源端开始逐层核对,不能只看离模型最近的那一层。
2.2 翻 Agent 框架的消息组装流程
确认日志没问题之后,我把目标转向了 Agent 框架的消息组装流程。
你手上如果是自己写的 Agent,那排查起来相对可控,顺着build_prompt()一步步看就行。但很多项目用的是现成框架,比如 LangChain、LlamaIndex 或者自己封装的一层AgentPipeline,这种时候就不能只看业务代码了,得往框架的 memory、context、history 模块里钻。
我当时的调用链是这样的:
- 业务脚本拿到数据库查询结果,50 条记录放进一个 list;
- 调用
agent.run(task_desc, extra_data=list); - Agent 内部把
extra_data转成字符串,准备拼进 system prompt; - 在那之前,Agent 先走了一遍
memory.add(records),把新数据写进对话历史; - 真正组装 prompt 时,框架从 memory 里读历史,而不是从
extra_data里读。
问题就在第 4、5 步的衔接处。
2.3 网关和 SDK 我们也没有放过
在把脏水泼到框架身上之前,我们还是先按流程过了网关和 SDK 这两环,免得定位不严谨。
网关层面,我们用的是 Nginx 做统一入口,client_max_body_size默认配的是 1m。如果请求体超过这个值,Nginx 一般会返回 413,属于显式错误,不太可能静默删掉中间 30 条。但我还是检查了自研网关的日志,确认转发出去的 body 和收到的 body 一致,没有做任何“智能截断”。
SDK 层面,OpenAI 之类的官方 SDK 正常情况下不会偷偷丢弃你传入的 messages。但我见过一些同事在代码里用messages[:20]做“测试时的临时截断”,后来忘了删,导致线上每次只发前 20 条。这种属于低级错误,但不能因为低级就不查。我们直接在 SDK 的chat.completions.create处打印了len(messages)入参,发现到这里仍然是 50 条。
到这里,问题锁定在框架组装 prompt 之前的那一段处理逻辑。于是我去翻记忆模块的源码。
3. 真凶落网:滑动窗口记忆把“源端上下文”当普通聊天记录裁剪了
3.1 代码定位和底层原因
顺着框架源码一路找,最终在 memory 初始化代码里看到了一行“人畜无害”的配置:
memory = ConversationMemory( max_messages=20, policy="sliding_window" )翻译一下:这个 Agent 框架的记忆模块最多只保留最近 20 条消息。当业务把 50 条工单记录写进 memory 时,框架认为写入的是 50 条“历史消息”,于是滑动窗口机制启动,只保留了最后写入的 20 条,前面的 30 条直接被 drop 掉。
为什么是“最后写入的 20 条”而不是“前 20 条”?因为很多滑动窗口实现的队列入队顺序是:新消息追加到末尾,超长时从头部弹出。如果框架内部把数据库记录按顺序逐条 push 进去,最后留在队列里的确实是 21-50 条,而不是 1-20 条。但我们观察到的现象是模型只看到了前 20 条,说明这个框架的实现里,外部 data 是被当作一整块拼进某 one message 的,或者它额外加了个max_records_per_message=20的拆分逻辑,最终只有消息的前半部分被保留。
真实情况是:框架先把 50 条记录json.dumps成一个大字符串,然后在写入 memory 时调用了truncate_content(content, max_chars=20000),而 20 条记录正好约等于 20k 字符,所以第 21 条之后全部被切掉。截断动作发生在字符串层面,而不是语义层面——框架只是按长度切,哪条数据过线就被扔,完全没有“保首保尾”的智能。
3.2 为什么框架作者会做这种“坑人”设计
你会不会觉得这是框架设计缺陷?其实站在框架作者角度看,这是个刻意为之的默认行为:
- LLM 接口按 token 计费,无限塞上下文会让成本失控;
- 大多数对话场景里,模型只需要最近的几轮上下文,早期消息对当前回答帮助不大;
- 自研推理服务对超长 prompt 有显式长度限制,宁可主动裁剪也比接口 400 报错体验好。
但问题在于,这个“默认行为”根本不知道你的业务意图。对聊天机器人来说,最近 20 轮对话就是全部故事;可对数据分析 Agent 来说,50 条工单记录是完整的事实集合。你把完整事实当成普通聊天历史来做滑动窗口裁剪,等于让模型在一个被挖掉 60% 数据的“残缺事实”上做推理。
这也就是为什么我强调:在 Agent 架构里,上下文管理必须区分“对话历史”和“业务数据”两个池子。对话历史用滑动窗口没毛病,业务数据必须走单独的组装通道,除非你显式告诉系统“这里可以压缩,但绝不能静默丢弃”。
3.3 模型层截断和 Agent 层截断:两套逻辑,两种隐蔽性
把根因定位在 Agent 层之后,我也顺便做了个系统性总结,方便以后遇到相似问题能快速区分:
| 截断位置 | 典型触发条件 | 是否报错 | 排查难度 |
|---|---|---|---|
业务代码手动list[:20] | 程序员忘了删调试代码 | 否 | 低,但容易忽略 |
| Agent 记忆模块裁剪 | 滑动窗口压缩了历史/数据 | 否 | 较高,需翻框架源码 |
| 代理网关截断 | 请求体超限被中间层丢弃 | 通常是 413/400 | 中,网关日志兜底 |
| API 服务端截断 | prompt token 超上下文窗口 | 部分服务不报错,静默截断 | 高,要看服务端配置 |
| 模型自身注意力窗口 | 位置编码/上下文长度限制 | 不报错,但尾部信息丢失 | 高,需哨兵验证 |
模型层的“静默截断”也很有意思。比如某些自部署的 OpenAI 兼容接口,在max_model_len配置不足时并不返回错误,而是把 prompt 最前面的内容保留、后面的内容截掉,然后正常推理。这是因为一些推理框架默认truncate_prompt=True。你如果只盯着源端和模型端,不看服务端配置,很容易以为模型输出就是基于全部数据的推理结果,其实它连后 30 条长什么样都不知道。
所以,Agent 开发中做好“上下文边界”设计,比单纯调参重要得多。
4. 修复落地:让 50 条数据完完整整走到模型面前
4.1 硬性止血:临时禁用裁剪 + 显式哨兵
问题定位之后,最紧急的事情是先让线上任务恢复正确。我们的临时方案分两步。
第一步,把 memory 的裁剪策略直接关闭,或者至少让业务数据绕开 memory:业务数据单独拼进 system message,而不是走memory.add()。改造后的伪代码长这样:
records = fetch_records(limit=50) system_content = build_system_prompt(base_instruction, records=records) memory.add(user_message) # 只存对话轮次,不塞业务数据 messages = [ {"role": "system", "content": system_content}, *memory.build_history(), {"role": "user", "content": "请基于以上记录完成分类和摘要"}, ]第二步,在 prompt 末尾加一个显式哨兵标记,用来验证完整性。这个技巧强烈推荐:
prompt = ( f"请处理以下工单记录:\n{formatted_records}\n\n" f"[END_OF_RECORDS_50]" )模型如果完整看到了全部记录,它的回复里通常会带上“我看到共 50 条记录”之类的复述;或者我们在后处理逻辑里检查模型输出是否包含哨兵词的某种反射,但更工程化的做法是在请求前后用代码审计记录条数,而不是靠模型“自觉”。
4.2 建立“计数审计”:发送前的最后一道防线
静默截断最怕“没有感觉”,所以要在 Agent 内部做一个强制校验层。我的做法是在build_messages()函数末尾统一做三件事:
- 统计业务源里实际条数,记为
source_count; - 统计最终 messages 里所有 content 中出现的记录编号数量,记为
reached_count; - 如果两者不一致,直接拒绝发送,并抛出带有上下文差异的错误日志。
这里有个细节:你统计“记录编号数量”时,不能简单数len(records),因为可能一条 message 里塞了 50 条而另一条 message 里全被截断。要写个正则或者逐条比对编号集合。
import re ids_in_prompt = set(re.findall(r"TICKET_ID:(\w+)", prompt_text)) source_ids = {r["ticket_id"] for r in records} if source_ids != ids_in_prompt: missing = source_ids - ids_in_prompt raise RuntimeError(f"静默截断检测失败,缺失 {len(missing)} 条记录: {list(missing)[:10]}")这套“发送前校验”上线后,成本很低,但价值极高。因为它在问题发生前就拦截,而不是等模型输出错了再去猜。这也是我认为 Agent 系统里最重要的基建之一:输入可观测性。
4.3 记忆策略重构:分层、摘要、按需加载
止血之后,中长期方案是重构记忆与上下文策略。我们的落地做法分三层:
- 数据面与对话面分离:对话历史继续使用滑动窗口,但业务数据(工单、报表、文档切片)永远不混入 memory,始终通过独立的上下文组装模块注入 prompt。
- 超长业务数据改用摘要或分批处理:如果 50 条记录全量输入后 token 超限,那就不能让模型“一口吃成胖子”。更稳妥的做法是分批让模型做局部摘要,再汇总成最终结论;或使用 Map-Reduce 式的 Agent 流程,先对每 10 条生成一个中间结果,再对中间结果做最终综合。
- 动态窗口替代固定 20:业务场景可以接受“最近 N 条”时,N 应该基于 token 预算动态计算。比如我们定了一个硬预算:业务上下文最多占模型上下文窗口的 60%,剩下 40% 留给指令、历史、输出。通过
tiktoken或transformers的分词器预估 token,动态决定这次能塞多少条记录。
import tiktoken enc = tiktoken.encoding_for_model("gpt-4o") budget_tokens = 0.6 * model_context_window for record in records: record_tokens = len(enc.encode(json.dumps(record, ensure_ascii=False))) if used_tokens + record_tokens > budget_tokens: break selected.append(record)如果某条业务场景要求“50 条一条都不能少”,那就必须选更大上下文的模型,或者把数据逻辑前移——不一定非要让模型读原始 50 条,可以先用规则代码提取结构化重点,再让模型基于浓缩后的事实输出。
5. 更值得沉淀的:一套“静默故障”的快速排查模板
5.1 这类问题的共同特征
这次排查让我意识到,静默截断不是某一个环节的专利,它可能有多个叠加来源。它们有一个共同特征:链条上的每个环节单独看都“正常”,只有放在一起对比时才发现端倪。
- Agent 框架会静默裁剪记忆;
- 模型 API 可能在超过上下文窗口时丢弃尾部;
- 数据库查询结果可能被分页限制默认截断;
- 日志系统打印长文本时也可能输出省略。
排查这一类问题,我总结出一个固定动作序列,希望对你有用:
- 从源端开始盘点:明确源端到底有多少条数据、多少 token。
- 在请求出口抓包:确认真实发往模型的请求内容,不要依赖平台日志。
- 对比三个数字:源端条数、出口消息条数、模型输出中实际体现的条数。
- 检查“熟悉的陌生人”:先看自己的业务代码有没有截断,再看框架记忆模块有没有压缩,最后才怀疑 API 服务端。
- 善用哨兵标记:如果必须在线上频繁验证,可以在 prompt 结尾加一个隐藏指令“如果看到结尾标记请回复 OK”,模型没回复 OK 就说明它没看到尾部。
另外,并发场景下要格外小心。多个异步任务同时向同一个 Agent 实例写 memory,可能因为共享变量被覆盖,导致 A 任务的数据被 B 任务的滑动窗口挤掉。这种 bug 比纯长度截断更隐蔽,因为它是“偶发”的。我们的经验是:Agent 实例尽量保持无状态,或者给每个任务开独立的 memory 上下文。
5.2 我的工具清单和每一步的关键动作
最后整理一份排查静默截断时常用工具清单,不涉及具体商业产品,完全从工程实操出发:
| 工具/方法 | 用途 | 关键动作 |
|---|---|---|
| tcpdump 或代理中间件 | 捕获真实出站请求 | 抓取并保存完整请求体,不要只看日志 |
| tiktoken / transformers 分词器 | 估算 token 数 | 在组装前后各算一次,确认是否接近上下文窗口 |
| 哨兵标记 | 验证模型是否看到全部内容 | 在 prompt 尾部加标记,通过模型回复判断完整性 |
| 页面计数正则 | 自动审计记录条数 | 用 IDs 集合对比源端和请求端 |
| 单元测试固定数据 | 复现特定截断行为 | 故意塞超过阈值的记录,断言是否触发截断 |
这套工具链我到现在还在用,已经帮我抓出过至少三次同类问题:一次是框架默认max_tokens截断输出,一次是数据库 ORM 默认分页只有 20 条却没人传limit,一次是日志框架本身只记录头部 1000 字符导致误判。每次真相都藏在“你以为是 A 其实是 B”的地方。
回到最初的问题:源端 50 条,模型只剩 20 条,本质上不是模型的问题,是我们没有给模型一条完整的信息通路。Agent 开发里,记忆、上下文、数据通道这三件事,真得当成一等公民来对待。希望这篇排查记录能让你以后少走几个小时的弯路。如果你也遇到过类似的静默截断,不妨从“日志到底记了多长”这一步重新查起。