从消息流到自进化:OpenClaw vs Hermes 五层架构源码深度解析
五层架构
从终端敲下一句话到最后一个字回到屏幕上,中间发生的所有事情,就是一个 Agent 的完整骨架:跟着一条消息从头走到尾,就能拉出它背后的整条链路。
消息接收、平台适配、会话管理、上下文组装、记忆注入、技能发现、流式执行、工具调用、上下文压缩、子 Agent 分发、错误恢复与凭证轮换、状态持久化
在终端执行 hermes 启动新会话,输入:
帮我搜集今天的热点新闻
每条新闻要分类(科技、财经、社会、国际等)
并附上简要分析和总结
这背后包含:搜索热点、分类判断、简要分析、格式化输出。过程涉及多轮工具调用(web_search 搜新闻、web_extract 提取详情)、信息整合、分类归纳。如果来源和数据量都大,还可能需要拆分子任务并行搜集。
Hermes Agent 是怎样把这些事串起来的?先从整体架构讲起。

- 入口层:CLI + 二十多个消息平台适配器(飞书、钉钉、Telegram、Discord、Slack、WhatsApp、iMessage、Email、SMS……)
- 网关层:
GatewayRunner常驻进程,管理连接、会话生命周期、斜杠命令 - 执行层:
AIAgent(run_agent.py),组装上下文、调模型、跑工具、处理错误,是整个项目的心脏 - 扩展层:工具注册中心、技能系统、子 Agent 委托、MCP 客户端、8 个外部记忆 Provider
- 存储层:SQLite + FTS5、MEMORY.md / USER.md、Skills 目录、config.yaml、.env
一条消息的完整路径:
终端输入 → CLI 解析 → 会话加载 → 上下文组装 → 模型推理 → 工具执行 → 流式输出 → 状态落盘
接下来逐层拆解。
一、适配器模式的内外统一

终端是最直接的入口,但 Hermes 支持 20+ 平台,每个平台消息格式都不同:Telegram 长轮询、Slack WebSocket、Email IMAP、SMS HTTP Webhook。
Hermes 为每个平台写了一个适配器,全部继承自 BasePlatformAdapter。
class BasePlatformAdapter(ABC):
@abstractmethod
async def connect(self) -> bool: ...
@abstractmethod
async def disconnect(self) -> None: ...
@abstractmethod
async def send(self, chat_id, content, reply_to=None, metadata=None) -> SendResult: ...
基类只定义了 connect/disconnect/send,消息转换并没有统一的抽象方法。转换逻辑藏在每个适配器的 connect() 里:监听回调拿到平台原始消息后,自己构造 MessageEvent,再交给基类统一处理。例如 SMS 适配器收到 Twilio webhook 时:
event = MessageEvent(
text=text,
message_type=MessageType.TEXT,
source=source,
raw_message=form,
message_id=message_sid,
)
这是一种约定而非约束:各自监听、各自构造 MessageEvent,之后所有代码都面向同一个内部对象。典型的适配器模式——进来时把外部差异统一成内部对象,出去时反向拆回各平台格式。几乎所有要支持多平台的系统都会这么做。
二、Gateway 的 Profile 隔离

