能力扩展
通过能力包、外部证据、持久状态和标准协议扩展 Agent 能做的事情。
如何为 Agent 增加 Skill、检索、记忆和外部协议能力?概念校准
记忆
- 它是什么
- 跨步骤或会话保存、检索并使用状态的机制,必须定义写入、读取和遗忘策略。
- 它不是什么
- 不只是压缩历史对话,也不应把所有旧信息重新塞回上下文。
上下文
- 它是什么
- 一次模型调用可见的信息集合,包括指令、对话、工具 Schema 与结果、检索证据和当前状态。
- 它不是什么
- 不只是聊天记录,也不等于模型能够永久记住的全部信息。
可观测性
- 它是什么
- 通过结构化日志、Trace、指标和版本信息解释系统在一次运行中发生了什么。
- 它不是什么
- 不只是保存终端输出;没有关联 ID、状态转换和敏感信息边界的日志难以诊断。
状态、会话、记忆与上下文压缩
设计会话状态、Checkpoint、短期记忆、长期记忆、压缩和重放。
Python 3.12 · pytest · Pydantic · SQLite · deterministic Mock概念校准\n\n在开始操作代码前,先把本章涉及的概念放回正确的工程边界。\n\n\n\n### 记忆 memory\n\n- 它是什么: 跨步骤或会话保存、检索并使用状态的机制,必须定义写入、读取、冲突、保留和遗忘策略。\n- 它不是什么: 它不只是历史摘要,也不应把全部旧记录放进每次上下文;保存的数据只有被正确检索和使用时才形成有效记忆。\n- 与相邻概念的关系: State 表示当前进度,Checkpoint 保存一致快照,Memory 跨边界复用信息,Context 只承载本次调用选择出的部分。\n- 在 HeatStack Forge 中的位置: Forge 把任务状态、用户确认和长期偏好分开存储,使用版本与作用域检索,并让任务可暂停、恢复和重放。\n- 典型误用与修正: 误用是每轮自动写入全部模型输出。修正方法是设置写入门槛、来源、过期和冲突策略,并测试陈旧数据不能覆盖当前事实。\n\n### 上下文 context\n\n- 它是什么: 一次模型调用实际可见的信息集合,包括指令、对话片段、工具 Schema 与结果、检索证据、当前状态和输出约束。\n- 它不是什么: 它不只是聊天记录,也不是数据库中的全部信息;只有经过选择并放入本次调用的信息才属于本次上下文。\n- 与相邻概念的关系: RAG 选择外部证据,Memory 保存跨步骤状态,Tool Schema 描述可用动作;三者都可供给上下文,但职责不同。\n- 在 HeatStack Forge 中的位置: Forge 的 context builder 按任务、权限、预算和来源优先级组装输入,并记录每段信息的来源与版本。\n- 典型误用与修正: 误用是把更多 Token 当成更完整的理解。修正方法是建立选择、截断、引用和敏感信息过滤规则,并用固定夹具验证。\n\n### 可观测性 observability\n\n- 它是什么: 通过结构化日志、Trace、指标、状态转换和版本信息解释一次运行发生了什么,并支持定位失败与比较版本。\n- 它不是什么: 它不只是终端输出,也不是无限记录所有输入;缺少关联 ID、状态转换或敏感信息边界的日志既难排查又可能泄露数据。\n- 与相邻概念的关系: Harness 产生运行事件,Agent Loop 与工具调用形成 Trace,Evaluation 聚合指标,Memory 与恢复依赖版本记录。\n- 在 HeatStack Forge 中的位置: Forge 为每次任务生成 operation_id,记录阶段、工具、耗时、成本、错误类别、状态变化和证据路径,同时过滤敏感信息。\n- 典型误用与修正: 误用是等失败后再临时加日志。修正方法是在合同设计时定义事件与关联字段,并用故障注入确认能重建时间线。\n\n
区分当前状态与历史事实,并用不可变事件确定性重建 RunState
先看一个具体问题
如何从事件序列重建当前状态?
你正在构建一个长任务执行系统,需要记录运行过程中的关键事实,例如运行创建、步骤开始、权限撤销等。这些事实一旦发生就不可更改,但当前状态(如运行是否还在进行、当前步骤是什么、是否允许写入)会随着事件不断变化。
如果直接修改一个可变的状态对象,当系统崩溃或需要审计时,你无法知道状态是如何一步步变成现在这样的。因此,你需要将事件作为不可变的历史记录,并通过重放事件来确定性重建状态。
本阶段你将实现一个事件溯源的核心:定义事件和状态模型,并编写 apply_event 函数,使每个事件都能更新状态。同时,你需要保证事件序列连续且运行终止后不再接受新事件。
先做判断
在实现 reduce_events 之前,请预测:如果事件序列中存在间隙(例如序列 1 后直接是序列 3),系统应该如何处理?
- 忽略间隙,继续应用后续事件
- 抛出异常,因为状态无法可靠重建
- 自动填补缺失的事件
- 仅记录警告,但继续执行
判断依据: 正确答案是抛出异常。事件序列必须连续,因为每个事件都依赖于前一个事件产生的状态。如果存在间隙,说明历史记录不完整,无法确定中间发生了什么,因此必须拒绝重建,而不是猜测或忽略。
它是什么
状态是当前进度的投影,事件是历史事实
状态(RunState)表示某一时刻运行的最新情况,例如运行状态、当前步骤、是否允许写入。它是从事件序列中推导出来的,而不是独立存储的。
事件(RunEvent)是不可变的历史记录,每个事件包含序列号、类型和负载。事件一旦创建就不能修改,因此可以安全地重放。
通过 apply_event 函数,将事件逐个应用到状态上,最终得到当前状态。这个过程是确定性的:给定相同的事件序列,总是得到相同的状态。
它不是什么
状态不是历史记录,事件也不是可变对象
状态不是所有事件的列表,它只包含当前需要的信息。历史事件可以保留用于审计,但状态是精简的投影。
事件不是可变的,一旦创建就不能修改。如果发现错误,不能修改旧事件,而应该追加一个新事件来纠正。
状态不能直接从历史记录中读取,必须通过重放事件来重建,否则无法保证一致性和可审计性。
它与相邻概念的关系
事件驱动状态转换
每个事件都对应一个状态转换。例如,RUN_CREATED 事件创建初始状态,STEP_STARTED 事件更新当前步骤,WRITE_PERMISSION_REVOKED 事件将 write_allowed 设为 False。
事件序列必须连续,因为每个事件都基于前一个事件产生的状态。如果序列有间隙,就无法确定中间发生了什么,因此必须拒绝。
运行终止后(COMPLETED 或 FAILED),不能再接受新事件,因为状态已经最终确定,继续应用事件会导致不一致。
本阶段的边界决定: 当事件序列不连续或运行已终止时,必须抛出异常,而不是尝试修复或忽略。这保证了状态重建的可靠性和可审计性。
区分会话和单次运行,并显式管理运行开始、结束与并发边界
先看一个具体问题
为什么需要区分会话和运行?
在上一阶段,我们建立了状态和事件模型,但还没有管理多个运行的能力。现在,一个用户会话可能包含多次运行,例如一次失败后重试,或者并行处理多个任务。如果每次运行都使用相同的 run_id,历史记录就会混在一起,无法区分哪次运行产生了哪些事件。
本阶段要求实现一个 Session 容器,它能够启动多个运行、完成运行,并保证 run_id 在会话内唯一。测试 test_session_tracks_multiple_runs_without_merging_them 验证会话能正确跟踪多个运行而不合并它们;测试 test_duplicate_run_id_is_rejected 验证重复的 run_id 会被拒绝。
先做判断
在实现 start_run 时,如果传入一个已经存在的 run_id,应该发生什么?
- 静默忽略,不添加新运行
- 抛出 ValueError,因为 run_id 必须唯一
- 覆盖旧的运行记录
- 自动生成一个新的 run_id
判断依据: 正确答案是抛出 ValueError。因为测试 test_duplicate_run_id_is_rejected 明确要求重复 run_id 被拒绝,并且错误消息中包含 ‘unique’。静默忽略或覆盖都会导致历史丢失或混乱,自动生成新 ID 则违背了调用者的意图。
它是什么
会话是运行的容器
Session 是一个不可变的数据结构,包含 session_id 和一个 runs 元组。每个 run 是一个 RunRecord,包含 run_id 和 status。start_run 函数向会话中添加一个新的 RunRecord,并返回新的 Session 实例。finish_run 函数将指定 run 的状态改为终态(completed、failed 或 cancelled)。
会话的生命周期由这些函数显式管理:创建会话、启动运行、完成运行。运行 ID 在会话内必须唯一,这是通过 start_run 中的检查保证的。
它不是什么
会话不是单次运行
会话不是一次具体的执行,而是多次执行的容器。运行是单次执行,有明确的状态转换。混淆两者会导致状态管理混乱,例如无法区分不同运行的事件。
会话也不等同于聊天记录或上下文;它只保存运行的结构信息,不保存具体的事件内容。
它与相邻概念的关系
会话、运行和状态的关系
Session 包含多个 RunRecord,每个 RunRecord 有 run_id 和 status。start_run 和 finish_run 是操作 Session 的函数,它们返回新的 Session 实例,保持不可变性。active_run_ids 属性从 runs 中筛选出状态为 active 的运行 ID。
这种设计使得会话状态可以安全地传递和共享,而不会意外修改。
本阶段的边界决定: 在 start_run 中,必须检查 run_id 是否已存在;如果存在,抛出 ValueError。这是会话内运行 ID 唯一性的边界。
保存带版本和完整性校验的最小 Checkpoint,并在损坏或版本不兼容时拒绝恢复
先看一个具体问题
保存的检查点可能被篡改或版本不兼容
在长任务执行中,我们需要保存当前状态以便暂停、恢复或重放。如果检查点文件被意外修改(例如磁盘错误或人为篡改),恢复时可能加载到错误的状态,导致后续决策基于不可信的数据。
此外,随着系统演进,检查点的结构可能变化。如果加载旧版本或未知版本的检查点,程序可能因字段缺失或类型不匹配而崩溃,或者静默地产生错误结果。
本阶段要求实现一个带版本号和 SHA-256 校验和的最小检查点模块,在加载时验证版本和完整性,拒绝损坏或不支持的快照。
先做判断
在实现检查点加载时,以下哪种做法能最可靠地防止加载被篡改的状态?
- 只检查 JSON 文件是否能被解析为 CheckpointEnvelope 模型。
- 在加载时重新计算状态的 SHA-256 校验和,并与存储的校验和比较。
- 信任文件系统权限,认为只有授权用户能修改文件。
- 在加载时只检查 schema_version 是否等于当前版本。
判断依据: 正确选项是重新计算校验和并比较。仅解析 JSON 或检查版本无法发现内容被篡改;文件系统权限不能防止所有意外修改;版本检查只能处理结构兼容性,不能保证数据完整性。
它是什么
检查点契约
检查点是一个包含 schema_version、state 和 checksum_sha256 的不可变信封(CheckpointEnvelope)。schema_version 标识结构版本,state 是当前运行状态,checksum_sha256 是对 schema_version 和 state 的规范化 JSON 表示计算的 SHA-256 哈希。
保存时,我们计算校验和并原子写入文件;加载时,先验证 schema_version 是否受支持,然后重新计算校验和并与存储值比较,任何不匹配都抛出 ValueError。
它不是什么
检查点不是什么
检查点不是状态的简单序列化,它必须包含完整性证明。它也不是长期记忆或会话历史的替代品;它只保存某一时刻的状态快照。
检查点不负责决定何时保存或恢复,也不处理并发写入冲突;这些策略由上层会话管理决定。
它与相邻概念的关系
与其他概念的关系
检查点与状态(RunState)紧密相关:它封装了状态并提供持久化。与上下文不同,检查点不会自动进入模型上下文;只有显式加载并选择部分状态后,才成为上下文的一部分。
可观测性方面,检查点的版本和校验和提供了审计线索,帮助诊断恢复失败的原因。
本阶段的边界决定: 当加载检查点时,必须同时验证 schema_version 和 checksum_sha256;任何一项失败都应拒绝恢复,而不是尝试部分加载或静默修复。
用 Checkpoint 加尾部事件恢复运行,并证明恢复结果与完整事件重放一致
先看一个具体问题
如何证明从检查点恢复的状态与完整事件重放完全一致?
在长任务执行中,系统会定期保存检查点(Checkpoint)以支持暂停和恢复。当任务中断后,我们希望通过加载检查点并重放检查点之后的事件(尾部事件)来恢复运行状态。
然而,仅仅加载检查点并应用尾部事件并不能保证恢复结果正确。如果尾部事件不完整(例如遗漏了某个事件),恢复出的状态可能与从头重放所有事件得到的状态不一致。
本阶段要求你实现一个验证函数 verify_recovery_equivalent,它比较“检查点+尾部事件”恢复的状态与“完整事件重放”得到的状态,若不一致则抛出 RecoveryMismatch 异常。
先做判断
在实现恢复等价性验证之前,请预测:如果从检查点恢复时遗漏了一个尾部事件,会发生什么?
- 恢复的状态可能与完整重放不一致,但系统无法自动发现
- 恢复的状态一定与完整重放一致,因为检查点包含了所有必要信息
- 系统会抛出异常,因为检查点校验和会检测到事件缺失
- 恢复的状态可能不一致,但可以通过日志手动检查
判断依据: 正确答案是第一个选项。检查点只保存了某个时刻的状态快照,它无法知道之后应该发生哪些事件。如果尾部事件不完整,恢复出的状态就会缺少那些事件的影响,导致与完整重放不一致。因此需要显式比较来发现这种不一致。
它是什么
恢复等价性验证是什么
恢复等价性验证是一种安全机制,它通过比较两种计算路径的结果来确保恢复正确:路径一是从检查点加载状态并应用尾部事件;路径二是从头重放完整事件历史。
如果两条路径得到的状态完全相同,则证明检查点和尾部事件足以重建完整状态;如果不同,则说明尾部事件有缺失或顺序错误,必须抛出异常。
它不是什么
恢复等价性验证不是什么
它不是检查点校验和验证。校验和只能保证检查点文件未被篡改,不能保证尾部事件完整。
它也不是简单的状态比较,而是必须实际执行完整重放,因为状态可能包含复杂对象,仅靠表面字段无法判断深层一致性。
它与相邻概念的关系
与其他概念的关系
检查点(Checkpoint)保存了某个时刻的状态快照,是恢复的起点。
事件重放(Event Replay)通过依次应用事件来构建状态,是完整路径。
恢复等价性验证将两者结合:检查点提供加速,尾部事件提供增量,完整重放提供基准。
本阶段的边界决定: 当且仅当检查点加尾部事件恢复的状态与完整事件重放的状态完全相等时,才认为恢复等价;否则必须抛出 RecoveryMismatch 异常,阻止错误状态继续传播。
把可恢复运行状态与可检索长期记忆分开,并实现作用域、过期和删除语义
先看一个具体问题
长期记忆与运行状态分离,并确保记忆只在正确作用域和有效期内可检索
在上一阶段,我们实现了可恢复的运行状态,但还没有长期记忆。现在需要新增 forge/memory.py,定义 MemoryScope(RUN、SESSION、USER)和 MemoryRecord,并实现 MemoryStore 的 put、query、delete、purge_expired 方法。
关键约束:查询时必须按作用域和主体过滤,并排除已过期记忆;删除操作必须显式且幂等。测试 test_memory_is_queried_only_inside_its_scope_and_subject、test_expired_memory_is_hidden_and_can_be_purged、test_deletion_is_explicit_and_idempotent 将验证这些行为。
当前起始代码没有 forge/memory.py,运行测试会报 ModuleNotFoundError。你需要从零创建该文件,并修改 forge/init.py 导出相关符号。
先做判断
在实现 MemoryStore.query 时,如果只按 scope 和 subject_id 过滤,而不检查 expires_at,会发生什么?
- 已过期的记忆仍然会被返回,导致后续决策使用陈旧信息。
- 查询会抛出异常,因为 SQL 语句不完整。
- 已过期的记忆会自动被数据库删除,所以不会返回。
- 查询结果会按创建时间排序,但不会包含过期记忆。
判断依据: 正确选项是第一个。如果查询不检查 expires_at,那么即使记忆已过期,只要 scope 和 subject_id 匹配,它就会被返回。这会导致过期记忆污染当前上下文,影响决策。测试 test_expired_memory_is_hidden_and_can_be_purged 专门验证这一点。
它是什么
记忆边界模型
记忆是一个带有作用域和生命周期的数据记录。作用域(RUN、SESSION、USER)定义了记忆的可见范围:RUN 仅在一次运行内可见,SESSION 在一次会话内可见,USER 跨会话长期可见。
每条记忆都有 created_at 和可选的 expires_at。查询时,系统根据当前时间过滤掉已过期的记忆。删除操作通过 memory_id 精确删除,并且重复删除不会报错,返回 False 表示未删除任何记录。
它不是什么
记忆边界不是什么
记忆不是简单的聊天记录压缩,也不是把所有历史信息都塞进上下文。它必须定义写入、读取、冲突、保留和遗忘策略。
记忆不等于运行状态。运行状态(如当前步骤、重试次数)是易失的,而记忆是持久化的,可以跨运行复用。
它与相邻概念的关系
记忆与其他概念的关系
记忆与上下文:上下文是一次模型调用可见的信息集合,记忆是上下文的可能来源之一。只有通过查询并放入上下文的记忆才会影响本次调用。
记忆与状态:状态表示当前进度,记忆保存跨步骤或会话的信息。状态可以通过检查点恢复,而记忆通过作用域和过期策略管理生命周期。
本阶段的边界决定: 在实现 MemoryStore.query 时,必须同时过滤 scope、subject_id 和 expires_at,确保只返回当前有效且属于请求作用域的记忆。
只压缩普通对话材料,同时逐字保留指令、权限决定和承诺,并用保真检查阻止静默丢失
先看一个具体问题
压缩上下文时如何防止权限决定被静默篡改?
在长任务中,上下文窗口有限,必须压缩普通消息以节省空间。但压缩不能影响关键信息:指令、权限决定和承诺必须逐字保留,否则系统可能执行未授权的操作。
当前代码中,forge/context.py 尚未创建,测试导入失败。你需要实现 ContextPacket、CompressedContext、compress_context 和 verify_critical_fidelity,确保压缩后的关键字段与源完全一致,并且任何改动都会触发保真检查失败。
先做判断
在实现 verify_critical_fidelity 时,如果压缩后的 permission_decisions 与源不同,应该发生什么?
- 静默接受,因为压缩可能改变权限
- 抛出 ValueError,因为权限决定必须逐字保留
- 记录警告但继续执行
- 自动恢复为源权限
判断依据: 正确选项是抛出 ValueError。权限决定属于关键上下文,任何改动都意味着安全风险,必须立即失败。静默接受或自动恢复都可能掩盖篡改。
它是什么
上下文压缩保真模型
上下文压缩将普通消息总结为短文本,同时将关键字段(指令、权限决定、承诺)原样复制到压缩结果中。
关键字段通过 SHA-256 指纹绑定,验证时逐字段比较并检查指纹,确保压缩过程没有丢失或篡改关键信息。
它不是什么
不是无差别总结
压缩不是对所有内容进行摘要。关键字段不能总结,因为总结可能改变语义,例如将“write_access=revoked”总结为“write access discussed”。
压缩也不是简单的截断。普通消息可以截断,但关键字段必须完整保留,否则后续步骤无法依赖这些信息。
它与相邻概念的关系
组件关系
ContextPacket 是源数据,包含所有字段。compress_context 生成 CompressedContext,其中关键字段直接复制,普通消息被总结。
verify_critical_fidelity 接收源和压缩结果,比较关键字段并验证指纹。如果任何关键字段不匹配或指纹不一致,抛出 ValueError。
本阶段的边界决定: 关键字段(指令、权限决定、承诺)必须逐字保留并指纹化;普通消息可以压缩。验证时,任何关键字段的差异或指纹不匹配都必须导致失败。
用可审计事件日志重放运行,并把记忆删除同步传播到依赖它的派生摘要缓存
先看一个具体问题
删除记忆后,派生摘要缓存仍暴露已删除内容
在上一阶段,我们实现了上下文压缩,但系统仍缺少可审计的事件日志和安全的记忆删除机制。当前代码中,forge/replay.py 文件尚未创建,因此测试在导入时即失败(ModuleNotFoundError: No module named ‘forge.replay’)。
本阶段需要实现两个核心机制:一是基于哈希链的事件日志,确保日志可审计且防篡改;二是在删除记忆时,显式传播到所有依赖该记忆的派生摘要缓存,避免缓存继续暴露已删除的敏感信息。
具体而言,测试 test_deletion_propagates_to_derived_summary_cache 会创建一个用户记忆 private-note,并生成两个派生摘要:summary-1 引用了 private-note,summary-2 未引用。调用 delete_memory_everywhere 后,期望 summary-1 被失效(invalidated == 1),而 summary-2 保持不变。
当前故障夹具(fixtures/failure.json)显示,delete_memory_everywhere 在删除主存储中的记忆后直接返回,未调用 DerivedSummaryCache.invalidate_for_memory,导致测试失败。
先做判断
在实现 delete_memory_everywhere 时,如果只删除主存储中的记忆而不处理派生摘要缓存,会发生什么?
- 派生摘要缓存会自动更新,无需额外操作
- 派生摘要缓存仍保留已删除记忆的内容,导致数据泄露
- 系统会抛出异常,因为缓存与主存储不一致
- 删除操作会失败,因为缓存中存在引用
判断依据: 正确答案是第二个选项。派生摘要缓存是独立存储的,不会自动感知主存储的变化。如果不显式失效,缓存会继续暴露已删除的记忆内容,违反数据删除的完整性要求。
它是什么
事件日志与派生摘要缓存的关系
事件日志(EventJournal)是一个仅追加的、基于哈希链的审计记录。每条记录包含前一条记录的哈希(previous_hash)和当前事件的哈希(record_hash),任何篡改都会导致哈希不匹配而被检测到。
派生摘要缓存(DerivedSummaryCache)存储由多条记忆生成的摘要,并记录每条摘要引用的源记忆 ID 列表(source_memory_ids)。当删除某条记忆时,必须找到所有引用该记忆的摘要并将其删除,否则缓存会保留过时内容。
delete_memory_everywhere 函数负责协调删除操作:先删除主存储中的记忆,然后调用 summary_cache.invalidate_for_memory(memory_id) 失效所有相关摘要,并返回失效数量。
它不是什么
常见误解
事件日志不是普通的文本文件,它通过哈希链保证完整性,任何修改都会破坏链。
派生摘要缓存不会自动与主存储同步,必须显式失效。
删除记忆不是简单的单点操作,需要传播到所有依赖它的派生数据。
它与相邻概念的关系
组件协作
EventJournal 使用 RunEvent 和 reduce_events 重放状态,确保从日志恢复的状态与原始状态一致。
DerivedSummaryCache 通过 source_memory_ids 字段建立摘要与记忆的依赖关系,invalidate_for_memory 方法利用该字段查找并删除相关摘要。
delete_memory_everywhere 将 MemoryStore 和 DerivedSummaryCache 连接起来,实现跨存储的删除传播。
本阶段的边界决定: 在删除记忆时,必须同时处理主存储和所有派生缓存;仅删除主存储而忽略缓存会导致数据不一致和潜在泄露。
完成本章
让长任务可以暂停、恢复和重放,同时控制上下文增长。
本地实验自检
- 未开始
- 2阅读中
- 3实验已下载
- 4测试结果已读取
- 5本地自检通过
verification.json 只在当前浏览器中解析,不会上传。这里验证的是实验合同,不是服务器认证或第三方背书。
概念校准与一周复习
三道题检查你是否掌握了本章边界、交付证据和恢复方法。答案只保存在当前浏览器。