49万字符杀死一个会话:Agent上下文失控的生产事故复盘
现状之痛:Agent 会话用着用着突然整段报废——报错看着像超长或限流,重试没用、换模型也没用,你甚至说不清它到底坏在哪一步。
读完你将:看懂一次 49 万字符会话事故的完整证据链:上下文膨胀、渐进致死、内容审核毒化、错误类型误判,并拿到一套会话长度守卫的落地机制。

一、11 个字,问死了一个干了 5 小时的会话
2026-09-04 中午 13:38,老板在微信里给物流 Agent「六六」发了一条消息:
NRT 018-92898702 // CIF 发预报
NRT 是成田,CIF 是到岸价条款,一票再普通不过的货代指令。可这条 11 个字的指令,让六六连报 3 次「model provider failed」。会话 20260904_084257 当天早上 8:42 开工,已经连续跑了近 5 个小时。errors.log 里留下的 traceback 长这样:
openai.BadRequestError: Error code: 400
{'error': {'message': 'Content Exists Risk',
'type': 'invalid_request_error',
'param': None, 'code': 'invalid_request_error'}}
失败链路(下图)非常干净:主模型 deepseek-v4-flash 报 400 → 系统自动 fallback 到 deepseek-v4-pro → 同样 400 → 三次重试全部失败,13:38:32 会话被标记为 context-overflow,错误回给了老板。

转折在 5 分钟后:13:43,六六手动补发成功,消息 Sent #434 正常发出。机器全挂,人一接手就好了——这条线索后面会反复出现。
复盘时才看懂一个反直觉的事实:这 11 个字根本没进到模型。被拒的是背着 49 万字符历史、约 22 万 token 的整个请求体。13:38 那一刻,会话已经累积了 158 轮历史消息——系统提示词、对话、工具输出全在请求里,11 个字的新消息只是它末尾的一小截。指令越短,不代表请求越轻。
二、复盘一:上下文不是免费的
六六的会话不是天生这么大。上午它连续干了三票大活:报告 7 天窗口改造、斯洛文尼亚方向的代理深度搜索、卢布尔雅那方向的运价询价。每票活都要反复调工具,而每一次工具输出,都会全量留在对话历史里。
agent.log 里数得出来的大块头:session_search 单次输出 39,985 字符,terminal 单次输出 58,295 字符,browser 快照 16,000+ 字符出现过多次。我把这个会话 5 小时内的工具输出全部累加:342,340 字符,约 34 万。加上对话历史,整个会话约 49 万字符。
正常会话应该多大?我们生产里其他会话在 2-3 万字符。六六这个会话,是正常值的 16 倍以上。它不是某一次调用突然撑爆的,而是几十次大输出一次次垒出来的——每垒一次,下一次请求就重一分。会话起步就不轻:8:43 的第一轮请求,输入已有 33,123 token,系统提示词和工具说明是常量,进场即占位;到 11:00,单次请求输入涨到 243,354 token,两个多小时翻了七倍多。

