“Agent 就是一个循环。”

把上下文发给模型,模型要调用工具就执行,把结果塞回去,再问一次。直到模型回答完,或者循环次数用尽。只看这段逻辑,确实不复杂。

可一旦把它做成一个能持续使用的后端,问题马上就变了:模型协议换了怎么办?执行命令前谁来判断能不能做?进程在工具执行后崩溃,重启时会不会再执行一遍?用户从昨天的一条消息分叉,今天已经被改过的文件会跟着回去吗?

这些问题都不会因为 while 写得漂亮就消失。

工作流、三层设计和依赖倒置串得很清楚,最后留下了“分叉对话”和“对话压缩”两道作业。我很认同它从流程里找概念、再划分职责的思路。

下面沿用这条主线,再加上自己的工程推导。中断恢复、执行权限、分叉的数据语义、摘要的失效与验收方案,都是本文在视频基础上的扩展设计;案例中的任务和预算数字也都是为了说明机制而设定的,并非上线实测。

一、先让一个 Agent 跑起来,再看循环漏掉了什么

假设我们要做一个编程助手。用户说:

检查这个项目为什么测试失败,修复后再跑一遍。不要改部署配置,也不要删除已有文件。

最低限度的流程可以写成这样:

1
2
3
4
5
6
接收用户消息,加入会话
→ 组织指令、工具定义和历史消息
→ 请求模型
→ 如果模型提出工具调用,检查后执行
→ 把调用结果加入会话,再次请求模型
→ 没有工具调用时返回回答,或达到上限时结束运行

这是一个合理的起点。单用户、本地运行、只有几个只读工具时,用一个模块把链路跑通,能很快验证模型是否理解任务、工具是否好用。

我不觉得原型阶段就应该先堆一排抽象类。需要拆分的信号,是不同的变化开始挤在同一处。

比如最初使用一家模型服务,代码直接在循环里读取它的响应字段。后来换服务商,不只是调用地址变了,历史消息、工具定义、工具结果的格式都要跟着改。再想测“审核拒绝后会不会继续执行”,测试居然必须连接真实模型,结果还不稳定。

这时,真正的问题已经不是循环怎么写,而是:哪些规则属于我们的系统,哪些细节来自外部服务?如果这两种东西绑在一起,一次外部变化就会穿透整个流程。

视频从这里引出领域层、应用层和适配层。分层的价值,也要从这类变化里理解。

二、三层架构到底在隔离什么

领域层定义系统自己的语言。什么叫一次会话,什么叫工具调用,审核结果里必须有什么,这些都由我们决定。

应用层把这些概念编排成用例。用户发来一条消息,先保存还是先请求模型?工具被拒绝后如何反馈?什么时候继续,什么时候停止?这些属于流程。

适配层负责和外面的世界打交道。把模型服务的响应翻译成自己的消息,把会话写进文件或数据库,把 HTTP 请求翻译成应用层调用,把工具请求交给实际的执行环境。

Agent 三层架构的代码依赖方向:应用层依赖领域概念,适配层实现应用层接口,组装根负责创建与注入

图里表示的是代码依赖方向。运行时,当然可能是 AgentService 调用一个模型适配器;但应用层只认识 ModelClient 接口,不需要导入那个适配器的具体类。

视频把 SessionStore 和 ModelClient 接口放在应用层,道理就在这里:应用层先提出“我需要怎样的能力”,适配层再负责提供。接口放在哪个目录并没有唯一答案,关键是核心流程不要反过来依赖某一家 SDK。

可以用一个很直接的办法检查边界:

现在要改什么 主要应该影响哪里 如果改动扩散,先检查什么
工具被拒绝后,把拒绝原因反馈给模型 应用流程与领域结果类型 拒绝是不是被当成普通异常随手吞掉了
JSONL 存储换成数据库 存储适配器、组装配置 应用层是不是直接拼文件路径或 SQL
新增一个命令行入口 输入适配器、组装配置 HTTP 的请求对象是不是传进了领域层
新接入一种模型协议 模型适配器、能力检查与配置 核心历史里是不是存满了厂商私有字段

这里有个容易被说过头的地方:分层并不保证任何新模型都只改一行。已有接口能表达的新实现,往往可以主要改组装配置;如果新能力改变了语义,比如流式事件、多模态内容、异步工具,就可能需要扩展接口。

