上个月我在做一个自动化客服项目,需要让 LLM 自己决定什么时候查数据库、什么时候调外部 API、什么时候直接回复用户。试了 LangChain 的 Agent,配置太重了,光依赖就装了二十多个包。后来同事推荐我看看 Hermes Agent——NousResearch 搞的那套轻量级 Agent 框架,基于 function calling 的思路但更灵活。折腾了两天,确实比我预期的简单不少,这篇把完整流程记录下来。
Hermes Agent 是 NousResearch 推出的轻量级 AI Agent 框架,核心思路是利用 Hermes 系列模型原生的 tool-use 能力,让 LLM 在对话中自主决策调用哪些工具,开发者只需要定义工具函数和系统提示词就能跑起来。
先说结论
| 维度 | Hermes Agent | LangChain Agent | 原生 function calling |
|---|---|---|---|
| 上手时间 | 30 分钟 | 2-3 小时 | 1 小时 |
| 依赖数量 | 3 个包 | 20+ 个包 | 1 个包 |
| 多步推理 | 原生支持 | 需要配 chain | 手动循环 |
| 模型兼容 | OpenAI 兼容接口都行 | 绑定特定 provider | 看模型支持 |
| 可控性 | 高(tool 定义透明) | 中(抽象层太多) | 最高 |
适合场景:中等复杂度的 Agent(3-10 个工具),不想引入重框架,想快速出原型。
环境准备
Python 3.10+,装这几个包就够了:
pip install openai pydantic rich
没错就三个。Hermes Agent 的核心逻辑其实不需要专门的 SDK,它本质上是一套 prompt 工程 + tool calling 协议,任何兼容 OpenAI 接口的服务都能跑。
你需要一个能调 Hermes 系列模型(或者任何支持 function calling 的模型)的 API Key。我这边用的是 hermes-3-llama-3.1-8b 做开发测试,生产环境切 Claude Sonnet 4.6 效果更稳。
方案一:最小化 Hermes Agent(单工具)
先搞一个最简单的——让 Agent 能查天气:
import json
from openai import OpenAI
client = OpenAI(
api_key="your-key",
base_url="https://api.ofox.ai/v1"
)
# 定义工具
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "获取指定城市的当前天气信息",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市名称,如 Beijing, Tokyo"
}
},
"required": ["city"]
}
}
}
]
# 模拟天气 API
def get_weather(city: str) -> str:
# 实际项目换成真实 API 调用
fake_data = {"Beijing": "晴 28°C", "Tokyo": "多云 22°C", "Singapore": "雷阵雨 31°C"}
return fake_data.get(city, f"{city}: 数据暂无")
# Agent 主循环
def run_agent(user_input: str):
messages = [
{"role": "system", "content": "你是一个helpful助手。需要时使用工具获取信息,不要编造数据。"},
{"role": "user", "content": user_input}
]
while True:
response = client.chat.completions.create(
model="hermes-3-llama-3.1-8b",
messages=messages,
tools=tools,
tool_choice="auto"
)
msg = response.choices[0].message
messages.append(msg)
# 没有工具调用,直接返回
if not msg.tool_calls:
return msg.content
# 执行工具调用
for tool_call in msg.tool_calls:
func_name = tool_call.function.name
args = json.loads(tool_call.function.arguments)
if func_name == "get_weather":
result = get_weather(args["city"])
else:
result = f"未知工具: {func_name}"
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": result
})
# 测试
print(run_agent("东京现在天气怎么样?"))
跑起来大概是这样的输出:
根据查询结果,东京现在的天气是多云,气温22°C。适合出门,建议带一件薄外套。
关键点:那个 while True 循环就是 Agent 的核心——模型决定要不要调工具,调完之后把结果喂回去,模型再决定是继续调还是直接回复。
方案二:多工具 + 多步推理 Agent
实际项目里一个工具肯定不够。我那个客服项目需要:查订单、查库存、发优惠券、转人工。下面是简化版:
import json
from openai import OpenAI
from datetime import datetime
client = OpenAI(
api_key="your-key",
base_url="https://api.ofox.ai/v1"
)
tools = [
{
"type": "function",
"function": {
"name": "query_order",
"description": "根据订单号查询订单状态和物流信息",
"parameters": {
"type": "object",
"properties": {
"order_id": {"type": "string", "description": "订单号"}
},
"required": ["order_id"]
}
}
},
{
"type": "function",
"function": {
"name": "check_inventory",
"description": "查询某商品的库存数量",
"parameters": {
"type": "object",
"properties": {
"product_name": {"type": "string", "description": "商品名称"}
},
"required": ["product_name"]
}
}
},
{
"type": "function",
"function": {
"name": "issue_coupon",
"description": "给用户发放优惠券,仅在用户明确不满或投诉时使用",
"parameters": {
"type": "object",
"properties": {
"user_id": {"type": "string"},
"amount": {"type": "number", "description": "优惠券金额(元)"}
},
"required": ["user_id", "amount"]
}
}
},
{
"type": "function",
"function": {
"name": "transfer_human",
"description": "转接人工客服,仅在无法解决问题时使用",
"parameters": {
"type": "object",
"properties": {
"reason": {"type": "string", "description": "转接原因"}
},
"required": ["reason"]
}
}
}
]
# 工具实现(实际接数据库/微服务)
def query_order(order_id):
return json.dumps({"order_id": order_id, "status": "已发货", "tracking": "SF1234567890", "eta": "2026-04-28"})
def check_inventory(product_name):
return json.dumps({"product": product_name, "stock": 23, "warehouse": "华东仓"})
def issue_coupon(user_id, amount):
return json.dumps({"success": True, "coupon_code": "SORRY20", "expires": "2026-05-30"})
def transfer_human(reason):
return json.dumps({"queued": True, "position": 3, "wait_time": "约2分钟"})
TOOL_MAP = {
"query_order": lambda args: query_order(args["order_id"]),
"check_inventory": lambda args: check_inventory(args["product_name"]),
"issue_coupon": lambda args: issue_coupon(args["user_id"], args["amount"]),
"transfer_human": lambda args: transfer_human(args["reason"]),
}
def run_customer_agent(user_input: str, user_id: str = "u_10086", max_turns: int = 5):
system_prompt = f"""你是电商客服 Agent。当前时间: {datetime.now().strftime('%Y-%m-%d %H:%M')}
用户ID: {user_id}
规则:
1. 先尝试用工具解决问题
2. 发优惠券前必须确认用户确实遇到了问题
3. 超过3轮工具调用仍无法解决,转人工
4. 回复简洁友好,不要复述工具返回的原始JSON"""
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_input}
]
turn = 0
while turn < max_turns:
response = client.chat.completions.create(
model="claude-sonnet-4.6", # 生产用 Sonnet,推理更稳
messages=messages,
tools=tools,
tool_choice="auto"
)
msg = response.choices[0].message
messages.append(msg)
if not msg.tool_calls:
return msg.content
for tool_call in msg.tool_calls:
func_name = tool_call.function.name
args = json.loads(tool_call.function.arguments)
executor = TOOL_MAP.get(func_name)
if executor:
result = executor(args)
else:
result = json.dumps({"error": f"未知工具 {func_name}"})
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": result
})
turn += 1
return "抱歉,我暂时无法处理您的问题,正在为您转接人工客服。"
# 测试多步推理
print(run_customer_agent("我的订单 ORD-2026042201 到哪了?说好3天到的已经5天了"))
实测下来,Claude Sonnet 4.6 在这种多工具场景下的表现比 Hermes 3 8B 好很多——8B 模型偶尔会"幻觉"出不存在的工具参数,Sonnet 基本不会。
sequenceDiagram
participant U as 用户
participant A as Agent (LLM)
participant T1 as query_order
participant T2 as issue_coupon
U->>A: 订单 ORD-xxx 到哪了?已经5天了
A->>T1: query_order("ORD-xxx")
T1-->>A: {status: 已发货, eta: 4月28}
A->>A: 判断: 用户不满,且确实超时
A->>T2: issue_coupon("u_10086", 10)
T2-->>A: {success: true, code: SORRY20}
A-->>U: 您的包裹预计28号到,已补偿10元券
踩坑记录
坑 1:tool_call_id 不能瞎填
一开始我图省事,tool response 的 tool_call_id 随便写了个 uuid,结果报错:
Error code: 400 - {'error': {'message': "An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'.", 'type': 'invalid_request_error'}}
必须用模型返回的那个 tool_call.id,一一对应。
坑 2:Hermes 3 8B 的 JSON 输出偶尔不合法
大概 5% 的概率,8B 模型返回的 function.arguments 不是合法 JSON。我加了个 try-except:
try:
args = json.loads(tool_call.function.arguments)
except json.JSONDecodeError as e:
# 尝试修复常见问题:尾部多逗号、单引号
fixed = tool_call.function.arguments.replace("'", '"').rstrip(",}") + "}"
args = json.loads(fixed)
这个 workaround 挺丑的,换大参数模型(70B 或 Sonnet)基本不会遇到。
坑 3:max_tokens 设太小导致工具调用被截断
默认 max_tokens 如果只给 256,复杂的多工具调用会被截断,模型返回的 tool_calls 数组不完整。我现在统一设 2048,反正 Agent 的中间步骤用户看不到,不心疼 token。
进阶:给 Agent 加记忆
最简单的做法——把 messages 列表持久化。但对话超过 20 轮之后 token 消耗会爆炸。我目前的方案是每 10 轮做一次摘要压缩:
def compress_history(messages: list, client) -> list:
"""把前面的对话压缩成一段摘要"""
history_text = "\n".join([f"{m['role']}: {m.get('content', '[tool_call]')}" for m in messages[1:-4]])
summary = client.chat.completions.create(
model="hermes-3-llama-3.1-8b", # 摘要用小模型省钱
messages=[
{"role": "system", "content": "用3-5句话概括以下对话历史的关键信息,保留所有具体数据(订单号、金额等)"},
{"role": "user", "content": history_text}
],
max_tokens=300
)
compressed = [
messages[0], # system prompt 保留
{"role": "system", "content": f"[对话历史摘要] {summary.choices[0].message.content}"},
*messages[-4:] # 最近4条保留原文
]
return compressed
小结
Hermes Agent 的核心就是 tool calling 的循环调用,没有什么黑魔法。框架的价值在于:约定了工具描述的格式,约定了多步推理的循环逻辑,约定了何时停止。
我也不确定 Hermes 3 8B 是不是做 Agent 的最佳小模型选择——DeepSeek V4 预览版的 function calling 准确率据说也挺高,等正式版出来我再测测。
实际工程里最重要的几件事:工具描述写清楚(模型靠这个决定调不调)、错误处理要兜底(网络超时、JSON 解析失败)、设置最大循环次数(防止 Agent 陷入死循环烧你的钱)。我那个客服项目跑了两周,P95 延迟在 1.2s 左右,大部分时间花在模型推理上,工具执行本身很快。