1. 为什么我要自己造一套 AI 编程代理工具链
去年下半年开始,我几乎把日常开发流程都交给了 AI 编程代理。一开始体验确实惊艳,但用了两三个月之后,问题开始集中爆发:代理记不住项目约定,每次都要重新解释一遍架构;改一个函数经常牵一发动全身,因为它根本不知道这个符号在哪些地方被引用;最要命的是 token 消耗完全失控,月底一看账单,比我自己写代码还贵。
我试过市面上不少现成方案,但总有几个绕不过去的坎。要么是闭源的黑盒,出了问题只能等更新;要么是功能太单一,记忆、上下文、成本控制各管各的,拼在一起反而更乱。折腾了大概两个月,我决定干脆自己动手,把踩过的坑一个个填上,最后沉淀成了 9 个独立仓库,全部用 MIT 协议开源。
这套工具链解决的核心问题其实就三件事:让代理记住该记的、看懂该看的、算清该算的。记忆层负责跨会话保留项目约定和决策历史;代码地图层负责构建符号级的依赖关系,让代理改代码之前先知道影响面;token 记账层负责实时统计每次调用的消耗,把成本可视化。三者串起来,才是一个能长期跑下去的编程代理工作流。
这篇文章适合两类人看:一类是正在用 AI 编程代理、但被上下文丢失和成本失控折磨的开发者;另一类是想自己搭一套可控工具链、不愿意被单一平台绑死的团队。我会把每个模块的设计思路、关键实现、以及实际跑下来踩到的坑都讲清楚,代码片段和配置都会给到,你可以直接抄作业。
2. 记忆层:让代理跨会话记住项目约定
2.1 为什么简单的对话历史不够用
大多数人用 AI 编程代理的方式,就是把当前文件或者最近几轮对话丢进去。这种做法在单文件小改动上没问题,但一旦涉及跨文件重构,代理就会开始胡言乱语。根本原因在于,对话历史是线性的、易失的,而项目知识是网状的、需要持久化的。
我举个实际例子。我的项目里有一条约定:所有对外暴露的 API 响应必须用统一的Result<T>包装,错误码走枚举而不是字符串。这条约定写在文档里,但代理每次新开会话都不知道。结果就是它时不时给我生成直接返回裸对象的代码,我还得手动改。更麻烦的是,有些约定是隐性的,比如某个模块禁止直接依赖另一个模块,这种规则连文档都没写,只存在于老员工的脑子里。
所以记忆层要解决的不是"记住上一句话",而是"记住这个项目的规则和决策"。这两者是完全不同的问题。前者是上下文窗口管理,后者是知识库构建。
2.2 记忆的三种类型与存储结构
我把记忆分成三类,分别用不同的存储策略:
| 记忆类型 | 内容示例 | 存储方式 | 生命周期 |
|---|---|---|---|
| 事实记忆 | 项目用了什么框架、目录结构 | 结构化 JSON | 长期,随项目变更更新 |
| 规则记忆 | 编码规范、架构约束 | Markdown + 标签 | 长期,手动维护 |
| 决策记忆 | 为什么选 A 不选 B | 带时间戳的日志 | 长期,只追加不修改 |
事实记忆我放在.agent/facts.json里,结构大概是这样:
{ "framework": "custom-mvc", "entry": "src/main.ts", "modules": { "auth": {"path": "src/auth", "depends_on": ["db", "cache"]}, "order": {"path": "src/order", "depends_on": ["db", "auth"]} }, "conventions": { "response_wrapper": "Result<T>", "error_style": "enum" } }规则记忆用 Markdown 写,因为人也要读。每条规则打上标签,比如#architecture、#style、#security,代理在检索时按标签过滤。决策记忆最关键,我用 JSONL 格式,每行一条,包含时间、决策内容、原因、影响范围。这样代理在遇到类似问题时,可以回溯"上次我们为什么这么做"。
提示:决策记忆一定要只追加不修改。我一开始图省事直接改旧记录,结果后来排查问题时完全理不清哪条决策是什么时候生效的,血的教训。
2.3 检索策略:不是把所有记忆都塞进去
记忆存下来只是第一步,怎么在合适的时机把合适的记忆喂给代理,才是真正影响效果的地方。我试过全量注入,结果上下文窗口被撑爆,代理反而抓不住重点。后来改成两级检索:
第一级是基于当前操作文件的路径匹配。代理要改src/order/service.ts,我就把order模块相关的规则和决策优先捞出来。第二级是基于关键词的语义匹配,用简单的 TF-IDF 就够了,不需要上向量数据库,因为项目内的术语重复率很高,词频统计效果意外地好。
实测下来,每次注入的记忆控制在 800 到 1500 token 之间效果最好。太少代理会犯低级错误,太多它会忽略关键信息。这个区间是我反复调出来的,不同项目可能略有差异,但量级可以参考。
2.4 记忆更新的触发时机
记忆不能靠手动维护,否则一定会烂掉。我设计了三个自动触发点:
- 代理完成一次任务后,如果这次任务涉及新的文件或模块,自动更新事实记忆里的模块依赖。
- 用户显式纠正代理时,比如我说"不对,这里应该用 Result 包装",系统会把这条纠正转成一条规则记忆,打上
#correction标签。 - 每次 git commit 时,解析 commit message,如果包含
decision:前缀,就提取成决策记忆。
第三个触发点是我最喜欢的,因为它把记忆维护和日常开发流程绑在了一起,不需要额外操作。你只要在 commit 时多写一句decision: 选 X 因为 Y,记忆就自动沉淀了。
3. 代码地图:让代理改代码前先看懂影响面
3.1 符号级依赖比文件级依赖有用得多
大部分代码索引工具停留在文件级别,告诉你 A 文件 import 了 B 文件。但代理真正需要知道的是:我要改的这个函数,被哪些地方调用了?改完之后哪些测试会挂?文件级依赖给不了这个答案。
所以代码地图必须做到符号级。我用 TypeScript 的编译器 API 做解析,因为项目本身是 TS 写的,而且 TS 的 AST 信息足够丰富。核心思路是遍历所有源文件,提取每个导出符号的定义位置、类型签名、以及所有引用点,构建一张有向图。
interface SymbolNode { id: string; // 唯一标识,格式 module:symbolName kind: 'function' | 'class' | 'interface' | 'const'; signature: string; // 类型签名 file: string; line: number; references: string[]; // 引用它的符号 id 列表 }这张图存在.agent/codemap.json里,每次代码变更后增量更新。增量更新是关键,全量重建在中等规模项目上要几十秒,代理等不起。
3.2 影响面分析的实现细节
有了符号图,影响面分析就是一次图遍历。代理要改某个符号时,我先做反向遍历,找出所有直接和间接引用它的符号,然后按距离排序。距离为 1 的是直接调用者,必须检查;距离为 2 的是间接影响,需要留意;再远的就只做提示。
这里有个坑:循环依赖会让遍历陷入死循环。我加了 visited 集合去重,同时限制最大深度为 5。超过 5 层的依赖关系,实际上已经很难追踪了,强行展开只会让代理困惑。
另一个细节是测试文件的特殊处理。测试文件里的引用不算"生产影响",但它们是验证改动的关键。所以我在图里给测试文件单独打标,影响面分析时把相关测试单独列出来,提示代理"改完记得跑这几个测试"。
3.3 地图与记忆的联动
代码地图和记忆层不是孤立的。当代理要修改一个符号时,我会同时做两件事:从地图里捞影响面,从记忆里捞这个模块的规则和决策。两者拼在一起,才是完整的上下文。
举个例子,代理要改order模块的calculateTotal函数。地图告诉它这个函数被 3 个地方调用,其中 1 个是测试;记忆告诉它这个模块有"金额计算必须用整数分"的规则。两条信息合起来,代理就知道改的时候要小心精度问题,并且改完要跑那个测试。
这种联动是我这套工具链里最满意的设计之一。单独看地图或记忆都有价值,但合起来才真正解决了"代理乱改代码"的问题。
3.4 地图的更新成本控制
符号图最大的敌人是更新成本。每次保存文件都全量重建,CPU 直接拉满。我的做法是监听文件变更事件,只重新解析变更的文件,然后做局部图更新。局部更新的难点在于跨文件的引用关系,比如 A 文件删了一个导出,B 文件的引用就悬空了。
处理方式是维护一个反向索引:从符号 id 映射到定义它的文件。文件变更时,先找出这个文件定义的所有符号,把它们从图里摘掉,重新解析后再插回去。引用关系通过重新扫描变更文件的 import 语句来修复。实测在 500 个文件左右的项目上,单文件变更的更新耗时在 200 毫秒以内,完全可以接受。
4. Token 记账:把成本从黑盒变成仪表盘
4.1 为什么必须自己记账
用第三方 API 的时候,平台一般会给你一个总消耗,但不会告诉你每次调用具体花了多少、花在哪个环节。这就导致优化无从下手。我一开始以为大头在代码生成,记账之后才发现,真正吃 token 的是每次调用都重复注入的那一大段系统提示和记忆内容。
自己记账的核心价值在于归因。我要知道每一分钱花在了哪里:是系统提示太长,还是记忆注入太多,还是代理反复读同一个文件。只有归因清楚了,优化才有方向。
4.2 记账的数据结构
每次调用 API,我都记录一条结构化日志:
{ "timestamp": "2025-01-15T10:23:45Z", "session_id": "sess_abc123", "phase": "planning", "model": "default", "input_tokens": 3200, "output_tokens": 450, "breakdown": { "system_prompt": 800, "memory_injection": 1200, "codemap_context": 600, "user_input": 200, "file_content": 400 }, "cost": 0.0234 }breakdown字段是关键,它把输入 token 拆成了几个来源。这样我就能一眼看出,如果memory_injection占比过高,就该去优化记忆检索策略了。
4.3 按阶段和会话聚合
原始日志太细,看不过来。我做了两个维度的聚合:按阶段(planning、coding、reviewing)和按会话。按阶段聚合能看出哪个环节最费钱,按会话聚合能看出哪次任务整体超标。
聚合结果输出成一个简单的 HTML 报告,每次会话结束自动生成。报告里有个"成本瀑布图",直观展示 token 从系统提示到最终输出的流向。这个图帮我发现了一个大问题:planning 阶段居然占了总消耗的 40%,因为代理在规划时会反复读取大量文件。后来我改成 planning 阶段只给代码地图的摘要,不给完整文件内容,消耗直接降了一半。
4.4 预算控制与告警
光记账不够,还得能控制。我给每个会话设了预算上限,默认是 0.5 美元。当消耗达到 80% 时,系统会在代理的上下文里插入一条提示:"预算即将耗尽,请优先完成核心任务"。达到 100% 时,直接中断并保存当前状态。
这个机制救过我好几次。有一次代理陷入了一个死循环,反复尝试同一个失败的修复方案,token 哗哗地烧。预算告警触发后我及时介入,手动调整了方向,省下了一大笔。
注意:预算阈值不要设得太死。我一开始设 0.2 美元,结果正常任务经常被中断,反而影响效率。后来调到 0.5 美元,配合 80% 的软告警,体验就顺了。
5. 九个仓库的分工与协作方式
5.1 仓库划分的逻辑
很多人问我为什么拆成 9 个仓库而不是一个 monorepo。原因有两个:一是每个模块的更新频率和依赖完全不同,记忆层几乎每周都在改,代码地图相对稳定;二是开源之后,不同的人只关心其中一两个模块,拆开更利于独立使用和贡献。
9 个仓库大致分成三组:
- 核心层(3 个):记忆引擎、代码地图构建器、token 记账器。这三个是基础,其他都依赖它们。
- 适配层(3 个):分别对接不同的代理运行时、不同的模型接口、不同的编辑器。适配层的作用是把核心层的能力暴露给具体环境。
- 工具层(3 个):命令行工具、报告生成器、配置管理器。这些是给人用的,不直接参与代理运行。
5.2 核心层之间的数据流
三个核心模块通过一个约定好的目录结构交换数据,都在.agent/下:
.agent/ facts.json # 事实记忆 rules/ # 规则记忆,按标签分文件 decisions.jsonl # 决策记忆 codemap.json # 符号依赖图 ledger/ # token 记账日志,按天分文件 config.json # 全局配置这种基于文件系统的解耦方式,好处是每个模块可以独立运行、独立测试。记忆引擎不需要知道代码地图的存在,它只管读写自己的文件。坏处是并发写入需要加锁,我用了一个简单的文件锁机制,写入前先获取锁,写完释放。
5.3 适配层的设计要点
适配层最容易写成一坨意大利面,因为每个运行时的接口都不一样。我的做法是定义一个统一的抽象接口,适配层只负责把具体运行时的调用翻译成这个接口。
interface AgentRuntime { onBeforeCall(context: CallContext): Promise<CallContext>; onAfterCall(result: CallResult): Promise<void>; getFileContent(path: string): Promise<string>; applyEdit(edit: Edit): Promise<void>; }核心层只跟这个接口打交道。想接入新的运行时,实现这个接口就行,核心层一行不用改。这个设计让后来接入第二个运行时的时候,我只花了半天。
5.4 工具层的实用功能
工具层里我最常用的是命令行工具,几个高频命令:
agent mem search <keyword>:搜索记忆,快速找回某条决策。agent map impact <symbol>:查看某个符号的影响面。agent ledger report --today:看今天的 token 消耗报告。agent config set <key> <value>:改配置,不用手动编辑 JSON。
这些命令看起来简单,但省了大量时间。尤其是map impact,改代码前跑一下,心里有底。
6. 实际跑下来的效果与踩过的坑
6.1 效果数据
在一个约 800 个文件的中型项目上,我对比了使用工具链前后的情况。任务完成率从大概 60% 提升到 85% 左右,这里的"完成"指的是代理一次改对、不需要我手动修正。token 消耗方面,单次任务平均从 1.2 万降到 7000 左右,主要省在记忆注入的精准化和 planning 阶段的上下文裁剪。
最明显的变化是返工率。以前代理改完代码,我经常要花时间检查它有没有破坏别的地方。现在有了影响面分析,代理自己会提示"这个改动影响 3 个调用点,已一并处理",我检查起来快多了。
6.2 记忆污染问题
踩过最大的坑是记忆污染。有一次我随口纠正了代理一句"这里用 var 就行",结果这条被当成规则记忆存了下来,之后代理在所有地方都开始用 var,完全无视了项目的 let/const 规范。问题出在我的纠正触发逻辑太宽松,任何纠正都存成规则。
修复方式是加了一个确认步骤:纠正被记录后,不会立即生效,而是进入"待确认"状态,下次会话开始时提示我确认。确认后才转正。这个改动之后,记忆污染基本没再出现过。
6.3 代码地图的误报
符号图偶尔会误报影响面,尤其是涉及动态导入或者反射调用的时候。TS 的静态分析看不到运行时的动态引用,所以地图会漏掉一些实际调用点。我的处理是在地图里标注"静态分析可能不完整"的符号,代理看到这个标注会额外谨慎,主动去搜索相关代码。
这个坑提醒我,静态分析再强也有边界。工具的价值不是给出绝对正确的答案,而是给出足够好的线索,让代理知道该往哪个方向查。
6.4 记账的精度问题
token 计数在不同模型上有细微差异,尤其是涉及多模态输入的时候。我一开始用字符数除以 4 来估算,误差能到 20%。后来改成调用模型提供的计数接口,精度上来了,但每次调用多了一次网络往返,有延迟。
折中方案是:日常用估算,误差可接受;关键决策点用精确计数。这样既保证了速度,又保证了重要环节的准确性。
7. 如果你想自己搭一套,我的几点建议
7.1 从记账开始,而不是从记忆开始
很多人一上来就想做记忆系统,觉得那个最酷。但我的经验是,先做记账。因为记账能立刻给你反馈,让你知道钱花在哪,优化方向自然就出来了。记忆系统做得好不好,很难量化,容易陷入自我感觉良好的陷阱。
记账系统一天就能搭起来,一个日志文件加一个聚合脚本的事。但它带来的信息量,能指导你后面所有的优化决策。
7.2 记忆的粒度宁粗勿细
我一开始把记忆切得很细,每条规则都很具体。结果检索的时候匹配不上,因为代理的表述和记忆的表述对不上。后来改成粗粒度,一条规则覆盖一类情况,匹配率反而高了。
比如"所有 API 响应必须用 Result 包装"这一条,比"getUser 接口返回 Result"、"createOrder 接口返回 Result"分开写要好得多。粗粒度规则的复用性高,维护成本也低。
7.3 代码地图不必追求全语言支持
我最初想做一个支持所有主流语言的代码地图,后来发现根本不现实。不同语言的 AST 差异太大,维护成本爆炸。最后我聚焦在 TypeScript 上,做深做透。如果你的项目主要是某一种语言,就先把那一种做好,其他的用文件级依赖兜底。
7.4 预算控制要留缓冲
前面提过,预算阈值别设太死。我的建议是,先跑一周不做任何限制,统计出正常任务的消耗分布,然后取 75 分位数作为默认预算。这样大部分任务不会触发告警,只有异常情况才会被拦下来。
7.5 开源之后的心态
9 个仓库全部 MIT 开源之后,收到不少反馈。有人提 issue 说某个模块的设计不合理,有人直接提 PR 改代码。我的心态是:核心层保持稳定,适配层欢迎各种贡献。因为核心层一旦乱改,整个工具链的行为都会变,而适配层本来就是为不同环境服务的,多样性是好事。
如果你也想开源类似的工具链,我的建议是先把文档写好再开源。我一开始文档很潦草,结果前两周的 issue 全是"怎么用"这类问题,真正有价值的反馈被淹没了。后来补了详细的 README 和示例,情况才好起来。
这套工具链到现在跑了大概半年,中间重构过两次,踩的坑基本都填上了。它不是什么银弹,但确实让我的 AI 编程工作流从"能用"变成了"好用且可控"。如果你也在被类似的问题困扰,不妨从记账模块开始试试,那个见效最快。