ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

深入拆解Hermes Agent Loop:AI Agent执行流程的关键细节

深入拆解Hermes Agent Loop:AI Agent执行流程的关键细节

就直接切入正题,先聊一个很多做Agent的人都绕不开的问题:你在本地模型上把"AI Agent执行流程"跑通之前,对Agent Loop的理解大概率是打折扣的。我之前一直用现成的Agent框架,直到在某次项目里需要完全掌控循环的每一步——什么时候该停、什么时候该重试、什么时候该把上下文交给工具——才发现"Agent执行流程"这几个字背后有多少细节。

Hermes Agent Loop是我在调研开源AI Agent平台时关注到的一个实现,它把AIAgent执行流程拆得足够薄,薄到你能看懂每一次LLM调用、每一次工具返回值之后发生了什么。这篇文章不聊抽象概念,直接沿着它的执行代码走一遍,把循环里的关键节点、控制逻辑、上下文处理全部拆开。适合两类人:一类是打算自己实现Agent循环、不想套框架的开发者;另一类是用过Agent框架但总感觉"差点意思"、想搞清楚内部机制的工程同学。

1. 代理循环的本质:一个while循环和它的三个守护条件

先别急着看框架。任何Agent执行流程,剥掉包装之后就是一个while循环:给LLM发消息,LLM要么给出最终答复,要么请求调用一个工具;如果是后者,就执行工具、把结果作为观测(observation)丢回对话历史,然后继续下一轮LLM调用。这听起来简单,但真正让循环可落地的,是循环退出条件的工程化设计。

Hermes Agent Loop的循环体核心做了三件事:限制最大迭代次数、检测模型输出的"终止意图"、拦截重复或无效的工具调用。用伪代码表示就是:

while round < max_rounds: output = llm.chat(messages, tools=tool_schemas) if output.is_final_answer(): break if output.tool_call_count == 0: # 没有工具调用也没有最终回答:视为异常,进入纠正分支 messages.append(repair_prompt(output)) continue for call in output.tool_calls: result = run_tool(call) messages.append(tool_result_message(call, result)) round += 1

这里最需要注意的,也是很多人自己写Agent循环时容易漏掉的,是第二个条件——is_final_answer()。本地模型经常出现一种情况:它在回答里同时包含了"最终答案文本"和"某个工具调用"。如果代码只判断"有没有工具调用",就会在模型明明已经回答完毕时,硬把它拉回去再调用几次工具。所以循环里必须有一个独立的出口判断:当模型输出的文本部分是完整的最终答复且没有后续行动意图,就立即终止循环,不以工具调用为唯一出口信号。

第三个守护条件也值得多说一句:拦截重复调用。模型在低参数量或旧版本模型上,很容易在同一轮迭代里反复调用同一个工具、传同样的参数,直到撑爆上下文。这种"无效循环"在现实里的表现就是日志里连续几十行同样的tool_call记录。Hermes Agent Loop在处理这类情况时,会记录上一轮的tool_call签名(工具名加参数哈希),如果本轮完全相同,就不执行工具,而是给模型追加一段提示,告知"该操作已执行过且未产生新信息,请更换策略或直接回答"。这一小块逻辑,能有效缓解上下文被稀释的问题。

2. Hermes Agent Loop执行流程逐步拆解

这一章沿着代码实际运行的顺序走,每到一个关键节点都说明它的作用和容易被忽略的细节。整个执行流程可以划分为四个阶段:初始化、请求组装、工具执行、上下文归约。其中上下文归约是最容易出问题的阶段,我放到2.4单独讲。

2.1 第0阶段:会话初始化和工具注册

循环开始之前,有一个不产生任何用户可见输出的阶段,但决定了循环的整个行为边界。初始化阶段要做的事情包括:加载模型配置(模型路径或API地址、温度、最大生成token数)、加载工具清单、构建系统提示词。

工具注册在Hermes Agent Loop里不是简单的"填入函数列表",它需要为每个工具准备三样东西:可被LLM读取的函数schema(JSON Schema格式,包含参数说明)、对应的本地执行函数、以及一个延迟导入机制。延迟导入的意思是:不是所有工具都在进程启动时加载,而是按需加载。我的理解是,这是为了避免把几十个工具的依赖全部堆进内存,导致Agent启动时间变长、工具冲突变多。

