三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

【系列:手搓自主 AI Agent:Hermes 架构原理剖析 · 第 5 篇】

【系列:手搓自主 AI Agent:Hermes 架构原理剖析 · 第 5 篇】

上下文压缩与错误恢复:长对话不失控、故障不宕机

导读:Agent 跑得越久,上下文越膨胀、故障越频繁。本文拆解 learn-hermes-agent 阶段 1 收官代码:三层递进压缩把 50K tokens 压回可控范围,四类错误路径让 Agent 从崩溃边缘自动恢复。读完你也能写出不失控、不宕机的单 Agent。

你的 Agent 正在被两个问题杀死

先看两个真实场景。

场景一:你让 Agent「读取 agents 目录下的所有文件」。它开始 read_file,一个文件塞进上下文,又一个……读到第 4 个文件,上下文估算撞线 50000 tokens。压缩触发,中间几十条 tool 输出被压成摘要。但摘要没写清楚「还剩哪些文件」,模型只能重新规划,甚至重复读已读文件。于是——压缩、重读、再压缩、再重读。死循环。

场景二:Agent 跑得好好的,突然 API 返回 429。主循环没做任何错误处理,直接崩。你盯着终端,前面 20 分钟的工作白费了。或者更糟:模型输出被截断(finish_reason: length),Agent 以为任务完成了,实际只做了一半。

这两个问题,一个叫上下文膨胀,一个叫故障崩溃。它们是生产级 Agent 绕不过去的两座山。

今天拆解的s05_context_compression.py(28731 字节)和s06_error_recovery.py(21177 字节),就是专门翻这两座山的。这也是 Hermes 系列阶段 1 的收官——一个能压缩上下文、能从错误中恢复的单 Agent。

上下文窗口不是无限的

先看清敌人长什么样。

你的 Agent 每轮对话,messages 数组里堆着:系统提示、用户请求、每轮工具调用的输入输出、模型回复。读大文件,输出几万字;跑长命令,结果几百行;多轮工具调用,旧结果越积越多。

三个后果:

  • 模型注意力被淹没——重要信息淹没在旧输出里,模型「忘」了最初的目标
  • API 越来越贵——每轮都把所有历史发给模型,token 费用线性上涨
  • 撞上限——最终触发上下文窗口上限,任务中断

s05 用三个常量定义压缩策略:

COMPRESSION_THRESHOLD=50000# 估算 token 超过这个阈值就触发压缩TAIL_TOKEN_BUDGET=20000# 尾部预算:从后往前累加,直到撞线COMPRESSION_MIN_SHRINK=0.9# 压缩后必须降到原 token 的 90% 以下

阈值 50K,尾部保留 20K,压缩后必须缩小到 90% 以下。这三个数字定义了「什么时候压、留多少、压到什么程度」。

第 1 层:裁剪旧工具输出,不花钱

最便宜的压缩,是裁剪

prune_old_tool_results做的事很简单:把旧的工具输出消息的 content 替换成占位符"[Old tool output cleared]"

注意一个关键细节:不删除消息本身。为什么?因为 assistant 的 tool_call 消息和 tool 的 response 消息必须配对——靠tool_call_id关联。你删了 tool 消息,assistant 那边还引用着这个 ID,API 直接报错。

所以裁剪只替换 content,保留消息结构和 ID。token 省了,配对关系还在。

这一层不调用任何 LLM,不花钱,纯字符串操作。能压多少压多少,压不动再上第二层。

第 2 层:保护头尾,找边界

裁剪不够,就得动真格的了。

find_boundaries(protect_first, tail_token_budget)的策略是:头部前 N 条消息不动(通常是系统提示和最初的用户请求),尾部从后往前累加 token,直到撞上 20000 的预算。

头部是任务的「初始条件」,动了它模型就不知道自己在干嘛。尾部是「最近记忆」,模型刚做完的事、刚拿到的结果都在这里,动了它就断片。

中间那段,就是要被压缩的「历史包袱」。

但切分有个坑:切点可能落在 tool 消息中间。想象一下:一条 assistant 消息说「我要调用 read_file」,对应的 tool 消息返回了文件内容。如果你把切点放在这两条消息之间,就制造了一个「孤儿 tool 消息」——assistant 的调用没有对应的返回,或者 tool 的返回没有对应的调用。API 直接报配对错误。

_align_to_assistant_boundary就是干这个的:把切点吸附到最近的、非 tool 消息的 assistant 消息上。宁可多保留几条,也不能切出孤儿。

第 3 层:辅助 LLM 摘要,最贵但最有效

裁剪和切分都是「暴力手段」——信息直接丢了。真正能保留信息的压缩,是摘要

summarize_middle用结构化模板做摘要:

  • Goal(目标)
  • Progress(进展)
  • Key Decisions(关键决策)
  • Files Modified(改过的文件)
  • Next Steps(下一步)

这个模板不是随便定的。它覆盖了「任务连续性」所需的全部信息维度:目标是什么、做到哪了、决定了什么、动了哪些文件、接下来干嘛。

