Hermes Agent 源码探秘 (3):核心循环 — run_conversation() 深度拆解

0 阅读6分钟

系列:Hermes Agent 源码探秘 作者:元思未来 字数:约3500字


这是整个系列最重要的一篇文章。

前两篇我们理解了 AI Agent 的概念,亲手跑起来体验了。现在,打开源代码,看它到底是怎么工作的


一、找到核心代码

# 如果你已经安装过 Hermes
cd ~/.hermes/hermes-agent/
# 核心文件
ls -la run_agent.py

run_agent.py 大约 4100 行,是 Hermes 里最大的文件。但别怕——核心循环不靠行数多,靠逻辑清晰

真正的循环逻辑已经抽离到了独立文件中:

ls agent/conversation_loop.py

这个文件约 4084 行,包含了 run_conversation() 的完整实现。

我们这次的拆解目标就是找出那不到 100 行的核心骨架,然后理解每个部分的作用。


二、核心骨架:Agent 循环的本质

不管外部包装得多复杂,所有 AI Agent 的核心循环都是这样的:

# ===== 核心骨架(伪代码) =====
def run_conversation(self, user_message):
    # 1. 准备
    messages = self._build_messages(user_message)
    
    # 2. 循环
    while self._should_continue():
        
        # 3. 调用LLM
        response = self._call_llm(messages)
        
        # 4. 判断
        if response.has_tool_calls():
            # 5. 执行工具
            for tc in response.tool_calls:
                result = self._execute_tool(tc)
                messages.append(result)
            # 继续循环(让LLM看到结果后决定下一步)
        else:
            # 6. 返回最终回复
            return response.text
    
    # 7. 超时/达到限制,返回当前结果
    return self._finalize(messages)

Hermes 的实现遵循这个骨架。我们现在一行行看真实代码。


三、真实代码拆解

3.1 循环入口

run_agent.py 中,AIAgent 类有两种调用方式:

# 简单接口——返回字符串
def chat(self, message: str) -> str:
    result = self.run_conversation(message)
    return result["final_response"]

# 完整接口——返回完整结果字典
def run_conversation(self, user_message: str,
                     system_message: str = None,
                     conversation_history: list = None,
                     task_id: str = None) -> dict:
    ...

外部使用者通常用 .chat(),内部核心逻辑全在 run_conversation() 里。

3.2 主循环

核心循环的条件是:

while (api_call_count < self.max_iterations 
       and self.iteration_budget.remaining > 0) \
       or self._budget_grace_call:
    
    if self._interrupt_requested:
        break
    
    # ... 循环体

几个关键控制变量:

变量作用默认值
max_iterations最大LLM调用次数90(可配置)
iteration_budgetToken预算跟踪按模型上下文自动计算
_budget_grace_call预算耗尽后的一次"优雅结束"机会True
_interrupt_requested用户中断标记外部设置

设计亮点:

  • 硬限制(max_iterations)+ 软限制(budget)+ 优雅降级(grace call)
  • 多层保障,防止 Agent 无限循环

3.3 调用 LLM

循环体内第一件事:调用大模型。

response = client.chat.completions.create(
    model=model,
    messages=messages,
    tools=tool_schemas,  # 工具定义列表
    **kwargs             # 其他参数
)

这里有个重要的设计:tools 参数。OpenAI-compatible API 都支持在请求中传入工具定义列表,LLM 会返回是直接回复还是调用工具。

tool_schemas 从哪里来?来自我们下一篇文章要拆解的工具注册系统。简单说就是:

# model_tools.py 中的代码
tool_schemas = get_tool_definitions(
    enabled_toolsets=self.enabled_toolsets,
    disabled_toolsets=self.disabled_toolsets
)

每个工具定义是一个 JSON Schema,描述工具名称、参数和功能描述。LLM 看到这些 Schema 后,就知道"哦,我有这些工具可以用"。

3.4 解析响应

LLM 返回的响应有两种可能:

if hasattr(response.choices[0].message, 'tool_calls') \
   and response.choices[0].message.tool_calls:
    
    # 情况1:LLM决定调用工具
    tool_calls = response.choices[0].message.tool_calls
    
    for tool_call in tool_calls:
        # 记录思考过程
        assistant_msg = {
            "role": "assistant",
            "content": response.choices[0].message.content or "",
            "tool_calls": [tool_call]
        }
        messages.append(assistant_msg)
        
        # 执行工具
        result = self._execute_tool_call(tool_call)
        
        # 重要:角色交替!
        tool_result_msg = {
            "role": "tool",
            "tool_call_id": tool_call.id,
            "content": result
        }
        messages.append(tool_result_msg)
    
    # 继续循环
    api_call_count += 1

else:
    # 情况2:LLM决定直接回复
    return {
        "final_response": response.choices[0].message.content,
        "messages": messages
    }

关键设计:消息角色交替

Assistant Message → Tool Result Message → Assistant Message → Tool Result Message → ...

OpenAI 的 API 要求 tool result 必须跟在对应的 assistant message 之后,不能连续两个 assistant 或连续两个 tool 消息。这是 LLM 调用的基本约束,违反了会导致 API 报错。

