Hermes工具系统设计(安全扩展体系篇)

Hermes工具系统(下):四层纵深防御 + 零配置插件 + 常驻事件循环,构建生产级工具安全与扩展体系

工具系统是智能体的"双手",也是最危险的单点

假设你在编辑器里对Agent说:“帮我把这个项目重构一下,旧的配置文件全删了,用新的来代替。”

Agent开始执行。它先读了你的项目结构,然后逐一删除旧的配置文件,创建新的。一切正常——直到它执行了这样一条命令:

find . -name "*.yaml" -exec rm -rf {} \;

等等——它把 node_modules 下面的 .yaml 文件也删了。那些不是"旧的配置文件",是第三方库的运行时依赖。Agent没有恶意,它只是忠实地执行了你给它的terminal权限。

这就是工具系统最核心的矛盾:工具越强大,安全风险越高。 文件读写、终端执行、网络请求、浏览器操控——每一个都是双刃剑。上一篇文章讲了注册→发现→供给→执行的流水线,但流水线解决的是"怎么跑",还有四个工程硬骨头没啃:

  1. 安全:谁来拦截危险操作?单点防护被绕过怎么办?
  2. 扩展:第三方工具怎么接入?能不能不改核心代码?
  3. 异步:异步内核怎么套同步外壳?"Event loop is closed"怎么根治?
  4. 适配:Schema能不能随运行时状态动态变化,而不是静态写死?

本文将逐一拆解这四个工程体系。


一、四层纵深防御:单点防护不可靠,层层兜底才安全

安全不是一堵墙,而是多层过滤网

纵深防御(Defense in Depth)是安全领域的核心架构思想。它的意思是不依赖单一安全措施,而是层层设防,每一层都有独立的拦截能力。

就像银行不只靠一把锁——大门有门禁,金库有密码锁,保险柜有钥匙,监控24小时录像。任何一层被突破,下一层还能拦住。

在软件系统里,纵深防御的工程价值在于容错。如果安全只靠一道墙,这道墙一旦被绕过(代码bug、配置错误、零日漏洞),攻击者就直接拿到了系统的完整访问权限。多层防御让单点故障不会变成系统性灾难。

Hermes为此构建了四层防线:

LLM 发起工具调用

第一层:check_fn 门控
环境不满足?工具直接不出现

第二层:危险命令黑名单
rm -rf / ?即使工具启用也拦截

第三层:pre_tool_call 前置钩子
插件自定义风控,每次执行前触发

第四层:ACP 操作审批机制
write_file / patch 需要人工确认

工具执行

逐层拆解。

第一层:check_fn 门控——最彻底的防护是"不存在"

check_fn 是每个工具在注册时绑定的零参数布尔校验函数。它的职责是动态检测工具运行环境的可用性——Docker是否安装?API密钥是否配置?权限是否满足?

registry.register(
    name="terminal",
    toolset="system",
    schema={...},
    handler=terminal_handler,
    check_fn=lambda: shutil.which("docker") is not None,  # 没装Docker?工具直接不可见
)

check_fn 返回 False 时,工具从Schema列表中完全移除。LLM根本不知道这个工具存在。

这是最彻底、最轻量化的防护手段:工具未暴露,LLM就无法发起调用。 没有API密钥?第三方接口工具不出现在工具列表里。没装Docker?终端执行工具不存在。不是通过报错来拦截,而是连调用入口都不给。

第二层:危险命令黑名单——工具启用不代表命令安全

有些工具必须暴露给LLM(比如terminal),但里面的部分命令绝对不能执行。Hermes在 terminal_tool.py 中内置了危险命令检测逻辑,实时拦截高风险操作:

  • rm -rf / —— 系统文件批量销毁
  • curl | sh —— 远程脚本直接执行
  • 批量文件删除、系统配置篡改、端口监听劫持

工具可用,不代表里面的命令都能执行。黑名单是工具级别的二次过滤——check_fn通过了(Docker可用),但执行命令时还要核验。

第三层:pre_tool_call 前置钩子——插件可自定义拦截

pre_tool_call 是一个全局前置钩子,在每次工具执行前触发。它的作用是让插件实现自定义安全校验逻辑:

  • 操作日志审计:记录每次工具调用的完整上下文
  • 调用频次限流:同一工具10秒内调用超过5次?拦截
  • 特殊场景拦截:深夜时段禁止执行terminal命令
  • 自定义风控规则:接入企业内部的审批工作流
