跳到主要内容

快速开始:首个 Hermes 风格 Agent

本指南在 可运行代码决策解释 之间建立桥梁:你会完成环境准备、构建天气 Agent、理解 Hermes 标签解析循环、用 Tool Call Inspector 建立可观测心智,并实现 PDF → 摘要 → 计算器 文件处理链及 Retry/Fallback。全文假设 Python 3.10+;Node 栈读者可对照 LangChain.js API,结构相同。

边练边看

打开 Tool Call Inspector 与子站 Code Sandbox,把本文每一步的 model_raw 与解析结果对照粘贴,比单读文档快三倍。


一、环境准备

1.1 硬性要求

项目要求说明
Python3.10+需 match 语句、类型联合语法
包管理venv 或 uv隔离依赖,避免污染系统 Python
模型 APIOpenAI 兼容OpenAI、DeepSeek、硅基流动等;或本地 Ollama
推荐模型Hermes-2-Pro、Qwen2.5、GPT-4o mini需稳定 function calling 或 Hermes 标签输出
环境变量OPENAI_API_KEY若用 Ollama:OPENAI_API_BASE=http://localhost:11434/v1

1.2 创建虚拟环境并安装依赖

cd ~/hermes-lab
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -U pip
pip install langchain-openai langchain-core pydantic pypdf
用途
langchain-openaiChatOpenAI + bind_tools
langchain-core@tool、消息类型
pydantic参数校验、Tool Schema
pypdf文件处理 Agent 读取 PDF(Day 1 实战)

1.3 验证 API 连通

# verify_api.py — 运行通过再写 Agent
import os
from langchain_openai import ChatOpenAI

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
print(llm.invoke("回复 OK 两个字母").content)

若报错 AuthenticationError,检查 API Key;若 Connection error,检查 OPENAI_API_BASE 与网络。

1.4 项目目录建议

hermes-lab/
├── .env # OPENAI_API_KEY=sk-...
├── agents/
│ ├── weather_agent.py
│ ├── hermes_parser_loop.py
│ └── file_pipeline_agent.py
├── tools/
│ └── registry.py
└── tests/
└── test_tool_selection.py

不要把 Key 提交进 Git;.env 加入 .gitignore


二、首个天气 Agent(LangChain + OpenAI Functions 路径)

本节使用 OpenAI Functions 路径(LangChain 默认),因为 SDK 帮你处理了 tool_calls 解析。第三节再切换到 Hermes 标签 手写循环——两种路径你都要会。

2.1 定义工具:Passive vs Active

# tools/weather.py
from langchain_core.tools import tool

@tool
def get_weather(city: str) -> str:
"""当用户询问某城市当前天气、温度、是否下雨时调用。
不要用于历史天气统计或地震查询。
参数 city: 中国城市名,如「北京」「上海」,不要带「市」后缀。"""
# 演示:真实项目替换为 HTTP 天气 API
mock_db = {
"北京": "晴,25°C,湿度 40%,北风 2 级",
"上海": "多云,28°C,湿度 65%,东南风 3 级",
}
key = city.replace("市", "").strip()
if key not in mock_db:
return f"ERROR: 未找到城市 {city},请确认中文名"
return f"{key}{mock_db[key]}"


@tool
def get_weather_backup(city: str) -> str:
"""仅当 get_weather 返回 ERROR 或超时时调用,作为备用数据源。"""
return f"{city}(备用源):阴,24°C,数据可能有延迟"

为何 Active 描述重要: 用户问「北京今天适合跑步吗」——模型需识别这是 天气意图,不是闲聊。Passive 描述「查询天气」容易被忽略;Active 的「当用户询问当前天气…」提高触发率。

2.2 绑定工具并手动执行一轮

# agents/weather_agent.py
import json
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, ToolMessage
from tools.weather import get_weather, get_weather_backup

TOOLS = {t.name: t for t in [get_weather, get_weather_backup]}
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
llm_with_tools = llm.bind_tools([get_weather, get_weather_backup])

messages = [HumanMessage(content="北京今天天气怎么样?")]
response = llm_with_tools.invoke(messages)

# 模型为何选 get_weather?见 2.4 节
if response.tool_calls:
messages.append(response)
for tc in response.tool_calls:
fn = TOOLS[tc["name"]]
result = fn.invoke(tc["args"])
messages.append(ToolMessage(content=str(result), tool_call_id=tc["id"]))
final = llm_with_tools.invoke(messages)
print(final.content)
else:
print(response.content)