上下文不是免费的:Agent 每次调用工具,都在给会话永久加码。 写提示词时我们精打细算每一行,工具输出却任由它裸奔进历史——这笔账迟早要还。
三、复盘二:渐进致死——坏的时候没人发现
真正值得警惕的时间点不是 13:38。回翻日志,11:03:12 这个会话就第一次报了同样的 400 Content Exists Risk,三个重试全部失败。
系统做了什么?只记了一行日志,目的是防止 transcript 无限增长:
Skipping transcript persistence for context-overflow failure
in session 20260904_084257 ... to prevent session growth loop.
没有压缩,没有重建,没有告警。会话继续跑、继续垒,11:03 到 13:38 之间整整 2.5 小时带病运行,直到老板的消息撞上来,整段报废。这是上下文失控最阴险的地方:坏的那一刻没人发现,发现的时候已经死了。怎么判断系统有没有在处置?看日志里有没有对应的动作:压缩、重建、告警,三者至少占一个才算处置。只记一行 overflow 然后继续跑,等于没有处置。
四、复盘三:真凶不是超长,是内容审核毒化
到这里,六六最初的自诊断是「上下文太长,撑爆了会话」——听起来合理,却是错的。
先看数字:会话 token 量在 22 万上下(11:00 的日志里,单次请求输入已达 243,354,还在涨)。DeepSeek 的上下文窗口是 1M——这个会话连窗口的四分之一都没用到。真超长会报 context length exceeded,实际报的是 Content Exists Risk,两者含义完全不同。
Content Exists Risk 是服务端内容审核的拒绝:请求体里有某一段内容被判定存在风险,整次请求直接 400,根本不进入生成环节。也就是说,不是汤太多,是汤里进了一粒老鼠屎——49 万字符历史中有一段被审核标记的内容,每一次请求都原样带着它,于是每一次请求都被拒。
这一下解释通了所有现象:为什么重试 3 次全挂——重试不换请求体;为什么 fallback 到 deepseek-v4-pro 也挂——换模型不换请求体,老鼠屎还在。重试 100 次也一样。判断会话死活不能只看长度,还得看历史里有没有「带毒内容」。
五、复盘四:400 是非重试性错误——我们重试错了对象
大部分 Agent 框架的重试逻辑是同一套:失败就退避重试,像对待限流一样。这个直觉对 429(限流)和 5xx(服务端故障)是对的——等恢复即可。
但 400 是另一类:invalid_request_error,客户端错误,请求体本身有问题。OpenAI 兼容 SDK 里它叫 BadRequestError,和 RateLimitError 是两个错误类。一个等 3 秒能好,另一个等多久都不会自己好。粗分三档就够用:429 限流,退避重试;5xx 服务端故障,等恢复重试;400 客户端错误,先查请求体,重试排在最后。按错误类型分流的写法长这样:
# 伪代码:按错误类型决定重试策略
except openai.RateLimitError as e: # 429:可退避重试,有意义
retry_with_backoff(e)
except openai.BadRequestError as e: # 400:请求体问题,重试无意义
if "Content Exists Risk" in str(e):
rebuild_session(preserve="summary") # 毒会话必须重建,不是重试
raise NonRetryable(e)
Content Exists Risk 这类审核拒绝还有个特征:它标记的是「历史里的内容」,不是「这次的输入」。同一个用户、同一段历史,换个措辞重发也一样被拒——处置动作只有一个:重建会话,把摘要带进新会话,让那段内容离开请求体。
六、修复:会话长度守卫 + 人机兜底
事故当天,我们把防再犯做成了物理机制,issue 编号 ISSUE-038,验证通过后关闭:

