☰
开源AI编程代理工具链:记忆、代码地图与Token记账实践
2026/10/13 15:25:11 网站建设 项目流程

说实话,这套 AI 编程代理工具链一开始并不是为了“开源给别人用”做的,纯粹是某段时间我被市面上各种编程代理搞得有点崩溃。模型本身确实越来越聪明,但一放到真实项目里就露馅:跨会话失忆、在仓库里迷路、token 烧得飞快还不知道烧在哪。我把踩过的这些坑攒成需求,陆续写了记忆、代码地图、token 记账这几个方向的组件,最后整理成 9 个仓库,全部用 MIT 协议开源了。这篇文章把这套工具链从动机、设计思路、技术选型到实际踩坑完整讲一遍,适合正在接编程代理、或者想自己搭 agent 基础设施的朋友参考,也欢迎直接拿去改。

1. 先说我为什么折腾出这 9 个仓库

1.1 三个把我劝退的翻车现场

真正让我决定动手的,是三次发生在真实项目里的失败。

第一次是跨文件重命名重构。代码库不算特别大,十几万行,但公共函数被很多模块引用。编程代理按我的指令去改函数签名,结果它在前几轮里疯狂打开引用了这个函数的十几个文件,一个个看调用方式,把上下文窗口塞得满满当当。等到真正要动刀的时候,它已经“记不住”最初的修改目标了,开始凭感觉生成了一段看起来合理、实际破坏接口的代码。当时的感受是:它不缺推理能力,缺的是一张能随时查询的调用关系图,让它不用反复读无关文件。

第二次是跨会话的“失忆”。同一个项目里,前一天和编程代理讨论过某个模块的命名约定和实现风格,第二天开个新会话让它继续做相邻需求,它把所有旧约定忘得干干净净,重新猜了一套实现方式,最后产出的代码风格和项目原有代码完全不搭。这不是模型能力问题,是对话上下文被重置之后,没有任何机制把之前的结论留存下来。

第三次是账单冲击。一个下午跑了几个自动化任务,看到模型 API 消耗曲线的时候真的有点头皮发麻——接近百万 token,大部分消耗在反复读取同一批文件和同一段上下文上。更可怕的是,由于没有记录任务维度的用量明细,我根本说不清是哪个任务吃掉了大头。

这三件事放一起,结论非常明确:编程代理缺的不是一个更强的模型,而是一套围绕代理的“基础设施”,至少包括记忆层、代码语义导航、用量会计这三个部分。

1.2 编程代理真正缺的不是模型,是基础设施

这里说“基础设施”并不是为了给项目拔高,而是这三个问题恰好对应了三个可以被独立设计、独立实现的模块。

记忆层解决“从金鱼变成有手账的人”的问题。代码地图解决“在项目里不迷路”的问题。token 记账解决“知道钱花在哪,以及花钱要有预算”的问题。正常情况下,模型 API 本身不提供这些能力,它们天然是“代理壳”之外的东西。

更进一步说,这三块能力彼此之间没有强耦合:记忆层不依赖代码地图才能工作,记账模块也不关心记忆存在哪张表里。它们之间只需要对“任务”和“会话”这两个概念有统一理解就行。这个特性决定了这套工具链适合拆成一组松散耦合的仓库,而不是一个巨型单体。我按这个思路把边界划分清楚,自然就有了后面的 9 个仓库。

另一个关键判断是:这些组件不应该绑定在某一个具体的编程代理实现上。不管是自研的还是开源的 agent 框架,都应该能通过标准数据接口把它们接进来。所以我在设计接口时尽量保持协议简单,让记忆、代码地图、记账模块都能以本地 HTTP 或命令行方式对外服务。

2. 记忆模块:让编程代理从“金鱼”变成“带着手账干活的人”

2.1 记忆到底分几层存

我首先把记忆拆成三个层次,避免一上来就搞一个“什么都能记”的大杂烩。

第一层是任务内短期记忆:当前的对话历史、当前步骤的目标、刚读过的文件内容。这一层其实被上下文窗口天然承担了,不需要额外持久化,工具链也基本不碰它。