2.3 模型为什么选 get_weather 而不是 get_weather_backup?

逐步拆解 注意力 + 描述 + 用户句 三因素:

步骤模型内部逻辑(教学化表述)你可验证的信号
1解析「北京」为 city 实体tool_calls[0].args.city
2匹配「天气怎么样」→ 当前天气类name=get_weather
3backup 描述含「仅当…失败」→ 前提不满足未选 backup
4无 tool 时应用 Constraints 禁止编造不应直接胡编温度

Tool Call Inspector 粘贴 response 的 JSON,确认 tool_calls[0].function.namearguments

对照实验(建议自做)

  1. get_weather 改成 Passive 描述,同样问句再跑 10 次,记录是否出现 不调用工具直接编造
  2. 增加无关工具 search_web,看是否误触发——若误触发,在 search_web 加 WHEN NOT「不要用于已知城市天气」。

2.4 完整可运行脚本(复制即跑)

#!/usr/bin/env python3
"""weather_agent_demo.py — 单轮工具调用 + 自然语言汇总"""
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage, ToolMessage

@tool
def get_weather(city: str) -> str:
"""当用户询问某城市当前天气时调用。参数 city: 中文城市名。"""
data = {"北京": "晴 25°C", "上海": "多云 28°C"}
c = city.replace("市", "")
return data.get(c, f"ERROR: 未知城市 {c}")

@tool
def get_weather_backup(city: str) -> str:
"""仅当 get_weather 返回 ERROR 时调用。"""
return f"{city} 备用: 阴 24°C"

tools = [get_weather, get_weather_backup]
by_name = {t.name: t for t in tools}
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0).bind_tools(tools)
msgs = [HumanMessage("上海今天天气如何?")]
r1 = llm.invoke(msgs)
if not r1.tool_calls:
print(r1.content)
raise SystemExit(0)
msgs.append(r1)
for tc in r1.tool_calls:
out = by_name[tc["name"]].invoke(tc["args"])
msgs.append(ToolMessage(content=out, tool_call_id=tc["id"]))
r2 = llm.invoke(msgs)
print(r2.content)

三、Hermes 标签格式与解析循环

Hermes 模型(及若干开源权重)在 assistant 文本 中输出:

<tool_call>
{"name": "get_weather", "arguments": {"city": "北京"}}
</tool_call>

而非 API 级 tool_calls 字段。你必须实现 解析 → 执行 → 回灌 循环。

3.1 解析器核心

# agents/hermes_parser_loop.py
import json
import re
from typing import Any

TOOL_CALL_RE = re.compile(
r"<tool_call>\s*(\{.*?\})\s*</tool_call>",
re.DOTALL,
)

def parse_tool_calls(text: str) -> list[dict[str, Any]]:
calls = []
for m in TOOL_CALL_RE.finditer(text):
raw = m.group(1)
try:
obj = json.loads(raw)
except json.JSONDecodeError:
obj = json.loads(raw.replace("'", '"')) # 容错
calls.append({
"name": obj["name"],
"arguments": obj.get("arguments") or json.loads(obj.get("arguments", "{}")),
})
return calls

def format_tool_response(name: str, result: str) -> str:
payload = json.dumps({"name": name, "result": result}, ensure_ascii=False)
return f"<tool_response>\n{payload}\n</tool_response>"

3.2 多轮循环直到无 tool_call

MAX_TURNS = 8

def run_hermes_agent(llm, tools: dict, user: str, system: str) -> str:
messages = [
{"role": "system", "content": system},
{"role": "user", "content": user},
]
for _ in range(MAX_TURNS):
assistant_text = llm.invoke(messages) # 你的 LLM 封装,返回 str
messages.append({"role": "assistant", "content": assistant_text})
calls = parse_tool_calls(assistant_text)
if not calls:
return assistant_text # 最终自然语言
for call in calls:
name, args = call["name"], call["arguments"]
if name not in tools:
result = f"ERROR: unknown tool {name}"
else:
result = tools[name](**args)
messages.append({
"role": "user",
"content": format_tool_response(name, result),
})
return "ERROR: max turns exceeded"

3.3 System Prompt 中如何声明 Hermes 工具

OpenAI 路径把 Schema 放在 API tools;Hermes 开源权重常把 工具列表附录 写进 System:

你可以使用以下工具,需要时用 <tool_call> JSON </tool_call> 格式调用:

- get_weather(city: str): 当用户问当前天气时调用
- calculator(expression: str): 当需要精确数学计算时调用

收到工具结果后会以 <tool_response> 形式提供给你。

切勿 在同一对话混用 OpenAI bind_tools 与 Hermes 标签解析——模型输出格式会不稳定。


四、Tool Call Inspector 用法心智

Inspector 不是「日志美化」,而是 逐步验证教学假设 的三棱镜。

4.1 每一行记录什么

字段含义你应问的问题
turn第几轮 LLM 调用是否 unnecessary 多轮?
model_raw模型原文是否出现未闭合标签?
parsed_call解析后的 name/argsJSON 是否合法?
tool_result工具返回ERROR 是否触发了 Fallback?
latency_ms耗时哪一步该加缓存?

4.2 推荐调试流程

  1. 先 paste raw,再看 parsed——很多「模型选错工具」其实是解析器把 name 截断。
  2. 对照 turn 序号——File Pipeline 应近似 read_pdf → summarize → calculator;若第二步跳到 calculator,说明摘要工具描述与计算工具边界不清。
  3. 保存失败样本——加入 tests/fixtures/,改 Prompt 后回归。

子站 Code Sandbox 可单步执行工具函数,与 Inspector 互补:Sandbox 验证 工具本身;Inspector 验证 模型决策链


五、文件处理 Agent:PDF → 摘要 → 计算器

Day 1 第二个实战:用户上传财报 PDF,问「前三页提到的营收数字相加是多少?」——需要 线性 Tool Graph

5.1 三个工具定义

from langchain_core.tools import tool
from pypdf import PdfReader
import ast
import operator

@tool
def read_pdf(path: str, max_pages: int = 5) -> str:
"""当用户提供 PDF 路径且需要读取正文时调用。不要用于非 PDF 文件。
参数 path: 本地绝对或相对路径;max_pages: 最多读几页,默认 5。"""
reader = PdfReader(path)
chunks = []
for i, page in enumerate(reader.pages[:max_pages]):
text = page.extract_text() or ""
chunks.append(f"--- page {i+1} ---\n{text}")
return "\n".join(chunks) if chunks else "ERROR: empty pdf"


@tool
def summarize_text(text: str, focus: str) -> str:
"""当 read_pdf 返回的长文本需要提炼要点、数字、结论时调用。
不要跳过 read_pdf 直接对本工具传入用户原话。
参数 focus: 摘要焦点,如「营收数字」「风险因素」。"""
# 演示:生产环境可再调 LLM 做摘要
snippet = text[:2000] + ("…" if len(text) > 2000 else "")
return f"[focus={focus}] 摘要: {snippet}"


@tool
def calculator(expression: str) -> str:
"""当需要精确数值加减乘除时调用;不要心算。
参数 expression: 仅含数字与 +-*/ 括号的表达式,如 (100+200)*3"""
allowed = {ast.Add: operator.add, ast.Sub: operator.sub,
ast.Mult: operator.mul, ast.Div: operator.truediv, ast.USub: operator.neg}
def _eval(node):
if isinstance(node, ast.Num):
return node.n
if isinstance(node, ast.BinOp):
return allowed[type(node.op)](_eval(node.left), _eval(node.right))
if isinstance(node, ast.UnaryOp):
return allowed[type(node.op)](_eval(node.operand))
raise ValueError("unsupported")
tree = ast.parse(expression, mode="eval")
return str(_eval(tree.body))

5.2 模型逐步选工具的原因(教学 trace)

用户问:「请读 ./report.pdf 前 3 页,把提到的两个营收数字相加。」

轮次预期工具模型为何这样选
1read_pdf用户显式给路径 + PDF;read_pdf 的 WHEN 匹配「提供 PDF 路径」
2summarize_text原文过长;focus 含「营收数字」匹配 summarize 的 WHEN
3calculator用户要「相加」;calculator WHEN「精确数值」;表达式来自摘要而非幻觉

若第 2 轮直接 calculator,常见原因:

  • summarize 描述未写「不要跳过 read_pdf」
  • read_pdf 返回 ERROR 但模型未读 ERROR 字符串
  • System Constraints 未写「数字必须来自 tool_response」

5.3 串联执行框架(LangChain)

from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, ToolMessage

tools = [read_pdf, summarize_text, calculator]
by_name = {t.name: t for t in tools}
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0).bind_tools(tools)

