本地AI Agent实战:基于Hermes模型的工具调用与任务循环设计
2026/9/8 4:17:07 网站建设 项目流程

这阵子我把日常里大量重复动作都交给了本地跑着的 hermes-agent,它从一个只有几十行代码的实验脚本,慢慢长成了我电脑上几乎每天都在用的常驻服务。如果你也在折腾 AI Agent,或者正在纠结要不要自己动手组装一个,这篇文章应该能给你一些参考。

hermes-agent 不是一个商业产品,也不是什么全新的框架,它就是用我自己的方式,把大模型、工具调用、任务规划和外部系统粘合在一块儿的智能体。核心定位非常窄:本地优先、工具可插拔、行为可配置。我选 Hermes 系列模型作为默认的“大脑”,因为它开箱即用、函数调用表现稳定,而且跑在本地就能完成大部分工作。

1. 我为什么放着现成Agent框架不用,自己攒了hermes-agent

先交代一下背景。我手头有一台不带独立显卡的迷你主机,16G 内存,日常跑着几个容器服务。最早我用的是网页版聊天工具来帮我总结文章、列计划,后来发现一个问题:所有任务都要靠我手动复制粘贴,而且数据一来一回全在云端过一遍。我想要的不是聊天,而是让一个大模型能够直接帮我调工具、查接口、整理文件,最好是本地就把事情办了。

1.1 本地跑大模型的成熟度,比很多人以为的高

很多人对本地大模型的印象还停留在“只能聊天、一复杂就乱”的阶段。实际上这两年开源模型的迭代速度非常快,尤其是 Hermes 系列这种偏指令微调、工具使用的模型,在 8B 级别就能表现出相当稳定的函数调用能力。

我最早尝试的是 7B 到 13B 的量化版本,模型格式统一用 GGUF,跑在 llama.cpp 和 Ollama 上。量化到 Q4_K_M 之后,显存占用不高,速度也能接受。关键点是,Hermes 系列在训练时加入了大量多轮对话和工具调用数据,它不像一些纯基座模型那样,让它输出 JSON 就满嘴跑火车。它知道什么时候该说话,什么时候该调工具,什么时候该停下来等结果。

一开始我也怀疑本地模型做 Agent 会不会太勉强,实测下来发现,只要把 prompt 和工具协议设计得清楚,8B 模型处理“查天气、建日程、读文件、调接口”这类任务完全够用。真正跑不动的不是模型,而是把模型和外部工具粘起来的工程。

1.2 现成框架不是不好,是我不确定改起来要花多久

我认真对比过市面上主流的 Agent 编排框架,包括 Autogen、LangGraph、CrewAI 这些。它们的思路没问题,抽象层次也很高,但对我这种“想完全掌控行为细节”的人来说,框架反而成了负担。

框架会替你决定消息怎么传递、状态怎么保存、多智能体之间怎么协作。如果只是 demo,跟着文档走很快;一旦你要做定制,比如给某些工具加独立的历史记忆,或者对敏感操作加一道人工确认,就得去翻源码看钩子在哪里。框架层加得越多,黑盒就越多,出问题的时候越难定位。

我后来想明白一件事:Agent 本身不是一个复杂的机器学习问题,它更像一个“带工具的循环调度器”。与其去适配别人的抽象,不如自己写一个足够简单的核心,把所有变量暴露在自己面前。hermes-agent 就这样诞生了:核心逻辑只有主循环、工具注册表、上下文管理、策略控制四块,没有多余概念。

1.3 为什么偏偏选 Hermes 系列当“大脑”

名字确实有点误导,好像“Hermes”这个词有什么特殊含义。对我来说它有两层意思:一是底层模型我主要用的是 Nous Research 开源的 Hermes 系列;二是希腊神话里 Hermes 本来就是神的信使,负责传递消息、执行指令,这个名字和 Agent 的定位非常契合。

模型选型上,我并不是一开始就选了 Hermes。中间试过 Qwen、Llama 和 Mistral 系列,各有各的强项。但 Hermes 在函数调用这块给我的感觉是最“省心”的:它生成的工具调用 JSON 结构干净,很少有夹带解释文字或者格式错乱的情况。对于本地小参数模型来说,这一点特别重要,因为解析容错率低,模型一旦在 JSON 前后夹带多余内容,整个 Agent 循环就会断掉。

2. hermes-agent的整体结构:一个带工具夹的规划器,而不是聊天机器人

很多人会把 Agent 理解成“更聪明的聊天机器人”,这是最大的误区。聊天机器人是“你问我答”,Agent 是“你说目标,我去执行”。所以 hermes-agent 的内部结构从一开始就不是围绕对话展开的,而是围绕“任务循环”展开的。

