☰
Hermes Agent 实战:循环控制、工具契约与上下文预算
2026/11/4 3:23:21 网站建设 项目流程

很多人第一次接触 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 - calc

temperature这个参数在 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 的稳定性不是调出来的,是设计出来的。工具契约清晰、状态显式维护、预算提前切好,这三件事做到位,你会发现需要"调提示词"的场景少了一大半。反过来,如果地基没打好,再怎么在提示词里加"请仔细思考""务必检查",效果都很有限——模型看不到你的函数实现,它能依靠的只有你给它的那点信息。

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

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

立即咨询