好的抽象应该把这种差异暴露在清楚的位置,而不是假装所有模型都完全一样。

Assistant 和 Reviewer,是职责不同的两个角色

视频中,Assistant 根据会话构建回答用户所需的上下文,输出 AssistantMessage;ToolCallReviewer 构建审核某次工具调用的上下文,输出 Decision。

它们可以使用不同的指令、模型和预算,但这并不意味着必须引入两个独立服务,或者先搭一个复杂的多智能体平台。起步时,它们完全可以只是两个上下文构建器,加上两组模型配置。

Decision 也不该只剩一个布尔值。拒绝执行时,需要有模型和用户能理解的原因,应用层才能把它组织成对应的工具结果。

三、先分清历史、上下文和运行状态

后面两道作业能不能设计清楚,取决于这里有没有分开。

会话历史回答的是“发生过什么”:用户提出了什么要求,模型请求了哪个工具,工具实际返回了什么。上下文回答的是“这一次模型需要看到什么”:系统指令、工具定义、相关历史、摘要和必要证据。运行状态回答的是“系统正在做什么”:当前运行到了哪一步,哪些调用尚未完成,还有多少预算。

它们有交集,但不是同一个对象。

一个较长的历史,不必每次全部发给模型;一次工具执行超时,也不能直接把它从历史中抹掉,假装没有发生。用户只是关闭网页时,后端运行是否继续,更不能由聊天列表里最后一句话决定。

在本文的设计中,把它们整理成下面几种对象。后文接口代码只展示契约,不是一套已经实现的 Agent 框架。

对象 必须说清的内容 它解决的问题
Session 会话 ID、有序消息、版本、可选的分叉来源 我们正在继续哪段对话
Message 稳定 ID、消息类型、内容、来源 某个事实在历史中的位置
ToolCall 调用 ID、工具名、参数 模型具体想做什么
ToolResult 对应调用 ID、状态、内容或证据引用 那次调用到底得到了什么
Context 指令、工具描述、本次选入的信息、预期输出类型 这次模型看见什么、应返回什么
Run 运行 ID、状态、预算、当前步骤 一次用户请求执行到哪里

视频里最基础的消息有 UserMessage、AssistantMessage 和 ToolResult。其中 AssistantMessage 可以同时包含文本和多个 ToolCall,结果通过调用 ID 与请求配对。

这个配对不能省。假设模型同时请求读 package.json 和运行测试,返回的两段文本如果没有调用 ID,后面就很难分清哪段是文件内容,哪段是测试输出。出现并行执行或重试后,仅靠排列顺序更容易出错。

本文进一步给结果区分 succeeded、failed、denied 和 unknown。denied 表示确定没有获准执行;unknown 表示外部效果尚未查明,比如请求超时后不知道写入是否已经发生。把这两种情况混成“失败”,恢复时就可能作出错误决策。

还要区分工具的说明和实现。模型只需要看到名称、用途和参数约束,不需要拿到执行器、凭据或文件句柄。视频用一个带 execute 方法的 Tool 表达工具行为;如果后面需要远程执行、沙箱或独立授权,可以进一步拆成 ToolSpec 和 ToolExecutor。

拆分的依据是执行环境确实开始独立变化,不是对象越多就越专业。

预期输出类型,需要真正检查

视频用 Context[T] 表达“这次调用应该返回什么类型”,这个思路很实用。例如回答用户时期待 AssistantMessage,审核工具时期待 Decision。

不过 Python 类型注解不会自动把网络响应变成可信对象。适配器还得解析响应,检查必填字段和参数结构;应用层还得确认工具名存在、调用 ID 不冲突。

结构错误时,可以在限定次数内要求修正,也可以让这次运行明确失败。不能先把不合格结果写成已确认的工具请求,再指望后面碰巧能执行。

四、把循环当成一段会中断的流程

回到修复项目的案例,模型提出先读取配置,再运行测试。应用层至少要保证这样的顺序:

1
2
3
4
5
6
保存用户消息
→ 生成并校验 AssistantMessage
→ 保存 AssistantMessage,其中包含工具调用
→ 逐个检查工具调用,记录审核与执行状态
→ 保存每个调用对应的 ToolResult
→ 在本轮调用都已得到处理结果后,组织下一次上下文

