Agent 工程体系 · 第 23/98 篇。内容以 2026 年 9 月可验证的公开规范和稳定接口为基线;框架版本敏感能力会明确标注,不把实验行为写成通用保证。

Agent 上下文摘要:触发、保真、滚动更新、校验和恢复

Agent 的上下文摘要,不是把旧消息压缩成一段更短的文字,而是对运行状态进行有损编码:系统主动丢弃部分原始轨迹,只保留后续决策仍然需要的信息。

这一定义带来一个直接结论:

摘要可以替代部分阅读材料,但不能自动替代事实源、工具结果、执行状态和审计记录。

如果把 Agent 的输入仅理解为“最近几轮聊天消息”,摘要很容易做成一个看似节省 Token、实际破坏状态的黑盒。真正需要摘要的对象至少包括:

  • 用户消息与助手消息;
  • 工具调用及其参数;
  • 工具返回值、错误和重试结果;
  • 当前任务目标、约束和已确认事实;
  • 已经完成的副作用,例如订单创建、文件写入、邮件发送;
  • 尚未完成的计划、待确认动作和人工审批状态;
  • 长期记忆的读取结果及其来源;
  • Agent 的中间状态、版本号和恢复位置。

本文把摘要视为上下文管理系统的一部分,重点讨论四个问题:

  1. 什么时候触发摘要;
  2. 如何控制摘要保真度;
  3. 如何在新消息和工具轨迹到来后滚动更新;
  4. 如何使用校验和、版本和持久化状态完成校验与恢复。

一、先区分四种“上下文”

摘要系统经常失败,不是因为摘要模型能力不足,而是因为把不同性质的数据混在了一起。

1. 原始事件流

原始事件流是不可变的事实记录,例如:

event_id: 184
role: user
content: “把订单 10086 改成明天送达”

或者:

event_id: 185
type: tool_call
tool: update_delivery_date
arguments: {"order_id": "10086", "date": "2026-09-02"}

以及:

event_id: 186
type: tool_result
status: success
result: {"order_id": "10086", "delivery_date": "2026-09-02"}

原始事件流适合审计、重放和争议处理,不适合无限制地直接放进模型上下文。

2. 工作上下文

工作上下文是当前请求真正需要看到的输入,通常由以下内容拼接而成:

系统规则
+ 当前任务状态
+ 摘要
+ 最近若干轮原文
+ 最近工具结果
+ 检索到的长期记忆
+ 当前用户消息

它不是数据库中的“全部历史”,而是一次模型调用的视图

3. 摘要

摘要是从历史事件流计算出的压缩表示。它可以是自然语言,也可以是结构化对象:

{
  "goal": "将订单 10086 的配送日期改为 2026-09-02",
  "confirmed_facts": [
    {
      "fact": "用户拥有订单 10086 的修改权限",
      "source_event_ids": [181]
    }
  ],
  "completed_actions": [
    {
      "action": "更新配送日期",
      "status": "succeeded",
      "source_event_ids": [185, 186]
    }
  ],
  "pending_actions": [],
  "constraints": [
    "未获得用户授权时不能修改其他订单"
  ],
  "open_questions": [],
  "last_event_id": 186
}

4. 检查点

检查点是某个时刻的可恢复状态,至少包含:

摘要版本
事件游标
最近消息
待执行动作
工具调用幂等键
模型调用上下文
校验和

LangGraph 将这类短期、线程范围内的图状态快照称为 checkpointer;而跨线程保存的用户偏好、事实和共享知识属于 store。两者的作用范围不同,不能用长期 Store 替代当前执行线程的检查点。(docs.langchain.com)


二、摘要的目标不是“更短”,而是“在限定任务上等价”

设完整历史为 HH,摘要为 S(H)S(H),当前请求和系统规则为 QQ。模型基于上下文产生动作:

a=π(Q,H)a = \pi(Q, H)

摘要后变为:

a=π(Q,S(H))a' = \pi(Q, S(H))

理想条件不是要求 S(H)=HS(H)=H,因为这会失去压缩意义,而是要求:

π(Q,H)π(Q,S(H))\pi(Q, H) \approx \pi(Q, S(H))

也就是:在当前任务相关的决策上,摘要前后的行为保持一致。