更妙的是增量更新:传入原始用户请求 + 上一份摘要,生成新的摘要。不是从头总结全部历史,而是在旧摘要基础上叠加新进展。信息熵不衰减——每次压缩都基于上次压缩的结果,而不是把历史全部抹掉重来。

还有一个反直觉的细节:摘要消息的 role 必须是assistant

为什么?如果 role 是user,模型会把它理解成新的用户指令,触发重新规划——Agent 可能抛开原任务,开始执行「摘要」里的内容。如果 role 是system,部分模型会忽略中段的 system 消息。

所以 s05 给摘要加了个前缀:

[CONTEXT COMPACTION - system-generated summary of earlier turns, not a new instruction]

明确告诉模型:这是系统生成的摘要,不是新指令。别当真。

压缩卡死?直接早退

压缩不是万能的。有一种情况:头部本身就太大——比如用户第一条消息就塞了 10 万字的文档。保护头部不动,中间可压缩区太小,压完还是超过 90% 的阈值。

这就是CompressionStuckError的用武之地。主循环捕获这个异常后,直接早退,告诉用户「会话已无法压缩,请新开 session」。

为什么要早退而不是继续循环?看看 issue #2 客户报告的真实问题:「一直压缩、一直循环」。压缩不达标 → 再压 → 还不达标 → 再压……直到 MAX_ITERATIONS 才停。浪费大量 API 调用,最后任务还是失败。

压缩失败就承认失败,让用户新开会话。这是工程上的务实选择。

TaskState:摘要救不了的,用不可压缩区来救

摘要是有损的。这是本质缺陷。

回到开头的场景:Agent 读 agents 目录下的所有文件,读到第 4 个撞阈值,中间几十条 tool 输出被压成摘要。摘要没写「还剩哪些文件」,模型只能重新规划,甚至重复读已读文件。

摘要救不了任务连续性。因为它是有损的,而任务状态需要无损。

s05 的答案是TaskState——一个不可压缩区

@dataclassclassTaskState:goal:str=""todos:list[dict]=field(default_factory=list)# 每个 todo: {"id", "subject", "status": pending|in_progress|completed}

这个对象不进 messages 数组,压缩算法根本看不见它,永远不会被裁剪或摘要。每轮 API 调用前,task_state.render()把它拼到 system prompt 末尾,格式是:

# Task State (live, never compressed) ## Goal 读取 agents 下所有文件 ## TODO [x] 读取 config.py [~] 读取 s05_context_compression.py [ ] 读取 s06_error_recovery.py

三个工具维护这个状态:task_set_goal(goal)设目标、todo_write(items)写清单、todo_update(id, status)更新状态。

压缩随便压,TaskState 永远无损。模型每轮都能看到「还剩哪些文件没读」,不会重复劳动。

注意区分:TaskState 和后面 s07 的 memory 是两回事。TaskState 是单次任务内的活跃状态,会话结束就丢;memory 是跨会话的持久化记忆(用户偏好、历史教训)。一个管当下,一个管长期。

上下文救回来了,故障呢?

压缩解决了「对话越长越贵」的问题。但 Agent 跑在生产环境,还要面对另一个残酷现实:API 会出错

模型输出截断、上下文太长 400、网络超时、限流 429、API key 过期 401、模型不存在 404……多提供商场景下(200+ 模型),错误种类更多,措辞还不一样。

没有恢复机制,主循环在第一个错误上就崩了。

s06 的答案是:先把错误分类,再按类处理

四类错误路径:分类器把状态码翻译成决策

classify_error返回一个结构化决策 dict:

{"reason":"rate_limit"|"context_overflow"|"server_error"|"auth"|"model_not_found"|"unknown","retryable":bool,"should_compress":bool,"should_fallback":bool}

映射关系很清晰:

  • 429→ rate_limit,可重试
  • 400 + context→ context_overflow,该压缩
  • 500/502/503→ server_error,可重试
  • 401/403→ auth,该故障转移
  • 404→ model_not_found,该故障转移
  • 其他→ unknown,全 False

为什么要把错误分类?因为不同的错误需要完全不同的处理策略。限流和服务器错误是暂时的,重试就好;上下文太长是可修复的,压缩再试;认证失败和模型不存在是不可恢复的,重试一万次也没用,得换路。

输出截断?续写

模型生成到一半,token 用完了,finish_reason: length。这不是错误,是「话没说完」。

s06 的处理:发一条续写消息"Please continue from where you left off.",让模型接着写。最多续写 3 次。

但有个陷阱:thinking-budget 检测。有些模型把 token 全花在「思考」上了,输出几乎为空,finish_reason 也是 length。这时候续写没有意义——模型不是在生成内容,是在空转。s06 会检测这种情况,直接报错,不浪费续写次数。

退避重试:加抖动,别撞车

429 限流、500 服务器错误,这类临时故障的处理方式是退避重试

sleep时间按指数退避 + 随机抖动:

delay=min(base_delay*(2**(attempt-1)),max_delay)# 5s→10s→20s...jitter=random.uniform(0,delay*0.5)returndelay+jitter