@register_tool( name="shell_exec", description="Execute a shell command and return stdout/stderr", parameters={ "command": {"type": "string", "description": "Command line to run"}, "timeout": {"type": "integer", "description": "Timeout in seconds"} } ) def shell_exec(command: str, timeout: int = 30): ...

工具注册还有一个隐藏作用——在循环的"请求组装"阶段,工具清单会被序列化成模型所需的格式。如果你的模型走的是OpenAI兼容接口,那就是tools数组;如果走的是本地模型的原生格式,可能是函数列表或特殊的语法模板。Hermes Agent Loop统一在注册阶段做格式适配,循环体内不感知具体格式差异。

2.2 第1阶段:请求组装,不只是发一条消息

很多人写Agent循环时,请求组装就是messages.append(user_message)然后直接调用模型,结果效果不稳定。问题在哪儿?在于messages里积压的历史记录往往包含非常长的工具返回内容,比如一段日志、一个JSON文件、一次数据库查询结果。这个长度可能超过模型上下文窗口的一半甚至更多。所以请求组装阶段必须做两件事:

第一件是历史压缩。Hermes Agent Loop的做法是:对超过一定轮数(比如10轮)的对话进行摘要。具体来说,前9轮消息保持不变,更早的消息会被提取关键信息生成一个summary块,替换原来的详细记录。这个压缩策略比"全部保留"稳定,也比"全盘丢弃"信息保真度高,是一个不错的中间方案。

第二件事是把当前执行的工具观测结果做"物化"。所谓物化,就是在工具返回结果里,把纯文本的JSON字符串转成结构化字典,再根据模型的要求做一次裁剪。以shell执行工具为例,返回内容可能是一整屏的日志,有时候只需要最后50行。Hermes Agent Loop允许工具本身设置return_limit参数,在工具层面就截断内容,而不是等内容进入messages之后再处理。这能在源头省下不少token。

def request_turn(messages, tools_manifest): messages = compress_history(messages, keep_recent=10) payload = { "messages": messages, "tools": tools_manifest, "max_tokens": 2048, } return client.chat.completions.create(**payload)

这个阶段还有一个容易忽略的点:system prompt里要写明"工具返回内容可能被截断"。如果模型不知道工具输出会被截断,它有时会假设自己看到了完整输出,基于部分信息做决策。写明这个约束,模型会倾向于在回答里说明"该结果可能不完整"或主动要求再次获取完整数据。这个小改动对行为稳定性的提升很明显,算是我在多次复现中觉得最值得借鉴的细节之一。

2.3 第2阶段:工具执行与结果回填

模型返回tool_calls之后,循环进入执行阶段。这里有两层控制:一层是工具级控制(是否成功返回、是否超时),另一层是流程级控制(工具A的结果会否影响工具B的选择)。

工具级控制相对简单。Hermes Agent Loop为每个工具调用注册了超时上限,默认30秒,超过即返回一个失败的tool_result,里面包含错误信息。值得强调的是,失败的工具结果也要进入对话历史。很多Agent循环实现里,工具调用失败只是被记录在日志中,没有回填到messages里,模型下一轮根本不知道工具失败了,还会重复调用同样的工具。这是"工具重试循环"形成的核心原因之一。正确做法是把失败结果当作一次正常观测写入messages,让模型在下一轮里自己决策:是换参数重试,还是换一个工具,还是直接放弃工具并给用户一个说明。

流程级控制做工比较多的,是工具之间的依赖关系。比如先执行search拿到一个文件路径,再把路径传给read_file工具。如果第一轮search返回的结果里路径信息模糊,模型可能在第二轮用错误参数调用read_file。Hermes Agent Loop里没有显式的DAG依赖图,它依赖模型自身的推理能力来串联工具。但代码里做了一个辅助设计:每轮工具调用最多并行执行3个工具,且同批次的工具调用共享同一个上下文快照。这样既限制了工具调用的并发度,避免模型一次抛出一大堆请求把工具执行队列打爆,也保证并行工具产生的结果可以互相参照。

