轻量级AI代理实践:可控透明的Agent设计与工具调用全解析
2026/9/10 6:07:42 网站建设 项目流程

最近手上的自动化需求越来越多,类Agent的调用链越来越长,试了一圈市面上的Agent框架之后,还是决定自己写一个轻量级的AI代理。这个项目我取名就叫hermes-agent,Hermes是希腊神话里的信使神,我希望这个Agent也扮演同样的角色——在用户意图和工具世界之间做精准的消息传递与任务路由。这篇文章不聊花哨的概念,就把我踩过的坑、设计上的取舍、以及最终跑通的完整实现拆开讲清楚。

如果你也正在纠结"直接用现成框架还是自己搭一个",或者已经受够了黑盒式的Agent行为,那这篇内容应该能给你一些实打实的参考。

1. 为什么放着现成框架不用,偏要自己造一个Hermes

先交代一下背景。我当时要处理的任务链路大概是这样的:用户丢过来一段模糊的请求(比如"把上周的销售数据整理成周报发到群里"),系统需要自动完成数据查询、格式转换、内容生成、发送动作,四个环节串在一起,而且中间任何一步都可能需要调用不同的内部服务。

1.1 现成框架最让我难受的三个点

  • 抽象层级太多,出了问题根本不知道它在干什么。LangChain早年版本里一个简单的ReAct循环要经过Chain、Executor、Agent、Tool的全套包装,日志一开能刷几百行,但真正想看"模型为什么选了这一步"却很难定位。
  • 自主性过强,缺少安全刹车。AutoGPT那类全自主Agent确实惊艳,但放到真实业务里,让模型自己决定要不要执行一个写操作,没有人在中间把一把,我实在放不下心。
  • 版本迭代导致的老项目维护成本。Prompt模板、工具描述格式、模型的函数调用约定,几乎每个小版本都在变,接进来容易,长期养着很难。

1.2 Hermes的核心设计立场

我给自己定的方向很明确:要一个可控的、透明的、轻量的Agent跑在业务里,而不是一套宏大平台。所以hermes-agent的核心设计立场就三条:

  1. 复杂任务的拆解交给大模型,但关键节点的执行决定权必须留给人或明确的规则;
  2. 每次运行的中间状态都能完整落盘,出问题可以回放;
  3. 工具接入方式保持简单,一个新工具的注册成本控制在十几行代码以内。

这套设计说白了一句话:把Agent当翻译官,而不是当决策者。模型负责把自然语言翻译成结构化动作,动作是否能执行、执行完怎么收尾,由外部规则和工具结果说了算。这也是整个项目所有代码和prompt设计的出发点。

2. 先把"信使"的角色边界定清楚:Hermes的核心职责拆解

很多Agent项目翻车,根源不在技术,而在角色定义模糊。hermes-agent的核心职责被我拆成了三块:听懂意图、拆解任务、调度工具。听起来很简单,但每块里面都有不少细节。

2.1 意图识别:不是所有输入都需要Agent出场

第一版设计里我走了一个弯路:把所有用户输入都丢给模型去拆解。结果就是用户随口一句"早上好"都会触发一连串工具调用,浪费token不说,还容易产生幻觉动作。

后面我加了一道前置轻量路由:先用分类模型或规则判断输入是否真的涉及工具调用。纯闲聊、纯问答类请求直接走普通对话通道,只有检测到明确的"任务指令"才进入Agent循环。这一步让整体API消耗下降了接近四成,是很划算的一道闸门。

2.2 任务拆解:让模型输出结构化计划,而不是自由文本

任务拆解是Agent的核心智力所在,但也是最容易失控的环节。如果让模型直接用自然语言"描述"它接下来要干嘛,你根本没法做自动化校验。

我的做法是约定一个强制性的结构化输出格式,让模型在每轮循环都输出一个JSON对象,包含thought(当前思考)、action(要调用的工具名)、action_input(给工具的参数)、is_finished(任务是否结束)这四个字段。这种格式有点类似ReAct模式的变体,但做了强约束:

  • action必须是工具注册表里的已注册名称,否则直接判负;
  • action_input必须通过对应工具的JSON Schema校验,可以有一条错误重试机会;
  • is_finished只有在工具结果确认任务目标已经达成时才允许为 true,防止模型"自己觉得自己干完了"。

2.3 工具调度:信使只负责传递,不负责代做

Hermes对工具的态度很克制:它不试图理解工具内部实现,只负责按协议调用、取回结果。每个工具都在注册表里暴露三样东西——名称、描述、参数Schema。模型通过描述来判断"这个工具能干什么",通过Schema来生成符合格式的参数,然后由hermes-agent的执行器去真正完成调用。