第二层是跨会话的长期记忆:用户对项目的偏好、常用的技术决策、踩过坑之后得出的结论。比如“这个项目不用 X 库,统一用 Y”,这类信息如果不记录,每次新开对话都要重新问一遍或者靠猜。

第三层是项目级事实记忆:代码库里“哪块模块是干什么的”“命名规范是什么”“架构分层约定是什么”。这一层和代码地图模块有一定重叠,我的处理方式是:凡是能从代码静态分析拿到的客观事实,优先交给代码地图去算;凡是需要结合上下文理解的主观约定,留给记忆模块存。

分层不是物理上严格分三套存储,而是给每个记忆条目打一个类型标签,查询的时候按标签过滤。这样实现成本低,将来要调整粒度也方便。

2.2 存储选型:SQLite 加 JSON,不急着上向量库

最开始我确实考虑过向量数据库,毕竟“记忆”这两个字听起来就和向量检索绑定。但实际跑下来之后我放弃了:单个项目里的记忆条目,规模通常也就是几百到几千条,在这个数量级下,SQLite 加简单的关键词检索完全够用,而且部署起来比向量库简单太多,用户克隆仓库之后不用额外起任何服务。

记忆条目的结构我尽量做得松散:核心字段包括内容、类型、来源会话 ID、时间戳、相关标签,外加一个可选的 JSON 字段存扩展信息。不把 schema 定死,是因为记忆内容的形态非常多变——有些是纯文本结论,有些是结构化决策,有些只是带上下文的“备注”,用带宽松字段的表或者直接存 JSON 文档,都比强行建模省心。

那向量检索什么时候有用?我的判断是:当记忆量超过一万条,且用户习惯用“上次那个定时任务的问题”这种高度语义化的描述去检索时,向量召回才会体现出明显优势。在这之前,关键词重叠加全文搜索已经能覆盖绝大多数召回场景。所以我的实现里把向量检索做成可选增强项,而不是基础依赖。

2.3 记忆召回:相关性、时效性、去重三位一体

召回策略决定了记忆模块能不能真正帮到编程代理,而不是帮倒忙。我的核心思路是打分排序。

打分公式很简单:score = 0.7 * 文本相关度 + 0.3 * 时间新鲜度。文本相关度的实现先用字符重叠和 TF-IDF 这种轻量算法,关键词命中越多分越高;时间新鲜度用指数衰减函数,几天前的记忆权重会明显低于刚刚写入的结论。

去重和合并同样重要。编程代理在多次任务里可能会反复得出同一个结论,如果在召回时把同一条结论的多个历史版本都返回给代理,既占上下文又造成困惑。我的做法是:内容相似度超过阈值时自动合并条目,保留最新版本,同时累计旧版本的引用时间,让这条记忆在相关场景下更容易被召回。

这里有个比较容易忽略的坑:召回数量一定要克制。我见过一些记忆系统做得“太努力”,每次一次性塞给模型几十上百条记忆,结果把上下文中真正有用的空间全部挤占了,模型反而不知道该信哪条。我目前的策略是单个任务最多取 top 10 条相关记忆,并且对每条记忆做一段摘要,而不是把原始长文本整个丢进去。

3. 代码地图:给编程代理装上一副“项目导航仪”

3.1 为什么 grep 不能解决问题

很多人觉得代码检索就是grep -r一把梭,代理需要知道什么就给它搜什么。但真实的问题是:grep 只能返回字符串出现的位置,它无法告诉代理这个位置是定义、调用、还是注释里的普通提及。

举个例子,一个公共函数被 20 个文件调用,其中 5 个是真调用,另外几个只是文档或者测试里提到了函数名。grep 会把 20 个文件全部列出来,编程代理拿到这份杂乱的结果集后,依然需要逐个文件打开、分析、判断,上下文照样被塞满。代码地图要做的,是把“谁会受影响”“影响面有多大”这种结论直接算出来喂给代理。

换句话说,代理需要的不是“文件里出现了哪个字符串”,而是一张语义导航图:函数 X 定义在哪、被谁调用了、影响多少个模块、重命名之后哪些文件要跟着改。

