1. 为什么 Agent Skills 需要一套分层记忆系统
如果你正在做 Agent Skills 相关的开发,大概率遇到过这几个场景:同一个 Skill 被反复调用,每次都要重新告诉它项目用 Python 还是 TypeScript;上一轮会话里刚确认过的接口约定,下一轮就忘得干干净净;想给 Skill 加一条“记住用户偏好”的能力,结果发现只能把全部历史对话塞进上下文,token 烧得飞快还容易超限。
这些问题的根源在于:大多数 Agent 框架的记忆是“平铺”的,所有信息不分轻重缓急地堆在一起。而人类处理信息的方式是分层的——短期工作记忆、长期经验、技能肌肉记忆各司其职。分层设计的记忆系统,就是把这套逻辑搬到 Agent Skills 上:高频、关键的信息放在最靠近推理层的位置,低频、海量的历史数据沉到可检索的存储层,可复用的解决路径固化成技能文件。
这篇文章要落地的方案,是用 SQLite 作为 L3 会话记忆的存储底座,配合 L1 核心记忆文件、L2 用户画像文件、L4 技能目录,搭出一套可复现、可调试的配置骨架。选 SQLite 的理由很直接:单文件、零运维、自带 FTS5 全文索引、Python/Node 都有成熟驱动,本地开发和服务器部署的体验完全一致。整套骨架的目标不是“功能最多”,而是“每一步都能验证、每一层都能单独调试”。
适合谁跟做:正在给 Agent Skills 加持久记忆的开发者、被跨会话上下文丢失困扰的工程同学、想用最小依赖搭出可复现记忆层的独立开发者。下面从表结构开始,一路写到 config.toml 骨架和验证请求。
2. TaoToken 前置:把模型接入这层先固定下来
分层记忆系统本身不依赖特定模型,但 Agent Skills 要跑起来,总得有个稳定的模型入口。我的做法是把模型接入这层单独抽出来,用 TaoToken 统一管理,这样记忆层的调试不会被模型切换打断。
TaoToken 在这里扮演的角色是“模型网关”:你拿到一个 API Key,就能在 Agent 的配置里统一指向它,后续换模型、调参数、看用量都在一个地方完成。对记忆系统来说,这意味着 L1/L2 的注入逻辑、L3 的检索触发、L4 的技能调用,都可以在模型不变的前提下反复调试。
具体操作路径:
- 先到官网 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 注册并登录
- 进入控制台 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 创建项目
- 在 API Keys 页面 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 生成一个 Key,复制保存
- 如果只是想先验证模型通不通,可以直接用模型对话 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 发一条消息测试
- 接入细节和参数说明看文档 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=
API 基础地址是 https://taotoken.net/api(这个地址不加 UTM 参数,直接用于代码里的 base_url)。拿到 Key 之后,把它写进环境变量,后面 config.toml 里只引用变量名,不硬编码。
注意:Key 只存在本地环境变量或密钥管理工具里,不要提交到 Git 仓库。config.toml 里写
${TAOTOKEN_API_KEY}这种占位形式即可。
如果你后续要做长期编码类 Agent 或需要跑大量 Skill 调用,可以了解 Coding Plan https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,它在用量和成本上更适合持续性的开发场景。Claude Code 相关的接入参考 https://taotoken.net/claudecode-anthropic?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。
3. SQLite 表结构与 config.toml 骨架
3.1 四层记忆的职责划分
在写代码之前,先把四层的边界定清楚,不然后面表结构会越加越乱。
| 层级 | 存储位置 | 容量约束 | 加载方式 | 典型内容 |
|---|---|---|---|---|
| L1 核心记忆 | MEMORY.md | ≤800 tokens | 会话启动时冻结为快照,注入系统提示词 | 当前任务关键上下文、错误堆栈、变量状态 |
| L2 用户画像 | USER.md | ≤500 tokens | 会话启动时注入 | 技术栈偏好、沟通风格、常用命令 |
| L3 会话记忆 | SQLite + FTS5 | 无硬上限 | 按需检索,不主动加载 | 全量历史对话、检索结果 |
| L4 技能系统 | skills/ 目录 | 按需 | 任务匹配时加载 | SKILL.md 定义的可复用解决路径 |
这个划分的核心思想是:越靠近推理层的信息越精炼、越稳定;越远离推理层的信息越全量、越按需。L1 和 L2 是“每次都要带上的”,所以必须小;L3 是“需要时才查的”,所以可以大;L4 是“匹配到才用的”,所以按目录组织。
3.2 SQLite 表结构
L3 会话记忆用 SQLite 存,核心是两张表:一张存原始消息,一张存 FTS5 全文索引。FTS5 是 SQLite 内置的全文检索模块,建好索引后查询是毫秒级的。
-- 开启外键约束 PRAGMA foreign_keys = ON; -- 会话表:记录每个会话的元信息 CREATE TABLE IF NOT EXISTS sessions ( session_id TEXT PRIMARY KEY, skill_name TEXT NOT NULL, created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), summary TEXT ); -- 消息表:存全量对话 CREATE TABLE IF NOT EXISTS messages ( message_id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, role TEXT NOT NULL CHECK (role IN ('user','assistant','system','tool')), content TEXT NOT NULL, token_count INTEGER DEFAULT 0, created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE ); -- FTS5 虚拟表:对 content 建全文索引 CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( content, content='messages', content_rowid='message_id', tokenize='unicode61' ); -- 触发器:保持 FTS 索引与主表同步 CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN INSERT INTO messages_fts(rowid, content) VALUES (new.message_id, new.content); END; CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.message_id, old.content); END; CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.message_id, old.content); INSERT INTO messages_fts(rowid, content) VALUES (new.message_id, new.content); END; -- 常用查询索引 CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at); CREATE INDEX IF NOT EXISTS idx_sessions_skill ON sessions(skill_name);把这段 SQL 存成schema.sql,用一条命令初始化:
sqlite3 memory.db < schema.sql验证表建好了:
sqlite3 memory.db ".tables" # 预期输出:messages messages_fts sessions3.3 config.toml 骨架
配置文件的作用是把“记忆层参数”和“模型接入参数”分开,这样调试记忆逻辑时不会误改模型配置。
# config.toml —— Agent Skills 分层记忆配置骨架 [model] provider = "taotoken" base_url = "https://taotoken.net/api" api_key_env = "TAOTOKEN_API_KEY" model_name = "claude-sonnet-4-20250514" max_tokens = 4096 temperature = 0.3 [memory.l1_core] path = "./memory/MEMORY.md" max_tokens = 800 freeze_on_session_start = true inject_into_system_prompt = true [memory.l2_profile] path = "./memory/USER.md" max_tokens = 500 auto_update = true update_trigger = "session_end" [memory.l3_session] db_path = "./memory/memory.db" fts_table = "messages_fts" search_limit = 10 search_mode = "bm25" auto_load = false search_tool_name = "session_search" [memory.l4_skills] skills_dir = "./skills" skill_file = "SKILL.md" auto_extract = true min_reuse_count = 2 [gateway] enabled = false platforms = []几个关键参数说明:
memory.l3_session.auto_load = false是分层设计的核心开关。它保证 L3 不会在会话启动时被全量加载,只有 Agent 主动调用session_search工具时才去查 SQLite。这一条如果设成 true,分层就退化成平铺了。
memory.l1_core.freeze_on_session_start = true表示会话开始时把 MEMORY.md 内容冻结成快照。冻结的好处是:即使会话中途 MEMORY.md 被其他进程修改,当前会话看到的仍是启动时的那份,避免上下文漂移。
memory.l4_skills.auto_extract = true控制是否自动把复杂任务的解决路径提炼成 SKILL.md。调试阶段建议先设 false,手动写几个 Skill 跑通流程后再打开。
3.4 目录结构
配置里引用的路径,对应到实际目录:
agent-skills/ ├── config.toml ├── schema.sql ├── memory/ │ ├── MEMORY.md │ ├── USER.md │ └── memory.db ├── skills/ │ ├── code-debug/ │ │ └── SKILL.md │ └── api-call/ │ └── SKILL.md └── src/ ├── memory.py └── agent.pyMEMORY.md 和 USER.md 初始内容可以很简单,但格式要固定,方便后续解析:
<!-- MEMORY.md --> ## 当前任务 - 正在调试 Agent Skills 的记忆层 - 关键约束:L3 不主动加载 ## 关键上下文 - 数据库路径:./memory/memory.db - FTS 表名:messages_fts<!-- USER.md --> ## 技术栈偏好 - 主力语言:Python - 数据库:SQLite ## 沟通风格 - 简洁,直接给命令4. 验证请求:从写入到检索跑通一遍
配置搭好之后,必须用一条完整的链路验证:写入消息 → FTS 索引同步 → 检索命中 → 注入上下文。下面用 Python 写一个最小验证脚本。
4.1 写入与检索
# src/memory.py import sqlite3 import os from pathlib import Path DB_PATH = Path("./memory/memory.db") def get_conn(): conn = sqlite3.connect(DB_PATH) conn.execute("PRAGMA foreign_keys = ON") return conn def create_session(session_id: str, skill_name: str): with get_conn() as conn: conn.execute( "INSERT OR IGNORE INTO sessions(session_id, skill_name) VALUES (?, ?)", (session_id, skill_name), ) def add_message(session_id: str, role: str, content: str): with get_conn() as conn: conn.execute( "INSERT INTO messages(session_id, role, content) VALUES (?, ?, ?)", (session_id, role, content), ) def search_messages(query: str, limit: int = 10): with get_conn() as conn: rows = conn.execute( """ SELECT m.session_id, m.role, m.content, bm25(messages_fts) AS score FROM messages_fts JOIN messages m ON m.message_id = messages_fts.rowid WHERE messages_fts MATCH ? ORDER BY score LIMIT ? """, (query, limit), ).fetchall() return rows if __name__ == "__main__": create_session("sess-001", "code-debug") add_message("sess-001", "user", "SQLite FTS5 的 bm25 排序怎么用") add_message("sess-001", "assistant", "在 MATCH 查询里用 bm25(表名) 作为排序字段即可") add_message("sess-001", "user", "那 unicode61 分词器支持中文吗") results = search_messages("FTS5") for r in results: print(r)运行:
cd agent-skills python src/memory.py预期输出类似:
('sess-001', 'user', 'SQLite FTS5 的 bm25 排序怎么用', -1.2e-06) ('sess-001', 'assistant', '在 MATCH 查询里用 bm25(表名) 作为排序字段即可', -1.1e-06)bm25 返回的是负值,越小越相关,排序时升序即可。这一步跑通,说明 L3 的写入和检索链路是通的。
4.2 验证 L1/L2 注入
L1 和 L2 的验证更简单,读文件、数 token、拼进系统提示词:
# src/agent.py import os import tiktoken from pathlib import Path def load_l1(path: str, max_tokens: int = 800) -> str: content = Path(path).read_text(encoding="utf-8") enc = tiktoken.get_encoding("cl100k_base") tokens = enc.encode(content) if len(tokens) > max_tokens: content = enc.decode(tokens[:max_tokens]) return content def load_l2(path: str, max_tokens: int = 500) -> str: return load_l1(path, max_tokens) def build_system_prompt(l1: str, l2: str) -> str: return f"""你是一个带分层记忆的 Agent。 ## 核心记忆(L1) {l1} ## 用户画像(L2) {l2} 需要历史信息时,调用 session_search 工具查询。 """ if __name__ == "__main__": l1 = load_l1("./memory/MEMORY.md") l2 = load_l2("./memory/USER.md") prompt = build_system_prompt(l1, l2) print(prompt) print("---") print(f"L1 tokens: {len(tiktoken.get_encoding('cl100k_base').encode(l1))}") print(f"L2 tokens: {len(tiktoken.get_encoding('cl100k_base').encode(l2))}")运行后检查 L1 是否在 800 token 以内、L2 是否在 500 以内。超了就截断,这是硬约束,不能妥协。
4.3 用模型对话验证整体链路
把上面两步串起来,发一条真实请求。这里用 TaoToken 的模型对话入口做快速验证:打开 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,把build_system_prompt的输出贴进系统提示词,然后问一句“我之前问过 FTS5 的什么问题”。如果模型能通过session_search检索到sess-001里的内容并回答出来,说明四层记忆的读取链路是通的。
这一步的意义在于:它验证的不是单个函数,而是“L1/L2 注入 + L3 按需检索 + 模型调用”的完整闭环。任何一层出问题,这里都会暴露。
5. 本篇常见错排查
5.1 FTS5 表建了但检索不到
最常见的原因是触发器没建,或者建了但顺序不对。FTS5 的外部内容表(content='messages')不会自动同步,必须靠触发器。检查方法:
sqlite3 memory.db "SELECT name FROM sqlite_master WHERE type='trigger';"如果输出为空,说明触发器没建成功。重新执行 schema.sql 里的触发器部分。另一个可能是插入数据时用了INSERT OR REPLACE,这会触发 DELETE + INSERT,如果 DELETE 触发器缺失,FTS 索引就会残留旧数据。
5.2 bm25 排序结果为空
MATCH查询对语法敏感。如果查询词里有特殊字符(比如-、:),需要加引号:
SELECT * FROM messages_fts WHERE messages_fts MATCH '"FTS5"';另外,unicode61分词器对中文是按字切分的,搜“记忆系统”会拆成“记”“忆”“系”“统”四个字分别匹配。如果要做中文词组检索,得换trigram分词器,或者在应用层做查询改写。
5.3 L1 注入后模型仍然“失忆”
先确认freeze_on_session_start是否真的生效。如果每次请求都重新读 MEMORY.md,而文件在会话中途被改过,模型看到的内容就会变。冻结的实现方式是在会话开始时把内容读进内存,后续请求复用这份内存副本,不再读文件。
另一个坑是系统提示词的位置。有些模型对系统提示词里的长文本注意力会衰减,L1 内容如果放在最前面,后面又跟了大段工具定义,模型可能“看不到”。把 L1 放在系统提示词的末尾,紧挨着用户消息,命中率会高很多。
5.4 config.toml 路径解析错误
TOML 里的相对路径是相对于“运行命令时的工作目录”,不是相对于 config.toml 所在目录。如果你在agent-skills/下运行python src/agent.py,那./memory/MEMORY.md解析为agent-skills/memory/MEMORY.md,没问题。但如果你在项目根目录运行,路径就错了。稳妥做法是在代码里统一用Path(__file__).parent.parent推导项目根目录,再拼配置里的相对路径。
5.5 API Key 读取失败
api_key_env = "TAOTOKEN_API_KEY"表示从环境变量读。检查:
echo $TAOTOKEN_API_KEY如果为空,说明没导出。临时导出用export TAOTOKEN_API_KEY="你的key",持久化写进~/.bashrc或~/.zshrc。注意不要在 config.toml 里直接写 Key 明文,也不要把含 Key 的配置文件提交到版本控制。
6. 继续往下走:把记忆层接进你的 Agent Skills
到这里,一套可复现的分层记忆骨架已经跑通了:SQLite 表结构建好、FTS5 索引同步、config.toml 参数分离、L1/L2 注入验证、L3 检索验证、常见错排查。接下来你可以按自己的场景做三件事。
第一,把session_search注册成 Agent 的工具。在工具定义里描述清楚它的用途和参数,模型才会在需要时主动调用。工具描述写“检索历史会话中的相关消息,用于回忆之前讨论过的内容”,比写“搜索数据库”有效得多。
第二,给 L4 技能目录加第一个 SKILL.md。格式参考 agentskills.io 的规范,至少包含名称、描述、输入输出、执行步骤。先手动写一个,跑通“任务匹配 → 加载 Skill → 执行”的流程,再打开auto_extract。
第三,把模型接入这层固定到 TaoToken。API Key 在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 管理,接入参数看 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,长期编码场景可以了解 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。这样记忆层的调试和模型层的切换互不干扰,出问题时能快速定位是哪一层的事。
分层记忆系统的价值不在于“存得多”,而在于“该记的记牢,该忘的忘掉,该查的查得到”。SQLite 在这里的角色是那个“该查的查得到”的底座,FTS5 让检索足够快,触发器让索引足够稳。剩下的,就是根据你的 Skill 场景去调 L1 的容量、L2 的更新时机、L4 的提炼阈值。这些参数没有标准答案,跑起来、看日志、改配置,比一次设计到位更实际。