2.1 主循环:计划、执行、观察,然后再来一轮

Agent 的灵魂是一个循环,我用伪代码来说就是这个样子:模型根据当前情况和可用工具决定下一步动作;如果动作是调用工具,就执行工具并拿到返回值;工具结果回到模型上下文里,模型再判断任务是否完成,如果没完成就继续。

在 hermes-agent 里,这个循环被我精简成了一个run()函数,加上一个上下文对象:

def run(task: str, max_steps: int = 8): ctx = Context() ctx.system(SYSTEM_PROMPT) ctx.user(task) for step in range(max_steps): resp = llm.complete(ctx.messages, tools=registry.all_schemas()) if resp.tool_calls: for call in resp.tool_calls: ctx.assistant_tool_call(call) result = registry.execute(call.name, call.arguments) ctx.tool_result(call.id, result) else: return resp.text raise LoopExceeded("超过最大步数,任务结束")

这个循环看起来简单,但它隐含了一个重要设计:模型并不是一次性生成完整答案,而是每走一步都站在最新的工具结果上重新判断。这就像人做菜,不是提前把所有步骤都写死,而是切完菜看火候,再决定下一步放多少盐。

最大步数上限是必须的,否则模型有可能陷入无限调用。默认 8 步是我反复试出来的平衡点:足够完成大多数调用链组合,又不至于让单次任务拖太久。

2.2 工具不是写死的,而是注册出来的

hermes-agent 里面的工具,本质上就是普通函数。我设计了一个注册器,让新增工具变成“加一个函数、写一段描述、声明参数结构”三件事:

@tool( name="get_weather", description="获取指定城市的实时天气", parameters={ "type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"], }, ) def get_weather(city: str): # 调用天气 API return {"city": city, "temperature": 12, "condition": "晴"}

注册器内部会把函数包装成模型能理解的 schema,同时把所有工具统一放进一个字典。主循环执行工具时,只需要从字典里取函数、传参数、拿结果,完全不需要知道工具内部怎么实现。

这个设计的好处是“高内聚、低耦合”。我后来加了日历工具、HTTP 请求工具、文件阅读工具,每个都只需要单独写函数,不用动主循环任何代码。这就是我想要的插件化:核心永远保持简单,工具则可以无限扩展。

2.3 策略层:不是所有工具都允许模型随便调

工具能力越强,越需要一个控制层。hermes-agent 里我加了三道策略:

第一道是工具启用列表。即使代码里注册了 20 个工具,每轮对话真正暴露给模型的也只有配置里打开的那几个。模型看不到没被启用的工具,就不会乱调。

第二道是最大步数和重复检测。如果模型连续 N 轮调用同一个工具且参数没有变化,我会强制打断并提醒它直接给结论。

第三道是敏感操作确认。比如删除文件、发送消息这类有副作用的工具,我要求它必须返回一个确认标记,确认通过后才实际执行,否则只返回“等待授权”。这是惨痛教训换来的,后面讲坑的时候细说。

agent: max_steps: 8 repeat_detection: true confirm_mode: [shell, send_message]

策略层让我在“让模型自动跑”和“防止模型闯祸”之间找到了一个相对舒服的位置。

3. Hermes系列模型的Function Calling,我是这么接入的

模型选好了,循环写好了,接下来最核心的问题就是:模型怎么知道有哪些工具,工具调用了之后,怎么把结果送回模型。这块做好,Agent 才真正“活”起来。

3.1 我统一走 OpenAI 兼容工具接口,而不是让每个模型各说各话

现在很多本地推理服务,比如 Ollama、llama.cpp server、vLLM,都提供了 OpenAI 兼容的 API。这意味着我可以直接用标准tools参数把工具描述发给模型,模型返回的工具调用也会以标准 JSON 结构呈现。

我选择统一走这个协议,好处很实际:以后想换底模、换推理后端,hermes-agent 的代码几乎不用动。这不代表 Hermes 官方只支持这一种方式,而是说在我的工程实现里,把所有模型差异全部挡在了llm.complete()这一个接口后面。

具体来说,发送给模型的 messages 是一个数组,工具 schema 是另一个数组,模型内部把它们合并理解。调用代码大概是:

client = OpenAI(base_url="http://127.0.0.1:11434/v1") resp = client.chat.completions.create( model="hermes3:8b-q4_K_M", messages=messages, tools=[schemas], stop=["<|im_end|>"], )