# 插件示例:自定义时间窗口限流
def pre_tool_call_hook(tool_name, args):
    if tool_name == "terminal" and is_outside_business_hours():
        return {"blocked": True, "reason": "非工作时间禁止执行终端命令"}
    return {"blocked": False}

registry.register_pre_tool_call_hook(pre_tool_call_hook)

插件可以返回阻止消息,直接中止工具执行。这层防线的核心价值是可扩展性——安全规则不是写死在框架里的,而是可以随业务需求动态演进的。

第四层:ACP 操作审批机制——人机协同的最后兜底

前三层都是自动化的。但如果都放行了,还有最后一关——人工审批。

在编辑器集成模式下,write_filepatch 这两个高危编辑操作需要用户手动点击确认才能执行。这不是配置层面的限制(不是"你别配这个工具集就行了"),而是交互层面的强制审批链。Agent不能自主决定写入文件——它必须请求,用户必须批准。

用户(编辑器)Hermes EngineLLM用户(编辑器)Hermes EngineLLM调用 write_file(path="/etc/config.yaml")前三层防线全通过审批请求:"Agent 要修改 /etc/config.yaml,是否批准?"点击确认执行写入返回成功结果

四层防线的核心思想:安全不是一堵墙,而是多层过滤网。任何单层被绕过,下一层还在。突破一层,还有一层。

第四层:ACP 审批机制

第三层:pre_tool_call 钩子

第二层:命令黑名单

第一层:check_fn 门控

是,绕过

否,绕过

否,绕过

危险调用请求

环境满足?

工具不出现在 Schema 中
LLM 无调用入口

命中黑名单?

拦截:rm -rf / 等
即使工具启用也不执行

插件风控通过?

插件返回阻止消息
工具执行中止

用户批准?

人工拒绝
高危操作未授权

工具执行


二、插件扩展:零配置接入,天然继承全套管控

2.1 为什么插件工具不应该"特殊"?

很多框架为插件工具设计了单独的注册路径:内置工具走一套,第三方插件走另一套。这带来两个问题:

第一,管控分裂。内置工具有安全检查,插件工具未必有。内置工具的返回格式做了标准化,插件工具可能自己发明一套格式。

第二,接入成本高。开发者要阅读"插件开发文档",学习另一套API——本质上是因为框架没有把插件当作一等公民。

Hermes的思路非常直接:插件工具和内置工具走完全相同的路径。 相同的注册API、相同的分发管道、相同的安全检查。

2.2 一个完整插件只需三步

自定义工具连创建一个类都不需要。一个Python文件,一个 registry.register() 调用,完成:

# my_plugin/weather.py
from tools.registry import registry, tool_error, tool_result
import os, requests

def weather_tool(args):
    city = args.get("city", "Beijing")
    api_key = os.environ.get("WEATHER_API_KEY")
    if not api_key:
        return tool_error("WEATHER_API_KEY not configured")
    resp = requests.get(f"https://api.weather.com/v1?city={city}&key={api_key}")
    return tool_result({"city": city, "temp": resp.json()["temp"]})

registry.register(
    name="get_weather",
    toolset="my-plugin",              # 工具集名自动识别,无需改 toolsets.py
    schema={
        "name": "get_weather",
        "description": "Get current weather for a city",
        "parameters": {
            "type": "object",
            "properties": {
                "city": {"type": "string", "description": "City name"}
            },
            "required": ["city"],
        },
    },
    handler=weather_tool,
    check_fn=lambda: bool(os.environ.get("WEATHER_API_KEY")),
)

不需要改 toolsets.py(工具集名在注册表中自动识别),不需要改 model_tools.py(分发自动路由),不需要改任何核心代码。启动时 discover_plugins() 自动扫描插件目录,导入所有插件模块,注册自动完成。

开发者创建
my_plugin/weather.py

文件中调用
registry.register(...)

放置到 plugins/ 目录下

服务启动

discover_plugins()
自动扫描 plugins/ 目录

导入所有插件模块

registry.register() 执行
工具加入注册表

工具立即可用
与内置工具走同一管道

2.3 插件天然继承的全部能力

因为走的是同一套注册→分发→执行管道,插件工具无需额外配置即自动获得:

能力说明插件开发者要做什么?
参数矫正LLM输出的非标准参数自动转换什么都不用做
异步桥接同步/异步自动适配什么都不用做
四层安全防护check_fn + 黑名单 + 前置钩子 + ACP审批写一个 check_fn 即可
错误脱敏异常信息自动清洗,不泄露堆栈tool_error() 返回错误
结果容量管控超长结果自动截断设置 max_result_size_chars 即可
缓存失效注册表generation变更时缓存自动刷新什么都不用做

