Hermes Agent:工具与技能的加载、执行与规模化策略
分析基于 hermes-agent-main 源码(2026-05),覆盖
tools/registry.py、tools/schema_sanitizer.py、tools/tool_result_storage.py、model_tools.py、agent/agent_init.py、agent/system_prompt.py、agent/prompt_builder.py、agent/conversation_loop.py、agent/context_compressor.py、agent/agent_runtime_helpers.py等核心模块。
一、整体流程概览
下图展示了 Hermes Agent 从收到用户请求到返回最终响应的完整主流程,包括初始化、系统提示构建、主循环(LLM 调用 → 工具执行 → 压缩 → 循环)以及持久化收尾:
#mermaid-svg-2IMsnUuBm2LNKu31{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-2IMsnUuBm2LNKu31 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-2IMsnUuBm2LNKu31 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-2IMsnUuBm2LNKu31 .error-icon{fill:#552222;}#mermaid-svg-2IMsnUuBm2LNKu31 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-2IMsnUuBm2LNKu31 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-2IMsnUuBm2LNKu31 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-2IMsnUuBm2LNKu31 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-2IMsnUuBm2LNKu31 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-2IMsnUuBm2LNKu31 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-2IMsnUuBm2LNKu31 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-2IMsnUuBm2LNKu31 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-2IMsnUuBm2LNKu31 .marker.cross{stroke:#333333;}#mermaid-svg-2IMsnUuBm2LNKu31 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-2IMsnUuBm2LNKu31 p{margin:0;}#mermaid-svg-2IMsnUuBm2LNKu31 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-2IMsnUuBm2LNKu31 .cluster-label text{fill:#333;}#mermaid-svg-2IMsnUuBm2LNKu31 .cluster-label span{color:#333;}#mermaid-svg-2IMsnUuBm2LNKu31 .cluster-label span p{background-color:transparent;}#mermaid-svg-2IMsnUuBm2LNKu31 .label text,#mermaid-svg-2IMsnUuBm2LNKu31 span{fill:#333;color:#333;}#mermaid-svg-2IMsnUuBm2LNKu31 .node rect,#mermaid-svg-2IMsnUuBm2LNKu31 .node circle,#mermaid-svg-2IMsnUuBm2LNKu31 .node ellipse,#mermaid-svg-2IMsnUuBm2LNKu31 .node polygon,#mermaid-svg-2IMsnUuBm2LNKu31 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-2IMsnUuBm2LNKu31 .rough-node .label text,#mermaid-svg-2IMsnUuBm2LNKu31 .node .label text,#mermaid-svg-2IMsnUuBm2LNKu31 .image-shape .label,#mermaid-svg-2IMsnUuBm2LNKu31 .icon-shape .label{text-anchor:middle;}#mermaid-svg-2IMsnUuBm2LNKu31 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-2IMsnUuBm2LNKu31 .rough-node .label,#mermaid-svg-2IMsnUuBm2LNKu31 .node .label,#mermaid-svg-2IMsnUuBm2LNKu31 .image-shape .label,#mermaid-svg-2IMsnUuBm2LNKu31 .icon-shape .label{text-align:center;}#mermaid-svg-2IMsnUuBm2LNKu31 .node.clickable{cursor:pointer;}#mermaid-svg-2IMsnUuBm2LNKu31 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-2IMsnUuBm2LNKu31 .arrowheadPath{fill:#333333;}#mermaid-svg-2IMsnUuBm2LNKu31 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-2IMsnUuBm2LNKu31 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-2IMsnUuBm2LNKu31 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-2IMsnUuBm2LNKu31 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-2IMsnUuBm2LNKu31 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-2IMsnUuBm2LNKu31 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-2IMsnUuBm2LNKu31 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-2IMsnUuBm2LNKu31 .cluster text{fill:#333;}#mermaid-svg-2IMsnUuBm2LNKu31 .cluster span{color:#333;}#mermaid-svg-2IMsnUuBm2LNKu31 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-2IMsnUuBm2LNKu31 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-2IMsnUuBm2LNKu31 rect.text{fill:none;stroke-width:0;}#mermaid-svg-2IMsnUuBm2LNKu31 .icon-shape,#mermaid-svg-2IMsnUuBm2LNKu31 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-2IMsnUuBm2LNKu31 .icon-shape p,#mermaid-svg-2IMsnUuBm2LNKu31 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-2IMsnUuBm2LNKu31 .icon-shape .label rect,#mermaid-svg-2IMsnUuBm2LNKu31 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-2IMsnUuBm2LNKu31 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-2IMsnUuBm2LNKu31 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-2IMsnUuBm2LNKu31 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 对话阶段:主循环(最多 max_iterations 次)
初始化阶段(每个会话执行一次)
是
否
是
否
是
否
修复失败
是
修复成功
否,无tool_calls
进程启动
import model_tools
模块顶层自动执行
discover_builtin_tools()
AST扫描 tools/*.py → importlib导入 → register()
discover_plugins()
扫描 ~/.hermes/plugins/ → register()
AIAgent.init()
get_tool_definitions(enabled_toolsets, disabled_toolsets)
memoize
cache 命中?
返回浅拷贝
_compute_tool_definitions()
resolve_toolset → check_fn过滤
动态Schema重建 → sanitize
agent.tools = ...
agent.valid_tool_names = {name,...}
build_system_prompt_parts()
stable tier
SOUL.md / 工具guidance / 技能索引 / 平台hints
context tier
AGENTS.md / .hermes.md
(injection扫描 + 20K截断)
volatile tier
记忆快照 + 日期/Session/Model
agent._cached_system_prompt(会话内不变)
run_conversation(user_message)
Preflight压缩
estimate_tokens ≥ threshold?
compress()
_prune_old_tool_results + LLM摘要
sanitize_api_messages()
清理孤儿 tool_call/result 对
LLM API 调用
携带 agent.tools + system_prompt
有 tool_calls?
名称验证
name in valid_tool_names?
repair_tool_call()
5种修复策略 + difflib模糊匹配(0.7)
返回错误+可用工具列表
最多重试3次
coerce_tool_args()
类型强制转换
handle_function_call()
pre_tool_call 插件钩子
可block执行
registry.dispatch()
O(1)字典查找 → handler()执行
maybe_persist_tool_result()
超100K chars → 落盘返回预览
post_tool_call / transform_tool_result 钩子
enforce_turn_budget()
本轮结果总量 > 200K → 溢出最大的
返回最终回复
_persist_session()
SQLite 持久化对话历史
结束
整体分为两大阶段:
- 初始化阶段 (
AIAgent.__init__):工具发现、注册、过滤、Schema 构建,以及系统提示(含技能索引)的组装。每个会话只执行一次。 - 对话阶段 (
run_conversation主循环):每轮接收用户消息,调用 LLM,解析并执行工具,处理结果,循环直到 LLM 不再需要工具。
二、工具的加载与执行原理
2.1 工具自注册机制(tools/registry.py)
Hermes 不维护工具列表文件。每个工具文件在模块顶层调用 registry.register(),进程启动时自动完成注册:
python
# tools/terminal_tool.py(示例结构)
registry.register(
name="terminal",
toolset="terminal",
schema={
"name": "terminal",
"description": "Run shell commands...",
"parameters": { ... } # JSON Schema
},
handler=lambda args, **kw: run_terminal(args, task_id=kw.get("task_id")),
check_fn=check_terminal_requirements, # 可选:运行时可用性检测
requires_env=["DOCKER_HOST"], # 可选:需要哪些环境变量
emoji="💻",
dynamic_schema_overrides=None, # 可选:运行时动态覆盖 schema 描述
)
ToolEntry dataclass(__slots__ 优化内存)保存所有元数据:
ToolEntry
├── name 工具唯一名称
├── toolset 所属工具集
├── schema OpenAI function-calling 格式的 JSON Schema
├── handler 真实 Python 函数引用(O(1) 调用,无字符串查找)
├── check_fn 可用性探测函数(TTL 30s 缓存)
├── requires_env 需要的环境变量列表(仅供 UI 展示缺失提示)
├── is_async 是否是异步 handler
├── emoji CLI 显示用的 emoji
├── max_result_size_chars 该工具结果的 inline 上限(字节数)
└── dynamic_schema_overrides 零参数 callable,返回运行时 schema 覆盖字段
_generation 计数器:每次 register() / deregister() 都自增,用于外层 memoization 的失效检测。
防重复注册规则:
- 同名同 toolset 注册:允许覆盖(MCP server 刷新常见场景)
- 同名跨 toolset 注册:默认 拒绝 ,打印 ERROR 日志;需传
override=True才允许(插件替换内建工具时的明确 opt-in) - MCP-to-MCP 跨名注册:允许(两个 MCP server 工具名冲突时后注册的覆盖)
2.2 工具自动发现(model_tools.py → discover_builtin_tools())
model_tools.py 在模块顶层直接调用 discover_builtin_tools(),这是整个工具体系的启动入口:
python
# model_tools.py(模块顶层,import 时自动执行)
discover_builtin_tools() # 扫描 tools/*.py,触发所有 register() 调用
discover_plugins() # 扫描 ~/.hermes/plugins/,触发插件工具注册
discover_builtin_tools() 的实现(tools/registry.py:62):
python
def discover_builtin_tools(tools_dir=None):
# 1. glob tools/*.py,排除 __init__.py, registry.py, mcp_tool.py
# 2. 对每个 .py 文件做 AST 解析,检测 module.body 顶层是否有
# registry.register() 调用(不深入函数体,避免误导入辅助模块)
# 3. importlib.import_module("tools.<stem>")
# → 模块顶层代码执行 → register() 被调用 → ToolEntry 写入 registry._tools
AST 扫描的意义:只 import 真正注册工具的文件,不触碰纯辅助模块(budget_config.py、schema_sanitizer.py 等),避免副作用。
2.3 工具 Schema 构建(model_tools.get_tool_definitions())
AIAgent.__init__(agent/agent_init.py:910)调用 get_tool_definitions() 将注册表转换为 LLM 可用的工具列表,存入 agent.tools:
python
# agent/agent_init.py:910
agent.tools = _ra().get_tool_definitions(
enabled_toolsets=enabled_toolsets,
disabled_toolsets=disabled_toolsets,
quiet_mode=agent.quiet_mode,
)
agent.valid_tool_names = {tool["function"]["name"] for tool in agent.tools}
get_tool_definitions() 内部完整流程(model_tools.py:270):
① memoization 快速返回(quiet_mode 下,cache_key 命中则直接返回浅拷贝)
② _compute_tool_definitions():
a. 解析 enabled_toolsets / disabled_toolsets
├── validate_toolset():检查 toolset 名称合法性
├── resolve_toolset():展开 toolset 名称为具体工具名集合
├── HERMES_KANBAN_TASK 环境变量:dispatcher 派生 worker 时自动补充 kanban toolset
└── set 运算:enabled_union - disabled = tools_to_include
b. registry.get_definitions(tools_to_include)
├── 按 tool_names 遍历注册表快照
├── 每个工具调用 _check_fn_cached(entry.check_fn)(TTL 30s + per-call 内层缓存)
├── check_fn 返回 False → 跳过,LLM 不可见
├── 应用 dynamic_schema_overrides(如 delegate_task 的并发数/深度描述)
└── 包装为 {"type": "function", "function": schema_with_name}
c. 动态 Schema 重建:
├── execute_code:只列出当前可用的 sandbox 工具子集(防止 LLM 被告知不可用工具)
└── discord / discord_admin:根据 bot 权限和用户 action 白名单重建
d. 跨工具引用清理:
browser_navigate 描述中的 "prefer web_search" 提示
→ 当 web_search 不可用时删除(防止 LLM 幻觉调用不存在的工具)
e. sanitize_tool_schemas():修复不兼容的 JSON Schema 格式
③ 缓存结果并返回浅拷贝(共享 dict 引用,多 Agent 不会相互污染)
最终 agent.tools 是一个标准 OpenAI format 列表:
json
[
{
"type": "function",
"function": {
"name": "terminal",
"description": "Run shell commands...",
"parameters": { "type": "object", "properties": { ... } }
}
}
]
2.4 工具执行完整调用链
LLM 返回工具调用请求后,执行链如下:
LLM 返回 assistant_message.tool_calls
│
▼
① 名称验证与自动修复(conversation_loop.py:3357)
├─ name in agent.valid_tool_names?
│ ├─ 是 → 继续
│ └─ 否 → repair_tool_call(name)(agent_runtime_helpers.py:1706)
│ ├─ 小写直接匹配(lowercase)
│ ├─ 连字符/空格 → 下划线(_norm)
│ ├─ CamelCase → snake_case(_camel_snake)
│ ├─ 剥离 _tool/-tool/tool 后缀,最多两轮
│ │ TodoTool_tool → TodoTool → todo_tool
│ └─ difflib.get_close_matches(cutoff=0.7) 模糊匹配兜底
│ 修复成功 → 打印 "🔧 Auto-repaired: 'X' -> 'Y'" 继续
│ 修复失败 → 向 LLM 返回错误消息 + 可用工具列表,最多重试 3 次
│
▼
② JSON 参数解析与类型强制(model_tools.coerce_tool_args())
├─ "42" → 42(integer schema)
├─ "true" → True(boolean schema)
├─ "https://a.com" → ["https://a.com"](array schema 单值自动包装)
├─ '["a","b"]' 字符串 → ["a","b"](JSON 字符串解析为 array)
└─ "null" → None(nullable schema)
│
▼
③ handle_function_call()(model_tools.py:760)
├─ pre_tool_call 插件钩子(可返回 block 指令阻止执行)
├─ ACP 编辑审批(write_file / patch 在 VS Code/Zed 中需用户确认)
├─ file_tools 读取连续计数器重置(非读操作时重置计数,防 read-loop 检测误判)
├─ registry.dispatch(name, args, task_id=...)
│ ├─ O(1) 字典查找:entry = _tools[name]
│ ├─ async handler → _run_async()(主线程/worker线程/网关 event loop 三路桥接)
│ ├─ entry.handler(args, **kwargs) 执行实际函数
│ └─ 异常捕获 → _sanitize_tool_error()
│ 剥离 XML 角色标签(</tool_call> 等)、代码围栏、CDATA,截断至 2000 字
├─ maybe_persist_tool_result()(超大结果落盘,见第五章)
├─ post_tool_call 插件钩子(可观察结果,接收 duration_ms)
└─ transform_tool_result 插件钩子(可替换结果字符串,first-wins)
三、技能的加载与执行原理
3.1 技能目录结构
技能存放于 ~/.hermes/skills/<category>/<skill_name>/SKILL.md:
~/.hermes/skills/
qa/
dogfood/
SKILL.md # 完整技能文档(通常几百行)
DESCRIPTION.md # category 一行描述,注入索引时作为 category 标题注释
devops/
kubernetes/
SKILL.md
terraform/
SKILL.md
...
每个 SKILL.md 包含 YAML frontmatter(name、description、version、platforms、metadata.hermes.*)和正文(目标、前提、操作步骤、注意事项、工具参考等)。
3.2 技能索引注入 System Prompt 的前提条件
build_system_prompt_parts()(agent/system_prompt.py:169)在组装 stable tier 时,只有当 valid_tool_names 中包含 skill_view 或 skill_manage 时才注入技能索引。没有这两个工具时,Agent 不具备读取技能的能力,注入索引毫无意义:
python
# agent/system_prompt.py:169
has_skills_tools = any(
name in agent.valid_tool_names
for name in ['skills_list', 'skill_view', 'skill_manage']
)
if has_skills_tools:
avail_toolsets = {
toolset for toolset in (
get_toolset_for_tool(t) for t in agent.valid_tool_names
) if toolset
}
skills_prompt = build_skills_system_prompt(
available_tools=agent.valid_tool_names,
available_toolsets=avail_toolsets,
)
3.3 技能索引的构建(build_skills_system_prompt(),agent/prompt_builder.py:983)
构建完成的索引格式:
## Skills (mandatory)
Before replying, scan the skills below. If a skill matches or is even
partially relevant to your task, you MUST load it with skill_view(name)
and follow its instructions. Err on the side of loading...
<available_skills>
qa: Quality assurance workflows
- dogfood: Exploratory QA of web apps: find bugs, evidence, reports.
devops:
- kubernetes: Deploy and manage Kubernetes clusters.
- terraform: Provision infrastructure as code.
</available_skills>
Only proceed without loading a skill if genuinely none are relevant.
每个技能只有 name: description(≤60字) 一行。100 个技能 ≈ 2000 token,500 个技能 ≈ 10000 token。
构建流程:
build_skills_system_prompt(available_tools, available_toolsets)
│
├─ L1 内存 LRU 命中?(cache_key 含 skills_dir / toolsets / platform / disabled)
│ └─ 命中 → 直接返回,move_to_end(LRU)
│
├─ L2 磁盘快照命中?(_load_skills_snapshot:mtime_ns + size 逐文件对比)
│ ├─ 命中 → 从 snapshot["skills"] 读取 pre-parsed 元数据
│ │ → 过滤 + 组装索引字符串
│ └─ 未命中 → L3 冷路径
│
├─ L3 冷路径(全量扫描)
│ ├─ iter_skill_index_files(skills_dir, "SKILL.md") 遍历所有技能文件
│ ├─ 每个文件:_parse_skill_file() → AST 解析 frontmatter + 提取 description
│ ├─ iter_skill_index_files(skills_dir, "DESCRIPTION.md") 读取 category 描述
│ └─ _write_skills_snapshot() 将结果写入磁盘(供下次冷启动跳过扫描)
│
├─ 扫描外部技能目录(skills.external_dirs 配置,无快照缓存)
│ └─ 本地技能名优先(seen_skill_names 去重,外部同名技能跳过)
│
├─ 对每个技能条目执行 _skill_should_show() 条件过滤
│
└─ 按 category 分组 → sorted → 组装索引字符串 → 写入 L1 缓存
3.4 技能条件过滤(_skill_should_show(),agent/prompt_builder.py:952)
以下情况的技能完全不出现在索引中(任一条件满足即隐藏):
python
# requires_tools:技能需要某工具,但该工具未在当前会话加载
for t in conditions.get("requires_tools", []):
if t not in available_tools: return False
# requires_toolsets:技能需要某工具集,但未启用
for ts in conditions.get("requires_toolsets", []):
if ts not in available_toolsets: return False
# fallback_for_tools:该技能是某工具的降级替代,但那个工具已可用 → 不需要降级
for t in conditions.get("fallback_for_tools", []):
if t in available_tools: return False
# fallback_for_toolsets:同上,针对工具集粒度
# platforms:当前 OS 不在技能支持的平台列表中(frontmatter platforms 字段)
3.5 技能按需全文加载
LLM 扫描索引后,若判断某技能相关,主动调用 skill_view(name="dogfood"),此时才读取完整 SKILL.md 并注入当前 turn 的 context:
System Prompt stable tier(永驻,每轮不变):
" qa:\n - dogfood: Exploratory QA of web apps..."
↓
LLM 判断任务与 QA 测试相关
↓
tool_call: skill_view(name="dogfood")
↓
读取 ~/.hermes/skills/qa/dogfood/SKILL.md(完整内容,可达几千行)
作为 tool result 注入当前 turn 的 messages
↓
LLM 根据完整 SKILL.md 中的 Phase 1-5 流程规划并执行
绝大多数技能的完整内容永远不进入 context window,只有被 LLM 主动调用时才加载。
四、系统提示的三层结构
build_system_prompt_parts()(agent/system_prompt.py:60)将系统提示分为三个 tier,拼接顺序设计为最大化 Prefix Cache 命中:
┌─────────────────────────────────────────────────────────────┐
│ stable tier(会话内不变,最大化 Prefix Cache 命中) │
│ │
│ ├─ SOUL.md(用户自定义身份)或 DEFAULT_AGENT_IDENTITY │
│ ├─ HERMES_AGENT_HELP_GUIDANCE(关于 Hermes 本身的帮助指引)│
│ ├─ 工具感知行为指导: │
│ │ memory/session_search/skill_manage 各有专属 guidance │
│ │ kanban worker/orchestrator 生命周期指导 │
│ │ computer_use 专项指导(macOS) │
│ ├─ TOOL_USE_ENFORCEMENT_GUIDANCE(按模型名称决定是否注入) │
│ │ Gemini/Gemma → GOOGLE_MODEL_OPERATIONAL_GUIDANCE │
│ │ GPT/Codex/Grok → OPENAI_MODEL_EXECUTION_GUIDANCE │
│ ├─ 技能索引(build_skills_system_prompt()) │
│ ├─ 环境提示(WSL/Termux 路径提示、active profile 名称) │
│ └─ 平台提示(telegram/discord/cli 各有不同行为指导) │
│ │
├─────────────────────────────────────────────────────────────┤
│ context tier(依赖 cwd,跨会话可变) │
│ │
│ ├─ caller 传入的 system_message(可选) │
│ └─ build_context_files_prompt(): │
│ ├─ SOUL.md(已在 stable 加载则跳过,避免重复) │
│ ├─ AGENTS.md / agents.md(cwd 顶层,不递归) │
│ ├─ .hermes.md / HERMES.md(向 git root 递归查找) │
│ └─ .cursorrules / .cursor/rules/*.mdc │
│ 所有文件先过 prompt injection 扫描,再硬截断 20K chars │
│ │
├─────────────────────────────────────────────────────────────┤
│ volatile tier(每次会话可变,不参与 prefix cache) │
│ │
│ ├─ 内建记忆快照(memory store format_for_system_prompt) │
│ ├─ USER.md 用户画像 │
│ ├─ 外部记忆 provider block(honcho/mem0 等) │
│ └─ 日期(精确到天,不含分钟,避免每分钟失效缓存) │
│ + Session ID + Model + Provider 行 │
└─────────────────────────────────────────────────────────────┘
缓存设计约束 :系统提示在 __init__ 末尾一次性构建,缓存到 agent._cached_system_prompt,整个会话期间不重建(仅在上下文压缩后重建)。这是保证 Anthropic / OpenRouter prefix cache 命中的核心约束------系统提示字节不变,云端 KV cache 才能复用。
五、规模化带来的核心矛盾与对应策略
5.1 核心问题
| 问题 | 具体表现 |
|---|---|
| Schema Token 膨胀 | 每个工具 Schema 约 200--800 token;100 个工具占 2--8 万 token;若全部注入则小 context 模型直接溢出 |
| 技能内容 Token 膨胀 | 每个 SKILL.md 约 500--3000 token;几百个技能全文注入不可行 |
| 工具结果 Token 累积 | terminal 输出、文件内容随对话轮次累积,逐渐占满剩余 context window |
| 模型幻觉与精度下降 | LLM 面对 100+ 工具时幻觉不存在的名称(TodoTool_tool、BrowserClick_tool),混淆参数类型 |
5.2 策略一:Toolset 白名单过滤
所有工具分组为 toolset,实例化时只传入当前场景需要的工具集:
python
agent = AIAgent(
enabled_toolsets=["messaging", "search", "file"],
disabled_toolsets=["code_execution", "discord"],
)
disabled_toolsets 是最终减法 ,无论 enabled_toolsets 如何组合,被 disabled 的工具集严格剔除(set.difference_update,model_tools.py:355)。
已定义 20+ 个 toolset:browser、clarify、code_execution、cronjob、debugging、delegation、discord、file、memory、messaging、search、terminal、todo、tts、vision、web 等。
5.3 策略二:check_fn 运行时可用性过滤
注册时附带的 check_fn 在 get_definitions() 中自动调用,返回 False 的工具不出现在 LLM 的工具列表中:
- 未安装 Playwright → browser 系 10 个工具全部不可见
- 无
DOCKER_HOST→ Docker-backed terminal 不可见 - Discord bot token 未配置 → discord 工具不可见
TTL 缓存 :_check_fn_cache dict(tools/registry.py:120)+ threading.Lock,30 秒有效。同一次 get_definitions() 调用内还有 per-call 内层缓存 check_results dict,同一个 check_fn 只运行一次。
通过 invalidate_check_fn_cache() 可立即清空缓存(hermes tools enable 命令会调用此函数)。
5.4 策略三:get_tool_definitions() 整体 memoization
model_tools.py 维护模块级 _tool_defs_cache dict(quiet_mode 下启用):
python
cache_key = (
frozenset(enabled_toolsets), # toolset 参数
frozenset(disabled_toolsets),
registry._generation, # 任何 register/deregister 后自增 → 自动失效
(config_mtime_ns, config_size), # config.yaml 变更后失效(dynamic schema 依赖配置)
bool(HERMES_KANBAN_TASK), # kanban worker 有额外工具
)
命中时返回浅拷贝列表(list(cached),共享 dict 引用),同时更新 _last_resolved_tool_names。
为什么是浅拷贝而非直接返回原引用 :AIAgent.__init__ 可能向 agent.tools 追加额外工具(memory、LCM),若多个 Agent 共享同一 list 引用,工具名称会重复累积,导致 DeepSeek/Moonshot 等强制唯一工具名的 Provider 报 HTTP 400(issue #17335)。
5.5 策略四:Schema 体积压缩与修复
正常路径 --- sanitize_tool_schemas()(tools/schema_sanitizer.py:40)
每次 get_tool_definitions() 末尾执行,对深拷贝后的 Schema 修复:
| 问题 | 修复方式 |
|---|---|
{"type": "object"} 无 properties |
补全 "properties": {} |
"type": ["string", "null"] 数组形式 |
折叠为单字符串 + nullable: true |
顶层 anyOf/oneOf/allOf/enum/not |
剥离(Codex 后端严格拒绝此格式) |
MCP 服务器输出的 additionalProperties: "object" 字符串值 |
替换为合法 dict |
Pydantic optional 字段的 anyOf: [{type:X},{type:null}] |
折叠为 X + nullable: true |
故障恢复路径 --- strip_pattern_and_format()(tools/schema_sanitizer.py:308)
仅在 llama.cpp 因 \d/\w/\s 等正则转义类报 HTTP 400 时按需触发(agent/conversation_loop.py:2358):
python
if classified.reason == FailoverReason.llama_cpp_grammar_pattern:
_, _stripped = strip_pattern_and_format(agent.tools)
continue # 删除 pattern/format 字段后自动重试
云端 Provider 保留 pattern/format 作为语义提示;本地 llama.cpp 报错时才删,最小化修改。
5.6 策略五:技能两级加载 + 三层缓存 + 条件过滤
两级加载 :System Prompt 只注入每条 ≤60 字的紧凑索引,完整 SKILL.md 由 LLM 主动调用 skill_view(name) 按需拉取。
三层缓存 (agent/prompt_builder.py:828):
| 层级 | 实现 | cache key | 失效条件 |
|---|---|---|---|
| L1 内存 LRU | _SKILLS_PROMPT_CACHE OrderedDict(最多 8 条) |
(skills_dir, external_dirs, available_tools, available_toolsets, platform_hint, disabled) |
key 不命中时淘汰最旧 |
| L2 磁盘快照 | ~/.hermes/.skills_prompt_snapshot.json |
mtime_ns + size 逐文件校验 | 任意 SKILL.md 或 DESCRIPTION.md 变更 |
| L3 冷路径 | 全量 iter_skill_index_files() 扫描 |
--- | 冷路径结束后写入 L2 快照 |
快照格式:{"version": 1, "manifest": {path: [mtime_ns, size]}, "skills": [...], "category_descriptions": {...}}。版本号 _SKILLS_SNAPSHOT_VERSION = 1,格式变更时快照全量失效。
条件过滤 _skill_should_show() (agent/prompt_builder.py:952)------任一条件满足即隐藏:
python
requires_tools → 技能依赖某工具,但该工具未加载 → 隐藏
requires_toolsets → 技能依赖某工具集,但未启用 → 隐藏
fallback_for_tools → 技能是某工具的降级替代,该工具已可用 → 隐藏
fallback_for_toolsets → 同上,工具集粒度
platforms → 当前 OS 不在支持列表 → 隐藏
外部技能目录 :skills.external_dirs 配置支持额外目录,本地技能名冲突时优先(seen_skill_names 去重)。外部目录无快照缓存(只读、通常较小)。
5.7 策略六:工具结果三层大小控制(tools/tool_result_storage.py)
当工具返回超大结果时,三层防御防止 context 爆炸:
第一层:工具自身 pre-truncation
各工具在返回前自行裁剪(如 search_files 限制行数),最轻量,工具作者控制。
第二层:maybe_persist_tool_result()(per-result,tool_result_storage.py:122)
python
# 阈值优先级:PINNED > tool_overrides > registry.max_result_size > default(100K chars)
effective_threshold = config.resolve_threshold(tool_name)
if len(content) > effective_threshold:
# 通过 stdin pipe 写入 sandbox /tmp/hermes-results/{tool_use_id}.txt
# (避免 MAX_ARG_STRLEN 128KB argv 限制,支持本地/Docker/SSH/Modal 后端)
_write_to_sandbox(content, remote_path, env)
# 返回 <persisted-output> 替换块
return _build_persisted_message(preview=1500chars, file_path=remote_path)
LLM 收到的替换块:
<persisted-output>
This tool result was too large (2,847,392 characters, 2.7 MB).
Full output saved to: /tmp/hermes-results/abc123.txt
Use the read_file tool with offset and limit to access specific sections.
Preview (first 1500 chars):
...
</persisted-output>
read_file 的阈值被 pin 为 ∞ (PINNED_THRESHOLDS),防止读取结果再次触发持久化造成无限循环。
第三层:enforce_turn_budget()(per-turn 聚合,tool_result_storage.py:186)
同一轮次所有工具结果之和超过 DEFAULT_TURN_BUDGET_CHARS(200K chars)时,将最大的未持久化结果依次溢出到 sandbox,直到总量降至预算以下:
python
if total_size > config.turn_budget: # 默认 200K chars
candidates.sort(key=size, reverse=True)
for idx, size in candidates:
# threshold=0 强制溢出,无论单个结果多小
tool_messages[idx]["content"] = maybe_persist_tool_result(..., threshold=0)
if total_size <= config.turn_budget: break
5.8 策略七:对话历史超限时分级压缩
ContextCompressor(agent/context_compressor.py)在每次 LLM API 调用前检查 token 数,触发阈值为 context_length × threshold_percent(默认 50%)。
防抖机制 :_ineffective_compression_count >= 2(连续 2 次压缩效果 < 10%)时停止,避免无效循环,提示用户 /new 或 /compress <topic>。
两阶段压缩:
阶段一:廉价预处理,无 LLM 调用(_prune_old_tool_results())
保护区:最新 protect_last_n(默认 20)轮 + tail_token_budget token 预算
├─ 保护区外旧工具结果 → 1 行摘要
│ "terminal: ran `npm test` → exit 0, 47 lines"
│ "read_file: read config.py from line 1 (3,400 chars)"
├─ 同一文件多次读取 → 去重,保留最新完整内容
└─ assistant 消息中的大型 tool_call arguments → 截断
阶段二:LLM 摘要压缩(compress())
├─ protect_first_n(默认 3)轮头部不动(保留背景上下文)
├─ protect_last_n(默认 20)轮尾部不动(保留近期完整记忆)
├─ 中间窗口 → 辅助 LLM 生成摘要
│ max_summary_tokens = min(context_length × 5%, _SUMMARY_TOKENS_CEILING)
│ summary_target_ratio = 20%(目标保留当前体积的 20%)
├─ 新摘要与 _previous_summary 迭代合并(多次压缩不丢失历史)
└─ 失败时(abort_on_summary_failure=False 默认行为):
返回原始消息,设置 _last_compress_aborted=True,让调用方冻结会话
压缩后调用 sanitize_api_messages() 清理孤儿 tool_call/tool_result 对(压缩边界可能切断配对关系),保证 API 不收到 mismatched IDs。
5.9 策略八:上下文文件大小限制 + Prompt Injection 防御
AGENTS.md、SOUL.md、.cursorrules、.hermes.md 等注入前经过两道关卡:
Prompt Injection 扫描 (agent/prompt_builder.py:44):
python
def _scan_context_content(content: str, filename: str) -> str:
findings = _scan_for_threats(content, scope="context")
if findings:
return f"[BLOCKED: {filename} 包含疑似 prompt injection. 内容未加载.]"
return content
检测 classic injection、promptware/C2 控制模式、角色劫持等。被污染的文件整段 BLOCK,不进入 system prompt。
硬截断 (CONTEXT_FILE_MAX_CHARS = 20_000):
python
head_chars = int(max_chars * 0.7) # 14,000 chars(保留文件头部,通常是最重要的部分)
tail_chars = int(max_chars * 0.2) # 4,000 chars(保留文件尾部,通常是最新配置)
marker = "[...truncated: kept head+tail. Use file tools to read full file.]"
头 70% + 尾 20%,中间插入截断标记。即使 AGENTS.md 写得很长也不会无限膨胀 system prompt。
六、现存的真实限制
| 场景 | 现状 | 建议 |
|---|---|---|
| 工具数 > 200 | Schema token 总量可能超过小 context 模型上限,无自动裁剪工具数 | 按场景手动配置 toolset,只启用当前任务相关工具集 |
| 技能数 > 1000 | 索引 token 增大,LLM 凭名称/描述选技能精度下降;无语义向量检索 | 合理分 category,保持描述精准;必要时拆分为多 Agent |
| 工具单次结果 > 100K chars | 落盘后 LLM 需额外调一次 read_file 才能看到完整内容 |
工具层自行 pre-truncate;或提示 LLM 使用 offset/limit 分段读取 |
| 长会话压缩反复失效 | 连续 2 次压缩效率 < 10% 时停止 | 任务边界清晰时主动 /new 开启新会话 |
| check_fn 状态变更延迟 | TTL 30s,hermes tools enable 后最多 30s 才生效 |
手动调用 invalidate_check_fn_cache() 立即失效 |
七、完整初始化到执行流程(文字版)
进程启动
│
├─ import model_tools(模块顶层自动执行)
│ ├─ discover_builtin_tools():AST 扫描 tools/*.py → importlib 动态导入 → register()
│ └─ discover_plugins():扫描 ~/.hermes/plugins/ → register()
│
▼
AIAgent.__init__(enabled_toolsets, disabled_toolsets, model, ...)
│
├─ 工具加载
│ └─ get_tool_definitions(enabled_toolsets, disabled_toolsets)
│ ├─ memoize 命中 → 直接返回浅拷贝
│ └─ _compute_tool_definitions()
│ ├─ resolve_toolset() → tools_to_include(set 运算)
│ ├─ registry.get_definitions()
│ │ └─ _check_fn_cached() TTL 30s → 过滤不可用工具
│ ├─ 动态 Schema 重建(execute_code / discord)
│ ├─ 跨工具引用清理
│ └─ sanitize_tool_schemas()(深拷贝 + 修复 JSON Schema)
├─ agent.tools = [...]
├─ agent.valid_tool_names = {name, ...}
│
├─ 系统提示构建
│ └─ build_system_prompt_parts()
│ ├─ stable tier:
│ │ ├─ SOUL.md / DEFAULT_AGENT_IDENTITY
│ │ ├─ 工具感知 guidance(memory/skill_manage/kanban)
│ │ ├─ build_skills_system_prompt()
│ │ │ ├─ L1 内存 LRU → L2 磁盘快照 → L3 冷扫描
│ │ │ └─ _skill_should_show() 过滤 → 紧凑索引
│ │ └─ 环境/平台 hints
│ ├─ context tier:AGENTS.md 等(injection 扫描 + 20K 截断)
│ └─ volatile tier:记忆快照 + 日期/Session/Model 行
├─ agent._cached_system_prompt = 三段拼接(会话内不变)
│
▼
run_conversation(user_message)
│
├─ 系统提示恢复(从 SessionDB 加载或重建)
├─ Preflight 压缩(estimate_tokens ≥ threshold → compress())
├─ pre_llm_call 插件钩子(可追加 user context)
│
└─ 主循环(最多 max_iterations 次)
│
├─ ContextCompressor.should_compress()
│ ├─ < threshold → 跳过
│ └─ ≥ threshold → _prune_old_tool_results()(廉价)→ compress()(LLM 摘要)
│
├─ sanitize_api_messages()(清理孤儿 tool_call/result 对)
│
├─ LLM API 调用(携带 agent.tools + active_system_prompt)
│
├─ 有 tool_calls → 对每个 tool call:
│ ├─ 名称验证 → repair_tool_call()(5 种修复策略 + difflib 模糊,最多重试 3 次)
│ ├─ JSON 解析 + coerce_tool_args()(类型强制)
│ ├─ handle_function_call()
│ │ ├─ pre_tool_call 钩子(可 block)
│ │ ├─ ACP 编辑审批(write_file/patch)
│ │ ├─ registry.dispatch():O(1) 字典查找 → handler() 执行
│ │ ├─ maybe_persist_tool_result()(超 100K chars → 落盘返回预览)
│ │ └─ post_tool_call / transform_tool_result 钩子
│ └─ enforce_turn_budget()(本轮所有结果 > 200K → 溢出最大的)
│
├─ 无 tool_calls → 返回最终回复
│
└─ 收尾:_persist_session() → SQLite 持久化对话历史
文档生成时间:2026-05-31 | 源码版本:hermes-agent-main