指数退避给服务器喘息时间——第一次等 5 秒,第二次 10 秒,第三次 20 秒,封顶。

抖动是干嘛的?想象你有 10 个 Gateway 会话同时收到 429,如果大家都按 5s→10s→20s 的节奏重试,第 3 次重试时大家又同时撞上去——这就是thundering herd(惊群效应)。加随机抖动,让每个会话的重试时间错开,避免二次撞车。

故障转移:换一组配置而已

401/403/404 这类不可恢复错误,重试没用,得换路

switch_to_fallback做的事:如果配置了FALLBACK_MODEL,就换 base_url 和 api_key,重建 OpenAI 客户端。

因为 Hermes 走的是 OpenAI 兼容接口,故障转移本质上只是换一组配置——换 provider、换模型、换 key。API 调用代码一行不用改。

更妙的是主模型恢复:下一轮run_conversation会尝试切回主模型。故障转移是临时的,不是永久的。主模型缓过来了就切回去,别赖在备用模型上不走。

主循环现在同时维护三件事

把压缩和错误恢复接入主循环后,Hermes 的 Agent 主循环现在同时干三件事:

  1. 任务推进——正常的工具调用、模型推理
  2. 上下文预算——每轮发请求前检查 token,超了就压缩
  3. 错误恢复——try/except 捕获异常,分类、决策、执行恢复路径

每条路径有自己的重试预算:续写最多 3 次、退避最多 MAX_RETRIES 次、压缩有 COMPRESSION_MIN_SHRINK 阈值。不会无限重试,不会死循环。

这是阶段 1 的收官。一个单 Agent 现在具备了:任务执行(Loop)、工具调用、持久化、提示词管理、上下文压缩、错误恢复。可以上生产了。

阶段 2 开始补智能层:记忆(MEMORY.md)、技能(SKILL.md)、安全、委派、配置。

避坑指南:初学者最容易犯的 11 个错

把 s05 和 s06 的坑合并精选,给你一份避坑清单:

上下文压缩(s05)

  1. 裁剪时删除 tool 消息——破坏了 assistant↔tool 配对,API 报错。只替换 content,不删消息
  2. 切点落在 tool 消息中间——制造孤儿 tool 消息。用_align_to_assistant_boundary吸附切点
  3. 摘要 role 用 user——模型把摘要当新指令,触发重新规划。必须用 assistant + 前缀声明
  4. 压缩失败还硬压——头部太大压不动,死循环。抛CompressionStuckError早退
  5. TaskState 放 messages 里——被压缩算法看见就白设计了。放独立数据结构,不进 messages
  6. token 估算太精确——用字符数 // 4 的粗略估算就够了,中文偏高估偏保守,够用

错误恢复(s06)

  1. 把所有错误当一种——429 和 401 的处理策略完全不同。先分类再决策
  2. 没有重试预算——无限重试等于死循环。每条路径设上限
  3. 续写提示太模糊——「继续」不如「Please continue from where you left off」明确
  4. 退避不加抖动——多会话同时重试撞车。加随机抖动
  5. 故障转移后不恢复主模型——永远留在备用模型上。每轮尝试切回

阶段 1 收官,下一篇进入智能层

上下文压缩和错误恢复,是 Agent 从「能跑」到「能生产」的分水岭。

压缩解决的是经济问题——token 是钱,上下文是稀缺资源。错误恢复解决的是可靠性问题——Agent 不能一碰就碎。

两件事合在一起,你的 Agent 才能长时间稳定运行。这是生产级 Agent 的底线。

下一篇(第 6 篇)进入阶段 2 的第一块拼图:记忆与技能。MEMORY.md 让 Agent 跨会话记住用户偏好,SKILL.md 让 Agent 把常用操作固化成可复用技能。如果说本文解决的是「不失控、不宕机」,下一篇解决的是「越用越聪明」。

你的 Agent 现在能处理多长的对话?有没有遇到过「一直压缩、一直循环」的坑?评论区聊聊你的实战经历。

参考文献

  • Hermes Agent 教学仓库:agents/s05_context_compression.py(本文代码素材,28731 字节真实可运行)
  • Hermes Agent 教学仓库:agents/s06_error_recovery.py(本文代码素材,21177 字节真实可运行)
  • Hermes Agent 教学仓库:docs/zh/s05-context-compression.mddocs/zh/s06-error-recovery.md(三层压缩、TaskState、四类错误路径详解)

📥源码获取:如需本系列全部源码,请在以下链接克隆:
https://gitcode.com/ganxin7932508/learn-hermes-agent.git


Hermes 架构原理剖析系列

  • 第 1 篇:整体架构与设计哲学
  • 第 2 篇:Agent 主循环与工具调用
  • 第 3 篇:工具注册与动态调度
  • 第 4 篇:持久化与提示词管理
  • 第 5 篇:上下文压缩与错误恢复(本文)
  • 第 6 篇:记忆与技能(下一篇)
← 返回列表