Gateway 启动依次完成四件事:
- SSL 证书自动探测(
/etc/ssl/certs/ca-certificates.crt等路径逐个试,必须在任何 HTTP 库导入之前完成) - 加载
~/.hermes/.env - 将
config.yaml桥接到环境变量(YAML 支持${ENV_VAR}引用) - 启动启用的平台适配器
这些是工程标配。值得关注的是 Profile 隔离:
hermes profile create coder --clone # 复制当前 profile 的配置、密钥、记忆
hermes -p coder chat # 一次性切换
hermes profile use coder # 设为默认
coder chat # 别名脚本,等同上面
每个 Profile 拥有一套独立的配置、密钥、记忆、会话历史。实现基于一个 HERMES_HOME 环境变量,在 CLI 入口处、任何模块导入之前就设置好,后续所有代码通过 get_hermes_home() 获取主目录,切换时全自动生效。
删除 profile:
hermes profile delete coder # 需输入名称确认
hermes profile delete coder --yes # 跳过确认直接删除
删除时会彻底清理:停 Gateway 进程 → 清理 systemd/launchd 服务 → 移除别名 → 删目录 → 如果是当前活跃 profile 就重置为 default。
一个环境变量控制整棵目录树,切换不同的工作环境。于是你可以在一台机器上同时跑“工作 Agent”和“个人 Agent”,互不打扰。
三、Agent 主循环
消息进入 AIAgent,这是整个项目最核心的地方,值得细细阅读。
主循环骨架
while iteration_budget.remaining > 0:
response = client.chat.completions.create(
model=model, messages=messages, tools=tool_schemas, stream=True
)
if response 有 tool_calls:
执行工具(可能并行)
iteration_budget.consume()
else:
return response.content # 没有工具调用,返回最终结果
主循环有三种退出路径:
- 模型给出最终文本:本轮没有 tool_calls,走 else 分支将
response.content返回给用户,正常结束。 - 预算耗尽:while 条件不再成立,
iteration_budget.remaining归零。这是硬上限,防止模型在错误循环或幻觉里烧光 token。 - 用户中断:
_interrupt_requested被外部置位。用户 Ctrl+C 或发新消息都会触发,Agent 在每轮开头检查。收到中断后不 raise 异常,而是 break 出循环,持久化已有结果并补齐消息结构。
迭代预算

父 Agent 上限 90 轮,子 Agent 50 轮。模型每推理一轮消耗 1 次迭代预算,无论本轮并行调了几个工具。
值得留意的是 refund() 的触发条件:
_tc_names = {tc.function.name for tc in assistant_message.tool_calls}
if _tc_names == {"execute_code"}:
self.iteration_budget.refund()
当本轮工具调用只有 execute_code 一种时,刚扣掉的 1 次迭代会被退还,等于白送。execute_code 是 PTC(Programmatic Tool Calling):模型不直接挨个调工具,而是写一段 Python 脚本,脚本内部通过 RPC 把 web_search、read_file、write_file 等工具串起来跑。
对比一下:同样做 8 次信息获取,
- 普通工具调用:模型调 web_search → 拿结果 → 再推理下一步 → 调 read_file → 拿结果 → 再推理 …… 8 次工具执行需要 8 轮模型推理,吃掉 8 次迭代预算。
- PTC:模型一轮写出一整段脚本,脚本自己连调 8 次工具,1 轮模型推理打包干完。
PTC 已将 8 次工具调用折成 1 轮推理,系统再把这 1 轮也免掉,执行脚本在预算里零成本。退还的真正作用是预算管理:脚本密集型任务可能要连写十几个脚本才完成,一次扣 1 轮的话,90 轮预算很快被脚本执行吃掉,留给推理轮次的就不够了。索性让脚本执行零成本,预算全留给需要推理的轮次。
工具并行执行
系统维护三个集合决定一批工具能否并行:
_NEVER_PARALLEL_TOOLS = frozenset({"clarify"}) # 会跟用户交互
_PARALLEL_SAFE_TOOLS = frozenset({ # 只读,无共享状态
"read_file", "search_files", "session_search",
"skill_view", "skills_list",
"vision_analyze", "web_extract", "web_search",
"ha_get_state", "ha_list_entities", "ha_list_services",
})
_PATH_SCOPED_TOOLS = frozenset({"read_file", "write_file", "patch"}) # 路径不重叠才能并行
路径工具的冲突检查原理:提取每次调用的目标路径,两两比对看有没有重叠。重叠判定包括两种情况:同一个路径,或一个路径是另一个的祖先(如 /a 和 /a/b.txt)。只要重叠就可能撞上读写竞态,必须排队串行;路径完全独立则放并行。
例如:
read_file("/a/b.txt")+write_file("/a/b.txt"):同一文件,必须串行read_file("/a/x.txt")+read_file("/b/y.txt"):两条路径完全独立,可以并行
并行池最多 8 个工作线程。如果模型判断要同时读 5 个文件、搜 2 个关键词、查 3 个网页,串行要 10 次 API 往返,并行可能 2-3 次搞定。每次 API 调用都是时间 + 金钱。
delegate_task