3.2 构建流程:从 AST 到符号索引,再到依赖图

代码地图的构建过程,我大致拆成四步。

第一步是语法树解析。用通用语法树解析框架读入每个源文件,不管项目是 Python、JavaScript 还是 Go,都能解析出结构化的语法单元。相比正则和 grep,语法树能准确区分函数定义、函数调用、类声明、导入语句这些不同语义节点。

第二步是符号提取。遍历语法树,把文件里包含的函数、类、方法签名、变量声明、导入导出关系全部提取出来,记录它们所在文件、行号、作用域类型。这一步相当于给整个仓库建了一份“符号户口本”。

第三步是依赖图构建。根据文件之间的导入关系建立文件级依赖边,再根据符号之间的调用关系建立符号级调用边。这个依赖图就是代码地图的核心数据结构,查询某个函数被谁调用时,只需要查这个图的邻接节点。

第四步是索引持久化。把符号表和依赖图序列化到本地存储,同时提供一个查询接口,对外暴露几个核心方法:查看某个符号的定义位置、列出调用某符号的所有调用点、列出导入某文件的所有文件。这样编程代理在动手改代码之前,可以先花一次工具调用的成本拿到影响面清单。

从实际效果看,一个 15 万行左右的项目,全量索引构建在几十秒量级,内存占用也处于可控范围。考虑到代理核心场景其实是反复查询同一个索引,构建一次后面每次查询都是常量时间,这个投入非常划算。

3.3 增量更新与 IDE 索引的差异

全量构建很快,但项目是持续变化的。我在这个模块里加了增量更新机制:监听文件系统变更事件,只对变更过的文件重新解析,再更新与之关联的依赖边。这个机制最大的坑在于文件重命名和删除——旧符号如果没被清干净,就会变成“幽灵符号”,代理会被不存在的定义误导。所以每次处理删除和重命名时,必须先把旧文件关联的所有索引项做失效处理,再重新建边。

说到 IDE 索引,很多人会问:代码地图是不是和 IDE 的“跳到定义”“查找引用”重复了?功能层面确实有重叠,但服务对象完全不同。IDE 索引是给人看的,讲究交互顺手;代码地图是输出给 LLM 用的,讲究结构化、紧凑、可裁剪。IDE 里一个“查找所有引用”的列表可能有几百行,但对代理来说,给它 Top 20 个真实调用点并附上“大致影响面”说明,远比塞给它一份完整列表有用。因此代码地图的查询结果在设计上就是面向 prompt 优化的,默认做截断和摘要。

4. token 记账:每一分钱的消耗都要说清楚

4.1 记账维度和数据模型

token 记账的第一步是厘清“记什么账”。我定义了几个核心维度:会话 ID、任务 ID、仓库 ID、时间、模型名称、输入 token、输出 token、缓存相关 token、请求耗时。把这些维度落到数据模型上,就是一套简单的明细加聚合表。

最核心的表是用量明细表,每一行记录一次模型 API 调用的 token 数量和归属。任务表和会话表用于归因,日汇总表用于看趋势。这套表结构非常简单,但支撑了后续几乎所有功能:按任务汇总、按日期画曲线、定位某个异常消耗峰值。

这里我想额外强调一下:记账不只是为了算钱,更是为了给优化提供数据。比如我发现某个任务在“反复读取同一文件内容”上消耗了大量 token,就会去查对应代码地图是否生效,或者看是不是工具调用设计有问题。没有记账数据,优化就没有抓手。

4.2 计量中间件的实现思路

实现上,我在编程代理和模型 API 之间加了一层计量中间件。这层中间件负责拦截每一次补全请求,把请求参数和响应里的用量数据记录到数据库。

一个重要细节是:一次“任务”往往不是一次 API 调用,而是多轮“思考—调用工具—再看结果”的循环。如果只在任务结束时记一笔总账,中间某次异常低效的调用就会被淹没。所以我的中间件按“回合”记录,每一轮调用都写一条明细,最后归因到任务层。这样定位问题的时候,能看到某次任务里是不是有一轮调用消耗了特别多 token。

