项目位置:
backend/hermes-agent/(第三方仓库NousResearch/hermes-agent的本地克隆,已通过父项目.gitignore排除) 文档目的:记录 Hermes-Agent 内置的上下文压缩(context compression)与记忆(memory)两大子系统的实现机制、配置项、与 Bridge 模式的实际关系,便于后续评估适配成本、规划启用方案。 评估时点:2026-08-11(HEADf4604f89)
Hermes-Agent 的上下文管理由两条独立子系统组成:
MEMORY.md / USER.md)、Skills 库、外部 Memory Provider 插件(Honcho / Mem0 / RetainDB 等)。与 Bridge 模式的关键事实:本项目通过 backend/hermes-bridge/hermes_bridge.py:228 显式传入 skip_memory=True,完全禁用了所有跨会话记忆机制;上下文压缩则保持默认启用状态。
判断入口:ContextCompressor.should_compress_info()
agent/context_compressor.py:2554-2585
def should_compress_info(self, prompt_tokens: int = None) -> "tuple[bool, str | None]":
tokens = prompt_tokens if prompt_tokens is not None else self.last_prompt_tokens
if tokens < self.threshold_tokens:
return False, None
if self._automatic_compression_blocked():
return False, self._compression_block_reason() or "blocked"
return True, None
触发依据:基于 token 阈值,不是消息条数,不是 LLM 自主决定。
prompt_tokens 与 threshold_tokensprompt_tokens 两个来源:
estimate_messages_tokens_rough(messages),字符数 / 4usage.prompt_tokens 写入 last_prompt_tokens(conversation_loop.py:3272)| 参数 | 默认值 | 含义 | 源码位置 |
|---|---|---|---|
threshold_percent |
0.50 |
触发比例(占上下文窗口) | context_compressor.py:2208 |
_MIN_CTX_TRIGGER_RATIO |
0.85 |
触发比例硬上限 | context_compressor.py:2090 |
_SMALL_CTX_THRESHOLD_PERCENT |
0.75 |
小上下文地板(窗口 < 512K 时强制 ≥75%) | context_compressor.py:664 |
_SMALL_CTX_CONTEXT_FLOOR |
512K |
"小上下文"的判定门槛 | context_compressor.py |
threshold_tokens_cap |
None |
绝对 token 上限(设置后取 min) | agent_init.py:1985-1992 |
threshold_tokens 计算公式:context_length × threshold_percent - max_tokens(保证不超过 85% 窗口,并预留回答空间),见 context_compressor.py:2166-2204。
agent/context_compressor.py:2647-2726
def _automatic_compression_blocked_locally(self) -> bool:
# 1. Summary LLM 失败冷却期内:不触发
if self._summary_failure_cooldown_until - time.monotonic() > 0:
return True
# 2. 连续 2 次压缩无效(节省 <10%)或连续 2 次使用 fallback:不触发
if (self._ineffective_compression_count >= 2
or self._fallback_compression_streak >= 2):
# 300 秒后允许一次"试探性"压缩,失败则再次阻断
return True
return False
阻断时通过 agent._warn_context_overflow_blocked(...) 向用户告警(conversation_loop.py:2077-2087)。
agent/context_compressor.py:5942-5977(算法注释)
Algorithm:
1. Prune old tool results (cheap pre-pass, no LLM call)
2. Protect head messages (system prompt + first exchange)
3. Find tail boundary by token budget (~20K tokens of recent context)
4. Summarize middle turns with structured LLM prompt
5. On re-compression, iteratively update the previous summary
context_compressor.py:2732-3005,四遍扫描:
"[Duplicate tool output — same content as a more recent call]"(context_compressor.py:2819-2843)min_prune_chars(默认 200)的旧 tool result 替换为一行摘要,如 [terminal] ran \npm test` -> exit 0, 47 lines output(_summarize_tool_result,context_compressor.py:1128-1187`)tool_call.arguments JSON(context_compressor.py:2906-2924)protect_tail_tokens × 1.5 时,对尾部内的 bulky tool 输出降级(context_compressor.py:2941-3005)
context_compressor.py:6054-6059
compress_start = self._protect_head_size(messages) # 头部边界
compress_start = self._align_boundary_forward(messages, compress_start)
compress_end = self._find_tail_cut_by_tokens(messages, compress_start) # 尾部边界
_protect_head_size(context_compressor.py:4682-4705)= system prompt(若有)+ protect_first_n 条非 system 消息_find_tail_cut_by_tokens(context_compressor.py:5026-5100)从末尾倒推累计 token,直至达到 tail_token_budget,消息数下限 min(protect_last_n, _MAX_TAIL_MESSAGE_FLOOR=8)
context_compressor.py:3469-3498、3765-3879
auxiliary_client.call_llm(context_compressor.py:3813)调用独立"摘要模型"(可配置 auxiliary.compression.provider/model)Goal / Active Task / In Progress / Decisions / Files / Completed Actions / Pending / Remaining Work 等字段(_summarizer_preamble,context_compressor.py:3637-3648)_previous_summary 时改用增量更新提示词(context_compressor.py:3479-3480)_SUMMARY_INPUT_MAX_CHARS = 160_000(约 40K token),超出则头尾保留、中段省略(context_compressor.py:395)max_tokens(context_compressor.py:3776-3785),max_summary_tokens 仅用于内部预算(上限 _SUMMARY_TOKENS_CEILING = 10_000)strip_think_blocks,避免思考模型(MiniMax / DeepSeek / QwQ)的 <think>...</think> 污染摘要(context_compressor.py:3854-3863)| 消息范围 | 处理方式 |
|---|---|
| system prompt | 永不压缩(始终在头部保护) |
头部 protect_first_n 条(默认 3) |
永不压缩;首次压缩后衰减为 0,避免早期消息"化石"(context_compressor.py:4693-4700) |
尾部 protect_last_n 条(默认 20,实际由 token 预算主导,下限 8) |
永不压缩 |
| 中段所有消息(user / assistant / tool) | 进入 LLM 总结输入,最终被摘要消息替换 |
工具调用结果是压缩的重点对象,三层处理:
_serialize_for_summary 把 tool 消息序列化为文本_strip_historical_media(context_compressor.py:6686-6692)把最新图片之前的所有图片 part 替换为文本占位符完全替换原消息,不附加。conversation_compression.py:2782 在调用前用 copy.deepcopy 保存原消息:
messages_before_compression = copy.deepcopy(messages)
...
compressed = compress_fn(messages, **compress_kwargs)
中段 N 条 → 1 条(或合并入尾部首条)摘要消息。
context_compressor.py:6564-6614
角色由交替规则动态决定(user / assistant),保证 OpenAI 兼容后端不会因角色连续而 400。强制规则:若 session 中没有任何真实 user 消息,摘要必须用 role="user"。
消息额外带进程内元数据(不上 wire):
{
"role": summary_role,
"content": summary,
COMPRESSED_SUMMARY_METADATA_KEY: True, # "_compressed_summary"
COMPRESSED_SUMMARY_HAS_USER_TURN_KEY: bool(...),
}
摘要文本末尾强制追加:
--- END OF CONTEXT SUMMARY — respond to the message below, not the summary above ---
system prompt 也会被追加一段说明:[Note: Some earlier conversation turns have been compacted into a handoff summary...](context_compressor.py:6419)。
| 阶段 | 保留情况 |
|---|---|
| 压缩过程中 | messages_before_compression 用于异常回滚(conversation_compression.py:2858-2905) |
提交后 in_place=true(默认) |
同一 session_id,直接覆盖,原始中段消息从内存与 DB 中消失 |
提交后 in_place=false |
创建子 session,父 session 保留原始消息作为审计轨迹 |
| 层级 | 触发场景 | 行为 | 源码 |
|---|---|---|---|
| 1. 模型回退 | aux 模型未找到(404/503)/ 超时 / JSON 解析失败 / 流式中断 | 切到主模型重试 | context_compressor.py:3965-3984 |
| 2. 冷却期 | 任意 LLM 失败 | 600 秒(无 provider 配置)或 60→300→900 秒阶梯(超时),期间 should_compress 返回 False |
context_compressor.py:627、1935-1951、4025-4028 |
| 3. 静态 fallback | LLM 总结失败但未 abort | 从被丢弃的中段本地提取连续性锚点(用户问句、assistant 动作、工具名、文件路径、错误文本),组装成结构化文本(上限 8000 字符) | context_compressor.py:6429-6454、3203-3332 |
| 4. 整体 abort | 鉴权失败(401/403)/ 网络断连 / abort_on_summary_failure=true |
保留原消息不变,向用户告警 | context_compressor.py:6354-6402、conversation_compression.py:2947-2956 |
压缩过程不产生 thinking_callback 输出。
证据:在 conversation_compression.py 中 grep thinking|on_reasoning|stream_callback|reasoning_callback 无任何匹配。压缩的状态通知走独立通道 agent._emit_status(用于 UI "压缩中"提示),与 thinking_callback 是两个不同的回调。
前端能看到:
status_callback → SSE)_compressed_summary 元数据,前端可据此特殊渲染)前端看不到:
<think> 块已被剥离)| 层级 | 类型 | 存储 | 生命周期 |
|---|---|---|---|
| 1 | 短期(会话内) | agent.messages 内存 + SQLite state.db |
会话内累加;超限时由压缩摘要后切到新 session_id |
| 2 | 中期(跨会话内置) | $HERMES_HOME/memories/MEMORY.md 与 USER.md(Markdown) |
跨会话持久,写入即时落盘 |
| 3 | 长期(外部 provider 插件) | Honcho / Mem0 / RetainDB / Supermemory 等后端 | 由插件决定,按 user_id / chat_id 隔离 |
| 4 | 技能库(Skills) | $HERMES_HOME/skills/<name>/(每个技能一个目录) |
跨会话持久,由后台 review 沉淀 |
agent.messages 列表 + 磁盘 SQLite SessionDB默认路径:hermes_state.py:239
DEFAULT_DB_PATH = get_hermes_home() / "state.db"
生命周期:会话内累加;上下文超限时由 conversation_compression.py 摘要后切到新 session_id(rotate)
tools/memory_tool.py:1-24(模块 docstring)
Provides bounded, file-backed memory that persists across sessions. Two stores:
- MEMORY.md: agent's personal notes and observations (environment facts, project
conventions, tool quirks, things learned)
- USER.md: what the agent knows about the user (preferences, communication style,
expectations, workflow habits)
Both are injected into the system prompt as a frozen snapshot at session start.
Mid-session writes update files on disk immediately (durable) but do NOT change
the system prompt -- this preserves the prefix cache for the entire session.
The snapshot refreshes on the next session start.
关键设计:
字符上限(默认值):
tools/memory_tool.py:165-169
def __init__(self, memory_char_limit: int = 2200, user_char_limit: int = 1375):
存储路径:
tools/memory_tool.py:53-67
def get_memory_dir() -> Path:
"""Return the profile-scoped memories directory."""
return get_hermes_home() / "memories"
MEMORY_BLOCK_HEADERS = {
"memory": "MEMORY (your personal notes)",
"user": "USER PROFILE (who the user is)",
}
ENTRY_DELIMITER = "\n§\n"
写入用原子 temp-file + rename(tools/memory_tool.py:863-876),并用 fcntl / msvcrt 做跨进程文件锁(_file_lock,tools/memory_tool.py:278-320)。
agent/memory_provider.py:1-31
"""Abstract base class for pluggable memory providers.
Memory providers give the agent persistent recall across sessions.
The MemoryManager enforces a one-external-provider limit to prevent
tool schema bloat and conflicting memory backends.
"""
只允许一个外部 provider 同时运行。已内置的插件位于 plugins/memory/:
| 插件 | 类型 |
|---|---|
honcho/ |
云端用户建模 |
hindsight/ |
— |
mem0/ |
— |
retaindb/ |
SQLite write-behind 队列 + 语义检索 |
supermemory/ |
— |
holographic/、byterover/、openviking/ |
— |
编排:agent/memory_manager.py 的 MemoryManager 负责 prefetch / sync / shutdown。
$HERMES_HOME/skills/ 下SKILL.md + 可选 references/、templates/、scripts/background_review.py)把"用户纠正 / 教训"沉淀到 skill 中_iters_since_skill
agent/background_review.py:1-17
"""Background memory/skill review — fork the agent to evaluate the turn.
After every turn, ``AIAgent.run_conversation`` may call
:func:`spawn_background_review` to fire off a daemon thread that replays
the conversation snapshot in a forked :class:`AIAgent` and asks itself
"should any skill/memory be saved or updated?". Writes go straight to
the memory + skill stores. Main conversation and prompt cache are never
touched.
agent/turn_context.py:582-590
# Track memory nudge trigger (turn-based, checked here).
should_review_memory = False
if (agent._memory_nudge_interval > 0
and "memory" in agent.valid_tool_names
and agent._memory_store):
agent._turns_since_memory += 1
if agent._turns_since_memory >= agent._memory_nudge_interval:
should_review_memory = True
agent._turns_since_memory = 0
agent/turn_finalizer.py:698-724
# Check skill trigger NOW — based on how many tool iterations THIS turn used.
_should_review_skills = False
if (agent._skill_nudge_interval > 0
and agent._iters_since_skill >= agent._skill_nudge_interval
and "skill_manage" in agent.valid_tool_names):
_should_review_skills = True
agent._iters_since_skill = 0
# External memory provider: sync the completed turn + queue next prefetch.
agent._sync_external_memory_for_turn(...)
# Background memory/skill review — runs AFTER the response is delivered
if final_response and not interrupted and (_should_review_memory or _should_review_skills):
try:
agent._spawn_background_review(
messages_snapshot=list(messages),
review_memory=_should_review_memory,
review_skills=_should_review_skills,
)
except Exception:
pass # Background review is best-effort
默认间隔:
nudge_interval: 10)skills.creation_nudge_interval: 15)Review fork 共享父 agent 的 _memory_store,但显式 skip_memory=True 避免触碰外部 provider,并 _persist_disabled=True 防止把审查 prompt 写入用户的 state.db:
agent/background_review.py:716-830
review_agent._memory_store = agent._memory_store
review_agent._memory_enabled = agent._memory_enabled
review_agent._user_profile_enabled = agent._user_profile_enabled
review_agent._memory_nudge_interval = 0
review_agent._skill_nudge_interval = 0
review_agent = AIAgent(..., skip_memory=True, ...)
review_agent._persist_disabled = True
review_agent._session_db = None
review_agent._session_json_enabled = False
工具白名单仅 memory / skill_manage(background_review.py:893-909),其他工具被拒绝。
@file: / @folder: / @diff / @staged / @git / @url: 的用户消息内联展开机制——不是 RAG 检索,是用户显式 @ 引用的精确解析。
agent/context_references.py:18-21
REFERENCE_PATTERN = re.compile(
rf"(?<![\w/])@(?:(?P<simple>diff|staged)\b|(?P<kind>file|folder|git|url):(?P<value>...))"
)
--- Attached Context ---)hard_limit = context_length × 0.50,soft_limit = context_length × 0.25_SENSITIVE_HOME_FILES、_SENSITIVE_HOME_DIRS)| 类型 | 保留内容 | 存储位置 |
|---|---|---|
| MEMORY.md | agent 个人笔记:环境事实、约定、工具怪癖、学到的教训 | $HERMES_HOME/memories/MEMORY.md |
| USER.md | 用户画像:偏好、沟通风格、期望、工作习惯 | $HERMES_HOME/memories/USER.md |
| Skills | "如何做这类任务"的类级技能库 | $HERMES_HOME/skills/<name>/ |
| state.db | 完整会话历史(SQLite,可通过 session_search 工具检索) |
$HERMES_HOME/state.db |
| 外部 provider | Honcho / Mem0 / RetainDB 等后端自己的存储 | 各自后端 |
写入方式:
memory 工具自主决策调用session_search:LLM 显式调用工具检索历史sync_turn:每轮后台自动执行| 机制 | 实现 | 源码 |
|---|---|---|
| 写入前注入 / 渗出扫描 | first_threat_message(content, scope="strict") |
tools/memory_tool.py:83-88 |
| 加载时再扫 | 命中则替换为占位符(保留原文本到 live state 让用户能看到并删除) | tools/memory_tool.py:242-276 |
| 写入审批门(write_approval) | stage(暂存待批)/ block / allow 三档 |
tools/memory_tool.py:911-965 |
| Drift 检测 | 检测外部 patch / shell append 污染,命中则备份 | tools/memory_tool.py:807-861 |
CLI /reset |
hermes memory 子命令重置 |
hermes_cli/subcommands/memory.py、hermes_cli/memory_reset.py |
compression.*)
cli-config.yaml.example:425-556、agent/agent_init.py:1860-2009、2455-2499
| YAML 字段 | 默认值 | 作用 |
|---|---|---|
enabled |
true |
总开关,false 完全关闭自动压缩 |
threshold |
0.50 |
触发阈值占上下文比例 |
model_thresholds |
{} |
按模型子串匹配覆盖阈值,如 "glm-5.2": 0.40 |
threshold_tokens |
null |
绝对 token 上限,与比例阈值取 min |
target_ratio |
0.20 |
尾部保留比例 = threshold × ratio × context_length |
protect_first_n |
3 |
头部额外保护消息数(首次压缩后衰减为 0) |
protect_last_n |
20 |
尾部最小保护消息数(实际受 token 预算与 floor=8 约束) |
min_tail_user_messages |
1 |
尾部至少保留的真实 user 消息数 |
max_attempts |
3(硬上限 10) |
单轮内压缩重试次数 |
in_place |
true |
true=同 session 覆盖,false=分裂子 session |
abort_on_summary_failure |
false |
true=LLM 总结失败时整体放弃压缩 |
proactive_prune_tokens |
0(禁用) |
独立的旧工具结果裁剪触发阈值 |
proactive_prune_min_result_chars |
8000 |
工具结果裁剪的最小字符门槛 |
proactive_prune_min_reclaim_tokens |
4096 |
裁剪必须至少回收这么多 token 才提交 |
progress_notices |
false |
是否在聊天平台显示压缩进度 |
idle_compact_after_seconds |
0(禁用) |
闲置 N 秒后下次回复前主动压缩 |
micro_compact |
false(opt-in) |
每轮滚动微压缩(实验性,默认关) |
摘要模型独立配置 在 auxiliary.compression.provider / model(默认 "auto":OpenRouter → Nous Portal → 主模型)。
是否可完全关闭:可以。compression.enabled: false。运行时所有触发点都先检查 agent.compression_enabled(conversation_loop.py:1966、2063、6360、6396)。
memory.*)
cli-config.yaml.example:665-683
| YAML 字段 | 默认值 | 作用 |
|---|---|---|
memory_enabled |
true |
关闭则不加载 MEMORY.md |
user_profile_enabled |
true |
关闭则不加载 USER.md |
memory_char_limit |
2200 |
MEMORY 字符上限(≈800 token) |
user_char_limit |
1375 |
USER 字符上限(≈500 token) |
nudge_interval |
10 |
0=禁用后台 memory review 触发 |
flush_min_turns |
6 |
0=禁用退出前 flush |
provider |
""(空字符串=禁用外部 provider) |
选哪个插件:honcho / mem0 / retaindb / supermemory 等 |
Skills(同位置 yaml 第 785 行):
| YAML 字段 | 默认值 | 作用 |
|---|---|---|
skills.creation_nudge_interval |
15 |
0=禁用后台 skill review |
agent/agent_init.py:1655-1669
_memory_toolset_requested = "memory" in (agent.enabled_toolsets or [])
if not skip_memory or _memory_toolset_requested:
mem_config = _agent_cfg.get("memory", {})
agent._memory_enabled = mem_config.get("memory_enabled", False)
agent._user_profile_enabled = mem_config.get("user_profile_enabled", False)
agent._memory_nudge_interval = int(mem_config.get("nudge_interval", 10))
if agent._memory_enabled or agent._user_profile_enabled:
from tools.memory_tool import MemoryStore
agent._memory_store = MemoryStore(
memory_char_limit=mem_config.get("memory_char_limit", 2200),
user_char_limit=mem_config.get("user_char_limit", 1375),
)
agent._memory_store.load_from_disk()
backend/hermes-bridge/hermes_bridge.py:219-231
kwargs = dict(
base_url=effective_base_url or None,
api_key=effective_api_key,
model=effective_model or None,
max_iterations=max_iterations,
quiet_mode=True,
tool_progress_mode="off",
skip_context_files=True,
load_soul_identity=False,
skip_memory=True, # ← 关键
)
agent = AIAgent(**kwargs)
skip_memory=True 的影响,对照 agent_init.py:1673-1739:
| 机制 | Bridge 模式下是否生效 | 原因 |
|---|---|---|
| 外部 Memory Provider | 完全禁用 | _memory_manager = None(整段被 if not skip_memory 包裹) |
| 后台 memory review 触发 | 不触发 | agent._memory_store 默认值 None + valid_tool_names 无 memory + 三个 AND 条件全失效 |
| 后台 skill review 触发 | 不触发 | 同上 |
| sync_all / queue_prefetch_all | 不执行 | _memory_manager 为 None,_sync_external_memory_for_turn 早 return |
| MEMORY.md / USER.md 加载 | 看 _memory_enabled 配置 |
若 enabled_toolsets 含 "memory"(默认 toolset 解析后含 memory),仍会建 store |
| MEMORY/USER 的 LLM 工具调用 | 取决于 memory_enabled 默认值 |
默认 False(mem_config.get("memory_enabled", False)) |
Bridge 没有传任何 compression.* 参数,走 AIAgent 默认:
compression.enabled = true(启用)compression.threshold = 0.50(50% 上下文窗口)compression.target_ratio = 0.20$HERMES_HOME/config.yaml 是否存在实际行为:会话内消息累积到约一半上下文窗口时,自动触发压缩流水线。
skip_memory=True(或改为 False)在 $HERMES_HOME/config.yaml 配置:
memory:
memory_enabled: true
user_profile_enabled: true
memory_char_limit: 2200
user_char_limit: 1375
nudge_interval: 10
外部 provider(可选):在 config.yaml 设 memory.provider: honcho + 配套 env(如 HONCHO_API_KEY)
per-user 隔离(可选):传 user_id / chat_id / gateway_session_key,参考 agent_init.py:1707-1723
| 影响项 | 评估 |
|---|---|
| system prompt 长度 | MEMORY 2200 字符 + USER 1375 字符 ≈ 1300 token 永久占用上下文 |
| prefix cache | 会话内不变(设计上保证 cache 命中) |
| 后台 LLM 调用成本 | 每 10 轮触发一次 background review fork(额外 LLM 调用) |
| 磁盘空间 | MEMORY.md / USER.md / skills/ 都很小(KB 级) |
| 隐私 | 用户对话内容会被沉淀到文件(已有注入扫描、写入审批、drift 检测三重防护) |
| Bridge session 隔离 | Bridge 按 session_id::hermes_home::model_id 缓存 agent(最多 8 个 LRU),不同 session_id 不共享 memory store |
| 文件 | 作用 |
|---|---|
agent/context_compressor.py |
主实现:阈值判断、5 阶段流水线、4 层失败降级 |
agent/context_engine.py |
基类,token 估算 |
agent/context_breakdown.py |
上下文构成分析(用于诊断) |
agent/conversation_compression.py |
压缩调用入口、回滚、用户告警 |
agent/conversation_loop.py:1972, 3272, 6360 |
触发点(请求前预检 + 响应后更新) |
agent/agent_init.py:1860-2009 |
配置加载与 ContextCompressor 实例化 |
agent/auxiliary_client.py |
独立的摘要模型客户端 |
cli-config.yaml.example:425-556 |
配置项示例 |
| 文件 | 作用 |
|---|---|
tools/memory_tool.py |
内置 MEMORY.md / USER.md 存储 + memory 工具 |
agent/memory_provider.py |
MemoryProvider ABC 接口 |
agent/memory_manager.py |
MemoryManager(编排 + prefetch / sync / shutdown) |
agent/background_review.py |
后台 fork 自评 |
agent/turn_context.py:582-590 |
Memory nudge 触发条件 |
agent/turn_finalizer.py:698-724 |
Review spawn / sync_all 调用点 |
agent/agent_init.py:1655-1739 |
Memory 初始化(双层 gate) |
agent/context_references.py |
@file: / @diff 等内联引用展开(与 memory 无关) |
plugins/memory/ |
外部 provider 插件目录 |
hermes_state.py:239 |
state.db 默认路径 |
cli-config.yaml.example:659-683 |
Memory 配置示例 |
| 文件 | 作用 |
|---|---|
backend/hermes-bridge/hermes_bridge.py:228 |
Bridge 显式 skip_memory=True |
backend/hermes-bridge/hermes_bridge.py:219-231 |
agent 创建参数 |