Hermes 与 Claude CodeHermes 与 Claude Code第 4 章 · 14 章Chapter 4 of 14
全文目录Contents
  1. 这个网页怎么用
  2. 1 · 零基础前置知识
    1. 1.1 大语言模型是什么
    2. 1.2 token 是什么
    3. 1.3 上下文与上下文窗口是什么
    4. 1.4 智能体与聊天机器人的区别
    5. 1.5 工具调用是什么,它到底怎么工作
    6. 1.6 什么是流式输出
    7. 1.7 什么是 API
    8. 1.8 提示词缓存 —— 全文最重要的技术概念
    9. 1.9 还会遇到的几个词
  3. 2 · 两个系统的宏观定位与架构总图
    1. 2.1 用一句话说清各自的定位
    2. 2.2 架构总图
    3. 2.3 关键数字对照
    4. 2.4 第一个值得记住的洞察
  4. 3 · 垂直分层与水平分区
    1. 3.1 垂直分层:六层模型
    2. 3.2 水平分区:同一层内部怎么切
    3. 3.3 一个反直觉的观察:巨型文件
    4. 3.4 分层之外:穿透所有层的四类逻辑
  5. 4 · 智能体主循环与错误恢复状态机
    1. 4.1 教科书版本的循环,以及它会死在哪
    2. 4.2 Claude Code 的做法:写成显式状态机
    3. 4.3 错误扣留:可恢复的错误不能立刻往外发
    4. 4.4 中断的正确处理姿势
    5. 4.5 切换备用模型:一个想不到的坑
    6. 4.6 Hermes 的做法:预算驱动的循环
    7. 4.7 Hermes 独有的能力:轮次中途插话
    8. 4.8 两家对照与取舍
  6. 5 · 工具抽象层与工具执行编排
    1. 5.1 Claude Code 的工具接口:一个教科书级抽象
    2. 5.2 渐进式工具加载:工具搜索机制
    3. 5.3 工具清单的装配:一个只有深度用过缓存才知道的坑
    4. 5.4 执行编排:并发分区
    5. 5.5 流式工具执行器:边流边执行
    6. 5.6 Hermes 的工具层:中心分发 + 参数强制矫正
    7. 5.7 两家对照与取舍
  7. 6 · 上下文治理阶梯 ★ 全文核心
    1. 6.1 Claude Code 的做法:五级流水线
    2. 6.2 第 ① 级:工具结果预算与落盘
    3. 6.3 第 ③ 级:缓存编辑 —— 全文最精彩的一处
    4. 6.4 第 ⑤ 级:自动摘要压缩的工程细节
    5. 6.5 上下文真的超了之后:三级恢复瀑布
    6. 6.6 Hermes 的做法:把整条阶梯抽象成一个插座
    7. 6.7 两家横向对照
  8. 7 · 权限模型与安全边界
    1. 7.1 Claude Code 的做法:十级决策级联
    2. 7.2 自动模式:用模型判断安全性,加三级快速通道
    3. 7.3 Hermes 的做法:正则红线 + 对抗性解析
    4. 7.4 Hermes 的第二道防线:执行环境隔离
    5. 7.5 一个常被忽略的攻击面:错误消息回灌
    6. 7.6 两家对照与取舍
  9. 8 · 多智能体协作编排
    1. 8.1 子智能体到底解决什么问题
    2. 8.2 Claude Code 的三种子智能体形态
    3. 8.3 分叉子智能体:把提示词缓存用到极致
    4. 8.4 子智能体的工具限制
    5. 8.5 Hermes 的做法:任务委派 + 看板协作
    6. 8.6 一个两家共同的硬约束:中断的级联
  10. 9 · 记忆系统与扩展体系
    1. 9.1 记忆:两种截然不同的答案
    2. 9.2 Hermes 的全息记忆值得单独看
    3. 9.3 Claude Code 的记忆预取:藏在流水线里的优化
    4. 9.4 扩展体系:四种扩展点
    5. 9.5 Hermes 的网关层:Claude Code 完全没有的一层
  11. 10 · 两个系统的横向对照总表
    1. 10.1 机制对照
    2. 10.2 两条架构路线各自的账本
    3. 10.3 两家一致的地方 = 事实上的行业共识
  12. 11 · 可以搬到自己项目里的实现范式
    1. 11.1 骨架一:带恢复状态机的主循环
    2. 11.2 骨架二:上下文治理阶梯
    3. 11.3 骨架三:工具抽象 + 并发分区
    4. 11.4 骨架四:按信任边界配置工具面
    5. 11.5 骨架五:不可绕过的安全底座
    6. 11.6 自建智能体的决策清单
    7. 11.7 一页纸检查清单
  13. 12 · 面试话术卡
    1. Q1 · 说说你理解的智能体架构
    2. Q2 · 上下文满了怎么办
    3. Q3 · 怎么防止智能体执行危险命令
    4. Q4 · 多工具并行怎么保证不出竞态
    5. Q5 · 智能体循环怎么防止无限循环
    6. Q6 · 什么时候该用子智能体
    7. Q7 · 智能体的长期记忆怎么做
    8. Q8 · 你怎么优化智能体的成本和延迟
    9. Q9 · 反问环节可以问的问题
    10. 最后:这份文档的正确用法
  14. 13 · 术语表
    1. 13.1 模型与调用
    2. 13.2 提示词缓存(全文最重要的概念组)
    3. 13.3 智能体与工具
    4. 13.4 上下文治理
    5. 13.5 编程与架构概念
    6. 13.6 两个系统的关键模块名

