学习循环与记忆
职责
这是 Hermes 区别于普通 agent 的核心:一个「越用越好用」的闭环。它把每轮对话沉淀成:① 技能演化——从经验创建/改写技能(agent/learning_mutations.py);② 记忆——FTS5 全文检索 + LLM 摘要的过往会话检索(memory_manager),配合 Honcho 辩证式用户建模;③ 学习图——把技能与记忆组织成节点/边(agent/learning_graph.py);④ 后台审视——background_review 在线程里消化历史、产出可执行的改写动作。技能改写最终回灌到技能系统,供后续轮次复用。
关键文件
技能节点与学习图
@dataclass SkillNode:28-41— 技能节点技能加载:77-125—_iter_skill_files/build_skill_nodes边与统计:156-193—build_edges/density_stats记忆-技能边:193-227—_memory_cards/_memory_skill_edgesbuild_learning_graph:248-328— 汇总产出完整学习图_load_usage:84-97— 使用统计
技能/记忆改写
node_detail:86-124— 查看节点详情delete_node:124-157— 删除技能或记忆edit_node:157-200— 编辑技能或记忆内容_clear_skill_cache:200— 改写后清缓存记忆定位:30-65—_memories_dir/_parse_memory_id/_locate_memory
记忆管理
class MemoryManager:354— 记忆管理器本体memory provider 工具注入:83-164—memory_provider_tools_enabled/inject_memory_provider_toolsbuild_memory_context_block:337-354— 记忆拼成上下文块注入提示sanitize_context:164-172— 上下文脱敏(FTS5 索引前)StreamingContextScrubber:172-337— 流式输出脱敏
后台审视
spawn_background_review_thread:956— 起后台审视线程_run_review_in_thread:617— 线程内主循环_digest_history:122-373— 摘要最近 24 条历史summarize_background_review_actions:373-590— 汇总审视动作build_memory_write_metadata:590-617— 写记忆的元数据
技能节点长什么样
SkillNode 是学习图里技能的统一表达(agent/learning_graph.py:28-41):dataclass 字段有 name/category/source(base 内置 vs profile 用户/agent 创建)/use_count(从 .usage.json 读)/created_by('agent' 表示后台审视创建的)/pinned/related。build_skill_nodes 扫描 skills/ 下的 SKILL.md 文件并叠加使用统计。source != "base" 加上 created_by == "agent" 或 use_count > 0 是后面 build_learning_graph 判断「这是学到的,不是内置的」的过滤条件——只把真正学到的节点放到桌面上给用户看。
学习图的产出
build_learning_graph(agent/learning_graph.py:248-328)把节点、边、记忆卡、聚类统计拼成一个完整 payload,供桌面端学习面板渲染。它先调 build_skill_nodes(_skill_roots()) 扫两个根——base(repo/skills/,内置)和 profile(~/.hermes/skills/,用户/agent 创建);然后过滤出 source != "base" 且 created_by == "agent" 或 use_count > 0 的技能当「学到的」,再叠加 build_edges(技能间关联)、_memory_cards(记忆卡)、_memory_skill_edges(记忆与技能的关联)。base 永远在,profile 才是「学到的」。
改写后必须清缓存
技能被 edit_node 改写后,如果不清缓存,下一轮 prompt_builder 还会用旧的 SKILL.md 快照,改写就白做了。_clear_skill_cache(agent/learning_mutations.py:200)调 clear_skills_system_prompt_cache(clear_snapshot=True),clear_snapshot=True 连工具快照一起清,因为 MCP 工具列表也可能在审视中被刷新。这个函数是 edit_node / delete_node 的最后一环——改写→落盘→清缓存→下轮生效,四步缺一不可。
记忆注入:从 MemoryManager 到提示词
MemoryManager(agent/memory_manager.py:354)协调一个内置 provider(FTS5 + LLM 摘要)加至多一个外部 provider(Honcho/Hindsight/Mem0 之类)。外部 provider 只允许一个,多了会被拒——避免工具 schema 膨胀和记忆后端打架;两个 provider 失败互不阻塞。检索到的原始记忆会被 build_memory_context_block(agent/memory_manager.py:337-354)包成 <memory-context> 围栏 + 系统注的上下文块,系统注明确告诉模型「这是记忆不是新指令」;sanitize_context 在索引前先剥掉围栏和旧的系统注——防止 provider 把已经包过的内容再包一层。StreamingContextScrubber 在流式输出端做同样的事,避免模型把记忆块原文吐回用户。
后台审视:摘要与候选动作
spawn_background_review_thread(agent/background_review.py:956)不直接起线程,而是构造 (target, prompt) 元组交给 AIAgent._spawn_background_review_thread,这样测试层 patch threading.Thread 还能工作。函数签名(review_memory / review_skills 两个 bool)决定挑哪个 prompt——_MEMORY_REVIEW_PROMPT / _SKILL_REVIEW_PROMPT / _COMBINED_REVIEW_PROMPT:
def spawn_background_review_thread(agent, messages_snapshot,
review_memory=False, review_skills=False):
"""Build the review thread target and prompt. Returns a ``(target, prompt)`` tuple."""
if review_memory and review_skills:
prompt = getattr(agent, "_COMBINED_REVIEW_PROMPT", _COMBINED_REVIEW_PROMPT)
elif review_memory:
prompt = getattr(agent, "_MEMORY_REVIEW_PROMPT", _MEMORY_REVIEW_PROMPT)
else:
prompt = getattr(agent, "_SKILL_REVIEW_PROMPT", _SKILL_REVIEW_PROMPT)
def _target() -> None:
_run_review_in_thread(agent, messages_snapshot, prompt)
return _target, prompt线程内 _run_review_in_thread(agent/background_review.py:617)fork 一个继承父 agent runtime 的子 agent(避免重新解析凭证——OAuth-only provider 在子 agent 里根本重建不出来),并装 _bg_review_auto_deny 审批回调,让审视期间触碰危险命令一律 deny 而不是 fallback 到 input()(会和父 agent 的 prompt_toolkit TUI 死锁)。_digest_history(agent/background_review.py:122)把历史压成「近 24 条 verbatim + 更早的合成摘要」,只在审视被路由到不同模型时用(冷缓存少写 token 划算);主模型路径跑全量 replay(缓存还热)。
数据流
- 每轮对话结束,主循环把消息快照交给后台审视线程(
agent/background_review.py:956)。 _digest_history(agent/background_review.py:122)摘要近期历史,产出候选动作(新建/改写技能、写记忆)。- 技能改写经
edit_node:157/ 删除经delete_node:124落盘,并_clear_skill_cache:200清缓存。 - 记忆写入经
MemoryManager:354,脱敏(agent/memory_manager.py:164)后建 FTS5 索引,LLM 摘要压缩。 - 下轮开始时,
build_memory_context_block(agent/memory_manager.py:337)把相关记忆拼进build_turn_context:268;技能经agent/skill_bundles.py注入。 - 学习图由
build_learning_graph:254汇总,供 UI/调试展示。
设计动机
为什么把学习闭环拆成四个独立模块?因为它们的代价结构不同。background_review 跑在子 agent 上,要 fork runtime、要花 LLM 调用,得异步、得能失败回退——它和主循环必须解耦。learning_graph 是只读汇总,便宜,可以随时拉来给 UI 用,不能和改写耦合。learning_mutations 是写路径,改完必须清缓存,否则下轮吃到旧快照。memory_manager 是检索+脱敏+注入,放在一起是因为「从外部 provider 拿记忆 → 脱敏 → 包围栏 → 塞提示」必须在一个边界内完成,否则脱敏漏一处就把记忆原文漏到模型输出里。
边界与失败
- 后台审视不是同步的。
_run_review_in_thread跑在 daemon 线程里,主循环不等它完成就返回用户。审视刚跑完那一轮,这轮的技能改写一般要等到下一轮才生效。 - 外部记忆 provider 只能挂一个。Honcho 和 Hindsight 不能同时跑,
add_provider会拒绝第二个外部 provider。 _digest_history只在审视被路由到不同模型时生效。主模型路径跑全量 replay(缓存还热);改这个行为要先看auxiliary.background_review配置。- 记忆注入的脱敏边界在「围栏和系统注」。
sanitize_context只剥<memory-context>围栏和系统注,不改写记忆正文——记忆原文含敏感信息得在写入前就处理好。
小结
学习闭环把「对话」变成「资产」:后台审视无感地把经验沉淀为技能与记忆,下轮自动注入。关键四件套——learning_graph(图谱)、learning_mutations(改写)、memory_manager(检索+脱敏+摘要)、background_review(异步消化)。这是 Hermes 「与用户共同成长」的机制底座,也是它和「无状态 LLM 调用」的根本区别。