这样安排,是因为每一个持久化点都可能成为重启后的恢复起点。

先执行工具、再补记“模型想调用它”,一旦中间崩溃,历史里就找不到执行原因。只在一整轮结束时统一保存,也可能把已经发生的工具效果全部漏掉。

视频提出消息产生时就持久化,适合作为基础。再向前走一步,就需要把“请求了调用”“开始执行”“执行结果确认”这些状态分开。一次运行的状态也应明确:运行中、等待用户授权,以及正常完成、预算耗尽、执行失败、已取消等终态。预算用完时结束这次运行,不等于这段会话以后不能继续。

最麻烦的中断点,是工具已经做完,但结果还没保存

假设工具已经修改了文件,进程在保存结果前退出。重启后只看见一条未完成的调用,直接再执行一遍可以吗?

读文件通常可以重新读。创建资源、追加内容、发起支付这类有副作用的操作,就不能这样处理。

一个执行记录可以用 (session_id, run_id, tool_call_id) 标识,并带上参数摘要。若外部服务支持幂等键,就把稳定的操作标识传下去,同一次操作重试时继续使用它。相同标识却带着不同参数,应当拒绝,而不是当成一次正常重试。

但本地多写一张执行表,并不能让任意外部命令获得“恰好执行一次”的保证。数据库提交和外部副作用之间仍然存在空隙。

如果工具既不能去重,也不能查询执行结果,崩溃后的合理状态就是 unknown。暂停后续写操作,先核对环境或交给用户处理。明确表达“不确定”,比再赌一次可靠。

这也是 AWS 在幂等 API 与安全重试的文章中强调请求标识和调用意图的原因。同一份参数,未必代表同一次操作;同一次操作的重试,需要有可识别的身份。

循环上限,只管住了一种失控

max_iterations 能防止主循环一直转,却管不住一个工具卡住十分钟,也管不住每次审核和压缩额外消耗的模型费用。

起步时,至少给整次运行设置截止时间和总调用预算,再给工具设置超时与输出上限。审核、修正格式、压缩上下文等附加调用,也要进入同一份预算。用户取消后,停止安排新动作;已经开始的外部操作能否取消,要按工具能力处理,不能只把界面改成“已停止”。

如果一个 AssistantMessage 中包含多个工具调用,先顺序处理就够了。确认调用之间没有数据依赖、写入冲突和顺序要求后,再考虑并行。没有收益证据时,不必为并行执行提前增加复杂度。

JSONL 很适合教学,但它不会自动解决并发

JSONL 的好处很直接:容易追加、容易检查、没有额外数据库服务。单进程教学版用它保存历史很合适。

可“追加了一行”不等于“这条数据已经可靠落盘”。写到一半崩溃怎么办、两个请求同时写怎么办,都需要约定。

本文的最小版本选择同一会话只允许一个活跃运行,其他请求排队或明确返回忙碌。历史追加带版本检查。需要跨进程处理时,再把占用运行、版本更新和消息追加放进有事务能力的存储设计中;不要在等待模型响应的几十秒里一直占着数据库事务。

五、工具审核要回答语义问题,执行器要管住实际能力

视频在执行工具前增加一个审核角色,检查模型提出的动作是否合理。对于我们的例子,它可以发现:“用户明确要求不要改部署配置,你现在却准备修改部署脚本。”

这层检查有价值,但我不会把它当成唯一的安全措施。

审核模型也会误判,也可能被仓库文件或工具输出中的恶意指令干扰。它说“允许”,只说明一次语义判断通过了;实际进程能访问什么文件、能不能联网、能不能读取凭据,仍然由执行环境决定。

比较清楚的处理顺序是:

1
2
3
4
5
6
校验工具名与参数结构
→ 确定性规则检查:权限、资源范围、预算、是否需要授权
→ 对需要语义判断的动作执行审核
→ 若需用户授权,等待与具体动作绑定的授权结果
→ 在受约束的执行环境中运行
→ 记录结果与可追踪证据

只读文件工具可以按策略直接执行,不必每次都多调一个审核模型。高风险动作则可能同时需要规则、审核与用户授权。默认怎么划分,应由产品允许它做什么决定。