4 · 智能体主循环与错误恢复状态机

4.1 教科书版本的循环,以及它会死在哪

如果你去搜「怎么写一个智能体」,得到的答案基本都是这五行:

while True: # 无限循环 resp = llm(messages) # 把全部历史发给模型 if not resp.tool_calls: break # 没有工具调用 = 任务完成,退出 results = [run(tc) for tc in resp.tool_calls] # 执行所有工具调用 messages += [resp, *results] # 把模型的回复和执行结果追加到历史里

llm 是 large language model 的缩写,指调用大语言模型;resp 是 response 即回复;tool_calls 就是第 1.5 节讲的那些「便条」。)

这五行确实能跑通一个演示。但把它放到真实环境里,它会死在下面每一条上:

会遇到的问题为什么这五行处理不了
上下文超了怎么办? 第 5 行会无限追加内容,迟早撞上上下文窗口上限,API 返回 413 错误。而且更麻烦的是:压缩之后还超怎么办?压缩这个动作本身失败了怎么办?
模型的输出被截断了怎么办? 模型单次输出也有上限。写到一半被切断时,如果只是把这个截断的回复原样追加进历史再问一遍,模型通常会从头开始道歉并重述 —— 白花一大笔 token。
用户中途按了 Ctrl+C 怎么办? 此时可能有几个工具调用已经发给了模型,但工具还在执行、执行结果还没产生。直接退出的话,历史里就有「孤儿工具调用」—— 有便条没有回复。下一轮 API 调用会因为这个不完整的配对直接报错。
模型服务过载要换备用模型怎么办? 不能简单换个模型重发。因为历史里可能有模型的「思考块」(thinking block),而思考块带有和原模型绑定的数字签名,把它重放给另一个模型会被拒绝。
循环永远不停怎么办? 这五行里没有任何计数器。谁来数轮次?谁来数钱?而且真正的死循环几乎都不来自主流程,而来自恢复逻辑互相触发 —— 这一点后面会详讲。

真实的智能体主循环,90% 的代码在处理这五行之外的东西。这正是两套系统最值得学的地方。

4.2 Claude Code 的做法:写成显式状态机

先解释什么是「状态机」

状态机是一种编程模式,它要求你把程序的运行情况归纳成有限的几个具名状态,并且明确写出「在什么条件下,从哪个状态跳到哪个状态」。

举个日常例子:一个电商订单的状态机是「待付款 → 已付款 → 已发货 → 已完成」,加上「已取消」「已退款」两个分支。每条箭头都有明确的触发条件(付款成功、超时未付、发起退款)。

这样写的好处是:你可以穷举所有可能的路径并逐条测试,而不是祈祷自己没漏掉某个情况。

Claude Code 把跨轮次的状态收进一个结构体