3.5 工具执行

_execute_tool_call 负责实际调用工具:

def _execute_tool_call(self, tool_call):
    function_name = tool_call.function.name
    function_args = json.loads(tool_call.function.arguments)
    
    try:
        result = handle_function_call(
            function_name=function_name,
            function_args=function_args,
            task_id=self._current_task_id
        )
        return result  # 返回JSON字符串
    except Exception as e:
        return json.dumps({"error": str(e)})

handle_function_call 来自 model_tools.py,它会:

  1. 根据函数名查找已注册的工具
  2. 执行工具的处理函数
  3. 将结果序列化为 JSON 字符串返回

四、循环之外的"周边能力"

上面的骨架看起来简单,但生产级 Agent 还有很多附加机制。

4.1 上下文压缩

随着对话进行,消息列表会越来越长,最终超过 LLM 的上下文窗口。Hermes 的处理策略:

# 每次循环前检查是否需要压缩
if self._should_compress(messages):
    messages = self._compress_conversation(messages)

压缩策略包括:

  • 丢弃最早的非关键消息
  • 摘要历史对话
  • 保留 system prompt 和最近的工具调用结果

压缩触发条件:当估算 token 数超过上下文窗口的 50% 时。

4.2 错误处理与重试

LLM API 调用可能失败(网络问题、限流、模型过载):

try:
    response = client.chat.completions.create(...)
except Exception as e:
    error_info = classify_api_error(e)
    
    if error_info.should_retry:
        # 指数退避重试
        time.sleep(min(2 ** retry_count, 30))
        continue
    elif error_info.should_fallback:
        # 切换到备用模型
        model = self.fallback_model
        continue
    else:
        # 不可恢复的错误,返回错误信息
        return {"final_response": f"错误:{str(e)}"}

4.3 速率限制跟踪

# agent/rate_limit_tracker.py
class RateLimitTracker:
    def __init__(self):
        self.call_times = []
    
    def wait_if_needed(self):
        # 检查是否超过速率限制
        # 如果超过,等待合适的时间
        ...

4.4 预算跟踪

# agent/iteration_budget.py
class IterationBudget:
    def __init__(self, context_length):
        self.total_budget = context_length
        self.remaining = context_length
    
    def deduct(self, tokens_used):
        self.remaining -= tokens_used

预算跟踪确保 Agent 不会在耗尽上下文窗口前停下。


五、完整流程图

把上面所有内容整合起来,run_conversation() 的完整流程是这样的:

用户输入
    │
    ▼
构建消息列表(System Prompt + 历史 + 新消息)
    │
    ▼
┌─── 循环开始 ──────────────────────────────────────┐
│    │                                                │
│    ▼                                                │
│  检查预算 & 中断标记 → 超限则退出循环                │
│    │                                                │
│    ▼                                                │
│  检查上下文长度 → 需要则压缩                         │
│    │                                                │
│    ▼                                                │
│  调用 LLM → 失败则重试/降级                          │
│    │                                                │
│    ▼                                                │
│  解析响应 ──────────────────────┐                   │
│    │                            │                   │
│  有 tool_calls?               无 tool_calls?        │
│    │                            │                   │
│    ▼                            ▼                   │
│  逐个执行工具                  返回文字响应          │
│  结果追加到消息列表              → 结束              │
│    │                                                │
│    ▼                                                │
│  api_call_count++                                   │
│    │                                                │
│    ▼                                                │
│  继续循环 ──────────────────────────────────────────┘
    │
    ▼
循环结束(超限或中断) → 返回当前结果

六、给读者的思考

现在你知道了 AI Agent 的核心循环。它其实不复杂——一个 while 循环 + LLM 调用 + 工具执行,就是全部了。

但在这个简单骨架之上,Hermes 增加了:

  1. 多层退出条件(iteration限制 + budget限制 + 中断)
  2. 优雅降级(grace call 给LLM一次"说再见"的机会)
  3. 弹性错误处理(重试 + 降级 + 不可恢复错误分类)
  4. 资源管理(上下文压缩、预算跟踪)
  5. 速率控制(防止触发API限流)

这些能力不是 AI 的魔法,是工程设计的智慧。作为一个老程序员,最让我感慨的是:这个系统的核心逻辑如此简单,真正的复杂度都在"边缘"——错误处理、性能管理、安全控制。

好的架构就是把核心做简单,边界做扎实。 Hermes 做到了。


七、下一篇预告

核心循环拆解完了,你知道了 Agent 怎么"思考"和"决定"。下一个问题是:它拿什么"做事"?

第四篇我们拆解 工具系统——tools/registry.py 和 model_tools.py。

你会看到:

  • 70+ 工具怎么自动注册
  • 工具 Schema 怎么自动转成 LLM 能理解的格式
  • 写一个新工具到底有多简单

代码位置: ~/.hermes/hermes-agent/run_agent.py
关键文件: run_agent.py (4104行), agent/conversation_loop.py (4084行)
系列目录: [Hermes Agent 源码探秘]


元思未来 · 行稳致远,进而有为