“审核一下 Bash 命令”尤其容易让人产生错觉。对命令字符串做黑名单过滤,挡不住所有间接执行、脚本内部行为和路径绕过。允许的命令再普通,进程如果拿着整个宿主机的权限,破坏范围也不会自动变小。

真正需要落实的是执行身份、工作目录、文件系统与网络访问范围、环境变量、资源和时间限制。路径检查也要考虑符号链接,以及检查后到执行前资源发生变化的情况。能够用受控的文件读写或测试工具表达的动作,往往没有必要一开始就开放任意 Shell。

如果用户已经批准某次操作,批准记录应绑定具体参数、资源范围和必要的环境版本,并有有效期。参数换了、目标文件换了,就重新判断。聊天里一句“之前同意过”,或者摘要里一条“用户已经批准”,都不能替代这份记录。

审核拒绝后,应用层生成对应调用 ID 的 ToolResult(status="denied"),说明拒绝原因,再让模型调整方案。执行器没有被调用,才算真正拒绝了操作。只给用户显示一条警告,后台却照常执行,流程上仍然是错的。

六、多模型适配,最重要的是把差异说出来

视频用 ModelClient 封装模型调用,用 ModelSettings 表达模型和思考预算等配置,再交给不同适配器翻译。这能把大量请求格式差异挡在外围。

但字段名称能翻译,不代表能力也能一一对应。

某个模型不支持指定的结构化输出,适配器该怎么办?把“高思考深度”映射成另一个参数,效果就等价了吗?某种响应必须携带服务商的续接信息,下次请求时又该由谁保存?

我更倾向于给适配器一份能力声明,再在开始运行时检查必需能力:

1
2
3
4
5
6
7
8
from dataclasses import dataclass

@dataclass(frozen=True)
class ModelCapabilities:
tool_calls: bool
structured_output: bool
streaming: bool
parallel_tool_calls: bool

应用只需要单个工具调用时,不必要求并行调用;确实依赖结构化审核结果时,缺失能力就应明确报错,或者采用事先设计并验证过的另一条路径。不能悄悄丢掉配置,再返回一个看起来成功的结果。

还需要保留协议要求的续接信息。它可以由适配层管理成带厂商和版本标识的不透明状态,核心流程负责持久化与交还,不必理解内部字段。切换协议时,要先确认这段历史能否转换;不能转换的私有状态,不应原样交给另一家服务。

分层给我们的,是一个检查兼容性的地方。它没有消除兼容性本身。

七、作业一:从历史消息分叉一段新对话

第一道作业,是从已有对话中的某条消息分出新会话,继承此前历史,之后各自继续。

看起来像复制列表,但真正的设计问题是:复制到哪、什么东西独立、什么东西没有跟着复制。

先把“从这条消息开始”说准确

假设历史如下:

1
2
3
4
5
6
m1 用户:检查测试失败原因,不要修改部署配置,也不要删除已有文件
m2 助手:读取 package.json,调用 c1
m3 工具:c1 返回文件内容
m4 助手:运行测试,调用 c2
m5 工具:c2 返回失败日志
m6 助手:建议升级依赖

用户想在读完配置的位置,换一条排查思路。我们定义接口中的 through_message_id 表示“包含这个消息,截取到这里为止”。如果传 m3,新会话继承 m1..m3,不带上 m4..m6。

这是一条本文明确选择的接口语义。界面如果提供“编辑这条用户消息后重新生成”,则应在旧消息之前截断,再追加编辑后的新消息,不去修改原会话。

工具调用还带来了另一个约束:不能随便切在半个调用中间。

如果截在 m2,历史里就有调用 c1,却没有它的结果。直接把这个历史发给模型,可能违反目标协议,也可能诱使运行器重新执行历史调用。

所以基础版本只允许在没有未配对工具调用的位置分叉,例如 m3、m5 或不带工具调用的普通消息之后。其他位置明确返回“切点不完整”,由界面引导选择。以后真的要支持任意位置,再设计“放弃调用”或截断整组调用的规则,不能静默挪动用户选的切点。

这里把一次助手工具请求及其全部对应结果看成一个完整单元。多个调用的结果还没收齐时,同样不是稳定切点。

最小接口与数据归属

1
2
3
4
5
6
7
def fork_session(
source_session_id: str,
through_message_id: str,
request_id: str,
) -> str:
"""复制包含切点的完整历史前缀,返回新会话 ID。"""
...

