Agent 长任务跑到一半就断片?把中间产物落盘再按需读回
数据截至 2026-07,各产品的额度与报错口径以官方最新说明为准。
**长任务跑不完,绝大多数时候不是模型笨,而是你把上下文当成了硬盘用。**每一步的工具返回、抓到的网页正文、中途算出的结论,全都留在对话里往后传,任务越往后走,每一轮请求里真正跟当前决策相关的比例就越低。到某个点上,模型开始重复做已经做过的事、拿三步之前被推翻的结论继续推理,或者干脆中断。这三种表现的成因不一样,处置动作也不一样,先别急着换模型。
这篇讲的是任务运行过程中自己产生的东西该放哪儿。它跟另外两篇是分工关系:断点续跑那篇(长时运行 Agent 的检查点与恢复)解决的是”崩了之后怎么从上次的位置接着跑”,大仓库塞不进上下文解决的是”外部已有的一大坨输入怎么切着喂”,而这篇管的是中间那一段——任务跑起来之后不断新增的产物,怎么写出去、怎么在需要时精准捞回来。三件事可以叠加,但排查顺序不能混。
一、先分清你遇到的是哪一种”跑不完”
三种现象经常被笼统说成”跑崩了”,但判别方法完全不同。
**第一种是撑爆型。**任务前半段一切正常,越往后每一轮响应越慢,成本曲线陡峭上翘,最后要么报错中断要么被截断。这类的特征是”单调恶化”——从第一轮到最后一轮,请求体积一路只增不减。
**第二种是失忆型。**中途某一步之后,Agent 开始重复调用刚调过的工具,或者问你一个前面已经明确回答过的问题。特征是”突变”——某一轮之前都记得,之后突然不记得了,往往对应着一次历史裁剪或压缩。
**第三种是污染型。**它不崩也不重复,就是结论错。你翻回去会发现它引用了一份早就被后续步骤推翻的中间结果,而那份结果还原封不动地躺在历史里。这类最隐蔽,因为整个过程看起来很顺。
判别这三种,靠一份逐轮日志就够了。不用依赖任何工具自带的统计面板,在你自己的调用封装外面套一层记录即可:
import json, time
def log_turn(run_id, turn, messages, path="runlog.jsonl"):
rec = {
"ts": time.time(),
"run": run_id,
"turn": turn,
"chars": sum(len(json.dumps(m, ensure_ascii=False)) for m in messages),
"roles": [m.get("role") for m in messages],
}
with open(path, "a", encoding="utf-8") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
chars 只是个粗略代理量,不等于计费口径,但用来看趋势足够了。把它按轮次画出来:一路上扬是撑爆型;中间有个断崖式下跌、之后行为开始异常,是失忆型;曲线平稳但结论错,是污染型。如果你连这份日志都没有,后面所有判断都是猜的,先补日志。
二、判别表
| 现象 | 大概率成因 | 怎么验证 | 处置动作 |
|---|---|---|---|
| 越跑越慢、末尾中断或被截断 | 工具原始返回全量留在历史里 | 逐轮日志中 chars 单调上升;抽查某一轮,看最大的几条消息是不是工具返回 | 工具返回改为写盘 + 回传路径与一句话摘要 |
| 某一轮之后突然重复劳动、反复问同一个问题 | 历史被裁剪或压缩,关键结论跟着被丢掉 | 日志中 chars 有断崖下跌,且下跌点与异常起点重合 | 把结论从对话历史搬到常驻索引里,裁剪只裁原始内容不裁索引 |
| 流程顺畅但结论自相矛盾 | 已作废的中间结果仍在历史中被当作有效 | 拿错误结论里的一个特征词去逐轮日志里搜,看它在被推翻的那一轮之后是否仍出现在请求消息里;再到产物目录搜同一主题,看是不是躺着两份没标新旧的结论 | 给每条产物加状态字段,推翻时改状态而不是留着两份 |
| 报 401/403,或提示额度已用尽 | 凭据、权限或计费侧问题,与上下文无关 | 用同样的凭据发一个最小请求复现 | 走凭据与额度这条线排查,别在上下文上折腾 |
| 报 429,退避重试后能过 | 限流,各家规则不同且会调整,以官方最新说明为准 | 看是否只在高频段出现、降速后消失 | 加指数退避与并发上限,与本文问题分开处理 |
| 报 ETIMEDOUT/ECONNRESET | 网络链路问题 | 换一条链路或直接 curl 目标地址复现 | 按网络问题处理,不要误判成模型或上下文 |
| 单次读文件就把窗口占满 | 输入本身太大,不是中间产物的问题 | 看这条消息是不是一次性读入的源文件 | 走切片那条路,见上文大仓库那篇 |
表里后四行的意义在于排除与分流:前三行才是本文要处理的,后面几行要么根本不是上下文问题,要么该走切片那条路。我见过太多人把 401 和 429 也归到”上下文管理没做好”里,然后花半天优化历史裁剪,问题一动不动。状态码这类通用标识是可信的分流器,先用它把无关分支砍掉。
三、落盘:写什么、写在哪、留什么在上下文里
落盘不是”把对话存下来”,是把产物和对产物的引用分开。
产物大致三类,处理方式不同。原始产物是工具的原始返回、抓下来的文本、查询结果集,体积大、信息密度低,这类应该整份写盘,上下文里一个字都不留。加工产物是你从原始产物里提炼出来的结论、清单、决策,体积小但价值高,写盘的同时要在上下文里留一行索引。执行痕迹是”第几步做了什么、成没成”,它决定下一步该不该重做,必须常驻。
目录约定比目录设计重要,能被猜到的路径才读得回来。一个够用的结构:
runs/<run_id>/
raw/ 原始返回,文件名带步骤号
artifacts/ 加工产物
index.jsonl 每行一条索引
索引每行只放几个字段:路径、一句话摘要、产出于第几步、当前状态(有效/已作废/待复核)。这一行就是留在上下文里的全部内容,模型看到它就知道”这份东西存在、大概是什么、要不要读”。
有一点容易做反:摘要要在写盘那一刻生成,不要等读的时候再生成。读时摘要意味着你得先把全文塞回上下文,那落盘就白做了。写盘的那一步你手里正好有全文,顺手写一行摘要,成本最低。
产物版本用 git 管最省事,不需要额外基建:
RUN_ID=2026-07-29-a # 尖括号占位符别直接抄,shell 会把它当重定向
cd "runs/$RUN_ID"
git init -q
git add -A
git commit -q -m "step-03: 采集完成"
每完成一个有意义的步骤提交一次,出问题时 git log --oneline 就是任务的执行史,git checkout <sha> -- artifacts/x.md 就是回滚单个产物。注意这个仓库是任务运行目录,别跟你的代码仓混在一起,也别把它推到远端。
四、按需读回:把读这件事做窄
落盘之后最常见的退化,是给 Agent 一个”读全文”的工具,结果它一读就把刚省下来的全塞回去了。读回要设计成窄接口。
给三个粒度的读取能力,而不是一个:查索引(按状态、按步骤过滤,只返回索引行)、按关键词搜(返回命中文件与命中行的上下文若干行)、按范围读(指定文件的行区间或小节标题)。把”整份读入”从工具列表里去掉,模型自然会先搜再定位。搜索这一层用现成命令就够:
grep -rn "接口超时" "runs/$RUN_ID/artifacts/" | head -20
范围读的参数要给得死一点,比如强制传起止行号且限制最大跨度。留着”可选参数,不传就读全文”这种设计,等于没设计——模型多半就不传。
还有一条:作废用标记,不用删除。某个结论被后续步骤推翻时,把索引里那一行的状态改成已作废,文件本身留着。删掉会导致回溯时无从判断当初为什么走错,而留着又不标记,就变成了上面判别表里的污染型。改状态是唯一两边都不亏的做法。
关于工具接口本身怎么切分粒度、参数怎么约束,Agent 工具怎么设计那篇讲得更细;上下文里每一类内容各自该怎么裁,可以看Agent 上下文管理。
五、什么情况下别再折腾
工程上最贵的不是修不好,是修的时间超过了重来一次的时间。下面几个信号出现时,停手。
同一步骤连续失败三次以上,且每次失败原因相同,说明卡点不在上下文组织,而在这一步本身的定义或它依赖的外部条件。这里要跟限流、网络抖动这类瞬时错误区分开——那几类按表里后四行走退避重试,退避后能过就不算失败次数;只有退避也不管用、报错原文每次一模一样的,才计入这条止损。继续加索引、继续裁历史都不会有变化。回到 git log 里最后一次成功的提交,把这一步从自动执行里摘出来手工做掉,再让流程往下走。
**轮次在涨,索引条目不涨。**这是原地打转的硬指标:模型在反复读、反复想,但没有产出任何新的东西。这种状态不会自己好转,越往后上下文越脏。直接终止这一次运行。
**产物目录里出现两份都标着有效、内容互相矛盾的结论。**说明作废机制没生效,此时任何后续推理都不可信。回滚到矛盾产生之前的那个提交,从那里重跑。
还有一种情况是该换条路:如果任务天然可以拆成互不依赖的几段,就别用一次连续运行去跑完。拆成多次独立运行,每次冷启动,只读上一次留下的索引和加工产物,不继承任何对话历史。这比在一次超长运行里精打细算要稳得多,代价是你得自己定义好每段的输入输出契约。
也要说清这套做法不解决什么:它不改善模型的判断力,第一步就分析错的产物,落盘只会让错误保存得更完整;它不替代重试与幂等设计,工具的副作用问题得单独处理;它也不能让一个本身定义模糊的任务变得可执行。有些托管环境不给你持久磁盘,容器一销毁产物就没了,这种情况下先解决存储位置,再谈落盘策略。
六、避坑清单
**产物写盘了,但每轮还是把索引全文附上。**为什么会踩:索引看起来很小,就懒得管。实际上任务跑到几十步之后,索引本身也会长到可观的体积。怎么避:索引常驻的只保留状态为有效的、且最近若干步产生的,历史索引同样走搜索读回。
**索引写成流水账。**为什么会踩:摘要图省事就写”已完成数据采集”,等于没写。模型看到这行不知道里面有什么,只能读全文。怎么避:摘要里必须出现可检索的具体名词——哪个接口、哪个模块、哪个字段,让搜索能命中。
**产物没有步骤号和时间戳。**为什么会踩:写的时候只有一份,觉得不会混。等到第二次运行同一步骤,两份文件谁新谁旧就说不清了。怎么避:文件名带步骤号,索引里带产出步骤;时间戳统一用 UTC 存储,展示时再转,跨时区协作时这一条能省掉一整类灵异问题。
**覆盖写导致旧版本消失。**为什么会踩:同名文件重写是最自然的写法。但中间产物的价值一半在于可对比。怎么避:要么写新文件、旧文件改状态,要么每步 commit 一次让 git 兜底。
**敏感信息跟着产物一起落盘。**为什么会踩:工具返回里带着令牌、客户数据,你整份写进 raw 目录,还顺手 commit 了。怎么避:凭据只走环境变量,绝不进产物;运行目录里放一份 .gitignore 排除 raw 下的敏感类型;写盘前对已知敏感字段做脱敏。相关做法可以参考API 密钥安全管理。
**路径靠猜。**为什么会踩:运行目录名带随机串或时间戳,模型每次都得先列目录才知道自己在哪。怎么避:把运行根目录固定成一个环境变量,工具内部拼绝对路径,模型只传相对路径:
export AGENT_RUN_DIR="$PWD/runs/2026-07-29-a"
**编码和换行把读回搞砸。**为什么会踩:Windows 下默认写出 CRLF,或者带上 UTF-8 BOM,读回后按行号切片全部错位,diff 也整片飘红。怎么避:所有读写显式指定 encoding="utf-8",写文件时明确 newline="\n",仓库里配好换行策略。
**以为框架自带的会话持久化就等于产物管理。**为什么会踩:两者名字很像,都在讲”存下来”。但会话持久化存的是消息序列,恢复时是把消息重新灌回上下文——你原本想省的东西,它一次性全还给你了。怎么避:把两层分开——会话持久化负责”从哪一步接着跑”,产物落盘负责”那一步的结果在哪儿”,前者只存指针,别存内容。
收个尾
这套东西的核心就一句话:上下文里放地图,磁盘里放货物。地图要小、要准、要能定位;货物随时能取,但不主动搬进来。做到这一点,任务能跑多长就不再取决于窗口,而取决于你的流程本身设计得好不好。
跑之前过一遍这张自检清单:
- 逐轮日志有没有?没有就先补,否则后面全是猜。
- 工具的原始返回是不是直接进了上下文?
- 每份产物有没有对应的一行索引,索引里的摘要能不能被搜到?
- 有没有”整份读入”这种工具?有就删掉。
- 结论被推翻时,走的是改状态还是留两份?
- 运行目录有没有版本化,回滚点在哪一次提交?
- 凭据和敏感字段确定没跟着落盘?
- 止损条件写下来了吗——第几次失败停、什么信号算原地打转?
最后一条最容易被跳过,但它决定了你是在调试,还是在陪着一个已经跑偏的任务耗下去。