Hermes Agent 是如何越用越聪明的
没有梯度更新,没有微调,Hermes 靠三个互相配合的持久化系统实现跨会话的知识积累——Memory、Skills、Session Search,加上一个后台自动复盘机制,让 Agent 真正做到"用得越多,懂得越多"。
上一篇文章我们打好了地基——搞懂了大模型 API 调用、多轮对话和 Tool Use。但一个只能"对话"的模型,充其量是个高级聊天机器人。真正好用的 Agent,应该越用越懂你:记住你的偏好、学会新的工作流、遇到老问题能翻出以前的解法。
Hermes Agent 就做到了这一点。但它不是通过机器学习(没有梯度更新、没有微调),而是靠 3 个互相配合的持久化系统 实现跨会话的知识积累。
本文带你从源码级别拆解这套机制。
总览:三个系统 + 一个触发器
先看全景图:
| 系统 | 类比 | 存储位置 | 作用 |
|---|---|---|---|
| Memory 工具 | 随手记笔记 | MEMORY.md / USER.md | 记住环境事实和用户偏好 |
| Skills 技能库 | 写操作手册 | skills/*.md | 存储可复用的工作流 |
| Session Search | 翻以前的日记 | SQLite FTS5 | 全文检索历史对话 |
| Background Review | 每隔一段时间主动复盘 | 后台线程 + 子 Agent | 自动触发上述三者 |
三者各有分工:Memory 存结论,Skills 存方法,Session Search 存完整历史。而 Background Review 是让这一切自动运转的发动机。
系统一:记忆工具 (Memory Tool)
源码位置: tools/memory_tool.py(585 行)
两个纯文本文件
Hermes 的"记忆"本质上就是两个 Markdown 文件:
~/.hermes/memories/
├── MEMORY.md ← Agent 的笔记(环境事实、项目约定、踩过的坑)
└── USER.md ← 关于你的画像(偏好、工作风格、沟通习惯)MEMORY.md 存的是客观信息——"这个项目用 pnpm 不用 npm"、"生产环境的数据库在 AWS us-east-1"。USER.md 存的是关于你的信息——"用户喜欢简洁的终端风格回答"、"用户是后端工程师,前端经验较少"。
LLM 通过一个 memory 工具来读写这两个文件:
# 记住用户偏好
memory(action="add", target="user",
content="用户喜欢简洁的终端风格回答,不需要 markdown 格式")
# 记住项目事实
memory(action="add", target="memory",
content="这个项目的 CI 用 GitHub Actions,不是 Jenkins")
# 修正过时信息
memory(action="replace", target="memory",
old_text="数据库在 us-east-1",
content="数据库已迁移到 ap-southeast-1")冻结快照:保护 Prompt Cache
这里有一个精妙的设计:Memory 的写入和读取是异步的。
class MemoryStore:
def __init__(self, memory_char_limit=2200, user_char_limit=1375):
self.memory_entries: List[str] = []
self.user_entries: List[str] = []
# 关键:冻结快照
self._system_prompt_snapshot: Dict[str, str] = {"memory": "", "user": ""}
def load_from_disk(self):
self.memory_entries = self._read_file(mem_dir / "MEMORY.md")
self.user_entries = self._read_file(mem_dir / "USER.md")
# 拍一张快照,整个会话期间不变
self._system_prompt_snapshot = {
"memory": self._render_block("memory", self.memory_entries),
"user": self._render_block("user", self.user_entries),
}流程是这样的:
- 会话启动时,从磁盘加载
MEMORY.md和USER.md,生成一份冻结快照注入 system prompt - 会话中间,如果 LLM 调用
memory(action="add"),内容立即写入磁盘文件 - 但当前会话的 system prompt 不变——快照是冻结的
- 下次会话启动时,重新加载磁盘文件,快照刷新
为什么要这样设计?因为 Anthropic 的 Prompt Cache。
Anthropic 的 API 有一个缓存机制:如果请求的 system prompt 前缀和上一次完全相同,服务端可以复用缓存的 KV 状态,token 开销只有正常价格的 10%。如果你在会话中间改了 system prompt(哪怕加一行 memory),缓存就失效了,所有后续请求都要全价重算。
所以 Hermes 选择了"写入立即持久化,读取延迟到下次会话"的策略——牺牲了"本次会话立即生效"的即时性,换来了整个会话期间的缓存命中率。
安全扫描
写入 memory 之前,内容会经过安全扫描:
_MEMORY_THREAT_PATTERNS = [
(r'ignore\s+(previous|all|above|prior)\s+instructions', "prompt_injection"),
(r'you\s+are\s+now\s+', "role_hijack"),
(r'do\s+not\s+tell\s+the\s+user', "deception_hide"),
(r'curl\s+[^\n]*\$\{?\w*(KEY|TOKEN|SECRET)', "exfil_curl"),
(r'authorized_keys', "ssh_backdoor"),
]因为 memory 内容会注入 system prompt,如果不过滤,恶意用户可以通过"让 agent 记住一段提示词注入"来劫持后续会话。这不是理论风险——在多用户共享 agent 的场景下,这是真实的攻击面。
系统二:技能库 (Skills)
源码位置: tools/skills_tool.py + tools/skill_manager_tool.py
Skills 是什么
如果说 Memory 是"我知道的事实",Skills 就是"我会做的事情"。
每个 Skill 是一个目录,核心是一个 SKILL.md 文件:
~/.hermes/skills/
├── deploy-to-aws/
│ ├── SKILL.md # 主文档:操作步骤
│ ├── references/ # 参考文档
│ │ └── aws-cli-cheat-sheet.md
│ └── templates/ # 输出模板
│ └── cloudformation.yaml
└── debug-docker-network/
└── SKILL.mdSKILL.md 使用 YAML frontmatter + Markdown 正文:
---
name: deploy-to-aws
description: 将 Next.js 项目部署到 AWS ECS
version: 1.0.0
platforms: [macos, linux]
prerequisites:
commands: [aws, docker]
---
# Deploy to AWS
1. 构建 Docker 镜像...
2. 推送到 ECR...
3. 更新 ECS 服务...三个工具
LLM 有三个工具来操作 Skills:
skills_list() # 列出所有技能标题和摘要
skill_view("deploy-to-aws") # 读取某个技能的完整内容
skill_manage(action="create", # 创建新技能
name="xxx",
content="...")渐进式加载:两层索引
这里有一个很实际的工程问题:如果你有 50 个 Skills,每个 SKILL.md 几千字,全部塞进 system prompt 会吃掉大量 token。
Hermes 的解决方案是渐进式加载:
第一层(System Prompt)——只放索引,每个 Skill 一行名称 + 摘要:
## Skills (mandatory)
Before replying, scan the skills below. If a skill matches...
deployment:
- deploy-to-aws: 将 Next.js 项目部署到 AWS ECS
- deploy-to-vercel: 一键部署到 Vercel
debugging:
- debug-docker-network: 排查 Docker 容器网络问题第二层(按需加载)——LLM 觉得某个 Skill 相关时,调用 skill_view() 读取全文。
第三层(深度参考)——Skill 目录下的 references/、templates/ 等文件,只有在 LLM 明确需要时才读取。
这样,system prompt 里只占几百 token 的索引,但 LLM 随时可以"翻开手册"查看完整步骤。
两层缓存
索引的构建本身也做了优化——内存 LRU 缓存 + 磁盘快照:
# 第一层:进程内 LRU 缓存(进程存活期间有效)
_SKILLS_PROMPT_CACHE = OrderedDict() # 最多 100 条
# 第二层:磁盘快照(进程重启后仍有效)
# .skills_prompt_snapshot.json — 存储解析后的元数据
# 通过 mtime/size manifest 校验是否过期
# 第三层:完整文件系统扫描(两层缓存都 miss 时才触发)只有当两层缓存都失效时,才会做完整的目录扫描。日常使用中,索引构建基本是零开销。
系统三:会话搜索 (Session Search)
源码位置: hermes_state.py(1444 行)
为什么需要这个
Memory 有字符数限制(MEMORY.md 2200 字符,USER.md 1375 字符),不可能什么都记。Skills 存的是通用方法,不是具体的对话细节。
但你经常需要回顾"上次我们怎么解决那个问题的"——完整的对话上下文,包括试错过程、最终方案、中间的讨论。
Session Search 就是干这个的:每次对话完整存入 SQLite,支持全文检索。
SQLite FTS5 全文搜索
-- 全文搜索虚拟表
CREATE VIRTUAL TABLE messages_fts USING fts5(
content,
content=messages,
content_rowid=id
);
-- 自动同步:消息写入时自动更新索引
CREATE TRIGGER messages_fts_insert AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
END;LLM 通过 session_search 工具来检索:
session_search(query="上次我们怎么修复的那个 docker 网络问题")返回的不是孤立的匹配行,而是带上下文的片段——每个匹配结果包含前后各 1 条消息,让 LLM 能理解对话的来龙去脉。
中日韩文字的处理
FTS5 默认的分词器对中文不友好——会把每个汉字单独拆开。Hermes 的做法是:先尝试 FTS5 查询,如果检测到 CJK 字符且 FTS5 结果不理想,降级到 LIKE 查询:
def search_messages(self, query: str, ...):
query = self._sanitize_fts5_query(query)
# 先尝试 FTS5 MATCH
# 如果 CJK 检测到且结果为空,降级到 LIKE写入并发控制
多个进程(CLI、网关、子 Agent)可能同时写同一个 state.db。SQLite 的默认策略是确定性退避——但这会导致"护航效应"(convoy effect),所有竞争者同步等待、同步重试,导致终端明显卡顿。
Hermes 用随机抖动替代确定性退避:
_WRITE_MAX_RETRIES = 15
_WRITE_RETRY_MIN_S = 0.020 # 20ms
_WRITE_RETRY_MAX_S = 0.150 # 150ms
for attempt in range(self._WRITE_MAX_RETRIES):
try:
self._conn.execute("BEGIN IMMEDIATE")
result = fn(self._conn)
self._conn.commit()
return result
except sqlite3.OperationalError as exc:
if "locked" in str(exc).lower():
jitter = random.uniform(
self._WRITE_RETRY_MIN_S,
self._WRITE_RETRY_MAX_S
)
time.sleep(jitter)随机 20-150ms 的抖动让竞争者错开重试时机,消除了可感知的 UI 卡顿。
触发器:后台自动复盘 (Background Review)
以上三个系统都需要 LLM 主动调用工具才能写入。如果完全依赖 LLM 的"自觉性",很多值得记录的内容会被遗漏。
Hermes 的解决方案是:自动触发后台复盘。
源码位置: run_agent.py(2757-2895 行)
触发时机
两个独立的计数器,分别控制 Memory 和 Skills 的复盘频率:
# Memory 复盘:基于用户消息轮次(默认每 10 轮)
self._turns_since_memory += 1
if self._turns_since_memory >= self._memory_nudge_interval: # 默认 10
_should_review_memory = True
self._turns_since_memory = 0
# Skills 复盘:基于工具调用迭代次数(默认每 10 次)
if self._iters_since_skill >= self._skill_nudge_interval: # 默认 10
_should_review_skills = True
self._iters_since_skill = 0注意两者的触发维度不同:Memory 按对话轮次(用户说了几轮话),Skills 按工具迭代次数(LLM 调了几次工具)。这是有道理的——用户偏好在对话中体现,可复用的工作流在工具使用中体现。
复盘流程
触发后,Hermes 会在后台线程启动一个全新的 Agent 实例:
对话进行中
│
│ 每10轮 ──→ 后台线程启动
│ │
│ ↓ 创建新的 AIAgent 实例(同模型、同工具)
│ ↓ 把当前对话历史复制给它
│ ↓ 追加一条复盘提示词
│ ↓ 子 Agent 自主判断、自主调用工具
│ │
│ ├── 值得记?→ memory("add") → 写入磁盘
│ ├── 可复用?→ skill_manage("create") → 写入技能
│ └── 没什么特别的 → "Nothing to save."
│
└──→ 主对话不受影响
用户看到一行 "💾 Memory updated"复盘提示词
复盘 Agent 收到的提示词长这样:
Memory 复盘:
_MEMORY_REVIEW_PROMPT = (
"Review the conversation above and consider saving to memory "
"if appropriate.\n\n"
"Focus on:\n"
"1. Has the user revealed things about themselves — their persona, "
"desires, preferences, or personal details worth remembering?\n"
"2. Has the user expressed expectations about how you should behave, "
"their work style, or ways they want you to operate?\n\n"
"If something stands out, save it using the memory tool. "
"If nothing is worth saving, just say 'Nothing to save.' and stop."
)Skills 复盘:
_SKILL_REVIEW_PROMPT = (
"Review the conversation above and consider saving or updating a "
"skill if appropriate.\n\n"
"Focus on: was a non-trivial approach used to complete a task that "
"required trial and error, or changing course due to experiential "
"findings along the way, or did the user expect or desire a different "
"method or outcome?\n\n"
"If a relevant skill already exists, update it with what you learned. "
"Otherwise, create a new skill if the approach is reusable.\n"
"If nothing is worth saving, just say 'Nothing to save.' and stop."
)关键词是"if appropriate"和"if nothing is worth saving, just say Nothing to save"——不是每次复盘都会写入,LLM 自己判断。
实现细节
def _spawn_background_review(self, messages_snapshot, review_memory, review_skills):
def _run_review():
review_agent = AIAgent(
model=self.model,
max_iterations=8, # 限制迭代次数,轻量复盘
quiet_mode=True, # 静默运行
)
# 共享 Memory/Skills 存储(写入同一个磁盘文件)
review_agent._memory_store = self._memory_store
# 禁用复盘触发器(防止递归:复盘 Agent 又触发复盘)
review_agent._memory_nudge_interval = 0
review_agent._skill_nudge_interval = 0
review_agent.run_conversation(
user_message=prompt,
conversation_history=messages_snapshot,
)
# 提取成功的写入操作,展示给用户
if actions:
self._safe_print(f" 💾 {summary}")
t = threading.Thread(target=_run_review, daemon=True, name="bg-review")
t.start()几个设计要点:
- daemon=True——后台线程不阻塞主会话退出
- max_iterations=8——轻量级复盘,不是完整任务执行
- 共享存储——子 Agent 写入的 memory/skill 直接持久化到磁盘
- 禁用递归触发——复盘 Agent 不会再触发自己的复盘
- 响应已返回后才启动——不和主对话抢模型算力
完整数据流
把三个系统串起来,看一次完整的"学习 → 回忆"流程:
┌──────────────── 会话 N ────────────────┐
│ │
│ 用户: "部署到 AWS 时 ECS 任务定义 │
│ 的内存参数要设多少?" │
│ ↓ │
│ Agent: 调用工具、查文档、试错 │
│ ↓ │
│ 用户: "对,就用 512MB,另外记住 │
│ 我们所有服务都跑在 ap-southeast-1"│
│ ↓ │
│ Agent: 主动调用 memory("add") 保存 │
│ → 写入 MEMORY.md(立即持久化) │
│ → 当前 system prompt 不变 │
│ ↓ │
│ ... 继续对话 ... │
│ ↓ │
│ [第 10 轮] 后台复盘触发 │
│ → 子 Agent 回顾对话 │
│ → 发现部署流程值得记录 │
│ → skill_manage("create", │
│ name="ecs-deploy", │
│ content="...") │
│ → 写入 skills/ecs-deploy/SKILL.md │
│ → 用户看到 "💾 Skill created" │
│ │
│ 会话结束,完整对话存入 SQLite │
└─────────────────────────────────────────┘
↓ 时间流逝 ↓
┌──────────────── 会话 N+1 ──────────────┐
│ │
│ 启动时: │
│ ├─ MEMORY.md 重新加载 → 新快照注入 │
│ │ system prompt(含 ap-southeast-1) │
│ ├─ Skills 索引重建 → 包含 ecs-deploy │
│ └─ session_search 工具随时可用 │
│ │
│ 用户: "帮我把新服务也部署一下" │
│ ↓ │
│ Agent: 看到 Skills 索引有 ecs-deploy │
│ → skill_view("ecs-deploy") 读全文 │
│ → 按照之前的步骤操作 │
│ → 自动使用 ap-southeast-1 区域 │
│ → 如果细节不够,session_search │
│ 检索上次的完整对话 │
│ │
└─────────────────────────────────────────┘三层存储各司其职:
- MEMORY.md 提供事实("区域是 ap-southeast-1")
- Skills 提供方法("部署 ECS 的完整步骤")
- Session Search 提供细节("上次具体报了什么错、怎么修的")
System Prompt 的组装
最后看一下这些知识是怎么注入到 system prompt 中的。
源码位置: agent/prompt_builder.py
System prompt 的结构如下:
┌─────────────────────────────────────┐
│ 1. Agent 身份 │
│ "You are Claude, made by..." │
├─────────────────────────────────────┤
│ 2. Memory 冻结快照 │
│ ══════════════════════ │
│ MEMORY (your personal notes) │
│ ══════════════════════ │
│ - 项目用 pnpm │
│ - 数据库在 ap-southeast-1 │
│ │
│ USER PROFILE │
│ - 用户喜欢简洁回答 │
│ - 后端工程师,前端经验少 │
├─────────────────────────────────────┤
│ 3. Skills 索引 │
│ ## Skills (mandatory) │
│ deployment: │
│ - ecs-deploy: 部署到 ECS │
│ debugging: │
│ - debug-docker: 排查网络 │
├─────────────────────────────────────┤
│ 4. 平台特定指令 │
│ (CLI / Telegram / Discord) │
├─────────────────────────────────────┤
│ 5. 工具使用指南 │
├─────────────────────────────────────┤
│ 6. Memory/Skills 使用指南 │
│ "当发现值得记录的内容时..." │
├─────────────────────────────────────┤
│ 7. Session Search 使用指南 │
│ "你可以搜索历史会话..." │
├─────────────────────────────────────┤
│ 8. 上下文文件 │
│ (SOUL.md, AGENTS.md 等) │
├─────────────────────────────────────┤
│ 9. 环境信息 │
│ (OS, Shell, 已安装工具等) │
└─────────────────────────────────────┘注意第 2、3 部分——Memory 快照和 Skills 索引是每次会话开始时重新构建的,所以上次保存的知识自然就出现在了新会话的"大脑"里。
构建代码的核心逻辑:
def build_memory_context_block(memory_store, user_profile_enabled=True):
"""用冻结快照构建 memory 上下文块"""
blocks = []
# 使用快照而非实时状态
memory_block = memory_store.format_for_system_prompt("memory")
if memory_block:
blocks.append(memory_block)
if user_profile_enabled:
user_block = memory_store.format_for_system_prompt("user")
if user_block:
blocks.append(user_block)
return "\n\n".join(blocks) if blocks else None本质总结
Hermes 的"自主学习"没有任何黑魔法:
LLM 的理解能力 + 文件持久化 = 跨会话的记忆积累它的精髓在于分层设计:
| 层次 | 容量 | 速度 | 用途 |
|---|---|---|---|
| MEMORY.md | 2200 字符 | 每次会话自动加载 | 关键事实,高频使用 |
| USER.md | 1375 字符 | 每次会话自动加载 | 用户画像,个性化 |
| Skills | 每个 10 万字符 | 按需加载 | 可复用工作流 |
| SQLite 会话 | 无上限 | 搜索时加载 | 完整历史,长尾检索 |
小容量的 Memory 像 CPU 的 L1 缓存——快但小;大容量的 SQLite 像硬盘——慢但全。Skills 介于两者之间,是结构化的"操作手册"。
更重要的是自动触发机制。不需要用户说"记住这个",后台复盘 Agent 会定期回顾对话,自主判断什么值得保存。这让"学习"从手动变成了自动——用户只管用,Agent 自己变聪明。
而且这套机制是模型无关的——不依赖特定模型的微调能力,任何支持 Tool Calling 的 LLM 都可以做到。这也是为什么 Hermes 能支持 Claude、GPT、DeepSeek 等多种模型。
下一篇预告
这篇讲的是 Agent 如何积累知识。但知识要有用,首先得有一个高效的执行循环——Agent Loop。下一篇我们来拆解 Hermes 的 Agent Loop 实现:模型怎么决定调什么工具、工具执行失败怎么重试、上下文太长怎么压缩。
地基(API)打好了,记忆(Memory)有了,接下来该看引擎了。