request_id 用来识别同一次创建请求,防止前端超时重试后创建两个分支。服务端还要绑定调用者身份;相同请求标识带着不同切点时,返回冲突。

新会话记录 parent_session_id 和 fork_point_message_id,方便追溯来源。它有自己的会话 ID、版本、后续消息与运行状态,不继承原会话正在执行的 Run。

第一版可以直接复制历史前缀,时间和空间成本都与前缀长度成正比。消息内容必须按不可变值处理,或者进行真正的深拷贝,不能只复制外层列表,内部参数字典仍被两个分支共同修改。

本文允许不可变历史消息保留原来的 message_id 和 tool_call_id,它们指向同一段已发生的历史;会话内成员关系以 (session_id, message_id) 标识。分支后新增的消息另生成 ID,历史调用也不会被当成新任务重放。

如果存储模型要求每份副本使用新 ID,就同时保存来源 ID,并成组重映射工具调用与结果的关联。只改请求的调用 ID、忘了改结果里的引用,会制造一段无法解释的历史。

创建分支时,一次固定一个历史前缀

应用层的处理可以整理成六步:

  1. 校验调用者对源会话有读取权限、对新会话有创建权限。
  2. 读取一个固定版本的历史前缀,确认切点存在且工具关系完整。
  3. 创建新会话,保存来源和切点。
  4. 写入前缀快照,初始化新会话自己的版本与空闲状态。
  5. 检查可继承的摘要,只保留没有覆盖到切点之后的有效摘要。
  6. 原子提交新会话、快照和请求去重记录,再返回新 ID。

源会话后来追加了 m7,不影响刚才固定的前缀。源会话在运行也不必一律禁止分叉,但切点必须已经持久化且完整;无法提供一致前缀的简化存储,可以先限制运行结束后再分叉。

数据量不大时,复制比共享祖先链更容易实现和排错。等长对话、多分支的存储成本确实显著,再考虑不可变消息共享、父指针或写时复制。升级后要一起解决祖先访问权限、删除与垃圾回收、过长引用链的读取问题。

对话分叉不会把文件和外部系统一起倒回去

这是我觉得这道作业最值得深挖的地方。

假设原会话在 m6 之后已经升级依赖,用户却从 m3 分叉。新会话的聊天记录停在升级之前,工作目录中的文件仍然可能是升级之后的版本。

复制聊天列表,对文件没有任何回滚效果。

因此需要区分两种产品能力:

分叉能力 复制什么 后续执行前要确认什么
对话分叉 消息、来源和有效的上下文记录 现在绑定的工作区是什么,当前文件是什么状态
带环境快照的任务分叉 对话前缀,以及能恢复的工作区快照 快照是否完整,恢复到哪个独立执行环境

一个 Git 提交或 worktree 可以帮助隔离受版本管理的文件,却不一定包含未提交文件、未跟踪文件、数据库或外部服务状态。要承诺“从当时重新执行”,就必须写清快照覆盖范围。

本文的基础作业只做对话分叉。分支获得独立的工作区绑定信息,执行前重新读取当前环境;需要环境重现时,再增加显式快照能力。旧分支中的一次工具授权,也不会自动变成新分支中的执行许可。

八、作业二:让长对话变短,但还能找回被省略的信息

第二道作业,是把较早的对话总结成摘要,之后发给模型的内容变成“摘要加最近消息”,减少上下文长度。

最容易实现的版本,是生成摘要后直接覆盖旧消息。它也最容易把恢复能力一起做没了。

回头问一句“刚才失败的是哪条测试”,摘要里如果没记住,系统就没有依据可查。更麻烦的是,一段错误摘要经过后续多轮引用,会越来越像已经确认的事实。

所以本文选择:原始历史保留,压缩只改变本次上下文的构建方式。历史是记录,摘要是有损的派生材料。

同一段历史可以分出独立会话,也可以生成只用于上下文的摘要;分叉不回滚外部状态,压缩不删除原始消息

什么时候压缩,先从预算算起

只按“超过二十条消息就压缩”,很难判断实际大小。一条测试日志可能比前二十轮聊天还长。按字符数估算也会有误差,不同模型和内容的分词方式不一样。

