原文连接: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%。但这还不够——随着对话持续,上下文总量还是在涨。这就需要第四层了。


上下文压缩:有损但不丢活

当三层防线拦不住、上下文继续膨胀到逼近模型上下文窗口时,ContextCompressoragent/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_tokensnormalize_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_tokensagent.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 WALhermes_state.py):WAL 模式让读写不互斥,BEGIN IMMEDIATE + 随机抖动重试(20-150ms,最多 15 次)打破 convoy effect,每 50 次写入做 PASSIVE 检查点控制 WAL 增长。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