你的编程 Agent 跑着跑着就“失忆”了?百万行仓库的上下文工程
在大型代码仓库里用 AI 编程助手,最崩溃的瞬间是什么?Agent 前半个小时还在准确改动核心模块,过了几十轮之后突然开始重复问你“这个函数在哪里”,或者自作主张改了一个同名但完全无关的文件,更夸张的是,它忘了自己十轮之前刚定下的接口方案。这不是模型变笨了,而是你把一个百万行仓库直接丢给了它,却完全没做上下文工程。
我最早接手这类问题是在一个老旧的模拟项目 X 上,代码总量不算夸张,几十万行,但模块之间耦合严重,文档几乎为零。AI 助手一上来就给我表演了一个“五分钟失忆”,从“帮我改这个服务”到“这个服务在哪个目录”,全程不超过二十轮对话。后来我花了两周时间复盘整个交互流程,才意识到问题根本不在模型能力,而在上下文的管理方式。这也是这篇内容想聊透的事:为什么 Agent 会失忆,以及如何用上下文工程让它在百万行仓库里保持清醒。
1. 先搞清楚“失忆”到底是怎么发生的
1.1 上下文窗口不是硬盘,是一个工作台
很多人在用编程 Agent 的时候,默认把上下文窗口当成仓库的“硬盘”:以为窗口越大,Agent 记住的就越多。这个类比是错的。上下文窗口更像一张物理工作台,它有尺寸上限,但哪怕桌面足够大,你也不可能一次性把所有零件和图纸全堆上去——真正的问题是:信息太多的时候,你需要扫一眼才能找到眼前这个螺丝。
大语言模型在超长上下文里的注意力是会被稀释的。业界已经有不少实验验证过“Lost in the Middle”现象:模型对开头和结尾的信息把握最好,对埋在中间的内容容易忽略。也就是说,即使你把十万行代码全部塞进窗口,Agent 真正“认真看”的可能只有开头、结尾以及它刚刚操作过的那一小段,剩下的大段代码成了噪音。
还有一个被低估的问题:Agent 自己的工具调用历史、搜索结果、错误日志也会占用上下文。你让 Agent 读了一个文件,它把整个文件内容都放进窗口;它跑了一次构建,把几百行编译输出也放进了窗口;这些中间结果堆着堆着,真正重要的任务指令就被挤到了边缘,失忆自然发生。
1.2 三类典型的失忆场景
在实际使用中,Agent 的失忆大致可以分成三类,每一类对应的处理方式都不一样。
第一类是任务级失忆。你最初让它做的是“重构用户认证模块的缓存逻辑”,它做到一半开始专注于“优化 Redis 连接池”,完全忘了缓存的最终目标是减少重复认证查询。表现是:它在输出里加入了大量与目标无关的改动。
第二类是代码级失忆。Agent 改完了 A 文件,添加了一个工具函数,后来在 B 文件里需要调用它时,却重新定义了一个同名但逻辑不同的函数,导致整个项目里出现重复定义或隐式覆盖。
第三类是环境级失忆。它忘了项目使用的是哪种代码规范、哪个包管理工具、某些模块之间的依赖规则。你会发现,对话前二十轮它还会遵循项目的目录结构,之后就开始自行发明新的文件组织方式。
这三类失忆的本质,都是关键信息没有稳定地保存在可被持续访问的位置。对话历史并不是可靠的记忆载体,因为它在不停被新内容冲刷。
1.3 信息越多,Agent 反而越“糊涂”
我在实际测试里做过一个对比。同样一个小型需求,给 Agent 只提供三个相关文件,它能在一轮内定位问题并给出合理修改;给 Agent 提供整个模块三十个文件的“全量上下文”,它的定位时间反而变长,而且经常关注到一些无关的辅助代码,甚至被某些废弃逻辑带偏。
这不是模型变笨了,而是信号被噪音淹没了。当任务指令只占总输入 token 的 1%,模型最有可能遵循的不是指令,而是那些篇幅巨大的代码文件。所以,上下文工程的首要原则不是“装更多”,而是“在正确的时间加载正确的一部分”。
2. 上下文工程的核心设计思路
2.1 别把仓库塞进上下文,要给 Agent 一张“地图”
想明白失忆的原因之后,我开始重新设计 Agent 的输入。一个比较自然的类比是:新人入职第一天,你不会把公司全部源码打印出来放到他桌上,你会先给他画组织架构图、讲核心流程、给一份文档索引,然后让他遇到具体问题再去翻对应模块的代码。
上下文工程做的是同一件事。它不追求让 Agent 在内存中装下整个仓库,而是建立一套信息路由机制:什么信息该在什么阶段出现,哪些信息应该被压缩成摘要,哪些信息要用工具临时检索而不是常驻窗口。
我把这套机制拆成了四个核心问题:
- 该放什么:当前任务必须依赖的信息,比如任务描述、验收标准、相关接口定义。
- 不该放什么:无关模块的源码、历史对话中的寒暄、过期日志。
- 什么时候放:任务开始前放入全局信息,中途按需检索局部信息。
- 怎么压缩:把大文件总结成摘要,把操作记录改写为结构化条目。
2.2 分层上下文架构:从任务目标到局部代码
现在我在实践中一直用的是四层上下文架构。每一层有各自的更新频率和生命周期。
第一层是任务层。它的内容是本次任务的完整描述,包括目标、验收标准、不允许触碰的边界。这一层相对稳定,从任务开始到结束都保持一致。为了防止 Agent 在中途忘掉它,我会要求 Agent 在每次工具调用前快速回顾任务层的关键约束。
第二层是仓库层。这个层相当于项目的“地图”,核心包含:项目的整体目录结构、核心模块的职责、入口文件、构建方式、测试命令。它不是全部源码,而是压缩过的“索引页”。这一层通常在任务开始时注入一次,当 Agent 开始探索不同模块时再做增量更新。
第三层是局部层。这是 Agent 当前正在处理的文件和函数级别的内容。它需要保持精简,通常只包含当前改动相关的几个文件的关键片段。这个层变化最快,每次 Agent 切换任务子目标时,我都会要求它清空不再需要的旧代码片段,重新加载新的局部上下文。
第四层是状态层。它记录 Agent 到目前为止做过的决策、已经改过的文件、还没有做的事以及遇到的问题。这一层非常重要,它相当于外部记忆。每次对话结束时,我都会让 Agent 把状态层的内容同步到一个持久化文件里。
分层设计的关键在于,不要让某一层的内容无限膨胀。比如状态层如果写得太啰嗦,Agent 反而会漏掉里面的关键决定。我给自己定了一个标准:每个层级在上下文里占据的空间不应超过总窗口的 20%,任务层和局部层往往占大头,状态层则要极其精简。
2.3 记忆的显性化:把“记得”变成“写下来”
分层架构落地时,最实用的手段就是记忆文件。早期我依赖 Agent 在对话里自己“记住”,后来发现哪怕上下文窗口足够大,模型在生成时依然会出现信息遗漏。最好的办法是让 Agent 把记忆写到一个固定的 Markdown 文件里,每次迭代后强制更新。
我在模拟项目 X 里使用的记忆文件名叫 AGENT_MEMORY.md,结构大致如下:
# 项目级信息 - 项目架构: 服务端 / 网关 / 管理端 - 构建命令: make build # 当前任务 - 目标: 将用户认证模块的 Redis 缓存替换为本地缓存 - 验收标准: - 认证响应 P95 下降 30% - 原有 Redis 配置不再被加载 # 已完成决策 - 2025-02-01: 确定使用 singleflight 模式,避免缓存击穿 - 2025-02-01: 不在网关层做缓存,保持网关无状态 # 当前修改文件 - services/auth/auth_service.go (重构中) - services/auth/cache_local.go (新增) # 下一步待办 - 编写本地缓存失效的测试用例 - 移除旧 Redis 依赖这个文件会在每次对话开始时被读取,也会在每轮迭代结束时被更新。实际操作下来,我觉得最有用的部分是“已完成决策”和“当前修改文件”。Agent 一旦在后续对话中开始偏离方向,我只需要指一下这个文件,它就能迅速回到轨道上。
2.4 检索策略选型:关键词、语义还是代码结构
上下文工程不能只靠预设文件,Agent 在执行过程中必须主动检索。问题是,怎么检索才合理?我试过几种策略。
第一种是纯关键词检索,像 grep/ripgrep。它的优点是精确、可控、可靠;缺点是查不到语义相近但字面不匹配的内容。
第二种是语义检索,通过 embedding 把代码片段向量化,然后根据语义相似度召回。它的优点是能处理“找某个功能模块”这类模糊查询;缺点是代码语义和自然语言语义差别很大,召回结果有时候很奇怪,而且向量检索往往不能感知文件层级、函数定义、作用域这类代码结构信息。
第三种是基于代码结构信息的检索,比如通过 AST 分析拿到函数定义、调用关系、依赖关系。这种检索最接近开发者实际的浏览方式,它可以回答“哪些地方调用了这个函数”,而不仅仅是“哪个文件包含这个字符串”。
在实际项目中,我很少只用一种策略。通常的做法是:先用 ripgrep 做精确匹配,快速锁定关键词出现的位置;再用代码结构信息做调用链分析,确认影响范围;最后在候选文件较少的情况下,才使用语义检索扩展“这段逻辑有没有类似实现”。检索结果到手之后,还需要做一次筛选:只把与当前任务相关的函数片段放入局部层,而不是整文件塞入。
关于向量库,我的经验是:对于百万行仓库,先别急着把全部代码做 embedding。更好的做法是对核心模块、关键接口、历史变更记录做索引,代码正文仍以关键词检索为主。不然索引建完,Agent 查出来的还是“似曾相识但无法直接使用”的代码片段,反而浪费上下文。
3. 落地实操:在百万行仓库里让 Agent 保持清醒
3.1 第一步:先建仓库索引,再让 Agent 开工
让 Agent 直接在上百万行的代码里自由探索,几乎一定会迷路。我在每次启动新任务前,会先做一次仓库级扫描,并把扫描结果整理成精确的地图。
这里说的扫描不是把代码读一遍,而是生成结构索引。我常用的命令组合是:
# 查看顶层结构,限制目录深度 tree -L 3 --dirsfirst | head -n 200 # 精确匹配函数定义 rg -n "^func |^def |^class |^struct " --type go --type py -g '!vendor' -g '!node_modules' # 生成本地符号索引 ctags -R --languages=Go,Python --exclude=node_modules --exclude=vendor .这几条命令的目的不是生成一份大而全的清单,而是让 Agent 在开始改代码之前,先知道三个信息:
- 仓库的分层是什么,业务代码放在哪里,公共库在哪里;
- 与它要改的功能相关的函数、类型定义在哪些文件;
- 哪些目录是生成代码或第三方依赖,不应该碰。
我通常会把这些结果整理成一个“仓库地图”段落,直接放入系统提示或首轮用户消息里。如果 Agent 工具本身支持“自定义上下文”或“项目说明文件”,我会让这份地图常驻其中。
比较耗时但值得做的一步:给 Agent 写一个精简版 ARCHITECTURE.md,内容是模块职责、核心链路、常用端口、部署方式。它不需要很长,两三页就够,但对 Agent 后续所有操作都有正向影响。
3.2 第二步:把大任务拆成小任务,每个任务都带独立上下文清单
一个百万行仓库的改造任务,如果整体交给 Agent 一口气跑完,中途失忆几乎是必然的。我现在的工作方式是把大任务拆成一系列可验证的小任务,每个小任务都有明确输入和输出。
举个例子,假设任务是把整个订单服务的 SQL 查询统一改为预编译。我不会让 Agent 直接去“重构整个订单服务”,而是拆成五个小任务:
- 找出所有包含动态字符串拼接 SQL 的代码位置。
- 为每个位置定义参数化查询接口。
- 修改核心订单查询函数,接入新的参数化接口。
- 修改订单状态更新函数,接入新的参数化接口。
- 运行全部相关测试,并检查是否还有遗漏的原始拼接 SQL。
每个小任务开始时,我都会向 Agent 注入一份明确的“任务卡”。任务卡里包含任务目标、涉及的文件清单、你应该先读哪几个函数、不要修改哪些文件、验收命令是什么。任务卡特别重要的一行是“已完成的上一步结果摘要”,这样即使 Agent 是全新会话,也能快速衔接前序工作。
3.3 第三步:控制工具返回,别让无关信息挤占上下文
Agent 失忆的原因之一,是它的上下文被工具返回内容淹没。比如你让它读一个文件,它可能直接把整个文件丢进上下文;你让它搜索一个关键词,它可能把五百条匹配结果全部保留。
我现在会给 Agent 设定明确的工具使用约束,通常写在系统提示里:
- 读取文件时,默认只读前 200 行;如果需要更多,用参数指定行列范围。
- 搜索时限制结果为 50 条以内,按哈希去重并保留文件路径。
- 编译、构建、测试输出只保留最后 50 行,除非出现明确错误,否则不保留完整日志。
- 当一次工具调用返回了超过 300 行内容时,Agent 必须先做摘要,再决定是否继续深入。
这种约束看似限制了 Agent 的能力,实际上大大提高了它的稳定性。因为 Agent 并不是读得越多就越聪明,它只有在“读得刚好”的时候才表现最好。把每次读入的代码量控制在两百行以内,上下文窗口就能留出足够空间给任务指令、状态记忆和决策推理。
3.4 第四步:让 Agent 定期“写日记”
我在前面提到了 AGENT_MEMORY.md。它不是一次任务开始时写一次就完了,而是要每完成一个子步骤就更新一次。
具体做法是在每个小任务结束时,向 Agent 发出如下指令:“如果本子任务有任何决策、文件变更或未完成事项,先更新 AGENT_MEMORY.md,再继续下一步。”
这个操作有两个好处:第一,它强制 Agent 在做出任何实质修改后暂停并整理状态,降低“闷头乱改”的概率;第二,当对话出现意外中断,或者说序号轮次过多导致开始遗忘时,我可以直接清空历史,开一个新会话,让新 Agent 读取 AGENT_MEMORY.md 继续工作。所有旧对话被丢弃都不会有太大影响,因为记忆已经显性化到了文件里。
为了让这个机制更可靠,我还会在关键节点(比如设计接口定稿后、大范围重构前)人工编辑记忆文件,补充从 Agent 输出里没有显式体现的信息,比如“这里为什么不用缓存”这类隐含决策。
3.5 第五步:验证回路与回滚机制
上下文工程不只是管输入,也要管输出。Agent 改完代码后,如果没有任何验证环节,那它可能在你没注意的地方引入了编译错误,而它自己还以为完成得很漂亮。
在百万行仓库里,我要求每个子任务结束后必须执行三件套:编译或类型检查、相关模块测试、代码风格检查。如果仓库的构建链路很长,至少要执行针对当前改动模块的单测和一个静态检查。
其次,Agent 的改动一定要尽量独立、可回滚。我习惯在每个子任务开始时,让 Agent 先基于当前主干创建独立分支或提交一个“改动前快照”。这样一来,即使 Agent 的修改出了问题,也能快速撤回而不是手工查找它到底动了哪些文件。
我还格外强调一点:Agent 在修改文件前,需要先输出“即将修改文件清单及改动摘要”,由我确认后再动手。这一步在自动化场景下也许显得多余,但在需要稳定落地的场景里,可以有效预防“Agent 改错同名文件”这类惨案。
4. 常见问题与排查技巧实录
4.1 为什么 Agent 反复问同一个问题
我遇到最多的问题是,Agent 明明已经被告知某个接口的地址,过了几轮还会再问一遍,或者在同一个类里重复搜索同一个函数定义。
这类问题的根源通常是:相关的上下文已经被挤出了有效窗口。可能的原因有两个,一是中间插入了大量检索结果,二是 Agent 的对话历史中还保留着许多尝试性操作。
排查时我会先看当前上下文里任务目标是否依然出现的靠前位置,如果被挤出前 20%,那基本可以断定是注意力稀释。解法是在后续对话中直接重申关键约束,不要期望 Agent 自己去历史里找。更工程化的做法是在设计阶段就设置好“重复记忆”机制:每次工具调用前,Agent 先回顾一次记忆文件中与当前步骤直接相关的那一句结论。
4.2 Agent 改错了文件:同名文件与近似语义的陷阱
大仓库里经常出现几个非常相似的名字,比如 auth_service.go 和 auth_service_app.go。Agent 在快速检索时很容易定位到错误的目标。
这个问题不能完全靠“提示词更精确”解决,因为真实代码库里存在大量上下文依赖。我尝试过几个有效手段:
第一,改动前强制 Agent 输出完整的文件路径,并且用“该文件是否包含目标符号”来自我验证。具体做法是让 Agent 先执行一次 grep 确认关键符号确实存在,再动工。
第二,在局部上下文阶段只允许 Agent 持有它正在修改的那一个同名文件组,其他相似文件以路径占位符表示,避免混淆。
第三,为最容易混淆的文件在记忆文件里建立“易混清单”,直接说明哪个是真正需要改的。
4.3 对话变长之后,Agent 开始输出无中生有的 API
这是最吓人的失忆表现。明明项目里没有某个函数,Agent 却生成了一个调用代码,然后断言“项目里已有此方法”。我在一个历史项目上遇到过一次,它引用了某个根本不存在的事件总线字段,导致整个模块无法编译。
根本原因很可能是项目早期信息被大量中间内容稀释,Agent 进入了“补全”模式,而不是“检索”模式。它会用自己训练时见过的通用代码模式来填补记忆空白。
这类问题的有效防线有两个。第一个是设置“未知即检索”的硬规则:Agent 在提到任何项目内符号之前,必须先用搜索工具确认它真实存在;如果不确定,要在回复里明确说“未在仓库中找到该符号”。第二个是分段会话:当对话运行超过一定轮数(我一般控制在 30 到 50 轮之间)就主动开启新会话,用记忆文件延续任务,而不是一直挂长对话。
4.4 修改冲突和丢失:Agent 不读最新状态
当多个 Agent 或人工并行修改同一个文件时,很容易出现覆盖。Agent 可能在一开始读过某个文件的旧版本,然后基于旧版本做改动,把其他人的新提交覆盖掉了。
解决这个问题的核心是:让 Agent 在每次修改文件前重新读取一次最新版本,而不是依赖上下文里的旧版本。我会专门在系统提示里加一条:“修改任何文件之前,先执行一次 git status 和 git diff 检查当前工作区状态;如果文件已发生变化,重新读取后再修改。”
另外,修改步骤尽量控制在单个文件内完成,涉及多个文件的大改动要拆成多个提交。每个提交的 message 里写清楚上下文,方便出问题时定位回滚。
4.5 检索不到关键文件:索引与关键词的双重失灵
有些时候,Agent 明明知道某个关键词存在,但无论怎么搜索都找不到对应文件。检查之后发现原因通常是:文件被 .gitignore 忽略了、文件扩展名不在默认搜索范围、或者代码里使用了别名路径。
我的排查顺序是:先检查搜索命令是否包含了所有目标目录,再检查是否有 symlink 指向实际代码文件,然后检查是否有生成代码目录被自动排除。这个问题在工程上很好解决,只要在 Agent 的搜索工具配置里显式加上“包括 .mjs、.ts、.vue、.proto”等扩展名,并关闭对某些无关目录的排除。
大部分检索失灵都发生在“Agent 使用默认配置”时,日常使用中稍微调一下工具参数就能解决。
4.6 上下文工程速查表
下面这份速查表是我在实际工作中总结出来的,直接打印出来贴在工位上也不为过:
| 典型问题 | 典型表现 | 核心原因 | 推荐解法 |
|---|---|---|---|
| 重复提问 | 多次搜索同一函数 | 上下文被挤占 | 增加记忆文件,强制在工具调用前回顾 |
| 改错文件 | 改到同名文件 | 定位不精确 | 修改前输出文件路径 + 符号验证 |
| 输出无中生有的 API | 引用不存在的函数 | 信息空白补偿 | 设置“未知即检索”规则 |
| 覆盖他人修改 | 旧版本覆盖新提交 | 没有读取最新状态 | 修改前重新拉取并检查 git status |
| 搜索不到文件 | 频繁定位失效 | 索引或扩展名配置问题 | 检查搜索工具配置,显式添加扩展名 |
| 对话越长越乱 | 早期约束被无视 | 长上下文注意力稀释 | 主动剪断对话,用记忆文件延续 |
这张表还有一个隐藏的用途:每当你觉得 Agent “变笨了”,先别急着换模型或调 prompt,而是按顺序排查一遍上下文管道的每个环节。百分之八十的“失忆”都不是模型退化,而是上下文管道某个环节堵住了。
实话说,把上下文工程这套流程真正跑起来之后,我的开发节奏发生了明显变化。以前我总要盯着 Agent 的一举一动,生怕它跑偏;现在我可以把很大一部分“定位代码、梳理调用链、修修改改”的事交给它,我只负责拆任务、审记忆、主持验收。这个转变不是因为我选了某个更聪明的模型,而是因为我给了 Agent 一张不会迷路的地图,一套随时可读的笔记,以及一条不断验证和回退的路径。
如果你也想在自己的百万行仓库里跑稳 Agent,我建议从两件事入手:第一,从今天开始维护一份 AGENT_MEMORY.md,哪怕一开始只有三行;第二,拒绝“一把梭”式的大任务投喂,把一个大型重构拆成五个你能随时检查的小步骤。只要坚持这两点,你大概率会和我一样发现,Agent 其实并没有那么容易失忆。