至少要把这些内容一起算进去:

1
2
3
4
5
6
固定指令与工具定义
+ 摘要与保留的历史
+ 本次需要补入的证据
+ 预留输出
+ 安全余量
≤ 所选模型的上下文窗口

工具定义、输出预算和协议开销都不能遗漏。优先使用模型适配器对应的计数方式;没有精确计数时,采用保守估算,并为误差留出空间。

比如一个假设模型有 32,000 token 窗口,预留输出 4,000,安全余量 2,000,那么本次输入最多计划使用 26,000。固定指令和工具定义占 6,000,历史与检索材料的空间就只剩 20,000。

这不是推荐所有模型都照抄的配置。真正需要固定的是预算关系,数字跟着模型、工具数量和任务变化。

触发压缩也应早于硬上限,因为生成摘要本身需要上下文和输出空间。历史已经超出摘要模型容量时,要按完整单元分块处理;单个超长工具结果可以转成保留原文的证据引用,不能只在主调用前临时赌一次。

压缩到哪里,要尊重工具调用的完整性

沿用前面的历史,若 m4 请求运行测试,m5 才给出日志,就不该只摘要到 m4,把 m5 单独留在尾部。

切分依据首先是完整单元,其次才是要保留多少最近内容。当前运行中尚未收齐结果的工具调用,始终留在未压缩部分。最新用户要求和正在推进的步骤,也应优先保留。

这会产生一个现实取舍:保留最近消息越多,连续性通常越容易维持,但能节省的输入越少。如果一个完整工具单元已经很大,就需要先处理工具输出的体积,不能靠任意截断凑出预算。

摘要里究竟应该留下什么

“把上面的对话总结一下”太宽泛了。编程助手至少需要保留目标、明确约束、已经作出的决定、未解决问题和证据位置。对不确定的信息,还要保留不确定性。

一个摘要记录可以采用这样的结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"id": "summary_01",
"session_id": "session_a",
"covered_through_seq": 5,
"source_digest": "sha256:...",
"schema_version": 1,
"goal": "定位并修复测试失败",
"constraints": [
{"text": "不要修改部署配置,也不要删除已有文件", "source_ids": ["m1"]}
],
"decisions": [],
"open_questions": [
{"text": "失败是否由依赖版本导致,尚未验证", "source_ids": ["m5"]}
],
"evidence_refs": [
{"message_id": "m5", "artifact_id": "test_log_01", "note": "原始失败日志"}
]
}

这里的序号是会话中固定的前缀位置,source_digest 用来校验摘要对应的原文。还可以记录摘要模型、生成时间、提示模板版本,方便追查是哪一版压缩策略引入了问题。

记录里没有“已经证明是依赖版本问题”,因为到 m5 只有失败日志,还没有完成验证。摘要把猜测变成事实,就会让后面的模型沿着错误前提继续。

对于“不要修改部署配置”这类硬约束,我会从带来源的明确用户要求中建立独立约束记录,原文保留、变更可追踪,不把它完全交给自由摘要。约束冲突时按新的明确指令与权限规则处理,不能凭摘要自行覆盖。

即便如此,摘要仍不能成为授权来源。工具权限、用户批准和执行状态要从服务端的真实记录读取。仓库文件中的恶意文字,也不能经过摘要后被提升成系统指令。上下文构建时需要保留材料的来源和信任边界。

谁负责生成,谁负责保存

可以把设计分成三个明确的职责:

  • CompactionPolicy 根据预算与完整单元选择压缩范围。
  • Summarizer 为选定的历史前缀生成结构化摘要。
  • ContextBuilder 组合指令、约束、有效摘要、最近消息和必要证据。

它们不一定各自成为独立服务。基础版本在应用层安排流程、通过模型和存储接口完成工作就够了。

发布摘要前,需要校验结构、来源引用和覆盖范围。如果摘要不合格,保留原历史,不覆盖上一版有效记录。如果在预算内可以继续使用原始上下文,就明确使用它;否则返回“当前上下文预算不足”,等待缩小任务或采用已定义的分块策略。

压缩失败不能被伪装成成功,也不能以发出必然超限的请求作为默认处理。