更严格地,可以定义一个任务相关判定函数 DD

D(π(Q,H),π(Q,S(H)))=1D(\pi(Q,H), \pi(Q,S(H))) = 1

表示两次输出在安全性、事实、工具参数和任务进度上等价。

这个定义解释了为什么“摘要看起来通顺”远远不够。下面两个摘要都很自然:

用户想要修改订单配送日期,目前正在处理中。
用户已确认将订单 10086 改为 2026-09-02,更新操作已成功完成。

第一个摘要丢失了订单号、目标日期和完成状态。对闲聊任务可能足够,对下一次工具调用则不够。

保真度的四个层次

可以把摘要保真度拆成四个层次:

  1. 语义保真:保留用户真正想完成的目标;
  2. 事实保真:保留数值、名称、时间、权限、约束等可验证事实;
  3. 状态保真:准确区分未开始、执行中、成功、失败、取消和未知;
  4. 因果保真:保留哪些动作导致了哪些状态变化。

工程上最危险的是只检查第一层。例如摘要仍然说“用户要改配送日期”,但把“工具调用失败”压缩成“正在处理中”,后续 Agent 可能重复执行副作用。


三、什么时候触发摘要

摘要触发通常有三类条件:容量触发、结构触发和风险触发。

1. 容量触发

一次请求可用的上下文预算不是模型的最大上下文窗口。设:

  • WW:模型单次请求的上下文窗口;
  • II:系统提示、工具定义、当前消息和检索结果占用的 Token;
  • OO:预留输出 Token;
  • RR:推理或隐藏过程所需的预算;
  • MM:安全余量;
  • HH:历史原文占用的 Token。

可用历史预算为:

B=WIORMB = W - I - O - R - M

当:

H>BH > B

时,继续追加原文就有超限风险。

需要注意,上下文窗口通常同时受输入、输出以及某些模型的推理 Token 影响,不能只按照消息输入长度估算。OpenAI 文档明确将这些部分都纳入请求生命周期中的上下文限制,并建议使用 Tokenizer 或 tiktoken 进行估算。(developers.openai.com)

实际系统不应等到超限才摘要,而应采用提前触发:

H>αBH > \alpha B

其中 α\alpha 可以是一个低于 1 的阈值,例如 0.7 或 0.8。这个数不是规范要求,而是实现策略;具体值应由输出长度、工具调用密度和失败重试成本决定。

2. 结构触发

即使 Token 还没有超限,也可能需要摘要:

  • 一个工具调用链已经完成;
  • 一个子任务已经结束;
  • 用户切换了主题;
  • 当前任务从“规划”进入“执行”;
  • 某个重要事实发生了确认或更正;
  • 人工审批完成;
  • 对话出现多个分支,需要合并主线。

结构触发的原因是:上下文的风险不只来自长度,还来自状态混乱。一个 20 万 Token 的工具轨迹即使仍放得下,也可能让模型难以判断哪个结果是最终结果。

3. 风险触发

以下事件不应只依赖普通摘要:

  • 付款、转账、删除、发布、发信等不可逆副作用;
  • 权限、身份和合规结论;
  • 工具返回相互矛盾;
  • 关键外部状态可能已经变化;
  • 摘要校验失败;
  • 服务重启后从未知位置恢复;
  • 并发分支同时修改同一任务。

风险触发通常意味着“摘要 + 原始证据”组合,而不是“摘要替代证据”。


四、为什么不能简单地按消息条数裁剪

最简单的裁剪算法是:

history = history[-20:]

它有三个问题。

问题一:消息边界不等于任务边界

一轮工具调用往往包含:

assistant tool_call
tool result
assistant interpretation

如果只保留最后 20 条,可能留下工具结果,却裁掉调用参数;或者保留调用,却裁掉用户授权。

问题二:最近不等于重要

用户在第 2 轮确认了“只修改订单 10086”,第 50 轮产生了大量日志。按时间裁剪后,权限边界可能消失。

问题三:文本不能表达执行状态

“调用更新接口”与“更新接口返回成功”不是同一件事。裁剪或自由摘要如果没有明确状态枚举,就容易把计划写成事实。

因此,摘要前应先将事件按不可拆分单元分组:

单元 A:用户授权 + 任务目标
单元 B:查询订单 + 查询结果
单元 C:更新调用 + 更新结果
单元 D:用户追问 + 助手解释