这里有一个很重要的取舍:工具返回结果不应该被模型"美化"后再返回给下一环。原始结果是什么样就原样返回(可以做截断但不要改写),因为任何一步的信息改写都可能污染后续决策。我甚至在不少工具返回值前面加了一个固定前缀"这是工具返回的原始结果:",防止模型在长上下文中混淆数据来源。

3. 骨架搭建:从意图路由到工具调用的第一版跑通

基础架构确定之后,我开始写第一版可运行的骨架。整个代码量不大,核心就是一个Agent主循环加一个工具注册表,但要把循环控制好,相关的细节还挺多的。

3.1 核心主循环的简化版实现

先看最核心的一段代码,我用Python写了一个精简版的循环逻辑:

import json from typing import Any class HermesAgent: def __init__(self, llm_client, tool_registry, max_iterations=8): self.llm = llm_client self.tools = tool_registry self.max_iterations = max_iterations def run(self, task: str, context: list[dict] | None = None): messages = self._build_initial_messages(task, context) iteration = 0 while iteration < self.max_iterations: response = self.llm.chat(messages, tools=self.tools.schemas()) parsed = self._parse_response(response) if parsed["is_finished"]: return {"status": "done", "result": parsed.get("final_answer", "")} tool_name = parsed["action"] tool = self.tools.get(tool_name) if tool is None: messages.append(self._system_nudge(f"工具 {tool_name} 不存在,请从以下列表重新选择:{self.tools.names()})")) iteration += 1 continue try: tool_result = tool.execute(**parsed["action_input"]) except Exception as exc: tool_result = {"error": str(exc)} messages.append(self._assistant_message(parsed)) messages.append(self._tool_result_message(tool_name, tool_result)) iteration += 1 return {"status": "max_iteration_reached", "result": "任务在最大迭代次数内未完成"}

这个循环看起来简单,但有几个容易被忽略的细节我在第一版里全部踩过一遍:

  • 迭代上限必须要有。没有上限的Agent在任务模糊时会陷入无限循环,一边是自己反复调用同一个工具,一边是上下文越来越长,token像水一样流走。我默认设置了8次,超时就强制退出。
  • 模型返回的JSON不一定合法。有些模型在输出JSON时会在前面带一段解释文字,或者把单引号当成双引号用。_parse_response里必须做容错解析,我加了好几层兜底逻辑(提取首个{}区间、修复常见JSON错误、甚至是截取关键字段)。
  • 工具报错不能直接让Agent自闭。工具执行抛异常时,我把错误信息作为正常结果返回给模型,让它自己判断是换个参数重试、换工具、还是放弃。实测下来,这一招比外层try-except直接中断有效得多。

3.2 工具注册表的设计:接入一个新工具只要十几行

工具注册表是hermes-agent的骨架。我设计了一个简洁的装饰器模式,任何一个Python函数都可以被快速注册成Agent可调用的工具:

from pydantic import BaseModel, Field class SalesReportArgs(BaseModel): start_date: str = Field(description="开始日期,格式为 YYYY-MM-DD") end_date: str = Field(description="结束日期,格式为 YYYY-MM-DD") @hermes.register( name="get_sales_report", description="获取指定日期范围内的销售汇总数据", args_schema=SalesReportArgs, ) def get_sales_report(start_date: str, end_date: str) -> dict[str, Any]: # 这里写真正的数据查询逻辑 return {"total_amount": 12345.67, "order_count": 320}

通过Pydantic的Field(description=...)为每个参数生成模型的函数描述,让模型知道这个参数的格式要求。这意味着我在接入内部服务时不需要写prompt模板,只需要把参数Schema定义清楚。

参数描述写的好坏,直接影响模型生成参数的准确率。比如日期参数,如果描述里不写明"格式为 YYYY-MM-DD",模型很可能给你生成2024年1月1日或者2024/1/1这种"人话格式",后续解析很容易报错。

3.3 数据流和状态:每一轮中间态都落一份日志

做Agent最怕的就是出问题没法复盘。从第一版开始我就给hermes-agent加了一套完整的状态落盘机制。每一轮循环的完整状态——模型原始响应、解析后的结构化动作、工具参数、工具返回值、上下文长度——都追加写入一个JSONL日志文件。

logs/2025-07-22/run_3f54a2.jsonl

这个设计在后面的实际运行中帮了大忙。不管模型是绕了远路还是选错工具,我都能从日志里按时间线回放每一步,定位问题到底是模型理解错了、工具参数没对上、还是返回结果数据格式不符合预期。没有这套日志,后面章节里提到的很多坑根本没法从一个黑盒里挖出来。

4. 记忆与上下文管理的三个坑:缓存、剪枝与污染