另外,模型服务商返回的用量字段里,通常会有缓存相关的口径,比如缓存命中输入和缓存未命中输入,价格不同。明细表里必须把这类字段单列出来,否则账目和实际账单会对不上,预算控制也会失真。

4.3 预算告警与熔断

记账不是记完就完,实时性很关键。我做了两层预算机制。

第一层是软告警:当任务或会话的用量达到预算的 60% 和 80% 时,在命令行或 dashboard 上给出提示,让使用者决定是否继续。

第二层是硬熔断:超过 100% 后,计量中间件直接拒绝后续模型调用,除非使用者手动确认继续。这个功能最初是为了防止“人下班了,任务还在烧 token”这种情况。有过一次半夜被账单吓到的经历之后,我再也不敢不设熔断了。

实现实时聚合时,我遇到得比较多的还是 SQLite 因为频繁写入而锁库。后面专门为记账模块调整了写入策略:中间件把明细先攒在内存里,按固定间隔批量落库,而不是每来一次请求就单独 insert 一条,这样既保证了明细完整,又不会拖慢正常请求延迟。

5. 9 个仓库怎么拆,以及我为什么全用 MIT

5.1 拆仓库的边界逻辑

这套工具链之所以是“9 个仓库”,不是刻意凑数,是真的按边界拆出来的。核心原则有三条:独立发布、依赖方向单向、尽量降低使用门槛。

先看仓库概览,我用内部命名列举如下:

仓库名职责可以不依赖的组件
agent-base公共类型定义、工具函数无
agent-memory跨会话记忆存取与召回仅依赖 agent-base
agent-code-map代码索引与依赖关系查询仅依赖 agent-base
agent-token-ledger用量计量、预算控制仅依赖 agent-base
agent-tools通用工具集合封装依赖 agent-memory、agent-code-map
agent-plugins插件化扩展点依赖 agent-tools
agent-cli命令行入口与任务编排依赖全部核心模块
agent-server本地服务接口封装依赖全部核心模块
agent-examples示例配置和模板项目依赖 agent-cli

这个结构最大的特点,是底层的三个功能模块彼此互不依赖,谁也不会因为另一个模块改动而被迫升级。比如我只想用代码地图,那就只拉 agent-code-map 这一个仓库,不会被迫带上记忆和记账。而 agent-cli 这种“壳”层仓库依赖核心模块,改动频率自然更高,但它不会反向要求底层模块为它适配,依赖方向是单向的。

拆仓库的时机也是个经验问题。我的建议是不要一步到位拆 9 个,除非你和我一样已经把这些模块在单体里跑了很久、边界摸得很清楚。更稳妥的路径是:先在单仓库里按目录划分边界,跑通之后再按目录拆包发布。

5.2 MIT 不是万能牌,但在这件事上是对的

选 MIT 许可的原因很直接:我希望这套基础组件被尽可能多的人拿过去用,包括商用场景。MIT 协议在这件事上几乎没有理解成本,使用者不需要找法务确认条款,这本身就是一种对传播的鼓励。

但“9 个仓库全 MIT”这个表述要严谨一点。自己能控制协议的是我自己的代码,但依赖的三方库不一定都是 MIT。所以我特意写了一个许可证扫描脚本,把整棵依赖树拉出来逐个检查,确保没有 GPL 或 AGPL 这类带传染性的协议混进来。否则名义上全 MIT,实际依赖里混进了一个 AGPL 的库,会给使用者埋雷。

MIT 也不是没有问题。如果有人 fork 之后闭源修改,从许可证角度我无法阻止,这是 MIT 的代价。我看得很开:基础工具本来就赚不到“软件许可”的钱,它带来的潜在价值是生态和影响力,而不是法律壁垒。

5.3 给想复刻这套结构的人一条落地路径

如果你被我这个结构打动,想在自己项目里复刻,我的建议是先冷静一下。

第一步,先把三个核心问题在“最原始状态”下各写一个最小实现:记忆用一个文本文件存,代码地图用一次性 grep 脚本顶一下,记账先手动记录会话用量。目标是验证这三件事确实能带来收益。

