把对话模型接入真实业务时,最常遇到的瓶颈是:模型只会聊天,不会干活。它能写出漂亮的回答,却无法帮你查天气、查订单、计算价格、下发工单。网上 Agent Demo 很多,但大多停在“会聊天”的层面,真正要落地时,你会发现还需要补上工具调用、结果回填、异常处理这一整套工程逻辑。本文围绕开源项目 NousResearch / hermes-agent 做一次完整实战拆解,从 Hermes 模型本地部署到手写工具调用循环,最终构建一个能查天气、能算数的最小 Agent。适合刚接触 Agent 开发的后端工程师、算法工程师和学生,文章中每一段代码都可以直接复制运行,关键步骤会解释为什么这么做。
为了不把文章写成一份简单的“工具使用说明书”,我会把重点放在 Agent 的核心机制上,也就是模型是如何决定调用哪个工具、参数如何解析、工具结果如何回传、循环何时终止。读完本文,你不仅能跑通一个 Demo,还能理解主流 Agent 框架背后的基本数据流,之后再去读 hermes-agent 源码或者集成 LangChain、LlamaIndex,都会轻松不少。
1. hermes-agent 是什么:从对话模型到任务助手
1.1 NousResearch 与 Hermes 模型
NousResearch 是一个围绕开放模型训练与开源工具链的 AI 研究组织,提起它大家最熟悉的应该是 Hermes 系列模型。这个系列的特点是:在基础模型之上做高质量指令微调,让模型更擅长理解用户的真实意图,并且在社区里比较受关注的能力包括严格的指令遵循和工具调用支持。
以 Hermes 3 为例,它针对 agent 和 function calling 场景做了专门优化。简单说,普通对话模型接收到“帮我查一下北京的天气”时,只能生成一段文字回答;而经过工具调用微调的 Hermes 模型,会在回答里输出一个结构化的 JSON,描述它想调用哪个函数、传入什么参数。我们要做的就是在应用层把这个 JSON 解析出来,真正去执行函数,再把结果喂回给模型。
hermes-agent 这个项目,本质上就是把这条链路工程化的产物。模型负责“思考”,应用层负责“执行”,两者通过标准的 tool call 协议通信。本文不替代官方源码阅读,而是先按这个思路从零实现一遍最小版本,当你理解了核心机制后再去看项目源码,会清楚很多。
1.2 Agent 与传统对话的差异
传统聊天机器人的工作流程非常简单:用户输入 -> 模型生成 -> 返回文本。这种模式适合问答、写作、翻译,但无法完成任何需要外部系统参与的实时任务。
Agent 的完整循环则包含更多环节:
- 用户输入任务目标。
- 模型根据任务判断是否需要调用工具。
- 如果需要,模型输出结构化工具调用请求,而不是普通文本。
- 应用层解析请求,执行对应的本地函数或 API。
- 工具执行结果作为一条新的消息回传给模型。
- 模型基于工具结果继续推理,要么调用下一个工具,要么生成最终回答。
这个循环会一直持续,直到模型认为任务已经完成,不再产生新的工具调用请求。所以,Agent 不是一个新的模型,而是一种“模型 + 工具 + 控制循环”的组合架构。hermes-agent 的核心价值就是把这层控制循环封装得清晰、稳定、可扩展。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与版本说明
2.1 基础环境
本文示例在本地开发机上运行,不依赖云服务,操作系统可以是 Windows、macOS 或 Linux。建议环境如下:
| 组件 | 建议版本 | 说明 |
|---|---|---|
| Python | 3.10 及以上 | 本文代码用到类型注解和 f-string,Python 3.8 以下不建议 |
| pip | 最新稳定版 | 用于安装 Python 依赖 |
| Ollama | 最新稳定版 | 用于本地部署 Hermes 模型,并提供 OpenAI 兼容接口 |
| openai SDK | 1.x | 仅用于连接 Ollama 的本地端点,不是调用 OpenAI 云服务 |
版本需要根据你的项目实际情况调整。尤其是 Ollama 和 openai SDK 迭代很快,如果后续接口有变化,以官方文档为准,本文重点演示的是配置思路和排错方法。
2.2 部署可用的 Hermes 模型服务
第一步是让本机具备一个可调用的模型服务。最省事的方式是使用 Ollama。安装完成后,先在终端里确认服务已经启动。
bash复制# 查看 Ollama 服务状态,正常会返回模型列表 JSON
curl http://localhost:11434/v1/models
然后搜索并拉取 Hermes 模型。不同时间点 Ollama 模型库里的标签可能有差异,建议先搜索再拉取。
bash复制# 搜索模型库中的 Hermes 模型
ollama search hermes
# 根据搜索结果拉取对应模型,示例命令如下
ollama pull nous-hermes3
拉取完成后,可以用 ollama list 查看本地已有模型,记住模型名称,后面 Agent 代码里需要用到。如果你本机性能有限,可以选择更小的量化版本,不过工具调用能力可能会受量化程度影响,建议优先用 Q4 以上精度。
这里说明一下,为什么要让本地 Ollama 提供 OpenAI 兼容接口。因为 OpenAI 的 chat.completions 接口已经成为事实标准,支持 tools 参数,模型可以返回结构化 tool call。Ollama 在 http://localhost:11434/v1 路径下实现了这套兼容接口,所以我们只需安装一个 openai Python SDK,改一下 base_url 就能直接使用,不需要自己封装 HTTP 请求。
3. Agent 工作循环原理拆解
3.1 一次工具调用的完整数据流
在写代码之前,先把数据流画在脑子里。假设用户提问:“北京天气怎么样?顺便计算 (12.5 + 3.5) * 2 的结果。”
第一轮请求发出时,消息列表包含 system 提示词和用户问题,同时携带两个工具描述:get_weather 和 calculator。模型看到问题后,认为需要调用工具,于是返回一个 assistant 消息,里面不包含最终回答,而是包含 tool_calls 字段,例如:
json复制{
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\": \"北京\"}"
}
},
{
"id": "call_2",
"type": "function",
"function": {
"name": "calculator",
"arguments": "{\"expression\": \"(12.5 + 3.5) * 2\"}"
}
}
]
}
应用层要做的事情是:把这个 tool_calls 解析出来,逐个执行对应的 Python 函数,再把函数结果包装成 role: "tool" 的消息,携带同一个 tool_call_id 回传给模型。模型收到工具结果后,才能生成基于真实数据的最终回答。
如果模型第一轮没有返回 tool_calls,说明它认为不需要调用工具,循环直接终止,返回普通文本。
3.2 为什么模型能“调用”工具
很多初学者会有一个误解,认为模型真的能执行代码。实际上模型没有执行能力,它只是在训练阶段学习了“当用户需要外部信息时,应该输出一个特殊 JSON 结构”这一模式。真正执行代码的是应用层 Python 函数。
这意味着三件事:
- 模型是否调用工具,取决于训练质量和工具描述是否清晰。
- 模型返回的参数不保证 100% 合法,应用层必须做 JSON 解析和参数校验。
- 工具执行过程中可能出错,应用层需要把错误信息回传给模型,让它尝试修正或告知用户。
理解这个机制后,你就能明白为什么 Agent 项目的大量工作不在模型本身,而在工具定义、消息管理、异常处理上。这恰恰是本文要重点演示的部分。
4. 完整实战:从零实现 hermes-agent 最小工程
接下来进入核心环节。我们会创建一个小型项目,包含工具层和 Agent 主循环。项目结构很简单,但逻辑完整,你可以在此基础上扩展真实业务工具。
4.1 创建项目结构
在任意目录下创建项目文件夹,例如 hermes-agent-demo:
code复制hermes-agent-demo/
├── agent.py
├── tools.py
└── requirements.txt
每个文件的职责:
tools.py:定义工具函数、工具 JSON Schema、工具路由分发函数。agent.py:实现 Agent 主循环,负责调用模型、解析工具请求、回填结果。requirements.txt:声明 Python 依赖。
4.2 实现工具层
先写 requirements.txt:
code复制openai>=1.0.0
安装依赖:
bash复制pip install -r requirements.txt
然后编写 tools.py。这里定义两个演示工具:一个是模拟天气查询,另一个是安全计算器。工具函数本身不复杂,关键是为每个工具定义一份 JSON Schema,这是模型判断是否调用该工具的依据。
python复制# 文件路径:hermes-agent-demo/tools.py
import datetime
from typing import Any, Dict
def get_weather(city: str) -> Dict[str, Any]:
"""
模拟天气查询工具。
演示环境不直接请求第三方天气服务,而是返回稳定可控的模拟数据。
接入真实业务时,把这里替换为已获得授权的天气 API 调用即可。
"""
return {
"city": city,
"temperature": "26℃",
"condition": "多云",
"humidity": "45%",
"wind": "东南风 3 级",
"updated_at": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}
def calculator(expression: str) -> float:
"""
安全计算器:只允许数字、四则运算符、小括号和点号。
注意:示例使用 eval 仅为演示,实际生产环境不要直接使用 eval,
推荐使用 simpleeval 或自研表达式解析器,避免注入风险。
"""
allowed = set("0123456789+-*/(). ")
for ch in expression:
if ch not in allowed:
raise ValueError(f"表达式包含非法字符:{ch}")
# 仅用于演示;生产环境请替换为 simpleeval / 自研解析器
result = eval(expression, {"__builtins__": {}}, {})
return float(result)
# 工具 Schema:模型看到这份 JSON 才知道有哪些工具可用
TOOL_SCHEMAS = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询指定城市的当前天气信息,包括温度、天气状况、湿度和风力。",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市名称,例如:北京、上海、广州、成都",
}
},
"required": ["city"],
},
},
},
{
"type": "function",
"function": {
"name": "calculator",
"description": "计算数学表达式的值,支持加减乘除和括号,例如 (12.5 + 3.5) * 2。",
"parameters": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "四则运算数学表达式,例如:3 * (2 + 1)",
}
},
"required": ["expression"],
},
},
},
]
def dispatch_tool(name: str, arguments: Dict[str, Any]) -> Any:
"""根据工具名把参数路由到具体实现。"""
if name == "get_weather":
return get_weather(**arguments)
if name == "calculator":
return calculator(**arguments)
raise ValueError(f"未知工具:{name}")
代码中 dispatch_tool 是一个简单的路由分发函数,后续新增工具时,只需要在 TOOL_SCHEMAS 中追加描述,并在 dispatch_tool 中增加一个分支即可。工具结果统一使用字典或基础类型返回,Agent 主循环会负责序列化成 JSON。
4.3 实现 Agent 主循环
接下来是核心文件 agent.py。主循环的逻辑可以拆成几个步骤:
- 组装 messages,首轮包含 system 提示词和用户问题。
- 调用模型接口,传入工具列表。
- 判断返回结果中是否包含
tool_calls。 - 如果不包含,输出最终回答并结束。
- 如果包含,逐个执行工具,把结果以
role=tool的消息回填。 - 带着新的消息列表继续请求模型,直到达到最大步数。
python复制# 文件路径:hermes-agent-demo/agent.py
import json
import sys
from typing import Any, Dict, List, Optional
from openai import OpenAI
from tools import TOOL_SCHEMAS, dispatch_tool
# 根据实际拉取的模型名称修改
MODEL_NAME = "nous-hermes3"
BASE_URL = "http://localhost:11434/v1"
API_KEY = "ollama" # 本地 Ollama 端点不校验密钥,占位即可
SYSTEM_PROMPT = (
"你是一个任务助手。当用户的问题需要实时数据、数学计算或外部系统能力时,"
"请优先调用可用工具;工具调用必须按 JSON 规范输出参数。"
"拿到工具结果后,用简洁的中文回答用户,不要编造工具未返回的信息。"
)
client = OpenAI(base_url=BASE_URL, api_key=API_KEY)
def dump_tool_calls(tool_calls) -> Optional[List[Dict[str, Any]]]:
"""把 SDK 返回的 tool_calls 对象转成标准字典,便于后续回传模型。"""
if not tool_calls:
return None
return [
{
"id": tc.id,
"type": tc.type,
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments,
},
}
for tc in tool_calls
]
def run_agent(user_message: str, max_steps: int = 5) -> Optional[str]:
messages: List[Dict[str, Any]] = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_message},
]
for step in range(1, max_steps + 1):
print(f"\n===== 第 {step} 步 =====")
response = client.chat.completions.create(
model=MODEL_NAME,
messages=messages,
tools=TOOL_SCHEMAS,
)
assistant_msg = response.choices[0].message
assistant_content = assistant_msg.content or ""
tool_calls = dump_tool_calls(assistant_msg.tool_calls)
# 把 assistant 回复写回消息链
assistant_entry: Dict[str, Any] = {
"role": "assistant",
"content": assistant_content,
}
if tool_calls:
assistant_entry["tool_calls"] = tool_calls
messages.append(assistant_entry)
if not tool_calls:
print(f"模型最终回答:{assistant_content}")
return assistant_content
print(f"识别到 {len(tool_calls)} 个工具调用请求")
for tool_call in tool_calls:
fn_name = tool_call["function"]["name"]
raw_args = tool_call["function"]["arguments"]
try:
fn_args = json.loads(raw_args)
except json.JSONDecodeError as e:
fn_args = {}
print(f"参数 JSON 解析失败:{e},原始内容:{raw_args}")
print(f"调用工具:{fn_name},参数:{fn_args}")
# 工具异常不能中断整个 Agent,而是作为错误信息回传模型
try:
result = dispatch_tool(fn_name, fn_args)
except Exception as e:
result = {"error": str(e)}
print(f"工具执行异常:{e}")
messages.append({
"role": "tool",
"tool_call_id": tool_call["id"],
"content": json.dumps(result, ensure_ascii=False),
})
print(f"达到最大步数 {max_steps},结束循环。")
return None
if __name__ == "__main__":
question = sys.argv[1] if len(sys.argv) > 1 else "北京天气怎么样?顺便计算 (12.5 + 3.5) * 2 的结果"
run_agent(question)
这段代码里有两个细节值得留意。
第一,assistant 消息回传时必须保留 tool_calls 字段。因为 OpenAI 兼容协议要求:如果 API 响应里包含 tool_calls,那么在后续请求的 messages 中,这条 assistant 消息也必须原样带上 tool_calls,否则接口会报错或者模型无法正确关联工具调用。dump_tool_calls 就是把 SDK 对象转换成纯字典,避免序列化问题。
第二,工具执行使用 try / except 包裹,异常不会导致程序崩溃,而是被封装成 {"error": "..."} 回传给模型。这样模型就知道工具执行失败了,它可以尝试换一种方式调用,或者向用户解释错误原因。这是 Agent 工程里很重要的容错设计。
4.4 运行与验证
在 hermes-agent-demo 目录下执行:
bash复制python agent.py
程序会使用默认问题启动。预期输出的大致流程如下:
code复制===== 第 1 步 =====
识别到 2 个工具调用请求
调用工具:get_weather,参数:{'city': '北京'}
调用工具:calculator,参数:{'expression': '(12.5 + 3.5) * 2'}
===== 第 2 步 =====
模型最终回答:北京当前多云,气温 26℃,湿度 45%,东南风 3 级。
(12.5 + 3.5) * 2 的计算结果是 32.0。
实际输出可能会因为模型生成策略不同而略有差异。有些情况下模型会分两步执行工具,先查天气,再计算,这是正常的。你可以通过命令行参数测试其他问题:
bash复制python agent.py "帮我查一下上海的天气"
python agent.py "计算 99 * 99 的结果"
如果模型始终不调用工具,而是直接生成回答,请先排查工具描述是否清晰、模型是否支持 function calling,以及是否真的传入了 tools 参数。这些会在第 6 节详细说明。
5. 让模型更愿意调用工具的调优技巧
5.1 写好工具描述
工具描述直接决定模型是否选择调用它。描述太宽泛,模型不知道什么时候该用;描述太窄,模型可能漏掉合适的场景。好的工具描述应该包含三个信息:
- 工具解决什么问题。
- 什么场景下必须使用这个工具。
- 参数的含义和取值范围。
例如天气工具,如果只写“查询天气”,模型可能无法判断参数里要填城市名。改成“查询指定城市的当前天气信息,包括温度、天气状况、湿度和风力”,模型就清楚多了。
5.2 示例参数与返回结构
parameters 里的 description 同样重要。给参数提供示例值,能显著降低模型生成非法参数的概率。
json复制"city": {
"type": "string",
"description": "城市名称,例如:北京、上海、广州、成都"
}
工具返回的结果也应该保持结构稳定。Agent 主循环会把工具结果序列化成 JSON 字符串再回传给模型,如果返回结构每次都不一样,模型在第二步推理时可能无法正确提取信息。建议工具结果统一使用字典,并且包含关键字段名,例如:
json复制{
"city": "北京",
"temperature": "26℃",
"condition": "多云"
}
5.3 System Prompt 引导
除了工具描述,System Prompt 也会影响模型行为。如果模型倾向于直接回答而不是调用工具,可以在 System Prompt 里加一句明确指令:
text复制你是一个任务助手。当用户的问题需要实时数据、数学计算或外部系统能力时,
请优先调用可用工具;工具调用必须按 JSON 规范输出参数。
拿到工具结果后,用简洁的中文回答用户,不要编造工具未返回的信息。
这段提示词的核心作用是缩小模型的选择空间,让它明确“实时信息必须查工具,不要凭记忆瞎编”。在 agent 类项目中,System Prompt 和工具描述需要反复迭代调优,不是一次就能写好的。
6. 常见问题与排查思路
6.1 高频问题速查表
| 问题现象 | 常见原因 | 排查思路 |
|---|---|---|
| 模型始终不调用工具,直接生成文字回答 | 模型不支持函数调用、工具描述太模糊、未传入 tools 参数 |
确认模型支持 tool calling;优化工具 description;检查请求日志里是否有 tools |
JSONDecodeError 参数解析失败 |
模型返回的 arguments 不是合法 JSON,或被上下文截断 |
打印原始 raw_args 定位问题;增加最大 token;给参数提供更具体的示例 |
| 请求报 404 / model not found | Ollama 中不存在对应模型名,或模型名写错 | 执行 ollama list 查看本地模型;修改 MODEL_NAME 对齐 |
| 接口返回 400,提示 tool_call_id 问题 | assistant 消息未正确回传 tool_calls,或 tool 消息与 assistant 消息不匹配 |
检查消息序列是否符合 OpenAI 协议;确认 assistant 消息携带 tool_calls |
| 模型回答被截断 | 上下文窗口或输出 token 限制过小 | 调整 Ollama 的 num_ctx 参数;或对历史消息做裁剪 |
| 中文输出乱码 | 终端编码问题 | Windows 终端执行 chcp 65001;确保 Python 文件保存为 UTF-8 |
这里的多数问题,根源都在消息链格式和模型选择上。只要严格按照 OpenAI 兼容协议组装 messages,并在本地选用支持 function calling 的模型,就能避开大部分坑。
6.2 从日志定位 Agent 调用链问题
当 Agent 行为不符合预期时,最有效的排错方式不是猜,而是打印每一步的消息结构。建议在 run_agent 里临时加上日志,输出每一轮收到的 assistant 消息和工具结果:
python复制print(json.dumps(messages, ensure_ascii=False, indent=2))
重点观察三处:
- 模型是否返回了
tool_calls。 - 工具结果的
content是否是合法 JSON。 - 下一轮请求时,assistant 消息的
tool_calls是否原样保留了。
如果模型没有返回 tool_calls,说明问题在模型或工具描述;如果工具结果回传后模型仍然重复调用同一个工具,说明工具结果不够明确,模型没有从中提取到足够信息来生成最终答案。
7. 最佳实践与工程建议
7.1 工具层安全边界
工具是 Agent 暴露给模型的操作入口,权限边界必须严格设计。本文演示的计算器使用了 eval,虽然做了字符白名单,但生产环境不建议直接使用。更稳妥的方案是使用 simpleeval 库,或者自研一个支持四则运算和括号的表达式解析器。
对于真实业务里的工具,例如发送邮件、修改数据库、执行命令,一定要遵循最小权限原则。工具内部使用只读账号或独立服务账号,任何有副作用的操作都必须在执行前二次确认并记录审计日志。
7.2 异常处理与重试
工具执行可能因为网络超时、参数非法、依赖服务不可用而失败。Agent 的容错策略是:把错误信息回传给模型,而不是直接抛给用户。模型可以基于错误信息调整参数重新调用,或者向用户说明失败原因。
对于第三方 API 调用,建议在工具函数内部增加超时控制和有限次数的重试:
python复制import requests
try:
resp = requests.get(url, params=params, timeout=5)
resp.raise_for_status()
except requests.Timeout:
return {"error": "请求第三方服务超时,请稍后重试"}
except requests.HTTPError as e:
return {"error": f"第三方服务返回错误:{e}"}
这样既能避免 Agent 主循环卡死,也能为用户提供清晰的错误信息。
7.3 上下文管理与性能
Agent 每一轮工具调用都会往 messages 里追加多条消息,多轮任务很容易撑爆上下文窗口。常见的优化手段有:
- 只保留最近 N 轮对话,丢弃早期轮次。
- 工具结果只保留关键字段,不把完整大对象塞进消息。
- 对超过阈值的工具结果做摘要,交给模型前先压缩。
在性能方面,单次 Agent 任务可能需要多次请求模型,延迟比普通对话高。如果工具之间没有依赖关系,尽量让模型一次性返回多个 tool_calls 并行执行,正如第 4 节示例中同时查询天气和计算结果一样。
7.4 日志、权限与合规
Agent 涉及模型的外部调用和自动执行,必须有日志支撑追踪。建议每次请求都记录:模型名称、消息摘要、工具调用参数、工具执行耗时、最终回答。这样线上出现问题才能快速定位。
另外要注意,接入真实第三方 API 时,必须确认你拥有合法的调用授权。涉及用户隐私数据的场景,还要遵守最小化采集原则。在测试环境验证无误后,再经过配套的权限审批和灰度发布流程上生产。
8. 总结与下一步学习路线
本文围绕 NousResearch / hermes-agent 的思路,从零实现了一个最小可运行的 Agent 工程。你掌握了 Agent 与传统对话的差异,理解了 function calling 的标准数据流,学会用 Ollama 本地部署 Hermes 模型,并用 OpenAI 兼容接口完成了工具调用循环。更重要的是,代码里包含了参数解析失败保护、工具异常回传、消息链正确组装等工程化细节,这些是网上零散 demo 里很难学到的东西。
如果你手头有 Ollama,现在就可以打开终端按第 4 节的步骤运行一遍,然后把 tools.py 里的两个工具替换成你业务里真正需要的函数,比如查数据库、调企业内部 API。最后再次提醒:任何 Agent 工具调用都意味着自动化操作,权限边界和人工确认机制一定要放在功能开发之前设计。祝调试顺利,有问题评论区交流。