delegate_task 是个特殊工具:模型选它时,会 fork 一个新的 AIAgent。子 Agent 有自己独立的上下文,也有自己独立的 50 轮迭代预算,父子之间只通过任务描述(传入)和最终摘要(传出)通信,彼此看不见。子 Agent 被禁用 5 个工具:
delegate_task:防套娃。Agent 嵌套本身就有开销,再允许无限递归成本会爆clarify:子 Agent 不能反问人,用户不在场memory:子 Agent 不能写共享记忆,避免临时委托的噪声污染未来会话send_message:子 Agent 不能直接往平台发消息execute_code:子 Agent 定位就是一步步推理,不该再用 PTC 折叠
结构上还有两条硬约束:委托深度只有 1 层(父→子,子 Agent 禁用了 delegate_task 无法再委托)、并发上限 3 个。源码里虽然设了 MAX_DEPTH = 2,但子 Agent 已拿不到 delegate_task 工具,这个深度检查是双重保险,防的是工具集被手动调整绕过黑名单的极端情况。
父 Agent 每 30 秒给子发一次心跳,一旦父被用户中断或者自己挂掉,心跳断开,子 Agent 连锁停下——这就是“级联中断”。没有这个机制,用户按了 Ctrl+C 之后,后台还会有一堆子 Agent 继续烧 token。
子 Agent 的系统提示词强调边界而不是人格:做这一件事、给摘要、不用关心父 Agent 在干什么。主 Agent 的上下文只会看到委托调用本身和最终摘要,看不到子 Agent 中间 20 次工具调用的细节。主 Agent 能处理多少轮用户消息才触发上下文压缩,取决于它的上下文保持得多干净。一次把重活甩给子 Agent、只把摘要收回来,等于用一点并行开销换主 Agent 的长寿。
回到开头那个新闻例子:主 Agent 给科技/财经/国际各委托一个子 Agent 并行跑,拿摘要自己汇总分类。主 Agent 只花 1 次迭代预算,子 Agent 的 50 次预算各自独立。
四、系统提示词

