历史保真与摘要、多模态占位、checkpointer 持久化
在多 Agent 图里保证长对话历史不失真,加一个摘要节点压缩早期内容,并用 checkpointer 把执行状态持久化下来。
今日目标
- 能实现一个摘要节点,在历史过长时生成摘要并保留关键信息
- 能接入 LangGraph 的 checkpointer,持久化图的执行状态
- 能从 checkpoint 恢复一次未完成的执行并继续跑完
昨天那张图会拆任务、会并行、会打回重做,跑一次要调九次模型。它有两个没解决的问题:评审回路转两轮,历史就长得没法看;而这一切都在内存里,进程一挂,九次模型调用的钱白花。今天专门收拾这两件事。读完回到页面顶部把三条目标勾掉。
小白版讲解
施工日志可以写总结,基坑深度不能省
工地上每天写施工日志。开工三个月,日志厚成一摞,于是项目经理在每个阶段末尾写一份总结,把那一摞压成两页纸。但有几样东西只能原样抄进去,不能改写:基坑挖了多深、混凝土是哪个批次、哪天甲方书面同意了变更、谁签的字。下一道工序要按这些数字施工,监理要按这些签字追责——总结可以写得漂亮,这些数字写错一个就是事故。
这就是历史保真:压缩是必须的,但被压掉的那部分里有一小撮信息压完必须还在,而且要能追溯到是谁在第几步说的。
先把三层记忆的边界划清楚——这三层课里各占过一天,很容易混成一团:
- D6 是短期上下文的压缩。 对象是「这一次要发给模型的那份请求」:超了就把早期几轮换成一段摘要再发出去,压完的结果不落地,下一轮重新算。
- D12 是长期记忆。 对象是「跨会话的用户事实与偏好」,不进消息数组,存在向量库里按需检索注入。
- 今天是图执行状态。 对象是「这张图这一刻的全部状态」——消息、工作区、评审轮次、附件、降级标记,整份。它既要能被压短,又要能存下来、取回来、从中间某一刻重新出发。
差别不是量级,是丢了怎么办。D6 压掉的东西丢了,最多这一轮答得差一点,原文还在会话记录里;今天压掉的是图状态本身,它会被检查点原样持久化,之后每一次从这条线恢复读到的都是压过的那一份。这里的丢失是永久的。
多 Agent 还多一重麻烦,也是「保真」这个词的由来。D17 的 Critic 判一份产出合不合格,靠的是「这句结论是执行者写的、还是用户自己提的要求」。一段把发言人抹平的流水摘要——「用户咨询了订单与退款,助手给出了方案」——读起来很通顺,Critic 拿它却做不了任何判断:它分不清哪句是待验收的产出、哪句是验收标准。单 Agent 里摘要丢的是细节,多 Agent 里摘要丢的是判据。
所以今天的摘要有一条比 D6 更硬的要求:每一条被压掉的消息,在摘要里都要留下「第几条 + 谁说的」这两个坐标。实现只有一行,后面就会看到。真正难的是下一个问题:这份状态压好之后,你打算把它存到哪去?
阶段总结什么时候写、谁来写
先把触发口径定死,两条线任意一条到就压:消息超过 20 条,或估算超过 8000 token。token 沿用 D6 的保守估法——一个字符算一个 token,故意高估,因为低估会让阈值永远触发不了。
为什么两条线?只看条数,一条五千字的长消息能把窗口顶爆而条数还是 1;只看 token,一串很短的工具返回会把条数堆到几百条,光序列化就开始拖慢每次检查点写入。两条线各防一种形状的历史。
保留多少原文?本课取最近 6 条不动,一个字都不能改写。这里有个 D6 讲过的坑要一句话回指:切口必须对齐到一轮开头——切在工具调用和对应的工具结果之间,下一次请求就出现「有调用没结果」的悬空消息,多数厂商直接返回 400。所以切口只能往前挪,保留的原文可能多于 6 条,绝不会少于 6 条。
摘要谁来生成?一次模型调用。这里和 D6 有个不同选择:D6 把摘要塞回消息数组当成一条 user 消息,今天不这么干,摘要写进 summary 这个独立字段。理由是图状态要被持久化——原文与派生数据分开存,才有可能未来换一种策略重新生成一次;混在一起,你再也分不清哪条是真发生过的、哪条是事后编的。
还有一个不踩不知道的坎:累加通道压不短。D15 给 messages 配的合并规则是拼接,摘要节点返回一个只有 6 条的短数组,结果被接在原来那 23 条后面,历史反而变成 29 条。要让它能变短,reducer 必须认识一个「整份替换」的写入指令——这不是取巧,LangChain 自己的消息通道也认一种特殊消息来表达删除。一条通道存进去的类型和写进来的类型可以不一样,这是累加通道做减法的唯一出路。
// 通道的写入指令:数组是追加,{ replace } 是整份替换。累加通道要想变短,全靠后者
const messagesReducer = (old, next) => (Array.isArray(next) ? old.concat(next) : next.replace)
const MAX_MESSAGES = 20
const MAX_TOKENS = 8000
const KEEP_RECENT = 6
export function needsSummary(messages) {
if (messages.length > MAX_MESSAGES) return `条数 ${messages.length} 超过 ${MAX_MESSAGES}`
const tokens = messages.reduce((n, m) => n + m.content.length, 0) // 1 字符 ≈ 1 token
return tokens > MAX_TOKENS ? `估算 ${tokens} token 超过 ${MAX_TOKENS}` : null
}
// 返回的增量里只有 messages 和 summary。attachments 根本没出现——摘要不该看见它
export async function summarizeNode(state) {
if (!needsSummary(state.messages)) return {}
let cut = state.messages.length - KEEP_RECENT
while (cut > 0 && state.messages[cut].role === 'tool') cut -= 1 // 切口对齐到一轮开头
if (cut <= 0) return {}
// 每一行都带「第几条 + 谁说的」:这两样丢了,Critic 就判不出结论是谁给的
const transcript = state.messages
.slice(0, cut)
.map((m, i) => `#${i + 1} ${m.role}: ${m.content}`)
.join('\n')
const digest = await callModel('summarize', transcript)
return { messages: { replace: state.messages.slice(cut) }, summary: digest }
}from dataclasses import dataclass
@dataclass
class Replace:
"""整份替换的写入指令。累加通道要想变短,reducer 必须认识它"""
messages: list
MAX_MESSAGES, MAX_TOKENS, KEEP_RECENT = 20, 8000, 6
def needs_summary(messages: list) -> str | None:
if len(messages) > MAX_MESSAGES:
return f"条数 {len(messages)} 超过 {MAX_MESSAGES}"
tokens = sum(len(m.content) for m in messages) # 1 字符 ≈ 1 token
return f"估算 {tokens} token 超过 {MAX_TOKENS}" if tokens > MAX_TOKENS else None
async def summarize_node(state) -> dict:
"""返回这一步改了哪几个字段。attachments 不在里面——摘要根本不该看见它"""
if needs_summary(state.messages) is None:
return {}
cut = len(state.messages) - KEEP_RECENT
while cut > 0 and state.messages[cut].role == "tool": # 切口对齐到一轮开头
cut -= 1
if cut <= 0:
return {}
# 每一行都带「第几条 + 谁说的」:这两样丢了,Critic 就判不出结论是谁给的
transcript = "\n".join(
f"#{i + 1} {m.role}: {m.content}" for i, m in enumerate(state.messages[:cut])
)
return {
"messages": Replace(state.messages[cut:]),
"summary": await call_model("summarize", transcript),
}// 依赖:JDK 17+,无第三方库。密封接口正好表达「写入只有追加和替换两种」,
// 编译器会逼你在 reducer 里两种都处理到
sealed interface MessagesUpdate permits Append, Replace {}
record Append(List<Message> messages) implements MessagesUpdate {}
record Replace(List<Message> messages) implements MessagesUpdate {}
static final int MAX_MESSAGES = 20, MAX_TOKENS = 8000, KEEP_RECENT = 6;
static Optional<String> needsSummary(List<Message> messages) {
if (messages.size() > MAX_MESSAGES)
return Optional.of("条数 " + messages.size() + " 超过 " + MAX_MESSAGES);
int tokens = messages.stream().mapToInt(m -> m.content().length()).sum(); // 1 字符 ≈ 1 token
return tokens > MAX_TOKENS
? Optional.of("估算 " + tokens + " token 超过 " + MAX_TOKENS)
: Optional.empty();
}
/** 返回这一步改了哪几个字段。attachments 不在里面——摘要根本不该看见它 */
static Map<String, Object> summarizeNode(AgentState state) {
var messages = state.messages();
if (needsSummary(messages).isEmpty()) return Map.of();
int cut = messages.size() - KEEP_RECENT;
while (cut > 0 && messages.get(cut).role().equals("tool")) cut -= 1; // 切口对齐到一轮开头
if (cut <= 0) return Map.of();
var head = messages.subList(0, cut);
// 每一行都带「第几条 + 谁说的」:这两样丢了,Critic 就判不出结论是谁给的
var transcript = IntStream.range(0, head.size())
.mapToObj(i -> "#" + (i + 1) + " " + head.get(i).role() + ": " + head.get(i).content())
.collect(Collectors.joining("\n"));
return Map.of(
"messages", new Replace(List.copyOf(messages.subList(cut, messages.size()))),
"summary", callModel("summarize", transcript));
}/// 通道的写入指令:追加,或者整份替换。累加通道要想变短,reducer 必须认识后者
enum MessagesUpdate {
case append([Message])
case replace([Message])
}
/// 增量用 Optional 表达「这个字段没改」。这里压根没有 attachments 这一项——摘要碰不到它
struct Delta {
var messages: MessagesUpdate?
var summary: String?
}
let maxMessages = 20, maxTokens = 8000, keepRecent = 6
func needsSummary(_ messages: [Message]) -> String? {
if messages.count > maxMessages { return "条数 \(messages.count) 超过 \(maxMessages)" }
let tokens = messages.reduce(0) { $0 + $1.content.count } // 1 字符 ≈ 1 token
return tokens > maxTokens ? "估算 \(tokens) token 超过 \(maxTokens)" : nil
}
func summarizeNode(_ state: AgentState) async throws -> Delta {
guard needsSummary(state.messages) != nil else { return Delta() }
var cut = state.messages.count - keepRecent
while cut > 0 && state.messages[cut].role == "tool" { cut -= 1 } // 切口对齐到一轮开头
guard cut > 0 else { return Delta() }
// enumerated() 一步就拿到「第几条 + 这条是谁说的」,正好是历史保真要保住的那两样
let transcript = state.messages[..<cut].enumerated()
.map { "#\($0.offset + 1) \($0.element.role): \($0.element.content)" }
.joined(separator: "\n")
return Delta(messages: .replace(Array(state.messages[cut...])),
summary: try await callModel("summarize", transcript))
}电梯还没到货,井道要先留出来
电梯设备往往比土建晚几个月到货,但没有施工队会说「等设备到了再在墙上凿个洞」——图纸上一开始就把井道留出来,哪怕空着大半年。因为主体结构一旦浇筑完,改是天价。
attachments 这个字段就是那个井道。今天不接图像模型、不做任何图片理解,只做两件事:把这条通道留在状态里,并且保证它能穿过摘要和检查点两道工序而毫发无损。
为什么现在留、不是以后加?因为图状态的形状一旦被检查点持久化,改字段就不是改代码,是数据迁移:库里躺着几十万条按老形状写的检查点,你加一个必填字段,它们读出来全是缺的。而现在留一个空数组,成本是零。
字段形状是 D15 定的:id、kind、ref、可选的 caption。两个决定值得说透。
第一,存引用不存内容。 ref 是一个指向对象存储的地址,不是 base64。理由用数字说最清楚:本课实验里一次请求会写 6 个检查点,只存引用时这条线在 Postgres 里一共占 6.8 KB,整轮 19 毫秒;把一张 384 KB 的图片编成 base64 塞进 ref,同一条线变成 4.2 MB,整轮 125 毫秒。状态里多一个字节,一次执行就要多写六遍。 这是检查点这个机制最反直觉的成本结构:你不是存了一份,是每一步都存了一份。
第二,那个描述字段刻意不叫 summary,叫 caption。 因为 AgentState.summary 已经是「早期消息的摘要」。同一个状态类型里两个 summary 各是一个意思,是那种评审看不出来、线上才炸的错误。
摘要节点必须完整跳过 attachments,理由到这里就清楚了:附件存的是引用不是内容,把引用摘要掉等于把井道填了。 摘要会把「有一张 blob://tickets/2026/att-1.png 的破损照片」压成「用户上传了一张照片」——照片还在对象存储里,但再没有人知道它的地址。原文丢了还能从别处补,引用丢了那个对象就是彻底孤儿。
每完成一个阶段签一次字:检查点存哪、存成什么
每完成一个阶段,甲方、监理、施工方一起签字确认。签完这一阶段就算封存;下一阶段出问题,从上一个签字点往下返工,而不是把基坑重新刨一遍。
checkpointer 就是这套签字制度:图每跑完一个超步(superstep),把当前的全部状态存一份。存下来的东西叫检查点,三个部分缺一不可:
- 状态快照:这一刻每条通道的值。
- 父指针:上一个检查点是谁。它把一条线串成一条链,也让分叉成为可能。
- 还没跑的那几步:中断那一刻已经排上队、还没执行的节点,连参数一起。这一条最容易被忽略,下一节整节都在讲它。
存到哪?内存版讲原理、跑本地实验,生产必须落库。今天自己写一个落到 Postgres 的实现,表就一张,五个字段:
create table if not exists graph_checkpoints (
thread_id text not null, -- 哪条会话线
checkpoint_id text not null, -- 这条线上的哪一刻,单调递增
parent_id text, -- 上一个检查点,串成链
state jsonb not null, -- 状态快照 + 元信息 + 待跑的那几步
created_at timestamptz not null default now(),
primary key (thread_id, checkpoint_id)
);存成什么格式?jsonb。选它是因为可查、可索引、出事故时能直接用 psql 看清里面是什么。三笔代价:
第一笔,状态越大写得越慢,上一节那组数字就是这笔账。所以状态里该放的是指针和结论而不是原始素材:附件放引用、检索结果放文档 id 与摘录、大段工具原文用完就该被结论替换掉。
第二笔,jsonb 有上限,而且比你想的更早开始疼。 单字段硬上限是 1 GB,听着遥远;但一行超过大约 2 KB 就会被挪进 TOAST 表外存,每次读写多一次 IO。所以真正的工程线是别让单个检查点变成几百 KB——评审回路转三轮、消息不压缩,很容易就到。
第三笔,最容易被忽略:恢复时的版本兼容。 检查点存的是「当时那个版本的代码眼里的状态形状」。改一次字段名、加一次必填字段,库里躺着的旧检查点就和新代码对不上了——而它们不会消失,用户随时可能点进去继续。最坏的是它多数时候不抛错。实验里造了一份 v1 形状的老检查点(附件的引用字段当时叫 url,子任务还没有 toolCalls),新代码读它的输出是这样的:
不迁移:附件引用读出来是 ["undefined"],预算算出来是 NaN(没有一处报错)
迁移后:附件引用 ["blob://tickets/2025/att-old.png"],预算 1字符串 "undefined" 会一路拼进给用户看的文案;NaN 比 5 恒为假,于是 D17 定的工具预算上限在这条线上彻底失效。三条纪律:在读的那一侧迁移(批量刷库是浪费,绝大多数检查点再也不会被打开);迁移只补默认值和改名、不做业务判断(它要是会失败,你就没法恢复了);旧字段读完就丢。
// 一个检查点 = 状态快照 + 待跑的步 + 父指针。父指针把一条线串成链,分叉就是链上多长出一支
export class CheckpointStore {
#rows = new Map() // key 是 `${threadId}/${id}`;生产里这一层就是 graph_checkpoints 表
put(threadId, cp) {
this.#rows.set(`${threadId}/${cp.id}`, cp)
}
get(threadId, id) {
return this.#rows.get(`${threadId}/${id}`)
}
// checkpoint_id 单调递增,所以「最新」就是这条线上最大的那个 key
latest(threadId) {
const line = [...this.#rows.keys()].filter((k) => k.startsWith(`${threadId}/`)).sort()
return line.length > 0 ? this.#rows.get(line[line.length - 1]) : undefined
}
// 沿父指针往回走。replay 要重放哪几步、分叉从哪一刻岔开,全靠这条链
ancestors(threadId, id) {
const chain = []
for (let cp = this.get(threadId, id); cp; cp = cp.parentId && this.get(threadId, cp.parentId)) {
chain.unshift(cp)
if (!cp.parentId) break
}
return chain
}
}from dataclasses import dataclass
from typing import Any
@dataclass
class PendingTask:
"""中断那一刻还没跑的一步,连参数一起。D17 的 Send 存进检查点之后就是这个形状"""
node: str
args: dict[str, Any]
@dataclass
class Checkpoint:
"""状态快照 + 待跑的步 + 父指针。父指针把一条线串成链,分叉就是链上多长出一支"""
id: str
parent_id: str | None
values: Any
next: list[PendingTask]
class CheckpointStore:
def __init__(self) -> None:
# 生产里这一层就是 graph_checkpoints 表,语义完全一样:主键是 (thread_id, checkpoint_id)
self.rows: dict[str, dict[str, Checkpoint]] = {}
def put(self, thread_id: str, cp: Checkpoint) -> None:
self.rows.setdefault(thread_id, {})[cp.id] = cp
def get(self, thread_id: str, cp_id: str) -> Checkpoint | None:
return self.rows.get(thread_id, {}).get(cp_id)
def latest(self, thread_id: str) -> Checkpoint | None:
# checkpoint_id 单调递增,所以「最新」就是这条线上最大的那个 key
line = self.rows.get(thread_id, {})
return line[max(line)] if line else None
def ancestors(self, thread_id: str, cp_id: str) -> list[Checkpoint]:
"""沿父指针往回走。replay 要重放哪几步、分叉从哪一刻岔开,全靠这条链"""
chain: list[Checkpoint] = []
cursor = self.get(thread_id, cp_id)
while cursor is not None:
chain.insert(0, cursor)
cursor = self.get(thread_id, cursor.parent_id) if cursor.parent_id else None
return chain// 依赖:JDK 17+,无第三方库。TreeMap 按 key 有序,正好对上「checkpoint_id 单调递增」这条约定,
// 拿最新的一个就是 lastEntry,不用再排一次序
record PendingTask(String node, Map<String, Object> args) {}
record Checkpoint(String id, String parentId, AgentState values, List<PendingTask> next) {}
static final class CheckpointStore {
// 生产里这一层就是 graph_checkpoints 表,语义完全一样:主键是 (thread_id, checkpoint_id)
private final Map<String, NavigableMap<String, Checkpoint>> rows = new HashMap<>();
void put(String threadId, Checkpoint cp) {
rows.computeIfAbsent(threadId, k -> new TreeMap<>()).put(cp.id(), cp);
}
Optional<Checkpoint> get(String threadId, String id) {
return Optional.ofNullable(
rows.getOrDefault(threadId, Collections.emptyNavigableMap()).get(id));
}
Optional<Checkpoint> latest(String threadId) {
var line = rows.getOrDefault(threadId, Collections.emptyNavigableMap());
return line.isEmpty() ? Optional.empty() : Optional.of(line.lastEntry().getValue());
}
/** 沿父指针往回走。replay 要重放哪几步、分叉从哪一刻岔开,全靠这条链 */
List<Checkpoint> ancestors(String threadId, String id) {
var chain = new ArrayDeque<Checkpoint>();
for (var cp = get(threadId, id); cp.isPresent(); cp = get(threadId, cp.get().parentId())) {
chain.addFirst(cp.get());
if (cp.get().parentId() == null) break;
}
return List.copyOf(chain);
}
}/// 中断那一刻还没跑的一步,连参数一起。D17 的 Send 存进检查点之后就是这个形状
struct PendingTask: Codable {
let node: String
let args: [String: String]
}
/// 状态快照 + 待跑的步 + 父指针。整个类型都是 Codable,序列化进 jsonb 不用手写任何编码逻辑
struct Checkpoint: Codable {
let id: String
let parentId: String?
let values: AgentState
let next: [PendingTask]
}
final class CheckpointStore {
// 生产里这一层就是 graph_checkpoints 表,语义完全一样:主键是 (thread_id, checkpoint_id)
private var rows: [String: [String: Checkpoint]] = [:]
func put(_ threadId: String, _ cp: Checkpoint) {
rows[threadId, default: [:]][cp.id] = cp
}
func get(_ threadId: String, _ id: String) -> Checkpoint? {
rows[threadId]?[id]
}
// checkpoint_id 单调递增,所以「最新」就是这条线上最大的那个 key
func latest(_ threadId: String) -> Checkpoint? {
rows[threadId]?.max { $0.key < $1.key }?.value
}
/// 沿父指针往回走。replay 要重放哪几步、分叉从哪一刻岔开,全靠这条链
func ancestors(_ threadId: String, _ id: String) -> [Checkpoint] {
var chain: [Checkpoint] = []
var cursor = get(threadId, id)
while let cp = cursor {
chain.insert(cp, at: 0)
cursor = cp.parentId.flatMap { get(threadId, $0) }
}
return chain
}
}Java 和 Swift 没有 LangGraph,这点 D15 说过:上面两份不是在调某个库,而是把同一套机制用这门语言的惯用法手写出来。写完你会发现核心就是一张按「线加时刻」索引的表加一条父指针链——框架在这层真正值钱的,是替你决定「什么时候该签这个字」。
出问题从上一个签字点重来
有了签字点,返工就有了起点。检查点的两种用法长得像,用途完全不同:
- 恢复(resume):这条线上次没跑完,接着往下跑。坐标只需要
thread_id。 - 分叉(fork):回到过去某一刻换个走法。坐标要
thread_id加checkpoint_id。
恢复有一条必须记住的口径:不要给输入。状态已经在检查点里,你带着原来那句话再调一次,框架不报错——它会把这次输入当成一次新的状态更新叠在中断点上,历史变成两份。实验里正确恢复之后消息从 1 条变成 2 条(只多了那条回复),错误恢复之后变成 3 条。
分叉的对称错误是忘了带 checkpoint_id:只给 thread_id 拿到的是这条线最新的状态,于是「从第 2 步重来」变成「在最后一步后面接着写」。同样不报错,只有对比子任务列表才看得出来。合起来就是本节的口径:恢复是「接着」,分叉是「回到」,写错的代价都是静默的。
// 恢复:不给输入。状态已经在检查点里,next 里连「还没跑的那几步和它们的参数」都存着,
// 所以已经跑过的节点一个都不会重跑。再喂一遍历史不是恢复,是在中断点上又追加了一次输入
export const resume = (graph, threadId) =>
graph.invoke(null, { configurable: { thread_id: threadId } })
// 分叉:同一条线,但把坐标指回过去某一刻。
// 少了 checkpoint_id,它就退化成最新那一个——「从第 2 步重来」变成「在第 6 步后面接着写」
export const forkFrom = (graph, threadId, checkpointId, input) =>
graph.invoke(input, { configurable: { thread_id: threadId, checkpoint_id: checkpointId } })# 恢复:第一个参数传 None。状态已经在检查点里,next 里连「还没跑的那几步和它们的参数」
# 都存着,所以已经跑过的节点一个都不会重跑
async def resume(graph, thread_id: str):
return await graph.ainvoke(None, config={"configurable": {"thread_id": thread_id}})
# 分叉:同一条线,但把坐标指回过去某一刻。少了 checkpoint_id 就落在最新那一个上
async def fork_from(graph, thread_id: str, checkpoint_id: str, payload: dict):
config = {"configurable": {"thread_id": thread_id, "checkpoint_id": checkpoint_id}}
return await graph.ainvoke(payload, config=config)// 依赖:JDK 17+。Java 没有 LangGraph,这里用上一组的 CheckpointStore 手写同样的语义:
// 读回快照 → 按 next 里存着的那几步接着派活
static AgentState resume(CheckpointStore store, String threadId) {
var cp = store.latest(threadId)
.orElseThrow(() -> new IllegalStateException("这条线没有检查点"));
// next 里存着中断那一刻还没跑的步,连参数一起,所以 planner 不用重跑
return runFrom(cp.values(), cp.next());
}
/**
* 分叉:同一条线,但把坐标指回过去某一刻。
* 少了 checkpointId 这一句,它就退化成 latest——「从第 2 步重来」变成「在第 6 步后面接着写」。
*/
static AgentState forkFrom(
CheckpointStore store, String threadId, String checkpointId, AgentState input) {
var cp = store.get(threadId, checkpointId)
.orElseThrow(() -> new IllegalStateException("检查点不存在:" + checkpointId));
return runFrom(merge(cp.values(), input), cp.next());
}/// 恢复:不带任何输入。状态已经在检查点里,next 里连「还没跑的那几步和它们的参数」都存着,
/// 所以已经跑过的节点一个都不会重跑。再喂一遍历史是在中断点上又追加了一次输入
func resume(_ store: CheckpointStore, _ threadId: String) async throws -> AgentState {
guard let cp = store.latest(threadId) else { throw ResumeError.noCheckpoint(threadId) }
return try await runFrom(cp.values, cp.next)
}
/// 分叉:同一条线,但把坐标指回过去某一刻。
/// 少了 checkpointId 这个参数,它就退化成 latest——「从第 2 步重来」变成「在第 6 步后面接着写」
func forkFrom(
_ store: CheckpointStore, _ threadId: String, _ checkpointId: String, _ input: AgentState
) async throws -> AgentState {
guard let cp = store.get(threadId, checkpointId) else {
throw ResumeError.noCheckpoint(checkpointId)
}
return try await runFrom(merge(cp.values, input), cp.next)
}现在回到昨天挂着的那个问题:D17 用 Send 动态扇出,中断时那几个还没派出去的子任务,到底存没存下来? 今天实测给答案:存下来了,而且是完整的参数。中断在 Executor 之前,检查点的通道列表里除了七个业务字段,还多出一个内部通道 __pregel_tasks,里面躺着三条这样的记录:
[
{ "node": "executor", "args": { "task": { "id": "t-1-order", "goal": "查订单状态并说明当前阶段(订单 SO20260901)", "toolCalls": 0, "status": "pending" } } },
{ "node": "executor", "args": { "task": { "id": "t-2-shipping", "goal": "查物流轨迹并给出预计送达时间(订单 SO20260901)", "toolCalls": 0, "status": "pending" } } }
]三个结论。第一,恢复不用重跑 Planner——实测里恢复阶段的模型调用次数正好等于待执行的子任务数,因为待办本身就在检查点里。第二,Send 的参数按普通 JSON 存,里面只能放能序列化的东西;塞一个类实例、一个函数、一个数据库连接,恢复出来就是空壳。第三,它在一个内部通道里,不在你的业务字段里。 所以自研 checkpointer 只写了「存状态快照」而漏掉「存待跑的那几步」,恢复会丢掉所有待执行的扇出——图看起来跑完了,其实一件活都没派出去,且没有任何报错。
源码导读
动手实验
今天有基础设施依赖,lab 根目录带了 docker-compose.yml,Postgres 映射到宿主 5518。不设 DATABASE_URL 就走内存版,跑的是同一份图、同一份自检,两条路结果必须一致。注意 MOCK=1 只管模型调用离线,检查点走内存还是 Postgres 由 DATABASE_URL 决定。src/shared/state.ts 原样来自 D15。先跑一次,那四条失败项就是待办清单。
- 先原样跑
MOCK=1 SELFTEST=1 pnpm start,看清第 1 项「23 条压成 23 条」和第 2 项「历史从 1 条变成 3 条」——这两个现象就是今天要修的。 - 练习 1,实现两条触发线与摘要切分,第 1 项从压不短变成「6 条原文 + 1 段带条号与发言人的摘要」。
- 练习 2,把恢复改成不带输入,第 2 项的历史从多出一整份变成只多一条回复,恢复阶段的模型调用次数降到子任务数。
- 练习 3,给分叉补上
checkpoint_id,第 4 项的分叉线不再带着主线那件活。 - 练习 4,实现老检查点的字段迁移,第 5 项从
"undefined"与NaN变成正常值;顺手起一次 compose,用 psql 看看graph_checkpoints里到底存了什么。
面试题
今天 4 道题在下方题库区,侧重记忆分层与检查点/重放,第一道「记忆怎么分层」几乎必被问到,第三道「replay 要注意什么」区分度最高。展开后先看「分析过程」再看要点,照着推导练比背要点管用。标注「国内高频 / 海外高频」方便按目标市场取舍。
检查清单与明日预告
- 能实现一个摘要节点,在历史过长时生成摘要并保留关键信息
- 能接入 LangGraph 的 checkpointer,持久化图的执行状态
- 能从 checkpoint 恢复一次未完成的执行并继续跑完
- 能说清短期上下文、长期记忆、图执行状态三层各管什么、丢了有什么后果
- 能说出检查点的三个真实代价,并解释附件为什么存引用而不是内容
- 能说清恢复与分叉在坐标上差什么,以及两者写错时为什么都不报错
- 实验的 5 条验收标准全部通过(五项自检全部通过)
- 4 道面试题不看要点也能答出至少 3 道
明天(D19)把这套多 Agent 服务接进 W2 那个 mini-koda,这是两个里程碑项目第一次连起来。为什么排在今天之后?因为一个跨服务被调用的 Agent,第一件被追问的事就是「你崩了之后我这单还在不在」,今天才刚让它答得上来。明天的正题是另一个问题:两个服务之间凭什么信任对方?答案不是一把共享密钥,而是让调用方带着用户自己的护照过来。
面试题库
记忆应该怎么分层?短期上下文、摘要、长期记忆分别放什么、丢了会怎么样?How should agent memory be layered? What belongs in short-term context, in summaries, and in long-term memory — and what happens when each is lost?
国内高频海外高频基础#memory#context-management#multi-agent分析过程 · 先想清楚再作答
- 这题的区分度不在能不能列出三层,而在能不能说出**每一层丢了会怎样**。只报名词的答案,面试官听不出你有没有真的运维过。
- 先给一条可复用的拆法:按「谁在读它、活多久、丢了能不能补」三个问题去分,任何一个记忆方案都能被这三问切开。
- 短期上下文是这一次请求要发给模型的那个消息数组,随请求结束作废,全量进 token 账单;它丢了只影响这一轮的连贯性,原文还在你自己的会话记录里,可以重放。
- 摘要是短期上下文的派生数据,用来在窗口顶到之前把早期内容压短;它丢了可以重新生成——**前提是原文另存了一份**。所以摘要绝不能覆盖原文,这是「压缩不可逆」那条纪律的实际落点。
- 长期记忆是跨会话的用户事实与偏好,不进消息数组,存在外部检索层里按需捞几条注入;它丢了的表现是「这个用户被系统忘光了」,不影响单次可用,但产品价值直接掉一层。
- 多 Agent 还要补第四层,也是最容易被忽略的一层:**图的执行状态**。它包含消息、共享工作区、评审轮次、降级标记,是唯一一份会被检查点持久化并在恢复时重放的数据。它丢了的后果最重——一次已经花掉九次模型调用的执行必须从头再来,而且用户界面还停在转圈。
- 可以预期的追问:摘要该放在消息数组里还是单独一个字段?答单独字段,理由是原文与派生数据要分开存,才可能换一种策略重新生成;混在一起之后你分不清哪条是真发生过的、哪条是事后编的。
How to reason about it · think before answering
- The discriminator here is not listing three layers, it is saying what breaks when each one is lost. An answer that only names the layers tells the interviewer you have never operated one.
- Offer a reusable split first: sort any memory scheme by who reads it, how long it lives, and whether it can be rebuilt after loss. Those three questions cut through every design.
- Short-term context is the message array sent to the model this turn. It dies with the request and is billed in full every turn. Losing it only costs coherence for that turn, because the raw transcript still lives in your own store and can be replayed.
- A summary is derived from short-term context, produced to shrink early turns before the window fills. It can be regenerated after loss — but only if the raw transcript was stored separately. That is the practical reason a summary must never overwrite the original.
- Long-term memory holds cross-session user facts and preferences. It never enters the message array; it lives in a retrieval layer and a few hits get injected on demand. Losing it means the system forgot the user — single requests still work, but the product gets noticeably worse.
- Multi-agent adds a fourth layer people usually miss: graph execution state — messages, shared workspace, review rounds, degraded flags. It is the only copy that gets checkpointed and replayed on resume, and losing it is the most expensive failure: a run that already burned nine model calls starts over while the user watches a spinner.
- Expect the follow-up: should the summary live inside the message array or in its own field? Say its own field — keeping raw and derived data apart is what lets you regenerate with a different strategy later; merged together you can no longer tell what actually happened from what was written after the fact.
答题要点
- 按「谁在读、活多久、丢了能不能补」三问分层,比背名词有用
- 短期上下文:本轮请求的消息数组,随请求作废,全量计费,丢了可从原始记录重放
- 摘要:短期上下文的派生数据,可重新生成,前提是原文另存——所以摘要不能覆盖原文
- 长期记忆:跨会话的用户事实,存在检索层按需注入,丢了是「系统忘了这个人」
- 多 Agent 多一层图执行状态:消息 + 工作区 + 评审轮次 + 降级标记,会被检查点持久化并在恢复时重放,丢了最贵
- 摘要放独立字段而不是塞回消息数组,原文与派生数据分开存才可能换策略重生成
Key points
- Layer by who reads it, how long it lives, and whether it can be rebuilt — that beats reciting names
- Short-term context: this turn's message array, discarded after the request, billed in full, replayable from your own transcript
- Summary: derived from short-term context and regenerable, but only if the raw transcript is stored separately — so it must never overwrite the original
- Long-term memory: cross-session user facts in a retrieval layer, injected on demand; losing it means the system forgot the user
- Multi-agent adds graph execution state — messages, workspace, review rounds, degraded flags — checkpointed and replayed on resume, and the most expensive to lose
- Keep the summary in its own field rather than back in the message array, so raw and derived data stay separable
长对话做摘要时,怎么保证关键信息不丢?在多 Agent 场景下这件事有什么特别的?When summarizing a long conversation, how do you keep the critical information from being lost — and what is different about this in a multi-agent system?
国内高频海外高频进阶#context-compression#multi-agent#reliability分析过程 · 先想清楚再作答
- 题眼在后半句。只答「保留用户约束、保留最近几轮」是单 Agent 的标准答案,能过但不出彩;面试官问「多 Agent 有什么特别的」,是在看你有没有真的在协作图里踩过这个坑。
- 先把单 Agent 那半答扎实:触发用阈值不用定时器,本课口径是消息超过 20 条或估算超过 8000 token(一个字符算一个 token,故意高估,低估会让阈值永远触发不了);保留最近 6 条原文不动;切口必须对齐到一轮的开头,切在工具调用与工具结果之间会让下一次请求出现悬空消息,多数厂商直接返回 400。
- 然后给出多 Agent 那半的关键差别:单 Agent 里摘要丢的是**细节**,多 Agent 里摘要丢的是**判据**。评审者判一份产出合不合格,靠的是分清「这句是待验收的产出、那句是验收要求」;一段把发言人抹平的流水摘要读起来通顺,但评审拿它做不了任何判断。
- 所以多 Agent 的摘要有一条额外硬要求:每条被压掉的消息,在摘要里都要留下「第几条 + 谁说的」这两个坐标。实现只有一行——把转录写成带序号和角色前缀的形式再交给模型。
- 再补一条边界,这条最能显出你写过:**摘要只对自然语言历史动手,不碰任何结构化字段**。把共享工作区压成一句话,「按 id 找到某条子任务、比对验收要求」就整个失效了,结构化数据压成自然语言就再也回不去。附件字段更是碰不得——它存的是引用不是内容,摘要掉等于把那个对象变成孤儿。
- 可以预期的追问:摘要用哪个模型、失败了怎么办?答可以用更便宜的小模型(它只做归纳不做推理),失败时的正确行为是**跳过这一轮压缩继续跑**并告警,而不是让整次执行失败——阈值定在七八成就是为了留出这次抢救余量。
How to reason about it · think before answering
- The hinge is the second half. Answering only keep user constraints and the last few turns is the standard single-agent answer — passable, not memorable. Asking what is different in multi-agent is asking whether you have actually hit this in a collaboration graph.
- Get the single-agent half solid first: trigger on thresholds, never a timer. This course uses more than 20 messages or an estimated 8000 tokens, counting one character as one token — deliberately high, because underestimating means the threshold never fires. Keep the last 6 messages verbatim. Align the cut to a turn boundary: cutting between a tool call and its result produces a dangling message and most vendors return 400.
- Then name the real difference: in a single agent a summary loses detail; in a multi-agent graph a summary loses the criteria. A critic decides whether output passes by telling apart what is being reviewed from what the requirement was. A smooth narrative summary that flattens speakers reads fine and is useless to the critic.
- So multi-agent summarization has one extra hard requirement: every compressed message must leave behind two coordinates — its index and its speaker. The implementation is one line: build the transcript with numbered, role-prefixed entries before handing it to the model.
- Add the boundary that shows you have shipped this: summarize natural-language history only, never structured fields. Compressing the shared workspace into a sentence kills every lookup by task id and every comparison against an acceptance requirement, and structured data does not come back. Attachments are even more off-limits — they hold a reference, not content, so summarizing one orphans the underlying object.
- Expect the follow-up: which model writes the summary, and what if it fails? A cheaper small model is fine since the job is condensation, not reasoning. On failure the correct behaviour is to skip this round of compression, keep running, and alert — not to fail the whole execution. Setting the threshold at seventy or eighty percent exists precisely to leave that rescue room.
答题要点
- 触发用阈值不用定时器:超过 20 条或估算超过 8000 token,token 按一字符一 token 保守高估
- 保留最近 6 条原文不动,切口必须对齐到一轮开头,否则会出现有调用没结果的悬空消息、请求直接 400
- 多 Agent 的差别:摘要丢的不是细节而是判据,评审者靠「谁在第几步说的」区分产出与验收要求
- 所以每条被压掉的消息都要在摘要里留下条号与发言人,实现就是把转录写成带序号和角色的形式
- 只压自然语言历史,不碰共享工作区这类结构化字段,更不能碰存引用的附件字段
- 摘要可用更便宜的小模型;摘要调用失败时跳过这一轮压缩并告警,不要让整次执行失败
Key points
- Trigger on thresholds, not timers: more than 20 messages or an estimated 8000 tokens, counting one character as one token to stay conservative
- Keep the last 6 messages verbatim and align the cut to a turn boundary, or you ship a dangling tool call and the request 400s
- The multi-agent difference: a summary loses criteria, not just detail — the critic needs to know who said what and at which step
- So every compressed message keeps its index and speaker in the summary; the implementation is a numbered, role-prefixed transcript
- Summarize natural-language history only — never the shared workspace or other structured fields, and never the attachment references
- A cheaper small model is fine for summarizing; if the call fails, skip compression for this round and alert rather than failing the run
从 checkpoint 恢复执行(replay)需要注意什么?说几个真实会踩的坑。What do you need to watch out for when replaying execution from a checkpoint? Give failure modes you would actually hit.
国内高频海外高频深入#checkpointing#replay#reliability分析过程 · 先想清楚再作答
- 这题最容易答成「读出来接着跑就行」。区分度在于你能不能说出**这些坑几乎全是静默的**——不抛异常、日志干净、结果看起来也对,只有对比数据时才发现不对。能说出这一点,答案就已经赢了一半。
- 先给一条推导链:检查点里存的是「当时那个版本的代码眼里的状态形状」,恢复就是把它塞回今天这个版本的代码里。所以所有坑都来自**两端不一致**:数据的形状、执行的入口、和那些不该被重放的东西。
- 坑一,恢复时又把输入喂了一遍。恢复的入口是不带输入地调用,状态已经在检查点里;带着原来那句话再调一次,框架会把它当成一次新的状态更新叠在中断点上,历史变成两份。它不报错。
- 坑二,分叉忘了带检查点 id。只给会话 id 拿到的是这条线最新的状态,于是「从第 2 步重来」变成了「在最后一步后面接着写」。同样不报错,只有对比子任务列表才看得出来。
- 坑三,版本兼容。改一个字段名、加一个必填字段,库里的老检查点就和新代码对不上;而缺字段读出来是 undefined,拼进文案就是字符串「undefined」,参与算术就是 NaN——比如工具预算的上限判断,一旦变成 NaN 比较,恒为假,预算上限在恢复出来的那条线上彻底失效。正确做法是在读的那一侧迁移,迁移函数只补默认值和改名、不做业务判断,绝不能失败。
- 坑四,不该被重放的东西进了状态。一次性的人工干预(比如人工改派)如果写进图状态,就会被检查点持久化并在每次恢复时重放一遍。判断口径:这条信息说的是「这一次执行怎么跑」还是「这个会话是什么」,前者进运行时配置,后者才进状态。
- 可以预期的追问:待执行的并行子任务存不存?答存——检查点里除了状态快照还有一份「还没跑的那几步,连参数一起」,所以恢复不用重跑规划节点;但它存在框架的内部通道里,自研存储层只实现「存状态」而漏掉这一半,恢复出来的图会看起来跑完了、其实一件活都没派出去。
How to reason about it · think before answering
- The easy failure is answering just load it and keep going. The discriminator is recognising that almost every replay bug is silent — no exception, clean logs, plausible output, and you only notice when you diff the data. Saying that up front wins half the question.
- Give a chain first: a checkpoint stores the state shape as the code of that moment understood it, and replay pushes it back into today's code. So every failure comes from a mismatch across those two ends — the shape of the data, the entry point of execution, and things that should never have been replayed at all.
- Trap one: feeding the input again on resume. Resume takes no input; the state is already in the checkpoint. Passing the original message once more makes the framework treat it as a fresh update stacked on the interrupt point, and the history quietly doubles. Nothing throws.
- Trap two: forking without a checkpoint id. With only the thread id you get that thread's latest state, so start over from step 2 silently becomes append after the last step. Again nothing throws; you only see it by diffing the task list.
- Trap three: version drift. Rename a field or add a required one and every old checkpoint stops matching the new code. A missing field reads as undefined, which renders as the literal string undefined in user-facing text and as NaN in arithmetic — a tool-budget ceiling compared against NaN is always false, so the budget silently stops existing on resumed threads. Migrate on read, and keep the migration to defaults and renames only: it must never fail.
- Trap four: replayable data that should not be replayed. A one-off human override written into graph state gets checkpointed and re-applied on every resume. The test: does this describe how this run executes, or what this conversation is? The former belongs in runtime config, only the latter in state.
- Expect the follow-up: are pending parallel tasks preserved? Yes — a checkpoint holds not just the state snapshot but the steps not yet run, arguments included, so the planner does not re-run. But they live in a framework-internal channel, so a hand-rolled store that persists state and forgets that half will resume into a graph that looks finished while no work was ever dispatched.
答题要点
- 先点破共性:replay 的坑几乎全是静默的,不报错、日志干净、结果看着也对
- 恢复不要带输入,带了就是在中断点上又追加一次,历史变成两份
- 分叉必须带检查点 id,只给会话 id 会落在最新状态上,「从第 2 步重来」变成「接着往后写」
- 版本兼容:缺字段读出来是 undefined 或 NaN,会让预算上限之类的比较恒为假;在读的那一侧迁移,迁移只补默认值和改名且不能失败
- 一次性的人工干预不要进图状态,否则会被持久化并在每次恢复时重放;「这次怎么跑」进配置,「这个会话是什么」才进状态
- 待执行的并行子任务连参数一起存在检查点里,所以恢复不重跑规划;自研存储层漏掉这一半,恢复出来的图会一件活都不派
Key points
- Lead with the pattern: replay bugs are almost all silent — no exception, clean logs, plausible output
- Resume takes no input; passing one appends another update at the interrupt point and doubles the history
- Forking requires the checkpoint id — thread id alone lands on the latest state, turning start over from step 2 into append after the end
- Version drift: missing fields read as undefined or NaN, so comparisons like a tool-budget ceiling become permanently false. Migrate on read, restricted to defaults and renames, and never let it fail
- Keep one-off human overrides out of graph state or they get persisted and re-applied on every resume — how this run executes belongs in config, what this conversation is belongs in state
- Pending parallel tasks are stored with their arguments, so the planner does not re-run; a hand-rolled store that skips that half resumes into a graph that dispatches nothing
checkpointer 在多 Agent 系统里解决了什么问题?它的代价是什么?What problem does a checkpointer solve in a multi-agent system, and what does it cost?
国内高频海外高频进阶#checkpointing#cost#operations分析过程 · 先想清楚再作答
- 这题的下半句才是考点。只答「能恢复、能容错」是功能介绍,任何文档都写着;面试官想听的是你有没有算过这笔账,以及知不知道它会在哪里疼。
- 先把价值说具体,用钱和时间说:一次带评审回路的多 Agent 执行要调九次模型,跑到第七次进程被换版本重启,没有检查点就是九次全废、用户界面还停在转圈。有检查点则从上一个签字点接着跑,已经跑完的节点一次都不重跑——它买的是「失败的粒度从一整次执行降到一个节点」。
- 顺带说清它解锁的另外三件事,这三样单靠重试做不到:**人工审批闸口**(在某个节点前停下等人点确认,状态就停在那儿)、**时间旅行调试**(回到出问题那一步之前看状态长什么样)、**分叉对比**(从同一个检查点跑两种走法,比较结果,这也是评估的基础设施)。
- 然后是代价,三笔要说全。第一笔,**状态越大写得越慢**,而且是每一步都写一份——一次请求写六个检查点,状态里多一个字节就要多写六遍。所以附件存引用不存内容,检索结果存文档 id 不存全文。
- 第二笔,**存储本身有上限**。用 jsonb 存的话,硬上限很远,但单行超过大约两 KB 就会被挪到外存、每次读写多一次 IO,所以真正的工程线是「别让单个检查点变成几百 KB」,而不是那个理论上限。
- 第三笔也是最容易被忽略的:**版本兼容**。检查点是长期存活的数据,你每改一次状态形状就欠下一笔迁移债,而缺字段读出来通常不报错,只是静默给出 undefined 或 NaN。这一条决定了状态字段要尽早占好位子——图状态的形状一旦被持久化,改字段就不是改代码,是数据迁移。
- 可以预期的追问:那检查点要不要清理?答要,按会话线设保留期与归档策略,否则这张表会随日活线性膨胀;另外要留意它是敏感数据——图状态里有完整对话,删除用户数据时这张表必须一起处理。
How to reason about it · think before answering
- The second half is the real question. Answering it enables recovery and fault tolerance is a feature blurb any doc carries. The interviewer wants to know whether you have done the arithmetic and where it hurts.
- Make the value concrete in money and time: one multi-agent run with a review loop costs nine model calls. If the process is restarted for a deploy at call seven, without checkpoints all nine are wasted and the user is still watching a spinner. With them the run continues from the last signed-off point and no completed node re-runs. What you bought is a smaller unit of failure — a node instead of a whole run.
- Mention the three things it unlocks that retries alone cannot: human approval gates (pause before a node and the state simply waits), time-travel debugging (go back to just before the bad step and inspect state), and forking for comparison (run two variants from one checkpoint) — which is also the infrastructure evaluation is built on.
- Then the costs, all three. First, bigger state means slower writes, and it is written at every step: one request produces six checkpoints, so a byte added to state is six bytes written. Hence attachments hold references, not content, and retrieval results hold document ids, not full text.
- Second, the store has limits. With jsonb the hard cap is far away, but a row past roughly two kilobytes gets pushed to out-of-line storage and costs an extra IO on every read and write. The real engineering line is do not let a single checkpoint reach hundreds of kilobytes, not the theoretical cap.
- Third, the one people forget: version compatibility. Checkpoints are long-lived data, so every change to the state shape incurs migration debt, and missing fields usually do not throw — they silently yield undefined or NaN. This is why state fields should be reserved early: once a shape is persisted, changing a field is a data migration, not a code edit.
- Expect the follow-up: do checkpoints need cleanup? Yes — retention and archival per thread, or the table grows linearly with active users. Also treat it as sensitive data: graph state contains full conversations, so it must be included whenever you delete a user's data.
答题要点
- 核心价值:把失败的粒度从「一整次执行」降到「一个节点」,九次模型调用的执行不会因为一次重启全废
- 还解锁三件重试做不到的事:人工审批闸口、时间旅行调试、从同一个检查点分叉对比(也是评估的基础设施)
- 代价一,状态越大写得越慢,而且每一步都写一份——所以附件存引用、检索结果存文档 id
- 代价二,存储有上限:jsonb 单行超过约两 KB 就外存、多一次 IO,工程线是别让单个检查点到几百 KB
- 代价三,版本兼容:状态形状改一次就欠一笔迁移债,缺字段静默给出 undefined 或 NaN;所以字段要尽早占位
- 运维上还要有保留期与归档,并把它当敏感数据处理——图状态里有完整对话,删用户数据时必须一起删
Key points
- Core value: it shrinks the unit of failure from a whole run to a single node, so a nine-call run is not wasted by one restart
- It also unlocks three things retries cannot: human approval gates, time-travel debugging, and forking from one checkpoint to compare variants — the substrate evaluation is built on
- Cost one: bigger state writes slower, and it is written at every step — hence references for attachments and document ids for retrieval results
- Cost two: storage limits — a jsonb row past roughly two kilobytes goes out-of-line and costs an extra IO, so the practical line is keeping a checkpoint well under hundreds of kilobytes
- Cost three: version compatibility — every change to the state shape is migration debt, and missing fields silently yield undefined or NaN, which is why fields should be reserved early
- Operationally you need retention and archival, and you must treat it as sensitive data: graph state holds full conversations and must be purged with the user's data
评论
登录后即可参与讨论
还没有评论,来说第一句。