第二步,验证通过后,再分别独立成模块,单元测试和接口都按“可复用”标准写。这时候就已经具备拆仓库的雏形了。

第三步,控制好依赖方向,从顶层壳出发往下梳理,保证没有循环依赖,然后才拆成独立仓库。整个周期可能需要两到三个月,比想象中长,但这样拆出来的结构是真正稳的,而不是为了“看起来开源”硬凑的。

6. 实测效果和踩坑记录

6.1 一次带工具链和不带工具链的对比

我自己对这套工具链做了个很朴素的对照实验,选了一个中小规模的存量项目跑同一组重构任务。

指标不带工具链带工具链
单个任务读取文件次数较高,且重复严重明显下降
上下文重复片段占比高,同一文件信息反复出现低
任务完成耗时长缩短
任务总 token 消耗高低

必须承认,这个对照不算严谨,项目任务难易、上下文窗口大小都会影响结果。但方向性结论很清晰:代码地图省掉了无效闲逛,记忆层省掉了重复讨论,记账数据让优化有据可依。对我个人使用体验的提升是实打实的。

6.2 五个值得写下来的坑

第一个坑,是增量更新里的幽灵符号。文件被重命名或删除时,如果没有把旧索引项清干净,代码地图会持续返回已经不存在的位置,代理会拿这些错误信息做决策。修复方案是文件级失效:收到变更事件后,先把该文件关联的索引全部标记失效,再重建。

第二个坑,是 SQLite 的写锁。代理和 CLI 同时运行、同时写记忆和记账表时,很容易出现database is locked。我的处理是两步走:给数据库开 WAL 日志模式,把并发读写冲突降到很低;同时把高频写入改成批量合并,减少事务数量。这两个动作配合下来,锁问题基本消失。

第三个坑,是 token 计数的口径不一致。本地用一个 token 计数库数出来的结果,和模型服务商 API 计费时的用量经常有出入,尤其是中文文本、代码片段和特殊 token 比较多的时候。正确做法是:账单以 API 返回的用量字段为准,本地计数只用于趋势分析和预估,不做最终计费依据。

第四个坑,是记忆污染。早期版本里,我让记忆模块“什么值得存档的都存”,结果它把大量中间状态、临时假设、半成品代码片段都存进去了。下次任务召回时,这些过时信息反而把正确结论挤了下去,代理开始回忆“旧版本方案”,带偏了不少任务。后来我加了一道写入过滤逻辑:只有明确被标记为“结论”“约定”或“踩坑”的内容才进入长期记忆,普通中间过程一律不入库。

第五个坑,是代码地图查询结果过载。接口设计初期,我让它返回所有调用点,结果一份结果列表有时长达几百行,代理同样会把上下文撑爆。最终改成限制返回条数,并且附上聚合摘要——“该符号被 5 个文件里的 12 处调用,涉及 X、Y、Z 三个模块”,代理拿这份摘要做影响评估已经足够。

6.3 我接下来的打算

这套工具链目前的形态已经完全够我自己日常用,但这不意味着它不继续长。方向上我很明确:先把代码地图对更多语言的支持补上,把记忆模块升级出团队共享服务,再做一个更直观的可视化 dashboard。如果你也踩过类似的坑,或者有自己的一套做法,欢迎从仓库里挑一个模块开始用;这种基础设施类的东西,只有被丢进真实项目里摩擦过,才知道哪里该改。

最后说一点个人体会。开源这套东西之后,我最大的收获其实不是代码本身,而是把每个设计决策背后的“为什么”想清楚了。之前随手写脚本解决自己的小问题时,很多选择都靠直觉,整理成 9 个独立仓库的过程逼着我把边界、协议、依赖方向都过了一遍,踩过的坑反而变成了最有价值的文档素材。如果你也在做类似的编程代理工具链,我比较强烈的建议是:从最小闭环开始,先让一个任务在干净环境里跑通,再逐步加记忆、加地图、加记账,千万别一开始就奔着“全家桶”去设计。工具链的价值不在于仓库多,而在于每加一个模块,都能实打实让代理少走一段弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询