1. 这套工具链到底在解决什么问题
AI 编程代理这两年从"能补全一行代码"进化到了"能自己读仓库、改文件、跑测试",但真正把它放进日常开发流里用上一周,多数人都会撞上同一堵墙:代理每次对话都像失忆,改到第三个文件就忘了第一个文件里定义的类型;仓库一大,它就开始瞎猜文件路径,明明函数就在隔壁目录却找不到;一个任务跑下来 token 烧得飞快,月底看账单才发现钱花在了重复读取同一批文件上。
我开源的这套工具链,就是冲着这三个痛点去的。它由 9 个独立仓库组成,全部 MIT 协议,覆盖了记忆管理、代码地图、token 记账三条主线,外加一些把三者串起来的胶水层。说白了,它不是一个"大而全的框架",而是一组可以单独拆出来用的零件——你只想解决记忆问题,就只拿记忆那个仓库;你只想知道 token 花哪了,就只接记账那个模块。
适合谁来参考?如果你正在自己写 AI 编程代理、或者用现成的代理框架但总觉得"不够听话",这套东西能给你一套可落地的工程思路。如果你只是偶尔用用代码补全,那可能用不上,但里面关于上下文裁剪、代码索引的策略,读一读也能帮你理解为什么有些代理"聪明"有些"笨"。
我先把三条主线的定位讲清楚,再逐个拆开讲实现细节。整篇文章会围绕"为什么这么设计"展开,因为工具链这种东西,抄代码容易,理解取舍难。
2. 三条主线的整体设计与选型考量
2.1 为什么拆成 9 个仓库而不是一个
一开始我也想过做成 monorepo 一把梭,但实际用下来发现,AI 编程代理的各个能力模块生命周期差异太大。记忆模块可能一周迭代三次,代码地图的索引格式一旦定下来半年不动,token 记账更是几乎不用改。塞在一个仓库里,每次改记忆都要跑全量测试,CI 时间从 2 分钟涨到 11 分钟,非常难受。
拆成 9 个仓库之后,每个仓库有自己的版本号、自己的测试、自己的发布节奏。更重要的是,用户可以按需组合。我见过有人只想要代码地图,把它接进自己的代理里,完全不碰记忆模块——这种用法在 monorepo 里会很别扭,因为依赖关系被强行绑死了。
代价也有:跨仓库的接口变更需要同步发版,我一般用固定版本的 peer dependency 来锁,避免"记忆模块升级了但代码地图还在用旧接口"这种事故。这个取舍我认为是值得的,因为灵活性带来的收益远大于偶尔的版本对齐成本。
2.2 三条主线的职责边界
记忆管理负责的是"代理在多次交互之间记住什么"。它不是简单的对话历史堆叠,而是分层的:短期记忆存当前任务的临时状态,长期记忆存跨任务的项目知识(比如"这个仓库的测试命令是 pnpm test:unit"),工作记忆存当前正在编辑的文件上下文。
代码地图负责的是"代理怎么快速找到相关代码"。它把仓库解析成符号级别的索引——函数、类、类型、导入关系——然后提供查询接口。代理不需要把整个文件读进来,先查地图拿到符号位置,再精准读取那几行。
token 记账负责的是"每次调用花了多少、花在哪"。它记录每次模型调用的输入输出 token 数,按任务、按文件、按调用类型分类统计,让你能看出哪一步是烧钱大户。
三条线之间通过一个轻量的上下文对象传递数据,不互相直接依赖。这个设计让每个模块都能独立测试,也方便替换——比如你不想用我的记忆实现,换成自己的,只要实现同样的接口就行。
2.3 技术栈选择的理由
全部用 TypeScript 写,核心原因是 AI 编程代理生态里 Node 工具链最成熟,而且类型系统能帮我在跨仓库调用时提前发现接口不匹配。索引部分用了 tree-sitter 做语法解析,因为它对增量解析支持好,仓库改一个文件不需要重建整个索引。
存储层没有引入数据库,记忆和索引都用 JSON 加内存缓存。有人会问为什么不用 SQLite,我的考虑是:代理工具通常跑在开发机上,数据量不大(一个中型仓库的符号索引也就几 MB),引入数据库反而增加了部署复杂度。等数据量真的上来了再换也不迟,接口层已经抽象好了。
3. 记忆模块:让代理不再"聊完就忘"
3.1 分层记忆的具体结构
记忆模块的核心是一个三层结构。最底层是原始事件流,记录每一次交互的完整内容,只追加不修改,相当于一个日志。中间层是摘要层,定期把原始事件压缩成简短摘要,比如把"用户让我改 A 文件、我读了 A、我改了 A、测试通过"压缩成"完成任务:修改 A 文件并验证"。最上层是知识层,存放跨任务的稳定事实,比如项目约定、常用命令、架构决策。
为什么要分三层?因为代理的上下文窗口是有限的,你不能把所有历史都塞进去。原始事件流用于审计和回溯,摘要层用于给代理提供近期上下文,知识层用于提供长期背景。实际调用模型时,通常只把知识层加最近几条摘要塞进 prompt,原始事件流根本不进上下文。
这个分层借鉴了人类记忆的机制——你记得昨天午饭吃了什么(原始事件),但更记得"我不吃香菜"(知识)。代理也一样,它不需要记住每次读文件的细节,但需要记住"这个项目的配置文件在 config/ 目录下"。
3.2 摘要什么时候触发
摘要触发策略我调了很久。最初是"每 N 次交互触发一次",但 N 设小了摘要太碎,设大了上下文又爆。后来改成基于 token 预算触发:当原始事件流的 token 数超过阈值(默认 8000),就触发一次摘要,把最老的一批事件压缩掉。
压缩的时候不是简单截断,而是让模型生成摘要。这里有个坑:如果让模型自由发挥,它可能漏掉关键信息。我的做法是给一个结构化模板,要求摘要必须包含"涉及的文件、做的操作、结果状态"三个字段。这样即使摘要很短,关键信息也不会丢。
注意:摘要本身也要花 token。如果任务很短,频繁触发摘要反而更贵。所以我在实现里加了一个判断:只有当被压缩的事件 token 数大于摘要生成成本的 3 倍时才触发,否则宁可让上下文多撑一会儿。
3.3 知识层怎么写入和读取
知识层的写入有两个来源。一是显式写入,代理在完成任务后主动调用remember(key, value)把稳定事实存进去。二是隐式提取,模块会扫描摘要层,如果某个事实反复出现(比如"测试命令是 pnpm test"出现了 5 次),就自动提升到知识层。
读取的时候用简单的键值匹配加模糊搜索。键值匹配用于精确查询,比如代理明确问"测试命令是什么"。模糊搜索用于相关召回,比如代理在处理测试相关任务时,自动把测试命令相关的知识塞进上下文。
这里有个经验:知识层不要存太多东西。我见过有人把整个 README 塞进知识层,结果每次调用都带着一大堆无关内容。知识层应该只存"代理做决策时需要知道的稳定事实",控制在几十条以内最好。
3.4 记忆模块的实操接入
接入方式很简单,初始化一个 MemoryStore,然后在每次调用模型前后各调一次钩子:
import { MemoryStore } from '@toolchain/memory'; const memory = new MemoryStore({ summaryThreshold: 8000, knowledgePromotionCount: 5, }); // 调用模型前,获取要注入的上下文 const context = memory.getContextForPrompt(taskId); // 调用模型后,记录这次交互 memory.recordEvent(taskId, { type: 'file_edit', file: 'src/utils.ts', result: 'success', });getContextForPrompt返回的是一个拼好的字符串,直接塞进 system prompt 或 user message 都行。我一般放在 system prompt 末尾,因为它属于"背景知识"而不是"当前指令"。
4. 代码地图:让代理精准定位而不是瞎猜
4.1 索引什么、不索引什么
代码地图不是把整个仓库的每个字符都索引一遍,那样又慢又占空间。它只索引符号级别的信息:函数名、类名、类型名、变量名(仅顶层)、导入导出关系。每个符号记录它的位置(文件、起止行)、签名、以及它引用了哪些其他符号。
不索引的东西包括:函数体内部的实现细节、注释内容、字符串字面量。这些内容在需要的时候直接读文件就行,没必要进索引。这个取舍让索引体积控制得很好——一个 5 万行的 TypeScript 仓库,索引文件大概 2MB 左右。
为什么不做全文索引?因为 AI 编程代理找代码的方式和人类搜索不一样。人类搜关键词,代理需要的是"这个函数在哪、它依赖谁、谁依赖它"。符号级索引正好匹配这个需求,而且更新成本低——改一个函数体不影响索引,只有改签名才需要更新。
4.2 用 tree-sitter 做增量解析
tree-sitter 的好处是它能对单个文件做增量解析。仓库里改了一个文件,只需要重新解析那一个文件,然后更新索引里对应的符号条目,不需要重建整个索引。实测下来,改一个文件到索引更新完成,平均 40 毫秒左右,基本无感。
解析流程是这样的:先用 tree-sitter 把文件解析成语法树,然后遍历树提取符号节点。不同语言提取规则不同,TypeScript 提取 function/class/interface/type 声明,Python 提取 def/class,Go 提取 func/type。我目前支持了 6 种主流语言,其他语言可以按同样的模式扩展。
提示:tree-sitter 的语法文件需要和运行时版本匹配,版本不匹配会解析失败。我在仓库里锁死了语法文件版本,升级时要一起升,别单独升一个。
4.3 查询接口怎么设计
查询接口主要有四个:
findSymbol(name):按名字精确查找符号,返回位置和签名。findReferences(symbolId):查找谁引用了这个符号。findRelated(file, line):给定文件和行号,返回相关的符号(比如光标所在函数调用的其他函数)。searchSymbols(keyword):模糊搜索,用于代理不确定确切名字时。
findRelated是最常用的,因为代理经常是"我在改这个函数,它调用了哪些函数、哪些函数调用了它"。这个接口会返回一个相关符号列表,代理可以据此决定下一步读哪些文件。
4.4 地图和记忆怎么配合
代码地图和记忆模块的配合点在于:地图提供"当前代码结构",记忆提供"历史操作上下文"。比如代理要改一个函数,地图告诉它这个函数被 3 个地方调用,记忆告诉它"上次改这个函数时,调用方 B 的测试挂了"。两者结合,代理就能提前知道要重点检查 B 的测试。
实现上,地图查询结果会作为结构化数据传给记忆模块,记忆模块在生成上下文时把相关的历史操作附加上去。这个配合不是强耦合的,两个模块各自独立也能用,只是配合起来效果更好。
5. token 记账:把每一分钱花在哪看清楚
5.1 记账的粒度
token 记账记录到单次模型调用这个粒度。每次调用记录:调用类型(读代码、生成代码、生成摘要、生成索引描述等)、输入 token 数、输出 token 数、关联的任务 ID、关联的文件列表、时间戳。
为什么要记调用类型?因为不同调用类型的成本差异巨大。生成代码通常输出 token 多,读代码通常输入 token 多,生成摘要虽然调用次数少但单次输入可能很大。不分类的话,你只知道"这个任务花了 5 万 token",但不知道是花在读代码还是生成代码上,没法优化。
5.2 成本归因的实现
成本归因的核心是"把 token 数映射到具体的代码操作上"。实现方式是:每次调用模型时,把当前任务正在操作的文件列表传进来,记账模块把这次调用的 token 按文件均摊(或者按文件大小加权分摊)。
这样你就能看到"改 A 文件花了 1.2 万 token,改 B 文件花了 800 token",一目了然。如果某个文件特别烧钱,通常意味着代理反复读它——这时候就该检查是不是代码地图没索引到相关符号,导致代理只能整文件读取。
5.3 报表和告警
记账模块提供一个report(taskId)接口,返回这个任务的 token 消耗明细。我一般会把它打印成表格:
| 调用类型 | 次数 | 输入 token | 输出 token | 占比 |
|---|---|---|---|---|
| 读代码 | 23 | 45000 | 2000 | 52% |
| 生成代码 | 8 | 12000 | 18000 | 33% |
| 生成摘要 | 3 | 8000 | 1500 | 10% |
| 其他 | 5 | 3000 | 1000 | 5% |
告警方面,可以设置单任务 token 上限,超过就提示。我自己的经验值是:一个中等复杂度的任务(改 3-5 个文件),token 消耗在 3 万到 8 万之间算正常,超过 15 万就要看看是不是哪里出问题了。
5.4 记账数据的存储和清理
记账数据默认存在本地 JSON 文件里,按天分文件。为什么不存数据库?因为查询需求很简单,就是按任务或按天聚合,JSON 加内存过滤完全够用。数据量大了之后(比如积累了几十万条记录),可以定期归档,把超过 30 天的记录压缩存储。
注意:记账数据里可能包含代码片段(如果记录时带了上下文),要注意脱敏。我的实现默认只记 token 数和文件路径,不记具体内容,避免敏感信息泄露。
6. 三条线怎么串起来:一个完整任务的执行流程
6.1 任务开始时的初始化
代理接到任务后,先做三件事:从记忆模块加载相关知识(项目约定、常用命令),从代码地图加载相关符号(任务涉及的文件和它们的依赖),初始化一个记账会话。这三步是并行的,因为互相不依赖。
初始化完成后,代理手里有了:一份背景知识、一份代码结构、一个空的记账会话。接下来就是正常的"读代码-思考-改代码-验证"循环。
6.2 循环中的协作
每次循环里,代理先通过代码地图查询"当前要改的符号在哪、谁依赖它",然后决定读哪些文件。读文件之前,记忆模块会检查"这个文件之前读过吗、读的时候发现了什么",避免重复读。读完之后,记账模块记录这次读取的 token 消耗。
改代码之后,代理会调用记忆模块记录这次修改,同时代码地图更新索引(因为符号可能变了)。如果测试失败,失败信息也会进记忆,供后续参考。
6.3 任务结束时的收尾
任务结束时,记忆模块把这次任务的关键信息压缩成摘要,如果发现有稳定事实(比如"这个项目的 lint 命令是 pnpm lint"),提升到知识层。记账模块生成报表,代码地图做一次全量校验(确保索引和实际代码一致)。
这个收尾流程很重要,它决定了下一个任务能不能"站在上一个任务的肩膀上"。我见过很多代理工具,任务之间完全隔离,每次都要重新摸索项目结构,效率很低。
7. 常见问题与排查技巧实录
7.1 记忆模块的典型问题
问题一:摘要丢信息。表现是代理在后续任务里问"我们之前是不是改过这个文件",但摘要里没记。排查方法是看摘要生成时的模板是不是太宽松,导致模型自由发挥漏了字段。解决方法是收紧模板,强制要求包含文件、操作、结果三个字段。
问题二:知识层污染。表现是代理总是带着一堆无关知识,浪费 token。排查方法是看知识层里有多少条记录,超过 50 条基本就是污染了。解决方法是加一个"知识有效期"机制,长期没被引用的知识自动降级回摘要层。
问题三:摘要触发太频繁。表现是 token 消耗里"生成摘要"占比超过 20%。排查方法是看阈值设置,8000 可能对你的场景太小了。解决方法是调大阈值,或者改成基于任务复杂度动态调整。
7.2 代码地图的典型问题
问题一:索引和实际代码不一致。表现是代理按索引找到的位置去读文件,结果读到的不是想要的符号。排查方法是看索引更新时间戳和文件修改时间戳。解决方法是加一个文件监听,文件一变就触发增量更新。
问题二:跨语言引用找不到。表现是 TypeScript 文件引用了 Python 生成的类型定义,地图找不到关联。排查方法是看索引是否支持跨语言引用。目前的实现是支持的,但需要在配置里显式声明语言间的映射关系。
问题三:大仓库索引慢。表现是首次索引一个 10 万行的仓库要几分钟。排查方法是看是不是全量解析了所有文件。解决方法是支持按目录增量索引,先索引核心目录,其他目录按需索引。
7.3 token 记账的典型问题
问题一:记账数字和实际账单对不上。表现是记账显示 5 万 token,账单显示 6 万。排查方法是看是不是漏记了某些调用(比如流式调用的中间 chunk)。解决方法是确保所有模型调用都经过记账钩子,包括重试和流式。
问题二:成本归因不准。表现是某个文件显示消耗很高,但实际上没怎么读它。排查方法是看分摊算法,均摊在文件大小差异大时会失真。解决方法是改成按实际读取的字符数加权分摊。
问题三:报表太粗看不出问题。表现是只知道总消耗,不知道哪一步烧钱。排查方法是看调用类型分类够不够细。解决方法是把"读代码"再细分成"读单文件""读多文件""读整个目录"等子类。
7.4 三条线协作的典型问题
问题一:记忆和地图数据不一致。表现是记忆说"这个函数在 A 文件",地图说在 B 文件。排查方法是看是不是文件被移动了但记忆没更新。解决方法是地图更新时通知记忆模块,让记忆里的文件路径也同步更新。
问题二:记账会话泄漏。表现是任务结束了但记账会话没关闭,导致后续调用被记到旧任务上。排查方法是看会话生命周期管理。解决方法是加一个超时自动关闭机制,任务结束 5 分钟没活动就自动关闭。
问题三:初始化太慢。表现是任务开始时要等好几秒才能开始干活。排查方法是看三个模块的初始化是不是串行的。解决方法是并行初始化,并且把不紧急的部分(比如全量索引校验)延后到后台执行。
8. 一些实操心得和扩展方向
8.1 我踩过的几个坑
第一个坑是过度设计记忆层。一开始我想做一套完整的"知识图谱",结果实现复杂、查询慢、维护难。后来砍成简单的键值加模糊搜索,反而更好用。教训是:代理的记忆不需要像人脑那么复杂,够用就行。
第二个坑是索引粒度太细。最初我连函数内部的局部变量都索引,结果索引体积暴涨,查询也慢。后来只索引顶层符号,体积降了 80%,查询速度提升明显,而代理的实际需求(找函数、找类)完全能满足。
第三个坑是记账同步写。最初每次调用都同步写 JSON 文件,高频调用时 IO 成了瓶颈。后来改成内存缓冲加定期刷盘,性能好了很多,代价是进程崩溃可能丢最后几条记录——这个代价可以接受。
8.2 后续可以怎么扩展
一个方向是多代理协作。现在这套工具链是给单个代理用的,如果多个代理同时工作,记忆和地图需要加锁或做版本控制。这个我还没做,但接口层留了扩展点。
另一个方向是跨仓库索引。现在代码地图只索引单个仓库,如果项目由多个仓库组成,代理需要跨仓库找符号。这个可以通过把多个仓库的索引合并来实现,但要注意符号冲突的处理。
还有一个方向是成本预测。基于历史记账数据,预测一个新任务大概要花多少 token。这个对团队管理很有用,可以提前判断某个任务值不值得让代理做。
8.3 给想接入的人的建议
如果你只想解决一个问题,就只接对应的模块,别一上来就全接。三个模块各自独立都能工作,全接反而增加了调试复杂度。我建议的顺序是:先接代码地图(收益最直接),再接 token 记账(帮你发现问题),最后接记忆(需要一定数据积累才见效)。
接入的时候,先把配置项都调成保守值(比如摘要阈值调大、索引范围调小),跑一段时间看报表,再根据实际情况调整。别一上来就追求"最优配置",因为最优配置取决于你的具体使用场景,只有跑起来才知道。
最后,这套东西全部 MIT 协议,你可以随便改、随便用、随便商用。我开源它的目的就是让更多人少走弯路——AI 编程代理的工程化还有很长的路要走,大家一起踩坑、一起填坑,比一个人闷头搞快得多。