:Hermes 的对话流程解析及其 Session 管理)
前言很多 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中途用户中断CtrlC的快速响应上下文超限时的就地压缩并继续二、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/gatewayparent_session_idTEXT,-- 父子链started_atREAL,ended_atREAL,end_reasonTEXT,-- compression/user_exit/timeouttitleTEXT,modelTEXT,system_promptTEXT,-- 冻结的 system prompt 快照input_tokensINT,output_tokensINT,cache_read_tokensINT,cache_write_tokensINT,estimated_cost_usdREAL,-- ...)关键点当自动压缩发生时旧 session 被标记end_reasoncompression新 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_tokens0:_real_tokens_compressor.last_prompt_tokenselse:_real_tokensestimate_request_tokens_rough(messages,toolsself.tools)ifself.compression_enabledand_compressor.should_compress(_real_tokens):messages,active_system_promptself._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_tokensmax(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_tokensNone):tokensprompt_tokensifprompt_tokensisnotNoneelseself.last_prompt_tokensiftokensself.threshold_tokens:returnFalseifself._ineffective_compression_count2:logger.warning(Compression skipped — last 2 saved 10% each.)returnFalsereturnTrue连续 2 次低效压缩 10% 节省→ 关闭自动压缩。防每轮挤掉 1-2 条消息的死循环。用户要/new或/compress focus才能继续。四、压缩触发的三个时机Hermes 共有3 个压缩入口各自服务不同场景时机 1Preflight预飞行检查源码run_agent.py:11709-11776何时主对话循环开始之前。用途处理从 SQLite 恢复了大会话但模型刚被切到小窗口的场景。如果不预先压缩第一次 API 调用就会爆。if(self.compression_enabledandlen(messages)self.context_compressor.protect_first_nself.context_compressor.protect_last_n1):_preflight_tokensestimate_request_tokens_rough(messages,system_promptactive_system_prompt,toolsself.tools,)if_preflight_tokensself.context_compressor.threshold_tokens:for_passinrange(3):# 最多 3 个 pass_orig_lenlen(messages)messages,active_system_promptself._compress_context(...)iflen(messages)_orig_len:break# 压不动了if_preflight_tokensthreshold:break# 压到阈值下特点用粗估chars / 4判定最多 3 个 pass很大的会话 很小的窗口需要多轮带 tools schema 算50 个工具能加 20-30K token。时机 2Per-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_promptself._compress_context(...)conversation_historyNone# 清旧引用让 flush 写入新 session特点用provider 报的真实last_prompt_tokens单次压缩session_id 在压缩内部轮换。时机 3错误恢复context_overflow源码run_agent.py:13682-13831何时API 报错被classify_api_error判定为context_overflowAnthropic “prompt is too long” / OpenAI “context_length_exceeded” 等。用途当 preflight 和 per-turn 都漏掉时的兜底。ifis_context_length_error:# 1. 解析错误消息得到真实 context_length 限制parsed_limitparse_context_limit_from_error(error_msg)ifparsed_limit:compressor.update_model(modelself.model,context_lengthparsed_limit,...)# 2. 强行压缩messages,active_system_promptself._compress_context(...)# 3. 重试该 turnrestart_with_compressed_messagesTruebreak特点解析 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 个 phasePhase 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 tokentool_call args 截断 500 字符的 args → 解析 JSON、shrink 字符串字段、reserialize。关键是保 JSON 有效——直接砍字节会破坏结构provider 拒收issue #11762Phase 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 3LLM 总结源码_generate_summary(:793-1071)把中段消息交给辅助模型auxiliary.compression.model默认走主模型总结。预算公式summary_budgetmax(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_PREFIXsummaryEND_MARKER},*messages[compress_end:],# tail 原样]Role 选择必须不与前后冲突API 要求 user/assistant 交替。看 head 末尾和 tail 开头选 summary 该用什么 role极端情况两边都阻挡 → 合并到 tail 第一条消息开头。SUMMARY_PREFIX固定的告示文本明确告诉 LLM “这是历史交接不是新指令”并重申 MEMORY.md/USER.md 仍权威。END markersummary 末尾 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 阶段compressedself.context_compressor.compress(messages,current_tokens,focus_topic)# ③ 检查 LLM summary 错误弹用户警告去重ifself.context_compressor._last_summary_error:self._emit_warning(...)# ④ TODO 列表重新注入todo_snapshotself._todo_store.format_for_injection()iftodo_snapshot:compressed.append({role:user,content:todo_snapshot})# ⑤ 重建 system prompt_invalidate_system_prompt 会重读 MEMORY.mdself._invalidate_system_prompt()new_system_promptself._build_system_prompt(system_message)self._cached_system_promptnew_system_prompt# ⑥ Session 切换关键ifself._session_db:self.commit_memory_session(messages)# 旧 session 谢幕self._session_db.end_session(self.session_id,compression)old_session_idself.session_id self.session_idf{ts}_{uuid6}# 新 idself._session_db.create_session(session_idself.session_id,parent_session_idold_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_idx0# flush 游标重置# ⑦ 通知 context engine session 切了self.context_compressor.on_session_start(self.session_id,boundary_reasoncompression,old_session_idold_session_id,)# ⑧ 通知 memory providers session 切了self._memory_manager.on_session_switch(self.session_id,parent_session_idold_session_id,resetFalse,reasoncompression,)# ⑨ 重复压缩警告 2 次提示用户 /newifself.context_compressor.compression_count2:self._vprint(⚠️ Session compressed N times — accuracy may degrade.)# ⑩ 更新 token 估算 清 file_dedup 缓存_compressed_estestimate_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 tailparent_session_id把新旧串成单向链/goal等元数据单独迁移到新 session 的 state_meta标题自动编号hermes-cli/title.py的get_next_title_in_lineagesystem_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_ftsmessages_fts_trigram。2. 外部 provider syncself._sync_external_memory_for_turn(original_user_messageoriginal_user_message,final_responsefinal_response,interruptedinterrupted,)异步把这一轮整段 user_msg assistant_response 推给外部 providermem0/honcho/etc。中断的 turn 跳过 sync——半截内容会污染 provider 后续召回。3. 触发后台 reviewif_should_review_memoryor_should_review_skills:self._spawn_background_review(messages_snapshotlist(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 skillstoolsetmax_iterations16限额_memory_nudge_interval0防递归stdout/stderr 重定向/dev/nullsuppress_status_outputTrue双层抑制输出危险命令自动 deny防弹窗死锁共享_memory_store实例review 写入立刻对主会话生效4. 插件 hook returnon_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²)。计费意义上不准——主流 providerAnthropic、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_historymessages传入。第一次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:11438Preflight 压缩run_agent.py:11709-11776主循环双重嵌套run_agent.py:11863-14970Per-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-4259compress()核心算法agent/context_compressor.py:1355_generate_summaryLLM 调用agent/context_compressor.py:793-1071_sanitize_tool_pairsagent/context_compressor.py:1118-1176Anti-thrashing 防抖动agent/context_compressor.py:493-513redact_sensitive_textagent/redact.pySessionDB 持久化hermes_state.py:185-306_cached_system_prompt构建run_agent.py:5613-5804配置默认值hermes_cli/config.py:1053