一个每次运行之间都会遗忘一切的 AI Agent,根本算不上 Agent——它只是一个带着 token 预算的计算器。上下文窗口是有限且昂贵的,会话会轮换,每次重启都会把一切抹得干干净净。长期记忆解决了这个问题:把 Agent 所见所学的内容存储为向量嵌入,然后让它按需检索相关的那一片段。本教程用 PostgreSQL 和 pgvector 扩展来构建这个记忆层——就是你现在已经用来跑其他所有东西的那个数据库,如今它还能做快速的向量相似度搜索。无需新数据库、无需新供应商、无需多维护一个新服务。
即便是 100 万 token 的上下文窗口,也只是一个草稿本,不是记忆。有三个问题会把你推向外部存储:
Agent 大致需要三种记忆:
pgvector 能出色地处理情景记忆和语义记忆:你把一条记忆编码成向量,和它的纯文本内容及元数据一起存储,然后按语义而非关键词来检索。
pgvector 是一个 PostgreSQL 扩展,它新增了一个 vector 列类型,以及用于近似最近邻(ANN)搜索的索引类型。核心要点:
vector(n)——固定维度的浮点数组。当前版本最高支持 16,000 维,覆盖了所有主流的嵌入模型。<->(L2 / 欧氏距离)、<#>(负内积)和 <=>(余弦距离)。做语义相似度时你通常需要余弦距离。HNSW(快,内存图)和 IVFFlat(省内存,基于列表)。HNSW 是生产环境召回的首选。相对专用向量数据库的杀手级优势:你本来就在跑 Postgres。少了一个需要加固、监控和付费的服务。
在 Debian/Ubuntu 上,pgvector 以打包扩展的形式为每个 Postgres 大版本提供:
$ sudo apt update
$ sudo apt install postgresql-16-pgvector
Reading package lists... Done
Setting up postgresql-16-pgvector (0.7.4-1) ...
然后在你的数据库里启用它:
$ sudo -u postgres psql -c "CREATE EXTENSION IF NOT EXISTS vector;"
CREATE EXTENSION
$ sudo -u postgres psql -c "SELECT extversion FROM pg_extension WHERE extname='vector';"
extversion
------------
0.7.4
pg_config 做标准的 make && make install。pgvector README 对两条路径都有说明。这个 schema 把内容(可以展示、记录日志、以及作为回退的纯文本)和嵌入(你要搜索的向量)分离开来。每一行都携带足够的元数据,把检索范围限定到正确的 Agent 和会话:
-- schema.sql
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE IF NOT EXISTS agent_memories (
id BIGSERIAL PRIMARY KEY,
agent_id TEXT NOT NULL, -- 'susu', 'hermes', 'im-bot:room-7'
namespace TEXT NOT NULL DEFAULT 'default',
memory_type TEXT NOT NULL DEFAULT 'episodic', -- 'episodic' | 'semantic' | 'procedural'
content TEXT NOT NULL,
embedding vector(1536),
metadata JSONB DEFAULT '{}',
importance REAL NOT NULL DEFAULT 0.5,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ
);
CREATE INDEX idx_memories_agent ON agent_memories(agent_id, created_at DESC);
CREATE INDEX idx_memories_metadata ON agent_memories USING GIN (metadata);
vector(1536) 列对应 OpenAI 的 text-embedding-3-small(以及若干开源模型)。如果你用不同的嵌入模型,就把维度改成与之匹配——本教程的其余部分与维度无关。
嵌入是一个稠密向量,它把语义相近的文本放在相近的位置。你可以用托管 API 或本地模型来生成;关键在于你必须在建索引和查询时使用同一个模型——混用模型会产生毫无意义的距离。
# embed.py — 用 sentence-transformers 生成本地嵌入(无需 API key)
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("BAAI/bge-small-en-v1.5") # 384 维;把列改成 vector(384)
def embed(text: str) -> list[float]:
return model.encode(text, normalize_embeddings=True).tolist()
print(embed("the deployment failed on a missing env var"))
# [0.012, -0.033, 0.041, ...] 384 个浮点数
如果你更喜欢托管模型,调用就是一行命令——这里用 OpenAI 的 1536 维 text-embedding-3-small 来匹配上面的 schema:
$ curl -s https://api.openai.com/v1/embeddings \
-H "Authorization: Bearer *** \
-H "Content-Type: application/json" \
-d '{"model":"text-embedding-3-small","input":"the deployment failed on a missing env var"}' \
| jq -r '.data[0].embedding | length'
1536
<=> 余弦距离,就在写入时把嵌入归一化到单位长度(大多数库会自动这么做)。跳过归一化不会损坏任何东西,但会改变距离,可能降低召回排序的质量。用一条语句同时写入内容和它的嵌入。用 ::vector 把浮点数组的 JSON 转成向量类型:
$ sudo -u postgres psql -d agent_rule <<'SQL'
INSERT INTO agent_memories (agent_id, namespace, memory_type, content, embedding, metadata)
VALUES (
'hermes',
'project-sun-port',
'episodic',
'Deploy failed because the sun-port container could not mount the TLS cert volume; fixed by bind-mounting /etc/letsencrypt instead of a named volume.',
'[0.012,-0.033,0.041,0.019,-0.008]'::vector, -- 此处为展示做了截断
'{"service":"sun-port","severity":"high"}'
);
SQL
INSERT 0 1
检索就是一条 ORDER BY ... <=>。余弦距离 <=> 返回 [0, 2] 区间内的值,其中 0 表示完全相同;用 1 减去它,就得到 [-1, 1] 区间内的相似度分数:
$ sudo -u postgres psql -d agent_rule <<'SQL'
SELECT content,
ROUND((1 - (embedding <=> '[0.012,-0.033,0.041,0.019,-0.008]'::vector))::numeric, 3) AS similarity
FROM agent_memories
WHERE agent_id = 'hermes'
ORDER BY embedding <=> '[0.012,-0.033,0.041,0.019,-0.008]'::vector
LIMIT 5;
SQL
content | similarity
-------------------------------------------------------+------------
Deploy failed because the sun-port container could... | 0.972
... | 0.811
... | 0.764
这就是整个检索循环:嵌入查询、跑最近邻查询、把 top 结果注入 Agent 的上下文。
在 <=> 上做线性扫描是精确的,但一旦超过几千行就会变慢。HNSW 以小幅召回折损为代价,换来亚毫秒级的近似搜索:
CREATE INDEX ON agent_memories USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
确认查询计划器确实用上了它:
$ sudo -u postgres psql -d agent_rule -c "EXPLAIN SELECT id FROM agent_memories ORDER BY embedding <=> '[0,0]'::vector LIMIT 5;"
QUERY PLAN
------------------------------------------------------------------------
Limit (cost=...)
-> Index Scan using agent_memories_embedding_idx on agent_memories
Order By: (embedding <=> '[0,0]'::vector)
IVFFlat 配合约为 sqrt(rows) 的 lists 数量是更轻量的替代方案。无论哪种,都要在批量加载之后再建索引,而不是之前。在一张共享表上做无过滤的向量搜索,会把不同的 Agent 和项目混在一起。用 agent_id、namespace 和 memory_type 限定每一条查询——元数据过滤先执行,再做向量排序:
SELECT content, 1 - (embedding <=> $1) AS similarity
FROM agent_memories
WHERE agent_id = 'im-bot:room-7'
AND namespace = 'project-sun-port'
AND memory_type = 'semantic'
AND (expires_at IS NULL OR expires_at > now())
ORDER BY embedding <=> $1
LIMIT 10;
永不过期的记忆是一种负担——过时的事实会误导 Agent,还会让索引膨胀。三条简单的策略能让它保持健康:
-- 1. TTL:按计划清理过期记忆(cron)
DELETE FROM agent_memories WHERE expires_at < now();
-- 2. 去重:合并近乎相同的记忆(同一 agent + 极高相似度)
DELETE FROM agent_memories a
USING agent_memories b
WHERE a.id > b.id
AND a.agent_id = b.agent_id
AND 1 - (a.embedding <=> b.embedding) > 0.98;
-- 3. 重要性衰减:保留信号,过滤噪声
UPDATE agent_memories
SET importance = importance * 0.99
WHERE created_at < now() - INTERVAL '7 days';
把这些安排成 cron 任务——参见我们的 cron 自动化教程,了解如何把 SQL 维护任务接入 Hermes 的 cron 调度。
记忆只有在 Agent 真正去查询它时才有用。这个循环是:嵌入查询 → 检索 → 注入 → 生成 → 存储。
# Agent 循环的伪代码(Hermes / im-bot)
def agent_turn(user_message, agent_id):
q = embed(user_message) # 1. 嵌入查询
memories = recall(agent_id, q, k=8) # 2. 余弦 top-k
context = render(memories) # 3. 格式化为上下文
reply = agent.run(user_message, context=context) # 4. 带记忆生成
store(agent_id, content=f"user: {user_message}", # 5. 写回
embedding=q, memory_type="episodic")
store(agent_id, content=reply, embedding=embed(reply),
memory_type="episodic")
return reply
在 Hermes Agent 里,这里就是你读取 top-k 记忆、并在模型运行前把它们前置到 system prompt 的位置。在 im-bot 的多 Agent 房间中,同一张表在多个 Agent 之间共享,各自用 agent_id 限定范围——于是房间里的 Agent 会构建共享的情景记忆,同时各自保留独立的语义存储。
content 列更是如此。对任何形似凭据的内容都要脱敏或跳过记忆写入,并让这张表受到和你其他 Postgres 数据相同的访问控制。官方 pgvector/pgvector 镜像自带了扩展,所以你无需在容器里安装任何东西。把它和你的 Agent 一起运行:
services:
pg-vector:
image: pgvector/pgvector:pg16
container_name: agent-memory-db
restart: unless-stopped
environment:
POSTGRES_DB: agent_rule
POSTGRES_USER: agent_rule
POSTGRES_PASSWORD_FILE: /run/secrets/pg_password
volumes:
- pgdata:/var/lib/postgresql/data
- ./schema.sql:/docker-entrypoint-initdb.d/schema.sql:ro
ports:
- "127.0.0.1:5432:5432"
secrets:
- pg_password
secrets:
pg_password:
file: ./secrets/pg_password.txt
volumes:
pgdata:
$ docker compose up -d
$ docker compose exec pg-vector psql -U agent_rule -d agent_rule \
-c "SELECT extversion FROM pg_extension WHERE extname='vector';"
extversion
------------
0.7.4
schema.sql 的挂载通过 entrypoint 在首次启动时运行一次,所以全新的卷总会带着记忆表和索引一起上线。把它绑定到 127.0.0.1——记忆数据库没有理由暴露到公网。如果你的 Agent 跑在另一个容器里,就通过私有网络把它们连起来,而不是发布端口。记忆 schema 会漂移。把它们当作任何代码变更来对待:给迁移做版本控制、审查 diff、如果破坏了召回就回滚。
$ git init agent-memory && cd agent-memory
$ git add schema.sql docker-compose.yml
$ git commit -m "Add agent_memories table with pgvector HNSW index"
$ git log --oneline
b1c2d3e Add agent_memories table with pgvector HNSW index
对于已有的数据库,用有序的迁移文件(001_init.sql、002_add_hnsw.sql、…)并用 psql 或迁移执行器之类的工具来应用——这正是我们在 Git 工作流教程里介绍的那套纪律。
在信任它进入生产之前,先证明三件事:
# 1. 索引已生效且正在被使用
$ sudo -u postgres psql -d agent_rule -c \
"SELECT indexname FROM pg_indexes WHERE tablename='agent_memories';"
# 2. 召回有效:关于 sun-port 部署的查询应首先返回那条部署记忆
$ sudo -u postgres psql -d agent_rule -c \
"SELECT content FROM agent_memories ORDER BY embedding <=> '[...]'::vector LIMIT 1;"
# 3. 延迟在目标规模下可以接受
$ sudo -u postgres psql -d agent_rule -c \
"\timing on" -c "SELECT 1 - (embedding <=> '[...]'::vector) FROM agent_memories LIMIT 1;"
Time: 0.431 ms
metadata 或一张配置表里。vector(1536) 列,写入时就会报错。让列的维度和你的模型保持同步。agent_id 限定范围。vector_cosine_ops 建索引,并用 EXPLAIN 确认计划器用上了索引。agent_id、namespace 和 memory_type 过滤,让一张共享表不会在 Agent 之间泄漏上下文。EXPLAIN 和延迟检查在你信任它之前证明它有效。长期记忆正是把「只会回答」的 Agent 和「会记住」的 Agent 区分开来的东西。有了 pgvector,你只需付一个扩展的代价就能得到它——剩下的都是纪律:限定查询范围、在规模下建索引、清理过时的内容。做到这些,你的 Agent 就不会每天早晨重新学一遍同样的教训了。