messages = [HumanMessage(
"读取 ./report.pdf 前 3 页,找出营收相关数字并求和。"
)]
for step in range(6):
ai = llm.invoke(messages)
if not ai.tool_calls:
print("FINAL:", ai.content)
break
messages.append(ai)
for tc in ai.tool_calls:
print(f"STEP {step+1} tool:", tc["name"], tc["args"])
out = by_name[tc["name"]].invoke(tc["args"])
messages.append(ToolMessage(content=out, tool_call_id=tc["id"]))

在 Inspector 中应看到 三步 tool_calls;步数 >4 时要检查是否重复 read_pdf(Memory 未记住 Observation)。


六、Retry 与 Fallback 实现

生产环境工具会失败:网络超时、PDF 加密、表达式非法。Hermes 路径要求 有上限的重试命名清晰的 Fallback

6.1 Retry 包装器

import time
from typing import Callable

def with_retry(fn: Callable, max_attempts: int = 3, backoff: float = 1.0):
def wrapped(**kwargs):
last_err = None
for i in range(max_attempts):
try:
result = fn(**kwargs)
if isinstance(result, str) and result.startswith("ERROR:"):
last_err = result
time.sleep(backoff * (2 ** i))
continue
return result
except Exception as e:
last_err = str(e)
time.sleep(backoff * (2 ** i))
return f"ERROR: after {max_attempts} attempts: {last_err}"
return wrapped

6.2 Fallback 工具链

def execute_with_fallback(primary, fallback, args: dict) -> str:
result = primary.invoke(args)
if str(result).startswith("ERROR"):
return fallback.invoke(args)
return result

# 天气场景
# execute_with_fallback(get_weather, get_weather_backup, {"city": "北京"})

6.3 自我修正 Prompt

工具返回 ERROR 时,把 完整 ERROR 字符串 作为 Observation 回灌,并在 System 加:

若 tool_response 含 ERROR,你必须:
1. 阅读错误原因;
2. 修正参数或换用 Fallback 工具;
3. 同一工具最多重试 2 次;
4. 仍失败则向用户诚实说明,禁止编造。

七、常见踩坑清单

现象根因修复
模型从不调用工具描述 Passive / 模型不支持 FC改 Active;换模型
tool_calls 为空但内容像 JSON混用 Hermes 标签与 OpenAI 路径统一协议分支
arguments 是字符串部分 API 双重编码json.loads 二次解析
重复调用 read_pdf工作记忆未保留 Observation检查 messages append
编造数字缺 ConstraintsSystem 加「数字必须来自 tool」
无限工具循环无 MAX_TURNS设 6–8 上限 + 日志
Fallback 从未触发主工具返回软失败非 ERROR约定 ERROR 前缀规范
PDF 乱码扫描件无文本层增加 ocr_pdf Fallback(Day 3)

7.1 格式混用(最高频)

# 错误示范:bind_tools 却用 Hermes 解析器去抠 assistant 文本
response = llm_with_tools.invoke(msgs) # OpenAI 路径
text = response.content or ""
parse_tool_calls(text) # 通常永远空

# 正确:OpenAI 路径读 response.tool_calls
for tc in response.tool_calls:
...

7.2 参数幻觉

用户说「帝都天气」——city 应为「北京」而非「帝都」。在 get_weather 描述加:「若用户用别称,先映射为标准城市名或请用户确认」;或在工具内维护 aliases = {"帝都": "北京"}

7.3 安全

calculator 使用 ast 受限求值,禁止 eval()read_pdf 应校验路径在白名单目录内,避免任意文件读取。


八、验证清单

完成本章后,逐项自检:

  • verify_api.py 输出正常
  • 天气 Agent 对「北京/上海」发起 get_weather 而非 backup
  • 故意 mock ERROR 时模型走 get_weather_backup 或 Retry
  • Hermes 解析循环 parse_tool_calls 单元测试通过
  • PDF 链三步顺序在 Inspector 中可见
  • 设置 MAX_TURNS 且无死循环
  • 使用 Tool Call Inspector 保存一条完整 trace

九、下一步

文档内容
开发指南五层 Persona、条件链/并行链
从零到一7 天课表与毕业项目
入门介绍四件套、ReAct+、MCP 关系
GitHub 项目与资源上游仓库与 ZIP 文档包

你现在已经完成 Day 1 上午+下午 的核心交付:两个 Agent 原型、两种协议认知、Inspector 心智、失败策略骨架。接下来进入 角色工程——让 Agent 不只「会调工具」,还「像对的专家」。