生成摘要的过程中,用户可能又追加了消息。因此压缩开始时要固定前缀及其摘要,完成时检查这个前缀是否仍然一致、当前生效摘要版本是否发生冲突。尾部仅仅追加了新消息,不代表旧前缀摘要失效,新消息继续留在尾部即可。

同时不能让一个较早启动、较晚返回的压缩任务,把已经覆盖更长前缀的新摘要覆盖掉。摘要的启用需要版本条件,不能按“谁最后写入谁生效”处理。

被压掉的细节,必须还有路可查

用户接着问:“刚才失败的是哪条测试,原始错误是什么?”

如果摘要只写了“测试失败”,模型需要根据 evidence_refs 读取原消息或原始日志。读取接口仍要做权限检查,并受当前分支的历史范围限制;不能借一个旧引用,越过分叉切点读取另一条分支后来的信息。

长日志可以存为不可变工件,保存内容摘要、大小和定位信息,按需读取相关片段。只留下一个临时文件路径却随时清理文件,就不算保留了找回能力。

还要规定找回失败时怎么办:引用已失效或原文不可用,就明确说明证据缺失,不能用摘要补写一份“原始日志”。原始历史保留多久,也应和产品的数据保留政策一致;过期清理后,检索能力自然会受限。

多轮压缩同样会积累信息损失。如果每次只总结上一版摘要,最早漏掉的东西越来越难回来。可以保留摘要的来源链,并在必要时从原始历史重建;不能指望不断“摘要的摘要”始终保持原意。

Anthropic 的上下文工程文章也讨论了压缩的取舍:过度压缩可能丢失后续才显现价值的信息,需要用复杂轨迹评估保留与舍弃。对具体系统来说,缩短输入和保住任务所需证据,要一起验证。

九、两道作业放在一起,会出现一个更有意思的错误

假设原会话已经有 80 条消息,当前摘要覆盖了 m1..m60。用户从 m40 分叉。

如果直接把原会话的摘要复制过去,新分支虽然原始历史只到 m40,摘要却已经带着 m41..m60 的结论。

比如原分支在 m55 才确认要升级依赖,新分支一开始就“记住”了这个决定。这相当于从未来偷带了信息,分叉的语义已经被破坏。

因此,继承摘要至少要满足两个条件:覆盖范围不超过新分支切点,来源前缀也确实一致。可以继承覆盖到 m30 的有效摘要,再保留 m31..m40;没有合适摘要时,就从分支自己的原始前缀重新构建,或在预算允许时直接使用原文。

这也解释了为什么摘要需要明确的覆盖位置与来源标识。只有一段自然语言,就很难回答“它到底属于哪段历史”。

还有一件事不会因为重新摘要就解决:工作区依旧可能已经被原分支修改。历史范围、模型上下文和外部环境必须分别核对。

十、把两道作业做成一个能验收的小项目

前面已经给出了参考设计。真要动手,我会把范围控制成:单用户、同一会话单个活跃运行、非流式模型调用、不可变历史、顺序工具执行。先把数据语义做对,再增加并发与性能优化。

目录不需要复杂,大致按职责分开即可:

1
2
3
4
5
6
7
8
9
10
agent/
domain/ 消息、调用结果、会话、摘要与分叉来源
application/ AgentService、接口、上下文构建与压缩流程
adapters/ 模型、存储、工具执行器、HTTP 或 CLI
main.py 创建具体实现并注入,作为组装根
tests/
fakes/ 可脚本化模型、内存存储、记录调用的工具
test_fork.py
test_compaction.py
test_run_recovery.py

作业一的交付与验收

实现 fork_session,支持包含指定消息的前缀复制,返回新会话;再允许在新会话中继续发送消息。把切点、ID、权限、环境范围和重复请求的语义写进接口说明。

先写下面这些行为用例,再实现逻辑:

输入或条件 应观察到的结果
从前例 m3 分叉 子会话只有 m1..m3,没有 m4..m6
子会话追加“先检查测试环境” 原会话没有这条消息;原会话追加内容也不进入子会话
请求在只有调用、没有结果的 m2 分叉 返回明确的切点错误,不创建半成品会话
同一个请求标识重复提交相同参数 返回同一个子会话 ID;参数不同则冲突
源会话在复制期间追加新消息 子会话仍对应固定的历史前缀
原会话摘要覆盖到切点之后 不继承这份摘要,不能泄漏后续结论
原分支已经修改工作区文件 子会话不宣称文件已回滚,执行前重新核对绑定的环境
无源会话读取权限 拒绝创建,不泄露历史和来源内容

