Provider 与 Plugin 生态
30 个 ProviderProfile 怎么处理 Provider quirks;Plugin Hook 体系; 一份内置 plugin 的全貌。
最后这一章 Part V 收尾。我们看 Hermes 怎么让用户切换 30 个不同的 LLM provider 而不改一行代码, 以及 plugin 系统怎么让所有功能扩展都不动 core。读完这一章你应该能在不分叉仓库的情况下 扩展 Hermes 到任何你想要的形态。
12.1Provider 系统:为什么需要它
Hermes 用户的需求:
- "我想用 Claude Opus 干主活,DeepSeek-R1 做 reasoning 实验,Haiku 跑 curator review"
- "我有公司发的 OpenRouter key,但研究用 OpenAI 直连"
- "国内不能访问 OpenAI,我想用国产模型(智谱 GLM、Kimi、Qwen、MiniMax)"
- "我自己微调了一个开源模型 host 在 vLLM 上"
所有这些都不该需要改 Hermes 源码。Provider 系统让"换模型"变成配置问题。
12.2ProviderProfile:声明式 Provider
每个 provider 在 plugins/model-providers/<name>/__init__.py 里。最简形态:
plugins/model-providers/openrouter/__init__.py
from providers import register_provider
from providers.base import ProviderProfile
register_provider(ProviderProfile(
name="openrouter",
display_name="OpenRouter",
description="Aggregator — 300+ models behind one API",
base_url="https://openrouter.ai/api/v1",
env_vars=("OPENROUTER_API_KEY",),
aliases=("or",),
fallback_models=(
"anthropic/claude-opus-4.6",
"openai/gpt-5.2",
"deepseek/deepseek-v4",
),
))
这是纯声明式。没有自定义类、没有方法重载——只是几个字段。Hermes 主代码读这些字段构造 HTTP request。
ProviderProfile 的关键字段:
| 字段 | 作用 |
|---|---|
name | 唯一标识 |
display_name | 用户看到的名字 |
base_url | OpenAI 兼容 endpoint |
env_vars | 从哪个 env var 读 API key |
aliases | 命令行简写 or、oai 等 |
fallback_models | 用户不指定时用哪些 |
auth_method | api_key / oauth / none |
quirks | Provider-specific 处理(下面讲) |
12.3处理 Quirks:方法重载
简单 provider(OpenRouter、智谱、OpenAI 自家)声明式就够了。但有怪癖的需要重载方法。
看 plugins/model-providers/deepseek/__init__.py:
plugins/model-providers/deepseek/__init__.py
"""DeepSeek provider profile.
DeepSeek's V4 family defaults to thinking-mode ON when extra_body.thinking
is unset. The API then returns reasoning_content and starts enforcing the
contract that subsequent turns echo it back; combined with how Hermes
replays history this lands on HTTP 400 reasoning_content must be passed
back error.
This profile overrides build_api_kwargs_extras to mirror the wire shape
that DeepSeek's OpenAI-compat endpoint expects:
{"reasoning_effort": "<low|medium|high|max>",
"extra_body": {"thinking": {"type": "enabled" | "disabled"}}}
"""
from __future__ import annotations
from typing import Any
from providers import register_provider
from providers.base import ProviderProfile
def _model_supports_thinking(model: str | None) -> bool:
m = (model or "").strip().lower()
if not m: return False
if m.startswith("deepseek-v") and not m.startswith("deepseek-v3"):
return True
if m == "deepseek-reasoner":
return True
return False
class DeepSeekProfile(ProviderProfile):
"""DeepSeek — extra_body.thinking + top-level reasoning_effort."""
def build_api_kwargs_extras(self, *, model: str | None = None,
**context: Any) -> dict[str, Any]:
body: dict[str, Any] = {}
if _model_supports_thinking(model):
# 显式传 thinking enable,避免后续 echo back 报错
body["extra_body"] = {
"thinking": {"type": "enabled"}
}
return body
register_provider(DeepSeekProfile(
name="deepseek",
display_name="DeepSeek",
description="DeepSeek API (reasoning models supported)",
base_url="https://api.deepseek.com/v1",
env_vars=("DEEPSEEK_API_KEY",),
fallback_models=("deepseek-v4-pro", "deepseek-chat"),
))
注意
- 顶部那段注释价值连城——它是踩坑后写下的实战 lesson。 "如果不传 thinking enable,DeepSeek V4 返回 reasoning_content 但下一轮你不 echo back 会 400"。 这种知识只有踩过坑的人才知道,开放源码项目的注释是这种知识的最佳载体。
- 重载是方法粒度,不是子类化整个 client。
build_api_kwargs_extras只在某一步被调用,返回的 dict 合并进 API 请求 body。其他全部继承默认。 - 每个 quirk 单独一个 hook 方法:
build_api_kwargs_extras、normalize_response、build_auth_headers、filter_tools……Hermes 留了大约 10 个扩展点。
12.4真实 Quirk 四例
不同 provider 的 quirk 五花八门。挑四个 Hermes 实际处理过的:
Quirk 1: Gemini 不支持 tool_choice="required"
GeminiProfile 重载:
class GeminiProfile(ProviderProfile):
def build_api_kwargs_extras(self, **ctx):
kwargs = ctx.get("_api_kwargs", {})
if kwargs.get("tool_choice") == "required":
# Gemini 不支持 — 改 auto
kwargs["tool_choice"] = "auto"
logger.warning("Gemini doesn't support tool_choice=required; "
"falling back to auto")
return {}
Quirk 2: Anthropic prompt cache 显式标记
大部分 provider 自动 cache;Anthropic 要显式 cache_control。AnthropicProfile:
class AnthropicProfile(ProviderProfile):
def supports_cache_control(self) -> bool:
return True
def annotate_cache_breakpoints(self, messages):
# 在 system message 末尾、tools 末尾、context 末尾标 4 个 breakpoint
...
Quirk 3: Codex Responses API (OpenAI 新协议)
OpenAI 推 Responses API 替代 Chat Completions。CodexProfile:
class CodexProfile(ProviderProfile):
def api_mode(self) -> str:
return "codex_responses" # 主 loop 看到这个会走不同 path
def normalize_response(self, raw_response):
# 把 Responses API 的 {output: [...]} 格式转成
# 经典 Chat Completions 的 {choices: [{message: ...}]}
...
主 conversation_loop 不知道这事——它只看经典格式。Provider profile 负责适配。
Quirk 4: Unique tool names 强制(DeepSeek / Kimi / MiMo)
这些 provider 不允许 tools 列表里有重名工具,否则 400。Hermes 默认确保 unique,
但 long-running gateway 可能因为 cache pollution 出现重名。Profile 在
filter_tools hook 里做去重:
def filter_tools(self, tools):
seen = set()
result = []
for t in tools:
n = t["function"]["name"]
if n in seen:
logger.warning("Dropping duplicate tool name %s for strict provider", n)
continue
seen.add(n)
result.append(t)
return result
12.5Lazy Discovery
30 个 provider 全部都 import 太费启动时间。Hermes 用 lazy discovery。
providers/__init__.py:
providers/__init__.py:1-100
_REGISTRY: dict[str, ProviderProfile] = {}
_ALIASES: dict[str, str] = {}
_discovered = False
def register_provider(profile: ProviderProfile) -> None:
"""Register a provider profile by name and aliases.
Later registrations with the same name replace earlier — so user
plugins under $HERMES_HOME/plugins/model-providers/ can override
bundled profiles without editing repo code.
"""
_REGISTRY[profile.name] = profile
for alias in profile.aliases:
_ALIASES[alias] = profile.name
def get_provider_profile(name: str) -> ProviderProfile | None:
"""Look up a profile by name or alias."""
if not _discovered:
_discover_providers()
canonical = _ALIASES.get(name, name)
return _REGISTRY.get(canonical)
def _discover_providers() -> None:
"""Populate registry by importing every provider plugin.
Order (later overrides earlier):
1. Bundled: <repo>/plugins/model-providers/<name>/
2. User: $HERMES_HOME/plugins/model-providers/<name>/
3. Legacy: providers/<name>.py
"""
global _discovered
if _discovered: return
_discovered = True
# 1. Bundled
if _BUNDLED_PLUGINS_DIR.is_dir():
for child in sorted(_BUNDLED_PLUGINS_DIR.iterdir()):
if not child.is_dir() or child.name.startswith(("_", ".")):
continue
_import_plugin_dir(child, "bundled")
# 2. User
user_dir = _user_plugins_dir()
if user_dir is not None:
for child in sorted(user_dir.iterdir()):
if not child.is_dir() or child.name.startswith(("_", ".")):
continue
_import_plugin_dir(child, "user")
# 3. Legacy: providers/{name}.py — 老路径兼容
for legacy in (Path(__file__).parent.glob("*.py")):
if legacy.name in ("__init__.py", "base.py"): continue
importlib.import_module(f"providers.{legacy.stem}")
设计点:
- Lazy:第一次
get_provider_profile()触发整个扫描。 不调就一个 provider 都不 import。启动快。 - Three-layer override:bundled → user → legacy。后者覆盖前者。
让用户能在
$HERMES_HOME/plugins/model-providers/openai/__init__.py重写 bundled OpenAI 配置。 - 独立于通用 PluginManager:model-provider plugin 单独发现路径。
原因——通用 PluginManager 会 invoke
register(ctx), 但 provider 是模块级register_provider(...)。混在一起会双注册。
12.630 个 Provider
Hermes 仓库的 plugins/model-providers/:
plugins/model-providers/
├── anthropic # Claude 直连
├── openai-codex # OpenAI Responses API
├── openrouter # 聚合器
├── deepseek # DeepSeek V4 + R1
├── gemini # Google
├── xai # Grok
├── nous # Nous Portal
├── alibaba # 通义千问
├── alibaba-coding-plan
├── kilocode # 国产 coding 专用
├── kimi-coding # 月之暗面
├── minimax # MiniMax
├── novita # AI 云
├── ollama-cloud # 本地 + 云端 Ollama
├── nvidia # NIM
├── stepfun # 阶跃星辰
├── xiaomi # MiMo
├── zai # 智谱 GLM
├── huggingface
├── opencode-zen
├── arcee
├── azure-foundry # Azure 系
├── bedrock # AWS
├── copilot # GitHub Copilot
├── gmi # GMI
├── ai-gateway
├── qwen-oauth # 通义 OAuth flow
├── custom # 兜底 — 任意 OpenAI 兼容
30 个 provider,全部走同一份核心代码。切换提供商=改配置:
# config.yaml
model:
provider: anthropic
model: claude-opus-4.7
fallback_provider: openrouter
fallback_model: openai/gpt-5.2
2026 年的 Provider 现状
到本书写作时(2026 年 5 月),主流 provider 的能力梯度大致是这样:
| Provider | 当前主力模型 | 定位 | 价格区间 (input/M) |
|---|---|---|---|
| Anthropic | Claude Opus 4.7 / Sonnet 4.6 | SOTA reasoning + 长上下文 | $15 / $3 |
| OpenAI | GPT-5.2 / GPT-5 / o3 | SOTA reasoning,强工具调用 | $15 / $2 |
| Gemini 3 Pro / Flash | 多模态、便宜 Flash | $7 / $0.4 | |
| DeepSeek | V4 / R2 | 开源权重,性价比之王 | $0.6 / $2 |
| 智谱 (z.ai) | GLM-5 | 中文友好,本地可部署 | ~$1 |
| Moonshot | Kimi K2 / Reasoning | 长上下文 + reasoning | $2 |
| Alibaba | Qwen 3.5 / Thinking | 开源权重 + reasoning | $1 / 开源免费 |
| xAI | Grok 4 | 实时数据接入 | $5 |
| 本地 (Ollama / vLLM) | Llama 4 / Qwen 3 | 完全离线 | 电费 |
辅助任务(curator review、auxiliary client、压缩):Sonnet 4.6 / Gemini Flash / DeepSeek(便宜)。
fallback:选一个不同 provider的,防 Anthropic / OpenAI 整厂宕机时全挂。
国内场景:智谱 GLM-5、Kimi K2、Qwen 3.5 都已能产线用。
Provider 厂商的 Agent SDK
2025–2026 各家除了卖模型,也都推出"配套 Agent SDK"。Hermes 不强制用——但了解它们对比 Hermes 设计有帮助:
| SDK | 核心抽象 | 位置 |
|---|---|---|
| OpenAI Agents SDK | Handoff(Agent 之间交接) | 取代 2024 的 Assistants API |
| Anthropic Claude Agent SDK | Tool use + Sub-agents | 2025 中发布 |
| Google Vertex Agents | 声明式 yaml 编排 | 企业向 |
| Microsoft Agent Framework (MAF) | Actor 模型 | 替代 AutoGen |
和 Hermes 的关系:这些是"模型厂家的 framework",绑定特定 provider。 Hermes 选择不依赖任何 SDK,直接用 OpenAI 兼容协议——为的是不被任何一家锁定。
12.7通用 Plugin 系统
第 7 章已经介绍过 Plugin Hook。这里完整看 PluginManager 的实现。
hermes_cli/plugins.py:
hermes_cli/plugins.py (摘要)
class PluginManager:
def __init__(self):
self._hooks: dict[str, list[Callable]] = {h: [] for h in VALID_HOOKS}
self._plugin_tool_names: set = set()
self._cli_subcommands: dict[str, dict] = {}
def discover_plugins(self):
"""Find & load all plugins from:
- bundled plugins/ (only if has plugin.yaml)
- $HERMES_HOME/plugins/
- project-local ./.hermes/plugins/
- pip entry points (group: 'hermes_plugins')
"""
for directory in [_BUNDLED, _USER, _PROJECT_LOCAL]:
for plugin_dir in directory.iterdir():
manifest = plugin_dir / "plugin.yaml"
if not manifest.exists():
continue
self._load_plugin(plugin_dir)
# pip entry points
for ep in entry_points(group="hermes_plugins"):
self._load_entry_point(ep)
def _load_plugin(self, plugin_dir: Path):
manifest = yaml_load(plugin_dir / "plugin.yaml")
kind = manifest.get("kind", "general")
if kind == "model-provider":
# model provider 走另一条 discovery,这里跳过
return
# 导入 __init__.py 拿 register()
spec = importlib.util.spec_from_file_location(...)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
if not hasattr(module, "register"):
logger.warning("Plugin %s lacks register() — skipping", plugin_dir)
return
ctx = PluginContext(self, plugin_dir, manifest)
module.register(ctx)
PluginContext
给 plugin 用的 helper API。可以注册 hook、tool、CLI subcommand:
class PluginContext:
def register_hook(self, hook_name, callback):
self._manager._hooks[hook_name].append(callback)
def register_tool(self, name, toolset, schema, handler, ...):
from tools.registry import registry
registry.register(name=name, toolset=toolset, ...)
self._manager._plugin_tool_names.add(name)
def register_cli_command(self, name, parser_fn, handler):
# 让 hermes <plugin_name> <subcmd> 可用
self._manager._cli_subcommands[name] = {
"parser_fn": parser_fn,
"handler": handler,
}
12.8真实 plugin 一例:Kanban
Hermes 自带的 kanban plugin 是多 agent 任务板。它展示了 plugin 能做到多大规模:
plugins/kanban/
├── plugin.yaml
├── __init__.py # register(ctx)
├── kanban_cli.py # hermes kanban <verb> CLI
├── kanban_tool.py # LLM tools: kanban_show, kanban_complete, ...
├── kanban_dispatcher.py # 长跑后台 daemon
├── kanban_db.py # SQLite 持久化
├── dashboard/ # Web UI(FastAPI + React)
│ ├── api.py
│ ├── dist/ # 编译好的 React
│ └── manifest.json
└── systemd/
└── hermes-kanban-dispatcher.service
它做的事:
- 提供一个多 Agent 协同任务板(用 SQLite 持久化)。
- 注册 9 个
kanban_*工具供 Agent 操作任务。 - 注册一组
hermes kanbanCLI 子命令供用户操作。 - 提供 Web dashboard 给运维监控。
- 提供 systemd unit 文件方便部署成 daemon。
整个 plugin 大约 3000 行代码——不动 Hermes core 一行。 这就是 plugin 系统的威力上限。
12.9"Plugin 不动 core" 的铁律
Hermes AGENTS.md 里有:
Plugins MUST NOT modify core files (run_agent.py, cli.py, gateway/run.py, hermes_cli/main.py, etc.). If a plugin needs a capability the framework doesn't expose, expand the generic plugin surface (new hook, new ctx method) — never hardcode plugin-specific logic into core.
这条规则的原因和历史很重要:
早期 Hermes 在 main.py 里硬编码了 honcho(一个 memory plugin)的 argparse
子命令。每次 honcho 改命令格式,要发 PR 改 main.py。PR #5295 移除了这 95 行
硬编码,换成一个通用的"plugin 可注册 CLI subcommand"机制。
从此规则确定:core 永远不知道任何特定 plugin 的存在。 plugin 需要新能力,扩 PluginContext,加新 hook。
12.10"无新 in-tree memory provider" 的政策
同样的精神扩展到 memory plugin。AGENTS.md 里:
No new in-tree memory providers (policy, May 2026): the set of built-in memory providers under plugins/memory/ is closed. New memory backends must ship as standalone plugin repos that users install into ~/.hermes/plugins/ (or via pip entry points).
意思:哪怕你写了世界最强的新 memory provider,不收进 Hermes 主仓库。 你必须独立发包。
动机:
- 降低耦合:每个 memory provider 自有迭代节奏,不被 Hermes release 周期束缚。
- 用户选择:装 Hermes 不强制带 8 个 memory provider 的依赖。
- 生态去中心化:第三方 provider 和官方 provider 平等。
这是对生态的承诺。Hermes 不想做"所有功能都在我这"的胖框架, 它想做"thin core + 强生态"的瘦平台。
12.11本章带走的
- 30 个 Provider 通过声明式 ProviderProfile 接入。简单 provider 几行字段,复杂 provider 重载方法处理 quirks。
- 真实 quirks 不少:DeepSeek thinking flag、Gemini tool_choice、Codex Responses API、Unique tools enforcement——每个都隔离在 Profile 子类。
- Lazy discovery 让启动快。bundled → user → legacy 三层 override 顺序。
- 30 个 provider 覆盖国际 + 国产 + 自建 + 聚合器 + 本地。
- 通用 PluginManager 走 plugin.yaml + register(ctx) 协议。注册 hook / tool / CLI subcommand。
- Plugin 不动 core 铁律(Teknium rule, 2026.05):扩展点开放,core 闭合。 违反就闭 PR。
- 无新 in-tree memory provider 政策:保持生态去中心化,不做胖框架。
- Kanban plugin 是 plugin 能达到的规模上限例子——3000 行不动 core。
章末练习
- Easy ProviderProfile 是声明式的——但 quirk 需要重载方法。这种"声明 + 重载"的混合设计有什么好处?
-
Easy
你想接一个新的 OpenAI 兼容 endpoint(你自己 host 的 vLLM)。
写出最简的
~/.hermes/plugins/model-providers/myvllm/__init__.py。 -
Medium
Lazy discovery 有一个 trade-off:第一次
get_provider_profile()调用慢 (扫描 30 个目录)。设计一个 caching 策略让冷启动也快——比如"启动时只读 manifest, 用到 profile 时再 import 模块"。 -
Medium
Hermes 不允许"两个 plugin 注册同名工具(不带 override)"。但两个 plugin 都需要
fetch_url这种工具怎么办? 讨论:是 plugin 应该用 namespace(fetch_url_v2),还是 core 应该提供工具别名机制? -
Hard
Plugin Hook 系统有一个被忽略的顺序依赖问题:两个 plugin 都注册了
transform_tool_result,先后顺序决定最终结果。 读invoke_hook的实现(第 7 章末有引),讨论这个顺序是怎么定的。 然后写一段约 200 字的"plugin 协作 best practices"。 -
Hard
"Plugin 不动 core 铁律"听起来很纯粹,但有边界例子:plugin 需要在
run_conversation的某个具体行号插入 hook,core 还没暴露这个 hook。怎么演进?写一份"扩展 Plugin Hook 集"的提案模板: 命名、参数、调用时机、回调返回值语义。