type State = {
  messages: Message[]                       // 当前的完整消息历史
  toolUseContext: ToolUseContext            // 工具执行需要的上下文对象

  // 下面这些字段全部是"恢复用的记账"——
  autoCompactTracking: ... | undefined      // 自动压缩的追踪状态
  maxOutputTokensRecoveryCount: number      // 输出被截断后已经重试了几次
  hasAttemptedReactiveCompact: boolean      // 这一轮是否已经做过反应式压缩(幂等锁)
  maxOutputTokensOverride: number | undefined  // 是否已经把输出上限升过档
  pendingToolUseSummary: Promise<...> | undefined
  stopHookActive: boolean | undefined
  turnCount: number                         // 已经进行了几轮

  transition: Continue | undefined          // ★ 上一轮是"因为什么原因"继续的
}

claude-code/src/query.ts · State 类型定义

type State = {...} 是 TypeScript 语言定义一个数据结构的写法,可以理解成「这个叫 State 的东西,包含以下这些字段」。number 是数字,boolean 是真/假,undefined 表示「这个字段现在没有值」。)

最后那个 transition 字段是点睛之笔

transition 这个字段完全不参与业务逻辑。它存在的唯一目的,是记录「上一轮循环是因为什么原因决定继续的」。源代码里的注释直接说明了原因:

「Why the previous iteration continued. Undefined on first iteration. Lets tests assert recovery paths fired without inspecting message contents.」

译:上一轮为什么继续。第一轮时是空的。它让测试代码可以直接断言某条恢复路径被触发了,而不必去翻消息内容。

为什么这很重要?举个具体例子。假设你要写一个测试,验证「输出被截断时会正确重试」:

没有 transition 字段时有 transition 字段时
只能去翻消息数组,检查里面有没有出现那句提示文案("Output token limit hit...")。

问题:这个测试和文案强耦合。哪天有人改了一个字,测试就红了 —— 但功能其实没坏。这种测试会被团队慢慢禁用掉。
直接断言一行:
expect(transition.reason).toBe('max_output_tokens_recovery')

好处:测的是「哪条恢复路径被走了」这个事实本身,和任何文案、任何消息格式都无关。
可以搬走的做法

在你自己的状态机里,加一个纯粹为了可观测和可测试而存在的字段,记录「这次状态转移的原因」。它不参与任何业务判断,但它让你的错误恢复逻辑第一次变得可测试。

代价是多一个字段;收益是每条恢复路径都能被单独验证。这个交易非常划算。

智能体循环错误恢复状态机
智能体循环错误恢复状态机 — 中间发光的 CALL MODEL(调用模型)是唯一的枢纽,所有恢复路径都回到它。六条橙色的恢复边各自带着自己的幂等锁(图上标在 guard 后面);只有绿色的 next_turn(正常推进)会重置全部计数器点击放大

七条转移边逐条解释

循环里有 7 个 continue 语句(continue 的意思是「跳过本轮剩余部分,直接开始下一轮循环」)。每一个都对应一条具名的恢复路径:

路径名称 什么情况会触发 做什么动作
next_turn
正常推进
模型这一轮返回了工具调用,并且工具已经执行完了 把模型回复和执行结果追加进历史,重置所有恢复计数器,进入下一轮
collapse_drain_retry API 返回 413(上下文过长) 先「排空」暂存的上下文折叠 —— 这是最便宜的恢复手段,而且能保住细粒度信息
reactive_compact_retry 413,而且上一步排空之后仍然超 做一次完整的摘要压缩。每轮只允许一次(幂等锁:hasAttemptedReactiveCompact
max_output_tokens_escalate 模型的输出被默认的 8,000 token 上限截断了 同一个请求原封不动重发一次,只是把输出上限提到 64,000。每轮只允许一次
max_output_tokens_recovery 提到 64,000 之后还是被截断 注入一条「接着写」的指令(下面会详细看这条指令)。最多 3 次
stop_hook_blocking 用户配置的「结束前检查钩子」判定这个回答不合格 把钩子给出的错误信息当成一条用户消息注入,让模型自己修
token_budget_continuation 用户设置了 token 预算,而且还没用完 注入一条「继续深入」的提示,让模型把剩余预算用掉

那条「接着写」的指令值得单独看

const recoveryMessage = createUserMessage({
  content:
    `Output token limit hit. Resume directly — no apology, no recap ` +
    `of what you were doing. Pick up mid-thought if that is where the ` +
    `cut happened. Break remaining work into smaller pieces.`,
  isMeta: true,
})

claude-code/src/query.ts · 输出截断恢复

这段英文的意思是:「输出 token 上限到了。直接继续 —— 不要道歉,不要复述你刚才在做什么。如果是在句子中间被切断的,就从那里接上。把剩下的工作拆成更小的块。」

三句话解决三个真实问题:

  • 「不要道歉」 —— 模型的默认行为是先说一句「抱歉,我的回复被截断了」。这句话本身就要花钱,而且毫无信息量。
  • 「从句子中间接上」 —— 不这样明确许可的话,模型倾向于把刚才那段重新说一遍再往下写,浪费更多 token。
  • 「拆成更小的块」 —— 防止下一次又被截断,陷入反复截断的循环。

isMeta: true 这个标记的作用是:这条消息只发给模型,不显示给用户看。用户看到的是连贯的输出,感觉不到中间发生过一次截断恢复。

每条恢复路径都必须有幂等锁

这是全章最重要的一条经验

请注意上面表格里每条路径的限次条件:

  • hasAttemptedReactiveCompact —— 一个真/假开关,做过就锁住
  • maxOutputTokensRecoveryCount < 3 —— 一个计数器,超过 3 次就放弃
  • maxOutputTokensOverride === undefined —— 检查「还没升过档」,只允许升一次

源代码里有一段注释,记录了漏掉这种锁的真实后果:

「Resetting to false here caused an infinite loop: compact → still too long → error → stop hook blocking → compact → … burning thousands of API calls.」

译:在这里把标记重置成 false 曾导致一个无限循环:压缩 → 还是太长 → 报错 → 结束钩子判定不合格要求重试 → 又去压缩 → …… 烧掉了几千次 API 调用。

注意这个循环的形状:它不是一条路径自己转圈,而是两条恢复路径互相触发。压缩路径和钩子路径各自看起来都有终止条件,但组合起来就成了死循环。恢复路径没有幂等锁 = 生产事故。

关键细节:锁只在正常推进时重置

看那张状态机图上的绿色边:只有 next_turn(正常推进,也就是模型给了工具调用、工具执行成功)这条路径,才会把所有恢复计数器清零。

为什么?因为「正常推进」意味着系统真的往前走了一步,之前的问题解决了。而走恢复路径时绝不能清零 —— 恢复本身不代表问题解决了,如果清零,就等于允许无限次重试。上面那段注释里的事故,本质就是在恢复路径上清零了。

4.3 错误扣留:可恢复的错误不能立刻往外发

这是 Claude Code 一个很精巧、也很容易被忽略的设计。

先说清楚背景:Claude Code 的主循环是一个「生成器」(generator)—— 它一边处理一边把消息吐给外部调用方(可能是终端界面,也可能是桌面应用、软件开发工具包)。

现在的问题是:当 API 返回一个可以恢复的错误(上下文过长、输出被截断、图片过大)时,该不该把这个错误吐给外部?

Claude Code 的答案是不吐,先扣住

let withheld = false                          // withheld = 被扣留的

// 三类可恢复错误,任何一类命中就扣住
if (contextCollapse?.isWithheldPromptTooLong(message, ...)) withheld = true
if (reactiveCompact?.isWithheldPromptTooLong(message))      withheld = true
if (mediaRecoveryEnabled &&
    reactiveCompact?.isWithheldMediaSizeError(message))      withheld = true
if (isWithheldMaxOutputTokens(message))                      withheld = true

if (!withheld) { yield yieldMessage }         // 没被扣住的才吐出去

// 但无论扣不扣,都要放进内部数组,供下面的恢复逻辑找到它
if (message.type === 'assistant') assistantMessages.push(message)

claude-code/src/query.ts · 流式循环内部

只有当所有恢复路径都试过、都失败了,才把这条错误吐出去。

为什么必须这样做

源代码注释:「Yielding early leaks an intermediate error to SDK callers (e.g. cowork/desktop) that terminate the session on any error field — the recovery loop keeps running but nobody is listening.」

译:过早吐出去会把一个中间状态的错误泄露给外部调用方(比如桌面应用),而那些调用方看到任何 error 字段就会终止会话 —— 于是恢复循环还在勤勤恳恳地跑,但已经没有人在听了。

这是「内部可恢复状态不应该泄露到外部协议」的经典案例。任何做流式接口的服务端都会遇到同类问题:你内部正在优雅重试,但你已经把一个 error 字段发出去了,对方的客户端已经按「出错了」的逻辑挂断了连接。

4.4 中断的正确处理姿势

用户按下 Ctrl+C 时,系统可能正处在这样一个状态:

已经发生的: · 模型返回了 3 个工具调用(3 张便条),每张都有一个唯一的 id · 工具 1 执行完了 · 工具 2 正在执行中 · 工具 3 还在队列里排队 现在用户按了 Ctrl+C,如果直接退出 —— 历史里有 3 个 tool_use,但只有 1 个 tool_result → 工具 2 和工具 3 成了"孤儿工具调用" → 下一次 API 调用时,服务器发现便条和回复对不上,直接返回 400 错误 → 这场会话彻底废了,用户无法用 --resume 恢复

(400 是另一个网络错误码,表示「你发来的请求格式不合法」。和 413「内容太长」是不同的错误。)

Claude Code 的处理分两条路:

if (toolUseContext.abortController.signal.aborted) {     // 检测到中止信号
  if (streamingToolExecutor) {
    // 用了流式执行器的情况:
    // 必须消费 getRemainingResults(),它会为"排队中"和"执行中"的工具
    // 生成合成的(也就是假造的)tool_result
    for await (const update of streamingToolExecutor.getRemainingResults()) {
      if (update.message) yield update.message
    }
  } else {
    // 没用流式执行器的情况:
    // 为每一个 tool_use 兜底造一条标记为错误的 tool_result
    yield* yieldMissingToolResultBlocks(assistantMessages, 'Interrupted by user')
  }
  return { reason: 'aborted_streaming' }
}

claude-code/src/query.ts · 中断处理

那个兜底函数 yieldMissingToolResultBlocks(意思是「吐出缺失的工具结果块」)实现很简单,但它是整个系统的安全网

function* yieldMissingToolResultBlocks(assistantMessages, errorMessage) {
  for (const assistantMessage of assistantMessages) {       // 遍历每条模型回复
    const toolUseBlocks = assistantMessage.message.content
        .filter(c => c.type === 'tool_use')                  // 找出所有便条
    for (const toolUse of toolUseBlocks) {
      yield createUserMessage({                              // 为每张便条造一条回复
        content: [{ type:'tool_result',
                    content: errorMessage,                   // 内容是错误说明
                    is_error: true,                          // 标记为错误
                    tool_use_id: toolUse.id }],              // ★ 关键:id 必须对上
        ...
      })
    }
  }
}

这个函数在四个地方被调用:用户中断时、切换备用模型时、流式请求失败回退时、以及最外层的异常捕获里。

可以搬走的做法

凡是可能在「已经发出工具调用、但还没产生执行结果」这个时间窗口里退出的代码路径,都必须补齐合成的执行结果。

实现上只有一条规则:每一个 tool_use 的 id,必须有一个 tool_result 带着同一个 id 回应它。内容是什么不重要,可以是「被用户中断」,可以标记为错误 —— 但配对必须完整。

这是自建智能体最常踩、也最难排查的一个坑:症状是「用户一按 Ctrl+C,这个会话就再也恢复不了了」,而报错信息通常只说「请求格式不合法」,完全不提是哪里不合法。

4.5 切换备用模型:一个想不到的坑

当主模型过载(服务器返回「容量不足」)时,需要切换到备用模型重试。Claude Code 在这里做了三件事,第三件是大多数人想不到的:

  1. 把已经产生的模型回复全部打上「墓碑」标记tombstone)—— 从界面和对话记录里彻底删除。因为这些回复来自旧模型,混在历史里会造成混乱。
  2. 丢弃流式执行器里所有待定的结果,重建一个新的执行器 —— 避免带着旧工具调用 id 的孤儿结果泄露到重试后的请求里。
  3. 剥离所有「思考块」的数字签名。