裁剪时只删除完整单元,不能删除工具调用和工具结果之间的半条链。


五、摘要应采用“稳定摘要 + 最近原文”的双层结构

单一摘要会持续重写,错误也会被不断放大。更稳妥的上下文视图是:

[稳定摘要 S_k]
[摘要之后的增量事件 E_(k+1...n)]
[当前用户消息]

模型看到的不是一次性生成的全文总结,而是:

Cn=SkEk+1:nC_n = S_k \oplus E_{k+1:n}

其中:

  • SkS_k 是最近一次通过校验的稳定摘要;
  • Ek+1:nE_{k+1:n} 是摘要之后尚未折叠的原始事件;
  • \oplus 表示按协议拼接,而不是简单字符串连接。

当增量事件达到触发条件后,再生成:

Sk+1=Update(Sk,Ek+1:n)S_{k+1} = \operatorname{Update}(S_k, E_{k+1:n})

生成成功后,提交新的摘要版本,并把事件游标推进到 nn

这个设计有两个重要性质:

  1. 摘要失败时,仍可使用旧摘要和原始增量继续运行;
  2. 摘要错误时,可以从旧检查点和原始事件重新计算。

六、摘要内容必须结构化表达状态

自然语言适合给模型读,结构化字段适合校验和恢复。生产系统可以采用“结构化主数据 + 人类可读说明”:

{
  "schema_version": 3,
  "task": {
    "goal": "为订单 10086 查询可用配送日期",
    "status": "waiting_for_user_confirmation"
  },
  "facts": [
    {
      "key": "order_id",
      "value": "10086",
      "confidence": "confirmed",
      "source_event_ids": [12]
    }
  ],
  "decisions": [
    {
      "decision": "用户尚未确认是否接受 2026-09-02",
      "status": "active",
      "source_event_ids": [19]
    }
  ],
  "tool_state": {
    "query_delivery_options": {
      "status": "succeeded",
      "request_id": "req-abc",
      "source_event_ids": [15, 16]
    }
  },
  "pending": [
    {
      "action": "等待用户确认配送日期",
      "blocking": true
    }
  ],
  "omitted_event_range": {
    "from": 1,
    "to": 16
  }
}

状态字段必须使用有限集合

例如:

planned
running
succeeded
failed
cancelled
unknown

unknown 很重要。服务超时并不等于副作用没有发生:

请求支付接口超时

正确状态可能是:

payment_status = unknown

而不是:

payment_status = failed

后者可能导致 Agent 重复扣款。

事实必须带来源

摘要中的事实最好保留:

  • source_event_ids:来自哪些事件;
  • observed_at:何时观察到;
  • authority:用户、工具、数据库还是模型推断;
  • valid_until:是否有有效期;
  • confidence:确认、推断或未知。

例如:

{
  "key": "delivery_date",
  "value": "2026-09-02",
  "authority": "order_service",
  "confidence": "confirmed",
  "observed_at": "2026-09-01T10:20:00+08:00"
}

这能区分“用户曾经说过”与“外部系统当前确认”。


七、摘要生成不是一次总结,而是一次状态折叠

一次滚动摘要可以拆成五步。

第一步:确定折叠边界

假设当前状态为:

稳定摘要版本:S7
已折叠事件:1–80
新增事件:81–96

系统先确定本次只处理 81–96,而不是把全部历史重新交给摘要模型。

第二步:锁定不可丢失字段

在调用摘要模型前,系统根据任务类型生成保真约束:

必须保留:
- 订单号
- 用户授权范围
- 工具调用状态
- 成功写操作的 request_id
- 未完成动作
- 失败原因
- 事件范围

这些约束不能只写在摘要提示词里,还应由代码在输出后检查。

第三步:生成候选摘要

候选摘要可以使用结构化输出。伪代码如下:

candidate = summarize(
    previous_summary=S7,
    new_events=events[81:97],
    required_fields=[
        "goal",
        "facts",
        "completed_actions",
        "pending_actions",
        "tool_state"
    ]
)

摘要模型的职责是压缩和归纳,不是重新执行工具,也不是凭空修复矛盾。

第四步:合并与规范化

合并时需要处理同一事实的更新:

旧摘要:delivery_date = 2026-09-02
新事件:用户改口要求 2026-09-03

如果新事件来自用户,通常可以将旧值标记为过期:

{
  "key": "delivery_date",
  "value": "2026-09-03",
  "previous_value": "2026-09-02",
  "status": "active",
  "source_event_ids": [101]
}

但如果新事件只是模型猜测,就不能直接覆盖工具确认值。

第五步:校验后提交

只有通过校验的摘要才能成为新稳定摘要:

生成候选 S8
    ↓
Schema 校验
    ↓
关键字段完整性检查
    ↓
事件游标连续性检查
    ↓
工具状态一致性检查
    ↓
写入检查点
    ↓
原子提交 S8

如果任一步失败,应保留 S7 和 81–96 的原始事件,不应直接覆盖旧摘要。


八、保真度校验:摘要必须能够被反驳

摘要校验不应只检查 JSON 格式。至少需要四类检查。

1. 结构校验

检查字段类型、枚举值和必填字段:

assert summary["schema_version"] == 3
assert summary["task"]["status"] in {
    "planned", "running", "waiting_for_user_confirmation",
    "succeeded", "failed", "cancelled", "unknown"
}

2. 覆盖校验

对于高风险事件,摘要必须引用来源:

for action in summary["completed_actions"]:
    assert action["source_event_ids"]

3. 反事实校验

向另一个模型或规则检查器提出问题:

根据摘要,订单 10086 的更新操作是否已经成功?
如果不能确定,请回答 unknown。

然后与摘要中的状态比较。

4. 工具一致性校验

如果原始工具日志显示:

update_order: timeout

摘要不能出现:

update_order: succeeded

如果工具系统有独立数据库状态,摘要中的副作用状态还应优先由数据库或工具查询结果确认,而不是由摘要模型决定。


九、校验和到底校验什么

校验和不是用来证明摘要“正确”,而是用来检测摘要或事件是否发生了意外变化。

设规范化后的事件序列为:

E=Canonicalize(e1,,en)E = \operatorname{Canonicalize}(e_1,\ldots,e_n)

摘要状态为 SS,版本为 vv,则可以计算:

h=SHA256(vcursorES)h = \operatorname{SHA256}(v \Vert cursor \Vert E \Vert S)

其中:

  • vv:摘要协议版本;
  • cursor:已经折叠到哪个事件;
  • EE:被折叠的事件范围;
  • SS:规范化后的摘要;
  • hh:最终校验和。

必须先规范化再计算,否则 JSON 字段顺序、空格和时间格式变化都会产生不同结果。

示例实现:

import hashlib
import json

def canonical_json(value: object) -> bytes:
    return json.dumps(
        value,
        ensure_ascii=False,
        sort_keys=True,
        separators=(",", ":"),
    ).encode("utf-8")

def checksum(summary: dict, cursor: int, event_ids: list[int]) -> str:
    payload = {
        "schema_version": summary["schema_version"],
        "cursor": cursor,
        "event_ids": event_ids,
        "summary": summary,
    }
    return hashlib.sha256(canonical_json(payload)).hexdigest()

输入:

summary = {
    "schema_version": 3,
    "task": {"goal": "查询订单", "status": "running"},
}
print(checksum(summary, 96, list(range(1, 97))))

预期输出是一个 64 位十六进制字符串。具体字符串取决于输入内容;它只能说明“当前内容与保存时内容是否一致”,不能说明“内容是否真实”。

校验和的三个边界

校验和不能发现模型语义错误

模型把“失败”写成“成功”,只要该错误摘要被重新计算校验和,校验和仍然有效。

校验和不能替代来源

必须同时保存事件范围、事件 ID 或事件哈希,否则只校验摘要文本,无法知道摘要覆盖了哪些历史。

普通哈希不能提供防篡改证明

如果攻击者可以同时修改摘要和校验和,SHA-256 只能检测普通数据损坏,不能证明数据未被有权限的攻击者修改。需要防篡改时,应使用带密钥的 HMAC、数据库审计链或外部不可变日志。


十、用哈希链检测事件缺失和重排

单个摘要校验和只能验证一个快照。对于连续事件流,可以使用哈希链:

hi=SHA256(hi1Canonicalize(ei))h_i = \operatorname{SHA256}(h_{i-1} \Vert \operatorname{Canonicalize}(e_i))

其中 h0h_0 是固定初始值。

如果事件 82 被删除,或者事件 85 与 86 对调,后续哈希都会变化:

h80 → event81 → h81 → event82 → h82 → ... → h96

检查点保存:

{
  "cursor": 96,
  "event_chain_hash": "…",
  "summary_checksum": "…"
}

恢复时重新计算事件链。如果链断裂,系统不能安全地把当前摘要当作完整状态,而应进入降级流程:

  1. 查找最近一个链完整的检查点;
  2. 从该检查点重新读取原始事件;
  3. 重新生成摘要;
  4. 对未确认的副作用执行状态查询;
  5. 必要时要求人工介入。

十一、恢复的关键不是“重新摘要”,而是找到安全边界

典型故障发生在摘要提交中间:

1. 读取 S7 和事件 81–96
2. 生成候选 S8
3. 写入摘要成功
4. 写入 cursor 失败

此时系统可能出现:

summary = S8
cursor = 80

如果重试时再次把 81–96 合并进 S8,可能重复计算或重复表达状态。

因此,摘要和游标必须原子提交,或者使用可检测的两阶段状态:

PREPARED:
  candidate_summary = S8
  candidate_cursor = 96
  candidate_checksum = h8

COMMITTED:
  summary = S8
  cursor = 96
  checksum = h8

恢复逻辑:

def recover(snapshot, events):
    if snapshot.phase == "COMMITTED":
        verify(snapshot, events)
        return snapshot

    if snapshot.phase == "PREPARED":
        if verify_candidate(snapshot, events):
            commit(snapshot)
            return snapshot
        else:
            discard_candidate(snapshot)
            return rebuild_from_previous_checkpoint(snapshot, events)

    raise RuntimeError("unknown snapshot phase")

这里的 verify_candidate 不仅检查候选摘要格式,还要检查:

  • 候选游标是否连续;
  • 事件链哈希是否匹配;
  • 摘要引用的事件是否存在;
  • 工具副作用是否已确认;
  • schema 版本是否兼容。

十二、摘要恢复与副作用恢复必须分开

这是 Agent 系统中最容易被忽视的边界。

假设 Agent 在事件 120 调用了退款接口,随后进程崩溃:

tool_call: refund(order_id=10086, idempotency_key=k1)
network: timeout
process: crashed

恢复时不能因为没有工具结果就重新发起一个新请求。正确流程是:

1. 使用 idempotency_key=k1 查询退款接口状态;
2. 如果已成功,记录 succeeded;
3. 如果明确失败,记录 failed;
4. 如果仍未知,暂停并要求人工或业务系统确认;
5. 只有确认服务端未接受时,才允许重试。

摘要只负责表达:

refund_status = unknown

它不负责证明退款是否发生。证明必须来自工具服务、业务数据库或带幂等语义的外部系统。


十三、并发摘要必须使用版本控制

当同一会话存在并发请求:

请求 A:读取 S7,处理事件 81–90
请求 B:读取 S7,处理事件 81–95

如果 A 和 B 都成功写入,后写入者可能覆盖前者,导致事件 91–95 的状态消失。

可以使用乐观并发控制:

UPDATE agent_context
SET
    summary_json = :new_summary,
    cursor = :new_cursor,
    checksum = :new_checksum,
    version = version + 1
WHERE
    conversation_id = :conversation_id
    AND version = :expected_version
    AND cursor = :expected_cursor;

如果更新行数为 0,说明版本已经变化,当前摘要不能直接提交。系统应:

  1. 重新读取最新检查点;
  2. 将未折叠事件重新计算;
  3. 再次生成或合并;
  4. 重试有限次数;
  5. 超过次数后转入串行队列或人工诊断。

当两个分支代表用户明确选择的不同方案时,不应强行合并。应把它们记录为不同分支,直到用户确认主线。


十四、OpenAI 会话状态与本地摘要的关系

OpenAI 的 Responses API 支持几种会话状态管理方式:

  • 手动把历史消息和工具项传回请求;
  • 使用 conversation 标识持久化会话;
  • 使用 previous_response_id 将响应串成线程。