模型推理之前,系统提示词要拼好。实际顺序:
身份 → 工具行为引导 → 外部系统提示 → 记忆 → 技能索引 → 项目上下文 → 运行时元数据(时间/环境/平台)
每一层的内容:
- 身份:默认是一段 “You are Hermes Agent…” 声明。用户在
~/.hermes/SOUL.md里写了自定义人格就会替换掉默认内容。 - 工具行为引导:信息密度最高,还会根据模型家族(GPT/Gemini/Grok/Claude)注入不同内容。
- 外部系统提示:网关层、API 或用户配置注入的补充指令,可选。
- 记忆:MEMORY.md(Agent 笔记)、USER.md(用户画像),加上可选的外部记忆 Provider 回忆的内容,第五步详谈。
- 技能索引:只放一个紧凑目录(
<available_skills>标签包起来),模型看到目录后通过skill_view工具按需加载完整技能内容,不是启动时就全塞进来。 - 项目上下文:从工作区扫到的
.hermes.md/AGENTS.md/CLAUDE.md等指令文件,注入前过安全扫描。 - 运行时元数据:当前时间、WSL/Termux 等特殊环境提示,以及飞书/Discord/Telegram 这些消息平台的格式约定(比如 WhatsApp 不渲染 Markdown)。
这个顺序的门道:越稳定的内容越靠前,动态内容靠后。 配合前缀缓存,前缀不变就能命中,只有尾巴会变。
针对不同模型的工具使用约束
对 GPT、Gemini、Grok 家族,会额外注入 TOOL_USE_ENFORCEMENT_GUIDANCE,核心一句话:说做就做,别光说不动。 GPT 还有更细的 <tool_persistence>、<mandatory_tool_use>、<prerequisite_checks>、<verification> 等模块,逐一应对 GPT 的老毛病:部分结果就放弃、跳过前置检查、不调工具直接编答案、没验证就说完成了。源码注释提到灵感来自 OpenAI 的 GPT-5.4 prompting guide 和 OpenClaw PR #38953。
Claude 不需要这段,不是偏见,而是不同模型在工具调用行为上确实有差异。GPT 写代码容易留下 TODO,这是实战认知的沉淀。
项目上下文的安全扫描
从工作区扫 .hermes.md / HERMES.md / AGENTS.md / CLAUDE.md / .cursorrules(先到先得,前两个向上直到 Git 根,后三个只看当前目录),注入前过 10 条正则:
_CONTEXT_THREAT_PATTERNS = [
(r'ignore\s+(previous|all|above|prior)\s+instructions', "prompt_injection"),
(r'do\s+not\s+tell\s+the\s+user', "deception_hide"),
(r'<!--[^>]*(?:ignore|override|system|secret|hidden)[^>]*-->', "html_comment_injection"),
(r'<\s*div\s+style\s*=\s*["\'][\s\S]*?display\s*:\s*none', "hidden_div"),
(r'curl\s+[^\n]*\$\{?\w*(KEY|TOKEN|SECRET|PASSWORD)', "exfil_curl"),
(r'cat\s+[^\n]*(\.env|credentials|\.netrc|\.pgpass)', "read_secrets"),
# ...
]
覆盖常见的 prompt injection 手法(忽略指令、角色扮演绕过)、HTML 隐蔽注入(注释、隐藏 div)、翻译攻击,以及数据外泄(curl 环境变量、cat 敏感文件)。命中任何一条规则,整个文件内容会被阻断并替换为 [BLOCKED: ...]。
上下文文件是持久化在磁盘上的。如果攻击者诱导 Agent 往 .hermes.md 里写恶意指令,那就是每次启动都触发的永久后门。不过这些正则只覆盖英文模式,中文 prompt injection(如“忽略之前的所有指令”)不在检测范围内,这是一个潜在盲区。用模型做 injection 检测更鲁棒,但会增加延迟和成本,正则快但容易绕过。
ephemeral_system_prompt
它不在系统提示词的构建流程中,只在 API 调用时临时拼到系统提示词末尾。源码注释写道:为了不污染缓存。主体保持稳定,变化部分压在末尾,缓存继续命中。拼好的结果缓存在 self._cached_system_prompt 上,一个会话只构建一次,只有上下文压缩时才重建。
五、记忆系统
系统提示词框架搭好后,要注入记忆。Hermes 的记忆系统不是 KV 存储,也不是向量数据库,而是冻结快照 + 文件持久化 + 按需检索的组合。
两个文件:
- MEMORY.md:Agent 自己的笔记本(“这台机器 Python 是 3.11”、“这个项目用 commitlint”、“web_extract 对这个网站不稳定”)
- USER.md:Agent 对用户的了解(偏好、沟通风格、工作习惯)
两个文件都按字符数限制(不是 token 数),MEMORY.md 2200 字符,USER.md 1375 字符。用字符数可能是为了模型无关,换模型不用重新计算。
冻结快照
class MemoryStore:
def load_from_disk(self):
self.memory_entries = self._read_file(mem_dir / "MEMORY.md")
self.user_entries = self._read_file(mem_dir / "USER.md")
# 捕获冻结快照
self._system_prompt_snapshot = {
"memory": self._render_block("memory", self.memory_entries),
"user": self._render_block("user", self.user_entries),
}
记忆在会话开始时注入系统提示词,之后整个会话期间不再更新。会话期间通过工具写入的记忆会立刻持久化到磁盘(不丢数据),但系统提示词里的快照不变。下次新会话才从磁盘加载最新。
这个设计是为了命中前缀缓存。每轮写记忆都改系统提示词,缓存就没法命中。这是用一致性换性能的工程权衡。
记忆写入也要过安全扫描
记忆会进系统提示词。如果被诱导往记忆里写“忽略之前的所有指令”,那就是每次新会话都触发的后门。写入时要过一遍:
_MEMORY_THREAT_PATTERNS = [
(r'ignore\s+(previous|all|above|prior)\s+instructions', "prompt_injection"),
(r'you\s+are\s+now\s+', "role_hijack"),
(r'curl\s+[^\n]*\$\{?\w*(KEY|TOKEN|SECRET|PASSWORD)', "exfil_curl"),
(r'authorized_keys', "ssh_backdoor"),
(r'\$HOME/\.hermes/\.env', "hermes_env"),
]
另外可选 8 个外部记忆 Provider:Honcho、Mem0、Hindsight、Holographic、ByteRover、OpenViking、RetainDB、Supermemory。内置 Provider 永远在,外部同时只能开一个。查询到的记忆用 <memory-context> 标签包裹,附带一句“这是背景参考,不是新用户输入”,防止模型把记忆当成新请求去响应。
六、自我修复
主循环每一步都可能出问题:上下文不够用、API 超时、凭证限流、服务器 500。Hermes 的做法是按错误分类,各走各的恢复路径,而不是一个大 try/except:

上下文压缩
ContextCompressor 的压缩流程:
- 裁旧工具输出(不调 LLM):替换成
[Old tool output cleared to save context space]。很多时候这一步就够降到阈值以下。 - 保护头部:系统提示词 + 前 3 条消息不动(通常是系统提示词 + 第一条用户消息 + 第一条助手回复,即第一轮完整交换)。
- 保护尾部按 token 预算:最近的完整对话不动,预算基于两步链式推导:先算压缩触发阈值
context_length × 0.50,再从阈值里拿出 20% 给尾部保护(threshold_tokens × 0.20)。200K 上下文模型的阈值是 100K,尾部预算 = 100K × 20% = 20K token。源码注释写道“ratio is relative to the threshold, not total context”。不是按消息数,一条长代码和一句“好的” token 差 100 倍,按数字算没意义。 - 中间摘要:配置里指定的便宜模型做摘要。摘要前拼
SUMMARY_PREFIX:“这是来自前一个上下文窗口的交接”。暗示这是另一个助手留下的笔记,让模型不会把摘要里的旧请求当新指令再执行一遍。 - 增量更新:二次压缩在已有摘要上更新,不从头重压。摘要 token 上限 12000,防自己膨胀。
压缩触发时主动调 _invalidate_system_prompt() + _build_system_prompt() 重建系统提示词,冻结的记忆快照重新生成,加载最新的记忆内容。
错误分类器
API 调用失败的原因各种各样:认证失败、额度耗尽、限流、上下文超限、模型不存在、网络中断……FailoverReason 枚举把这些归了 14 种。每个错误抛出时先过一道分类器,再封装成 ClassifiedError,只带四个布尔恢复标记:
retryable: bool # 能不能直接重试
should_compress: bool # 要不要先压缩上下文再重试
should_rotate_credential: bool # 要不要切换到下一个 API Key
should_fallback: bool # 要不要切到 fallback 模型
主循环拿到 ClassifiedError 后不自己做字符串匹配,只看这四个标记决定下一步。所有“这条报错里带 rate_limit、那条带 insufficient_funds、还有一条是 openai 模块抛的 BadRequestError”之类的脏活儿全集中在分类器里,一次性把错误映射到恢复动作,主循环只负责 dispatch。
典型的对比是 HTTP 402 和 429。它们表面都是“限额”类错误,但处理方式完全不同:
- 429 是临时限流:退避重试同一个 Key 就能恢复
- 402 是额度耗尽:必须立即切到下一个 Key
分类器把这两种错误映射到不同的恢复标记组合(429 置 retryable=True,402 置 should_rotate_credential=True),主循环看标记就知道该退避还是该换钥匙。
用户中断
每轮开头检查 _interrupt_requested。用户 Ctrl+C 或发新消息触发时不 raise 而是 break:持久化已有结果,返回 interrupted=True。如果前面 tool_calls 已追加但没执行,会补一个伪造的错误 tool result,保证消息结构对 API 合法,下次恢复对话不会被 Provider 拒。做过 Agent 开发的人都知道,工具结果缺失会导致下一次调用报错,这个伪造结果很关键。
七、消息返回