插件工具

内置工具

共享执行管道

参数矫正

四层安全防护

统一路由分发

错误脱敏

file/read_file

terminal/run_command

my-plugin/get_weather

这意味着插件开发者只需要关注核心业务逻辑(调用天气API并解析结果),其余所有通用管控能力由框架统一提供。不是"功能少所以简单",而是"框架帮你做了你本来就该做但容易忘的事"。


三、异步同步桥接:常驻事件循环根治"Event loop is closed"

3.1 矛盾的本质

Hermes的工具在LLM视角下是同步的:调用、等待结果、继续执行。但工具底层大量基于异步库实现——httpx发HTTP请求、AsyncOpenAI调API。这是两组相反的编程模型:同步的外壳怎么套住异步的内核?

Python里最直观的异步桥接方式是 asyncio.run()

def sync_wrapper(*args, **kwargs):
    return asyncio.run(async_handler(*args, **kwargs))

这在一次性脚本里能用,但在长期运行的Gateway进程里会触发一个隐蔽但致命的Bug:Event loop is closed。

原因很简单:asyncio.run() 每次调用都创建一个新的事件循环,执行完就关掉。但异步HTTP客户端(比如httpx的AsyncClient)在创建时绑定了特定的事件循环。循环一关,客户端还持有对已关闭循环的引用,下次调用时发现循环已经不存在了——抛异常。

3.2 Hermes的方案:常驻事件循环 + 线程隔离

Hermes的答案是 _run_async() 桥接函数,核心思路:保持循环常驻不关闭。

它针对三种不同的调用场景做了适配:

  • 场景一:已在异步上下文中(如asyncio驱动的Gateway主循环)——直接 await,零开销
  • 场景二:同步上下文 + 主线程——获取或创建常驻事件循环,在新线程中运行协程
  • 场景三:同步上下文 + 非主线程——每个子线程维护自己的常驻循环,线程间隔离
# 概念示意(非完整源码)
def _run_async(coro):
    try:
        loop = asyncio.get_running_loop()  # 已经在异步上下文中
    except RuntimeError:
        # 同步上下文:走常驻循环
        loop = get_or_create_persistent_loop()
        return loop.run_until_complete(coro)
    return coro  # 由调用方 await

关键细节在于 get_or_create_persistent_loop()——它不是每次调用都新建循环,而是复用已存在的。循环创建了就一直在那,直到进程退出才关闭。异步客户端绑定的循环始终有效,不会有"循环已关闭"的问题。

同时,不同线程拥有独立的事件循环,互不干扰。这规避了 asyncio.run() 的另一个坑:在主线程创建的协程不能从子线程的循环中调度。线程隔离让每个线程的异步资源完全自治。

场景一:是

场景二/三:否

主线程

子线程

工具被调用
_run_async(coro)

asyncio.get_running_loop()
当前是否在异步上下文中?

直接 await coro
零开销

当前在哪个线程?

获取/创建主线程常驻循环
run_until_complete(coro)

获取/创建子线程专属常驻循环
run_until_complete(coro)

返回结果

3.3 为什么这个问题值得单独一章

因为它是Demo里永远测不出来、但生产环境必现的Bug。开发阶段工具调用频率低,异步客户端存活时间短,asyncio.run() 看起来没毛病。上了生产,长期运行的进程里,第100次调用突然抛 “Event loop is closed”——排查成本远高于设计成本。

Hermes在异步桥接上的设计投入,体现了一个工程原则:基础设施层的问题要在基础设施层解决,不能让每个工具开发者自己去踩坑。


四、运行时动态适配:Schema不能是静态写死的

4.1 静态Schema的三个失效场景

工具的Schema定义工具的名称、参数、类型、描述——LLM根据它来理解和调用工具。如果Schema写了不存在的能力,LLM就会幻觉调用它,然后报错。

静态Schema在三个典型场景下失效:

场景一:沙箱工具的可用依赖随工具集变化。 execute_code 工具允许在沙箱内调用其他Hermes工具。Schema里写着"你可以用 web_search 搜索资料"。但如果 web 工具集被禁用了,这句话就变成了误导——LLM会尝试调用一个不存在的工具。

场景二:Discord Bot的可用操作随账号权限变化。 Bot的管理权限决定了它能执行哪些操作。如果用户的Bot没有"管理消息"权限,Schema中就不该出现 delete_message 参数。

场景三:跨工具引用随启用状态变化。 browser_navigate 的Schema写着"获取页面内容后,建议使用 web_search 做补充搜索"。如果 web 工具集没启用,这行建议就会把LLM引向一条死胡同。