// Thinking signatures are model-bound: replaying a protected-thinking
// block (e.g. capybara) to an unprotected fallback (e.g. opus) 400s.
// Strip before retry so the fallback model gets clean history.
messagesForQuery = stripSignatureBlocks(messagesForQuery)

译:思考块的签名是和模型绑定的:把一个受保护的思考块(比如来自代号 capybara 的模型)重放给一个不受保护的备用模型(比如 opus),会返回 400 错误。所以重试前先剥掉签名,让备用模型拿到干净的历史。

什么是「思考块」?较新的模型在正式回答前会先做一段内部推理,这段推理内容可以被返回给调用方,叫做 thinking block。为了防止被篡改,它带有一个加密签名。签名是特定模型生成的,换模型就验证不通过。

源代码里关于思考块的三条规则,写得像魔法书

The Rules of Thinking(思考块三定律)
  1. 含有 thinking 或 redacted_thinking 块的消息,必须出现在一个允许思考的请求里(max_thinking_length > 0)。
  2. thinking 块不能是内容序列里的最后一个元素。
  3. thinking 块必须在整条模型轨迹期间完整保留 —— 所谓「一条轨迹」指:一个轮次,如果这个轮次里含有工具调用,那么还要包括其后的工具执行结果以及紧接着的下一条模型回复

