Hermes 与 Claude CodeHermes 与 Claude Code第 11 章 · 14 章Chapter 11 of 14
11 · 可以搬到自己项目里的实现范式
这一章把前面所有分析压缩成能直接抄进你自己项目的东西。骨架代码用 Python 写(服务端岗位更常见),但结构是语言无关的。
代码怎么读:每段代码都有中文注释。带 ★ 标记的行是最容易写错、也最关键的地方。如果你只看一处,看那些行。
11.1 骨架一:带恢复状态机的主循环
from dataclasses import dataclass, replace
from typing import Literal, Optional
# 所有可能的"继续原因",穷举出来。对应第 4.2 节的 transition 字段
Reason = Literal["next_turn", "compact_retry", "output_truncated",
"hook_blocking", "budget_continue"]
@dataclass(frozen=True) # frozen=True 表示这个结构体不可修改
class LoopState:
messages: list # 当前的完整消息历史
turn: int = 1 # 已经进行了几轮
# —— 每条恢复路径一个独立的幂等锁或计数器 ——
compact_attempted: bool = False # 本轮是否已经压缩过(真假开关)
truncation_retries: int = 0 # 输出截断已重试几次(计数器)
max_tokens_override: Optional[int] = None # 是否已升过输出上限档位
hook_active: bool = False
transition: Optional[Reason] = None # ★ 只为可观测和可测试而存在
MAX_TRUNCATION_RETRIES = 3
def run(state: LoopState, ctx) -> str:
while state.turn <= ctx.max_turns and ctx.cost < ctx.max_cost:
# ① 上下文治理流水线(见骨架二)
msgs = govern_context(state.messages, ctx)
# ② 调用模型。可恢复错误在这里被"扣住",不向外抛(见第 4.3 节)
resp, recoverable = call_model(msgs, ctx,
max_tokens=state.max_tokens_override)
# ③ 恢复路径 —— 每条都必须先检查自己的锁
if recoverable == "context_overflow" and not state.compact_attempted:
state = replace(state,
messages=compact(state.messages, ctx),
compact_attempted=True, # ★ 上锁
transition="compact_retry")
continue
if recoverable == "output_truncated":
if state.max_tokens_override is None: # 还没升过档 → 升一次
state = replace(state, max_tokens_override=ctx.escalated_max,
transition="output_truncated")
continue
if state.truncation_retries < MAX_TRUNCATION_RETRIES:
state = replace(state,
messages=state.messages + [resp, RESUME_NUDGE],
truncation_retries=state.truncation_retries + 1,
max_tokens_override=None,
transition="output_truncated")
continue
# 重试次数耗尽 —— 现在才把错误抛出去
raise AgentError(recoverable)
if recoverable: # 是可恢复错误,但上面没有一条路能走
raise AgentError(recoverable)
# ④ 没有工具调用 = 任务完成(见第 1.5 节)
if not resp.tool_calls:
return resp.text
# ⑤ 执行工具(见骨架三)
results = execute_tools(resp.tool_calls, ctx)
# ⑥ 正常推进:★ 只有这里才重置所有恢复计数器
state = replace(state,
messages=state.messages + [resp] + results,
turn=state.turn + 1,
compact_attempted=False, # ★ 只在正常推进时重置
truncation_retries=0,
max_tokens_override=None,
transition="next_turn")
return "预算已耗尽"
RESUME_NUDGE = {
"role": "user",
"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."),
}
三个最容易写错的地方
- 恢复推进时绝不能重置锁。只有第 ⑥ 步的
next_turn分支才把compact_attempted设回 False。在恢复分支里重置 = 无限循环。Claude Code 有过这个真实事故,烧掉了几千次 API 调用(见第 4.2 节那段注释)。 - 可恢复错误不能立即抛给调用方。先在循环里试恢复,全部失败才抛。否则下游看到错误字段就断开连接,你的恢复逻辑白跑(见第 4.3 节)。
frozen=True配合replace()。状态不可变,每条转移边都显式写出所有字段的新值。可变状态加部分赋值,是这类循环最常见的 bug 温床 —— 你以为你只改了一个字段,实际上漏掉了另一个该重置的。
11.2 骨架二:上下文治理阶梯
def govern_context(messages: list, ctx) -> list:
"""成本递增的四级流水线。越靠前越便宜,能在前面解决就不往后走。
对应第 6 章。"""
# ① 落盘:单条工具结果超限 → 存文件,只回预览 + 路径。成本 0
messages = spill_oversized_results(messages, ctx)
# ② 精确删除:旧的可压缩工具结果,保留最近 N 条。成本 0
# ★ 关键:缓存状态是决策输入(见第 6.3 节)
if ctx.cache_is_warm:
# 缓存是热的 —— 本地一个字都不改,让服务端删(如果接口支持)
ctx.queue_cache_edits(pick_stale_tool_ids(messages, keep=ctx.keep_recent))
else:
# 缓存已经凉了 —— 前缀反正要全部重写,直接就地清空内容
messages = clear_stale_tool_results(messages, keep=max(1, ctx.keep_recent))
# ★ 至少留 1 条,别删光
# ③ 结构化折叠:把整段交互折叠成可展开摘要,保留粒度。成本低
if estimate_tokens(messages) > ctx.collapse_threshold:
messages = apply_collapses(messages, ctx)
# ④ 整段摘要:一次完整模型调用,有损不可逆。最后手段
if estimate_tokens(messages) > ctx.compact_threshold:
messages = summarize_and_replace(messages, ctx)
return messages
如果你的模型供应商不支持缓存编辑(绝大多数情况)
不要试图模拟它。退回到「按缓存冷热分流」这个更朴素的版本就够了:
- 缓存热(距上次请求 < 5 分钟)→ 什么都别删。宁可多花点输入 token,也别把整段缓存打掉。
- 缓存凉(距上次请求 > 5 分钟)→ 大刀阔斧清理。前缀反正要重写,不清白浪费。
判断缓存冷热只需要一个时间戳,成本为零。这条规则能拿到缓存编辑大部分的收益,而且完全没有「本地状态和服务端状态双写」的复杂度。
真正值得抄的是「把缓存冷热建模成策略输入」这个思路本身,不是缓存编辑那个具体实现。
压缩用的提示词模板
COMPACT_PROMPT = """CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.
- Do NOT use Read, Bash, Grep, Glob, Edit, Write, or ANY other tool.
- You already have all the context you need in the conversation above.
- Tool calls will be REJECTED and will waste your only turn — you will fail.
- Your entire response must be plain text: an <analysis> block followed
by a <summary> block.
Before the summary, wrap your analysis in <analysis> tags. Chronologically
go through each section of the conversation and identify:
- the user's explicit requests and intents
- your approach to addressing them
- key decisions, technical concepts, and code patterns
- specific details: file names, full code snippets, function signatures
Then produce <summary>.
"""
def summarize_and_replace(messages, ctx):
raw = call_model_no_tools(COMPACT_PROMPT, messages, max_turns=1)
summary = strip_analysis_block(raw) # ★ analysis 是草稿纸,用完就扔
return [protected_head(messages), # 保留开头
summary_message(summary), # 中间换成摘要
*protected_tail(messages, n=ctx.protect_last_n)] # 保留结尾
为什么禁用工具的指令要写这么凶
因为压缩这次调用通常继承了父会话的完整工具集(为了缓存键匹配,见第 6.4 节)。工具还在清单里,模型就有概率去调用它。
Claude Code 的实测数据:措辞温和时,Sonnet 4.6 有 2.79% 的概率仍然尝试调用工具(4.5 只有 0.01%)。而由于最大轮次被设为 1,一次被拒绝的工具调用就等于整次压缩失败。
三个有效手法:放在最前面、穷举点名具体工具、明确说明违规后果。
11.3 骨架三:工具抽象 + 并发分区
from typing import Protocol, Any
class Tool(Protocol): # Protocol 是 Python 里定义"接口"的方式
name: str
input_schema: dict
max_result_chars: int # 结果超过这个长度就落盘
def call(self, args: dict, ctx) -> Any: ...
# —— 给调度器看的安全谓词,全部 fail-closed(失败倒向保守)——
def is_concurrency_safe(self, args: dict) -> bool: return False
def is_read_only(self, args: dict) -> bool: return False
def is_destructive(self, args: dict) -> bool: return False
def check_permissions(self, args, ctx) -> "Decision": ...
# ★ 注意这些方法都接收 args ——
# 同一个 Bash 工具,跑 ls 安全,跑 rm 不安全
def partition(calls: list, tools: dict) -> list[tuple[bool, list]]:
"""贪心分区:相邻的安全工具合并并行,遇到不安全的就切断。
★ 完整保留模型隐含的顺序语义。见第 5.4 节。"""
batches = []
for c in calls:
tool = tools.get(c.name)
try:
args = validate(tool.input_schema, c.args)
safe = bool(tool.is_concurrency_safe(args))
except Exception:
safe = False # ★ fail-closed:解析或判定失败都当不安全
if safe and batches and batches[-1][0]:
batches[-1][1].append(c) # 上一批也安全 → 并进去
else:
batches.append((safe, [c])) # 否则 → 开新批
return batches
def execute_tools(calls, ctx):
results = []
for is_safe, batch in partition(calls, ctx.tools):
if is_safe:
# ★ 两级中止作用域:批内失败杀兄弟进程,但不杀整个轮次
# 见第 5.5 节
sibling = ChildCancelScope(parent=ctx.cancel)
with ThreadPoolExecutor(max_workers=10) as pool:
results += list(pool.map(
lambda c: run_one(c, ctx, cancel=sibling), batch))
else:
for c in batch:
results.append(run_one(c, ctx, cancel=ctx.cancel))
if ctx.cancel.is_set():
# ★ 中断兜底:为每个还没有结果的工具调用补一条合成结果,
# 否则下一轮 API 会因为"便条与回复不配对"直接报错
# 见第 4.4 节
results += synth_results_for_missing(calls, results,
"Interrupted by user")
break
return results
11.4 骨架四:按信任边界配置工具面
# ★ 工具的"实现"和"投放"必须解耦。见第 3.2 节
CORE_TOOLS = ["web_search", "read_file", "write_file", "terminal",
"patch", "search_files", "delegate", "memory"]
TOOLSETS = {
# 交互式会话:有人在场,能弹确认框 → 全量工具
"interactive": {"tools": CORE_TOOLS},
# 后台任务:弹不出确认框 → 收窄到基本只读
"background": {"tools": ["web_search", "read_file", "search_files"]},
# 公开 webhook:内容来自不可信第三方(PR 标题、issue 评论)
# → 只给只读工具。
# ★ 防提示词注入靠"危险工具根本不在清单里",
# 不靠在提示词里写"请不要执行危险命令"
"webhook": {"tools": ["web_search", "read_file"]},
}
def tools_for(context) -> list[Tool]:
preset = ("webhook" if context.source == "webhook"
else "background" if not context.can_prompt_user
else "interactive")
names = TOOLSETS[preset]["tools"]
return [t for t in ALL_TOOLS
if t.name in names and not denied_by_rule(t, context)]
11.5 骨架五:不可绕过的安全底座
import re
# 命令位置锚点:行首 / 分隔符后 / 子 shell 开启符后 / sudo|env|exec 包装后
# ★ 这个锚点是全部的关键。见第 7.3 节
_CMDPOS = r'(?:^|[\n;&|]|\$\(|`|\b(?:sudo|env|exec)\s+)\s*'
HARDLINE = [
(_CMDPOS + r'rm\s+(-[^\s]*\s+)*(/(?:(?:\.\.?)?/)*(?:\.\.?)?\**|/ \*)(?=[\s;&|]|$)',
"递归删除根目录"),
(_CMDPOS + r'mkfs(\.[a-z0-9]+)?\b', "格式化文件系统"),
(_CMDPOS + r'dd\b[^\n]*\bof=/dev/(sd|nvme|vd)[a-z0-9]*', "写入裸磁盘设备"),
(_CMDPOS + r'(shutdown|reboot|halt|poweroff)\b', "关机或重启"),
]
# ★ 模块加载时就预编译。见第 7.3 节末尾(避免正则缓存被挤掉后反复重编)
HARDLINE_C = [(re.compile(p, re.IGNORECASE | re.DOTALL), d) for p, d in HARDLINE]
# 这两条在命令行任意位置都有效,没有命令名可以锚定 → 用引号遮蔽
POSITIONLESS = [
(re.compile(r'>\s*/dev/(sd|nvme|vd)[a-z0-9]*\b'), "重定向到裸磁盘设备"),
(re.compile(r':\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:'), "分叉炸弹"),
]
# 会把引号内容交给另一个 shell 执行的命令
SHELL_CARRIERS = {"eval", "sh", "bash", "zsh", "ksh", "dash", "source", "."}
def check_hardline(cmd: str) -> str | None:
for rx, desc in HARDLINE_C:
if rx.search(cmd):
return desc
# ★ 遇到 sh -c / eval 时,引号里是代码不是散文 → 必须扫原始字符串
scan = cmd if has_shell_carrier(cmd, SHELL_CARRIERS) else mask_quoted(cmd)
for rx, desc in POSITIONLESS:
if rx.search(scan):
return desc
return None
def mask_quoted(cmd: str) -> str:
"""把引号内容清空(保留引号字符本身)。
★ 但双引号里的 $(...) 和反引号必须保持原样 —— shell 真的会执行它们。
见第 7.3 节那张三行对照表。"""
...
这里的关键判断
check_hardline() 的返回值必须排在所有用户配置之前生效。用户可以关掉确认弹窗,但关不掉这一层。
同时 _CMDPOS 锚点是必需的,不是可选优化 —— 没有它,git commit -m "fix: block rm -rf / spellings" 会被误拦,而用户会因此很快把整个安全机制关掉。
误报率过高的安全措施,等于没有安全措施。
11.6 自建智能体的决策清单
按建议的决策顺序排列。每一条都对应前面某一章的分析。
| 决策 | 选 A | 选 B |
|---|---|---|
| 1. 供应商绑定 第 6、10 章 |
绑定单一供应商 → 能用提示词缓存的高级能力、缓存编辑、原生思考块。成本可能差一个量级。 | 做多供应商抽象 → 必须加参数矫正层、按供应商重新标记缓存分界点、放弃所有私有优化 |
| 2. 上下文策略 第 6 章 |
硬编码阶梯 → 你自己最了解你的负载特征,能做到最优 | 定义成抽象基类交出去 → 只在你真的要开放给第三方时才值得 |
| 3. 工具建模 第 5 章 |
富接口(安全谓词 + 权限 + 预算 + 渲染) → 调度策略能从工具实现里剥离出来 | 普通函数注册 → 起步快,但并发和权限逻辑迟早散落到各个调用处 |
| 4. 执行隔离 第 7 章 |
没有 A/B 选项。生产环境必须有硬隔离(容器 / 独立用户账号 / 沙箱)。正则表达式和权限规则是纵深防御的最外层,不是唯一层。Hermes 那次审计报告的头号严重问题,就是「默认无沙箱」。 | |
| 5. 子智能体 第 8 章 |
调用栈模型 → 秒级探索任务,追求延迟和缓存命中 | 工作流模型(看板 + 心跳 + 插话)→ 长时任务,必须可干预、可恢复 |
| 6. 记忆 第 9 章 |
纯文本 + 全量加载 → 用于指令性记忆(偏好、规范)。人类可读可审阅是刚需 | 检索 → 用于事实性记忆。词法 + 向量混合,而且必须有信任分数衰减 |
| 7. 权限 第 7 章 |
确定性规则 → 零成本、可审计、可测试 | 模型分类器 → 能理解意图,但必须有快速通道挡掉 80% 的调用 |
| 8. 多入口 第 9 章 |
直接在业务代码里写 if platform == ... → 2 个平台以内可以接受 |
独立网关层 + 能力声明式适配器 → 3 个平台以上必须这样做 |
11.7 一页纸检查清单
上线前逐条自查
- ☐ 每条错误恢复路径都有独立的幂等锁或计数器,而且只在正常推进时重置
- ☐ 可恢复错误在循环内被扣住,所有恢复手段都失败后才向外抛
- ☐ 中断时为每一个未完成的工具调用补齐合成结果(便条与回复必须配对)
- ☐ 并发执行有独立的批内中止作用域,一个失败不拖垮整个轮次
- ☐ 并发安全性由工具自己声明,接收参数,而且解析失败时倒向「不安全」
- ☐ 上下文治理是一条成本递增的阶梯,摘要是最后一级而不是第一级
- ☐ 缓存冷热是压缩策略的输入,而不是两种场景共用同一套策略
- ☐ 单条工具结果有大小上限,超限落盘只回预览和文件路径
- ☐ 存在用户无法通过任何配置关掉的安全底座
- ☐ 危险模式匹配只在命令位置生效,不误伤参数里的字面量
- ☐ 工具面随入口的信任级别和交互能力同步收缩
- ☐ 工具的错误消息在回灌进上下文前做过净化
- ☐ 子智能体不能向用户提问、不能改全局状态、不能操作兄弟任务
- ☐ 生产环境有容器或沙箱级别的硬隔离
- ☐ 扩展点区分「可叠加的能力」与「互斥的策略」
- ☐ 慢的旁路计算藏在模型流式输出的时间窗里,而且消费点不阻塞