模型抽象与 Provider 兼容层
本章核心源码:
run_agent.py(559-581 API 模式检测)、agent/model_metadata.py、agent/usage_pricing.py
定位:本章拆解 Hermes 如何用一套编排逻辑适配 Anthropic、OpenAI、DeepSeek、本地 Ollama 等数十个 LLM provider——从 API 模式自动检测到上下文窗口探测到成本追踪。 前置依赖:第 4 章(AIAgent 初始化阶段 2/4)。适用场景:想理解 Hermes 如何做到"换个 model 字段就能切 provider",或需要接入新的 LLM provider。
为什么模型抽象是 Run Anywhere 的基础
"Run Anywhere"不只是指运行在不同的操作系统上——它同样意味着运行在不同的 LLM provider 上。Hermes 的用户群体横跨:
- Anthropic API 直连用户(需要 prompt caching、extended thinking)
- OpenAI API 用户(需要 Responses API、reasoning tokens)
- OpenRouter 中转用户(需要统一计费、多模型切换)
- 本地模型用户(Ollama、LM Studio、vLLM、llama.cpp)
- 国产模型用户(DeepSeek、DashScope/Qwen、GLM、Kimi、MiniMax)
- 企业平台用户(GitHub Copilot、Hugging Face Inference)
每个 provider 的 API 格式、认证方式、上下文限制、定价模型都不同。Hermes 需要在编排层之下建立一个兼容层,让 AIAgent.run_conversation() 不需要知道底层是 Claude 还是 GPT 还是 Qwen。
三种 API 模式
Hermes 将所有 LLM provider 归类为三种 API 模式(run_agent.py:559-581):
graph LR
subgraph "API Mode Detection"
INPUT["model + provider + base_url"] --> CHECK{"检测逻辑<br/>run_agent.py:559-581"}
CHECK -->|"provider == anthropic<br/>或 URL 含 api.anthropic.com"| AM["anthropic_messages"]
CHECK -->|"provider == openai-codex<br/>或直连 OpenAI"| CR["codex_responses"]
CHECK -->|"其他所有情况"| CC["chat_completions"]
end
AM --> SDK_A["Anthropic SDK<br/>Messages API"]
CR --> SDK_O["OpenAI SDK<br/>Responses API"]
CC --> SDK_C["OpenAI SDK<br/>Chat Completions API"]
style CC fill:#9cf,stroke:#333,stroke-width:2px
检测逻辑详解
# run_agent.py:559-581(简化)
if api_mode in {"chat_completions", "codex_responses", "anthropic_messages"}:
self.api_mode = api_mode # 显式指定优先
elif self.provider == "openai-codex":
self.api_mode = "codex_responses"
elif self.provider == "anthropic" or "api.anthropic.com" in base_url:
self.api_mode = "anthropic_messages"
elif base_url.rstrip("/").endswith("/anthropic"):
self.api_mode = "anthropic_messages" # MiniMax、DashScope 的 Anthropic 兼容端点
else:
self.api_mode = "chat_completions" # 默认
# 直连 OpenAI 时自动切换到 Responses API
if self.api_mode == "chat_completions" and self._is_direct_openai_url():
self.api_mode = "codex_responses" # run_agent.py:580-581
| API 模式 | 使用场景 | 选择原因 |
|---|---|---|
chat_completions | OpenRouter、大多数第三方、本地模型 | 最广泛兼容的标准,几乎所有 provider 都支持 |
anthropic_messages | Anthropic 直连、MiniMax /anthropic 端点、DashScope /anthropic 端点 | 原生支持 prompt caching(cache_control blocks)、extended thinking |
codex_responses | OpenAI 直连 | GPT-5.x 的 tool calling + reasoning 需要 Responses API |
一个有趣的细节:URL 以 /anthropic 结尾的第三方端点(run_agent.py:569-573)被自动识别为 Anthropic 兼容模式。这是因为 MiniMax 和 DashScope 等国产 provider 提供了 Anthropic Messages API 兼容端点,Hermes 通过 URL 约定自动适配。
检测的时序约束
API 模式检测必须在 LLM 客户端构造之前完成(第 4 章的阶段 2 在阶段 4 之前),因为不同模式需要不同的 SDK 客户端:
anthropic_messages→build_anthropic_client()构造 Anthropic SDK 客户端chat_completions/codex_responses→ 构造 OpenAI SDK 客户端
Provider 路由
当用户没有显式指定 provider 和 base_url 时,Hermes 通过 resolve_provider_client() 自动路由:
# run_agent.py:761-763(简化)
from agent.auxiliary_client import resolve_provider_client
_routed_client, _ = resolve_provider_client(
self.provider or "auto", model=self.model
)
这里要区分两层职责:
- 主路由器在
agent/auxiliary_client.py的resolve_provider_client(),负责根据 provider、model、base_url、认证方式实际构造客户端 agent/model_metadata.py里的_URL_TO_PROVIDER只是辅助表,用于在模型元数据解析时根据 base URL 推断 provider
后者不是“真正决定客户端怎么建”的主路由器,它更像是元数据层的补充信号:
# agent/model_metadata.py:180-197
_URL_TO_PROVIDER: Dict[str, str] = {
"api.openai.com": "openai",
"api.anthropic.com": "anthropic",
"api.z.ai": "zai",
"api.moonshot.ai": "kimi-coding",
"dashscope.aliyuncs.com": "alibaba",
"openrouter.ai": "openrouter",
"generativelanguage.googleapis.com": "gemini",
"inference-api.nousresearch.com": "nous",
"api.deepseek.com": "deepseek",
# ... 共 16 个端点映射
}
上下文窗口探测
上下文窗口长度决定了 Hermes 的压缩策略、消息截断和 prompt 构建。但不同 provider 的同一个模型可能有不同的上下文限制(例如 Claude Opus 4.6 在 Anthropic 直连是 1M tokens,在 GitHub Copilot 只有 128K)。
get_model_context_length() 不是线性的“查 10 张表”,而是一条带分支的解析链(model_metadata.py:838-965):
| 优先级 | 来源 | 代码行 |
|---|---|---|
| 0 | 用户显式配置(config.yaml 的 context_length) | 860-861 |
| 1 | 持久化缓存(context_length_cache.yaml) | 868-872 |
| 2 | 活跃端点 /models API(仅真正的自定义未知端点) | 879-895 |
| 3 | 自定义本地端点的直接查询与早期 fallback 分支 | 897-909 |
| 4 | Anthropic /v1/models API | 911-917 |
| 5 | provider-aware 查找(先 Nous,再 models.dev) | 919-939 |
| 6 | OpenRouter 实时 API 元数据 | 942-944 |
| 7 | 硬编码默认值(DEFAULT_CONTEXT_LENGTHS) | 947-955 |
| 8 | 本地端点的最后一次查询机会 | 958-962 |
| 9 | 默认 fallback(128K) | 965 |
最关键的顺序修正是第 5、6 步:provider-aware 查找先于 OpenRouter 的通用元数据。这正是 Hermes 用来处理“同一个模型在不同 provider 上上下文窗口不同”的关键细节。
本地服务器自动探测(model_metadata.py:258-314)
当 base_url 指向本地地址时,Hermes 通过探针请求自动识别服务器类型:
# agent/model_metadata.py:258-314(简化)
def detect_local_server_type(base_url: str) -> Optional[str]:
# LM Studio: /api/v1/models
# Ollama: /api/tags(检查返回体包含 "models" 键)
# llama.cpp: /v1/props(检查 "default_generation_settings")
# vLLM: /version
Ollama 的探测有一个细节:LM Studio 在 /api/tags 路径也返回 200 但内容不同({"error": "Unexpected endpoint"}),所以 Hermes 不仅检查状态码,还验证返回体包含 "models" 键(model_metadata.py:283-290)。
错误驱动的上下文探测(model_metadata.py:574-599)
当正常探测失败时,Hermes 还能从 API 错误消息中提取上下文限制:
# agent/model_metadata.py:574-598
def parse_context_limit_from_error(error_msg: str) -> Optional[int]:
patterns = [
r'(?:max|limit)\s*(?:context\s*)?(?:length|size)?\s*(?:is|of|:)?\s*(\d{4,})',
r'context\s*(?:length|size)\s*(?:is|of|:)?\s*(\d{4,})',
r'(\d{4,})\s*(?:token)?\s*(?:context|limit)',
]
配合 CONTEXT_PROBE_TIERS(model_metadata.py:73-79)的阶梯探测机制:
CONTEXT_PROBE_TIERS = [128_000, 64_000, 32_000, 16_000, 8_000]
当首次 API 调用因为上下文超限失败时,Hermes 从错误消息中解析出实际限制,或者按阶梯降级重试。发现的限制会被持久化到 context_length_cache.yaml(model_metadata.py:538-556),后续请求直接使用缓存值。
Token 估算:粗略与精确
Hermes 使用两层 token 估算:
粗略估算(model_metadata.py:968-999)
# agent/model_metadata.py:968-972
def estimate_tokens_rough(text: str) -> int:
"""Rough token estimate (~4 chars/token) for pre-flight checks."""
return len(text) // 4
4 chars/token 的粗略估算用于预检——在 API 调用之前快速判断消息是否会超过上下文窗口。这比调用 tokenizer 快几个数量级。estimate_request_tokens_rough() 还包含了 tool schema 的估算(model_metadata.py:981-999),因为 50+ 个工具的 schema 可以占用 20-30K tokens。
精确追踪(usage_pricing.py:420-478)
API 调用返回后,normalize_usage() 将三种 API 格式的 usage 统一为 CanonicalUsage:
# agent/usage_pricing.py:420-478
def normalize_usage(response_usage, *, provider=None, api_mode=None) -> CanonicalUsage:
if mode == "anthropic_messages" or provider_name == "anthropic":
# Anthropic: input_tokens / output_tokens / cache_read_input_tokens / cache_creation_input_tokens
...
elif mode == "codex_responses":
# Codex: input_tokens 包含缓存; input_tokens_details.cached_tokens 分离
...
else:
# Chat Completions: prompt_tokens 包含缓存; prompt_tokens_details.cached_tokens 分离
...
三种 API 对缓存 token 的表示方式完全不同:
| 字段 | Anthropic | Codex Responses | Chat Completions |
|---|---|---|---|
| 输入 token | input_tokens | input_tokens(含缓存) | prompt_tokens(含缓存) |
| 缓存读取 | cache_read_input_tokens | input_tokens_details.cached_tokens | prompt_tokens_details.cached_tokens |
| 缓存写入 | cache_creation_input_tokens | input_tokens_details.cache_creation_tokens | prompt_tokens_details.cache_write_tokens |
CanonicalUsage(usage_pricing.py:28-44)将这些差异统一为 5 个标准字段:input_tokens、output_tokens、cache_read_tokens、cache_write_tokens、reasoning_tokens。
成本追踪
计费路由(usage_pricing.py:306-330)
resolve_billing_route() 将 model + provider + base_url 映射到计费路线:
# agent/usage_pricing.py:306-330
def resolve_billing_route(model_name, provider=None, base_url=None) -> BillingRoute:
if provider_name == "openai-codex":
return BillingRoute(..., billing_mode="subscription_included") # 订阅制,零成本
if "openrouter.ai" in base:
return BillingRoute(..., billing_mode="official_models_api") # 从 OpenRouter API 获取定价
if provider_name == "anthropic":
return BillingRoute(..., billing_mode="official_docs_snapshot") # 使用硬编码快照
if "localhost" in base:
return BillingRoute(..., billing_mode="unknown") # 本地模型,无法计费
多源定价查找(usage_pricing.py:390-417)
get_pricing_entry() 按优先级查找定价数据:
- 订阅制(如 GitHub Copilot):直接返回零成本
- OpenRouter API:从
/models端点获取实时定价 - 自定义端点 /models:从端点元数据中提取定价
- 官方文档快照:使用硬编码的定价表
官方文档快照(usage_pricing.py:83-287)
_OFFICIAL_DOCS_PRICING 是一个精心维护的定价快照,覆盖了主流 provider 的 17 个模型:
# agent/usage_pricing.py:83-96
_OFFICIAL_DOCS_PRICING = {
("anthropic", "claude-opus-4-20250514"): PricingEntry(
input_cost_per_million=Decimal("15.00"),
output_cost_per_million=Decimal("75.00"),
cache_read_cost_per_million=Decimal("1.50"),
cache_write_cost_per_million=Decimal("18.75"),
source="official_docs_snapshot",
),
# ... Google Gemini, OpenAI GPT-4.1, DeepSeek, etc.
}
使用 Decimal 而非 float 是刻意的——金额计算不能有浮点误差。每个条目都附带了 source_url 和 pricing_version,让用户可以验证定价来源。
成本估算(usage_pricing.py:481-557)
# agent/usage_pricing.py:529-536
if entry.input_cost_per_million is not None:
amount += Decimal(usage.input_tokens) * entry.input_cost_per_million / _ONE_MILLION
if entry.cache_read_cost_per_million is not None:
amount += Decimal(usage.cache_read_tokens) * entry.cache_read_cost_per_million / _ONE_MILLION
CostResult 的 status 字段告诉调用方这个成本的可信度:"actual"(provider API 提供)、"estimated"(从快照/元数据计算)、"included"(订阅制)、"unknown"(无法计算)。
CodexAuxiliaryClient:辅助任务的端点适配
当主 agent 使用 Codex Responses API(codex_responses 模式)时,存在一个端点形状不匹配的问题:辅助任务(压缩摘要、标题生成、会话搜索等)的代码使用的是 client.chat.completions.create() 接口,但 Codex 端点只暴露 Responses API,不支持 /chat/completions 路径。
CodexAuxiliaryClient(agent/auxiliary_client.py:424)通过适配器模式解决这个问题:
# agent/auxiliary_client.py:424-439
class CodexAuxiliaryClient:
"""OpenAI-client-compatible wrapper that routes through Codex Responses API.
Consumers can call client.chat.completions.create(**kwargs) as normal."""
def __init__(self, real_client: OpenAI, model: str):
self._real_client = real_client
adapter = _CodexCompletionsAdapter(real_client, model)
self.chat = _CodexChatShim(adapter)
调用方仍然写 client.chat.completions.create(messages=..., model=...),但 _CodexCompletionsAdapter 内部将 chat.completions 请求翻译为 Responses API 调用,再将 Responses 格式的返回值包装回 chat.completions 的响应形状。
这不是简单的 provider 路由——第 18 章前面讨论的 resolve_provider_client() 决定"用哪个 provider",而 CodexAuxiliaryClient 解决的是"同一个 provider 内两种 API 端点形状不兼容"的问题。对应的异步版本 AsyncCodexAuxiliaryClient(auxiliary_client.py:462)通过 asyncio.to_thread() 桥接同步适配器,供 web_tools、session_search 等异步消费方使用。
类似的适配器还有 AnthropicAuxiliaryClient(同文件),用于将 chat.completions 调用翻译为 Anthropic Messages API 格式。三个辅助客户端适配器共同确保了:无论主 agent 使用哪种 API 模式,辅助任务代码都只需要一套 chat.completions 接口。
Provider 前缀处理(model_metadata.py:22-61)
用户配置模型时可能使用各种格式:anthropic/claude-opus-4.6、local:qwen3.5:27b、deepseek:latest。_strip_provider_prefix() 需要区分 provider 前缀和 Ollama 的 model:tag 格式:
# agent/model_metadata.py:44-61
def _strip_provider_prefix(model: str) -> str:
prefix, suffix = model.split(":", 1)
if prefix_lower in _PROVIDER_PREFIXES:
# Don't strip if suffix looks like an Ollama tag (e.g. "7b", "latest", "q4_0")
if _OLLAMA_TAG_PATTERN.match(suffix.strip()):
return model # "qwen3.5:27b" → 保持原样
return suffix # "local:my-model" → "my-model"
return model # "some-model:tag" → 保持原样
_PROVIDER_PREFIXES 包含 30+ 个已知的 provider 名称(model_metadata.py:25-35),_OLLAMA_TAG_PATTERN 匹配 Ollama tag 的常见格式(数字+b、latest、q 量化标记等)。
设计启示
- 模式检测而非配置声明:大多数用户只配置 model 和 api_key,API 模式由 provider + base_url 自动推导。这减少了配置出错的可能性,同时允许高级用户通过
api_mode字段显式覆盖 - 10 级 fallback 链:上下文窗口探测不依赖单一数据源。任何一级失败都有下一级接管,最差情况回退到 128K 的安全默认值。错误驱动的探测(从 API 错误中解析限制)是最后的兜底
- 统一 token 语义:三种 API 对缓存 token 的表示差异很大,但
CanonicalUsage将它们统一为 5 个标准字段。这让上层代码(成本计算、usage 显示、压缩决策)不需要知道底层 API 的差异
设计赌注回扣:本章直接服务于 Run Anywhere 赌注——三种 API 模式的自动检测、30+ provider 的路由映射、10 级上下文探测 fallback,让用户只需改一个
model配置就能在 Anthropic、OpenAI、本地 Ollama、国产 DeepSeek 之间无缝切换。
版本演化说明
本章核心分析基于 Hermes Agent v0.8.x(2026 年 4 月)。 模型兼容层在 v0.5.0-v0.8.0 之间扩张很快:API 模式、provider-aware 上下文窗口探测和价格快照都不是一次成形的。可以明确确认的是,models.dev / Anthropic
/v1/models这一类 provider-aware 元数据能力在 v0.7.0 发布窗口已经出现,并在 v0.8.0 继续强化。v0.8.x 新增了CodexAuxiliaryClient适配器,解决了主 agent 使用 Codex Responses API 时辅助任务仍需 chat.completions 接口的端点形状不匹配问题。