Agent和普通对话最大的区别在于它的工作依赖上下文。我早期版本的hermes-agent在长任务上经常前后矛盾——前面查到的结果后面忘了,前面说好的方案后面不认了。这背后其实是三个独立的坑。

4.1 上下文窗口不是缓存,剪枝策略不能一刀切

一开始我图省事,只保留最近N条对话消息,超了就丢掉。结果就是Agent在任务做到一半的时候,把最初的用户需求也给"丢"了。

后来我把上下文分成了两层:固定层滚动层。固定层存放的是系统Prompt、用户核心任务描述、工具说明,这些内容每次循环都完整保留,是Agent的"短期北极星";滚动层才是最近几轮的工具调用与结果摘要,可以被剪枝。这样即使工具调用过了一二十轮,核心任务目标也不会被挤出窗口。

def _build_initial_messages(self, task: str, context: list[dict] | None = None): messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": f"当前目标任务:{task}"}, ] if context: messages.append({"role": "user", "content": f"补充上下文:{json.dumps(context, ensure_ascii=False)[:2000]}"}) return messages

4.2 长期记忆用SQLite,不整花活儿

hermes-agent的长期记忆我选了一个很朴素的方案:SQLite。用完即走的工具返回结果如果没有持久化,下次任务等于重新开始。所以我设计了一个简单的记忆表,存任务的输入、输出、关键中间结果,以及一个summary字段。

每次任务结束时,会先用模型把整段执行过程自动压缩成一条摘要,存进SQLite。下次遇到相似任务时,可以把相关历史任务摘要注入上下文,作为参考。这个方案从根本上避开了向量数据库的运维成本——几万条任务摘要用SQLite的LIKE查询加上模型的语义判断足够了。真要上向量检索,等数据量到了一定量级再考虑。

4.3 上下文污染:工具结果是Agent行为失控的第一来源

这是最隐蔽的一个坑。当工具返回的数据比较大时,比如一份几十K的JSON,模型在后续决策中很容易被这些原始数据带偏——它会试图"分析"数据内容,而不是专注在自己的调度本职上。

我的处理方式是对工具返回值做分层摘要。大体积结果不会完整进入模型上下文,而是先经过一个摘要器,提取关键指标和统计信息;完整数据放在本地文件里,让Agent通过另一个read_file_section工具按需读取。

这套机制让工具返回值和Agent决策逻辑之间形成了一道隔离墙,模型拿到的永远是被格式化过的"任务相关结论",而不是原始噪音。上下文被污染的概率明显下降,生成的JSON格式错误率也降了不少。

5. 全自主模式翻车实录:五次循环与幻觉决策的排查链路

等到基础版功能跑通之后,我开始测试让hermes-agent以"全自主"模式运行。结果一上线就翻车了,而且是同一类问题反复出现。我把其中一次完整的排查过程完整记录在这里,这也是整个项目中最有参考价值的部分。

5.1 现象:同一工具连续调用五次,参数完全没变化

某次测试中我给Hermes布置了一个任务:"把本月的新用户数据按周生成汇总,存成CSV,并记录文件路径。" 看日志时我发现它连续五次调用了同一个工具query_user_data,而且五次传入的参数完全相同:

[iter 1] action=query_user_data, action_input={"start_date":"2025-07-01","end_date":"2025-07-31"} [iter 2] action=query_user_data, action_input={"start_date":"2025-07-01","end_date":"2025-07-31"} [iter 3] action=query_user_data, action_input={"start_date":"2025-07-01","end_date":"2025-07-31"} [iter 4] action=query_user_data, action_input={"start_date":"2025-07-01","end_date":"2025-07-31"} [iter 5] action=query_user_data, action_input={"start_date":"2025-07-01","end_date":"2025-07-31"}

5.2 排查第一步:看工具返回结果是否正常

我第一反应是怀疑工具返回结果有问题,让模型觉得数据不对,只能重新查。于是我把每次工具调用的返回值都调出来逐一查看,结果发现五次返回的都是同一份结构正常的数据,并没有报错,也没有空值。这个怀疑被排除。

5.3 排查第二步:回放模型原始响应

接着我打开JSONL日志,查看模型每一步的原始输出。这里才发现问题症结:模型在前两步就已经查到了数据,但它一直没有输出is_finished: true,而是继续输出action: query_user_data。它的thought字段写着类似"我需要再确认一次数据是否完整"的内容。

这说明模型并不是真的需要工具结果,而是处于一种"不确定性焦虑"中——它拿到的数据明明已经足够完成后续工作了,但它自己不知道"什么时候可以结束"。

5.4 排查第三步:Prompt里缺少终止条件引导