「Heed these rules well, young wizard… If ye does not heed these rules, ye will be punished with an entire day of debugging and hair pulling.」
译:好好遵守这些规则,年轻的巫师……若你不遵守,你将受到整整一天调试与揪头发的惩罚。

玩笑归玩笑,第 3 条是真正的硬约束,而且它直接限制了第 6 章所有上下文压缩的实现:

压缩、截断、重放这三种操作,任何一处如果切在了「思考块轨迹」的中间,API 就会拒绝整个请求。

这意味着:你不能简单地说「保留最后 6 条消息,前面全压缩掉」—— 如果第 7 条消息是一个思考块,而第 6 条是它对应的工具结果,你就把一条完整轨迹劈成了两半。保护窗口的边界必须落在轨迹的缝隙上,不能落在轨迹中间。

4.6 Hermes 的做法:预算驱动的循环

Hermes 的主循环入口条件本身就带了三个约束:

while (api_call_count < agent.max_iterations           # 调用次数没超上限
       and agent.iteration_budget.remaining > 0)       # 迭代预算还有余额
      or agent._budget_grace_call:                     # 或者:还有一次"宽限调用"

hermes-agent/agent/conversation_loop.py 第 2029 行

最后那个 _budget_grace_call(宽限调用)是个很有意思的设计:预算耗尽时不是硬性切断,而是再给模型一次机会,通常用来让它把已有的结果总结一下再退出。这一次调用之后无条件退出:

if agent._budget_grace_call:
    agent._budget_grace_call = False          # 消费掉宽限标记,下一轮就退出了
elif not agent.iteration_budget.consume():    # 尝试扣一次预算,扣不动了
    _turn_exit_reason = "budget_exhausted"    # 记录退出原因
    break

Hermes 也维护了一个 _turn_exit_reason(本轮退出原因)字符串,作用和 Claude Code 的 transition 字段类似 —— 纯粹为了可观测。已经在代码里见到的取值包括 interrupted_by_user(被用户中断)、review_input_budget_exhausted(审查输入预算耗尽)、budget_exhausted(预算耗尽)。

可以搬走的做法:软着陆比硬切断好

预算撞线时直接 break,用户看到的是一个半成品加一句「预算用完了」。

给一次宽限调用,用户看到的是「我已经完成了 A 和 B,C 还没做完,当前进度是……」。同样的成本上限,体验差距很大。

4.7 Hermes 独有的能力:轮次中途插话

这是 Claude Code 完全没有的功能:模型正在思考的时候,用户可以插一句话,而且这句话在本轮就生效。Hermes 管这个功能叫 /steer(引导)。

实现这个功能的难点有两个,而且都和前面讲过的概念直接相关:

难点为什么难
不能破坏角色交替 API 要求消息必须按「用户 → 模型 → 用户 → 模型」交替出现。如果模型刚说完话,你又插一条用户消息,形式上没问题;但如果模型正在等工具结果,你插一条用户消息进去,就打断了「工具调用 → 工具结果」的配对,请求会被拒绝。
不能破坏提示词缓存 见第 1.8 节。往对话中间插入一条新消息,等于改变了上下文的中段 —— 从插入点往后的缓存全部失效。

Hermes 的解法很聪明:不新增消息,而是把插话追加到「最新一条工具结果消息」的末尾

_pre_api_steer = agent._drain_pending_steer()       # 取出待处理的插话
if _pre_api_steer:
    for _si in range(len(messages) - 1, -1, -1):    # 从最后一条消息往前找
        _sm = messages[_si]
        if _sm.get("role") == "tool":               # 找到最近的一条"工具结果"消息
            marker = format_steer_marker(_pre_api_steer)
            _sm["content"] = existing + marker      # ★ 追加到它末尾,不新增消息
            _injected = True
            break
    if not _injected:
        # 还没有任何工具结果消息(比如第一轮)——
        # 把插话放回队列,等下一批工具结果出现时再注入
        agent._pending_steer = _pre_api_steer

hermes-agent/agent/conversation_loop.py · 调用 API 前排空插话队列

可以搬走的做法:预留一个「带外信号注入点」

往对话里塞一条新消息 = 破坏缓存前缀 + 可能破坏角色交替。
往「最后一条消息的末尾」追加 = 只让最后一小段缓存失效,前面全部命中。

这个「最新工具结果消息的尾部」槽位,在 Hermes 里被复用了至少三次:
· /steer 用户中途插话
· 墙上时钟预算用到 80% 时的「请开始收尾」提醒
· 待办事项列表的提示

在你自己的项目里,可以固定预留这一个注入点,让所有「带外信号」都从这里进。这样你只需要保证一个地方的缓存安全性,而不是每加一个功能就重新思考一遍。

4.8 两家对照与取舍

Claude Code
  • 状态机显式:所有跨轮次状态收进一个 State 结构体,加具名的 transition 字段
  • 恢复路径分层递进:先试便宜的,不行再试贵的
  • 可恢复错误先扣住,不泄露到外部协议
  • 预算维度多(轮次 / 美元 / token / 任务预算)但都是硬闸,撞线即停
  • 代价:逻辑高度耦合 Anthropic 接口的具体语义(思考块签名、缓存编辑、任务预算),换供应商基本要重写循环层
Hermes
  • 状态挂在 agent 对象的属性上agent._xxx),不是独立结构体
  • 循环入口就是预算闸门,加一次 宽限调用做软着陆
  • 支持轮次中途插话(用户引导 / 重定向 / 收尾提醒)
  • 失败恢复面更宽:供应商切换、凭据轮转、413 重试
  • 代价:主循环单文件 8,676 行,状态散落在几十个对象属性上,可测试性明显弱于 Claude Code
面试追问:你的智能体循环怎么防止无限循环?

不要只答「设一个最大轮次上限」。那是兜底,不是主要手段。完整回答分三层:

  1. 硬闸 —— 最大轮次、最大美元花费、最长运行时间。这是最后一道防线,正常情况不该碰到它。
  2. 每条恢复路径独立限次,而且只在正常推进时重置。这是核心。真正的死循环几乎都不来自主流程,而来自两条恢复路径互相触发:压缩失败 → 报错 → 结束钩子判定不合格要求重试 → 又去压缩 → …… Claude Code 源码里就有一条注释记录了这个事故,说是因为在恢复分支里错误地重置了幂等锁,烧掉了几千次 API 调用。
  3. 软着陆 —— 预算快耗尽时给一次「宽限调用」让模型收尾,比硬切断的用户体验好得多。这是 Hermes 的做法。

如果想再加一层深度,补一句:失败路径要能识别「这个失败不该触发常规的质量重试机制」。比如上下文超长导致的失败,绝对不能交给「结束前质量检查」钩子处理 —— 因为那个钩子会往上下文里注入更多内容,越注入越超,源码里管这叫「死亡螺旋」。