转载--Hermes Agent 18 | 长会话治理:上下文压缩、Token 管理与成本控制
原文连接:Hermes Agent 18 | 长会话治理:上下文压缩、Token 管理与成本控制
无限的上下文是一个幻觉;有限窗口里的智慧取舍才是真功夫。
上一讲我们拆完了 MCP 集成——Agent 能连的东西越来越多了。但工具越多、会话越长,一个残酷的问题就浮上来了:上下文窗口是有限的,而 Agent 的对话可以无限长。
一个典型场景:你让 Hermes 帮你重构一个模块,它读了 15 个文件、跑了 8 次测试、写了 300 行代码。这时候上下文里已经塞了几十万 token 的工具输入输出。再继续聊,要么撞上模型的上下文上限,要么每轮 API 调用的 token 费用让你心跳加速。
Hermes 的解法不是一个"银弹",而是一套分层治理体系——从工具结果产生的那一刻就开始控制体积,到快撞上上下文上限时做有损压缩,再到凭证轮换和计费追踪来控制成本。这一讲我们把这套体系从底到顶拆一遍。
先看全景:四层防线
┌──────────────────────────────────────────────────────────┐
│ 第 4 层:上下文压缩(context_compressor.py, 1,163 行) │
│ 当 prompt_tokens 超过阈值时触发 LLM 辅助的有损压缩 │
├──────────────────────────────────────────────────────────┤
│ 第 3 层:单轮聚合预算(enforce_turn_budget) │
│ 一个 assistant turn 所有工具结果总量不超过 200K 字符 │
├──────────────────────────────────────────────────────────┤
│ 第 2 层:单结果持久化(maybe_persist_tool_result) │
│ 单个工具结果超过 100K 字符时落盘,留 1,500 字符预览 │
├──────────────────────────────────────────────────────────┤
│ 第 1 层:工具内截断(per-tool output cap) │
│ 每个工具自己负责控制输出大小(search_files 等) │
└──────────────────────────────────────────────────────────┘
前三层是"入口管控"——控制进入上下文的数据体积。第四层是"存量治理"——当积累的上下文已经太多时,做有损压缩。
我们从底往上拆。
大结果三层防线:控制进入上下文的数据量
第 1 层:工具内截断
这是最简单的一层,在第 03 讲工具系统里已经提过。每个工具自己负责控制输出大小——search_files 限制返回的匹配数,terminal 截断过长的命令输出。这层是工具作者的责任,框架不强制。
第 2 层:单结果持久化
当一个工具返回的结果超过了阈值,maybe_persist_tool_result() 会把完整内容写入沙箱的临时目录(标准本地环境通常回退到 /tmp/hermes-results/{tool_use_id}.txt;不同后端会优先用各自的 temp dir),然后在上下文中只留一段 1,500 字符的预览 + 文件路径引用。模型后续如果需要完整内容,可以用 read_file 按需读取。
来看 tools/tool_result_storage.py(226 行)的核心逻辑:
def maybe_persist_tool_result(
content: str,
tool_name: str,
tool_use_id: str,
env=None,
config: BudgetConfig = DEFAULT_BUDGET,
threshold: int | float | None = None,
) -> str:
effective_threshold = threshold if threshold isnotNoneelse config.resolve_threshold(tool_name)
if effective_threshold == float("inf"):
return content # read_file 永不持久化——防止 persist->read->persist 死循环
if len(content) <= effective_threshold:
return content # 没超阈值,原样返回
# 超了——写入沙箱
storage_dir = _resolve_storage_dir(env)
remote_path = f"{storage_dir}/{tool_use_id}.txt"
preview, has_more = generate_preview(content, max_chars=config.preview_size)
if env isnotNone:
if _write_to_sandbox(content, remote_path, env):
return _build_persisted_message(preview, has_more, len(content), remote_path)
# 写沙箱失败——回退到行内截断
returnf"{preview}\n\n[Truncated: tool response was {len(content):,} chars.]"
几个关键设计:
阈值解析有优先级(budget_config.py 第 38 行):
def resolve_threshold(self, tool_name: str) -> int | float:
# 优先级:pinned > tool_overrides > registry per-tool > default
if tool_name in PINNED_THRESHOLDS:
return PINNED_THRESHOLDS[tool_name]
if tool_name in self.tool_overrides:
return self.tool_overrides[tool_name]
from tools.registry import registry
return registry.get_max_result_size(tool_name, default=self.default_result_size)
PINNED_THRESHOLDS 里只有一条:read_file 的阈值是 float("inf")。原因很实际——如果 read_file 的结果被持久化了,模型看到"完整输出在文件 X 里",就会调 read_file 去读那个文件,结果又超了又持久化……无限循环。
写入通过 env.execute() ,不是直接写本地文件系统。这意味着无论后端是 Local、Docker、SSH 还是 Modal,持久化都能工作——文件总是写在沙箱环境内。
优雅降级:沙箱写入失败时(比如 Modal 冷启动超时),回退到行内截断,保留预览内容,不会因为一个基础设施问题导致工具调用失败。
第 3 层:单轮聚合预算
第 2 层管的是单个工具结果。但如果一个 assistant turn 并行调了 10 个工具,每个结果都是 50K 字符——单个没超 100K 的阈值,合起来却有 500K。第 3 层管的就是这个。
def enforce_turn_budget(
tool_messages: list[dict],
env=None,
config: BudgetConfig = DEFAULT_BUDGET,
) -> list[dict]:
candidates = [ ]
total_size = 0
for i, msg in enumerate(tool_messages):
content = msg.get("content", "")
size = len(content)
total_size += size
if PERSISTED_OUTPUT_TAG notin content: # 跳过已经持久化的
candidates.append((i, size))
if total_size <= config.turn_budget: # 200K 以内,放行
return tool_messages
# 超了——从最大的开始持久化
candidates.sort(key=lambda x: x[1], reverse=True)
for idx, size in candidates:
if total_size <= config.turn_budget:
break
# threshold=0 强制持久化
replacement = maybe_persist_tool_result(
content=..., tool_name="__budget_enforcement__",
tool_use_id=..., threshold=0,
)
...
算法很直接:算总量 → 超了就从最大的开始往沙箱里"溢出" → 直到总量回到 200K 以内。已经被第 2 层持久化过的结果(内容包含 <persisted-output> 标签)会被跳过。
三层的默认参数(budget_config.py):
|
参数 |
默认值 |
含义 |
|---|---|---|
DEFAULT_RESULT_SIZE_CHARS |
100,000 字符 |
单结果持久化阈值 |
DEFAULT_TURN_BUDGET_CHARS |
200,000 字符 |
单轮聚合预算 |
DEFAULT_PREVIEW_SIZE_CHARS |
1,500 字符 |
持久化后留在上下文中的预览大小 |
这些值可以通过 RL 环境配置(BudgetConfig)覆盖,也可以在工具注册时为特定工具设置单独的阈值。
三层防线在长会话中的表现
在一次持续 40 轮工具调用的重构会话里,三层防线的效果大致是这样的:
-
第 1 层在源头就拦住了最大的输出——比如
search_files在匹配数超过上限时自己截断 -
第 2 层把 3-5 个超大的工具结果(代码文件读取、长命令输出)从上下文中移到沙箱,每个省下 50K-100K 字符
-
第 3 层在几次"一口气跑 5 个测试"的并行工具调用中触发,把聚合结果从 400K 压回 200K
三层加起来,典型的长会话上下文增速会降低 40-60%。但这还不够——随着对话持续,上下文总量还是在涨。这就需要第四层了。
上下文压缩:有损但不丢活
当三层防线拦不住、上下文继续膨胀到逼近模型上下文窗口时,ContextCompressor(agent/context_compressor.py,1,163 行)出场。它做的是有损压缩——用一个 LLM 把中间的历史对话总结成一段摘要,然后丢掉原始消息。
何时触发
触发条件非常直接(第 310 行):
def should_compress(self, prompt_tokens: int = None) -> bool:
tokens = prompt_tokens or self.last_prompt_tokens
if not tokens or tokens < self.threshold_tokens:
return False
# 反抖动保护
if self._ineffective_compression_count >= 2:
return False
return True
threshold_tokens 的计算(第 268 行):
self.threshold_tokens = max(
int(self.context_length * threshold_percent),
MINIMUM_CONTEXT_LENGTH, # 64,000 tokens
)
默认 threshold_percent 是 0.50。对于一个 128K 上下文的模型,阈值是 64K;对于 1M 上下文的 Claude Sonnet 4.6,阈值是 500K。下限是 64K——永远不会低于这个值,避免在大上下文模型上过早触发。
反抖动保护(anti-thrashing):如果连续两次压缩各自省下的 token 不足 10%,就停止压缩。这防止了一种恶劣情况——tail 保护区的消息本身就很大(比如一次读了个巨大的代码文件),怎么压都压不下来,压缩器却一直尝试。
压缩算法:四个阶段
Phase 1: 工具结果预修剪(无 LLM 调用,纯规则)
↓
Phase 2: 确定保护边界(head / middle / tail 三段切分)
↓
Phase 3: 结构化 LLM 摘要(把 middle 段总结成摘要)
↓
Phase 4: 消息组装 + 工具对清理
Phase 1:工具结果预修剪
这是一个"免费"的预处理步骤——不调 LLM,纯靠规则就能砍掉大量 token。_prune_old_tool_results() 做三件事:
1. 重复工具结果去重(第 405 行)。如果同一个文件被 read_file 读了 5 遍,只保留最新的完整副本,其余替换为:
[Duplicate tool output — same content as a more recent call]
用 MD5 前 12 字符做内容指纹,200 字符以下的小结果不参与去重。
2. 旧工具结果替换为信息摘要(第 427 行)。不是简单地丢掉或替换成通用占位符 [cleared],而是生成一行保留关键信息的摘要:
def _summarize_tool_result(tool_name, tool_args, tool_content):
# 不同工具有不同的摘要格式:
# [terminal] ran `npm test` -> exit 0, 47 lines output
# [read_file] read config.py from line 1 (1,200 chars)
# [search_files] content search for 'compress' in agent/ -> 12 matches
这比通用占位符有价值得多——模型后续看到 [terminal] ran npm test -> exit 0 就知道测试通过了,不需要再跑一次。
3. 截断旧 assistant 消息中的大 tool_call arguments(第 449 行)。write_file 的 arguments 里可能有 50KB 的代码内容,在老消息里已经没用了——截断到 200 字符。
什么算"旧"? 这由 tail 保护区决定。_prune_old_tool_results() 会用 token 预算从后往前走,计算出一个"保护边界"(prune_boundary)。边界以内(靠前的)是"旧消息",可以修剪;边界以外(靠后的)是"近期消息",保持原样。
Phase 2:三段切分
压缩器把消息列表切成三段:
messages[0 : protect_first_n] ← head:会话历史最前面的几条消息,永不压缩
messages[protect_first_n : cut] ← middle:要被压缩的部分
messages[cut : end] ← tail:最近的对话,保持原样
head 保护默认 3 条消息(protect_first_n = 3)。在当前实现里,它保护的是会话历史最前面的几条消息;常见情况下这会覆盖首轮用户请求和最早的 assistant/tool 交互。需要注意:system prompt 在 Hermes 的主链路里通常缓存在 _cached_system_prompt,真正发 API 请求前才临时 prepend,不常驻在传给压缩器的 messages 列表里。
tail 保护不是固定消息数,而是基于 token 预算的(第 932 行,_find_tail_cut_by_tokens())。预算是 threshold_tokens * summary_target_ratio,默认 summary_target_ratio = 0.20,所以 tail 预算大约是阈值的 20%。对于 128K 模型,tail 保护约 12.8K token 的最近消息。
def _find_tail_cut_by_tokens(self, messages, head_end, token_budget=None):
if token_budget is None:
token_budget = self.tail_token_budget
soft_ceiling = int(token_budget * 1.5) # 允许超 50% 以避免切断大消息
accumulated = 0
cut_idx = n
for i in range(n - 1, head_end - 1, -1):
msg_tokens = len(content) // _CHARS_PER_TOKEN + 10
if accumulated + msg_tokens > soft_ceiling and (n - i) >= min_tail:
break
accumulated += msg_tokens
cut_idx = i
几个细节值得注意:
-
边界对齐(
_align_boundary_backward()):切分点不能落在 tool_call / tool_result 对的中间——一条 assistant 消息带着 tool_calls 和对应的 tool results 必须在同一侧 -
最新用户消息锚定(
_ensure_last_user_message_in_tail(),第 893 行):确保用户的最新请求永远在 tail 中。这修复了 #10896——压缩后模型不知道自己该做什么,因为用户的最新指令被压到了摘要里 -
硬下限:无论 token 预算如何,tail 至少保留 3 条消息
Phase 3:结构化 LLM 摘要
这是压缩的核心——调一次 LLM,把 middle 段总结成结构化摘要。
摘要预算(第 474 行):
def _compute_summary_budget(self, turns_to_summarize):
content_tokens = estimate_messages_tokens_rough(turns_to_summarize)
budget = int(content_tokens * _SUMMARY_RATIO) # 被压缩内容的 20%
return max(_MIN_SUMMARY_TOKENS, min(budget, self.max_summary_tokens))
|
参数 |
值 |
含义 |
|---|---|---|
_SUMMARY_RATIO |
0.20 |
摘要占被压缩内容的比例 |
_MIN_SUMMARY_TOKENS |
2,000 |
摘要的最小 token 数 |
_SUMMARY_TOKENS_CEILING |
12,000 |
摘要的绝对上限 |
摘要使用一个12 节结构化模板,每一节有明确的职责:
## Active Task ← 最重要:用户最近的请求,逐字复制
## Goal ← 整体目标
## Constraints & Preferences ← 用户偏好、编码风格
## Completed Actions ← 已完成的操作(编号列表,含工具名)
## Active State ← 当前状态:工作目录、分支、修改的文件、测试状态
## In Progress ← 压缩触发时正在进行的工作
## Blocked ← 阻塞项,含完整错误信息
## Key Decisions ← 重要技术决策及原因
## Resolved Questions ← 已回答的问题(含答案,防止新 assistant 重复回答)
## Pending User Asks ← 尚未回答的用户问题
## Relevant Files ← 涉及的文件列表
## Remaining Work ← 剩余工作(作为上下文,不是指令)
## Critical Context ← 具体值、错误信息、配置细节
摘要模板里有一个非常重要的前导指令(_summarizer_preamble):
_summarizer_preamble = (
"You are a summarization agent creating a context checkpoint. "
"Your output will be injected as reference material for a DIFFERENT "
"assistant that continues the conversation. "
"Do NOT respond to any questions or requests in the conversation — "
"only output the structured summary. "
)
两个设计来源:OpenCode 的 "do not respond to any questions" 指令,和 Codex 的 "different assistant" 分离框架。它们解决的是同一个问题——防止摘要模型把历史中的问题当成自己要回答的问题。
迭代更新(第 646 行):如果已经有过一次压缩(_previous_summary 不为空),不会从零开始重新总结,而是把前一次摘要和新增的对话一起喂给 LLM,要求它做增量更新。这保证了多次压缩后信息不会指数级丢失。
辅助模型:你可以指定一个便宜的模型来做压缩——比如主模型用 Claude Opus,压缩摘要用 GPT-4.1-mini。当前实现不是走旧的 compression.summary_model_override,而是走 auxiliary.compression 路由。配置方式:
compression:
enabled:true
threshold:0.50
target_ratio:0.20
protect_last_n:20
auxiliary:
compression:
provider:openai
model:"gpt-4.1-mini"
如果 auxiliary.compression 指向的辅助模型不可用(404/503),会自动回退到主模型(第 730 行),不会进入冷却期导致上下文无限增长。瞬态错误(超时、限速)触发 60 秒冷却;完全没配置 Provider 时触发 600 秒冷却。旧配置里的 compression.summary_* 字段在当前版本会被迁移到 auxiliary.compression.*,不建议再按旧字段书写。
Phase 4:消息组装
压缩完成后,组装新的消息列表:
[head 消息(会话历史前几条;若输入里本来含 system,则会附加压缩提示注释)]
[摘要消息(role 自动选择以避免连续同角色)]
[tail 消息(最近的对话,原封不动)]
如果压缩输入本身包含 system 消息,压缩器会在那条消息上追加一段注释(第 1082 行):
[Note: Some earlier conversation turns have been compacted into a handoff summary
to preserve context space. The current session state may still reflect earlier
work, so build on that summary and state rather than re-doing work.]
摘要消息的角色(role)选择有讲究——不能和 head 最后一条或 tail 第一条重复,否则 API 会拒绝连续同角色消息。如果两个角色都冲突,摘要会被合并进 tail 的第一条消息,用分隔线标记。
最后,_sanitize_tool_pairs()(第 778 行)扫描压缩后的消息列表,修复所有"孤儿"tool_call / tool_result 对——被压缩切断后,可能出现 assistant 的 tool_calls 在摘要一侧而 tool results 在 tail 一侧的情况,API 会因为 ID 不匹配而报错。
压缩的完整数据流
把四个阶段串起来看一次完整的压缩:
触发前:80 条消息,~70K tokens
← should_compress(): 70K >= 64K ✓
Phase 1: 预修剪
- 去重 3 个重复的 read_file 结果
- 将 15 个旧工具结果替换为 1 行摘要
- 截断 4 个大 tool_call arguments
→ 仍然 80 条消息,但 token 估算降到 ~55K
Phase 2: 三段切分
- head: messages[0:3](会话历史最前面的 3 条消息)
- middle: messages[3:62](59 条要压缩的消息)
- tail: messages[62:80](18 条最近消息,~12K tokens)
Phase 3: LLM 摘要
- 被压缩内容 ~38K tokens
- 摘要预算: 38K × 0.20 = 7.6K tokens
- 生成 12 节结构化摘要
Phase 4: 组装
- 3 (head) + 1 (摘要) + 18 (tail) = 22 条消息
- 估算 ~25K tokens
- 节省: ~45K tokens (64%)
压缩后:22 条消息,~25K tokens
Token 追踪与成本估算
知道"花了多少"和"还能花多少"对长会话治理同样重要。agent/usage_pricing.py(687 行)实现了一套跨 Provider 的 token 计费系统。
CanonicalUsage:统一的 token 计量
不同 API 的 usage 格式不一样——Anthropic 有 cache_read_input_tokens,OpenAI 有 prompt_tokens_details.cached_tokens,Codex Responses 又有 input_tokens_details.cached_tokens。normalize_usage() 把三种格式统一成一个 CanonicalUsage:
@dataclass(frozen=True)
class CanonicalUsage:
input_tokens: int = 0
output_tokens: int = 0
cache_read_tokens: int = 0
cache_write_tokens: int = 0
reasoning_tokens: int = 0
request_count: int = 1
标准化的关键在于:OpenAI 和 Codex 的 input_tokens / prompt_tokens 包含了 cache tokens,需要减掉才能得到"真正的输入 token":
if mode == "anthropic_messages":
# Anthropic 的 input_tokens 就是纯输入,cache 单独报告
input_tokens = getattr(response_usage, "input_tokens", 0)
cache_read_tokens = getattr(response_usage, "cache_read_input_tokens", 0)
elif mode == "codex_responses":
# Codex 的 input_tokens 包含 cache,需要减
input_total = getattr(response_usage, "input_tokens", 0)
cache_read_tokens = getattr(details, "cached_tokens", 0)
input_tokens = max(0, input_total - cache_read_tokens - cache_write_tokens)
else:
# OpenAI Chat Completions 同理
prompt_total = getattr(response_usage, "prompt_tokens", 0)
cache_read_tokens = getattr(details, "cached_tokens", 0)
input_tokens = max(0, prompt_total - cache_read_tokens - cache_write_tokens)
为什么要把 cache tokens 单独拆出来?因为缓存命中的 token 价格可以低一个数量级。Claude Sonnet 4.6 的输入价格是 ,但缓存命中只要0.30/M——差 10 倍。如果不区分,成本估算就完全失真了。
内置价格表
_OFFICIAL_DOCS_PRICING 是一个硬编码的定价字典,覆盖了主流模型的五维价格(以下为部分模型,单位:美元/百万 token):
|
模型 |
输入 |
输出 |
缓存读取 |
缓存写入 |
|---|---|---|---|---|
|
Claude Opus 4 |
$15.00 |
$75.00 |
$1.50 |
$18.75 |
|
Claude Sonnet 4 |
$3.00 |
$15.00 |
$0.30 |
$3.75 |
|
Claude 3.5 Haiku |
$0.80 |
$4.00 |
$0.08 |
$1.00 |
|
GPT-4o |
$2.50 |
$10.00 |
$1.25 |
— |
|
GPT-4.1 |
$2.00 |
$8.00 |
$0.50 |
— |
|
GPT-4.1-mini |
$0.40 |
$1.60 |
$0.10 |
— |
|
GPT-4.1-nano |
$0.10 |
$0.40 |
$0.025 |
— |
|
o3 |
$10.00 |
$40.00 |
$2.50 |
— |
每条价格记录都带 pricing_version 元数据(如 "anthropic-pricing-2026-03-16"),方便审计价格是什么时候的快照。
动态价格源:对于 OpenRouter 接入的模型,不走硬编码表,而是实时查询 OpenRouter 的 /models API 获取当前价格。对于自定义 OpenAI 兼容端点(如本地部署的 vLLM),尝试查询端点的 /models 接口。查不到就标记为 "unknown"。
成本估算
estimate_usage_cost() 的计算:
cost = (input_tokens × input_cost_per_million / 1,000,000)
+ (output_tokens × output_cost_per_million / 1,000,000)
+ (cache_read_tokens × cache_read_cost_per_million / 1,000,000)
+ (cache_write_tokens × cache_write_cost_per_million / 1,000,000)
+ (request_count × request_cost)
返回的 CostResult 带有 status 字段:"actual"(Provider 直接告诉你的)、"estimated"(用价格表算的)、"included"(订阅制,不额外计费)、"unknown"。
/usage 命令
用户随时可以输入 /usage 查看当前会话的 token 使用情况。Gateway 模式下的实现(gateway/run.py 第 6911 行):
⏱️ Rate Limits: 3,500 RPM | 120K TPM | 78% used
📊 Session Token Usage
Model: claude-sonnet-4-20250514
Input tokens: 45,230
Cache read tokens: 128,400
Output tokens: 12,890
Total: 186,520
API calls: 23
Cost: ~$0.2847
Context: 52,400 / 200,000 (26%)
Compressions: 1
这个命令从 agent.session_total_tokens、agent.session_api_calls 和 agent.context_compressor 三个地方拉数据,实时展示。特别是 Context 行——它告诉你当前上下文窗口的利用率,以及已经做过几次压缩。
凭证池:成本控制的另一个维度
Token 价格是一个维度,但对于使用 API key 的用户来说,还有一个维度:额度分散。一个 key 的 Rate Limit 可能是 3,500 RPM,但你有 4 个 key——怎么用满它们?
agent/credential_pool.py(1,439 行)实现了一个多凭证轮换系统。每个 Provider 可以配置多个 API key(或 OAuth token),并选择四种分配策略之一。
四种策略
STRATEGY_FILL_FIRST = "fill_first" # 默认:用第一个直到耗尽
STRATEGY_ROUND_ROBIN = "round_robin" # 轮转:每次换一个
STRATEGY_RANDOM = "random" # 随机选择
STRATEGY_LEAST_USED = "least_used" # 选请求数最少的
各自的实现(第 830 行 _select_unlocked()):
if self._strategy == STRATEGY_RANDOM:
entry = random.choice(available)
if self._strategy == STRATEGY_LEAST_USED and len(available) > 1:
entry = min(available, key=lambda e: e.request_count)
if self._strategy == STRATEGY_ROUND_ROBIN and len(available) > 1:
entry = available[0]
# 把刚用的那个移到队尾,更新所有 priority
rotated = [c for c in self._entries if c.id != entry.id]
rotated.append(replace(entry, priority=len(self._entries) - 1))
self._entries = [replace(c, priority=idx) for idx, c in enumerate(rotated)]
self._persist()
# fill_first (default): 直接用第一个可用的
entry = available[0]
什么时候用哪种?
|
策略 |
适用场景 |
特点 |
|---|---|---|
fill_first |
只有一个主 key + 一个备用 key |
简单,主 key 耗尽才换 |
round_robin |
多个同等级 key,想均匀分摊 Rate Limit |
每次请求切换,最均匀 |
random |
多个 key,不需要严格均匀 |
简单,统计上趋于均匀 |
least_used |
多个 key 有不同额度 |
自动倾斜向剩余额度多的 |
配置方式(config.yaml):
credential_pool_strategies:
anthropic: round_robin
openai: least_used
耗尽处理与自动轮转
当一个凭证收到 429(Rate Limit)或 402(额度耗尽)时,mark_exhausted_and_rotate() 会标记它为 exhausted,设置 1 小时的冷却期(EXHAUSTED_TTL_429_SECONDS = 3600),然后自动选择下一个可用凭证:
def mark_exhausted_and_rotate(self, *, status_code, error_context=None):
with self._lock:
entry = self.current()
self._mark_exhausted(entry, status_code, error_context)
self._current_id = None
next_entry = self._select_unlocked()
return next_entry
如果 Provider 的响应头里带了 reset_at 时间戳(表示限速什么时候解除),这个时间戳会覆盖默认的 1 小时冷却。
并发租约
Gateway 模式下,多个用户同时在聊天,每个会话需要一个凭证。acquire_lease() / release_lease() 实现了软租约机制:
def acquire_lease(self, credential_id=None):
with self._lock:
available = self._available_entries()
below_cap = [
entry for entry in available
if self._active_leases.get(entry.id, 0) < self._max_concurrent
]
candidates = below_cap if below_cap else available
chosen = min(candidates, key=lambda e: (self._active_leases.get(e.id, 0), e.priority))
self._active_leases[chosen.id] += 1
return chosen.id
优先选并发数最低的凭证,用 priority 做稳定的平局排序。所有凭证都达到并发上限时,不阻塞——仍然返回并发最少的那个。这是一个软限制,保证不会因为限额问题导致用户完全无法使用。
SQLite WAL 模式:长会话的持久化性能
长会话治理还有一个容易被忽视的角度——状态持久化的性能。Hermes 把会话历史、用户状态、记忆数据都存在 SQLite 里。当多个进程(Gateway + CLI + worktree agent)同时读写同一个 state.db 时,写竞争会导致"数据库锁定"错误和 TUI 卡顿。
hermes_state.py(1,293 行)里的 SessionDB 对此做了精细的工程处理。
WAL 模式
self._conn = sqlite3.connect(
str(self.db_path),
check_same_thread=False,
timeout=1.0, # 短超时——应用层重试代替长等待
isolation_level=None, # 关闭自动事务,手动管理
)
self._conn.execute("PRAGMA journal_mode=WAL")
WAL(Write-Ahead Logging)模式的核心优势:读写不互斥。在传统 rollback journal 模式下,写操作会锁住整个数据库,读操作必须等待。WAL 模式下,写操作写入 WAL 文件,读操作从主 DB 文件 + WAL 文件的快照中读取——读永远不阻塞,读也不阻塞写,只有写和写之间有互斥。
对 Hermes 的意义:Gateway 可以有数十个并发会话在读历史消息,同时 CLI 在写新会话——零冲突。只有当两个进程同时写时才需要处理竞争。
写竞争处理
即使在 WAL 模式下,同时写仍然会冲突。SessionDB 的解法分两层:
应用层重试(第 123-134 行):
_WRITE_MAX_RETRIES = 15
_WRITE_RETRY_MIN_S = 0.020 # 20ms
_WRITE_RETRY_MAX_S = 0.150 # 150ms
SQLite 内置的 busy handler 使用确定性的退避间隔——所有竞争者"齐步走",结果是严重的 convoy effect(车队效应):大家同时睡、同时醒、同时抢锁、同时失败。
Hermes 的做法是把 SQLite 的 timeout 设得很短(1 秒),然后在应用层用 random.uniform(20ms, 150ms) 的随机抖动做重试。随机间隔天然地错开了竞争者,避免了 convoy。
**BEGIN IMMEDIATE**(第 183 行):
self._conn.execute("BEGIN IMMEDIATE")
普通的 BEGIN 是延迟锁——事务开始时不锁,到写操作时才锁。这意味着你可能在做了很多读操作之后才发现拿不到锁。BEGIN IMMEDIATE 在事务开始时就尝试获取写锁——失败快、回退快,和随机抖动重试配合得很好。
定期 WAL 检查点
WAL 文件会无限增长——每次写都追加到 WAL,只有"检查点"操作才把 WAL 的内容合并回主 DB 文件。SessionDB 每 50 次成功写入做一次 PASSIVE 检查点:
_CHECKPOINT_EVERY_N_WRITES = 50
def _try_wal_checkpoint(self):
"""Best-effort PASSIVE checkpoint. Never blocks, never raises."""
with self._lock:
result = self._conn.execute("PRAGMA wal_checkpoint(PASSIVE)").fetchone()
PASSIVE(而非 FULL 或 RESTART):只合并当前没有被任何连接使用的 WAL 帧。不会阻塞正在读的连接,也不会阻塞写操作。如果有读者持有旧快照,那些帧就留在 WAL 里等下次。
关闭连接时(close() 第 237 行)也做一次 PASSIVE 检查点——确保进程退出时尽量收缩 WAL。hermes_cli/doctor.py(第 522 行)里的健康检查也会触发检查点——这是给那些常驻不关的 Gateway 进程的"清洁服务"。
把它们串起来:一次长会话的生命周期
让我们用一个完整的例子把所有组件串起来。假设你在用 128K 上下文的 Claude Sonnet 4 做一次大型重构:
轮次 1-5: 理解需求、读文件
- 3 个 read_file 结果都不大(< 100K),三层防线不介入
- 上下文 ~8K tokens
- /usage: $0.0012
轮次 6-15: 大量读文件 + 搜索
- search_files 返回 120K 字符 → 第 2 层持久化,留 1.5K 预览
- 一次并行调了 6 个工具,总量 350K → 第 3 层把最大的 2 个溢出到沙箱
- 上下文 ~25K tokens
- /usage: $0.0340
轮次 16-30: 修改代码 + 跑测试
- 每轮都有 read_file + write_file + terminal
- terminal 输出频繁超 100K(测试输出),被第 2 层持久化
- 上下文 ~55K tokens
- /usage: $0.1820
轮次 31: 压缩触发
- 上下文 65K tokens >= 阈值 64K
- Phase 1: 修剪 20 个旧工具结果、去重 4 个 read_file
- Phase 2: head=3, tail=16 条消息, middle=57 条
- Phase 3: 生成 ~6K token 的结构化摘要
- Phase 4: 22 条消息,~22K tokens
- 节省 ~43K tokens (66%)
轮次 32-50: 继续修改 + 测试
- 上下文重新增长
- 第二次压缩在 ~65K tokens 时触发
- 迭代更新摘要(保留前一次的 + 新增的)
- /usage: $0.5200, 2 次压缩
轮次 51: 完成
- 总共 51 轮工具调用
- 如果没有压缩,上下文会膨胀到 ~150K tokens——超出 128K 上下文限制
- 两次压缩让它始终在 65K 以内
- 凭证池中用了 2 个 key(第一个在轮次 38 触发 429,自动轮转到第二个)
配置速查
# config.yaml
compression:
enabled:true # 是否启用上下文压缩(默认 true)
threshold:0.50 # 上下文使用率达到多少时触发(默认 0.50)
target_ratio:0.20 # tail 保护区占阈值的比例(默认 0.20)
protect_last_n:20 # Phase 1 预修剪时保护近期消息的参考下限(默认 20)
auxiliary:
compression:
provider:openai # 可选;也可留 auto
model:"gpt-4.1-mini" # 用便宜模型做压缩(可选)
credential_pool_strategies:
anthropic:round_robin # 凭证池策略
openai:least_used
小结
这一讲把 Hermes 的长会话治理体系从底到顶拆完了。
大结果三层防线:工具内截断(第 1 层)→ 单结果超 100K 字符时持久化到沙箱(第 2 层,maybe_persist_tool_result())→ 单轮聚合超 200K 时溢出最大结果(第 3 层,enforce_turn_budget())。read_file 的阈值被 pin 为 ∞ 以防止 persist→read→persist 死循环。
上下文压缩(context_compressor.py,1,163 行):当 prompt tokens 超过上下文的 50% 时触发。四阶段算法——工具结果预修剪(去重 + 信息摘要替换)→ head/middle/tail 三段切分(token 预算驱动)→ 12 节结构化 LLM 摘要(支持迭代更新 + 辅助模型 + 主题聚焦)→ 消息组装 + 孤儿 tool pair 修复。反抖动保护避免无效压缩循环。
Token 追踪与计费(usage_pricing.py,687 行):CanonicalUsage 统一三种 API 的 usage 格式,_OFFICIAL_DOCS_PRICING 内置主流模型定价,OpenRouter 动态查价。五维成本估算(输入 / 输出 / 缓存读取 / 缓存写入 / 请求)。/usage 实时展示。
凭证池(credential_pool.py,1,439 行):fill_first / round_robin / random / least_used 四种策略,429/402 自动轮转 + 1 小时冷却,并发租约机制防止 Gateway 多会话资源争抢。
SQLite WAL(hermes_state.py):WAL 模式让读写不互斥,BEGIN IMMEDIATE + 随机抖动重试(20-150ms,最多 15 次)打破 convoy effect,每 50 次写入做 PASSIVE 检查点控制 WAL 增长。
更多推荐



所有评论(0)