我把最初的System Prompt翻出来看,发现里面写的是"根据需要调用工具完成任务"。这句话太松了,模型没有被告知判断任务完成的准则。我后来加入了两条明确的终止诱导:

  1. 在系统Prompt中显式声明:"当工具返回结果已包含回答用户问题所需的全部信息时,你必须设置 is_finished 为 true 并输出最终答案,不得继续调用工具确认。"
  2. 在每次工具返回的消息后面附加一行元信息:"本工具已返回完整请求数据,如需再次调用请给出新的参数依据。"

5.5 幻觉决策:还有一个更隐蔽的变体

循环调用问题解决后,又出现了一个更隐蔽的情况:模型在某个工具确实失败后,开始"脑补"出一个成功结果。比如write_to_crm工具因为权限原因报错,模型的下一轮thought里却写"已成功写入CRM系统",然后直接is_finished=true

这个问题靠Prompt已经压不住,因为错误信息明明就在前面的上下文里,但模型还是无视了。我最后是通过结果校验钩子解决的:在is_finished=true之前,Hermes会强制检查任务目标对应的关键工具是否在历史调用中确实成功执行过,检查依据是工具的status字段,而不是模型的thought。一旦发现关键步骤实际是失败的,直接拦截,把校验结果反馈给模型让它重试。

6. 稳定运行两周后的实测数据与优化心得

修完上面这些坑之后,hermes-agent进入了一个相对稳定的状态。我把它接入了实际的数据周报生成流程,连续运行了两周,把几个核心指标记录下来,供你参考。

6.1 加装安全护栏:全自主不是无限制

这里要特别说一下我的整体安全策略。在把hermes-agent接入带写操作的业务之前,我强制加装了四层护栏:

  • 词法拦截:工具参数经过敏感词过滤,碰上删除、覆盖、转账这类操作一律先暂停;
  • 白名单机制:写操作工具的调用必须先通过审批回调,只有管理员确认后才真正放行;
  • 迭代上限:任何任务最多8轮工具调用,超出就转为挂起状态;
  • 动作预算:单次任务的模型消耗设置了预算上限,超过后自动熔断,防止异常循环烧掉额度。

有了这些护栏,我才敢把部分工具的写权限交给Agent。在周五的周报任务里,它需要写一个CSV文件然后通过企业内部接口发送到指定群,这个链路在我手动审批过一次之后,后续同类任务的审批通过率明显提升——模型终于学会在同类情况下复用同样的授权决策。

6.2 关键数据:成功率、耗时与消耗

指标数值说明
任务总次数84其中61次是周报,其余是临时查询
一次通过率76.2%无需人工介入即可完成
平均完成耗时18.6秒含模型推理+工具调用+摘要生成
单任务平均token消耗约24K tokens输入为主,输出占比不高
最大迭代次数命中率4.8%4次跑满8轮仍未完成
人工介入率23.8%主要是首次审批和失败重试

一次通过率76%看起来不算惊艳,但考虑到其中有大量任务涉及跨系统调用,这个数字已经能满足我"挂机自动跑,每周人工抽查"的使用方式了。

6.3 两个性价比最高的优化动作

第一是结果摘要压缩。最初我把工具返回的完整数据直接丢给模型,上下文迅速膨胀。改成先做摘要后,平均token消耗从36K降到了24K,但任务成功率反而提高了——因为模型不再被一堆用不上的细节带偏。第二是引入本地工具链的并行调度。一些互相独立的数据查询工具可以并行执行,把原本串行需要30秒的任务缩短到了18秒。前提是Agent先输出一个"并行计划",由执行器判断工具之间是否有依赖,没有依赖就并发跑。

6.4 遗留问题与下一步计划

目前hermes-agent还有两个已知问题没完全解决。一是复杂任务的第一步规划质量不稳定:如果一开始任务拆解方向就是错的,后续工具调用再多也难补救,只能靠人工兜底。我在考虑引入"两步规划"机制,让模型在开始执行前先生成一个完整计划,经过规则校验后再逐步执行,避免边做边改导致的方向漂移。

第二个问题是对多语言混合输入的处理还不够好。当用户的中文请求里夹杂着英文系统名时,模型偶尔会错误地把系统名当成工具名去调用。我打算在意图路由层加一个"术语映射表",把常见的英文系统名映射到对应的工具名称,减少这种张冠李戴。

如果你也想搭一个类似的Agent,我的核心建议没变:别让模型做它不擅长的事——它擅长生成理解,但不擅长做精确的决策判断。把决策和校验留给规则,把理解和生成留给模型,这是hermes-agent走过所有弯路后得出的一条最实在的结论。

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

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

立即咨询