.. _lessons:
工程教训:从 12,000 行代码中提炼的模式
=============================================
Hermes Agent 的核心循环 ``run_agent.py`` 有 12,084 行。
加上工具系统、适配器、网关、CLI 等模块,总计超过 50,000 行 Python 代码。
这些代码不是凭空产生的——每一行都是对某个具体工程问题的回应。
本章的目标是从这些代码中提炼出可复用的模式、策略和教训。
我们将按照"架构模式"、"性能优化"、"错误处理"、"可扩展性"
和"技术债务"五个维度来组织分析。
.. mermaid::
:name: lessons-mindmap
:caption: Hermes 模式目录总览
mindmap
root((Hermes
模式目录))
架构模式
Strategy Pattern
API 路由
Self-Registration
AST 预检查
Observer Pattern
回调链
Adapter Pattern
SimpleNamespace
Circuit Breaker
MCP 断路器
OCC
history_version
WAL
SQLite WAL
性能优化
持久化事件循环
三层结果预算
Prompt 缓存
并行工具执行
双层技能缓存
错误处理
11 类错误分类
抖动退避
优雅降级
可扩展性
插件钩子系统
MCP 动态发现
工具集组合
皮肤引擎
技术债务
上帝类
提供商 Hack
预算共享
缓存失效
架构模式总结
--------------
Strategy Pattern:API 路由
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
**问题** :Hermes 需要同时支持 OpenAI、Anthropic、Bedrock、Google 等多个
LLM 提供商,每个提供商的 API 格式、认证方式、流式协议都不同。
**Hermes 的做法** :在 ``AIAgent.__init__`` 中,根据提供商类型设置
``api_mode`` 字段(如 ``"openai_chat"`` 、``"anthropic_messages"`` 、
``"codex_responses"``)。主循环根据 ``api_mode`` 选择不同的调用路径。
.. mermaid::
:name: strategy-pattern-api
:caption: Strategy Pattern:API 路由选择
flowchart TD
A["run_conversation()"] --> B{"api_mode?"}
B -->|"openai_chat"| C["OpenAI SDK
chat.completions.create()"]
B -->|"anthropic_messages"| D["Anthropic SDK
messages.create()"]
B -->|"codex_responses"| E["OpenAI SDK
responses API"]
B -->|"bedrock_converse"| F["Boto3 SDK
converse()"]
C --> G["统一响应格式
SimpleNamespace"]
D --> G
E --> G
F --> G
G --> H["工具调度 / 文本输出"]
**为什么不用继承?** 用继承的话,每新增一个提供商就需要一个新的子类。
Hermes 选择了在单个类内部用 ``api_mode`` 分支来实现策略切换。
这看起来违反了"用组合代替继承"的原则,但实际上有合理的工程考量:
- 大部分逻辑(预算控制、工具调度、消息管理)在所有提供商之间是共享的,
真正不同的只有 API 调用和响应解析两部分。
- 继承体系会导致共享逻辑在父类和子类之间来回搬移,
而策略模式将这些差异点集中在几个方法内部。
**何时使用** :当核心流程相同、只有特定步骤不同时。
**何时不用** :当不同策略的差异大到足以构成独立的模块时。
Self-Registration Pattern:工具注册表
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
**问题** :Hermes 有 40 多个工具,每个工具有自己的 schema、handler、
toolset 归属和可用性检查。如何让这些工具"自动发现"而非手动维护注册列表?
**Hermes 的做法** :
1. 每个工具文件(如 ``tools/file_tools.py``)在模块级别调用
``registry.register()`` 。
2. ``tools/registry.py`` 的 ``discover_builtin_tools()`` 函数扫描
``tools/`` 目录,用 **AST 预检查** 判断哪些文件包含注册调用,
只导入包含注册调用的文件。
3. 模块级代码在 import 时自动执行 ``register()`` ,将工具的 schema、
handler、检查函数注册到全局单例 ``registry`` 。
AST 预检查是这里的关键技巧:
.. code-block:: python
def _module_registers_tools(module_path: Path) -> bool:
"""Return True when the module contains a top-level registry.register() call."""
try:
source = module_path.read_text(encoding="utf-8")
tree = ast.parse(source, filename=str(module_path))
except (OSError, SyntaxError):
return False
return any(_is_registry_register_call(stmt) for stmt in tree.body)
这段代码只检查 **模块顶层语句** (``tree.body``),不检查函数内部。
这意味着辅助模块(内部的 ``register()`` 调用被封装在函数中)
不会被误导入。这是一个精巧的权衡:它避免了"导入所有文件"的代价,
同时不需要维护一个手工的注册列表。
**优点** :
- 新增工具只需创建文件并调用 ``register()`` ,零配置。
- AST 检查比 import-then-check 快得多(不需要执行任何代码)。
- 工具文件的注册声明与实现放在同一文件中,降低了遗漏风险。
**缺点** :
- 依赖隐式 import 副作用,新开发者可能不理解"为什么 import 了这个文件"。
- AST 检查无法识别动态构造的注册调用(如 ``getattr(registry, "register")()``)。
Observer Pattern:回调链
~~~~~~~~~~~~~~~~~~~~~~~~~~
**问题** :Agent 在运行过程中需要通知外部系统各种事件——流式文本输出、
工具执行开始/结束、思考过程展示、状态变更等。
如何在不耦合具体消费者的前提下实现这些通知?
**Hermes 的做法** :通过一系列回调函数实现观察者模式。
- ``stream_callback`` :流式文本输出,用于 TTS 管线和 TUI 渲染。
- ``tool_start_callback`` :工具开始执行时通知。
- ``tool_progress_callback`` :工具执行进度更新。
- ``status_callback`` :状态变更通知(如压缩警告、连接重置)。
- ``step_callback`` :每个迭代步骤通知,用于网关钩子系统。
这些回调在 ``run_conversation()`` 中以防御性方式调用——每个回调都被
try/except 包裹,确保回调失败不会影响主流程:
.. code-block:: python
if self.step_callback is not None:
try:
self.step_callback(api_call_count, prev_tools)
except Exception as _step_err:
logger.debug("step_callback error (iteration %s): %s", api_call_count, _step_err)
**教训** :回调必须与核心逻辑解耦。如果回调的失败能导致主流程崩溃,
那不是观察者模式,那是紧耦合。
Adapter Pattern:API 响应归一化
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
**问题** :不同 LLM 提供商返回不同格式的响应。OpenAI 返回
``response.choices[0].message`` ,Anthropic 返回
``response.content[0].text`` ,Bedrock 返回完全不同的结构。
**Hermes 的做法** :使用 Python 标准库的 ``SimpleNamespace`` 创建
轻量级的统一响应对象。
``SimpleNamespace`` 的妙处在于它既支持属性访问(``msg.content``),
又支持动态添加属性,且没有 ``dict`` 的键引号冗余。
在 Hermes 的 ``_normalize_codex_response()`` 、
``normalize_anthropic_response()`` 等函数中,
不同提供商的响应都被转换为具有 ``content`` 、``tool_calls``
等统一属性的 ``SimpleNamespace`` 对象。
这样主循环只需要处理一种格式,提供商差异被完全封装在适配器层。
Circuit Breaker:MCP 断路器
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
**问题** :MCP(Model Context Protocol)工具服务器是外部进程,
可能崩溃、超时或返回错误。如果每次调用都等待完整的超时时间,
整个 Agent 循环会被阻塞。
**Hermes 的做法** :在 ``tools/mcp_tool.py`` 中实现了断路器模式:
- 每个 MCP 服务器维护连续失败计数器。
- 当失败次数超过阈值时,服务器被标记为"短路"(short-circuited),
后续调用直接返回错误而不尝试连接。
- 定期重试以检测服务器是否恢复。
这避免了"一个坏掉的 MCP 服务器拖慢整个 Agent"的问题。
断路器的价值不在于它能修复问题,而在于它能快速失败,
让 Agent 继续执行其他工具。
Optimistic Concurrency Control:网关历史版本
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
**问题** :TUI 网关允许多个操作并发执行(用户可能一边等 Agent 响应,
一边触发撤销或压缩)。如何确保 Agent 的响应写入不会被并发操作覆盖?
**Hermes 的做法** :在 ``tui_gateway/server.py`` 中使用乐观并发控制:
每个会话维护一个 ``history_version`` 整数计数器。
每次修改历史(压缩、撤销、重试)时递增版本号。
当 Agent 完成一个 turn 时,检查当前版本是否与启动时的版本一致:
.. code-block:: python
# 开始 turn 时快照版本
history_version = int(session.get("history_version", 0))
# ... Agent 运行 ...
# 完成时检查版本
current_version = int(session.get("history_version", 0))
if current_version == history_version:
session["history"] = result["messages"]
session["history_version"] = history_version + 1
else:
# 历史在 turn 期间被外部修改了——不覆盖
print("[tui_gateway] history_version mismatch — output NOT written")
这比加锁更轻量:不需要在整个 turn 期间持有锁(那会阻塞所有并发操作),
只需在最终写入时检查版本。冲突时选择丢弃 Agent 的输出而非覆盖——
因为在 Agent 运行期间发生的历史变更(如用户撤销)通常优先级更高。
Write-Ahead Log:SQLite WAL 模式
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
**问题** :网关模式下,多个平台(Telegram、Discord、CLI)的会话
同时读写同一个 ``state.db`` 。如何在不牺牲并发性的前提下保证数据完整性?
**Hermes 的做法** :在 ``hermes_state.py`` 中启用 SQLite 的 WAL
(Write-Ahead Logging)模式:
.. code-block:: python
self._conn.execute("PRAGMA journal_mode=WAL")
WAL 模式的核心优势:
- **读不阻塞写** :多个读者可以同时访问数据库,即使有写入正在进行。
这对网关场景至关重要——一个平台的 Agent 在写入消息时,
另一个平台的用户应该能正常读取历史。
- **写不阻塞读** :写入操作追加到 WAL 文件,不影响当前读者。
只有在 WAL checkpoint 时才需要短暂的排他访问。
Hermes 还实现了定期 PASSIVE WAL checkpoint,防止 WAL 文件无限增长:
.. code-block:: python
def _maybe_checkpoint(self):
"""Best-effort PASSIVE WAL checkpoint. Never blocks, never raises."""
if self._write_count % 50 != 0:
return
try:
self._conn.execute("PRAGMA wal_checkpoint(PASSIVE)")
except Exception:
pass
性能优化策略
--------------
持久化事件循环:避免 asyncio.run() 的陷阱
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
**问题** :Hermes 的工具系统混合了同步和异步处理器。一些工具(如
web_search)使用 httpx 的异步客户端,但主循环是同步的。
如何高效地在同步上下文中调用异步工具?
**反模式** :在每个异步调用时使用 ``asyncio.run()`` 。
``asyncio.run()`` 的行为是:创建一个新事件循环,运行协程,
然后 **关闭** 事件循环。问题是,httpx 和 AsyncOpenAI 客户端
会将连接池绑定到创建它们的事件循环。当事件循环被关闭后,
这些客户端在垃圾回收时尝试清理连接,触发
``RuntimeError: Event loop is closed`` 错误。
**Hermes 的做法** (``model_tools.py``):
1. 主线程维护一个持久化事件循环 ``_tool_loop`` ,整个进程生命周期不关闭。
2. 工作线程(并行工具执行)使用线程局部存储维护各自的持久化循环。
3. 只有在已经处于异步上下文时(如网关的 async 栈),才退回到
``concurrent.futures.ThreadPoolExecutor`` + ``asyncio.run`` 的方案。
.. code-block:: python
def _run_async(coro):
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop and loop.is_running():
# 异步上下文中——用临时线程运行
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
future = pool.submit(asyncio.run, coro)
return future.result(timeout=300)
# 工作线程——用线程局部持久循环
if threading.current_thread() is not threading.main_thread():
worker_loop = _get_worker_loop()
return worker_loop.run_until_complete(coro)
# 主线程——用全局持久循环
tool_loop = _get_tool_loop()
return tool_loop.run_until_complete(coro)
**教训** :在混合同步/异步的系统中,``asyncio.run()`` 是一个危险的陷阱。
它看起来简洁,但会在高频调用场景下造成资源泄漏和运行时错误。
持久化事件循环虽然不那么"优雅",但在生产环境中更可靠。
三层结果预算系统
~~~~~~~~~~~~~~~~~~
**问题** :工具返回的结果可能非常大(如 ``read_file`` 读取一个 1MB 的日志文件)。
如果将所有工具结果都保留在消息历史中,上下文窗口会被迅速填满。
**Hermes 的做法** (``tools/budget_config.py``):三层预算控制。
.. mermaid::
:name: three-layer-budget
:caption: 三层结果预算系统
flowchart TB
A["工具返回结果"] --> B{"Layer 1: 单工具阈值
default 100K chars"}
B -->|"超过"| C["持久化到磁盘
替换为 preview (1.5K chars)"]
B -->|"未超过"| D["保留在消息中"]
C --> E{"Layer 2: 单轮聚合预算
default 200K chars"}
D --> E
E -->|"超过"| F["触发 Layer 2 持久化
大结果写入磁盘"]
E -->|"未超过"| G["继续"]
F --> H{"Layer 3: 总迭代预算
max_iterations"}
G --> H
H -->|"耗尽"| I["终止循环"]
- **Layer 1(per-tool)** :每个工具有独立的结果大小阈值(默认 100K 字符)。
超过阈值的结果被持久化到磁盘文件,消息中只保留 1.5K 字符的预览。
特殊工具如 ``read_file`` 被设为无限阈值,避免无限循环。
- **Layer 2(per-turn)** :单个 assistant turn 内所有工具结果的聚合预算
(默认 200K 字符)。超过时触发批量持久化。
- **Layer 3(total iterations)** :总迭代次数预算(默认 90 次),
通过 ``IterationBudget`` 类进行线程安全管理。
这种分层设计让预算控制既有粒度(per-tool)又有全局视野(per-turn),
避免了单一阈值要么太宽松、要么太严格的困境。
Prompt 缓存:system_and_3 策略
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
**问题** :多轮对话中,系统提示词和早期对话在每次 API 调用时都被重新发送,
造成大量重复的 token 计费。
**Hermes 的做法** (``agent/prompt_caching.py``):利用 Anthropic 的
``cache_control`` 机制实现 "system_and_3" 缓存策略。
Anthropic 允许最多 4 个缓存断点。Hermes 将它们分配为:
1. 系统提示词(在所有 turn 之间稳定不变)
2-4. 最近 3 条非系统消息(滚动窗口)
这意味着在多轮对话中,系统提示词和最近几条消息只需计算一次,
后续 turn 的 API 调用可以直接命中缓存。Hermes 报告约 75% 的
输入 token 节省。
一个关键实现细节:Hermes 在连续会话中会从 SQLite 加载之前存储的
系统提示词,而不是重新构建。这确保了提示词的逐字节一致性,
从而保证缓存命中率。如果每次都重新构建提示词,即使内容相同,
细微的格式差异也可能导致缓存未命中。
并行工具执行策略
~~~~~~~~~~~~~~~~~~
**问题** :当模型在一个响应中请求调用多个工具时,是否应该并行执行?
**Hermes 的做法** :三级并行策略。
- **NEVER_PARALLEL** :``clarify`` 等交互式工具,必须串行执行。
- **PARALLEL_SAFE** :``web_search`` 、``read_file`` 、``search_files``
等只读工具,可以安全并行。
- **PATH_SCOPED** :``read_file`` 、``write_file`` 、``patch`` 等文件操作,
可以并行但需要检查路径是否重叠。
路径重叠检测(``_paths_overlap()``)是一个精巧的设计。
它比较两个路径的路径组件前缀,而非直接比较字符串:
``/tmp/a.txt`` 和 ``/tmp/b.txt`` 不重叠,可以并行;
但 ``/tmp/dir/`` 和 ``/tmp/dir/file.txt`` 重叠,需要串行。
**当不确定时,默认串行。** 这是并行工具执行的核心原则。
``_should_parallelize_tool_batch()`` 在任何解析失败或未知工具的情况下
都返回 ``False`` ,确保安全性优先于性能。
双层技能缓存
~~~~~~~~~~~~~~
**问题** :技能索引(skills index)的构建需要扫描磁盘上的所有技能目录、
解析 YAML frontmatter、匹配平台条件。在每次 API 调用时重复这个过程代价太高。
**Hermes 的做法** :双层缓存——LRU 内存缓存 + 磁盘快照。
- 内存层:使用 LRU 缓存存储最近使用的技能索引结果。
- 磁盘层:将索引序列化为 JSON 快照文件,下次启动时直接加载。
这种模式在"首次构建慢、后续读取快"的场景中非常有效。
技能列表不频繁变更(只有在用户安装/卸载技能时才变),
因此缓存的命中率极高。
错误处理哲学
--------------
11 类错误分类:精确优于泛型重试
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
**问题** :API 调用可能因为各种原因失败。最简单的做法是捕获所有异常,
等待一段时间后重试。但这种"一刀切"的方式要么重试了不该重试的错误
(如认证失败),要么对可以恢复的错误(如限流)退避不足。
**Hermes 的做法** :在 ``agent/error_classifier.py`` 中实现了
11 类精细错误分类:
.. list-table::
:header-rows: 1
:widths: 20 30 50
* - 错误类型
- 恢复策略
- 典型触发条件
* - ``auth``
- 凭证轮换
- 401/403,API key 无效
* - ``auth_permanent``
- 终止,提示用户
- 认证刷新后仍失败
* - ``billing``
- 切换凭证/提供商
- 402,余额耗尽
* - ``rate_limit``
- 抖动退避 + 轮换
- 429,请求频率过高
* - ``overloaded``
- 抖动退避
- 503/529,提供商过载
* - ``server_error``
- 重试
- 500/502,内部错误
* - ``timeout``
- 重建连接 + 重试
- 连接/读取超时
* - ``context_overflow``
- 压缩上下文
- 上下文窗口溢出
* - ``payload_too_large``
- 压缩 payload
- 413,请求体过大
* - ``model_not_found``
- 切换模型/提供商
- 404,模型不存在
* - ``format_error``
- 终止或剥离重试
- 400,请求格式错误
分类管线的优先级顺序是一个重要的设计决策:
1. **提供商特定模式** (最高优先级):Anthropic thinking block 签名错误、
长上下文层级门控等。这些模式必须在通用处理之前检查,
因为它们的恢复策略与通用模式不同。
2. **HTTP 状态码** :基础分类层,根据 401/402/429/500 等状态码分类。
3. **错误码** :响应体中的结构化错误码(如 ``resource_exhausted``)。
4. **消息模式匹配** :当没有状态码时,通过错误消息中的关键词分类。
这一层需要仔细区分歧义情况——例如 402 可能是余额耗尽或临时配额限制。
5. **传输错误启发式** :连接中断、超时等网络层错误。
6. **上下文溢出启发式** :当大会话遇到连接中断时,推测为上下文溢出。
7. **兜底:未知** :可重试,但使用较长的退避间隔。
**402 歧义消解** 是分类管线中最精巧的部分。HTTP 402 通常意味着"需要付款",
但有些提供商在临时配额耗尽时也返回 402。Hermes 通过检查错误消息中是否
包含临时信号("try again"、"resets at"、"retry")来区分:
- "Usage limit exceeded, try again in 5 minutes" → 临时配额 → 当作限流处理
- "Insufficient credits" → 余额耗尽 → 切换凭证
这种区分避免了将临时配额错误当作账户问题处理(导致不必要的凭证切换),
也避免了将真正的余额耗尽当作临时问题反复重试。
.. mermaid::
:name: error-classification-flow
:caption: 11 类错误分类管线
flowchart TD
A["API 调用失败"] --> B{"提供商特定模式?"}
B -->|是| C["thinking_signature / long_context_tier"]
B -->|否| D{"有 HTTP 状态码?"}
D -->|是| E{"状态码分类"}
D -->|否| F{"有错误码?"}
E --> G["401→auth, 402→歧义消解,
429→rate_limit, 500→server_error"]
F -->|是| H["resource_exhausted→rate_limit,
insufficient_quota→billing"]
F -->|否| I{"消息模式匹配"}
I --> J["billing / rate_limit /
context_overflow / auth"]
I -->|无匹配| K{"传输错误?"}
K -->|是| L["timeout / context_overflow"]
K -->|否| M["unknown — 可重试"]
G --> N["ClassifiedError
+ 恢复策略提示"]
C --> N
H --> N
J --> N
L --> N
M --> N
抖动退避:避免重试风暴
~~~~~~~~~~~~~~~~~~~~~~~~
**问题** :当多个 Agent 会话同时遇到限流错误时,如果它们使用相同的
退避间隔,会在退避结束后同时重试,形成"重试风暴"(convoy effect)。
**Hermes 的做法** (``agent/retry_utils.py``):抖动指数退避。
.. code-block:: python
def jittered_backoff(attempt, *, base_delay=5.0, max_delay=120.0, jitter_ratio=0.5):
exponent = max(0, attempt - 1)
delay = min(base_delay * (2 ** exponent), max_delay)
# 用时间戳 + 单调计数器做种子,确保不同线程的抖动不同
seed = (time.time_ns() ^ (tick * 0x9E3779B9)) & 0xFFFFFFFF
rng = random.Random(seed)
jitter = rng.uniform(0, jitter_ratio * delay)
return delay + jitter
关键设计点:
- **确定性伪随机** :使用 ``random.Random(seed)`` 而非全局 ``random`` ,
避免影响其他使用 random 的代码。
- **种子混合** :将时间戳与单调计数器异或,即使系统时钟精度较低
也能保证不同调用的种子不同。
- **线程安全** :单调计数器通过 ``threading.Lock`` 保护。
- **上限保护** :``max_delay=120.0`` 确保退避时间不会无限增长。
**为什么不用全随机?** 全随机退避的期望延迟可能过高。
抖动退避保留了指数增长的确定性骨架(``base * 2^n``),
只在骨架上叠加随机偏移,兼顾了"足够快恢复"和"不形成风暴"。
优雅降级
~~~~~~~~~~
**问题** :上下文压缩依赖 LLM 生成摘要。如果摘要生成本身失败(例如
辅助模型不可用),系统不应该崩溃。
**Hermes 的做法** :摘要生成失败时,回退到静态压缩策略——
直接删除中间的消息,保留头尾。虽然会丢失一些信息,
但系统仍然可以继续运行。
类似地,在迭代预算耗尽时,Hermes 会给模型一次"宽限调用"(grace call)
来生成最终总结。如果宽限调用也失败,使用静态的兜底消息:
.. code-block:: python
except Exception as e:
logging.warning(f"Failed to get summary response: {e}")
final_response = (
f"I reached the maximum iterations ({self.max_iterations}) "
f"but couldn't summarize. Error: {str(e)}"
)
**教训** :在一个系统中,每一项"增强功能"(如 LLM 摘要)都应该有
一个"基础版本"的兜底方案。增强功能让系统更好用,兜底方案让系统不会坏。
可扩展性设计
--------------
插件钩子系统
~~~~~~~~~~~~~~
**问题** :如何在不修改核心代码的前提下,让用户和第三方扩展 Agent 的行为?
**Hermes 的做法** :定义了 10 个生命周期钩子(lifecycle hooks):
.. list-table::
:header-rows: 1
:widths: 25 75
* - 钩子事件
- 触发时机
* - ``gateway:startup``
- 网关进程启动时
* - ``session:start``
- 新会话创建时
* - ``session:end``
- 会话结束时
* - ``session:reset``
- 会话重置完成时
* - ``agent:start``
- Agent 开始处理消息时
* - ``agent:step``
- 工具循环的每个迭代步骤
* - ``agent:end``
- Agent 完成处理时
* - ``command:*``
- 任何斜杠命令执行时(通配符匹配)
钩子的设计遵循几个关键原则:
1. **错误隔离** :钩子错误被捕获并记录,但绝不阻塞主流程。
``HookRegistry.emit()`` 中每个处理器都被独立的 try/except 包裹。
2. **通配符匹配** :``command:*`` 匹配所有 ``command:xxx`` 事件,
支持一次性监听整类事件。
3. **动态加载** :钩子从 ``~/.hermes/hooks/`` 目录动态发现和加载,
不需要修改配置文件。每个钩子目录包含 ``HOOK.yaml`` (元数据)
和 ``handler.py`` (处理器代码)。
4. **同步/异步兼容** :钩子处理器可以是同步函数或异步函数,
框架自动检测并通过 ``asyncio.iscoroutine()`` 分发。
MCP 动态工具发现
~~~~~~~~~~~~~~~~~~
**问题** :MCP 工具服务器在运行时可能启动、停止、或更新工具列表。
如何让 Agent 动态感知这些变化?
**Hermes 的做法** :在 ``tools/mcp_tool.py`` 中实现了完整的
MCP 工具发现和动态注册:
- 启动时扫描 ``config.yaml`` 中的 ``mcp_servers`` 配置。
- 连接每个 MCP 服务器,获取其工具列表。
- 将工具注册到全局 ``ToolRegistry`` ,归属 ``mcp-`` toolset。
- 当服务器发送 ``notifications/tools/list_changed`` 时,
先 ``deregister`` 旧工具,再重新注册新工具。
注册表中有一个精巧的冲突解决机制:
.. code-block:: python
both_mcp = (
existing.toolset.startswith("mcp-")
and toolset.startswith("mcp-")
)
if both_mcp:
# MCP 工具允许同名覆盖(同一服务器的工具刷新)
pass
else:
# 非 MCP 工具不允许覆盖内置工具
logger.error("Tool registration REJECTED: '%s' would shadow existing tool")
return
这防止了 MCP 工具意外覆盖内置工具,同时允许 MCP 工具之间的同名覆盖
(因为同一服务器刷新工具列表时,新旧版本可能暂时同名)。
工具集组合与菱形依赖
~~~~~~~~~~~~~~~~~~~~~~
**问题** :工具集(toolsets)可以引用其他工具集,形成组合关系。
如果 A 包含 B 和 C,而 B 也包含 C,那么 C 的工具应该只出现一次。
**Hermes 的做法** (``toolsets.py``):使用集合操作解析组合关系。
``resolve_toolset()`` 函数递归展开工具集引用,用 ``set()`` 去重,
确保每个工具只出现一次。这解决了工具集组合中的"菱形依赖"问题——
无论一个工具被多少个路径引用,最终只被包含一次。
皮肤主题引擎
~~~~~~~~~~~~~~
**问题** :如何让用户自定义 CLI 的视觉外观,而不需要修改代码?
**Hermes 的做法** (``hermes_cli/skin_engine.py``):数据驱动的皮肤系统。
皮肤定义为 YAML 文件,包含颜色、spinner 动画、品牌文案等配置。
缺失的值从 ``default`` 皮肤继承——这是一种主题继承模式。
皮肤的继承关系通过 YAML 的层级结构自然表达:
用户定义的新皮肤只需覆盖想要改变的属性,其余属性自动继承默认值。
这种"约定优于配置"的设计降低了定制门槛。
技术债务反思
--------------
没有完美的系统,Hermes 也不例外。在赞赏其设计亮点的同时,
我们也必须诚实地面对它的技术债务。
12,000 行的上帝类
~~~~~~~~~~~~~~~~~~~
``run_agent.py`` 是一个 12,084 行的单一文件,``AIAgent`` 类
承担了几乎所有核心职责。这是 Hermes 最大的技术债务。
**为什么会这样?** 在早期开发中,Agent 的核心逻辑相对紧凑。
随着功能增加(多提供商支持、流式调用、上下文压缩、错误恢复、
预算控制、插件集成、记忆管理……),逻辑自然地被添加到
``AIAgent`` 类中。每次新增功能似乎都不值得单独提取一个模块,
但累积起来就形成了庞大的上帝类。
**后果** :
- 难以测试:单元测试需要模拟大量的依赖和状态。
- 难以理解:新开发者需要数天时间才能建立对 ``AIAgent`` 的整体认知。
- 合并冲突:多个开发者同时修改 ``run_agent.py`` 时频繁冲突。
**部分改善** :Hermes 团队已经将一些功能提取到 ``agent/`` 包中
(如 ``error_classifier.py`` 、``retry_utils.py`` 、``prompt_builder.py`` 、
``context_compressor.py``),这是正确的方向。
分散的提供商特定 Hack
~~~~~~~~~~~~~~~~~~~~~~~
Hermes 对不同提供商的支持中有大量 "if Ollama..." 或
"if provider == 'openrouter'..." 的分支逻辑分散在各处。
这些 Hack 的存在是因为不同提供商的 API 行为差异很大——
例如 Ollama 的上下文长度需要通过 ``/api/show`` 端点查询,
而其他提供商通过模型元数据获取。
理想情况下,提供商差异应该被完全封装在适配器层。
但在实践中,某些差异(如错误消息格式、连接管理、特殊参数)
渗透到了核心逻辑中,因为它们影响的是全局流程而非单一调用点。
父子 Agent 间的预算共享
~~~~~~~~~~~~~~~~~~~~~~~~~
Hermes 的子代理(delegate_task)获得独立的迭代预算(默认 50 次),
但共享父代理的凭证池和 API 速率限制。这意味着一个子代理的大量调用
可能耗尽凭证池,影响父代理和其他并发的子代理。
这种设计在简单场景下工作良好(大部分子代理只执行少量调用),
但在复杂的委派场景(如 Mixture of Agents 工具同时启动多个子代理)
可能造成意外的限流。
缓存失效复杂性
~~~~~~~~~~~~~~~~
系统提示词的缓存依赖"每次构建完全一致"的假设。
但提示词的内容可能因为内存状态变更、技能列表更新、
上下文文件变化等原因而改变。Hermes 通过在内存变更后
标记 ``_cached_system_prompt = None`` 来强制重建,
但这种"手动失效"容易遗漏。
特别是当多个子系统(内存管理器、技能管理器、插件系统)
都能触发提示词变更时,确保每个触发点都正确失效缓存
需要持续 vigilance。
模式与反模式总结
------------------
.. list-table::
:header-rows: 1
:widths: 40 30 30
* - 模式
- 值得学习?
- 关键要点
* - 自注册 + AST 预检查
- 是
- 零配置扩展,快速发现
* - 11 类错误分类
- 是
- 精确分类优于泛型重试
* - 持久化事件循环
- 是
- 避免 asyncio.run() 陷阱
* - 乐观并发控制
- 是
- 比全局锁更轻量
* - 三层结果预算
- 是
- 分层控制避免单一阈值困境
* - system_and_3 缓存
- 是
- 极高的缓存命中率
* - 上帝类
- 否
- 尽早提取模块
* - 提供商 Hack 散布
- 否
- 适配器层应完全封装差异
* - 手动缓存失效
- 警惕
- 考虑版本号或内容哈希
本章提炼的模式和教训将在下一章——"构建你自己的 Agent"——中
转化为具体的实践指导。我们将基于这些教训,设计一个更干净的架构,
并提供可直接使用的代码模板。