这组用例的重点是可观察行为。仅仅断言返回对象里多了一个 parent_id,证明不了两个分支真的独立。

作业二的交付与验收

实现预算计算、稳定切点选择、结构化摘要、摘要生效条件,以及按引用找回原文。原始消息保持不变,让测试直接检查“发给模型的内容”与“存储中的内容”两份结果。

输入或条件 应观察到的结果
总预算仍充足的短对话 不触发摘要调用,使用原始消息
长历史触发压缩 生成覆盖明确前缀的摘要,拼接完整尾部,存储原历史不变
切点落在一次工具请求与结果之间 选择合法完整边界,不留下孤立结果或悬空调用
原文含“不要修改部署配置” 压缩后该约束仍有可验证来源,不被弱化为一般偏好
摘要遗漏原始报错,用户追问细节 通过证据引用读取原文;找不到则报告缺失
摘要生成超时或结构错误 不启用坏摘要,原历史和上一版有效摘要不受损
压缩时追加新消息 摘要只覆盖固定前缀,新增消息留在尾部且不重复
两个压缩任务乱序完成 旧任务不能覆盖更新的有效摘要
模型或工具定义改变了预算 重新计数,不沿用上一模型的可用窗口假设
摘要文字声称用户已批准一个写操作 执行器仍读取独立授权记录,不据此放行

用假的模型返回预设消息,用假的工具记录是否执行,可以稳定验证这些流程。真实模型主要用于评估摘要是否保留了后续任务真正需要的含义,两类检查各有用途。

再加一道联合作业:分叉一个已经被压缩的会话

准备一段带摘要的历史,让摘要覆盖到 m60,从 m40 分叉。分别验证:子会话不含未来信息;旧约束仍在;历史细节可以按权限找回;继续执行时不会重放复制来的工具调用。

然后故意在旧摘要里放入一个 m55 才出现的决定,看新分支会不会受它影响。这个反例比“正常分叉成功”更能检查两种机制有没有真的接上。

如何判断压缩值得做

压缩率高,只说明输入短了。它没有说明任务做得是否一样好。

可以固定一组长对话轨迹,在相同模型、工具与后续问题下,比较完整历史和压缩上下文的表现。问题至少覆盖明确约束、较早的事实、尚未证实的猜测、原始证据和任务下一步。能看到的指标包括:

  • 后续任务完成情况,以及完成的判定依据。
  • 约束违反数,占所有设有明确约束的测试任务的比例。
  • 证据问题答对数,占全部证据问题的比例,同时检查引用是否真的支持答案。
  • 主调用、摘要调用和补读证据的总费用与总延迟。

模型有随机性时,重复评估并报告波动;不把一条成功样本当成普遍结论。输入缓存、摘要生成费用和证据补读,也都可能改变成本判断。

如果压缩只减少了主调用 token,却让摘要和反复补读花得更多,就需要调整压缩频率和保留范围。若错误集中在旧约束,优先修复约束保存;若错误集中在日志细节,优先改善证据定位。不要把所有失败都归结为“摘要模型还不够强”。

十一、我会怎样选择第一版

只做一个短对话工具助手,单模型、简单存储和顺序执行就可能足够。三层边界可以先体现在模块和接口里,不必拆服务。没有长历史,就先不做自动压缩;分叉数量少,就直接复制前缀。

当外部协议开始频繁变化,适配器让变化集中;当任务开始跨多轮运行,稳定 ID、执行记录和恢复语义变得必要;当历史真的挤压上下文预算,再引入带来源和找回路径的压缩。

视频让我最想继续推导的,就是最后这两道作业。分叉逼着我们回答“历史与环境分别归谁”,压缩逼着我们回答“省略了什么、还能不能找回来”。这两件事说清楚,前面的架构才不只是几张类图。

动手时可以从前面的六条消息开始:先在 m3 创建一个独立分支,再为原会话生成一份覆盖到 m5 的摘要。检查这份摘要是否被错误带进分支,以及分支下一次执行会读取哪个工作区。能解释清这两个结果,比一开始支持多少模型更能检验设计是否成立。

参考资料