场景三:跨工具引用失效

web 工具集未启用

browser_navigate Schema
建议使用 web_search

LLM 被导向死胡同

无效调用 + Token 浪费

场景二:权限动态变化

Discord Bot 无管理权限

Schema 仍包含 delete_message

LLM 尝试删除消息

报错:权限不足

场景一:沙箱依赖漂移

web 工具集被禁用

execute_code 的 Schema
仍描述 web_search 可用

LLM 幻觉调用 web_search

报错:工具不存在

核心原则一句话:LLM看到的工具能力,必须等于系统实际可用的能力,不能多也不能少。

4.2 dynamic_schema_overrides:运行时覆盖机制

Hermes通过 dynamic_schema_overrides 回调函数解决这个问题。它是工具注册时的一个可选字段,在每次生成Schema定义时被调用,可以对静态Schema做运行时覆盖:

def execute_code_schema_override(base_schema):
    """根据当前启用的工具集,动态调整沙箱内可用工具的描述"""
    enabled_tools = get_current_enabled_tools()
    # 如果 web_search 没启用,从沙箱的可用工具描述中移除相关引用
    if "web_search" not in enabled_tools:
        base_schema["description"] = base_schema["description"].replace(
            "You can use `web_search` to find information.", ""
        )
    return base_schema

registry.register(
    name="execute_code",
    toolset="code",
    schema={...},
    handler=execute_code_handler,
    dynamic_schema_overrides=execute_code_schema_override,
)

这不是"改一版Schema、部署重启、全局生效"的静态模式。它是每次 get_tool_definitions() 被调用时(即每个Agent循环开始时),根据实时状态动态计算。配置变了,Schema自动跟着变,无需重启。

4.3 设计哲学:Schema是运行时能力快照,不是编译期常量

传统思维把Schema当作代码的一部分——和handler绑定,写死了就是写死了。Hermes把Schema视为系统运行时能力的实时快照——它描述的是"当下这一刻你能做什么",而非"理论上你能做什么"。

这个区别很小,但影响很大。静态Schema → LLM拿到过时信息 → 调用失败 → 重试 → Token浪费。动态Schema → LLM拿到精确信息 → 调用成功 → 零浪费。一正一负,在高频调用场景下累积效应显著。

Hermes动态Schema

运行时快照

LLM获取精确信息

准确调用

一次成功

传统静态Schema

编译期固定

LLM获取过时信息

幻觉调用

报错 + 重试


五、故障排查速查表:基于生命周期的链路定位

四篇讲完,最后给一套可复用的排查规范。99%的工具类问题可以按以下链路定位:

故障现象优先排查排查清单
工具完全不可见注册层启动日志有无 Could not import tool module?文件里有没有 registry.register() 调用?是否触发命名冲突被拒绝注册?
工具不可见但注册正常供给层check_fn 返回了 True 吗?工具集被启用了吗?被禁用工具集抵消了吗?缓存有没有失效?
工具调用参数错误执行层①Schema参数类型定义对吗?coerce_tool_args() 被执行了吗?LLM原始输出参数是什么类型?
“Event loop is closed”桥接层是否误用了 asyncio.run()?应统一替换为 _run_async()
工具结果异常执行层②handler执行有没有异常?_sanitize_tool_error() 是不是把关键错误信息脱敏掉了?

每条排查路径对应流水线的一个环节,形成标准化的定位思路。


小结:工具系统的四重工程保障

把上篇和下篇串在一起看,Hermes工具系统的完整架构分为两大半场:

上半场——核心流水线(上篇)

职责核心问题
注册层“有什么”代码即配置,自注册 + 单向依赖链
发现层“怎么找”AST预扫描,先检查后导入
供给层“推什么”共享基线 + 组合模式 + 双向开关
执行层“怎么跑”七阶段标准化流水线

下半场——工程保障(下篇)

体系职责核心问题
四层纵深防御“什么不能跑”单点失效时下一层兜底
零配置插件“怎么扩展”插件 = 内置工具,同一套管道
常驻事件循环“异步怎么桥接”循环不关,Demo和生产都不报错
动态Schema适配“能力怎么同步”LLM看到的 = 系统实际可用的

这八部分共同构成了一个结论:工具系统不是工具组件的集合,而是一条从注册到执行的完整流水线,外加四重工程保障体系。 注册层不知道工具集的存在,工具集不知道安全检查的存在,安全检查不知道异步桥接的存在——职责拆开,各管各的。改一层不影响其他层。

这就是工程化:不是把功能堆在一起,而是把职责拆开来。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值