Hermes Agent 插件开发:从一个 Hook 开始,给智能体装上你的专属能力

📅 发布时间:2026/9/29 4:02:04
Hermes Agent 插件开发:从一个 Hook 开始,给智能体装上你的专属能力
1. 为什么从一个 Hook 开始写 Hermes Agent 插件Hermes Agent 插件开发这件事最容易劝退新人的不是 API 有多复杂而是不知道从哪里下手。官方文档一打开事件总线、工具注册、生命周期、权限模型全铺在面前看完一圈反而更懵。我的建议是别管全貌先抓住一个 Hook 把它跑通。Hook 是什么你可以把它理解成智能体运行过程中的“插座”。Hermes Agent 在执行任务时会经过若干固定节点——收到用户消息、准备调用工具、工具返回结果、生成最终回复——每个节点都会向外广播一个事件。插件只要在某个事件上挂一个回调函数就能在那一刻插入自己的逻辑。这个回调就是 Hook。为什么用 Hook 作为切入点因为它把插件开发拆成了两个可以独立验证的部分注册插件能不能被加载和触发回调能不能被执行。这两件事分开验证出错时你立刻知道是加载阶段的问题还是运行阶段的问题。相比之下一上来就写一个完整的自定义工具加载、参数解析、权限、返回值格式全缠在一起报错了根本不知道从哪查。这篇要交付的东西很具体一个能直接复制的插件目录结构、一份 Hook 注册配置骨架、一段可运行的 Python 回调代码以及加载验证和触发调试的具体命令。目标读者是已经装好 Hermes Agent、能跑通基本对话、想给它加一点自己逻辑的人。你不需要先读完所有插件文档跟着走一遍第一个自定义插件就能跑起来。我试过把这篇的骨架直接套在一个“记录每次工具调用耗时”的小插件上从建目录到看到日志输出大概十五分钟。下面把这十五分钟拆开讲。2. TaoToken 前置给插件一个稳定的模型入口插件本身不负责模型调用但插件触发时往往需要读环境变量、拿配置、甚至在某些 Hook 里发起一次轻量的模型请求比如对工具返回结果做摘要。这时候一个稳定的 API 入口就很重要——你总不希望插件调试到一半因为模型端点抖动而误判成自己代码的问题。TaoToken 在这里的角色是统一模型接入层。它提供 OpenAI 兼容的接口你可以在插件里用标准 SDK 调用不用为每个模型提供方写一套适配。对插件开发来说这意味着你的 Hook 回调里那段模型请求代码是通用的换模型只改配置不改逻辑。具体要准备的东西一个 API Key在控制台的 API Keys 页面创建https://taotoken.net/console/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite接口地址https://taotoken.net/api模型名按你实际要用的填把 Key 放进环境变量不要写进插件代码export TAOTOKEN_API_KEYsk-你的key export TAOTOKEN_BASE_URLhttps://taotoken.net/api注意插件代码里只读环境变量名绝不打印变量值。调试日志里出现sk-开头的内容说明你的脱敏没做好。如果你还没决定用哪个模型可以先去模型对话页面手动试几句确认响应风格符合预期再写进插件https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite长期跑编码类或 Agent 类任务的话Coding Plan 的额度模型更适合插件这种高频小请求的场景https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite3. 插件目录结构与 Hook 注册骨架Hermes Agent 的插件发现机制基于约定目录。不同版本的具体路径可能微调但结构逻辑是一致的一个插件一个目录目录里有清单文件和入口模块。先建目录mkdir -p ~/.hermes/plugins/tool_timer cd ~/.hermes/plugins/tool_timer touch plugin.yaml main.py目录长这样~/.hermes/plugins/ └── tool_timer/ ├── plugin.yaml # 插件清单名称、版本、入口、注册的 Hook └── main.py # 入口模块Hook 回调实现plugin.yaml是加载器第一个读的文件写错一个字段插件就不会被识别。骨架如下name: tool_timer version: 0.1.0 description: 记录每次工具调用的开始与结束时间 entry: main.py enabled: true hooks: - event: before_tool_call handler: on_before_tool_call - event: after_tool_call handler: on_after_tool_call这里的关键字段是hooks列表。每个条目声明一个事件名和一个处理函数名。事件名必须和 Hermes Agent 实际广播的事件一致处理函数名必须和main.py里的函数名一致。这两处任何一处拼错表现都是“插件加载成功但回调不触发”——这是新手最常见的坑后面排障章节会专门讲怎么定位。main.py里实现两个回调import time import logging logger logging.getLogger(hermes.plugin.tool_timer) # 用模块级字典暂存开始时间key 用调用 id 避免并发串扰 _start_times {} def on_before_tool_call(context): 工具调用前触发记录开始时间。 call_id context.get(call_id, unknown) tool_name context.get(tool_name, unknown) _start_times[call_id] time.monotonic() logger.info(tool start | id%s | tool%s, call_id, tool_name) # 返回 None 表示不干预主流程 return None def on_after_tool_call(context): 工具调用后触发计算耗时并输出。 call_id context.get(call_id, unknown) tool_name context.get(tool_name, unknown) started _start_times.pop(call_id, None) if started is None: logger.warning(tool end without start | id%s, call_id) return None elapsed_ms (time.monotonic() - started) * 1000 logger.info(tool end | id%s | tool%s | %.1fms, call_id, tool_name, elapsed_ms) return None两个回调都返回None意思是“我不修改上下文只是旁路观察”。这是最安全的 Hook 写法。如果你要修改上下文比如给工具参数加一个字段需要返回一个字典具体格式以当前版本的帮助输出为准。context里有什么不同事件给的字段不同。before_tool_call和after_tool_call通常包含call_id、tool_name、arguments、result等。写回调前先用日志把context.keys()打出来看一眼比猜字段靠谱。4. 加载验证与触发调试写完不等于跑通。分两步验证先确认插件被加载再确认 Hook 被触发。4.1 加载验证# 列出当前被识别的插件确认 tool_timer 在列表里 hermes plugins list # 查看单个插件的加载详情重点看有没有报错 hermes plugins info tool_timer如果plugins list里没有tool_timer按顺序查三件事目录名和plugin.yaml里的name是否一致、enabled是否为true、YAML 缩进是否用了空格Tab 会导致解析失败。如果列表里有但info显示加载错误通常是entry指向的文件不存在或者main.py导入时报了异常。单独跑一下导入cd ~/.hermes/plugins/tool_timer python3 -c import main; print(import ok)导入通过说明代码本身没问题问题在清单配置。4.2 触发调试加载成功只是第一步回调不触发才是真正花时间的地方。开一个终端看日志# 前台运行并提高插件日志级别实时观察 Hook 触发 hermes --log-level debug run然后在另一个终端发一条会触发工具调用的消息比如让它读一个文件。预期在日志里看到[INFO] hermes.plugin.tool_timer - tool start | idcall_abc123 | toolread_file [INFO] hermes.plugin.tool_timer - tool end | idcall_abc123 | toolread_file | 12.4ms看到这两行说明你的第一个 Hook 完整跑通了。没看到的话对照下一节的排查表。4.3 一个更省事的调试技巧每次改代码都重启 Hermes 很烦。可以在回调里加一个文件开关改逻辑时不用重启进程import os def _debug_enabled(): return os.environ.get(TOOL_TIMER_DEBUG) 1 def on_before_tool_call(context): if not _debug_enabled(): return None # 调试模式下把完整上下文打出来方便确认字段名 logger.debug(context keys: %s, list(context.keys())) ...调试时export TOOL_TIMER_DEBUG1生产时去掉。这样同一份代码既能详细调试又能安静运行。5. 本篇常见错排查把最容易卡住的几个现象列出来对照处理。现象一plugins list里根本没有这个插件。原因通常是目录放错位置。Hermes Agent 读的是用户级插件目录不是当前工作目录。确认路径是~/.hermes/plugins/不是./plugins/。另外检查目录权限如果 Hermes 以其他用户身份运行读不到你的家目录。现象二插件在列表里但info报 YAML 解析错误。九成是缩进问题。YAML 不允许 Tabhooks列表的每一项缩进必须一致。把plugin.yaml贴进任意 YAML 校验器过一遍比肉眼找快。现象三加载成功回调死活不触发。按这个顺序查事件名拼写before_tool_call不是beforeToolCall、处理函数名和main.py里的def名是否逐字符一致、函数是否定义在模块顶层定义在类里或嵌套函数里加载器找不到。最直接的验证方式是在main.py顶部加一行print(plugin loaded)启动时看到这行说明模块被导入了那问题就在事件名或函数名映射上。现象四回调触发了但context里取不到想要的字段。不同版本给context的字段名可能不同。别猜用logger.debug(keys: %s, list(context.keys()))打出来。取不到时用context.get(field, default)而不是context[field]避免 KeyError 把整个回调打断。现象五回调里抛异常导致主任务失败。Hook 回调应该永远不阻断主流程。所有可能出错的操作包在 try/except 里异常只记日志不往外抛def on_after_tool_call(context): try: # 你的逻辑 ... except Exception: logger.exception(tool_timer hook failed, ignored) return None一个统计耗时的插件把用户的文件读取搞崩了这是最不该发生的事。现象六并发调用时耗时算错。如果你用单个全局变量存开始时间两个工具并发调用时会互相覆盖。上面代码用call_id做 key 的字典就是为了避免这个。记得在after回调里pop掉否则字典会一直增长。6. 把 Hook 用起来下一步做什么第一个 Hook 跑通后你会发现插件开发的门槛其实在“知道有哪些事件可以挂”和“context 里有什么字段”这两件事上代码本身不复杂。建议你接着做三件事。第一把tool_timer改成一个真正有用的插件。比如在after_tool_call里判断工具是否失败失败时把tool_name和错误摘要写到一个本地文件这样你就有了一份自己的工具失败记录比翻完整日志快得多。第二试一个会修改上下文的 Hook。比如在before_tool_call里给所有文件读取类工具的路径参数加一个前缀限制强制它们只能在你指定的工作目录内操作。这需要返回修改后的上下文字典具体格式查当前版本的接入文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite第三如果你的插件需要在 Hook 里调用模型做判断比如判断工具返回内容是否包含敏感信息把模型请求那段单独抽成一个函数用 TaoToken 的兼容接口调用Key 从环境变量读。这样插件逻辑和模型接入解耦换模型不影响 Hook 结构。写插件时我踩过最深的坑是以为 Hook 是“拦截器”可以在里面否决工具调用。实际上大多数 Hook 是观察者返回值决定的是“是否修改上下文”不是“是否放行”。想清楚这一点很多设计上的纠结就没了。最后留一个检查习惯每次加新 Hook先在只读场景下验证触发再让它接触写操作。插件的能力边界应该由你显式声明而不是由它碰巧能访问到什么决定。