2.4 上下文归约:整个循环里最容易被低估的一环

我单独把上下文归约拉出来一节,是因为这个环节直接决定了Agent能不能稳定跑完长任务。上下文归约要处理的核心矛盾是:模型需要足够多的信息来维持连贯决策,但上下文窗口是有限的。尤其是执行代码生成类任务时,工具返回的长文本很容易在5轮之内就把8k上下文窗口吃满。

Hermes Agent Loop的归约策略分三层。第一层是工具输出的量化截断,已经在2.1和2.2提到;第二层是历史消息的key-value抽取,每轮结束后,把"用户目标、已执行动作、动作结果摘要、下一步状态"四个维度抽出来形成一行紧凑记录,并放入一个独立字段。这个字段在后续请求组装时作为辅助线索与压缩后的历史一起发给模型;第三层是终止前快照存储,循环即将结束时(判断依据是已接近最大轮数或模型输出接近上下文上限),把整个会话的关键状态写到本地文件,便于任务中断后恢复。

这三层配合下来,上下文占用在整个流程里基本维持在一个稳定区间,而不是线性增长。我试用过一段时间的体会是:如果没有这层"归约",很多Agent任务不是输在执行逻辑上,而是输在跑着跑着上下文就废了,模型开始答非所问、重复调用工具,最终整个循环以超时或错误收场。

3. 我在复现中踩过的四个坑

实话说,跑通Hermes Agent Loop不是难点,难点是"稳定地跑通"。我在复现和把它的思路迁移到自己的项目里时,遇到过一些教科书里不会写的问题,这里如实记录一下。

第一个坑是模型对工具schema中required字段的理解问题。有些开源模型对JSON Schema的required处理很糙,面对一个"可选参数"和一个"必填参数"时,它会倾向于把所有标了required的参数都填一遍空白值。结果工具执行端收到的参数是{"path": "", "format": ""}这类污染数据。解决办法是在工具执行前增加一层参数校验,校验不通过时,不是直接报错,而是把校验错误信息返回给模型作为tool_result,让模型自行修正。这比"简单报错并终止"要有效。

第二个坑和循环的sleep/backoff策略有关。部分本地模型接口没有内置限流保护,如果你在循环内连续很快地发出多次请求,接口直接返回503或连接拒绝。我一开始以为是自己脚本写慢了,后来排查发现是请求频率太高。处理方式是加入一个动态退避:当tool_result返回连接类错误时,先等待1秒,再尝试2秒、4秒的指数退避,最多退到30秒。这个方法放在Agent循环里同样重要,因为它能避免整个执行流程因偶发网络抖动而白跑。

第三个坑是并行工具的返回顺序错乱。当同一轮里有多个工具并行执行时,如果工具结果是异步返回的,回填到messages里的顺序和模型发起调用的顺序必须保持一致,否则模型在下一轮可能认为工具A的结果是工具B产生的,导致后面全部推理错误。我在自己代码里曾把顺序搞反,调了整整一下午。Hermes Agent Loop的处理方式是为每个tool_call带上id,回填时按id映射,不按返回时间顺序写入。

