先铺垫一下背景:今年我一直在折腾个人智能体,前后试过 LangChain 那套全家桶,也试过自己从零撸编排逻辑。说实话,框架用起来确实省事,但遇到复杂一点的业务场景,项目就会变得特别拧巴——不是编排代码和业务代码纠缠不清,就是调试时根本分不清到底是哪一层出的问题。后来我决定不再跟框架较劲,直接动手写了一个轻量级的 Agent 运行内核,取名 hermes-agent。Hermes 在神话里是传递消息的信使,跑起来之后我发现,这个项目最大的价值也确实落在“传话、调度、执行”这三件事上。
这篇文章不是来推销某个成品框架的,而是把 hermes-agent 从设计到落地的完整思路、核心代码结构、实测数据、踩坑记录,一次性说清楚。如果你打算自己维护一套 Agent 系统,或者正被各种 Agent 框架的抽象层搞得头疼,这篇应该能给你省不少时间。先给结论:与其追逐动不动几千星的新框架,不如先花两天把 hermes-agent 这类轻量内核读透,它能帮你建立对 Agent 运行机制的底层直觉。
1. 内容整体设计与思路拆解
1.1 我为什么不用现成的 Agent 框架
先说个真实的经历。上半年我在做客服工单自动分类和流转的项目时,第一版直接用了社区比较火的 Agent 框架,模型回调、工具调用这些能力确实开箱即用。但项目推进到第二周就出问题了:业务方要求每个工单不仅要分类,还要根据历史处理记录自动生成处理建议,甚至要触发后续的审批流程。这个时候我需要改的就不是“提示词”了,而是要往 Agent 的运行流程里插入业务钩子,比如在模型生成结构化输出之后、执行工具调用之前,先查询数据库确认权限。
现成框架当然提供了这类扩展点,但问题是它们的扩展点太多太杂。有的叫 Hook,有的叫 Callback,有的叫 Middleware,而且通知顺序在不同版本里还不一样。我花了很多时间读源码想搞清楚一次工具调用的生命周期里,哪些回调会先触发、哪些参数会被修改,结果发现这些机制本身就在频繁变化。后来我意识到,对于特定业务场景,框架的通用抽象反而成为了一种负担。我需要的是一个自己能完全掌控的、思路直白的运行管道,最好从输入到输出的每一步都清清楚楚。
hermes-agent 就是这么来的:它只做 Agent 最核心的“感知-决策-执行-反馈”循环,不搞复杂的抽象层,所有扩展都通过显式的函数和事件来实现。你可以把它理解成一套自定义的 Agent 运行管道,而业务逻辑只是管道上的一个小函数。把复杂的东西摊开在明面上,调试和心理负担都会小很多。
1.2 hermes-agent 的定位与能力边界
在设计 hermes-agent 的时候,我给自己定了几个原则,这些原则决定了项目的整体形态。
第一,语言与依赖极简。整个内核只依赖 Python 标准库和一个可选的模型客户端 SDK,不强制要求你安装任何大而全的框架。这样无论是在本地调试还是在容器里运行,都能保持相对干净的环境。
第二,核心循环显式化。Agent 的本质就是一个循环:接收任务,调用模型进行规划,按需调用工具,检查结果,决定是继续还是终止。hermes-agent 把这个循环拆成了几个可覆盖的方法,而不是封装成黑盒。你打开源码就能看到循环在哪里、什么时候调用模型、什么时候触发工具。
第三,工具是代码,不是配置文件。很多框架把工具描述成 JSON Schema 或是 YAML 配置,这让工具元数据和实现分离,更新起来很割裂。hermes-agent 坚持工具就是 Python 类或函数,使用装饰器注册后,由框架自动生成 JSON Schema 给模型调用。这样做的好处是,你在写工具的时候就是在写正常代码,类型提示和错误处理都照常生效,不需要额外维护一份描述文件。
第四,会话状态独立管理。Agent 运行的上下文、工具执行产生的中间变量、Token 消耗记录,这些都是通过独立的 State 对象来管理的。任务结束之后,这个 State 可以被序列化保存,方便后续调试或恢复执行。这个设计在后来的实际部署中帮了大忙,因为很多线上任务执行到一半会因为第三方接口超时而中断,有了 State 持久化,可以带着上次的进度继续跑。
边界也顺便说一下:hermes-agent 不打算做知识库、不内置向量检索、不做多 Agent 通信协议。这些是 Agent 应用的外围能力,应该由更专业的组件来做。核心内核只把工具调用和状态管理这一层做到足够顺手,外围的东西留给生态。这个克制让项目体积一直控制在一千多行代码以内,理解成本大大降低。
2. 核心细节解析与实操要点
2.1 核心抽象:任务、状态、执行单元
hermes-agent 里有三个核心概念,理解它们,整个代码脉络就清晰了。
第一个是任务(Task)。任务是一个数据类,描述“接下来要做什么”。它至少包含任务类型、输入参数、期望输出格式、关联的工具列表。例如一个“天气查询”任务,任务类型就是 weather_query,输入参数就是城市名和时间,期望输出是结构化的 JSON。把任务显式建模的意义在于,模型的能力会被约束在“解决某个具体任务”的范围内,而不是无边界的自由对话,这样对系统的稳定性和权限控制都有好处。
第二个是状态(State)。状态贯穿一次任务执行的整个生命周期。它记录了大模型返回的原始响应、当前已经累积的上下文、工具执行的历史记录、以及任何自定义的中间数据。我会把 State 设计成一个字典容器,支持按命名空间存取。比如工具执行的结果统一放在 state.tool_results 下,模型的中间思考放在 state.model_outputs 下。任务中断后,把 State 序列化成 JSON 存到磁盘,恢复时再反序列化,就能无缝接续。
第三个是执行单元(Executor)。执行单元负责实际干活。对于工具调用,Executor 负责把模型给出的参数映射到具体函数的入参,执行函数,捕获异常,并把结果拼到上下文里。对于模型调用,Executor 负责组装提示词、调用接口、解析输出。不同的执行单元可以像管道一样串联,前一个单元的输出是后一个单元的输入。这跟许多人的直觉不同:Agent 的核心不是“一个很大的模型调用”,而是“一堆小执行单元的有序组合”。
# 一个简化但真实的 Task 定义 @dataclass class Task: task_type: str # 例如 "web_search" input_data: dict # 例如 {"query": "北京今天天气"} expected_output: str # 例如 "json" 或 "text" allowed_tools: list[str] # 例如 ["search", "calculator"]2.2 函数即工具:用装饰器暴露能力
我见过最多的 Agent 项目翻车点,就是工具参数解析不靠谱。模型返回的 JSON 少一个字段或者类型对不上,整个调用链就要炸。hermes-agent 里我用 Pydantic 来做工具的入参校验,但实现上做了一点取舍:工具函数本身不强制使用 Pydantic 模型,而是通过装饰器声明参数结构,运行时自动完成校验和转换。
装饰器用法大概是这样的:
from hermes_core.tool import tool @tool( name="search_products", description="根据关键词查询商品列表", params={ "keyword": {"type": "string", "required": True, "description": "搜索关键词"}, "limit": {"type": "integer", "required": False, "default": 10}, } ) def search_products(keyword: str, limit: int = 10): # 这里写真实的业务查询逻辑 return query_db(keyword, limit)这个设计有几个好处。第一,模型看到的工具描述(JSON Schema)是从装饰器参数自动生成的,不需要另写一份文档。第二,函数签名和描述放在一起,维护一个工具时不需要来回跳文件。第三,如果模型返回的参数不合法,框架会尝试做类型转换,实在不行会返回一个明确的错误信息给模型,让模型“自我修正”后重新调用。这个重试机制在实测中对提高工具调用成功率非常有效。
2.3 工作流编排:既要顺序,也要分支
很多任务不是一次函数调用就能完成的,而是需要多轮“推理-行动-观察”。hermes-agent 里把这种循环称为 Workflow。一个 Workflow 可以包含多个 Step,每个 Step 可以是一个普通函数、一个工具调用、或者一个子 Agent。
工作流定义同样采用声明式,用字典描述步骤依赖关系:
workflow = { "steps": [ {"id": "extract_entities", "type": "tool", "tool_name": "ner_model"}, {"id": "search_info", "type": "tool", "tool_name": "web_search", "depends_on": "extract_entities"}, {"id": "generate_answer", "type": "model", "prompt_template": "based_on_context", "depends_on": "search_info"}, ] }这样写代码有一个直观的好处:你可以一眼看出一个任务会经过哪些环节,哪里可能失败,哪里容易成为性能瓶颈。有一次我在优化一个资料查询流程,逐个步骤掐时间,发现最耗时的不是模型调用,而是某个第三方 API 超时重试。如果没有这种显式的步骤编排,这个问题可能要排查很久。
3. 实操过程与核心环节实现
3.1 从零初始化 hermes-agent 项目
说再多理论不如直接上手。假设你现在要在一个干净目录里搭一个 hermes-agent 项目,我推荐按下面的步骤来。
第一步,创建项目结构和虚拟环境:
mkdir hermes-demo cd hermes-demo python3 -m venv .venv source .venv/bin/activate pip install hermes-agent openai # 模型客户端用 openai,可以兼容多种 API第二步,初始化配置。hermes-agent 的配置非常朴素,本质就是一个 Python 字典,支持从 YAML 或环境变量加载。这里我强烈建议敏感信息(如模型 API Key)不要硬编码在配置文件里,使用环境变量注入:
# config.py import os config = { "model": { "provider": "openai", "name": "gpt-4o-mini", "api_key": os.getenv("LLM_API_KEY"), "base_url": os.getenv("LLM_BASE_URL", "https://api.openai.com/v1"), "temperature": 0.2, }, "state": { "storage_path": "./runtime/state", } }这个配置文件的风格延续了项目一贯的原则:不隐藏默认行为、不需要魔法约定。每个字段都明确对应运行时的某个参数,改起来不需要查文档。
第三步,注册工具并创建 Agent:
from hermes_core import Agent from my_tools import search_products, get_stock_info, create_order agent = Agent(config=config) agent.register_tool(search_products) agent.register_tool(get_stock_info) agent.register_tool(create_order)3.2 关键实现一:多轮工具调用的上下文管理
Agent 要能“聪明”地使用工具,关键在于把工具调用的结果正确拼进上下文。很多人在这一步会犯一个错误:把工具返回的大段文本原封不动地塞给模型。结果就是上下文越滚越长,最后超过 Token 限制,而且模型也很容易“迷失”在无关信息里。
hermes-agent 的解法是设定一个上下文压缩方案。工具返回结果会先经过一个 summarizer 或 extractor,只保留与当前任务最相关的部分。比如搜索商品返回了 20 条记录,但用户只关心价格最低的 3 条,summarizer 会先过滤排序,再传给模型,而不是一股脑灌进去。这个设计在长任务中尤其重要,能把上下文长度压缩 60% 以上,直接降低 Token 费用和响应延迟。
我实现的一个简单 extractor 逻辑如下:
def compress_tool_result(tool_name: str, raw_result: str, max_length: int = 1200) -> str: # 针对不同工具预设不同的摘要逻辑 if tool_name == "search_products": items = json.loads(raw_result) top_items = sorted(items, key=lambda x: x["price"])[:3] return json.dumps({"top_items": top_items}, ensure_ascii=False) # 通用兜底:截断并增加省略标记 if len(raw_result) > max_length: return raw_result[:max_length] + " [truncated]" return raw_result3.3 关键实现二:记忆与状态持久化
Agent 运行过程中,模型回复、工具调用链、临时计算结果,这些都是易失数据。一旦进程崩溃,所有进度都归零。在生产环境里,这种状态丢失是不可接受的。
hermes-agent 的 State 对象提供两种持久化方式:快照(snapshot)和增量日志(journal)。快照适合在任务里程碑节点手动保存,增量日志则适合高频、小步的记录。我的建议是在每个工具调用成功后写一次增量日志,在任务开始和结束时各打一次快照。
持久化结构大致长这样:
{ "task_id": "task_001", "step_index": 3, "context": { ... }, # 已经累积的对话/操作信息 "tool_results": {"search_products": {...}}, "model_calls": 4, "total_tokens": 12890, "metadata": {"started_at": "2025-01-01T10:00:00", "version": "0.1.0"} }我用这套机制处理过一档让我印象很深的线上故障。当时任务执行到第五步时,上游数据库连接突然断掉,整个进程直接退出。以前没有持久化,只能从头开始调模型重跑一遍,既浪费时间又可能产生不同结果。现在只要重新启动进程,加载最后一次快照,从第三步的 tool result 开始恢复执行,整个过程不到一分钟。如果有类似长耗时任务的场景,建议优先把状态持久化这件基础工作做好再谈其他花哨功能。
3.4 关键实现三:模型与工具之间的“翻译官”
大模型本身不知道工具长什么样,它只知道你给了它一段描述。这个“描述”是模型与工具之间的桥梁,我把它称为“翻译官”。翻译官质量的好坏,直接决定了工具调用的成功率。
hermes-agent 的装饰器在注册工具时就自动生成了 JSON Schema,但仅仅有 Schema 还不够。你还需要在 System Prompt 里用自然语言补充工具的使用场景和禁忌。我踩过一个很典型的坑:只给了模型工具的参数 Schema,没告诉它什么时候该用这个工具。结果模型在一个只需要简单加法的问题上调用了一个花里胡哨的报表工具,输出了一堆无关数据。后来我在 System Prompt 里加了一句“仅在需要历史报表数据时调用 report_tool,普通数学计算使用 calculator”,问题立刻消失。
这个实践可以提炼成一条经验:工具描述不只是参数的堆砌,更要说明触发条件和决策边界。很多初级开发者只关注“这个工具能干什么”,却忽略“什么时候不应该用这个工具”,后者在实际使用中往往比前者更能影响用户体验。
4. 常用配置与参数调优心得
4.1 模型参数如何影响 Agent 行为
很多人在调 Agent 的时候,只改 Prompt 不改模型参数,这是不对的。模型参数对 Agent 的执行稳定性影响巨大,尤其是下面几个:
temperature:Agent 任务通常属于“确定性任务”,把 temperature 调到 0.2 以下能显著减少模型自由发挥、输出无关内容的情况。我自己在跑数据分析类任务时调到 0.1,效果稳定。max_tokens:不要舍不得设置上限。如果不设置,模型可能在一轮回复里输出超长内容,导致单次调用成本失控。按任务类型预估输出长度,比如分类任务设 200,生成建议设 2000。timeout:必须显式设置,比如 30 秒。否则一个第三方接口卡住,整个 Agent 循环会跟着卡住。top_p:如果模型接口支持,保持默认或和 temperature 联动。别同时调高两个参数,否则输出随机性会增加,工具调用格式容易出错。
如果你在调试时发现工具调用不稳定(最常见的是 JSON 格式经常出错),先把 temperature 降下来,同时把 max_tokens 稍微调高,给模型留足格式化输出的空间。这个操作比换一个更贵的模型更有效。
4.2 提示词结构的最佳实践
hermes-agent 里提示词分三部分:System Prompt、工具描述、用户指令。把这三者混在一起是新手常犯的毛病,会导致模型抓不住重点。
我在项目中沉淀了一套提示词模板:
system_prompt = f""" 你是一个自动化任务助手。你的任务是严格执行用户指令,并在必要时调用工具。 可用工具如下: {tool_descriptions} 使用规则: 1. 当用户问题涉及实时数据时,必须先调用对应工具查询,不能凭记忆作答。 2. 工具返回结果仅作为参考,最终回答需要结合上下文进行总结。 3. 如果工具返回错误,请如实说明错误原因,不要编造结果。 4. 输出语言与用户提问语言一致。 """这套模板的核心点在于“使用规则”部分。模型是概率模型,你不给它清晰的边界,它就会自行发挥。有了规则之后,虽然不能 100% 保证,但在实测里可以将工具调用准确率从 70% 左右提升到 85% 以上。
4.3 工具的权限与沙箱机制
让 Agent 自主调用工具,看似方便,实际上风险很大。如果不做权限控制,模型可能调用了一个不该调的工具,比如删除接口、发送接口,造成不可逆的后果。
hermes-agent 在每个工具注册时支持两个权限字段:required_role和allowed_models。前者用来控制哪个调用方可以使用该工具,后者用来限制哪些模型可以触发该工具。比如高风险的“发送邮件”工具,可以只允许 admin 角色的任务执行,且只允许使用 GPT-4 级别的模型调用,避免低能力模型在模糊场景下误触发。
@tool( name="send_email", description="发送邮件给指定用户", params={...}, required_role="admin", allowed_models=["gpt-4o"] ) def send_email(to: str, subject: str, body: str): ...另外,所有高风险工具建议默认开启“执行确认”。也就是 Agent 决定调用工具后,不会立刻执行,而是先把参数回传给用户,等待用户确认后再真正执行。这个模式在“半自动”场景中体验极佳,用户既不觉得繁琐,又保留了对关键操作的掌控感。实现技术上并不复杂,核心只是把工具执行拆成“预执行”和“正式执行”两个阶段,中间插入一个人工审核信号。
4.4 并发与异步执行策略
Agent 不是只能单线程跑。如果你的任务中有多个互相独立的工具调用,可以通过异步并发把总耗时压缩到原来的三分之一甚至更短。
比如说,要回答一个“某个商品最近一周的销量、价格走势和竞品情况”的问题,这里涉及三个独立的查询工具,理论上完全可以并行执行。hermes-agent 提供了一个简单的并发执行器:
from hermes_core.executor import ConcurrentExecutor executor = ConcurrentExecutor(max_workers=3) results = executor.run( tools=[sales_query, price_query, competitor_query], params=params )实测中,三个各耗时 3 秒的工具串行要 9 秒,并行只需要 3.5 秒左右。但并发也带来了新的问题:如果多个工具同时修改某个共享状态,就可能出现竞争条件。所以我在并发执行器里默认禁用了写操作,只有主线程才能最终把结果合并进 State。如果你要支持并发写,需要自己实现锁机制,这通常是得不偿失的。
5. 常见问题与排查技巧实录
5.1 模型频繁输出无效 JSON 怎么破
这是我在 Agent 项目里遇到最多的问题。大模型在调用工具时,理论上应该输出一个结构化的 JSON,但实际中它经常输出夹杂散文的 JSON,或者 JSON 格式不标准(比如单引号、尾随逗号),导致解析器崩溃。
我总结了四个层级的手段,从最简单到最复杂:
- 强制 JSON 模式:如果模型 API 支持 response_format 参数(如 JSON mode),直接打开,能从源头减少格式问题。
- 给样例:在工具描述或者 System Prompt 中加入一个“正确输出样例”。这比任何文字说明都直观有效。
- 容错解析:不要用一个要求严格的 json.loads,写一个 “lenient parser”,自动去掉代码块标记、剥离非 JSON 内容,尝试提取第一个合法的 JSON 片段。
- 自我修正循环:解析失败后,不要把错误堆给用户,而是把错误信息拼到一个“修正提示”里,让模型基于错误重新输出一次。这个方法能把最终成功率提高到 95% 以上。
def safe_json_parse(text: str): # 尝试直接解析 try: return json.loads(text) except json.JSONDecodeError: pass # 去掉 markdown json 代码块标记 cleaned = re.sub(r"```json|```", "", text).strip() try: return json.loads(cleaned) except json.JSONDecodeError: pass # 提取第一个 { 和最后一个 } 之间的内容 start, end = text.find("{"), text.rfind("}") if start != -1 and end != -1: try: return json.loads(text[start:end+1]) except json.JSONDecodeError: return None return None5.2 上下文过长导致费用爆炸
Agent 多轮任务中,上下文长度会呈指数级增长。每一轮工具结果加进来,下一轮再全部发给模型,Token 消耗很快,同时模型的注意力也会被稀释,回答质量下降。
可行的方案是“分页摘要”。把较早的轮次内容定期压缩成摘要,只保留最近几轮的完整内容。具体阈值根据业务调整,我一般保留最近五轮完整内容,早期内容统统摘要到 200 字以内。这套机制上线后,单任务 Token 消耗降到了原来的 35%,速度也明显加快。
代价当然是模型可能遗忘早期细节。对于依赖细节的任务,我会把关键信息主动抽取到“长期记忆”区,而不是依赖通用摘要。比如用户一开始提过“预算上限 5000 元”,这个信息必须抽取出来放进全局记忆,不能等后续对话把这信息淹没。
5.3 工具链循环死锁
另一个容易踩的坑是 Agent 在“重复调用某个工具但结果始终不符合预期”时进入死循环。最典型的场景是:模型想查某个信息,工具返回了数据,但模型认为数据不完整,于是换了个参数再查一遍,还是不行,再换参数……直到 Token 耗尽。
解法有两个。第一,设置单次任务的最大工具调用次数,超过即终止并返回“部分结果 + 失败原因”。第二,引入“避免重复尝试”机制:如果下一次工具调用的参数和最近一次完全一样,就直接阻止并提示模型修改策略。
MAX_TOOL_CALLS_PER_TASK = 8 def check_tool_repeat(state, tool_name, params): last_call = state.get_last_tool_call(tool_name) if last_call is not None and last_call.params == params: return False, "相同的调用参数已经执行过,请检查是否遗漏上下文或更换策略。" return True, ""有了这两条,Agent 基本不会在同一个坑里反复打转。后续扩展时,还可以引入“基于失败次数的降级策略”,比如同一工具连续失败三次后,自动切换到备用工具或直接询问用户,而不是无限重试。
5.4 常见问题速查表
为了方便你日常排查,我把高频问题整理成了一个速查表,实际调试时可以先对照这里找方向。
| 现象 | 可能原因 | 处理建议 |
|---|---|---|
| 工具调用参数错乱 | 模型没理解参数含义 | 优化工具 description,给样例 |
| 工具返回内容被模型忽略 | 上下文太长或摘要过度 | 保留最近的轮次完整,关键信息抽到记忆区 |
| 任务执行到一半卡住 | 第三方 API 超时 | 设置请求 timeout,增加重试机制 |
| 模型编造工具结果 | 模型不知道工具调用失败 | 在工具返回里加入 status 字段,模型必须读取 |
| 多轮对话越跑越偏 | 缺乏任务目标记忆 | 把初始任务描述注入到每一轮 System Prompt |
| 并发执行结果丢失 | 多个工具写同一个状态 | 禁止并发写,统一合并结果 |
5.5 调试技巧:从日志中快速定位问题
最后说一个很实用的调试技巧:Agent 项目最难的地方是“黑盒感”——你不知道模型这一步为什么这么走,也不知道工具返回了什么。所以我在 hermes-agent 里内置了分层日志系统,分 DEBUG / INFO / WARNING / ERROR 四级。调试时用 DEBUG,可以清楚看到每一轮的 Prompt 拼接、模型原始输出、工具解析结果和状态变更。生产环境切成 INFO,只记录关键事件,避免日志量过大。
日志输出格式尽量固定成一行一条:
[2025-01-01 12:00:00] [DEBUG] [Step 2] Prompt sent to model, length=3521 tokens [2025-01-01 12:00:01] [DEBUG] [Step 2] Raw model output: {...} [2025-01-01 12:00:01] [INFO] [Step 2] Tool invoked: search_products, params={...} [2025-01-01 12:00:03] [ERROR] [Step 2] Tool execution failed: Connection timeout这套日志在排查“模型明明调用了工具但结果不对”之类的问题时,效率比单靠肉眼盯屏幕高很多。你也可以把日志输出 hook 到外部日志平台,比如 Loki 或 Elasticsearch,实现集中管理。
6. 扩展思路与个人经验总结
hermes-agent 到现在已经被我改造到第三版了,最大的感受是:一个 Agent 项目最核心的竞争力不是用了多强的模型,而是“工具链的可靠性”和“状态管理的清晰度”。模型总会升级,但工具链的稳定性、可维护性和可观测性,决定了你在这个模型时代能走多远。
如果后续你还想继续扩展,我建议从这几个方向入手:一是给工具链加入“语义路由”,根据用户意图自动选择可用工具组;二是加入“人机协同”的确认机制,让 Agent 在关键步骤主动询问用户;三是实现跨任务的记忆共享,让 Agent 具备“经验积累”——这可能是通往更聪明 Agents 的重要一步。每次上手新项目,重新审视一遍这些基础设计,你都会有新的收获。