# Hermes 深入理解及源码解析(三):Hermes 的对话流程解析及其 Session 管理
2026/5/22 14:54:08 网站建设 项目流程

前言

很多 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 恢复不变(继续旧对话)
/branchfork 出新 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_tokensprovider 报的真实输入 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)

只对保护尾部之外的部分做:

  1. 去重:同一份 read_file 内容被读 5 次?老的 4 份换成[Duplicate tool output — same content as a more recent call]
  2. 摘要化:> 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
  3. 截屏剥离:base64 image 内容 →[screenshot removed to save context](一张图 ~1500 token)
  4. 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_prompt

Session 切换的关键不变量

行为
旧 session 的 messages在 state.db 里完整保留,FTS5 索引仍完整
新 session 的 messagescompressed列表(head + summary + tail)
parent_session_id把新旧串成单向链
/goal等元数据单独迁移到新 session 的 state_meta
标题自动编号(hermes-cli/title.pyget_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_dbrun_agent.py:4469
_spawn_background_reviewrun_agent.py:4117-4259
compress()核心算法agent/context_compressor.py:1355
_generate_summaryLLM 调用agent/context_compressor.py:793-1071
_sanitize_tool_pairsagent/context_compressor.py:1118-1176
Anti-thrashing 防抖动agent/context_compressor.py:493-513
redact_sensitive_textagent/redact.py
SessionDB 持久化hermes_state.py:185-306
_cached_system_prompt构建run_agent.py:5613-5804
配置默认值hermes_cli/config.py:1053

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询