第四个坑比较隐蔽——系统提示词里如果写了"step by step"或"let's think step by step"这类鼓励思考的句子,在循环场景下会显著增加token消耗,还会导致模型在每一步都输出大段推理文本。在Hermes Agent Loop里我看到的做法是:不在系统提示词里鼓励逐步思考,而是提供一套"决策框架",要求模型在每次工具调用之前输出一个非常短的意图行(比如# intent: need file path),其余推理内容尽量精简。这样做既保留了模型的可解释性,又不至于让上下文被推理文本挤爆。

# 顺序错乱示例(错误做法) for result in async_results: # 按完成时间 messages.append(tool_message(result)) # 正确做法:按tool_call.id映射 for call_id in round_tool_ids: messages.append(tool_message(results_by_id[call_id]))

4. 从"跑通循环"到"调出可用Agent":轮数与退出策略的平衡

把循环写出来容易,但要让它以"可用"的状态结束,需要重点关注两个参数:最大轮数(max_rounds)和退出条件。这两个参数在不同任务类型上的最优值差异很大,我列一个自己多次实验后的参考表:

任务类型建议最大轮数退出重点策略备注
单文件代码生成3-5首次有效输出即退出多轮往往是在自我怀疑
多文件项目重构8-15以测试通过为准轮数太少容易半途而废
数据分析/报告4-6以回答包含最终结论为准期间可能有1-2次工具调用
开放式问答1-3无工具调用时立即退出不需要无脑循环

这张表的意义在于:轮数不是越大越好,反而很多情况下,轮数越大,上下文负担越重,预测结果可回退性越差。我自己在跑Hermes Agent Loop的代码生成任务时,调过max_rounds=20,结果模型在后半段基本在自我怀疑和来回改文件,质量和效率都不如设成5轮、把该用的工具用足之后强制结束。

退出策略除了"模型输出最终回答"之外,还有一个常被忽略的软退出条件:当模型在两轮内没有产生新的信息增量(即两轮的tool_call完全相同或参数微小变化),可以提前终止循环,把当前已产出的内容作为结果返回。这个软退出和第一节说到的"重复调用拦截"是配套的——先拦截同一轮的重复调用,再检测跨轮无进展,两者配合能把无意义的循环消耗降到最低。

还有一个策略是"部分完成即返回"。在复杂的多步任务中,有些步骤模型还没做完,但已经产出了一个合理解。例如一次查询任务里,模型先查到了用户需要的核心数据,但还在尝试寻找额外的报表。此时如果因为最大轮数到了被强制终止,已查到的数据可能不会被整理成最终答复。Hermes Agent Loop在这个问题上采用了"终止前收口"机制:一旦达到最大轮数或检测到即将超限,就追加一次收口调用,提示模型"基于当前已获取的信息,尽量给出完整回答,不要求继续工具调用"。这相当于给循环加了一根保险丝,不至于让前面几轮的成果全部白费。

5. 从Hermes Agent Loop看Agent循环的可移植设计

先给一个直接结论:我不建议任何人直接照搬Hermes Agent Loop的代码到生产系统里。这个项目的价值更多在于示范——它告诉你要自己实现Agent执行流程时,哪些部分是必须有的、哪些可以按场景裁剪。基于我读完、跑完这个项目的体会,整理了几个可移植的设计要点。

工具调用协议应该是独立的一层。你的业务代码里的run_tool应该是一个抽象接口,底层可以接本地函数、REST API、数据库查询,甚至另一个Agent系统。只要返回的观测对象能被统一处理,这层边界就足够干净。Hermes Agent Loop把所有工具都包装成注册表形式,方便在循环里统一遍历和调用,这个抽象值得保留。

上下文策略应该单独抽成一个组件。不要把上下文管理逻辑散落在循环的各个角落。压缩、截断、摘要、快照,这些逻辑放在同一个模块里管理,当你要迁移到不同的上下文窗口大小、不同的任务场景时,只需要改这个组件,而不是动循环本体。我自己的项目里直接把这一层做成了ContextManager类,循环只调用它暴露的prepare()和append()两个方法。

Agent循环里需要单独考虑"系统级错误"和"业务级错误"的区分。系统级错误指的是工具超时、连接失败、参数格式非法这类问题,应该由循环自动处理(重试或终止);业务级错误指的是工具正常返回,但内容不满足任务要求,例如搜索结果为空、文件里没有匹配到模式。业务级错误要交给模型去判断和决策,循环不干预。如果把两者混在一起,要么循环控制逻辑过度复杂,要么错误处理不精确,任务失败率会明显升高。

最后分享一下我自己实际使用中的体会。一个Agent循环的成败,往往不在于模型多强、工具多丰富,而在于它的"退出意识"。什么样的状态算"任务结束"?什么样的状态算"需要继续"?这个判断如果只是简单依赖模型的输出,永远会有一批"薛定谔的结束"出现。Hermes Agent Loop给了一个很好的框架——把退出条件显式编码进循环逻辑,而不是让模型靠感觉来决定。你做自己的Agent循环时,建议从这个角度切入:先确定你的任务允许在哪几种状态退出,再反过来设计循环体。循环是骨架,退出策略才是灵魂。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进