很多人第一次接触 Hermes Agent,第一反应是"不就是包了一层模型接口吗",于是花半小时照着示例把 demo 跑通,然后直接接进真实业务,结果第二天就开始崩:任务跑一半停了、工具被同一个参数反复调用十几轮、结构化输出解析报错、账单比预算高出一个数量级。这些问题几乎都不是模型能力问题,而是对 Hermes Agent 这套运行模型的理解深度不够——它真正难啃的部分从来不是"调用一次模型",而是循环怎么控、工具契约怎么定、上下文预算怎么花。这篇教程面向已经能把基础 demo 跑起来、准备把 Hermes Agent 往真实场景推进的人,我会按"运行模型拆解 → 最小工程搭建 → 工具注册 → 上下文管理 → 故障排查 → 成本与可观测性"的顺序讲,中间穿插我自己踩过的坑和实测参数。新手也能看懂前半部分,但后半部分的排错链路,建议你至少跑过一个多步任务之后再回来读,感受会完全不一样。
1. 先看清 Hermes Agent 到底在循环什么
动手写代码之前,这一个小时值得花在理解运行模型上。我见过太多人跳过这一步,后面所有痛苦都源自这里。
1.1 一次任务从进入到返回,实际经过四层
把 Hermes Agent 想象成一个项目经理带一个执行团队。项目经理(模型)不做具体活,只负责判断"现在该干什么",然后把活派给执行团队(工具),拿到结果再判断下一步。围绕这个比喻,一次任务实际穿过四层:
| 层级 | 职责 | 出问题时的典型症状 |
|---|---|---|
| 编排层 | 控制循环次数、超时、终止条件 | 任务无限循环、提前中断 |
| 决策层 | 模型判断下一步动作 | 该调工具时不调、乱调工具 |
| 执行层 | 工具运行时,含参数校验与结果回传 | 参数类型错、异常吞掉、返回值过大 |
| 状态层 | 上下文、会话记忆、长期知识 | 忘掉前文、上下文爆掉、答非所问 |
新手最容易只关注决策层,觉得"换个更强的模型就好了",但实际排查下来,七成问题出在执行层和状态层。一个工具返回了 3 万字的原始数据,直接把上下文挤爆,模型再强也只能在垃圾信息里瞎猜。
编排层的核心是一个 while 循环,逻辑大致是:把当前上下文送给模型 → 模型返回动作(要么调用工具,要么给最终答案)→ 如果调用工具,执行并把结果追加进上下文 → 回到开头。这个循环必须有两个硬刹车:最大迭代次数和总耗时上限。我的经验是迭代次数默认给 15,复杂任务给到 30,超过这个数还没收敛,基本说明任务分解本身有问题,继续加次数只是烧钱。
1.2 工具描述不是注释,是模型唯一能看到的接口契约
这是 Hermes Agent 和普通函数库最大的区别。你写一个普通 Python 函数,调用方是人类程序员,他会去读源码。但在 Agent 场景下,调用方是模型,它看不到你的函数实现,只能看到你提供的那段工具描述和参数 schema。描述写得含糊,模型就会猜,猜就必然出错。
我做过一组对比测试,同一个查询订单的工具,描述分别写成"查询订单信息"和"根据订单号查询订单详情,返回金额、状态、创建时间三个字段;订单号必须是 16 位纯数字,不要传用户 ID"。后者的参数正确率从大概六成提升到九成以上。差别就是几十个字的描述。
所以写工具描述时,我固定包含四件事:
- 这个工具做什么,一句话说清,不用"可用于"这类模糊词
- 参数的确切格式,含位数、取值范围、枚举值
- 返回什么,字段名和含义,尤其要说明哪些字段可能为空
- 什么时候不该用它,这条最容易被忽略,但效果最好
1.3 三种启动形态,选错了后面全是返工
Hermes Agent 通常提供三种使用形态,适用场景差别很大,选之前先想清楚你的落地位置:
- 命令行单次执行:适合验证工具链通不通、调试单步行为。跑一条命令,看一次完整轨迹,最快定位问题。
- SDK 嵌入到业务代码:适合把 Agent 能力塞进已有服务,比如工单系统里加一个自动分诊。灵活性最高,但进程生命周期、异常处理要自己管。
- 常驻服务形态:适合多客户端调用、需要会话保持的场景。好处是上下文可以跨请求维持,坏处是状态管理和并发控制一下子复杂起来。
我个人的建议是:任何新项目都从命令行形态开始,先把工具跑顺,确认单步行为符合预期,再往上换成 SDK 或服务形态。反过来做的人,出问题时根本分不清是工具写错了还是框架集成错了。
2. 搭一个能跑的最小工程,别一上来就堆功能
最小工程的意义不是"能跑就行",而是建立一条可重复的验证路径。后面每加一个功能,都能回到这条路径上确认没把地基弄塌。
2.1 目录结构和依赖安装里的取舍
我习惯的目录结构是这样的,简单但够用:
hermes-demo/ ├── .env # 环境变量,不进版本库 ├── config/ │ └── agent.yaml # 模型、迭代上限、超时配置 ├── tools/ │ ├── __init__.py # 工具注册入口 │ ├── search.py │ └── calc.py ├── prompts/ │ └── system.md # 系统提示词单独放,便于版本对比 └── run.py # 入口脚本依赖安装上有一个坑值得单独说:Agent 框架往往和某些 HTTP 客户端库、序列化库有版本耦合。我遇到过一次,本地环境里某个库是较新的大版本,框架用的是旧版接口,表现是工具调用一切正常但流式返回时偶尔截断,排查了大半天。所以我的做法是:新建独立虚拟环境,装完之后立刻冻结一份依赖清单,后面所有调试都在这个环境里做,不跟其他项目共用。
python -m venv .venv source .venv/bin/activate pip install -r requirements.txt pip freeze > requirements.lock注意:不要把环境问题当成框架 bug。遇到行为诡异时,第一步永远是换一个干净环境复现一次,这一步能省掉大量无效排查。
2.2 模型接入与环境变量管理
模型接入这块,配置文件里我只放非敏感项,密钥一律走环境变量。配置文件长这样:
model: name: your-model-name temperature: 0.2 max_tokens: 2048 loop: max_iterations: 15 timeout_seconds: 120 tools: enabled: - search - calctemperature这个参数在 Agent 场景下尤其要压低。有人喜欢调到 0.7 追求"创造力",结果同一个任务跑五次给出五种不同的工具调用序列,根本没法排查。Agent 场景下 0.1 到 0.3 是比较稳的区间,需要创意的环节应该放在最终文本生成那一步,而不是决策那一步。
环境变量加载用标准做法即可,重点是别把密钥写进配置文件再提交上去:
import os from pathlib import Path def load_env(path: str = ".env") -> None: p = Path(path) if not p.exists(): return for line in p.read_text(encoding="utf-8").splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, _, value = line.partition("=") os.environ.setdefault(key.strip(), value.strip())这段代码很简单,但setdefault这个细节很重要——它保证已经存在的环境变量优先,方便在容器里覆盖配置而不改文件。
2.3 第一个可复现的任务脚本
最小验证脚本我一般这么写,重点是把完整轨迹打出来:
from hermes_agent import Agent, tool # 包名以你本地实际安装为准 @tool( name="calc", description="执行四则运算。expression 必须是仅含数字和 +-*/() 的字符串,例如 (12+8)*3。", ) def calc(expression: str) -> str: if not set(expression) <= set("0123456789+-*/(). "): return "错误:表达式包含非法字符" try: return str(eval(expression, {"__builtins__": {}}, {})) except Exception as e: return f"错误:{type(e).__name__}"调用侧的关键是拿到每一步的中间状态,而不是只看最终字符串:
agent = Agent.from_config("config/agent.yaml") result = agent.run("计算 (12+8)*3 的结果,再把结果乘以 1.5") for step in result.trace: print(f"[{step.index}] action={step.action} args={step.arguments}") print(f" observation={str(step.observation)[:200]}") print("最终答案:", result.output)注意eval那里我加了{"__builtins__": {}},因为模型完全可能生成出人意料的表达式,把内置函数禁掉是必要的兜底。这类"用户输入来自模型"的地方,全部要按不可信输入处理,后面第 3 章还会展开。
2.4 跑通之后先读日志,别急着加功能
脚本跑通那一刻,大多数人会立刻开始加下一个工具。我建议反过来,先在日志上停留一会儿,确认三件事:
第一,模型第一次调用工具时,参数是否符合你预期的格式,还是它"猜"对了。第二,工具返回的内容长度是否合理,有没有把整页原文塞进去。第三,循环用了多少轮,如果一个问题用了 8 轮才收敛,说明提示词或工具粒度有问题。
这三件事如果在只有两个工具的简单场景下就有毛病,等你加到十个工具,问题会成倍放大。先修地基,再盖楼,这是我做过十几个 Agent 项目后最想告诉新人的一句话。
3. 工具注册是整套系统最容易翻车的地方
工具写得好不好,直接决定 Agent 的稳定上限。模型的能力是给定的,但工具契约是你完全可控的部分。
3.1 函数签名的三条硬约束
我给团队定的规矩是三条,没有例外:
- 参数只用基础类型。字符串、数字、布尔、以及它们的列表。不要传自定义对象、不要传嵌套三层的字典。模型的参数生成能力在扁平结构上最稳,嵌套一深错误率陡增。
- 返回必须是字符串或可直接序列化的结构。返回内容会被写进上下文,任何无法序列化的对象都会在这一步炸掉。
- 一个工具只做一件事。叫
handle_order的工具,模型永远不知道该传什么;拆成query_order、cancel_order、refund_order,每个的触发场景一目了然。
这三条听起来很基础,但违反率极高。我最常见到的反例是把整个业务服务的十几个方法包成一个工具,用action参数区分——模型的action值填错率能到三成以上。
3.2 参数校验和错误回传,决定模型能不能自我纠错
工具执行失败时有两种回传方式:抛异常,或者返回一段描述错误的字符串。在 Agent 场景下,后者几乎总是更好的选择。
原因是这样的:抛异常会让整个循环中断,模型失去了看到错误并修正的机会;返回错误字符串则相当于给模型一个反馈信号,它下一轮很可能就改对了。我实测过一个场景,参数格式错误时返回明确提示,模型在第二轮自我修正成功的比例超过七成。
@tool(name="query_user", description="按用户 ID 查询用户基础信息。user_id 必须是 8 位纯数字字符串。") def query_user(user_id: str) -> str: if not (isinstance(user_id, str) and user_id.isdigit() and len(user_id) == 8): return "参数错误:user_id 必须是 8 位纯数字字符串,例如 00123456。请重新生成参数后重试。" data = fetch_from_store(user_id) if data is None: return f"未找到 user_id={user_id} 对应的用户,请确认 ID 是否正确或改用其他查询方式。" return format_brief(data)注意错误信息里的三个要素:说清错在哪、给出正确格式示例、提示下一步怎么做。只写"参数错误"四个字,模型的修正成功率会低很多。
3.3 工具数量膨胀之后,分组和懒加载怎么落地
工具从 3 个涨到 30 个时,你会遇到一个很典型的现象:模型开始选错工具。不是它笨,是候选太多、描述之间边界模糊。
我的处理办法分两步。第一步是按场景分组,比如"查询类""操作类""计算类",每组只暴露给对应的子任务。第二步是懒加载,主循环只挂载少量高频工具,其余工具通过一个"检索工具"按需取用:
@tool(name="find_tool", description="当现有工具无法完成任务时,用一句话描述你需要的能力,返回可用工具列表。") def find_tool(need: str) -> str: matched = registry.search(need, top_k=3) if not matched: return "没有找到匹配的工具,请尝试换个说法描述你的需求。" return "\n".join(f"- {t.name}: {t.description}" for t in matched)这套做法的代价是多一轮调用,但换来的是选择准确率明显提升。工具数超过 15 个时,我基本都会上这个方案。
3.4 危险操作必须加人工确认闸门
只要工具有写操作——发消息、改数据、下单、删文件——就必须有确认环节。这不是"最好有",是必须。我见过一个内部工具,Agent 在调试时把测试环境的配置批量改了一遍,虽然可以回滚,但那半天时间全废了。
实现上有两种粒度。轻量做法是在工具内做二次判断,把待执行动作返回给上层,等人工确认后再真正执行:
@tool(name="update_status", description="修改工单状态。调用前会返回确认信息,需人工确认后才真正生效。") def update_status(ticket_id: str, status: str) -> str: if status not in {"open", "processing", "closed"}: return "参数错误:status 只能是 open / processing / closed 之一。" return f"[待确认] 将工单 {ticket_id} 状态改为 {status}。请人工确认后调用 confirm_action 执行。"重量做法是在编排层拦截,任何标记为dangerous的工具调用都先挂起,走审批流再恢复。项目初期用轻量做法就够,等真正上生产再上审批流。
4. 上下文和记忆:预算没管住,后面全是白干
上下文是 Agent 最稀缺的资源,比 token 费用更值得关注。预算失控的表现往往不是报错,而是回答质量缓慢下降——模型开始忽略中间的信息,或者反复询问已经提供过的内容。
4.1 上下文窗口的分配策略与实测比例
我习惯把上下文按用途切成四块,并给每块一个预算上限:
| 用途 | 建议占比 | 说明 |
|---|---|---|
| 系统提示词 | 10% | 角色、规则、输出格式要求 |
| 工具描述 | 15% | 工具越多占比越高,超过 25% 要考虑懒加载 |
| 历史轨迹 | 50% | 每轮的动作和观察结果,最容易失控 |
| 留给输出 | 25% | 预留给最终答案和中间推理 |
历史轨迹这一块是重灾区。一个工具返回了 5000 字的原始数据,十轮之后就积累成 5 万字,直接吃掉全部预算。所以我对所有工具的返回值都做截断处理:
MAX_OBSERVATION_CHARS = 1500 def truncate(text: str, limit: int = MAX_OBSERVATION_CHARS) -> str: if len(text) <= limit: return text head = text[: int(limit * 0.7)] tail = text[-int(limit * 0.2):] return f"{head}\n...[中间省略 {len(text) - limit} 字]...\n{tail}"保留头尾、掐掉中间,是因为关键信息往往出现在开头(结构说明)和结尾(结论)。这个截断策略我用了很久,实测对最终答案质量的影响很小,但上下文占用能压到原来的三分之一。
4.2 短期会话记忆和长期知识的分工
很多人把这两件事混为一谈,结果两头都不好用。我的划分标准很简单:
- 短期记忆管理"这次任务进行到哪一步了"。包括已完成动作、已确认事实、待办事项。它是结构化的、有界的、随任务结束而丢弃。
- 长期知识管理"这个领域有哪些稳定事实"。比如产品参数、制度条款、历史案例。它通过检索按需注入,不进主上下文。
短期记忆我倾向于用一个显式的状态对象维护,而不是靠模型从长对话里自己回忆:
{ "confirmed": {"order_id": "20240115001", "amount": 328.5}, "pending": ["确认收货地址", "生成对账单"], "done": ["查询订单", "核对金额"] }每轮把这个状态序列化后放进上下文,模型对"现在该干什么"的判断会稳定得多。我做过对比,同样是十轮任务,有显式状态的完成率明显高于纯对话历史。
4.3 裁剪、摘要、检索三种压缩手段怎么选
上下文快满时的处理方式有三种,适用场景完全不同:
裁剪最简单,直接丢掉最早的几轮对话。适合任务步骤之间弱相关的场景,比如批量处理一批独立条目。缺点是会丢掉早期确认的关键约束。
摘要是把早期轨迹用模型压缩成一段话。适合长流程但信息密度不均的任务。缺点是摘要本身要花一次调用,而且有信息损耗。我的经验是每积累 8 到 10 轮做一次摘要,把结果写回轨迹开头。
检索是把完整轨迹存到外部,只在需要时按关键词取回相关片段。适合超长任务或需要回溯历史的情况。实现成本最高,但信息保留最完整。
我的默认组合是:优先裁剪,任务涉及多轮依赖时切成摘要,单次任务超过 40 轮再上检索。不要一上来就上最复杂的那套,维护成本会压垮你。
5. 多步任务跑不稳,按这条链路往下查
Agent 出问题时最忌讳的是一上来就改提示词。改提示词属于"盲调",改十次有九次是碰运气。下面这条排查链路是我反复用过、能稳定定位问题的方法。
5.1 循环停不下来和反复调用,特征其实很好认
死循环有两种典型表现,看轨迹就能区分:
第一种是同一个工具、同样的参数被调用多轮。这几乎一定是工具返回值让模型误以为"没成功"。比如工具返回了空字符串,模型会以为调用失败,于是重试。解决办法是让工具的返回永远有明确语义,哪怕是"查询结果为空"也要写成一句完整的话。
第二种是在两个工具之间来回跳,A 的结果触发 B,B 的结果又触发 A。这通常是任务分解逻辑有歧义,或者两个工具的职责边界重叠。解决办法是把其中一个合并或删掉,然后用提示词明确"拿到 X 之后不要再调用 Y"。
第三种更隐蔽:每轮参数都有微小变化,永远不收敛。常见于带分页或时间范围的工具,模型不断缩小范围试图"查全"。这类问题要在工具层面加限制,比如明确告诉它"一次最多返回 20 条,不需要翻页"。
5.2 从执行轨迹里定位失败节点
定位失败节点有个笨办法但极其有效:从后往前找第一个"看起来不对"的观察结果。
具体操作是,把轨迹按轮次打印出来,只显示动作名、参数摘要、观察结果前 200 字。然后从最终答案开始往前扫,找到第一个"这一步的结果和预期不符"的位置。它后面的所有内容基本都可以忽略,因为都是基于错误信息推出来的。
我通常会在排查脚本里加一段辅助输出:
def dump_trace(trace, max_obs: int = 200): for step in trace: args = str(step.arguments) obs = str(step.observation) flag = " <== 关注" if obs.startswith("错误") or len(obs) < 10 else "" print(f"[{step.index:02d}] {step.action} {args[:120]}") print(f" -> {obs[:max_obs]}{flag}")先把所有以"错误"开头、或者短得异常的观察结果高亮出来,八成问题就在这几行里。
5.3 结构化输出解析失败,四种原因按概率排
让 Agent 输出 JSON 是常见需求,解析失败也极其常见。按我遇到的频率排序:
第一位,模型在 JSON 外面包了一层说明文字。比如"好的,结果如下:{...}"。这不是模型的错,是提示词没写死。解决办法是在系统提示里明确"只输出 JSON,不要任何解释、不要代码块标记"。
第二位,字段缺失或拼写不一致。模型可能把user_id写成userId。解决办法是在提示词里给出完整的字段清单和示例,并在解析前做一次键名标准化。
第三位,数值类型变成了字符串。比如金额328.5变成"328.5"。这个在解析后做一次类型转换就能兜住。
第四位,JSON 本身语法错误。长输出时偶发,通常表现为尾部截断。解决办法是留足输出 token 预算,或者改用更小的输出结构分步生成。
import json, re def parse_json_safely(text: str): text = text.strip() text = re.sub(r"^```(?:json)?|```$", "", text, flags=re.MULTILINE).strip() start, end = text.find("{"), text.rfind("}") if start == -1 or end == -1: return None try: return json.loads(text[start : end + 1]) except json.JSONDecodeError: return None这个函数不优雅,但能兜住绝大多数脏输出。解析失败时一定要让上层知道,而不是返回空字典假装成功——静默失败是 Agent 系统里最难查的 bug 类型。
5.4 给自己留一个最小复现用例
每次修完一个 bug,我都会把触发它的那条输入存成一个固定用例,跑在回归脚本里。Agent 系统有个讨厌的特性:修 A 场景可能把 B 场景弄坏,因为两者共享同一段提示词或同一个工具。
我的回归集现在有二十来条,每条只包含"输入 + 期望的关键行为",不比对完整输出(因为模型输出有波动),只检查三件事:是否调用了预期工具、是否在预期轮数内收敛、最终答案是否包含关键字段。跑一遍不到两分钟,能拦掉大部分回归问题。
6. 成本、延迟和可观测性,上生产前的最后一道关
本地跑得欢,一上量就发现账单失控或者响应慢到没法用,这是 Agent 项目最常见的翻车姿势。
6.1 记一本 token 账,按阶段拆开看
总 token 数没什么参考价值,关键是要拆开看:系统提示词占多少、工具描述占多少、历史轨迹占多少、输出占多少。我见过一个案例,工具描述占了总消耗的四成——因为有人给每个工具写了三百字描述,挂了二十个工具。
简单的记账方式是在每轮调用后记录一次:
import time class TokenLedger: def __init__(self): self.records = [] def add(self, stage: str, prompt_tokens: int, completion_tokens: int, cost: float): self.records.append({ "stage": stage, "prompt": prompt_tokens, "completion": completion_tokens, "cost": cost, "ts": time.time(), }) def summary(self): agg = {} for r in self.records: s = agg.setdefault(r["stage"], {"prompt": 0, "completion": 0, "cost": 0.0}) s["prompt"] += r["prompt"] s["completion"] += r["completion"] s["cost"] += r["cost"] return agg跑几十个真实任务之后看这份汇总,你会清楚地知道钱花在哪。我的经验是,历史轨迹通常是最大头,其次是工具描述,这两个也恰好是最容易优化的两块。
6.2 并发和超时的参数怎么定
超时设置要分两层:单次模型调用超时和整个任务超时。前者的经验值是 30 到 60 秒,后者按任务复杂度给 120 到 300 秒。只设单次超时是不够的,因为一个任务可能由十次正常调用组成,加起来早就超了业务能忍的时长。
并发方面,Agent 任务大多是 IO 密集型的,适度并发能显著提升吞吐,但要注意两点:一是模型服务方通常有速率限制,并发数设太高会大量触发限流重试,反而更慢;二是工具如果有共享资源(数据库连接、外部接口配额),并发会放大冲突。
我一般从并发 5 开始压测,逐步往上加,观察两个指标:平均任务耗时和限流重试比例。重试比例超过 5% 就说明并发给高了。
import asyncio sem = asyncio.Semaphore(5) async def run_one(task_input: str): async with sem: try: return await asyncio.wait_for(agent.run_async(task_input), timeout=180) except asyncio.TimeoutError: return {"status": "timeout", "input": task_input}Semaphore加在任务粒度而不是调用粒度,这样单个任务内部的多次模型调用不会互相抢名额。
6.3 上生产前必须补齐的埋点
本地调试靠 print,上生产必须靠结构化埋点。我认为最低限度要有这四类:
| 埋点 | 用途 | 关键字段 |
|---|---|---|
| 任务级 | 统计成功率、耗时分布 | task_id、状态、总轮数、总耗时 |
| 轮次级 | 定位哪一步开始偏 | step_index、动作、参数、观察长度 |
| 工具级 | 发现哪个工具最不稳 | 工具名、调用次数、错误率、平均返回长度 |
| 成本级 | 控制预算 | 分阶段 token、单任务成本 |
有一个字段特别容易被忽略:观察结果的长度。它和上下文爆掉高度相关,一旦某个工具的平均返回长度突然从 500 涨到 5000,说明上游数据格式变了,上下文预算很快会出问题。这个指标比错误率更早发出预警。
再补一个实践中的小技巧:给每个任务生成一个短的追踪 ID,把它写进所有日志和工具调用的元信息里。出问题时用这一个 ID 就能串起整条链路,比按时间戳去猜要快得多。这个习惯我在第三个 Agent 项目才开始养成,之后每次排查故障的时间至少缩短一半。
最后分享一个我觉得最值钱的体会:Agent 的稳定性不是调出来的,是设计出来的。工具契约清晰、状态显式维护、预算提前切好,这三件事做到位,你会发现需要"调提示词"的场景少了一大半。反过来,如果地基没打好,再怎么在提示词里加"请仔细思考""务必检查",效果都很有限——模型看不到你的函数实现,它能依靠的只有你给它的那点信息。