9. 模型路由:多 Provider 的统一接入层
Hermes Agent 作为一个多模态 AI Agent 框架,其核心设计理念之一是 Provider 无关性——用户无需关心底层模型调用究竟发往 OpenRouter、Anthropic、OpenAI Codex 还是本地 Ollama 实例。实现这一抽象的核心模块就是 agent/auxiliary_client.py ,一个超过 2,400 行的"Provider 路由器",它为所有辅助任务(上下文压缩、视觉分析、网页提取、会话搜索、记忆刷写等)提供统一的 LLM 调用接口。
本章将从设计动机、架构实现、Provider 注册表、客户端适配器、缓存机制、错误恢复等多个维度,深入剖析 Hermes 的多 Provider 统一接入层。
9.1. 为什么需要多 Provider
单一 Provider 依赖是生产级 Agent 系统的反模式。Hermes 的设计从一开始就假设:没有哪个 Provider 是永远可用的 。
9.1.1. 成本多样性
不同 Provider 的定价模型差异巨大:
OpenRouter 提供按量付费的聚合接入,同一个模型(如
google/gemini-3-flash-preview)在不同 Provider 的价格可能相差数倍Nous Portal 订阅用户享有固定额度的推理调用
本地模型(Ollama、vLLM、llama.cpp)边际成本为零
Anthropic、DeepSeek 等直连 Provider 对特定模型有更优价格
Hermes 的辅助任务(如上下文压缩、会话标题生成)对模型能力要求较低,使用廉价模型即可完成。_API_KEY_PROVIDER_AUX_MODELS 字典为每个 Provider 预设了最优的辅助模型:
_API_KEY_PROVIDER_AUX_MODELS: Dict[str, str] = {
"gemini": "gemini-3-flash-preview",
"zai": "glm-4.5-flash",
"kimi-coding": "kimi-k2-turbo-preview",
"minimax": "MiniMax-M2.7",
"anthropic": "claude-haiku-4-5-20251001",
...
}
9.1.2. 可用性保障
Provider 中断是常态而非例外:
OpenRouter 可能在高峰期限流
Nous Portal OAuth Token 可能过期
本地 Ollama 实例可能未运行
API 余额可能耗尽
Hermes 的自动检测链(auto-detection chain)确保当首选 Provider 不可用时,自动回退到下一个可用的 Provider。这种 级联回退 机制是系统可用性的基石。
9.1.3. 能力差异
不同 Provider 支持的能力不同:
文本任务几乎所有 Provider 都支持
视觉/多模态任务需要 Provider 和模型同时支持图像输入
工具调用(Tool Calling)并非所有模型都支持
长上下文窗口(>128K tokens)只有部分 Provider 提供
Hermes 通过 任务类型路由 (text vs vision)和 模型能力查询 (models.dev 注册表)来智能匹配任务与 Provider。
9.2. Provider 注册表
hermes_cli/auth.py 定义了 PROVIDER_REGISTRY——一个全局的 Provider 配置注册表。每个条目是一个 ProviderConfig 数据类:
@dataclass
class ProviderConfig:
id: str # Provider 唯一标识
name: str # 显示名称
auth_type: str # 认证类型
portal_base_url: str = "" # Portal URL(OAuth Provider)
inference_base_url: str = "" # 推理 API 基础 URL
client_id: str = "" # OAuth Client ID
scope: str = "" # OAuth Scope
api_key_env_vars: tuple = () # API Key 环境变量(按优先级)
base_url_env_var: str = "" # Base URL 覆盖环境变量
认证类型分为三类:
认证类型 |
代表 Provider |
认证方式 |
|---|---|---|
|
OpenRouter, Gemini, Z.AI, Kimi, MiniMax, DeepSeek, xAI, Anthropic 等 |
从环境变量或 .env 文件读取 API Key |
|
Nous Portal |
设备码 OAuth 流程,Token 存储在 auth.json |
|
OpenAI Codex, Qwen OAuth, Google Gemini CLI |
外部 OAuth 流程(第三方认证页面) |
注册表中包含 33 个 Provider,覆盖了从全球到中国的主流 LLM 服务:
聚合器 :OpenRouter, Nous Portal, AI Gateway (Vercel), OpenCode, Kilo Code, Hugging Face
直连国际 :Anthropic, OpenAI Codex, Google Gemini, xAI, NVIDIA NIM, DeepSeek, Arcee
直连中国 :Z.AI (智谱/GLM), Kimi (月之暗面), MiniMax, Alibaba (通义千问), Xiaomi (小米 MiMo), Alibaba Coding Plan
本地部署 :Custom (Ollama, llama.cpp, vLLM, LM Studio)
企业级 :AWS Bedrock, Azure Foundry, GitHub Copilot, Copilot ACP
9.2.1. Provider 注册表:HermesOverlay 与 models.dev
Hermes 的 Provider 体系不是插件系统的一部分——Provider 是 核心内置 的,
通过 hermes_cli/providers.py 中的 HERMES_OVERLAYS 字典和
models.dev 社区目录 双数据源 合并实现。
9.2.1.1. HermesOverlay 数据类
每个 Hermes 特有的 Provider 配置通过 HermesOverlay 数据类定义:
@dataclass
class HermesOverlay:
provider: str # Provider 唯一标识
display_name: str # 显示名称
transport: str # 传输协议类型
base_url: str = "" # API 基础 URL
env_key: str = "" # API Key 环境变量名
auth_flow: str = "" # 认证流程类型
default_model: str = "" # 默认模型
...
transport 字段决定了请求如何发送,支持三种值:
openai_chat:OpenAI Chat Completions 兼容端点(大多数 Provider)anthropic_messages:Anthropic Messages APIcodex_responses:OpenAI Codex Responses API
HERMES_OVERLAYS 字典包含 24 个 Hermes 特有的 Provider 覆盖配置,
包括 OpenRouter、Nous Portal、Copilot ACP、Google Gemini CLI、
Kimi、MiniMax、Z.AI 等。
9.2.1.2. models.dev 注册表
agent/models_dev.py 集成了 models.dev 社区数据库,覆盖 4000+ 模型
和 109+ Provider。数据解析采用 离线优先 策略:
打包的快照(离线可用)
磁盘缓存(
~/.hermes/models_dev_cache.json)网络获取(每 60 分钟后台刷新)
PROVIDER_TO_MODELS_DEV 字典建立了 Hermes Provider ID 与 models.dev ID
的映射关系。模型查找使用精确匹配 + 大小写不敏感回退的策略。
9.2.1.3. 双数据源合并
当用户请求某个 Provider 时,系统先在 HERMES_OVERLAYS 中查找
Hermes 特有配置(如自定义 base_url、认证流程),然后合并 models.dev
中的元数据(如上下文长度、能力标记)。这确保了 Hermes 特有的配置
不会被社区数据覆盖,同时又能利用社区维护的模型信息。
agent/credential_pool.py 管理凭证的生命周期——包括缓存、轮换、过期检测。
当多个 Provider 配置了相同的 API Key 环境变量时,凭证池确保它们共享同一个
凭证实例,避免重复的环境变量查找。
9.3. 辅助客户端路由
resolve_provider_client() 是 Provider 路由的核心工厂函数,接受一个 Provider 标识符和可选的模型名称,返回一个配置好的客户端实例:
def resolve_provider_client(
provider: str,
model: str = None,
async_mode: bool = False,
raw_codex: bool = False,
explicit_base_url: str = None,
explicit_api_key: str = None,
api_mode: str = None,
main_runtime: Optional[Dict[str, Any]] = None,
) -> Tuple[Optional[Any], Optional[str]]:
该函数的解析逻辑如下:
规范化 Provider 名称 :通过
_normalize_aux_provider()处理别名(如google->gemini、claude->anthropic、codex->openai-codex)分派到特定 Provider 分支 :根据规范化后的 Provider 名称,进入对应的解析分支
认证查找 :从环境变量、auth.json、凭证池中查找认证信息
客户端构建 :创建 OpenAI SDK 客户端(或适配器包装的客户端)
异步模式转换 :如果
async_mode=True,将同步客户端转换为异步客户端
Provider 名称别名系统 _PROVIDER_ALIASES 支持 20+ 个常见别名映射,降低用户的记忆负担:
_PROVIDER_ALIASES = {
"google": "gemini",
"x-ai": "xai",
"grok": "xai",
"glm": "zai",
"kimi": "kimi-coding",
"moonshot": "kimi-coding",
"claude": "anthropic",
"claude-code": "anthropic",
...
}
9.4. 文本/视觉任务的优先级链
Hermes 对文本任务和视觉任务采用不同的自动检测链。
9.4.1. 文本任务优先级链
_resolve_auto() 函数实现了文本任务的自动检测链:
# Step 1: 用户的主 Provider + 主模型
# Step 2: 聚合器/回退链
# OpenRouter -> Nous Portal -> Custom -> Codex -> API-key Providers
Step 1 是首要路径:使用用户在 config.yaml 中配置的主 Provider 和主模型。这意味着如果用户选择了 deepseek/deepseek-chat ,辅助任务也会使用 DeepSeek,保持行为一致性。
Step 2 是回退链:当主 Provider 不可用时,按以下顺序尝试:
OpenRouter — 最广泛的模型聚合器
Nous Portal — OAuth 认证的推理平台
Custom — 本地或自定义端点
Codex — OpenAI Codex OAuth(通过 Responses API)
API-key Providers — 遍历所有配置了 API Key 的 Provider
9.4.2. 视觉任务优先级链
resolve_vision_provider_client() 实现了视觉任务的自动检测链,与文本任务有关键差异:
用户的主 Provider(如果支持视觉) — 使用
_PROVIDER_VISION_MODELS映射特定 Provider 的视觉模型:_PROVIDER_VISION_MODELS: Dict[str, str] = { "xiaomi": "mimo-v2-omni", "zai": "glm-5v-turbo", }
OpenRouter — 使用
google/gemini-3-flash-previewNous Portal — 免费用户使用
xiaomi/mimo-v2-omni,付费用户使用google/gemini-3-flash-preview停止 — 视觉任务不像文本任务那样尝试所有 Provider
flowchart TD
A[自动检测: 文本任务] --> B{主 Provider 已配置?}
B -->|是| C[resolve_provider_client 主Provider, 主模型]
C --> C1{客户端创建成功?}
C1 -->|是| C2[返回主 Provider 客户端]
C1 -->|否| D[进入回退链]
B -->|否| D
subgraph SG1["Fallback 1: OpenRouter"]
D --> E[_try_openrouter]
E --> E1{OPENROUTER_API_KEY 存在?}
E1 -->|是| E2[使用 google/gemini-3-flash-preview]
E1 -->|否| F[_try_nous]
end
subgraph SG2["Fallback 2: Nous Portal"]
F --> F1{Nous 已认证?}
F1 -->|是| F2{速率限制?}
F2 -->|是| F3[跳过]
F2 -->|否| F4{免费用户?}
F4 -->|是| F5[使用 mimo-v2-pro]
F4 -->|否| F6[使用 gemini-3-flash-preview]
F1 -->|否| G[_try_custom_endpoint]
end
subgraph SG3["Fallback 3: Custom Endpoint"]
G --> G1{OPENAI_BASE_URL 或 config 有自定义端点?}
G1 -->|是| G2[使用自定义端点 + gpt-4o-mini]
G1 -->|否| H[_try_codex]
end
subgraph SG4["Fallback 4: Codex OAuth"]
H --> H1{Codex Token 有效?}
H1 -->|是| H2[使用 gpt-5.2-codex via Responses API]
H1 -->|否| I[_resolve_api_key_provider]
end
subgraph SG5["Fallback 5: API Key Provider"]
I --> I1[遍历 PROVIDER_REGISTRY]
I1 --> I2{找到有 API Key 的 Provider?}
I2 -->|是| I3[使用该 Provider 的默认辅助模型]
I2 -->|否| J[返回 None, None — 无可用 Provider]
end
classDef start fill:#dbeafe,stroke:#3b82f6,color:#1e3a8a
classDef success fill:#dcfce7,stroke:#16a34a,color:#166534
classDef warn fill:#fef9c3,stroke:#ca8a04,color:#854d0e
classDef fail fill:#fee2e2,stroke:#dc2626,color:#991b1b
classDef info fill:#f1f5f9,stroke:#64748b,color:#334155
class A start
class C2 success
class J fail
9.5. 客户端适配器模式
Hermes 的辅助客户端使用 OpenAI SDK 的 chat.completions.create() 接口作为统一抽象。然而并非所有 Provider 都原生支持这一接口。适配器模式通过 接口转换 解决这一不一致性。
9.5.1. CodexAuxiliaryClient(Responses -> ChatCompletions)
OpenAI Codex 端点(chatgpt.com/backend-api/codex)使用 Responses API 而非 Chat Completions API。CodexAuxiliaryClient 将 Chat Completions 风格的调用转换为 Responses API 调用:
class CodexAuxiliaryClient:
"""OpenAI-client-compatible wrapper that routes through Codex Responses API."""
def __init__(self, real_client: OpenAI, model: str):
self._real_client = real_client
adapter = _CodexCompletionsAdapter(real_client, model)
self.chat = _CodexChatShim(adapter)
self.api_key = real_client.api_key
self.base_url = real_client.base_url
适配的核心转换逻辑在 _CodexCompletionsAdapter.create() 中:
消息格式转换 :
text->input_text,image_url->input_image系统消息提取 :将
system角色消息提取为instructions参数流式响应收集 :使用
responses.stream()收集所有文本增量和工具调用响应格式归一化 :将 Responses API 的输出转换为
choices[0].message.content格式
9.5.2. AnthropicAuxiliaryClient
Anthropic 的 Messages API 使用与 OpenAI 不同的请求/响应格式。AnthropicAuxiliaryClient 封装了原生 Anthropic 客户端,对外暴露统一的 chat.completions.create() 接口:
class AnthropicAuxiliaryClient:
def __init__(self, real_client, model, api_key, base_url, is_oauth=False):
adapter = _AnthropicCompletionsAdapter(real_client, model, is_oauth)
self.chat = _AnthropicChatShim(adapter)
适配器内部调用 build_anthropic_kwargs() 和 normalize_anthropic_response() 进行参数构建和响应归一化,并处理温度参数的特殊限制(Opus 4.7+ 拒绝任何非默认采样参数)。
9.5.3. 异步适配器
每个同步适配器都有对应的异步版本(AsyncCodexAuxiliaryClient 、AsyncAnthropicAuxiliaryClient),通过 asyncio.to_thread() 将同步调用包装为异步调用。这保证了所有消费者——无论是同步还是异步——都能使用相同的接口:
class _AsyncCodexCompletionsAdapter:
async def create(self, **kwargs) -> Any:
import asyncio
return await asyncio.to_thread(self._sync.create, **kwargs)
9.6. 客户端缓存架构
_get_cached_client() 实现了一个线程安全的客户端缓存,避免每次 LLM 调用都重新创建客户端。
9.6.1. 缓存设计要点
缓存键 :
(provider, async_mode, base_url, api_key, api_mode, runtime_key)最大容量 :64 条(
_CLIENT_CACHE_MAX_SIZE)驱逐策略 :FIFO(当缓存超过最大容量时,驱逐最早的条目)
线程安全 :通过
_client_cache_lock(threading.Lock)保护
9.6.2. 异步客户端的事件循环验证
异步客户端(AsyncOpenAI)内部使用 httpx,绑定到创建时的事件循环。在错误的事件循环上使用异步客户端会导致死锁或 RuntimeError 。缓存通过以下机制防止跨循环问题:
缓存键不包含循环标识——循环身份在命中时检查而非在键中编码
每次异步命中验证 :检查缓存的循环是否是当前且开放的循环
就地替换 :当检测到过时循环时,强制关闭旧客户端并替换为新客户端
if async_mode:
loop_ok = (
cached_loop is not None
and cached_loop is current_loop
and not cached_loop.is_closed()
)
if loop_ok:
return cached_client, effective
# Stale — evict and fall through to create a new client.
_force_close_async_httpx(cached_client)
del _client_cache[cache_key]
这种设计将缓存大小限制为 每个唯一 Provider 配置一个条目 ,而不是 每个(配置 x 事件循环)一个条目——后者曾在长运行的 Gateway 进程中导致无限的文件描述符累积。
sequenceDiagram
participant Caller as Caller
participant Cache as _get_cached_client
participant Store as _client_cache
participant Router as resolve_provider_client
participant EvLoop as Event Loop
Caller->>Cache: get_cached_client(provider, model, async_mode)
Note over Cache: Build cache key: provider, async_mode, base_url, api_key, api_mode, runtime_key
Cache->>Store: Lookup cache entry
alt Cache hit
Store-->>Cache: client, default_model, cached_loop
alt async_mode=True
Cache->>EvLoop: Get current event loop
alt cached_loop == current_loop and not closed
Cache-->>Caller: Return cached client
else cached_loop is stale
Cache->>Store: _force_close_async_httpx(client)
Cache->>Store: Delete stale entry
Note over Cache: Continue to create new client
end
else sync_mode
Cache-->>Caller: Return cached client
end
end
Note over Cache: Cache miss - create new client
Cache->>Router: resolve_provider_client(provider, model, ...)
Router-->>Cache: client, default_model
alt client is not None
Cache->>Store: Acquire _client_cache_lock
alt Cache size less than 64
Cache->>Store: Store client, default_model, current_loop
else Cache is full
Cache->>Store: Evict oldest entry FIFO
Cache->>Store: Store new entry
end
Cache-->>Caller: Return client and model
else client is None
Cache-->>Caller: Return None, None
end
Note over Caller,EvLoop: Shutdown phase
rect rgb(255, 230, 230)
Note over Cache: shutdown_cached_clients()
Cache->>Store: Iterate all entries
Cache->>Cache: _force_close_async_httpx(client)
Cache->>Cache: client.close() sync clients
Cache->>Store: _client_cache.clear()
end
9.6.3. 启动和关闭
启动时 :
neuter_async_httpx_del()猴子补丁AsyncHttpxClientWrapper.__del__为空操作,防止垃圾回收时在死循环上调度aclose()关闭时 :
shutdown_cached_clients()关闭所有缓存客户端(同步客户端调用close(),异步客户端标记 httpx 状态为CLOSED)每轮清理 :
cleanup_stale_async_clients()在每个 Agent 轮次后清理事件循环已关闭的异步客户端
9.7. 支付错误自动回退
当用户的 Provider 余额耗尽或支付失败时,Hermes 会自动尝试其他可用的 Provider,而不是直接向用户报告错误。
9.7.1. 支付错误检测
_is_payment_error() 检测以下类型的错误:
HTTP 402 (Payment Required)
HTTP 429 或其他状态码 ,但错误消息包含支付相关关键词(
credits、insufficient funds、can only afford、billing、payment required)连接错误 (DNS 失败、连接拒绝、超时)——这类错误表明 Provider 端点完全不可达
def _is_payment_error(exc: Exception) -> bool:
status = getattr(exc, "status_code", None)
if status == 402:
return True
err_lower = str(exc).lower()
if status in (402, 429, None):
if any(kw in err_lower for kw in ("credits", "insufficient funds",
"can only afford", "billing",
"payment required")):
return True
return False
9.7.2. 回退链
_try_payment_fallback() 在支付/连接错误后尝试替代 Provider:
跳过已失败的 Provider
遍历标准自动检测链
对每个可用 Provider 构建新的请求参数并重试
关键设计决策:只有 "auto" 模式(用户未明确指定 Provider)才触发回退 。如果用户明确配置了某个 Provider 并遇到支付错误,系统会直接报错而不是静默切换——避免用户误以为仍在使用付费 Provider 而实际上切换到了免费但质量较低的 Provider。
9.8. 模型特定策略
不同模型有不同的行为契约,Hermes 通过一系列模型特定策略来适配这些差异。
9.8.1. 固定温度
Kimi 的 kimi-for-coding 端点对温度参数有严格限制:
非思考模式(
kimi-k2.5、kimi-k2-turbo-preview等):固定 0.6思考模式(
kimi-k2-thinking等):固定 1.0任何其他值都会导致 API 错误
def _fixed_temperature_for_model(model: Optional[str]) -> Optional[float]:
normalized = (model or "").strip().lower()
fixed = _FIXED_TEMPERATURE_MODELS.get(normalized)
if fixed is not None:
return fixed
bare = normalized.rsplit("/", 1)[-1]
if bare in _KIMI_THINKING_MODELS:
return 1.0
if bare in _KIMI_INSTANT_MODELS:
return 0.6
return None
9.8.2. Developer 角色处理
GPT-5/Codex 系列模型使用 developer 角色(通过 Responses API)而非 system 角色。_CodexCompletionsAdapter.create() 自动将系统消息提取为 instructions 参数。
9.8.3. Anthropic 兼容 Provider
MiniMax 和 MiniMax-CN 等 Provider 暴露 Anthropic 兼容的 Messages API 端点(/anthropic)。_to_openai_base_url() 将这些 URL 重写为 OpenAI 兼容的 /v1 端点。同时 _is_anthropic_compat_endpoint() 检测是否需要将 OpenAI 格式的图像块转换为 Anthropic 格式。
9.8.4. Opus 4.7+ 采样参数限制
Opus 4.7+ 拒绝任何非默认的 temperature 、top_p 、top_k 参数。_build_call_kwargs() 在构建请求参数时检查这一限制并静默移除采样参数。
9.9. 上下文长度解析 10 级优先级
agent/model_metadata.py 中的 get_model_context_length() 实现了一个 10 级优先级的上下文长度解析链,确保在任何环境下都能获取尽可能准确的上下文窗口大小。
优先级 |
来源 |
说明 |
|---|---|---|
0 |
显式配置覆盖 |
|
1 |
持久缓存 |
|
2 |
端点元数据 |
自定义端点的 |
3 |
本地服务器查询 |
Ollama |
4 |
Anthropic API |
Anthropic |
5 |
Provider 感知查询 |
Nous 后缀匹配、models.dev Provider 映射 |
6 |
OpenRouter 元数据 |
OpenRouter |
7 |
Hardcoded 默认值 |
|
8 |
本地服务器探测 |
作为最后手段再次查询本地服务器 |
9 |
128K 回退 |
默认上下文长度 |
9.9.1. 本地服务器类型检测
detect_local_server_type() 通过探测已知端点来识别本地服务器类型:
Ollama :
/api/tags返回{"models": [...]}LM Studio :
/api/v1/models返回 200llama.cpp :
/v1/props包含default_generation_settingsvLLM :
/version返回{"version": "..."}
9.9.2. URL 到 Provider 推断
_infer_provider_from_url() 从 base URL 推断 Provider 名称,使得即使没有显式配置 Provider,也能通过 models.dev 查询上下文长度:
_URL_TO_PROVIDER: Dict[str, str] = {
"api.openai.com": "openai",
"api.anthropic.com": "anthropic",
"dashscope.aliyuncs.com": "alibaba",
"api.deepseek.com": "deepseek",
...
}
flowchart TD
A[get_model_context_length] --> B{config context_length set?}
B -->|Yes| C[Return explicit config value]
B -->|No| D{base_url exists?}
D -->|Yes| E[Query persistent cache context_length_cache.yaml]
E --> E1{Cache hit?}
E1 -->|Yes| E2[Return cached value]
E1 -->|No| F{Custom endpoint and not known provider?}
F -->|Yes| G[fetch_endpoint_model_metadata /models API]
G --> G1{Found context_length?}
G1 -->|Yes| G2[Return endpoint metadata value]
G1 -->|No| H{Local endpoint?}
H -->|Yes| I[_query_local_context_length]
I --> I1{Server type?}
I1 -->|Ollama| I2["/api/show - num_ctx or GGUF context_length"]
I1 -->|LM Studio| I3["/api/v1/models - loaded_instances.config.context_length"]
I1 -->|vLLM or generic| I4["/v1/models/model - max_model_len"]
I1 -->|llama.cpp| I5["/v1/props - n_ctx"]
I2 --> I6[save_context_length to persistent cache]
I3 --> I6
I4 --> I6
I5 --> I6
I6 --> I7[Return local query value]
H -->|No| J[Anthropic /v1/models API query]
F -->|No| J
J --> J1{Found?}
J1 -->|Yes| J2[Return Anthropic API value]
J1 -->|No| K{Provider-aware query}
K --> K1[Nous: _resolve_nous_context_length]
K --> K2[models.dev: lookup_models_dev_context]
K1 --> K3{Found?}
K2 --> K3
K3 -->|Yes| K4[Return provider-aware value]
K3 -->|No| L[OpenRouter metadata cache]
L --> L1{Found?}
L1 -->|Yes| L2[Return OpenRouter value]
L1 -->|No| M[DEFAULT_CONTEXT_LENGTHS hardcoded]
M --> M1{Model name matches?}
M1 -->|Yes| M2[Return hardcoded value]
M1 -->|No| N{Local endpoint?}
N -->|Yes| N1[_query_local_context_length last attempt]
N1 --> N2[Return local value or 128K]
N -->|No| O[Return DEFAULT_FALLBACK_CONTEXT = 128K]
classDef start fill:#dbeafe,stroke:#3b82f6,color:#1e3a8a
classDef success fill:#dcfce7,stroke:#16a34a,color:#166534
classDef warn fill:#fef9c3,stroke:#ca8a04,color:#854d0e
classDef fail fill:#fee2e2,stroke:#dc2626,color:#991b1b
classDef info fill:#f1f5f9,stroke:#64748b,color:#334155
class A start
class C,E2,G2,I7,J2,K4,L2,M2 success
class N2 warn
class O fail
9.10. Token 估算
Hermes 在多个环节需要进行 Token 估算,例如上下文压缩触发检查、预飞行(pre-flight)上下文窗口检查等。
9.10.1. estimate_tokens_rough
def estimate_tokens_rough(text: str) -> int:
"""Rough token estimate (~4 chars/token) for pre-flight checks."""
if not text:
return 0
return (len(text) + 3) // 4
使用 4 字符/token 的粗略估算比例。这是一个保守估计——实际分词器(如 tiktoken)的 token/字符比通常在 3-4 之间,取决于文本语言和内容。使用 4 字符/token 意味着估算值偏低,确保不会误报上下文溢出。
向上取整(+3 // 4)保证短文本(1-3 字符)不会估算为 0 个 token——这在处理大量短工具结果时尤其重要。
9.10.2. estimate_request_tokens_rough
def estimate_request_tokens_rough(
messages: List[Dict[str, Any]],
*,
system_prompt: str = "",
tools: Optional[List[Dict[str, Any]]] = None,
) -> int:
这个函数估算完整的 Chat Completions 请求的 token 数,包括三个主要部分:
System Prompt :系统提示文本
消息列表 :对话历史
工具 Schema :工具定义 JSON
工具 Schema 是一个容易被忽视但影响显著的 token 消耗源——当启用 50+ 工具时,仅 Schema 就可能消耗 20-30K tokens。
9.10.3. 上下文探测阶梯
当模型的上下文长度未知时,Hermes 使用 CONTEXT_PROBE_TIERS 阶梯式探测:
CONTEXT_PROBE_TIERS = [128_000, 64_000, 32_000, 16_000, 8_000]
从 128K 开始,如果请求超出上下文限制,逐步降低到下一个阶梯,直到找到可用的长度。这种方式避免了过度压缩或浪费上下文空间。
9.11. models.dev 注册表
agent/models_dev.py 集成了 models.dev ——一个社区维护的 LLM 模型数据库,覆盖 4000+ 模型和 109+ Provider。
9.11.1. 数据解析链
1. Bundled snapshot (离线优先)
2. Disk cache (~/.hermes/models_dev_cache.json)
3. Network fetch (https://models.dev/api.json)
4. Background refresh every 60 minutes
Hermes 采用 离线优先 策略:优先使用打包的快照,然后是磁盘缓存,最后才尝试网络获取。即使网络不可用,系统也能正常工作。
9.11.2. Provider ID 映射
Hermes 的 Provider 命名与 models.dev 不完全一致。PROVIDER_TO_MODELS_DEV 字典建立了映射关系:
PROVIDER_TO_MODELS_DEV: Dict[str, str] = {
"openrouter": "openrouter",
"anthropic": "anthropic",
"openai-codex": "openai",
"kimi-coding": "kimi-for-coding",
"copilot": "github-copilot",
"ai-gateway": "vercel",
...
}
模型查找使用 精确匹配 + 大小写不敏感回退 的策略:
def _find_model_entry(models, model):
entry = models.get(model) # 精确匹配
if entry:
return entry
model_lower = model.lower()
for mid, mdata in models.items(): # 大小写不敏感
if mid.lower() == model_lower:
return mdata
return None
9.11.3. 隐藏模型过滤
某些模型不适合在 Provider 目录中展示。_should_hide_from_provider_catalog() 过滤以下模型:
Google 的低 TPM Gemma 模型(在 Agent 流量下容易触发配额限制)
已废弃的 Google 模型(仍通过 models.dev 出现但在当前端点 404)
9.11.4. Agent 模型过滤
list_agentic_models() 返回适合 Agent 使用的模型,过滤条件:
tool_call=True—— 必须支持工具调用排除噪音模型(TTS、Embedding、预览快照、流式模型、纯图像模型)
_NOISE_PATTERNS = re.compile(
r"-tts\b|embedding|live-|-(preview|exp)-\d{2,4}[-_]|"
r"-image\b|-image-preview\b|-customtools\b",
re.IGNORECASE,
)
9.12. 错误消息解析
当 API 调用因上下文限制失败时,Hermes 能从错误消息中提取关键信息,用于动态调整上下文窗口。
9.12.1. 上下文限制提取
parse_context_limit_from_error() 使用多个正则表达式模式从错误消息中提取上下文限制:
patterns = [
r'(?:max(?:imum)?|limit)\s*(?:context\s*)?(?:length|size|window)?\s*(?:is|of|:)?\s*(\d{4,})',
r'context\s*(?:length|size|window)\s*(?:is|of|:)?\s*(\d{4,})',
r'(\d{4,})\s*(?:token)?\s*(?:context|limit)',
r'>\s*(\d{4,})\s*(?:max|limit|token)',
r'(\d{4,})\s*(?:max(?:imum)?)\b',
]
支持的错误消息格式包括:
"maximum context length is 32768 tokens""context_length_exceeded: 131072""Maximum context size 32768 exceeded""model's max context length is 65536"
9.12.2. 可用输出 Token 提取
parse_available_output_tokens_from_error() 区分两类上下文错误:
Prompt 过长 :输入本身超出上下文窗口——需要压缩历史
max_tokens 过大 :输入没问题,但
input + max_tokens > window——只需降低输出上限
Anthropic 的错误消息格式为:
"max_tokens: 32768 > context_window: 200000 - input_tokens: 190000 = available_tokens: 10000"
该函数提取 available_tokens 值,使得 Hermes 可以在不改变 context_length 的前提下,仅调整 max_tokens 参数重试请求。
9.13. 客户端适配器
Hermes 的传输逻辑集中在 agent/auxiliary_client.py(2400+ 行)中,
通过 适配器模式 桥接不同 API 格式,而不是使用独立的传输包。
辅助客户端是所有非主循环 LLM 调用的统一入口——上下文压缩、视觉分析、 网页提取、会话搜索、记忆刷写等辅助任务都通过它路由。
9.13.1. 适配器架构
auxiliary_client.py 内嵌了多种适配器,根据 HermesOverlay.transport
字段自动选择:
适配器 |
文件 |
适用场景 |
|---|---|---|
OpenAI (默认) |
|
OpenAI 兼容端点(OpenRouter、自定义、大多数第三方) |
AnthropicAdapter |
|
Anthropic Messages API(原生 Anthropic、Anthropic 兼容端点) |
BedrockAdapter |
|
AWS Bedrock Converse API |
CodexAdapter |
|
OpenAI Codex Responses API |
ACPAdapter |
|
GitHub Copilot ACP(设备码认证) |
GeminiCloudCode |
|
Google Gemini Cloud Code Assist API |
每个适配器处理各自协议的细节差异——消息格式转换、流式事件解析、
工具调用序列化、推理内容提取——对外暴露统一的 SimpleNamespace 响应格式。
9.13.2. AnthropicAdapter
agent/anthropic_adapter.py 将 Anthropic Messages API 的特有格式
翻译为 OpenAI 兼容格式:
content[]数组 →choices[0].message.contenttool_usecontent block →tool_calls[]数组tool_result→role: "tool"消息流式
content_block_delta→ OpenAI 格式的 delta chunk
9.13.3. BedrockAdapter
agent/bedrock_adapter.py 为 AWS Bedrock Converse API 提供原生适配:
原生 Converse API 统一接口支持所有 Bedrock 模型
AWS 凭证链:IAM 角色、SSO Profile、环境变量、实例元数据
动态模型发现:通过 Bedrock 控制面自动发现可用的基础模型
支持跨区域推理 Profile 实现更好的容量和自动故障转移
9.13.4. GeminiCloudCodeClient
agent/gemini_cloudcode_adapter.py 为 Google Gemini CLI 的 Cloud Code
Assist API 实现完整的双向翻译:
OpenAI
messages[]/tools[]→ Geminicontents[]/functionDeclarations请求体包裹为
{project, model, user_prompt_id, request}格式SSE 流式传输产出 OpenAI 格式的 delta chunk
9.14. 总结
Hermes 的多 Provider 统一接入层是一个精心设计的系统,其核心原则是:
双数据源合并 :models.dev 目录(109+ Provider)+ Hermes overlays(24 个 Provider 的特有元数据),通过
HermesOverlay统一暴露适配器模式 :六种适配器桥接不同 API 格式(OpenAI、Anthropic、Bedrock、Codex、ACP、Gemini Cloud Code),保持向后兼容
级联回退 :当首选 Provider 不可用时,自动尝试 OpenRouter → Nous → Custom → Codex → API-key 链
智能路由 :可选的 cheap-vs-strong 模型路由,通过消息复杂度分析自动选择廉价或强力模型
智能缓存 :线程安全的客户端缓存,带有事件循环验证和过时驱逐
优雅降级 :从精确配置到启发式估算,多层次的上下文长度解析
速率保护 :跨会话的 Rate Limit Guard,防止重试放大效应消耗配额
这些设计使得 Hermes 能够在 109+ 个 Provider 之间无缝切换,为用户提供始终可用的 Agent 体验。