官方文档说明,Conversations API 可以持久化消息、工具调用、工具输出等项目,并支持跨会话、设备或任务继续使用;previous_response_id 则用于把响应串成线程。(developers.openai.com)

这解决的是“服务端如何保存或关联会话项”,不等于解决了“业务上哪些内容应该进入模型上下文”。即使使用 previous_response_id,历史输入 Token 仍会计入输入计费;而上下文窗口仍然受单次请求的 Token 限制。(developers.openai.com)

因此,常见分层是:

业务事件库
    ↓
本地摘要与检查点
    ↓
选择当前模型上下文
    ↓
Responses API conversation / previous_response_id

服务端会话状态可以减少应用层重复拼接,但不能替代:

  • 业务事实的权威来源;
  • 工具副作用的确认;
  • 摘要 schema;
  • 事件游标;
  • 本地校验和;
  • 故障恢复策略。

OpenAI 文档还将 compaction 作为上下文管理能力,并提供服务端 compaction 与显式 compact endpoint 的方向;具体参数和可用范围应以目标 API 版本的官方文档为准,不能把服务端自动压缩当成业务状态一致性保证。(developers.openai.com)


十五、LangGraph 中的检查点、摘要和长期记忆

LangGraph 的持久化层将短期线程状态交给 checkpointer,将跨线程的长期数据交给 store。官方快速示例通过 thread_id 访问同一线程的图状态。(docs.langchain.com)

一个简化的结构如下:

from typing import TypedDict
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import StateGraph, START, END

class State(TypedDict, total=False):
    messages: list[dict]
    context_summary: dict
    cursor: int

def summarize_if_needed(state: State) -> State:
    messages = state.get("messages", [])
    summary = state.get("context_summary", {
        "goal": None,
        "facts": [],
        "pending_actions": [],
    })

    # 示例:真实系统应按 Token、工具边界和风险事件触发。
    if len(messages) <= 20:
        return state

    old = messages[:-8]
    recent = messages[-8:]

    candidate = {
        "goal": summary["goal"],
        "facts": summary["facts"],
        "pending_actions": summary["pending_actions"],
        "folded_message_count": len(old),
    }

    return {
        **state,
        "messages": recent,
        "context_summary": candidate,
        "cursor": state.get("cursor", 0) + len(old),
    }

builder = StateGraph(State)
builder.add_node("summarize", summarize_if_needed)
builder.add_edge(START, "summarize")
builder.add_edge("summarize", END)

graph = builder.compile(checkpointer=InMemorySaver())

result = graph.invoke(
    {
        "messages": [
            {"role": "user", "content": "我的订单号是 10086"},
            {"role": "assistant", "content": "已记录订单号"},
        ],
        "cursor": 0,
    },
    {"configurable": {"thread_id": "order-10086"}},
)

print(result)

这个示例展示的是生命周期,不是完整摘要器:

  1. 通过 thread_id 绑定线程;
  2. 读取当前图状态;
  3. 根据条件生成候选摘要;
  4. 保留最近原文;
  5. 将状态交给 checkpointer。

InMemorySaver 适合演示和测试,因为进程重启会丢失内存中的检查点;生产环境应使用持久化 checkpointer。官方文档列出了 PostgreSQL 和 SQLite 等实现,并提醒长期对话的检查点会无限增长,需要设置保留策略或定期清理。(docs.langchain.com)

如果子图产生了需要跨线程共享的用户事实,不应只依赖子图自己的 checkpoint namespace。官方文档指出,子图更新的状态可能不会立即被父图看到;跨图边界的数据应考虑使用共享 Store 或显式写入父图检查点。(docs.langchain.com)


十六、一个完整的滚动摘要流程

下面的时序可以概括生产系统中的主路径:

sequenceDiagram
    participant U as 用户
    participant A as Agent Orchestrator
    participant C as Context Builder
    participant S as Summary Service
    participant E as Event Store
    participant P as Checkpoint Store
    participant T as Tool Service

    U->>A: 新消息
    A->>E: 写入 user_event
    A->>P: 读取最新 checkpoint
    A->>C: 构造摘要 + 增量事件 + 最近原文
    C->>A: 当前模型上下文
    A->>T: 工具调用
    T-->>A: 工具结果
    A->>E: 写入 tool_call/tool_result
    A->>A: 判断是否触发摘要

    alt 需要摘要
        A->>S: 旧摘要 + 未折叠事件
        S-->>A: 候选摘要
        A->>A: Schema、来源、状态、哈希校验
        A->>P: CAS/原子提交新摘要和游标
    else 暂不摘要
        A->>P: 保存增量状态
    end

    A-->>U: 响应