机制分四步:监控会话长度,每完成一个工具调用就累加输出;逼近危险线(我们按约 12 万字符设阈值——正常会话 2-3 万,出事时 49 万,留足提前量)即触发;处置按渠道分流,微信这类一天一个长会话的渠道自动重建,把结论摘要带进新会话,飞书等其他渠道先告警,由人决定;最后 dry-run 推演加实跑验证都通过,才算关闭。
这里有个边界要讲清楚:会话长度守卫,适用于长时间单会话复用的对话型 Agent(微信客服、物流操作这类一天一个会话的渠道);短会话、按任务新建会话的系统,收益有限,不需要上这套机制。 判断标准是会话复用度——同一个会话里会连续干几小时、几十轮活的,才值得配守卫。
为什么不能靠 Agent 自觉?因为「该不该重置」的判断发生在调用链之外:Agent 专注干活时不会每轮评估自己的历史重量,而真到 49 万字符量级,它已经连正常请求都发不出来了。物理机制的意义,就是在 Agent 意识不到危险时替它做决定。这套「物理机制 > 自觉」的思路,我在前文《可观测性三件套的生产复盘》里拆过:Gate 事前防、Audit 事后查、Correction 责任改。这次事故就是一次完整的 Correction 闭环:错误入账,变成阈值,变成自动重建,用实跑验证收口。
人机协同是最后一层兜底:13:43 手动补发只用了 5 分钟。生产系统要的不是永不失败,是失败可见、人可接手——报错信息要能看懂(这次 400 的信息其实足够明确),并且随时留一个手工出口。机器扛常规流量,异常由人收尾,比让机器在坏请求上反复硬撑更稳。
修复上线当天下午,我们以为事故闭环了——直到同一类 400 再次出现,在一个全新的、干净的会话里。
六·五、复盘五:修好了还会复发——reset ≠ 清除
主母在微信新开了一个会话,前 6 次 API 调用全部正常,说明新会话本身是干净的。然后六六执行询价任务时,主动调用了一次 session_search——这是 Agent 的「翻历史」工具,用来检索过去会话找上下文。它滚动找到了早上那个毒化会话(084257),把其中 62,855 字符的消息窗口拉进了当前请求。下一次 API 调用,立刻 400。
问题出在我们第一轮修复的盲区:reset 毒化会话时,我们只标记了 end_reason(会话结束原因),消息文本还留在数据库里。session_search 一搜,毒化内容就被带回任何新会话——毒不是消失了,是藏进了检索索引里,等着被捞回来。
这一层漏洞比会话膨胀隐蔽得多:会话膨胀是「量变」,看得见;检索回灌是「质变」,看不见——新会话干净、模型正常、请求体看着没问题,但工具的一次历史检索,就把毒重新注入了上下文。
修复也分两层:会话标记结束之外,必须物理清除消息行;防复发做成看门狗机制——任何标记为 poisoned 的会话,巡检时自动物理清除 messages,幂等执行,每 30 分钟一轮。毒化 reset 从此等于消息同步清除,session_search 再无内容可带。
七、事故之外:上下文管理正在变成工程问题
这次事故把「上下文管理」从我原来以为的参数调优,逼成了实打实的工程问题。会话长度怎么治理、工具输出怎么留痕、错误类型怎么分流、阈值到了怎么自动处置——这些在 Agent 只是玩具时都是文档里没人细看的配置项;一旦 Agent 每天替真人处理业务,它们就和数据库连接池、消息队列一样,成了基础设施。
上下文工程(Context Engineering)不该只在论文里讨论窗口多大、压缩算法多聪明。生产里真正被反复问到的,是上下文怎么失控的、失控后怎么被尽早发现、被毒化后怎么自救。49 万字符这个数字会过时,但这套复盘路径不会:从日志证据出发,逐层排除错误归因,把教训物理化成机制。以后再遇到类似的 400,我们的第一反应也不再是加窗口、换模型,而是先问一句:请求体里哪一段历史可能被标记了。
此刻的你——读完这篇文章,你应该带走什么
① 一句话读懂
这个会话不是被 49 万字符「长」死的,是被历史里一粒被审核标记的「老鼠屎」毒死的——上下文失控是渐进的,抢救窗口在 11:03,不在 13:38。
② 你应该建立的认知
认知一:上下文是 Agent 的负债,不是资产。 每次工具输出留在历史里,都在放大下一次请求的失败半径;长会话必须配长度治理,像配日志轮转一样自然。
认知二:渐进失效需要主动发现机制,修复要连根拔。 400 首现的那一刻就该触发处置,而不是等用户撞上来才暴露;而处置只标会话结束远远不够——消息留在库底,会被 session_search 这类检索工具捞回任何新会话,毒以另一种方式复活。reset 要物理清消息,才算真 reset。
认知三:错误类型决定重试策略。 400、429、5xx 三类错误对应三种动作;内容审核类 400 标记的是历史内容,处置是重建会话,不是重试、不是换模型。
③ 现在,你该做什么
| 步骤 | 行动 | 耗时 |
|---|---|---|
| 1 | 打开生产 Agent 的日志,统计最长会话的字符与 token 量级 | 30 分钟 |
| 2 | 把重试逻辑按错误类型分支:400 不重试,内容审核类走会话重建 | 半天 |
| 3 | 给会话长度加阈值守卫:单会话渠道自动重建,其余渠道先告警 | 1 天 |
| 4 | 毒化会话 reset 时物理清除消息,并给检索工具加「毒化会话不返回」过滤 | 2 小时 |
④ 如果只记一句话
上下文不是免费的,失控是渐进的,毒化是重试救不回来的——Agent 生产化,先学会给会话排毒。
延伸阅读
- 可观测性三件套的生产复盘——Gate、Audit、Correction 是怎么转起来的
- Self-Improving Agents 不是神话——从 error-ledger 到 Loop Engineering 的完整闭环
- 我给 Agent 装了「三明治」架构——90% 的「忘记查知识库」问题被代码消灭了
关于作者 无记——AI / Agent / 数智化转型实践者。只写亲手跑通的东西,不聊概念。关注我,一起把认知变现。

浙公网安备 33010602011771号