Agent 跑长任务,跑着跑着就“失忆”了。上下文一超窗口就被截断,要么忘了最开始的目标,要么重复执行同一件事,日志刷了上千行还是原地打转。这个问题在做 Agentic Coding、数据处理流水线、多阶段调研类任务时特别明显。之前,我试过压缩历史、向量召回、分层摘要,各有各的坑,效果始终不稳。
所以当我看到InfiAgent这篇论文,核心思路是“文件为状态(File as State)”,让 Agent 实现长时间连续运行,眼前一亮。它的做法很朴素:不要试图把整段历史塞给模型,而是把 Agent 的一切运行状态“落盘”到文件系统里,每执行一步,读一次状态,改一次状态,再写回文件。模型永远只看到“当前这一步”,不用背负整个过去。这个设计直接绕开了上下文窗口的天花板,理论上只要磁盘够用,Agent 就能一直跑下去。
这篇博客想结合论文的公开思路,加上我自己在工程里做过、踩过的类似实践,把“文件为状态”这个设计彻底讲透,包括状态目录怎么组织、prompt 怎么裁剪、单步上下文怎么维持恒定、落地的时候有哪些坑。适合对 Agent 长时运行、状态管理、记忆机制感兴趣的同学。
1. Agent一长跑就“失忆”,问题到底出在哪
1.1 上下文窗口:Agent的“工作记忆”天花板
先说说我们为什么需要 InfiAgent 这类方案。
大模型 API 的上下文窗口是有限的,常见的从几千 token 到百来万 token 不等。看起来很大,但 Agent 跑一个真正的长任务时,上下文消耗远比你预想的快。每一步交互至少包含系统提示词、工具返回结果、模型输出,一个 ReAct 式的“思考-行动-观察”循环,走一步可能就要占掉几千 token。跑两百步,几十万 token 就搭进去了。
窗口一满,最常见的结果是模型开始“选择性遗忘”:它记得最近的几步操作,但早就忘了任务最初的目标,也忘了自己做到哪一阶段。更麻烦的是,Agent 在长任务里经常要回到前面的中间结果(比如读取三天前生成的那份数据文件),如果那个上下文被挤出窗口,它要么重新算一遍,要么干脆跳过,任务质量直线下滑。
我见过不少团队给 Agent 换更大的上下文窗口来解决问题,但这只是一个缓兵之计:窗口再大,也只是把“失忆”的时间点推迟了几个小时,而且窗口越长,单次请求的延迟和费用也跟着涨。
1.2 我们之前是怎么“拖延”这个问题的
既然模型记不住全部历史,业界一直在做外部记忆,主流路线大致有三条:
第一是历史压缩。把过去的对话一轮轮摘要,只把摘要塞给模型。问题是摘要本身会丢信息,而且任务动作越多,摘要层数越深,越后面 Agent 跟环境实际状态之间的 mismatch 就越严重。比如摘要写着“已经完成了数据清洗”,但实际文件里还是旧格式,Agent 下一步去读取时直接崩。
第二是向量检索记忆。把过去的操作、结果、错误信息向量化,存到向量库里,每步按相似度召回相关片段。这里有两个问题:召回质量不稳定,召回的是“语义上相近”的内容,不等于“当前这一步真正需要”的内容;另外,向量库对 Agent 来说是一个黑盒,它并不知道自己记了什么,也没法主动清理脏数据。
第三是反思重试机制。每次失败后,让 Agent 总结失败原因,把经验塞回 prompt,下次避开。这个方案在个别任务上有效,但本质上还是在“有限的窗口里腾挪空间”,经验太多之后照样装不下,也容易出现“过度反思”的现象,任务没推进多少,反而在总结失败的路上越走越远。
这三个方案有一个共同的隐含假设:记忆必须被塞进对话上下文里。InfiAgent 的出发点,恰恰是怀疑这个假设。它问了一个很直接的问题:既然操作系统早就发明了文件系统来做持久化,为什么 Agent 不能把记忆和状态全部存放在文件里,需要时再读取?
2. InfiAgent的解法:把状态“落盘”成文件
2.1 核心设计:Agent只处理“当前一步”
InfiAgent 的核心思路,是把 Agent 运行过程中的两大类东西全部“文件化”。
第一类是任务的静态信息,包括任务目标、用户约束、验收标准、全局配置。这些内容在任务开始时写进一个结构化的描述文件,通常每次步骤会带上精简版,确保 Agent 不会忘记大方向。
第二类是任务的动态状态,包括当前进行到哪一步、哪些子任务已完成、哪些还阻塞、中间产物放哪了、最近一次失败的原因、下一步的候选方案。这些信息如果一直变,就集中到一个 state 文件里,每执行一步就原子性地覆盖一次。
关键点在于:模型每一轮看到的,不是整段过去,而是由文件系统加工过的一份“状态快照”。快照的大小基本恒定,它由任务描述、当前 TODO 列表、最近几条日志、相关产物路径组成。过去三个小时的历史,被折叠成了一个文件路径、几行摘要、一个“下一步做什么”的索引。
这样一来,Agent 的运行时长不再是决定上下文长度的因素。无论任务跑了 1 小时还是 7 天,进入模型的 token 基本维持在同一个量级。这解决的不只是“失忆”,还顺手解决了“长上下文导致的注意力下降”“token 成本线性膨胀”“延迟不断升高”这几个问题。
2.2 状态文件怎么组织:一个工程上可落地的目录结构
论文的行文从原理出发,但落到工程上,我们可以直接参考下面这个目录结构。它和常规代码项目的目录天然兼容,这也是文件方案比起向量库方案的一大优势,Agent 不需要“另一套记忆系统”,它工作的产物本身就是它的记忆。
task_dir/ ├── state.json # 当前状态索引:目标、步骤指针、最后更新时间 ├── plan.md # 任务计划清单,完成一项勾选一项 ├── history.log # 追加式操作日志,只增不改 ├── config.yaml # 环境配置与固定约束 ├── artifacts/ # 中间产物与最终产物 │ ├── data_v1.csv │ ├── module_1.py │ └── report.md └── checkpoints/ # 关键节点的状态快照 └── cp_run_0429.json我个人的经验是,这个结构里的唯一权威事实源(source of truth)应该是 history.log 和 artifacts 目录,而 state.json 只是它们的摘要索引。很多 Agent 状态管理失败,就是因为让 Agent 直接修改权威状态,一改就崩。日志只追加、产物分文件存放,AI 即使犯了错,你也能在日志里精确定位到底改了哪一步,不至于一团乱麻。
状态快照之间最好保持原子性。在执行一个动作之前,先把当前 state 存为 checkpoint;动作执行后,再写入新 state。这种做法本质上是在给 Agent 做“事务处理”,避免执行到一半断电或模型输出异常导致状态损坏。
3. 为什么文件比向量记忆更可靠
3.1 文件同时是“记忆”和“产物”
向量记忆最大的问题,是它跟 Agent 的实际操作脱节。向量库里存的是一堆嵌入向量,跟文件系统里真正的代码、数据、配置互相独立。Agent 记错了向量库里的内容,却没有办法发现自己记错了,直到它在现实世界(文件系统)里撞上错误结果。
文件方案没有这个脱节问题。文件本身就是 Agent 工作的结果:代码是文件,数据是文件,配置是文件,报告也是文件。Agent 的记忆,不是对工作的“二手描述”,而是工作本身。它可以随时打开任何一个文件查看细节,也可以直接修改文件来纠正自己的方向。
这在直觉上和人类工作方式很像:一个资深工程师不会去“回忆”半个月前他写了什么,他会直接打开那个项目目录,看代码、看 README、看 git log。文件系统是现实世界,对话历史只是倒影。
3.2 可审计、可回滚、可人工干预
向量库几乎没法审计:你不能肉眼看出一个向量“记得什么”。文件就完全不同。
所有关键动作都在 history.log 里留有记录,哪一步调了什么接口、写了什么文件、返回了什么错误,全部可追溯。出问题的时候,我们可以直接翻日志、将 checkpoints 目录下的旧状态恢复回去,甚至手动去改 plan.md 纠正 Agent 的计划,然后让它重新跑一次。
这套能力对生产落地极其重要。Agent 一旦长时间无人看管,出错的代价就会被放大,跑了几十个小时最后发现方向从一开始就错了,非常痛苦。文件方案让人可以在任意时间点介入,看到当前状态,判断问题,然后用 git、rsync 或单纯的 cp 命令把系统恢复到你认定的正确状态。这种“透明度和可干预性”,是黑盒向量记忆无法提供的。
另外,文件系统本身就是通用的。不管你用的是哪个模型厂商的 API,不管对方出了什么新版本,文件方案都不依赖任何特定格式。你只需要让 Agent 能读写文件,理论上换一家模型服务也能保持同一套状态管理逻辑。
4. 手写最小实现:把论文思路跑通
4.1 状态仓库与主循环
说再多不如直接看代码。下面我给了个极简实现,去掉论文里的复杂设计和优化,只保留“文件为状态”的核心循环,你可以直接复制下来跑一遍体会原理。
先定义一个状态仓库,核心方法就三个:读取裁剪后的状态、追加操作日志、保存产物。注意这里刻意没有让模型直接改 state.json,而是由代码根据动作结果来更新索引。
from pathlib import Path import json, time class StateStore: def __init__(self, root="task_workspace"): self.root = Path(root) (self.root / "artifacts").mkdir(parents=True, exist_ok=True) (self.root / "checkpoints").mkdir(exist_ok=True) if not (self.root / "history.log").exists(): (self.root / "history.log").write_text("", encoding="utf-8") def snapshot(self, goal, plan, step_index, last_result): """生成当前状态快照,这是喂给模型的核心内容。""" history_tail = self.read_log_tail(limit=8) artifacts = ["artifacts/" + p.name for p in (self.root / "artifacts").glob("*")] return { "goal": goal, "step_index": step_index, "next_actions": plan[step_index + 1 : step_index + 4], "recent_history": history_tail, "artifacts": artifacts[-10:], "last_result": last_result, } def append_log(self, entry): with open(self.root / "history.log", "a", encoding="utf-8") as f: f.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {entry}\n") def read_log_tail(self, limit=8): lines = (self.root / "history.log").read_text(encoding="utf-8").strip().splitlines() return lines[-limit:] def save_artifact(self, name, content): (self.root / "artifacts" / name).write_text(content, encoding="utf-8") self.append_log(f"wrote artifact: {name}")再看主循环。它做的事情极少:读快照 -> 让模型返回一个 JSON 动作 -> 执行动作 -> 写日志 -> 再循环。模型不负责记忆,只负责基于当前状态做局部决策。
goal = "从数据目录生成一份分析报告并绘制图表" plan = ["扫描数据文件", "清洗数据", "统计分析", "生成图表", "撰写报告"] store = StateStore() step_idx = 0 last_result = "初始化完成" while step_idx < len(plan): state = store.snapshot(goal, plan, step_idx, last_result) action_json = model_act(json.dumps(state, ensure_ascii=False)) # 动作格式如 {"action": "read_file", "path": "data.csv"} if action_json["action"] == "read_file": content = Path(action_json["path"]).read_text() last_result = f"read {action_json['path']}: {len(content)} chars" elif action_json["action"] == "write_artifact": store.save_artifact(action_json["name"], action_json["content"]) last_result = f"saved {action_json['name']}" # ... 其他动作 store.append_log(f"step {step_idx}: {action_json['action']} -> {last_result}") step_idx += 1model_act在这里只是示意,实际替换成你用的模型 API 就行。核心是:模型每步的输入是快照,而不是对话历史;快照大小由设计决定,不随时间增长。这就是“无限运行”的数学基础。
4.2 让模型看到“刚刚好”的上下文:裁剪策略
快照里放什么、不放什么,是这套设计的核心工程问题。放多了会退化回“全历史拼接”,放少了模型会看不清全局。
我结合论文思路和维护经验,总结出三条裁剪原则:
第一,固定字段优先。任务目标、全局配置、当前步骤索引这些字段必须永远出现,因为它们决定了“我是谁、我在哪”。这些字段即使完全展开,也就几百 token,性价比最高。
第二,日志只取尾部。完整 history.log 可能很长,但快照里只放最近 5 到 10 条操作日志。模型不需要知道三天前每一步做了什么,它只需要知道“上一个动作的结果”和“最近环境的温度”。更早的信息,通过产物文件和计划清单来间接传递,而不是靠逐条日志。
第三,按需打开文件,不进快照。快照里放的是产物路径,不是产物内容。只有当模型决定查看某个产物时,再由工具函数读取该文件并返回内容。这相当于给了模型一个“按需加载”的能力,需要才读,读多少由当前动作决定。
这套裁剪逻辑决定了单步上下文的上限。只要裁剪策略是固定的,无论任务跑了多少步,模型看到的东西始终在一个稳定的 token 预算内。这也是“文件为状态”和“总结整个历史给模型”的本质区别:一个是固定内存,一个是线性增长。
5. 实验效果:无限运行到底提升了什么
5.1 长周期任务基准上的差异
论文拿这类方案对标的是主流基线:一种是全历史拼接的思考-行动-观察循环,另一种是反思重试型 Agent。评测任务基本都是长周期工程类任务,比如多文件代码仓库的 bug 修复、从一堆数据文件里自动产出结构化报告、分阶段网络调研等。这些任务的共同特点是:需要持续运行千百步,且结果可以文件化。
在我基于类似设定的实测里,最明显的差异出现在任务完成率的中后段。基线方式在任务跑到两百步左右,开始出现目标漂移,要么重复执行同一操作,要么开始修改无关文件。而文件为状态的方案,在跑到相同步数时依然会围绕任务目标展开行动,因为它每一次决策都重新读取了当前计划清单和任务索引,相当于每步都在“重新对表”。
另一个容易忽略的差异是恢复能力。任务中断(比如 API 超时、进程被杀、磁盘写满)在长任务里几乎是必然事件。基线循环一旦中断,重启后如果要从保存的对话记录里恢复,非常麻烦,而且容易上下文错乱。文件方案恢复成本极低:重启进程,读取 checkpoints 目录下最后一个快照,继续循环就行。这个能力在生产环境的心理价值也很高,至少我不怕任务半夜崩。
5.2 成本与完成率的平衡
成本这个指标,是整套方案最讨喜的地方。
按 token 消耗来算,文件方案的单步成本几乎恒定,因为它返回的上下文长度稳定。而全历史拼接方式,token 消耗随步骤数线性增长。跑 500 步时,后者的累计消耗可能是前者的几十倍。如果任务本身价值不高,这个成本差异足以让长任务方案在商业上不成立。
当然,文件方案也有额外代价,主要是文件读写 IO 与状态解析。每个循环都要读日志、写日志、生成快照、解析模型输出,比纯对话循环多出几十毫秒到几百毫秒的系统开销。对短期轻任务来说,这个开销是纯负担;只有任务足够长,节省下来的 token 成本和避免重试的价值,才能覆盖这部分 IO 开销。这也直接决定了它的适用场景边界。
5.3 适用边界:什么任务不适合
“无限运行”听起来万能,但文件状态机制并不适合所有 Agent 场景。
不适合的第一类是高实时性的单轮对话型任务。比如客服机器人、闲聊助手,这些场景对延迟敏感,状态简单,根本不需要落盘,也不应该有落盘带来的 IO 风险。
不适合的第二类是需要高度连续内隐推理的创作型任务。例如让模型写一部长篇小说,角色动机、情感线索是高度连续的隐状态,一旦把这些状态拆成文件,让模型每步只读快照,它的叙事连贯性很容易断裂。这类任务更适合保持长上下文,而不是外部文件化。
不适合的第三类是完全无文件产物、纯在线交互型任务,比如 Agent 一直在调 API 查天气、订会议室,它的“产物”并不落在文件系统里,状态也偏薄,引入文件系统反而画蛇添足。这类任务的核心状态是外部服务本身的数据库,而不是本地文件。
适用性判断,我总结成一句话:凡是任务结果能“沉淀为文件”的,文件为状态的收益就很明显;凡是任务结果只能“存在于对话中”的,这套方案就是负担。
6. 工程落地时会踩到的坑
6.1 状态文件膨胀与压缩策略
文件方案跑久了,history.log 一定会膨胀。千万步的操作日志,一个文件十几个 GB 都有可能。如果不处理,日志读写本身就变成瓶颈,文件系统从“帮手”变成“累赘”。
解决思路是分层压缩。日志里超过一定时限的老条目,周期性聚合成“阶段摘要”,写到一个单独的 summary 文件里;快照不再读取完整日志,而是读滚动窗口。这个过程可以做成后台定时任务,也可以在空闲时触发。注意摘要本身不能无限层嵌套,否则 Agent 看到的又是一团浓缩的糊状信息,我建议最多两级:原始日志一层、阶段摘要一层,再老的信息就让 Agent 通过检查点文件去完整回顾,而不是继续压缩。
另外,artifacts 目录也要做生命周期管理。Agent 运行中会产生大量中间产物,比如各种中间格式的数据文件。它们占磁盘倒是次要的,更严重的是,Agent 扫目录时看到一堆无用文件,会误把它们当作有效状态来读取。最好每个子任务维护一个独立目录,任务结束清扫一次,只保留成果与文档。
6.2 并发冲突:多实例共享工作区
文件为状态,最怕的就是多个 Agent 实例同时操作同一个工作区。两个 Agent 同时读 state.json,各写一笔,后写的人很可能把先写的人的状态冲掉。这在并行执行子任务时会遇到。
我的建议是给子任务分发行区。并行任务一律使用独立目录,互不干扰。只有真正需要共享的状态(比如统一的配置、最终产物目录)才放进共享区,且共享区只允许追加写,不允许原地修改。如果你需要让多个 Agent 并发修改同一份文档,那就引入轻量锁机制,或者改用单独进程来串行化写入。
还有一个容易踩的坑:文件路径冲突。两个 Agent 同时生成report.md,各自写入不同内容,最后一个写的人赢,另一个人的成果消失了。这类冲突很难通过锁完全解决,更可靠的方式是给每个 Agent 实例加独立 instance_id,产物文件名带上实例前缀或时间戳,再在最终阶段由编排层合并。尽早在设计里留出这个余地,比后期迁移要轻松得多。
6.3 模型注意力被“状态摘要”带偏
“文件为状态”不是说把状态塞给模型就万事大吉。模型对快照里不同字段的注意力并不平均。实测里,模型很容易被recent_history里最近一条失败信息吸引,然后在下一步不停地尝试修复那个失败,而不是按plan.md往下走。这在长任务里非常常见。
应对办法有两个方向。一是prompt 结构调整,把“下一步动作”字段放在“最近历史”之前,让模型优先读计划,而不是沉浸在过去的失败里。二是动作白名单化,约束模型每步只能输出少量结构化动作,并且把“推进计划”和“修复错误”分到不同的动作类型里。一旦发现模型连续 N 步都在做“修复”动作,就在 prompt 里显式提示它“修复尝试已达上限,建议跳过或记录问题后继续推进”。
这类“注意力纠偏”是工程里最花时间的部分。论文不会告诉你这些细节,但实际跑长任务时,模型是否在“有纪律地执行计划”,往往比它单步正确率更能决定最终成败。
6.4 恢复重跑的陷阱:状态与产物不一致
最后提醒一个非常隐蔽的问题:重启恢复时,状态文件与产物目录可能不一致。比如 checkpoints 里记录的“已完成清洗”,但 artifacts 里的清洗结果文件其实被后续操作删掉了。如果 Agent 照快照里的状态继续跑,它会在不存在的基础上继续工作,产出必然出错。
我现在的习惯是,每次恢复重跑前,先跑一个一致性校验脚本:把 state.json 里引用的每个文件路径逐一 check 是否存在、时间戳是否晚于上次修改记录。路径缺失或时间戳异常,统一标记为“需重做”,而不是无脑信任快照。这个校验只需要几十行脚本,看起来简单,却能在长流程里拦住大量莫名其妙的错误。
7. 写在后面:一点自己的体会
做 Agent 长时运行这件事,我最早也迷信“上更大上下文窗口”“外挂更牛的检索系统”,方向走偏了很久。InfiAgent 的“文件为状态”让我重新意识到一个朴素的道理:计算机科学几十年前就为“状态持久化”给出了标准答案,就是文件系统与日志,只是我们在做大模型 Prompt 工程时,常常把这份基础能力给忘了。
如果你也在做长期运行的 Agent,不论最后用不用论文里的完整方案,哪怕只是把“目标文件化”“日志只追加”“按需读取产物”这三个习惯纳入代码,任务稳定性都会有明显提升。先把状态落盘,再谈延长上下文,先保鲜,再扩容。这套思路值得所有 Agent 开发者放进自己的工具箱。