前言
很多 Agent 框架的"对话循环"就是简单的while: call LLM → execute tools → break if done。Hermes 不是。它的run_conversation()长达3700+ 行,藏着上下文压缩、Session 轮换、18 层错误恢复、7 层空响应兜底、记忆 nudge、Skill 自改进等一整套子系统。
理解清楚这套机制后,你会发现:一个长跑、跨平台、不爆窗、自维护的 Agent,远不止"调 LLM 拼消息"那么简单。
一、整体对话流程:一句话到最终回复
源码主入口:run_agent.py:11438 AIAgent.run_conversation()。完整流程如下:
用户输入 message ↓ ┌──────────── 预备阶段 ─────────────┐ │ ① 初始化 session 数据库行 │ │ ② 设置日志/线程上下文 │ │ ③ Memory nudge 计数 +1 │ │ ④ 检查/构建 cached system prompt │ │ ⑤ Preflight 压缩检查 │ │ ⑥ 通知 memory provider turn 开始 │ │ ⑦ 外部 provider prefetch_all() 一次 │ └─────────────────────────────────────┘ ↓ ┌──────────── 主循环 (while) ──────────┐ │ ▽ B. 构造 api_messages │ │ - sanitize/repair │ │ - 注入 prefetch + plugin ctx │ │ - 拼 system prompt │ │ - Anthropic cache_control │ │ │ │ ▽ C. 内层 retry 循环 │ │ - 流式 API 调用 │ │ - 验证响应 │ │ - 错误恢复链 (18 层) │ │ │ │ ▽ D. restart flag 判定 │ │ │ │ ▽ E. 响应处理 │ │ - 有 tool_calls → 执行 → continue│ │ - 无 tool_calls → 最终响应 → break│ │ - 空响应 → 7 层兜底 │ └──────────────────────────────────────┘ ↓ ┌──────────── 收尾阶段 ─────────────┐ │ ⑧ _flush_messages_to_session_db │ │ ⑨ memory_manager.sync_all │ │ ⑩ queue_prefetch_all (下轮预热) │ │ ⑪ 触发 _spawn_background_review │ │ ⑫ 返回 final_response │ └─────────────────────────────────────┘整个流程的核心是主循环里的双层嵌套:外层是"工具调用循环"(LLM 想调几次工具就转几轮),内层是"API 重试循环"(一次 API 调用最多 retry N 次)。这套设计让 Hermes 能处理:
- 模型一轮内连续调 10+ 次工具
- 单次 API 失败时的优雅重试 + fallback
- 中途用户中断(Ctrl+C)的快速响应
- 上下文超限时的就地压缩并继续
二、Session 是什么
session_id是 Hermes 给每段连续对话起的唯一标识符,格式YYYYMMDD_HHMMSS_<uuid6>,例如:
20260520_143022_a3f5b2一个 Agent 实例的生命里,session_id平时不变——一段对话从开始到结束都用同一个 id。只在 5 种事件下会轮换:
| 事件 | 入口 | parent_session_id |
|---|---|---|
/new或/reset | 用户主动清空 | null(真新对话) |
/resume <id> | 从 SQLite 恢复 | 不变(继续旧对话) |
/branch | fork 出新 session | 指向源 session |
| 自动压缩 | token 超阈值时 | 指向被压缩前的 session |
/compress | 用户手动压缩 | 同上 |
持久化:~/.hermes/state.db
每个 session 在sessions表里一行 row,包含:
sessions(idTEXTPRIMARYKEY,-- session_idsourceTEXT,-- 'cli'/'telegram'/'discord'/'gateway'parent_session_idTEXT,-- 父子链started_atREAL,ended_atREAL,end_reasonTEXT,-- 'compression'/'user_exit'/'timeout'titleTEXT,modelTEXT,system_promptTEXT,-- 冻结的 system prompt 快照input_tokensINT,output_tokensINT,cache_read_tokensINT,cache_write_tokensINT,estimated_cost_usdREAL,-- ...)关键点:当自动压缩发生时,旧 session 被标记end_reason='compression',新 session 通过parent_session_id串到它上面。旧 session 的原始消息 + FTS5 索引完整保留——后续session_search仍能搜到压缩前的内容。
三、上下文长度怎么追踪
每次 LLM 响应回来时,Hermes 读取response.usage,更新context_compressor的 token 计数器:
# run_agent.py:12790self.context_compressor.update_from_response(usage_dict)关键字段:
last_prompt_tokens:provider 报的真实输入 token 数——这是最权威的信号last_completion_tokens:本次输出 token 数(不算下次上下文)cache_read_tokens/cache_write_tokens:缓存命中统计
下一轮 turn 开始时判定要不要压缩:
# run_agent.py:14554if_compressor.last_prompt_tokens>0:_real_tokens=_compressor.last_prompt_tokenselse:_real_tokens=estimate_request_tokens_rough(messages,tools=self.tools)ifself.compression_enabledand_compressor.should_compress(_real_tokens):messages,active_system_prompt=self._compress_context(...)为什么不算 completion_tokens
注释(run_agent.py:14555-14559)说得很清楚:
Thinking models (GLM-5.1, QwQ, DeepSeek R1) inflate completion_tokens with reasoning, causing premature compression. (#12026)
推理模型把整段 reasoning 算进 completion_tokens——但这些 token不会留到下一轮上下文里(它们被 strip 或仅作 reasoning_details 字段保留)。所以只看 prompt_tokens。
阈值算法
# context_compressor.py:439-442self.threshold_tokens=max(int(self.context_length*threshold_percent),# 默认 0.50MINIMUM_CONTEXT_LENGTH,)200K 上下文窗口 × 50% =100K 触发压缩;小窗口模型用 floor 兜底。可通过context.threshold_percent: 0.6配置改。
防抖动机制
# context_compressor.py:493-513defshould_compress(self,prompt_tokens=None):tokens=prompt_tokensifprompt_tokensisnotNoneelseself.last_prompt_tokensiftokens<self.threshold_tokens:returnFalseifself._ineffective_compression_count>=2:logger.warning("Compression skipped — last 2 saved <10% each.")returnFalsereturnTrue连续 2 次低效压缩(< 10% 节省)→ 关闭自动压缩。防"每轮挤掉 1-2 条消息"的死循环。用户要/new或/compress <focus>才能继续。
四、压缩触发的三个时机
Hermes 共有3 个压缩入口,各自服务不同场景:
时机 1:Preflight(预飞行检查)
源码:run_agent.py:11709-11776
何时:主对话循环开始之前。
用途:处理"从 SQLite 恢复了大会话,但模型刚被切到小窗口"的场景。如果不预先压缩,第一次 API 调用就会爆。
if(self.compression_enabledandlen(messages)>self.context_compressor.protect_first_n+self.context_compressor.protect_last_n+1):_preflight_tokens=estimate_request_tokens_rough(messages,system_prompt=active_system_prompt,tools=self.tools,)if_preflight_tokens>=self.context_compressor.threshold_tokens:for_passinrange(3):# 最多 3 个 pass_orig_len=len(messages)messages,active_system_prompt=self._compress_context(...)iflen(messages)>=_orig_len:break# 压不动了if_preflight_tokens<threshold:break# 压到阈值下特点:用粗估(chars / 4)判定;最多 3 个 pass(很大的会话 + 很小的窗口需要多轮);带 tools schema 算(50 个工具能加 20-30K token)。
时机 2:Per-turn(每轮 API 调用后)
源码:run_agent.py:14570
何时:每次 API 响应回来后、tool_calls 执行完,进入下一轮 API 调用前。
用途:正常情况下随对话增长触发的压缩。
ifself.compression_enabledand_compressor.should_compress(_real_tokens):self._safe_print(" ⟳ compacting context…")messages,active_system_prompt=self._compress_context(...)conversation_history=None# 清旧引用,让 flush 写入新 session特点:用provider 报的真实last_prompt_tokens;单次压缩;session_id 在压缩内部轮换。
时机 3:错误恢复(context_overflow)
源码:run_agent.py:13682-13831
何时:API 报错被classify_api_error判定为context_overflow(Anthropic “prompt is too long” / OpenAI “context_length_exceeded” 等)。
用途:当 preflight 和 per-turn 都漏掉时的兜底。
ifis_context_length_error:# 1. 解析错误消息得到真实 context_length 限制parsed_limit=parse_context_limit_from_error(error_msg)ifparsed_limit:compressor.update_model(model=self.model,context_length=parsed_limit,...)# 2. 强行压缩messages,active_system_prompt=self._compress_context(...)# 3. 重试该 turnrestart_with_compressed_messages=Truebreak特点:解析 provider 返回的真实 context_length(比如 “your prompt has 250000 tokens, max is 200000”),步降 context_length并压缩;累计compression_attempts,上限 3 次。
五、压缩算法详解:4 阶段流水线
真正的压缩在context_compressor.compress()(agent/context_compressor.py:1355)里。算法分 4 个 phase:
Phase 1:工具结果剪枝(不调 LLM,便宜)
源码:_prune_old_tool_results(:519-685)
只对保护尾部之外的部分做:
- 去重:同一份 read_file 内容被读 5 次?老的 4 份换成
[Duplicate tool output — same content as a more recent call] - 摘要化:> 200 字符的老 tool result → 1 行描述。例如:
[terminal] ran `npm test` -> exit 0, 47 lines output [read_file] read config.py from line 1 (3,400 chars) [search_files] content search for 'compress' in agent/ -> 12 matches - 截屏剥离:base64 image 内容 →
[screenshot removed to save context](一张图 ~1500 token) - tool_call args 截断:> 500 字符的 args → 解析 JSON、shrink 字符串字段、reserialize。关键是保 JSON 有效——直接砍字节会破坏结构,provider 拒收(issue #11762)
Phase 2:边界确定
源码:_find_tail_cut_by_tokens(:1272-1334)
compress_start = protect_first_n(默认 3,含 system + 头几轮)compress_end反向走 token 预算:- 预算 =
threshold_tokens × summary_target_ratio(默认 20%) - 软上限 1.5× 预算(碰到大消息时容忍)
- 硬下限至少 3 条
- 预算 =
- 永不切开 tool_call / tool_result 配对(
_align_boundary_backward) - 最新 user message 必须在 tail(
_ensure_last_user_message_in_tail,#10896 修复)——否则用户最新请求会被 LLM 总结进"Pending User Asks",但 SUMMARY_PREFIX 又告诉模型"只回应 summary 后的 user message",任务直接消失
Phase 3:LLM 总结
源码:_generate_summary(:793-1071)
把中段消息交给辅助模型(auxiliary.compression.model,默认走主模型)总结。
预算公式:
summary_budget=max(2000,min(content_tokens ×0.20,min(context ×0.05,12000)))模板结构(12 个章节):
## Active Task ← 最重要:用户最近未完成请求的原话 ## Goal ← 整体目标 ## Constraints & Preferences ## Completed Actions ← 编号清单 (N. ACTION target — outcome [tool: name]) ## Active State ← 工作目录、修改的文件、测试状态 ## In Progress ## Blocked ## Key Decisions ## Resolved Questions ## Pending User Asks ## Relevant Files ## Remaining Work ## Critical Context迭代式更新:保存上一份_previous_summary,下次压缩用:
“PRESERVE existing info, ADD new actions, MOVE In Progress → Completed…”
让 LLM 在旧 summary 基础上增量更新,而不是从头总结。多次压缩信息不丢的核心机制就是这个。
双层 redact:输入序列化时一次(agent/redact.py30+ vendor 前缀模式扫密钥/token/密码),输出再一次(防 LLM 偷懒复述)。
错误处理矩阵:
- aux model 4xx/timeout/JSON decode/stream closed → 自动 fallback 主模型重试一次
- 主模型也失败 → 30s/60s/600s 冷却
- 完全失败 → 上层插静态占位符(不假装压缩成功)
Phase 4:组装
源码:compress()的后半段 (:1448-1556)
compressed=[*messages[:compress_start],# head 原样(system 追加 compaction note){role:user|assistant,content:SUMMARY_PREFIX+summary+END_MARKER},*messages[compress_end:],# tail 原样]Role 选择:必须不与前后冲突(API 要求 user/assistant 交替)。看 head 末尾和 tail 开头,选 summary 该用什么 role;极端情况两边都阻挡 → 合并到 tail 第一条消息开头。
SUMMARY_PREFIX:固定的告示文本,明确告诉 LLM “这是历史交接,不是新指令”,并重申 MEMORY.md/USER.md 仍权威。
END marker:summary 末尾 + tail 之间,固定字符串--- END OF CONTEXT SUMMARY — respond to the message below, not the summary above ---。防弱模型把 summary 里的"用户问过 X"当成新指令再做一遍。
_sanitize_tool_pairs:最后一道防线,扫一遍 orphan tool_call/tool_result 配对:
- 删 result 但 parent 没了的(orphan result)
- 给 tool_call 但 result 没了的插 stub:
[Result from earlier conversation — see context summary above]
否则 API 直接报 “No tool call found for function call output”。
六、_compress_context编排器:压缩之外的副作用
compress()只管"算法"。_compress_context()(run_agent.py:10005-10194) 负责一切副作用——这才是 session 切换发生的地方。
10 个步骤(按代码顺序):
def_compress_context(self,messages,system_message,*,approx_tokens,task_id,focus_topic):# ① 通知 memory provider 即将压缩self._memory_manager.on_pre_compress(messages)# ② 调核心压缩算法(上面的 4 阶段)compressed=self.context_compressor.compress(messages,current_tokens,focus_topic)# ③ 检查 LLM summary 错误,弹用户警告(去重)ifself.context_compressor._last_summary_error:self._emit_warning(...)# ④ TODO 列表重新注入todo_snapshot=self._todo_store.format_for_injection()iftodo_snapshot:compressed.append({"role":"user","content":todo_snapshot})# ⑤ 重建 system prompt(_invalidate_system_prompt 会重读 MEMORY.md!)self._invalidate_system_prompt()new_system_prompt=self._build_system_prompt(system_message)self._cached_system_prompt=new_system_prompt# ⑥ Session 切换(关键)ifself._session_db:self.commit_memory_session(messages)# 旧 session 谢幕self._session_db.end_session(self.session_id,"compression")old_session_id=self.session_id self.session_id=f"{ts}_{uuid6}"# 新 idself._session_db.create_session(session_id=self.session_id,parent_session_id=old_session_id,# ← 父子链...)# /goal 元数据转发# 标题自动编号 (e.g. "Refactor auth" → "Refactor auth (2)")self._session_db.update_system_prompt(self.session_id,new_system_prompt)self._last_flushed_db_idx=0# flush 游标重置# ⑦ 通知 context engine session 切了self.context_compressor.on_session_start(self.session_id,boundary_reason="compression",old_session_id=old_session_id,)# ⑧ 通知 memory providers session 切了self._memory_manager.on_session_switch(self.session_id,parent_session_id=old_session_id,reset=False,reason="compression",)# ⑨ 重复压缩警告(>= 2 次提示用户 /new)ifself.context_compressor.compression_count>=2:self._vprint("⚠️ Session compressed N times — accuracy may degrade.")# ⑩ 更新 token 估算 + 清 file_dedup 缓存_compressed_est=estimate_request_tokens_rough(compressed,...)self.context_compressor.last_prompt_tokens=_compressed_est reset_file_dedup(task_id)returncompressed,new_system_promptSession 切换的关键不变量
| 项 | 行为 |
|---|---|
| 旧 session 的 messages | 在 state.db 里完整保留,FTS5 索引仍完整 |
| 新 session 的 messages | 是compressed列表(head + summary + tail) |
parent_session_id | 把新旧串成单向链 |
/goal等元数据 | 单独迁移到新 session 的 state_meta |
| 标题 | 自动编号(hermes-cli/title.py的get_next_title_in_lineage) |
| system_prompt | 重新构建(重读 MEMORY.md!)并存到新 session 的 row |
这个设计让session_search能跨压缩边界搜索——你 3 个月前对话被压了 N 次,原始消息仍在 state.db 里可搜,FTS5 命中后通过_resolve_to_parent沿父子链回溯到根。
七、收尾阶段做了什么
主循环退出后(run_agent.py:15206之前):
1._flush_messages_to_session_db
把这一轮新增的 messages(自上次 flush 后)写入 state.db。多模态内容(图片)被剥成 text summary——base64 不进 DB。
写入时 SQLite 的 trigger 自动同步 FTS5 索引(messages_fts+messages_fts_trigram)。
2. 外部 provider sync
self._sync_external_memory_for_turn(original_user_message=original_user_message,final_response=final_response,interrupted=interrupted,)异步把这一轮整段 user_msg + assistant_response 推给外部 provider(mem0/honcho/etc)。中断的 turn 跳过 sync——半截内容会污染 provider 后续召回。
3. 触发后台 review
if_should_review_memoryor_should_review_skills:self._spawn_background_review(messages_snapshot=list(messages),review_memory=_should_review_memory,review_skills=_should_review_skills,)_spawn_background_review起一个 daemon 线程,fork 独立 AIAgent,让它读完整段对话决定要不要memory(add)/skill_manage(create/patch)。不阻塞用户,跑完打印一行 “💾 Self-improvement review: Memory updated · Skill patched”。
详细机制:
- fork 用同模型、同 credentials,但只启用
memory + skillstoolset max_iterations=16限额_memory_nudge_interval=0防递归- stdout/stderr 重定向
/dev/null+suppress_status_output=True双层抑制输出 - 危险命令自动 deny(防弹窗死锁)
- 共享
_memory_store实例(review 写入立刻对主会话生效)
4. 插件 hook + return
on_session_end插件 hook 触发(不阻塞),最后构造 result dict 返回:
return{"final_response":...,"messages":messages,"api_calls":api_call_count,"completed":True,}八、一个完整 turn 的时序图
user 输入消息 │ ├─ _user_turn_count += 1 ├─ _turns_since_memory += 1 → 到 10 设 _should_review_memory = True │ ▼ [预备] memory_manager.on_turn_start + prefetch_all(缓存这一整 turn) │ ▼ [Preflight 压缩检查] 估算 token,超阈值 → 压缩(最多 3 pass) │ ▼ ┌─ 主循环开始 ─────────────────────────────────────┐ │ ▽ B. 构造 api_messages │ │ sanitize / repair / 注入 ctx / │ │ Anthropic cache_control │ │ │ │ ▽ C. 内层 retry (流式) │ │ ├─ 成功 → 提取 token 计数 → break │ │ ├─ length 截断 → 续接 / 回滚 │ │ └─ 异常 → 18 层错误恢复链 │ │ ├─ surrogate / ASCII codec │ │ ├─ image rejection │ │ ├─ 401 (codex/nous/copilot/anthropic) │ │ ├─ thinking signature │ │ ├─ context_overflow → 压缩 + 步降 ctx │ │ ├─ 413 → 压缩 │ │ ├─ rate limit → eager fallback │ │ └─ ... │ │ │ │ ▽ D. restart flag 判定 │ │ restart_with_compressed_messages → continue│ │ restart_with_length_continuation → ↑ tokens │ │ │ │ ▽ E. 响应处理 │ │ ├─ tool_calls → 执行 → continue │ │ │ └─ 检查 _real_tokens → 触发压缩 │ │ └─ 无 tool_calls → 最终响应 │ │ ├─ 空响应 → 7 层兜底 │ │ │ (partial-stream / 旧内容回退 / │ │ │ post-tool nudge / thinking-prefill│ │ │ / 普通重试 / fallback provider / │ │ │ "(empty)" 终结) │ │ └─ 正常 → strip <think> → append → break│ └──────────────────────────────────────────────────┘ │ ▼ [收尾] ├─ _flush_messages_to_session_db → state.db │ └─ FTS5 trigger 自动同步索引 ├─ memory_manager.sync_all (provider 异步写) ├─ queue_prefetch_all (排队下轮召回) ├─ if _should_review_memory or skills: │ _spawn_background_review(daemon thread) ├─ plugin on_session_end hook └─ return final_response九、几个常见问题的源码答案
Q1: 一个 turn 内多次调工具的"O(N²) token 消耗"是真的吗?
字节会计意义上是真的——每次 API 调用都带完整消息列表,N 步累计 token 数是O(N²)。计费意义上不准——主流 provider(Anthropic、OpenAI、DeepSeek、Google)都有 prompt caching,二次项的常数被打到 1/10。Hermes 通过_cached_system_prompt保证 system prompt 字节稳定,最大化 cache hit。
Q2:/resume之后会发生什么?
CLI 调db.get_messages_as_conversation(session_id)把旧 session 的消息全部载入 messages 列表。新建 AIAgent 时conversation_history=messages传入。第一次run_conversation会触发 Preflight 压缩检查(如果上下文太大)。
Q3: 压缩时为什么要重建 system prompt?
旧 system prompt 是会话开始时冻结的快照。中间可能调过memory(add)把新事实写到磁盘,但 system prompt 这一会话不重读。压缩是天然的"重启点"——_invalidate_system_prompt()触发 MEMORY.md 重读,新 system prompt 就包含了这次会话写入的新记忆。
Q4: 怎么手动触发压缩?
/compress# 普通压缩/compress focus_topic# 引导式压缩(focus 主题分到 60-70% 预算)绕过should_compress的阈值检查和防抖动判定,强制跑一次。
Q5: 压缩可以撤销吗?
不能直接撤销,但可以/resume <old_session_id>恢复到压缩前的 session(消息原文仍在 state.db)。如果用/branchfork 出来则可以并行两条对话。
十、源码索引
| 主题 | 源码位置 |
|---|---|
run_conversation主入口 | run_agent.py:11438 |
| Preflight 压缩 | run_agent.py:11709-11776 |
| 主循环(双重嵌套) | run_agent.py:11863-14970 |
| Per-turn 压缩判定 | run_agent.py:14552-14580 |
| 错误恢复链 | run_agent.py:12918-14064 |
_compress_context编排 | run_agent.py:10005-10194 |
_flush_messages_to_session_db | run_agent.py:4469 |
_spawn_background_review | run_agent.py:4117-4259 |
compress()核心算法 | agent/context_compressor.py:1355 |
_generate_summaryLLM 调用 | agent/context_compressor.py:793-1071 |
_sanitize_tool_pairs | agent/context_compressor.py:1118-1176 |
| Anti-thrashing 防抖动 | agent/context_compressor.py:493-513 |
redact_sensitive_text | agent/redact.py |
| SessionDB 持久化 | hermes_state.py:185-306 |
_cached_system_prompt构建 | run_agent.py:5613-5804 |
| 配置默认值 | hermes_cli/config.py:1053 |