模型给出最终回答后,文本沿着和进来相反的方向走回去。流式 token 通过 _fire_stream_delta() 边生成边推。CLI 下直接写进 prompt_toolkit 的 patch_stdout,Gateway 下由 stream_consumer.py按 1 秒节流编辑同一条消息(不是每个 token 发一条,那样会被平台限流)。
附件处理:在文本里加 MEDIA:/absolute/path/to/file 前缀就能发附件。模型无论面对飞书、Discord 还是 iMessage,吐出的都是同样格式的 MEDIA: 行。Gateway 侧在文本展示前把 MEDIA: 指令剥掉,交给对应平台适配器转成各自的附件 API。飞书走上传素材接口,Discord 拼 file attachment,iMessage 走 BlueBubbles 的 attachment 字段。
回头看第 1 步,消息进来时也是同一套逻辑,各平台适配器把五花八门的消息统一成 MessageEvent。一进一出,两层适配把平台差异挡在核心之外:
进来把各平台消息统一成
MessageEvent,出去把统一格式的MEDIA:再拆回各平台附件机制。
核心代码(主循环、工具、记忆、技能)从头到尾只跟统一协议打交道,不用知道消息从哪来、要到哪去。想接新平台,写一个适配器就够了。
八、自进化
这里是 Hermes 区别于 OpenClaw 的核心:

模型给出最终回答,终端/平台也推送完毕。此时 run_conversation() 返回前还有三件事:落盘、记忆同步、后台复盘。都在用户看完回复、Agent 表面“闲下来”之后发生,对用户零感知。
记忆同步
- 内置 MEMORY.md / USER.md:每次
memory工具调用立刻 atomic rename 写磁盘;下次新会话load_from_disk()时快照才刷新,既保前缀缓存命中,又不丢数据。 - 外部 Provider:每轮结束调
sync_all(用户原话, 最终回复),推整轮交换给 Provider,让它自己抽事实;on_session_end不是每轮调,只在 CLI 退出、/reset或 Gateway 判定会话过期时调一次。
每条用户消息 = 一次 run_conversation,但一个会话包含多条消息。“每轮小 sync、会话末大 flush”是常见节奏。
后台复盘
这里是真正“自进化”的地方。先铺垫一下“技能”的概念:Hermes 的**技能(Skills)**是存在 Skills 目录里的一堆 Markdown 文档,每一篇是一段“做过之后沉淀下来的操作笔记”。会话启动时,这些文档的标题和简介会被拼成一个紧凑目录(技能索引)塞进系统提示词。模型在对话里遇到相关任务,看到目录条目,再用 skill_view 按需加载完整内容。技能越攒越多,Agent 下次做类似任务就越不用从头摸索,这是“自进化”的物质基础。
但技能不会自己长出来,得有人(或 Agent 自己)往里写。Hermes 用两条信号并行:
信号一:系统提示词里的主动引导。 SKILLS_GUIDANCE 告诉模型“复杂任务完成后主动存、用到过时的技能立即 patch”,让模型在合适的时刻自己调 skill_manage 写文件。
信号二:后台强制复盘。 默认 _skill_nudge_interval = 10,每消耗 10 次模型推理轮次触发一次“技能复盘”。计数器跨用户消息累加,不会因用户发了新消息就归零。如果这 10 轮里 Agent 已经调过 skill_manage(信号一生效),计数器重置,再过 10 轮才会再触发,避免刚存完又逼着复盘。
复盘触发后,_spawn_background_review() 在一个独立的后台线程里 fork 一个 mini Agent:max_iterations=8、quiet_mode=True(输出不回显给用户),喂给它的 prompt 大意是:
回顾上面这段对话。里面有没有用到过非平凡的方法(试错过、中途改过主意、或者用户期待的结果和实际不一样)?有就存成新技能或更新现有技能;没有就说 “Nothing to save.” 直接停下。
这个 mini Agent 拿整段对话当背景,任务就一件:判断值得存,就调 skill_manage 写一份新技能或更新旧技能;不值得,就退出。工程上最关键的一点:背景线程必须在回复已经发给用户之后才启动,绝对不和用户正在等的响应抢模型资源。两种做法的根本分歧:
- 让模型在每次回答过程中自己顺带想一下要不要写技能:会拖慢用户看到回复的延迟,还分散模型对主任务的注意力
- 另起一个背景线程定期复盘:对用户零感知,复盘时拿完整对话慢慢想
Hermes 选了后者,再配上信号一的主动引导兜底。Agent 自己主动存是理想情况,背景线程是防它漏掉或者偷懒的保险。 两条信号一起,技能库才能长期健康地长肉。
这就是“自进化”真正发生的地方:用户看不到它学,但每过 10 轮模型推理,就可能有一段新的经验被沉淀进技能库。下一次遇到类似任务,它就不用从头摸索。
上下文压缩与历史留存
上下文压缩有一个容易被忽略的副作用:摘要是有损的。用户今天聊了一大段,明天回来问“昨天你说的那个函数名叫什么来着”,模型看到的只是摘要,细节可能已被压缩掉,答不上来。如果压缩是“就地覆盖”旧对话,对用户来说就是历史丢了。
Hermes 的做法是:每次上下文压缩时,SessionDB 里做三件事:
- 结束当前 session,原始对话完整保留在数据库里,不删
- 开一个新 session,把压缩后的摘要作为新 session 的起点
- 新 session 的
parent_session_id指回旧 session 的 ID
连续压缩几次就形成一条链:新 session 的 parent 指回上一次的 session,一路能追溯到最初的那轮对话。
这样设计,“省成本”和“不丢历史”两个看似矛盾的目标用分层各自满足:
- 模型层(当前 session):只装系统提示词 + 摘要 + 近期对话,token 成本不会随对话无限膨胀
- 数据层(SQLite):所有 session 的原始消息全部留着,FTS5 索引全文可搜。用户再问“昨天那个函数”,
session_search工具直接命中老 session 的原文,把片段返给模型
模型看到的是压缩版,数据库存的是完整版。两个目标不该用同一份数据同时扛,用数据模型分开承载。
但要区分清楚:“能搜到历史”和“Agent 记住了”是两回事。session_search 是按需检索,搜索结果只是当次推理的临时上下文,不会自动写入 MEMORY.md。真正持久的“记忆”只有一条路:模型主动调 memory 工具写入,下次新会话启动时才从磁盘加载进快照。换句话说,session 链保的是原始数据不丢,记忆系统保的是经验沉淀不丢,两条通道各管各的。有 session 链不代表可以不写记忆,前者是被动存档,后者是主动学习。
结语
整条消息流程走完,几个关键设计值得再想一想:
- 压缩之后开一个新会话,形成会话链,既省 token 又不丢历史
- 会话中记忆不更新,保持前缀缓存命中
- 被中断的工具自动补齐结果,保证消息结构合法
- 不同的模型要用不同的提示词去鞭策
- 技能自进化,靠提示词主动引导 + 后台复盘双保险
这些设计决策背后都是成本和可靠性的权衡,也正是 Agent 工程中需要反复打磨的地方。