关键路径不是“调用摘要模型”,而是:

事件先落盘
→ 读取版本化检查点
→ 生成候选摘要
→ 校验候选摘要
→ 原子提交摘要、游标和校验和

如果摘要模型调用成功但提交失败,候选摘要只是临时对象;如果工具调用成功但事件落盘失败,则必须优先处理事件可见性问题,不能继续把状态当成已持久化。


十七、常见错误及其失败表现

把摘要当作唯一事实源

失败表现: 用户纠正一个早期事实后,摘要仍然沿用旧值。

原因: 摘要被覆盖写入,却没有保留事件来源和事实版本。

诊断:

检查摘要字段的 source_event_ids
检查事件游标是否覆盖纠正事件
检查事实是否有 active/expired 状态

只摘要自然语言,不摘要工具状态

失败表现: Agent 重复执行已经成功的写操作。

原因: 摘要保留了任务目标,却删除了幂等键、请求 ID 和工具结果。

修复: 将工具调用视为状态转移,而不是普通聊天文本。

使用超时作为失败结论

失败表现: 支付、退款、创建资源等动作发生重复副作用。

原因: 网络层失败被错误映射为业务层失败。

修复: 引入 unknown,恢复时查询外部系统状态。

摘要和检查点分别提交

失败表现: 摘要显示已折叠 100 条消息,但游标仍停在 80;重启后事件重复或丢失。

原因: 摘要、游标和校验和不是同一个原子版本。

修复: 使用事务、CAS 或 prepared/committed 两阶段状态。

用内存检查点验证生产恢复

失败表现: 单机测试正常,服务重启、扩容或故障转移后会话消失。

原因: 内存保存器只提供进程生命周期内的连续性。LangGraph 官方文档明确指出,MemorySaverInMemorySaver 重启后不会保留检查点。(docs.langchain.com)


十八、如何评估摘要是否真的保真

不要只测试摘要文本是否“读起来像原文”。应构造任务级评估:

事实恢复测试

给出摘要后,要求 Agent 回答:

订单号是什么?
用户授权修改哪些字段?
最后一次工具调用是否成功?
当前还缺什么确认?

与原始事件流中的标准答案比较。

工具参数测试

让 Agent 继续执行:

根据当前状态,下一次工具调用的参数是什么?

检查:

  • ID 是否正确;
  • 日期、金额和单位是否正确;
  • 是否错误重复执行;
  • 是否在缺少授权时继续执行。

故障恢复测试

在以下位置强制杀进程:

摘要生成前
摘要生成后、提交前
摘要提交后、响应返回前
工具请求发送后、结果落盘前

恢复后检查:

  • 是否丢失事件;
  • 是否重复副作用;
  • 是否能够定位最后一个安全检查点;
  • 是否把未知状态误判为失败或成功。

反事实测试

将一个关键事件删除、替换或重排,再检查哈希链、游标和摘要一致性检查能否发现异常。


结语:摘要是可验证的状态压缩

Agent 上下文摘要的核心不是“让提示词变短”,而是建立一套可恢复的状态压缩协议:

原始事件流:保存事实
摘要:压缩任务相关状态
最近原文:保留局部细节
检查点:保存恢复位置
校验和:检测意外变化
工具查询:确认外部副作用
长期记忆:保存跨线程事实

一个合格的摘要系统至少应满足:

可继续决策+可追溯来源+可检测损坏+可从旧状态恢复\text{可继续决策} + \text{可追溯来源} + \text{可检测损坏} + \text{可从旧状态恢复}

如果摘要只追求压缩率,它会成为新的隐性错误源;如果摘要带有事件游标、状态枚举、来源引用、版本控制和恢复路径,它才真正成为 Agent 工程体系中的上下文基础设施。


系列导航与关联阅读

官方资料

本文依据 Agent、模型、协议与框架官方资料重新梳理;正文、示例与生产清单由 WR BLOG 编写。