如果模型决定调用工具,它会返回一个tool_calls数组,里面包含idnamearguments这三个关键字段。arguments本身就是 JSON 字符串,解析之后就是工具函数的入参。

3.2 一次完整的工具调用,在 hermes-agent 里长什么样

以“北京现在温度多少”这个任务为例,理想状态下的消息流转是这样:

你是 hermes-agent 的规划器。需要执行外部操作时,只输出一个 JSON 对象,不要解释。 用户提问:北京现在温度多少? 模型输出: {"name": "get_weather", "arguments": {"city": "北京"}}

注意,这里我要求模型“只输出一个 JSON 对象,不要解释”。为什么要这么严格?因为本地小模型如果允许它自由发挥,它经常会在 JSON 前后加上“好的,我来查询”之类的话。一旦混入这些内容,解析端就得多做一层清洗,清洗逻辑多了,解析稳定性的坑也就多了。

执行完工具后,hermes-agent 会把结果以tool角色的消息放回对话:

工具返回: {"city": "北京", "temperature": 12, "condition": "晴"}

然后模型基于这个新消息继续回答:“北京当前气温 12 度,晴。”整个对话历史里既保留了用户任务,也保留了工具调用记录和工具结果,模型才能拼出完整的上下文。

3.3 工具结果不是越大越好,我会先压缩再回填

刚开始接工具的时候,我犯过一个想当然的错误:把工具的完整返回结果原样塞回上下文。比如查一个接口,返回了 5000 行数据,结果模型还没开始分析,上下文窗口就先爆了,或者模型被大量无关字段干扰,完全抓不住重点。

后来我在工具执行和上下文回填之间加了一个压缩层,原则是:只保留对当前任务可能有用的关键信息,别把原始流水账全倒给模型。

def _compress_tool_result(text: str, max_len: int = 800) -> str: if len(text) <= max_len: return text return text[:max_len] + "\n...[已截断]"

有些工具返回本身就是结构化 JSON,我会用字段路径提取关键部分。比如 HTTP 请求工具返回完整 response,如果请求时指定了parse_json=True,我就从中提取dataresult字段,而不是把 headers、status、cookies 也一并塞进去。模型不需要的信息,给了反而添乱。

4. 真实运行一段时间后,我踩到的几个大坑

写完第一版的时候,我以为最难的已经过去了。结果真正跑起来才发现,让 Agent 在真实环境里稳定工作,绝大多数时间都在处理各种边界情况。我把印象最深的几个坑列出来,希望能帮你少走弯路。

4.1 模型偶尔会把工具调用包在 Markdown 代码块里

Hermes 模型训练得再好,也不是每一次都老实按约定输出。遇到提示词稍微复杂,或者模型“想帮忙”的时候,它会把工具调用包裹在 Markdown 代码块里:

好的,我来查询。 ```json {"name": "get_weather", "arguments": {"city": "北京"}}
如果你直接用 `json.loads()` 去解析这段文本,必然报错。解决这个问题,我加了一个解析兜底函数,先去掉代码块标记,再提取第一个左括号到最后一个右括号之间的内容: ```python def parse_tool_json(text: str) -> dict: text = re.sub(r"^```(?:json)?|```$", "", text.strip(), flags=re.M) text = text[text.find("{"): text.rfind("}") + 1] return json.loads(text)

别小看这个兜底。工具调用解析的成功率,决定了 Agent 循环能否持续下去。一次解析失败,整个任务就可能变成纯文本回答,或者直接报错退出。

4.2 反复调用同一个工具,像是掉进了死循环

另一个高频问题:模型会反复调用同一个工具,即使没有新的信息增量。比如它已经拿到了天气结果,下一步还是继续调get_weather,而且参数一模一样,就好像某些不完善的代码逻辑陷入了无限循环。

我在主循环里加了重复检测逻辑,统计最近几轮消息里的工具调用记录,如果发现连续 3 次以上都是同一个工具、同一组参数,就直接强制中断并给模型追加一条提示:

recent_calls = [m.tool_call.name for m in ctx.messages[-6:] if m.tool_call] if len(recent_calls) >= 3 and len(set(recent_calls)) == 1: ctx.user("你刚才已经调用过这个工具且没有新输入,禁止重复调用,直接给结论。")

这个提示挺管用。模型看到之后通常会意识到自己的问题,转而生成最终回答。你也可以理解为 Agent 需要一个“外置的自我纠错机制”,因为模型不会像人一样自然而然意识到自己在原地打转。

4.3 上下文被工具返回结果撑爆,任务越长越容易崩

