用init.d思路管理AI编码代理状态:抗上下文压缩的工程实践
发布时间:2026/10/13 15:16:01 锦皓数字建站

1. 从一次上下文截断事故说起1.1 问题现场当编码代理突然“失忆”事情发生在几个月前我手头有一个持续迭代了将近三周的中型代码仓库大概四百多个文件包含前端、后端、构建脚本和一堆历史遗留的配置文件。我给它配了一个 AI 编码代理让它帮我做跨文件的批量重构——把一套旧的模块导入方式统一替换成新的路径别名方案。前两个小时一切正常。代理能准确找到每个引用点改完还会跑一遍类型检查报告哪些地方漏了。但到了某个临界点之后它开始出现一种很诡异的行为明明五分钟前刚改过的文件它又改回去了明明已经确认过的接口签名它又按旧版本去调用。我一开始以为是模型本身的能力波动换了几个不同的提示词模板甚至把温度调到最低问题依旧。后来我把整个会话的 token 消耗拉出来看才发现真正的原因上下文窗口被撑爆了。当对话历史加上代码文件内容超过模型能承受的上限时系统会自动做一次“上下文压缩”——把早期的对话摘要化把大段代码截断成片段。这个过程对代理来说就像做了一次脑叶切除术它还记得“我在做重构”这个事实但具体改过哪些文件、哪些接口已经迁移完毕全部变成了模糊的摘要。这就是标题里说的“20 万 token 的脑叶切除术”。20 万 token 是很多主流模型单次上下文的上限量级而我的项目加上对话历史早就越过了这条线。代理不是变笨了是它的记忆被切掉了。1.2 为什么常规的“记忆方案”救不了场遇到这种问题第一反应通常是上向量数据库或者外部记忆层。我试过效果有限。原因在于编码代理需要的不是“语义相似的历史片段”而是精确的状态。它需要知道“文件 A 的第 37 行已经改成了 X”而不是“我们讨论过关于文件 A 的修改”。向量检索返回的是模糊匹配对于代码这种要求逐字精确的场景模糊匹配等于没有匹配。另一个思路是把所有状态写进一个外部文件每次让代理去读。这个方向是对的但实现方式很关键。如果只是简单追加日志文件会越来越大最后读文件本身又变成一次上下文爆炸。我需要的是一个有生命周期管理的状态系统该启动的启动该停止的停止该清理的清理该持久化的持久化。这让我想到了 Unix 的 init.d 机制。传统 Unix 系统里init 进程负责按顺序启动各个服务每个服务有自己的启动脚本、停止脚本和状态管理。服务之间互不干扰一个服务挂了不会影响另一个。更重要的是init 系统有一套明确的“运行级别”概念系统启动时进入某个级别只启动该级别需要的服务切换级别时停掉不需要的启动新需要的。这套思路完全可以搬到 AI 编码代理的状态管理上。代理的“记忆”不应该是一坨不断膨胀的对话历史而应该是一组独立的、有明确生命周期的状态服务。每个服务负责一块具体的记忆当前任务列表、已修改文件清单、待验证的接口变更、已知的坑和约束。当上下文需要压缩时压缩的是对话历史但这些状态服务不受影响因为它们存在于对话之外。1.3 《记忆碎片》给的叙事启发电影《记忆碎片》的主角患有顺行性遗忘症无法形成新的长期记忆。他的应对方式是把关键信息纹在身上把即时任务写在拍立得照片上把复杂推理做成一套可重复执行的流程。他不需要记住“我为什么要做这件事”他只需要在每次“醒来”时按照身上的纹身和手头的照片继续执行下一步。这个叙事结构对 AI 代理的状态管理有直接启发。代理每次被上下文压缩后相当于一次“醒来”。它不需要记得完整的对话历史它需要的是纹身级信息那些绝对不能丢的核心约束比如“不要修改数据库迁移文件”“所有 API 变更必须同步更新类型定义”。这些应该写在一个代理每次启动都会读的固定位置。拍立得级信息当前正在进行的任务和最近几步的操作记录。这些有明确的时效性做完就归档。流程级信息一套可重复执行的检查清单比如“改完代码后跑哪几个命令验证”。这些不依赖记忆依赖的是脚本化的流程。把这三层信息用 init.d 式的服务管理起来代理就对上下文压缩免疫了。压缩可以清掉对话历史但清不掉纹身、拍立得和流程脚本。2. 整体架构设计用 init.d 思路管理代理状态2.1 核心设计原则状态与对话分离整个方案的第一原则是状态与对话分离。对话历史是易失的、会被压缩的、不可靠的。状态是持久的、结构化的、可验证的。代理在每次执行动作前先读状态执行完动作后写状态。对话只是用来传递“当前这一步要做什么”的临时通道。这个原则听起来简单但落地时需要解决几个具体问题。第一状态存在哪里如果存在对话里那和没分离一样。必须存在文件系统里代理通过读写文件来维护状态。第二状态怎么组织不能是一个大 JSON 文件因为读写整个文件会随着状态增长变得越来越慢而且并发写入容易冲突。需要按功能拆分成多个小文件每个文件对应一个“服务”。第三状态怎么更新不能靠代理“记得”去更新需要有一套触发机制让代理在特定动作后自动执行状态更新脚本。我最终的方案是在项目根目录下建一个.agent-state/目录里面按服务拆分子目录。每个服务目录里有三个核心文件status当前状态、start.sh启动逻辑、stop.sh停止逻辑。代理通过一个统一的入口脚本agentctl来管理这些服务就像系统管理员用service命令管理 init.d 服务一样。2.2 目录结构把记忆拆成可管理的服务单元具体目录结构是这样的.agent-state/ ├── agentctl # 统一管理入口 ├── services/ │ ├── task/ # 当前任务服务 │ │ ├── status │ │ ├── start.sh │ │ └── stop.sh │ ├── files/ # 已修改文件服务 │ │ ├── status │ │ ├── start.sh │ │ └── stop.sh │ ├── constraints/ # 约束条件服务 │ │ ├── status │ │ ├── start.sh │ │ └── stop.sh │ └── verify/ # 验证流程服务 │ ├── status │ ├── start.sh │ └── stop.sh └── logs/ └── agent.log每个服务的status文件是一个纯文本文件格式简单到代理不可能解析错。比如task/status可能是ACTIVE task_id: refactor-imports-001 description: 将旧模块导入替换为路径别名 progress: 37/120 files next: src/components/Button.tsxfiles/status记录已修改文件清单每行一个文件路径加修改类型。constraints/status记录硬约束比如“不要动 migrations 目录”。verify/status记录验证命令和最近一次验证结果。这种拆分的好处是每个文件都很小读写快每个服务独立一个服务出问题不影响其他代理只需要读它当前需要的那几个服务不需要把所有状态都塞进上下文。2.3 生命周期管理start、stop 与运行级别init.d 的精髓在于生命周期管理。一个服务不是永远开着的它有明确的启动和停止时机。对应到代理状态管理每个服务也有自己的生命周期。以task服务为例。当代理开始一个新任务时调用agentctl start task这个命令会执行task/start.sh把任务信息写入task/status并把状态标记为 ACTIVE。当任务完成或需要切换任务时调用agentctl stop task执行task/stop.sh把当前任务归档到logs/清空task/status。files服务的生命周期和任务绑定。任务启动时files也启动开始记录修改。任务停止时files把本次任务修改的文件清单归档然后清空。这样下一个任务开始时files/status是干净的不会混入上一个任务的记录。constraints服务是长期运行的不随任务切换而停止。它记录的是项目级的硬约束比如代码风格、目录结构规则、禁止修改的文件。这些约束在项目生命周期内一直有效。verify服务按需启动。当代理完成一批修改后调用agentctl start verify执行验证脚本把结果写入verify/status。验证通过后调用agentctl stop verify清空状态。如果验证失败状态保留代理下次“醒来”时第一眼就能看到失败信息。这套生命周期管理的关键在于代理不需要记住任何东西它只需要在正确的时机调用正确的 agentctl 命令。即使上下文被压缩到只剩最后一条消息只要那条消息是“继续执行先读 .agent-state/services/task/status”代理就能恢复完整的工作状态。3. 核心服务实现细节与实操要点3.1 task 服务让代理永远知道下一步做什么task服务是整个状态系统的核心。它要解决的根本问题是代理在上下文压缩后如何知道当前任务是什么、做到哪了、下一步该做什么。task/status的格式我改了好几版最终定下来的是最简的键值对加进度标记。太复杂的格式代理容易解析错太简单的又不够用。最终格式如下STATEACTIVE TASK_IDrefactor-imports-001 DESC将旧模块导入替换为路径别名 TOTAL120 DONE37 NEXTsrc/components/Button.tsx NOTES已处理所有 utils 和 hookscomponents 处理到 B 开头这里有几个设计细节值得展开。第一STATE只有三个值ACTIVE、PAUSED、DONE。代理只需要判断这三个状态就知道该启动、继续还是停止。第二NEXT字段是精确到文件的不是模糊的“继续处理 components”。模糊指令会让代理在压缩后重新扫描整个目录浪费大量 token。第三NOTES字段是给人类看的也是给代理在需要时参考的但代理的决策不依赖它只依赖NEXT。task/start.sh的逻辑是接收任务描述和文件清单生成初始 status把 TOTAL 设为文件总数DONE 设为 0NEXT 设为第一个文件。task/stop.sh的逻辑是把当前 status 复制到logs/task-{TASK_ID}.done然后清空 status 文件。实操中有一个关键点每次代理完成一个文件的修改后必须立即更新 task/status。这个更新不能靠代理“记得”而是要在代理的工作流里强制插入一步。我的做法是在代理的系统提示里写死一条规则“每完成一个文件执行agentctl update task --done 1 --next 下一个文件”。这条规则在每次上下文压缩后都会被重新加载因为它是系统提示的一部分不会被压缩掉。注意agentctl update命令要设计成幂等的。如果代理因为某种原因重复执行了同一条更新命令结果应该和只执行一次一样。实现方式是在 status 文件里记录一个递增的序号更新时检查序号避免重复计数。3.2 files 服务精确追踪每一个被改动的文件files服务解决的是“代理改过哪些文件”这个问题。在上下文压缩后代理很容易忘记自己已经改过某个文件然后重复修改或者改回去。files/status就是一个简单的追加日志每行格式是时间戳|操作|文件路径。2025-01-15T10:23:01|MODIFY|src/utils/format.ts 2025-01-15T10:25:17|MODIFY|src/utils/parse.ts 2025-01-15T10:28:44|CREATE|src/utils/alias.ts 2025-01-15T10:31:02|DELETE|src/utils/legacy.ts这个日志有两个用途。第一代理在修改一个文件前可以先查这个日志确认自己是否已经改过。如果已经改过且没有新的变更需求就跳过。第二当任务完成需要生成变更摘要时直接读这个日志就行不需要代理去回忆。files/start.sh在任务启动时清空日志并写入一个SESSION_START标记。files/stop.sh把日志归档到logs/files-{TASK_ID}.log。归档后日志文件可以被后续的代码审查工具消费生成人类可读的变更报告。这里有一个实操心得日志的写入必须由代理的每一次文件操作触发而不是靠代理事后补记。我最初的做法是让代理在完成一批修改后统一写日志结果发现代理经常漏记。后来改成在代理的文件操作工具层做拦截每次代理调用写文件或删文件的工具时工具内部自动追加一条日志。这样代理不需要“记得”写日志日志是操作本身的副产品。提示如果你的代理框架不支持工具层拦截退而求其次的方案是在系统提示里要求代理“每次文件操作后立即执行agentctl log files 操作 路径”。但这种方式依赖代理的自觉性可靠性不如工具层拦截。3.3 constraints 服务那些绝对不能丢的硬约束constraints服务记录的是项目级的硬约束这些约束在上下文压缩后最容易丢失但丢失的后果最严重。比如“不要修改 database/migrations 目录下的任何文件”“所有 API 路由变更必须同步更新 types/api.d.ts”“提交前必须跑 lint 和 typecheck”。constraints/status的格式是每行一条约束前面加一个优先级标记P0|禁止修改 database/migrations 目录 P0|API 路由变更必须同步更新 types/api.d.ts P1|提交前必须跑 npm run lint 和 npm run typecheck P1|新文件必须使用路径别名禁止相对路径超过两级 P2|组件文件必须导出默认组件和命名组件P0 是绝对不能违反的代理在每次执行动作前都应该检查。P1 是强烈建议遵守的代理在任务完成前应该检查。P2 是风格建议代理可以在方便时遵守。constraints服务不随任务切换而停止它是长期运行的。但约束本身可以动态增删。当项目规则变化时通过agentctl update constraints --add 新约束来添加通过agentctl update constraints --remove 旧约束来删除。实操中最重要的经验是P0 约束必须在代理的每一次“醒来”时被强制读取。我的做法是在代理的启动脚本里写死一条无论上下文压缩到什么程度代理恢复后的第一个动作必须是cat .agent-state/services/constraints/status。这条规则写在系统提示的最顶部确保它不会被任何压缩策略丢弃。3.4 verify 服务把验证流程脚本化verify服务解决的是“改完怎么确认没改坏”的问题。在上下文压缩后代理很容易忘记自己已经跑过验证或者忘记验证命令是什么。verify/status记录当前验证状态和最近一次结果STATEFAILED LAST_RUN2025-01-15T11:02:33 COMMANDnpm run typecheck EXIT_CODE1 ERRORSsrc/components/Button.tsx:12 - Type string is not assignable to type numberverify/start.sh执行验证命令把结果写入 status。verify/stop.sh在验证通过后清空 status。如果验证失败status 保留代理下次醒来时第一眼就能看到失败信息和具体错误。这里的关键设计是验证命令本身也存储在状态里而不是靠代理记忆。verify/start.sh从一个固定的配置文件verify/commands读取要执行的命令列表。这个文件在项目初始化时配置好之后不随对话变化。代理只需要调用agentctl start verify不需要知道具体跑什么命令。注意验证命令的输出可能很长直接写入 status 会导致 status 文件过大。我的做法是只写入错误摘要和错误数量完整输出重定向到logs/verify-{timestamp}.log。代理如果需要详细信息可以按需读取日志文件。4. 实操全流程从零搭建一套抗压缩的代理状态系统4.1 初始化建目录、写脚本、配权限第一步是在项目根目录下创建.agent-state/目录结构。我写了一个初始化脚本init-agent-state.sh内容如下#!/bin/bash set -e BASE.agent-state mkdir -p $BASE/services/{task,files,constraints,verify} mkdir -p $BASE/logs # 初始化各服务的 status 文件 echo STATEIDLE $BASE/services/task/status touch $BASE/services/files/status echo P0|禁止修改 database/migrations 目录 $BASE/services/constraints/status echo STATEIDLE $BASE/services/verify/status # 创建 agentctl 入口 cat $BASE/agentctl EOF #!/bin/bash SERVICE$1 ACTION$2 shift 2 BASE.agent-state/services/$SERVICE case $ACTION in start) bash $BASE/start.sh $ ;; stop) bash $BASE/stop.sh $ ;; update) bash $BASE/update.sh $ ;; status) cat $BASE/status ;; *) echo Usage: agentctl service start|stop|update|status [args] exit 1 ;; esac EOF chmod x $BASE/agentctl echo Agent state system initialized.这个脚本做了三件事建目录、初始化 status 文件、创建 agentctl 入口。agentctl是一个简单的分发器根据服务名和动作调用对应的脚本。初始化完成后需要在代理的系统提示里加入一段“启动协议”每次会话开始或上下文恢复后按以下顺序执行 1. cat .agent-state/services/constraints/status 2. cat .agent-state/services/task/status 3. 如果 task/status 中 STATEACTIVE读取 NEXT 字段继续执行 4. 如果 task/status 中 STATEIDLE等待新任务指令这段协议是抗压缩的核心。无论上下文被压缩成什么样只要代理还能读到系统提示它就能通过这三步恢复完整状态。4.2 任务启动把大任务拆成可追踪的单元当有一个新任务时比如“把 120 个文件的旧导入替换为新别名”不要直接把任务描述丢给代理。先做一次任务拆解生成文件清单然后通过agentctl start task启动任务服务。具体操作# 生成待处理文件清单 find src -name *.ts -o -name *.tsx | grep -v node_modules /tmp/task-files.txt # 启动任务服务 .agent-state/agentctl task start \ --id refactor-imports-001 \ --desc 将旧模块导入替换为路径别名 \ --files /tmp/task-files.txttask/start.sh会读取文件清单计算总数写入 status#!/bin/bash # task/start.sh ID DESC FILES while [[ $# -gt 0 ]]; do case $1 in --id) ID$2; shift 2 ;; --desc) DESC$2; shift 2 ;; --files) FILES$2; shift 2 ;; *) shift ;; esac done TOTAL$(wc -l $FILES) NEXT$(head -1 $FILES) cat status EOF STATEACTIVE TASK_ID$ID DESC$DESC TOTAL$TOTAL DONE0 NEXT$NEXT NOTES EOF cp $FILES files.list echo Task $ID started: $TOTAL files这个脚本把任务信息写入 status同时把文件清单保存到files.list供后续更新时查找下一个文件。4.3 执行循环代理的每一步都有据可查代理开始执行后工作循环是这样的读task/status获取NEXT字段指向的文件。读constraints/status确认没有违反 P0 约束。读files/status确认这个文件没有被改过避免重复修改。执行修改。调用agentctl log files MODIFY 文件路径记录修改。调用agentctl update task --done 1更新进度。从files.list中取下一个文件更新NEXT。重复 1-7直到DONE等于TOTAL。这个循环的每一步都是显式的、可验证的。代理不需要“记住”任何东西它只需要按照循环执行。即使在第 5 步之后上下文被压缩代理恢复后读task/status就能知道做到哪了读files/status就能知道哪些文件已经改过。task/update.sh的实现#!/bin/bash # task/update.sh DONE_DELTA0 while [[ $# -gt 0 ]]; do case $1 in --done) DONE_DELTA$2; shift 2 ;; *) shift ;; esac done source status NEW_DONE$((DONE DONE_DELTA)) NEXT$(sed -n $((NEW_DONE 1))p files.list) cat status EOF STATEACTIVE TASK_ID$TASK_ID DESC$DESC TOTAL$TOTAL DONE$NEW_DONE NEXT$NEXT NOTES EOF if [[ $NEW_DONE -ge $TOTAL ]]; then sed -i s/STATEACTIVE/STATEDONE/ status fi这个脚本读取当前 status更新 DONE 和 NEXT写回。当 DONE 达到 TOTAL 时自动把状态改为 DONE。4.4 验证与归档任务收尾的标准化流程当所有文件处理完毕后代理调用agentctl start verify执行验证。验证通过后调用agentctl stop task归档任务。task/stop.sh的逻辑#!/bin/bash # task/stop.sh source status if [[ $STATE ! DONE ]]; then echo Warning: task not done, current state: $STATE fi cp status ../logs/task-${TASK_ID}.done cp files.list ../logs/files-${TASK_ID}.list cp ../files/status ../logs/files-${TASK_ID}.log echo STATEIDLE status ../files/status echo Task $TASK_ID archived.归档后task/status回到 IDLEfiles/status清空准备迎接下一个任务。归档文件保存在logs/目录下可以用于生成变更报告或审计。提示归档时建议同时记录任务的起止时间和总耗时。这些数据在后续优化任务拆解策略时很有用。可以在task/start.sh里写入START_TIME在task/stop.sh里计算耗时并写入归档文件。5. 常见问题与排查技巧实录5.1 代理不读状态文件怎么办这是最常见的问题。代理在上下文压缩后有时会“忘记”去读状态文件直接凭模糊记忆继续操作。排查思路是检查系统提示里的启动协议是否在压缩后仍然存在。如果启动协议本身被压缩掉了那代理确实没有依据去读状态。解决方案是把启动协议写在一个不会被压缩的位置。不同代理框架的机制不同但通常系统提示的最顶部是相对安全的。如果框架支持“固定前缀”或“不可压缩区域”把启动协议放在那里。如果不支持退而求其次的方案是在每次用户消息的开头手动加上一句“先读 .agent-state/services/task/status”。虽然麻烦但有效。另一个技巧是在agentctl里加一个resume命令它一次性输出所有关键状态.agent-state/agentctl resume # 输出 # CONSTRAINTS # P0|禁止修改 database/migrations 目录 # TASK # STATEACTIVE # NEXTsrc/components/Button.tsx # DONE37/120 # LAST VERIFY # STATEPASSED代理只需要执行这一条命令就能拿到所有恢复所需的信息。这比让它分别读三个文件更不容易出错。5.2 状态文件被并发写入冲突如果代理有多个子任务并行执行或者代理框架支持并发工具调用可能会出现两个操作同时写同一个 status 文件的情况。结果是后写的覆盖先写的导致状态丢失。解决方案是给 status 文件的写入加锁。最简单的实现是用flock( flock -x 200 # 读写 status 的代码 ) 200status.lockflock是 Linux 自带的文件锁工具不需要额外安装。加锁后同一时间只有一个进程能读写 status避免冲突。如果代理框架不支持 shell 级别的锁另一个方案是把 status 文件设计成只追加的日志格式而不是覆盖写的键值对格式。追加操作在大多数文件系统上是原子的不会冲突。代理读取时解析最后一条有效记录即可。这种方案的代价是 status 文件会越来越大需要定期压缩归档。5.3 验证失败后代理反复重试同一错误验证失败后代理有时会陷入死循环跑验证、失败、改代码、再跑验证、还是失败、再改、再失败。原因是代理没有记录“我已经试过这个改法了没用”。解决方案是在verify/status里记录每次尝试的修改摘要和结果。代理在重试前先读这个记录如果发现当前改法和之前失败过的改法相同就跳过尝试不同的方案。verify/status的扩展格式STATEFAILED ATTEMPTS3 LAST_ERRORsrc/components/Button.tsx:12 - Type string is not assignable to type number TRIED_1将 Button 的 size 参数从 string 改为 number - 失败其他文件依赖 string TRIED_2在 Button 内部做类型转换 - 失败运行时错误 TRIED_3修改类型定义文件 - 失败影响范围过大代理读到ATTEMPTS3和三条失败记录后就知道不能再重复这些方案了需要换思路或者向人类求助。5.4 常见问题速查表问题现象可能原因排查动作解决方案代理重复修改同一文件files/status 未更新或代理未读取检查 files/status 是否有该文件记录在工具层拦截文件操作自动写日志代理忘记任务进度task/status 未更新或启动协议丢失检查 task/status 的 DONE 字段把启动协议放在不可压缩区域状态文件写入冲突并发操作同一文件检查是否有并行任务加 flock 或改为追加日志格式验证反复失败代理未记录失败尝试检查 verify/status 的 ATTEMPTS记录每次尝试的摘要和结果代理不读约束constraints 服务未启动或路径错误检查 constraints/status 是否存在在 resume 命令中强制输出约束任务完成后状态未归档stop 脚本未执行检查 task/status 是否仍为 ACTIVE在验证通过后自动触发 stop注意这张表里的“解决方案”都是我在实际项目中验证过的。但不同代理框架的工具调用机制不同具体实现时需要根据框架能力做调整。核心思路是一致的把状态管理从代理的“记忆”中剥离出来变成文件系统上的显式操作。6. 一些踩坑后的经验之谈这套方案跑了几个月中间迭代了四五版踩过的坑不少。最大的一个坑是过度设计。我一开始想把所有东西都状态化连代理的每一步推理都写进状态文件。结果状态文件膨胀得比对话历史还快读写开销反而成了瓶颈。后来砍掉了大部分只保留 task、files、constraints、verify 四个核心服务才稳定下来。第二个坑是状态格式太复杂。我试过用 JSON 存状态结果代理在解析时经常因为一个逗号或引号出错。后来全部改成纯文本键值对和管道分隔的行格式代理解析起来几乎不出错。代码场景下简单格式的可靠性远高于复杂格式。第三个坑是忘记清理。files/status 如果不定期归档会一直增长最后读一次要好几秒。后来在 task/stop.sh 里强制归档并清空才解决了这个问题。状态管理不只是“写”还包括“删”。该清理的不清理系统迟早会拖垮。最后一个心得是这套方案的价值不在于技术多先进而在于它把不可靠的东西变成了可靠的。对话历史是不可靠的会被压缩、会被截断、会被模型忽略。文件系统上的状态是可靠的只要脚本写对了它就在那里不会因为上下文变化而消失。对于需要长时间运行的编码代理来说这种可靠性比任何花哨的记忆机制都重要。如果你也在用 AI 代理做跨文件的重构或长期维护任务建议从最小的 task 服务开始试。先让代理学会“读任务状态、执行、更新状态”这个循环跑通了再逐步加 files、constraints、verify。不要一上来就搭全套那样调试成本太高。先跑通一个服务再扩展是我试过最稳的路径。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。