上下文管理是 Agent 工程里最容易被低估的问题。单轮工具调用还好,一旦任务需要多步工具调用,每一步的结果累加起来,很快就会逼近模型的上下文窗口。

我做过一次统计:一个 6 步的任务,每步工具返回平均 1500 字,最后上下文里光是工具结果就积累了近万字,再加上原始对话和系统提示,8K 上下文的模型几乎必然超过限制。

我的解决方案是分层处理:比较新的工具结果保留原文,比较远的只保留摘要。摘要怎么生成?让模型自己用小上下文窗口做一次压缩,或者用工具结果里的标题、状态码、关键字段拼一个精简版本。工程实现上不必太精致,目的是“别把模型撑爆”,而不是“精确还原每一条原始数据”。

4.4 工具多起来之后,模型开始出现选择偏差

工具少的时候,模型基本不会选错。当我注册了十几个工具,里面既有get_weather(当天天气),又有get_historical_weather(历史天气),模型偶尔就会在应该调历史查询的时候去调了当天天气。

这种问题不怪模型,是我工具定义的锅。我给工具描述里加上了明确的区分语义和使用场景:

get_historical_weather:获取过去指定日期的天气数据,适合用于回顾、对比、统计场景。

同时在参数的 description 里加上示例值。改完之后,选择准确率明显提升。经验是:工具描述不要写得太“学术”,要写“什么情况下该用我”,模型才能把场景和工具对上号。

5. hermes-agent的落地形态:从命令行一次调用到常驻后台服务

到这一步,功能已经能跑通,但要真正融入日常,还得解决“怎么用起来方便”的问题。我不可能每次都打开终端敲命令行,所以 hermes-agent 被我拆成了三层入口:命令行、常驻服务、消息接口。

5.1 配置全部摊开,一个人也能维护一整套 Agent

所有行为参数我收敛到一个 YAML 文件里。模型地址、工具开关、步数限制、确认模式、连接器配置,全部一目了然:

model: provider: ollama name: hermes3:8b-q4_K_M base_url: http://127.0.0.1:11434 context: 8192 agent: max_steps: 8 repeat_detection: true confirm_mode: [shell, send_message] tools: enabled: [get_weather, read_file, http_request, datetime, note] connectors: - type: cli enabled: true - type: telegram enabled: true token: "你的token"

我把配置文件当成 Agent 的中控台。改模型后端、加一个工具、调高步数限制,都只需要改配置重启服务,不用动代码。

5.2 用 systemd 把它变成常驻服务

我日常跑在 Linux 迷你主机上,最省心的方式就是用 systemd 托管。写一个 service 单元文件,配置好工作目录和虚拟环境,它就能开机自启、崩溃自动重启:

[Unit] Description=hermes-agent After=network-online.target ollama.service [Service] ExecStart=/opt/hermes-agent/.venv/bin/python -m hermes_agent --config /opt/hermes-agent/hermes.yaml Restart=on-failure User=hermes WorkingDirectory=/opt/hermes-agent [Install] WantedBy=multi-user.target

启动命令就一行:

systemctl enable --now hermes-agent

Restart=on-failure很重要。模型偶尔会返回一些奇怪的参数导致工具抛异常,如果没有这个配置,Agent 挂了就是挂了,等你发现的时候半天已经过去了。

5.3 接入日常入口:消息机器人、定时任务和 HTTP 接口

常驻服务跑起来之后,我给它加了一层薄薄的消息机器人入口。比如在 Telegram 里把 hermes-agent 添加为一个机器人,给它发消息,它就能调用整套工具链再把结果返回。机器人本身只是一个连接器,真正干活的是 Agent 核心。

除了被动响应,我也做了定时任务。比如每天早上 9 点,cron 触发一行命令:让 hermes-agent 汇总当天的日程和天气,然后推送到指定对话。这本质上就是给 Agent 一个“定时任务”的输入,它会自己决定调哪些工具、按什么顺序调。

这些入口加在一起,hermes-agent 才从一个终端玩具变成一个真正参与日常工作的系统。现在它每天早上帮我整理信息,平时帮我查资料、调接口、做笔记,基本达到了我最初“本地优先、工具可插拔、行为可配置”的目标。


如果你也想复刻一个类似的 Agent,我最想留给你的一句话是:先把单轮工具调用跑通,再把循环加上,不要一上来就堆记忆、规划、多智能体协作。hermes-agent 的核心复杂度从来不在模型本身,而在于你如何处理“模型说了,但你不想让它直接执行”的情况——把确认机制、工具白名单和上下文管理做扎实,Agent 才能真正从玩具变成工具。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询