Harness Engineering
第13章 多轮对话与会话状态机:Agent 状态管理的完整工程
第13章 多轮对话与会话状态机:Agent 状态管理的完整工程
“State is the root of all complexity — and the source of all capability.” — 并发系统设计的永恒真理
本章要点
- 理解 Agent 交互的本质——一个有分支、有回退、可中断恢复的状态机,而非简单的 request-response
- 掌握隐式状态 vs 显式状态的 Pareto 决策:什么时候用 messages 数组够用、什么时候必须上 StateGraph
- 读懂 LangGraph 的 Channel + Reducer 协议:状态如何安全并发合并
- 看懂 Claude Code 的 **TodoList 作为”任务备忘录”**的精妙设计——模型自己管理状态
- 掌握上下文切换的三种模式:隐式切换、显式任务栈、TodoList 追踪
- 理解并发隔离的 Git Worktree 方案:为什么 Claude Code 给子 Agent 开独立 worktree
- 深入 Checkpoint 机制的工程实现:SqliteSaver / PostgresSaver / Redis 三种后端对比
- 学会时间旅行(Time Travel)调试:从任一历史 checkpoint fork 一个新分支
- 掌握会话垃圾回收:超时策略、LRU、冷热分层
- 避开四类状态管理反模式:状态膨胀、隐式状态依赖、checkpoint 不一致、无 GC
13.1 单轮 vs 多轮:本质的范式差异
LLM 最简单的 API 是”输入一条消息,输出一段回复”。但真实的 Agent 交互几乎从来不是单轮的。
13.1.1 一个真实的 19 轮对话
用户: 帮我重构 auth 模块 ← R1 任务下达
Agent: 让我先看看代码结构 ← R2 分析
Agent: [Read src/auth/index.ts] ← R3
Agent: [Read src/auth/session.ts] ← R4
Agent: [Read src/auth/login.ts] ← R5
Agent: [Read src/auth/tests/*] ← R6
Agent: [Grep "AuthContext"] ← R7
Agent: 我建议分三步重构: ← R8 方案提出
1. 提取 TokenService
2. 分离 SessionStore
3. 注入 AuthContext
用户: 第二步不太对,应该用 Redis ← R9 用户修正
Agent: 明白,我调整方案:Step 2 改用 Redis ← R10
Agent: [Edit src/auth/token-service.ts] ← R11
Agent: [Edit src/auth/session-store.ts] ← R12
Agent: [Edit src/auth/context.ts] ← R13
Agent: [Edit src/auth/__mocks__/redis.ts] ← R14
Agent: [Bash "npm test"] ← R15
Agent: [Bash "npm test" 再次] ← R16 测试失败,重试
Agent: [Edit src/auth/session-store.ts] ← R17 修 bug
Agent: [Bash "npm test"] ← R18 通过
Agent: 重构完成,所有 42 个测试通过 ← R19
这 19 轮里 Agent 必须持续追踪:
- 当前任务是什么(“重构 auth”,不要漂移到别的话题)
- 处于哪个阶段(调研 → 方案 → 执行 → 验证)
- 用户有什么修正(“用 Redis”要刻在后续决策里)
- 哪些文件已改(避免重复修改或遗忘回滚)
- 测试状态(哪些通过、哪些还挂着)
- 工具调用历史(避免重复读同一个文件)
这就是会话状态(Session State)。管理不好,Agent 就”记性差”、“健忘”、“自相矛盾”——用户体验崩塌的头号原因。
13.1.2 Agent 交互的状态机本质
stateDiagram-v2
[*] --> Idle
Idle --> Understanding: 用户输入
Understanding --> Investigating: 需要更多信息
Understanding --> Planning: 信息充分
Investigating --> Planning: 调研完成
Planning --> WaitingApproval: 提交方案
WaitingApproval --> Executing: 用户批准
WaitingApproval --> Planning: 用户要求修改
WaitingApproval --> Idle: 用户取消
Executing --> Verifying: 执行完成
Verifying --> Executing: 验证失败(重试)
Verifying --> Completed: 通过
Completed --> Idle
Executing --> Interrupted: 用户切换话题
Planning --> Interrupted: 用户切换话题
Interrupted --> Understanding: 恢复原任务
Interrupted --> Understanding: 处理新任务
note right of WaitingApproval
Ch17 HITL 的介入点
end note
note right of Interrupted
多任务栈 or TodoList
end note
不是线性的请求-响应循环——它有分支(方案被拒重新做)、有回退(测试失败修 bug)、有中断恢复(用户切换话题再切回来)。
这种复杂度必须用状态机建模。没建模的代码就是 bug 温床。
13.2 两种状态管理范式:隐式 vs 显式
13.2.1 隐式状态:把一切塞进对话历史
最简单的方案:state = messages[]。所有历史对话、工具调用、结果都放在 messages 里,下次调 LLM 时完整发过去。
// Claude Code 的核心循环(简化)
const messages: Message[] = []
while (!done) {
const userInput = await getUserInput()
messages.push({ role: 'user', content: userInput })
while (true) {
const response = await llm.chat({
system: SYSTEM_PROMPT,
messages: messages, // 完整历史就是全部状态
tools: AVAILABLE_TOOLS,
})
messages.push({ role: 'assistant', content: response.content })
if (!response.stop_reason === 'tool_use') break
// 执行工具调用
const toolResults = await executeTools(response.tool_calls)
messages.push({ role: 'user', content: toolResults })
}
}
优点:
- 实现极简,几十行代码
- 模型天然从历史中理解任何上下文
- 对开发者几乎零心智负担
缺点:
- 状态不可程序化访问——你无法直接查询”当前任务完成了多少”,这信息隐藏在几十条 message 里
- 压缩难——messages 膨胀时只能让模型自己 summarize,控制不住精确度
- 并发不安全——两个地方同时改 messages 会乱
- 持久化粗粒度——只能整体存、整体恢复,不能按”关键字段”存
隐式状态适合 纯对话型 Agent——用户体验上就是”聊天”,没有复杂任务状态要追踪。Claude Code 就是走这条路,但配合了”TodoList”和文件系统弥补缺陷(下节详讲)。
13.2.2 显式状态:LangGraph 的 Channel/Reducer 模型
LangGraph 把状态提升为一等公民——用 TypedDict 定义状态结构,每个 Node 读写特定字段:
from langgraph.graph import StateGraph, START, END
from typing import TypedDict, Annotated
from operator import add
class AgentState(TypedDict):
# 基础字段
task: str # 任务描述
phase: str # "understanding"|"planning"|"executing"|"verifying"
# 累积字段(Reducer: add 会自动合并 list)
messages: Annotated[list, add] # 对话历史
files_modified: Annotated[list, add] # 已修改文件
test_results: Annotated[list, add] # 测试结果
# 覆盖字段(默认行为: 新值覆盖旧值)
plan: list[str] # 当前 plan
plan_step: int # 进行到哪一步
user_approved: bool # 用户审批状态
last_error: str | None # 最近一次错误
graph = StateGraph(AgentState)
13.2.3 Channel 与 Reducer
Annotated[list, add] 是 LangGraph 的Channel 声明。Channel 的核心是 Reducer 函数——它定义”多个并发 Node 对同一字段的更新怎么合并”:
from operator import add
# 内置 Reducer
Annotated[list, add] # 合并列表(list concat)
Annotated[int, add] # 累加整数
Annotated[set, or_] # 合集
Annotated[str, None] # 默认 None = 覆盖语义(last-write-wins)
# 自定义 Reducer
def merge_dict(a: dict, b: dict) -> dict:
return {**a, **b} # 右手优先覆盖
Annotated[dict, merge_dict]
Reducer 决定了并发写的语义。例如两个并行 Node 都向 files_modified 追加文件名——add Reducer 确保两个都被保留,而不是一个覆盖另一个。这对并行子 Agent 的合流极其关键(见 ch16 多 Agent 协调)。
13.2.4 隐式 vs 显式:决策矩阵
| 场景 | 推荐 | 理由 |
|---|---|---|
| 纯对话 chatbot | 隐式(messages) | 没有任务结构,对话历史已足够 |
| Coding Agent(单任务) | 隐式 + TodoList | Claude Code 路线,简洁够用 |
| 多步工作流 | 显式(StateGraph) | 需要追踪 plan、approval、result |
| 并发多 Agent 合流 | 显式 + Reducer | 必须定义并发语义 |
| 需要 time travel | 显式 + checkpointer | 只有显式 state 才能精确 fork |
| 需要可视化调试 | 显式 | LangGraph Studio 需要结构化 state |
我个人推荐:小项目起步用隐式(简单),等状态字段超过 5 个或需要并发时切到显式。切换成本并不高——因为 messages 本来就是显式 state 里的一个 channel。
13.2.5 Reducer 选错的三类典型事故
Reducer 虽然是一行 Annotated[T, func] 的事,选错代价不低。下面三类是生产里踩过的典型坑:
| 坑 | 错误选择 | 实际现象 | 正确选择 |
|---|---|---|---|
| list 用默认覆盖 | Annotated[list, None] | 并行分支各自更新 files_modified,合流时只保留最后一个分支的文件 | Annotated[list, add] 或自定义去重 merge |
dict 用 or | Annotated[dict, or_] | 运行时报 TypeError: unsupported operand | 自定义 merge_dict 函数 |
| 计数器用 add 但分支重叠 | 两个分支都 +1,主分支已经 +1 | 被重复累加成 +3 | 用 set 去重或显式 max |
最容易搞反的是第一类——“list 难道不应该默认追加吗”——LangGraph 的默认是覆盖(last-write-wins),因为它把”没有显式 Reducer”的字段当作标量语义处理。Pareto 决策再一次:不加标注的字段就按最简语义走,要并发合并的字段自己显式声明。这份显式性是工程上的优点不是缺点——你永远知道哪些字段有并发合并、哪些没有。
一个实战建议:所有 list / dict / set 字段,在定义 TypedDict 时都要加 Annotated 注释——哪怕是 Annotated[list, last](显式声明覆盖)。Code review 时一眼就能看出”这个字段有没有考虑并发”。这比事后出 bug 再去追 100 行代码找漏注解的成本低太多。
13.3 Claude Code 的 TodoList:模型自管状态的巧思
Claude Code 走的是隐式路线,但通过一个极聪明的设计补足了”状态不可见”的缺陷——让模型自己管理一个 TodoList。
13.3.1 TodoList 作为”状态备忘录”
Claude Code 提供 TodoWrite 和 TodoRead 工具。模型在做复杂任务时会主动调用它:
Agent: 我来重构 auth 模块,先列一下要做什么。
[TodoWrite:
- [ ] 调研现有 auth 代码结构
- [ ] 设计新的 TokenService 接口
- [ ] 设计 SessionStore(用 Redis)
- [ ] 实现 TokenService
- [ ] 实现 SessionStore
- [ ] 迁移 login 流程
- [ ] 更新测试
- [ ] 运行全量测试验证
]
[... 执行若干轮后 ...]
Agent: 进度更新一下
[TodoWrite:
- [x] 调研现有 auth 代码结构
- [x] 设计新的 TokenService 接口
- [x] 设计 SessionStore(用 Redis)
- [x] 实现 TokenService
- [>] 实现 SessionStore ← 正在做
- [ ] 迁移 login 流程
- [ ] 更新测试
- [ ] 运行全量测试验证
]
这个设计同时服务了三个目标:
- 帮助模型不”迷路”——TodoList 在 context 里,每次 LLM call 都能看到自己做到哪一步了
- 给用户可见性——用户打开终端就能看到任务进度,不用猜
- 无需额外 infra——不需要数据库、不需要状态机引擎,就是一个 markdown 列表
13.3.2 TodoList vs StateGraph
graph TB
subgraph "TodoList 方案(Claude Code)"
M1[模型] -->|TodoWrite| L1[待办列表]
L1 -->|TodoRead| M1
L1 -->|用户可见| U1[用户]
end
subgraph "StateGraph 方案(LangGraph)"
M2[模型 + 代码] -->|更新 state| S1[结构化状态]
S1 -->|读取 state| M2
S1 -->|checkpoint| DB[SQLite/Postgres]
S1 -->|可视化| Studio[LangGraph Studio]
end
style L1 fill:#10b981,color:#fff,stroke:none
style S1 fill:#3b82f6,color:#fff,stroke:none
两者的核心差异:
- TodoList 是自然语言状态,靠模型的 instruction-following 能力维护;简单但不保证一致性
- StateGraph 是结构化状态,靠代码更新;强一致但需要更多基础设施
Claude Code 选 TodoList 是因为它面向个人开发者——零配置启动比一致性保证更重要。LangGraph 选 StateGraph 是因为它面向生产级 agent 平台——可调试可追踪比简洁更重要。
没有对错,看你的产品形态。
13.3.3 TodoList 真实 schema 考古
前面示意图里写成了 markdown 复选框样式([x] [>] [ ])——那是给人读的呈现层。真正落盘到 ~/.claude/todos/<uuid>-agent-<uuid>.json 的结构比想象中更严格、字段只有三个:
[
{
"content": "创建项目基础结构",
"status": "completed",
"activeForm": "创建项目结构中"
},
{
"content": "实现依赖收集系统",
"status": "in_progress",
"activeForm": "实现依赖收集中"
},
{
"content": "实现高级特性",
"status": "pending",
"activeForm": "实现高级特性中"
}
]
| 字段 | 类型 | 取值 | 作用 |
|---|---|---|---|
content | string | 祈使句(“创建…”/“实现…”) | TodoList 主文案,完成后以此回顾 |
status | enum | pending / in_progress / completed | 三态枚举,没有 blocked / cancelled |
activeForm | string | 进行时态(”…中”) | 正在执行时 UI 展示给用户的实时文案 |
三个细节值得展开:
第一,status 只有三值、没有 blocked / cancelled。这是一个深思熟虑的克制——TodoList 是”动力”不是”看板”,遇到阻塞应该拆成新 todo(“先解决依赖 X”)而不是把原 todo 标 blocked 摆在那。这直接契合 13.4.3 节提到的”TodoList 保留 + 自然切换”模式:所有活任务都是 pending/in_progress/completed 之一,不存在”僵尸 todo”。
第二,activeForm 是为 UI 独立准备的进行时文案、而不是由代码 content.replace 动态生成。原因是:中文”实现依赖收集” → “实现依赖收集中”的正则可行,但英文”Implement dependency tracking” → “Implementing dependency tracking”涉及动词变位,代码转换极易出错。让模型在 TodoWrite 时一次性把 imperative 和 progressive 两种形式都生成好,比运行时转换鲁棒 10 倍——这是典型的”把计算前置给模型”的 harness 设计思路。
第三,整份 TodoList 是”覆盖写”而非”追加写”。每次 TodoWrite 都是把整个数组重新写一遍,不像对话历史那样 append。这简化了一致性:只有一个”当前视图”,不需要 CRDT 合并。但代价是——没有历史:你看不到”半小时前这个 todo 叫什么名字”,除非去翻会话 JSONL 里的 tool_use 入参。所以生产级实现若要做”TodoList 审计追踪”,必须从对话 JSONL 重建,单看 ~/.claude/todos/ 是不够的。
flowchart LR
Model[模型]
TW[TodoWrite 工具]
Disk[~/.claude/todos/xxx.json]
Msg[JSONL 会话流]
UI[终端 UI]
Model -->|生成 3 字段数组| TW
TW -->|整体覆盖写| Disk
TW -->|tool_use 留痕| Msg
Disk -->|即时读取| UI
Msg -.审计时回放.-> UI
style Disk fill:#10b981,color:#fff,stroke:none
style Msg fill:#3b82f6,color:#fff,stroke:none
一致性方向是单向的:TodoWrite 先写磁盘快照、再在会话流里留 tool_use 痕迹,UI 读磁盘快照显示。任何从会话流推导出来的”历史 TodoList”都只能作为审计参考,不能当作权威。这个”快照为王、事件为辅”的双层模型,是极简但可追溯的 state 存储范式。
13.4 上下文切换:用户”跳来跳去”怎么办
多轮对话里用户常常突然切话题:
用户: 帮我修登录 bug ← 任务 A 开始
Agent: [开始调查...]
用户: 等等,先看下部署脚本为什么报错 ← 突然切到任务 B
Agent: ???
三种处理策略:
13.4.1 隐式切换(Claude Code 默认)
什么都不做——把用户新的话追加到 messages,让模型从上下文中理解”用户改主意了”。
messages: [
...,
{role: 'user', content: '帮我修登录 bug'},
{role: 'assistant', content: '[调查中...]'},
{role: 'user', content: '等等,先看下部署脚本为什么报错'}, ← 新消息
// 模型自己判断:这是切话题,把 login bug 暂时搁置
]
优点:零实现成本。 缺点:任务 A 的状态可能被”冲淡”——如果任务 B 聊了很久,模型回到 A 时可能忘记原来做到哪一步了。
13.4.2 显式任务栈
维护一个栈,支持 push / pop:
interface Task {
id: string
description: string
status: 'active' | 'suspended' | 'completed'
snapshot: { messages: Message[]; todoList: Todo[]; filesTouched: string[] }
}
class TaskStack {
stack: Task[] = []
pushNewTask(description: string) {
// 暂存当前任务
if (this.current) {
this.current.status = 'suspended'
this.current.snapshot = captureState()
}
const task: Task = {
id: uuid(),
description,
status: 'active',
snapshot: null,
}
this.stack.push(task)
}
popCurrentTask() {
const completed = this.stack.pop()
if (this.current) {
this.current.status = 'active'
restoreState(this.current.snapshot)
}
}
get current() {
return this.stack[this.stack.length - 1]
}
}
优点:严格的任务边界;任务 A 的状态不会被任务 B 污染。 缺点:需要 UI 和逻辑让用户显式表达”push 新任务 / pop 回旧任务”,否则用户永远不会 pop——栈无限增长。
13.4.3 TodoList 保留 + 自然切换(最实用)
不维护栈,但让 TodoList 同时包含两个任务:
[TodoWrite:
## 任务 A: 修登录 bug
- [x] 定位错误栈
- [>] 分析根因
- [ ] 实现修复
- [ ] 回归测试
## 任务 B: 部署脚本报错(用户优先)
- [>] 查看报错
- [ ] 修复
]
用户可见两个任务,模型也能看到”A 还没做完只是暂停”。做完 B 后模型自己会回来继续 A。
这是 Claude Code 实践中最好用的模式——把栈语义通过 TodoList 表达出来,既不需要额外 infra,又避免隐式切换的”记性差”问题。
13.5 并发隔离:多个 Agent 同时跑怎么办
一个用户可能同时开几个终端跑 Agent;或者一个 Agent 派生了多个子 Agent 并行工作。如果这些 Agent 都能无限制访问同一个项目文件——灾难随时发生:
Agent A: 正在重构 src/auth/index.ts
Agent B: 同时在 src/auth/index.ts 加新功能
结果:两人改了同一个文件,谁后写谁赢 → 数据丢失
13.5.1 Git Worktree 隔离(Claude Code 的方案)
Claude Code 为每个派生的子 Agent 创建独立的 Git Worktree:
graph TB
Main[主 Agent<br/>working dir: ~/project<br/>branch: main]
Main -->|spawn subagent A| SubA[子 Agent A<br/>working dir: /tmp/wt-abc<br/>branch: sub-a]
Main -->|spawn subagent B| SubB[子 Agent B<br/>working dir: /tmp/wt-def<br/>branch: sub-b]
SubA -->|完成, 把 branch 返回| Main
SubB -->|完成, 把 branch 返回| Main
Main -->|审核并 merge / cherry-pick| MainRepo[主仓库]
style Main fill:#3b82f6,color:#fff,stroke:none
style SubA fill:#f59e0b,color:#fff,stroke:none
style SubB fill:#f59e0b,color:#fff,stroke:none
style MainRepo fill:#10b981,color:#fff,stroke:none
实现:
async function spawnAgentWithWorktree(prompt: string) {
const worktreeId = uuid().slice(0, 8)
const worktreePath = `/tmp/claude-wt-${worktreeId}`
const branchName = `agent-${worktreeId}`
// 基于当前 HEAD 创建独立 worktree
await exec(`git worktree add ${worktreePath} -b ${branchName}`)
try {
// 子 Agent 在隔离环境里跑
const agent = new Agent({ cwd: worktreePath, prompt })
await agent.run()
// 完成后 merge 回主分支(用户审核)
const diff = await exec(`cd ${worktreePath} && git diff main`)
return { branch: branchName, diff, worktreePath }
} finally {
// 自动清理(如果无改动)
if (await isCleanWorktree(worktreePath)) {
await exec(`git worktree remove ${worktreePath}`)
}
}
}
优点:
- 完全的文件系统隔离
- 主分支永远干净
- 子 Agent 做什么折腾都不影响其他 Agent
- Git 原生提供了 diff / cherry-pick / 冲突处理
缺点:
- Worktree 创建有开销(~100ms)
- 需要清理机制(僵尸 worktree 会堆积)
- 只适合 git 项目(非 git 场景要用别的隔离方式)
13.5.2 进程级隔离
每个 Agent session 独立 OS 进程,各自拥有:
- 独立的 messages 数组(上下文窗口)
- 独立的工作目录(或 worktree)
- 独立的环境变量
- 独立的 sub-process 树(tool 调用 fork 出来的)
进程级隔离 + 文件系统隔离是生产 agent 平台的标配。
13.5.3 Lock-based 协调(不推荐)
“每个文件加锁,先到先得” —— 听起来可行,实际极易死锁:
- Agent A 锁了 file1,想要 file2
- Agent B 锁了 file2,想要 file1
- 两个都卡着等对方
强烈推荐放弃锁方案。Git Worktree 的”拷贝隔离”比”互斥锁”简单 10 倍,鲁棒性高 100 倍。
13.5.4 Worktree 僵尸问题与清理策略
Git Worktree 虽好,实际用起来会积累”僵尸 worktree”——子 Agent 进程崩溃没跑到 finally、或者用户强杀、或者机器断电——/tmp/claude-wt-xxx 目录就留在磁盘上。堆积几百个以后,git worktree list 慢到几秒钟、git gc 拒绝执行。
生产级实现至少要有三道清理:
- 启动时扫描:Agent 平台进程启动时枚举
/tmp/claude-wt-*,对每个 worktree 跑git worktree prune清理已消失的 worktree; - 定期 GC:每天一次,找 mtime 超过 24 小时且没有活跃子 Agent 对应的 worktree,强制
git worktree remove --force; - 磁盘压力感知:当
/tmp使用率超过 80%,触发紧急清理——按 mtime 从老到新删除 worktree,直到降到 50% 以下。
另外还有一个细节容易忽略——Git 对 worktree 数量有软上限。当一个仓库的 worktree 超过几百个,git status / git fetch 都会变慢(因为要扫所有 worktree 的 HEAD)。所以上限要在应用层兜底:单用户同时在线的子 Agent worktree 数不超过 20,超过就排队而不是继续创建。
13.6 Checkpoint 机制:断点恢复的工程基石
长任务可能因为网络断开、进程崩溃、用户关机、服务器重启而中断。没有 checkpoint,重启后只能从头来——用户体验崩塌。
13.6.1 LangGraph Checkpointer
LangGraph 在每个 Node 执行后自动保存状态快照到 checkpointer 后端:
from langgraph.checkpoint.sqlite import SqliteSaver
checkpointer = SqliteSaver.from_conn_string("checkpoints.db")
graph = build_graph()
app = graph.compile(checkpointer=checkpointer)
# 执行 — 每个 Node 完成后自动 checkpoint
config = {"configurable": {"thread_id": "session-123"}}
result = app.invoke({"task": "重构 auth"}, config=config)
# 进程重启后恢复
state = app.get_state(config) # 从 DB 读回最新 checkpoint
# state.values 是完整的 AgentState
# state.next 是下一步要执行的 Node
# 从断点继续
final = app.invoke(None, config=config)
13.6.2 三种 Checkpointer 后端
| 后端 | 数据规模 | 并发性 | 持久性 | 适用场景 |
|---|---|---|---|---|
| MemorySaver | 内存 | 单进程 | 进程死即丢 | 开发 / 单元测试 |
| SqliteSaver | 本地文件 | 单机 | 永久 | 单机部署 / 桌面 Agent |
| PostgresSaver | 数据库 | 多节点 | 永久 | 生产集群 |
| RedisSaver(社区) | Redis | 多节点 | 可配 TTL | 短期 session cache |
生产配置推荐:PostgreSQL + Redis 混合——PG 存历史 checkpoint(永久),Redis 缓存最新 state(热路径)。
13.6.3 Checkpoint 的真实 TypedDict 定义(langgraph 源码)
libs/checkpoint/langgraph/checkpoint/base/__init__.py:65-97 里 Checkpoint 是一个 TypedDict、恰好 7 个字段——
class Checkpoint(TypedDict):
v: int # 格式版本,当前是 1
id: str # unique + 单调递增,可直接排序
ts: str # ISO 8601
channel_values: dict[str, Any] # 每个 channel 的反序列化值
channel_versions: dict[str, str|int|float] # 每个 channel 的版本号
versions_seen: dict[str, ChannelVersions] # 每个 node 见过的各 channel 版本
updated_channels: list[str] | None # 本次更新触达的 channel 名字
最反直觉的一个字段是 versions_seen——不是 flat、是嵌套 dict——node_id → {channel_name → version}——Pregel loop 判断一个 node 下一步要不要执行、就是看这个 node versions_seen 里某个订阅 channel 的版本是否落后于 channel_versions 里对应的最新版——落后就执行、追齐就 skip。这是整个 LangGraph 调度器的核心——比”显式 next 节点列表”优雅一个量级。
CheckpointMetadata(base/init.py:35-61)是另一个 TypedDict、4 字段——
class CheckpointMetadata(TypedDict, total=False):
source: Literal["input", "loop", "update", "fork"] # 四种触发源
step: int # -1 首个 input、0 首个 loop、往后递增
parents: dict[str, str] # namespace → parent checkpoint id(嵌套图需要)
run_id: str
source 的四值枚举值得玩味——
| 值 | 何时产生 |
|---|---|
"input" | invoke / stream / batch 入口、step = -1 |
"loop" | Pregel 主循环每一步 |
"update" | 用户手工 state.update() 打补丁 |
"fork" | 从历史 checkpoint 复制出新分支——time travel 就靠它 |
“fork” 是 time travel 的内部机制——不是 “回到过去”、是 “拷贝某个历史 checkpoint、从它分叉出一条新历史”——原分支保留、不被污染。这就是 parents 字段用 dict 而非 单个 parent id 的原因:嵌套子图场景下、一个 checkpoint 可能从多个 namespace 派生。
13.6.4 langgraph checkpoint 家族四个包的真实行数
langgraph 官方把 checkpointer 拆成四个独立 pip 包、在 monorepo 的 libs/ 下各占一格——
| 包 | 文件 | 行 | 作用 |
|---|---|---|---|
checkpoint(基础) | base/__init__.py | 628 | Checkpoint TypedDict、BaseCheckpointSaver 抽象、msgpack 序列化、memory/__init__.py(603 行 MemorySaver) |
checkpoint-sqlite | sqlite/__init__.py | 556 | 全 SQLite 实现、单文件(含 schema DDL + CRUD + sync/async) |
checkpoint-postgres(sync) | postgres/__init__.py | 476 | 同步 psycopg 驱动 |
checkpoint-postgres(async) | postgres/aio.py | 582 | asyncpg 驱动——比 sync 多 106 行是因为 async 事务边界、connection pool 管理更复杂 |
checkpoint-conformance | 多 spec 文件 | 2000+ | 第三方 checkpointer 的测试合约——任何社区实现(Redis/ScyllaDB 等)跑完这套 test_put / test_list / test_delete_thread / test_copy_thread 才算合格 |
一个被低估的工程投资——checkpoint-conformance 单独成包、2000 行测试——意味着 langgraph 不是把 checkpoint 当”可选扩展”、而是当 public 协议在维护——任何人写一个新后端、跑完 conformance 就能拍胸脯说”符合 langgraph 语义”。这比很多号称”可插拔”但实际没有测试合约的框架扎实得多。
13.6.5 Claude Code 的对话持久化(实测路径)
Claude Code 不用 StateGraph,但依然有持久化。常被误传的路径是 ~/.claude/sessions/<id>.json——这是错的。在本机 macOS 环境里对 ~/.claude/ 目录 ls 一遍就会发现,实际持久化分散在四个目录中:
| 目录 | 格式 | 存什么 | 粒度 |
|---|---|---|---|
~/.claude/projects/<cwd-encoded>/<uuid>.jsonl | JSON Lines | 完整消息流 | 追加写、一行一条 |
~/.claude/todos/<uuid>-agent-<uuid>.json | JSON 数组 | 当前 TodoList 快照 | 整体覆盖写 |
~/.claude/file-history/ | 二进制快照 | Read/Edit 触达过的文件原始内容 | Edit 前自动快照 |
~/.claude/sessions/ | JSON | 仅少量元数据(实测只有 6 个文件,与单会话无关) | 不是主存储 |
<cwd-encoded> 的编码规则就是把工作目录的 / 全部替换成 -——比如 /Users/yangyitao/yyt_repository/code/yyt-jt 对应的目录名是 -Users-yangyitao-yyt-repository-code-yyt-jt。这个约定把”session 隔离”和”项目隔离”合二为一——同一个 cwd 下的所有历史 session 自然聚在一个文件夹里,claude --resume 只需扫该文件夹就能列出候选。这是比 langgraph 的 thread_id 字符串更轻量的路由:连路由表都不需要,文件系统自身就是索引。
claude --resume # 列出当前 cwd 对应目录下的所有历史 session,让用户选
claude --resume <uuid> # 指定某个 uuid,直接跳过选择界面
这种做法简单够用,但有三个已知局限:
- 没有 checkpoint 链——只有”append-only 全量流水”,想 fork 只能全量 replay,不能”从第 3 步分叉”
- 文件会变大——实测一个较长 session(731 行 JSONL、6 种 type)膨胀到 2.3 MB,文本类 session 越长读取越慢
- GC 策略靠用户——Claude Code 不会自动删旧 session,
claude --resume随着时间推移列表越来越长,本机实测一个常驻项目积累了 上百个 jsonl 文件
这也解释了为什么 Claude Code 在界面上强调”新 session 用 /clear”——不是为了省 token,而是为了阻止单个 jsonl 无限膨胀拖慢启动。
13.6.6 JSONL 会话格式的六种 type 实录
把本机一份真实 session(~/.claude/projects/<proj>/<uuid>.jsonl、731 行)按 type 字段分类统计,能看到 Claude Code 到底在往持久化里写什么:
| type | 本次样本计数 | 字段集合(前四关键) | 作用 |
|---|---|---|---|
assistant | 378 | uuid, parentUuid, message, sessionId | 模型每轮输出(含 tool_use) |
user | 273 | uuid, parentUuid, message, promptId | 用户 prompt + tool_result |
file-history-snapshot | 54 | messageId, snapshot, isSnapshotUpdate | Edit 前对原文件内容的备份 |
system | 19 | subtype, level, error, retryAttempt | 429/5xx/网络错误重试日志 |
queue-operation | 6 | operation, content, sessionId | 用户在流式输出时排队的后续 prompt |
last-prompt | 1 | lastPrompt, sessionId | 指针:最后一条用户 prompt(快速跳转) |
几个工程细节值得拆开讲:
parentUuid链而非”消息索引”:每条消息除了自己的uuid还记parentUuid,整个会话本质是一棵有向树而不是线性数组。回退/重放不会破坏历史链——只需要从某个parentUuid分叉写新消息即可。这正是--resume可以在任意历史点继续的工程基础。file-history-snapshot单独 type:不和assistant.message混在一起,而是独立一类。这样 GC 时可以只裁剪文件快照而保留对话——对回看历史的用户最友好。system里有retryAttempt/retryInMs:说明所有 429 / 网络错误的重试都有结构化记录。生产上要调查”为什么这次回答慢”,不用猜,直接 grep"type":"system"就能看到时间轴。last-prompt只有 1 条:它是”指针”、每次覆盖写。用于Ctrl+↑快速召回上次提问——和 shell history 的最后一条记忆语义相同。
与 LangGraph Checkpoint 的根本差异——LangGraph 一条 checkpoint 记录完整快照(所有 channel 的当前值),占用大但 fork/恢复是 O(1) 读取;Claude Code 的 JSONL 记录增量事件(每次只写一条),占用小但回放是 O(N) 扫描。这是典型的 snapshot vs event-sourcing 权衡,没有优劣,只看场景:LangGraph 面向”随机访问任意历史点”(需要 Studio),Claude Code 面向”顺序 append + 偶尔从头 replay”(CLI 交互天然顺序)。
13.7 Time Travel:Agent 调试的超能力
有了 checkpoint 链,就能做一件传统调试工具做不到的事——时间旅行。
13.7.1 什么是 Time Travel
graph LR
C1[checkpoint-1<br/>task 开始]
C2[checkpoint-2<br/>plan 完成]
C3[checkpoint-3<br/>编辑 file1]
C4[checkpoint-4<br/>测试失败]
C5[checkpoint-5<br/>重试编辑]
C6[checkpoint-6<br/>完成]
C1 --> C2 --> C3 --> C4 --> C5 --> C6
Fork1[fork-A<br/>从 C3 分叉<br/>换不同实现]
Fork2[fork-B<br/>从 C4 分叉<br/>修 bug 的另一种方式]
C3 -.fork.-> Fork1
C4 -.fork.-> Fork2
style C6 fill:#10b981,color:#fff,stroke:none
style Fork1 fill:#f59e0b,color:#fff,stroke:none
style Fork2 fill:#f59e0b,color:#fff,stroke:none
给定一个历史任务,你可以:
- 回到 C3(编辑完 file1 还没测试),改变后续 prompt 或工具,看会不会产生不同结果
- 回到 C4(测试刚失败),用另一种修 bug 方式继续
这种能力在 Agent 调试中无可替代——传统 debugger 只能前进,不能”如果当初那样会怎样”。
13.7.2 LangGraph 的 Time Travel API
# 查看历史
history = list(app.get_state_history(config))
# 找到要 fork 的 checkpoint
fork_point = history[3] # 第 4 个历史点
# 从该点启动新的 thread,带修改
new_config = {"configurable": {"thread_id": "session-123-fork-1"}}
# 用该 checkpoint 的 state 作为起点,但换 prompt/工具继续
new_app = graph.compile(checkpointer=checkpointer)
new_app.update_state(new_config, {
"plan": modified_plan, # 我改成别的 plan 看看
})
result = new_app.invoke(None, new_config)
产品化应用:
- “What if”分析:产品经理可以回到任意节点试不同方案
- 回归测试:保留失败 case 的 checkpoint,修复后 fork 重跑验证
- 演示 / 培训:老师给学生一个 checkpoint,学生从那里开始实验
13.7.3 Time Travel 的局限
不是所有副作用都能”回溯”:
- 文件系统修改已经发生(用 Git 回滚可部分恢复)
- API 调用已经执行(比如发了邮件、扣了款)
- Redis 写入已经落盘
所以 Time Travel 只能回溯”状态”,不能回溯”副作用”。使用时要配合:
- Git 做代码状态回溯
- 数据库事务做数据回溯
- 外部 API 不可逆操作 → 永远审批(见 ch17 HITL)
13.8 会话生命周期管理
会话不是永远存在的——需要明确的生命周期管理。
13.8.1 四个阶段
graph LR
Active[Active<br/>活跃中]
Idle[Idle<br/>空闲]
Suspended[Suspended<br/>已暂停]
Archived[Archived<br/>归档]
Deleted[Deleted<br/>删除]
Active --> Idle
Idle --> Active
Idle -->|30 min 无活动| Suspended
Suspended -->|用户恢复| Active
Suspended -->|7 天无活动| Archived
Archived -->|90 天无活动| Deleted
style Active fill:#10b981,color:#fff,stroke:none
style Idle fill:#3b82f6,color:#fff,stroke:none
style Suspended fill:#f59e0b,color:#fff,stroke:none
style Archived fill:#8b5cf6,color:#fff,stroke:none
style Deleted fill:#ef4444,color:#fff,stroke:none
| 阶段 | 存储位置 | 访问成本 | 标志条件 |
|---|---|---|---|
| Active | 内存 + DB | 即时 | 有请求正在处理 |
| Idle | 内存 + DB | 即时 | 无活动 < 30 min |
| Suspended | DB only | 几百 ms 恢复 | 无活动 30 min - 7 天 |
| Archived | 冷存储(S3) | 几秒-分钟 | 无活动 7-90 天 |
| Deleted | 无 | 无法访问 | 超过 90 天或用户主动删除 |
13.8.2 GC 实现
class SessionManager {
private memoryCache = new LRUCache<string, Session>({ max: 1000 })
private db: Database
async get(sessionId: string): Promise<Session | null> {
// 1. 内存查
const cached = this.memoryCache.get(sessionId)
if (cached) {
cached.lastAccessed = Date.now()
return cached
}
// 2. DB 查
const session = await this.db.sessions.findOne({ id: sessionId })
if (!session) return null
// 3. 若冷存档里,解档
if (session.status === 'archived') {
session.state = await this.coldStorage.unarchive(session.id)
}
session.lastAccessed = Date.now()
this.memoryCache.set(sessionId, session)
return session
}
// 后台 GC 任务(每 5 min 跑一次)
async runGc() {
const now = Date.now()
const sessions = await this.db.sessions.findMany()
for (const s of sessions) {
const age = now - s.lastAccessed
if (age > 90 * DAY && s.status === 'archived') {
await this.delete(s.id)
} else if (age > 7 * DAY && s.status === 'suspended') {
await this.archive(s.id)
} else if (age > 30 * MINUTE && s.status === 'idle') {
await this.suspend(s.id)
}
}
}
}
GC 的关键是分层存储——不要把 Active 和 Archived 混在一个表里,冷热分离能降低数据库压力。
一个实战经验是:冷热分离的边界要和”用户行为分布”对齐。实测中用户有 80% 的继续会话动作发生在 24 小时内、10% 发生在 3 天内、剩余 10% 才是 7 天以上。所以热表只保留 24 小时是不够的、应当是 3 天——能覆盖 90% 的即时访问;超过 3 天再转温存储也完全来得及。“默认 30 分钟”那行代码写的是 UI 侧 suspend 阈值、不是热表/温表边界、两者不能混为一谈。
13.8.3 用户数据清理的合规考虑
GDPR / CCPA 这类合规要求用户可以要求删除他们的所有数据。会话状态必须支持:
async function deleteUserData(userId: string) {
// 1. 删除内存 cache
await sessionCache.deleteByUser(userId)
// 2. 删除热存储 DB 记录
await db.sessions.deleteMany({ user_id: userId })
// 3. 删除冷存储 S3 文件
await coldStorage.deleteByPrefix(`users/${userId}/`)
// 4. 删除所有 checkpoint 链
await db.checkpoints.deleteMany({ thread_id: { $in: userThreadIds } })
// 5. audit log 记录删除行为(满足合规审计)
await auditLog.record({ action: 'user_data_deleted', user_id: userId, timestamp: Date.now() })
}
三个容易漏的合规坑:
- 备份库里的副本——DB 日常快照会把用户数据带进备份,合规删除必须延伸到备份链;实务做法是备份保留期不超过 30 天、到期自然失效,而不是去备份里精确擦除(后者几乎不可能做干净);
- 向量索引里的残留——如果 session 内容进过 embedding 索引(比如语义搜索),那份向量数据也属于用户个人数据,同样要删;
- LLM 厂商缓存——发给 Claude / OpenAI 的 prompt 可能进了厂商侧的调优数据。GDPR 严格解读下厂商也是数据处理者,合同层面要确保厂商支持删除请求,否则用户的数据删除请求永远做不到 100%。
13.9 状态可观测性:让状态不再是黑盒
// 状态查询 API
GET /api/sessions/:id/state
{
"session_id": "sess-abc123",
"status": "active",
"phase": "executing",
"current_task": "重构 auth 模块",
"plan_progress": "3/8",
"messages_count": 24,
"tokens_used": { "input": 85000, "output": 12000, "cost_usd": 0.48 },
"tools_called": { "Read": 8, "Edit": 5, "Bash": 3 },
"files_modified": ["src/auth.ts", "src/auth.test.ts", "src/session.ts"],
"checkpoints_count": 12,
"last_checkpoint_at": "2026-04-17T14:30:00Z",
"started_at": "2026-04-17T10:30:00Z",
"last_active": "2026-04-17T14:45:23Z"
}
// 当前对话可读化展示
GET /api/sessions/:id/transcript
// 时间旅行: 列出可回溯的 checkpoint
GET /api/sessions/:id/checkpoints
// Fork
POST /api/sessions/:id/fork { from_checkpoint: "c-7" }
LangGraph Studio 是官方的可视化工具——以图形方式展示:
- 当前执行到哪个节点
- State 的所有字段当前值
- 历史 checkpoint 链
- 可以在 UI 上点一下 checkpoint 就 fork 一个新 thread
生产 agent 平台应该提供类似能力——没有可观测性的状态机就是活埋。
13.9.1 SLI / SLO:会话状态要监控哪些数字
把”可观测性”落到具体指标,才不是空话。下面这套是生产 agent 平台必须采集的指标集,按”三大维度 × 九条线”组织:
| 维度 | 指标 | 采集来源 | 建议起点 | 触发告警的含义 |
|---|---|---|---|---|
| 容量 | session.size_bytes p99 | Checkpoint DB 字段长度 | < 2 MB | 状态膨胀、需压缩或分片 |
| 容量 | messages.count p99 | state.messages 长度 | < 200 | 未做压实(compaction) |
| 容量 | checkpoint.count_per_session p99 | checkpoint 表 group by thread | < 500 | 缺 TTL 或 Node 粒度太细 |
| 延迟 | checkpoint.save.duration p95 | 打点 saver.put | < 80 ms (Postgres) | DB 慢、序列化 CPU 高、网络阻塞 |
| 延迟 | checkpoint.restore.duration p95 | 打点 saver.get | < 50 ms | 冷数据不在热表 |
| 延迟 | session.cold_start.duration p99 | suspended → active | < 500 ms | 冷热分层边界错误 |
| 一致性 | checkpoint.schema_version_mismatch | migration 路径计数 | = 0 | 有历史 checkpoint 没迁移 |
| 一致性 | state.roundtrip_test_fail | 单测 / 线上抽样 | = 0 | save → restore 不等价、已经在丢数据 |
| 一致性 | gc.lag_seconds | 上次 GC 时间戳 | < 600 s | 后台任务挂了、数据会堆积 |
几个容易漏的点:
checkpoint.count_per_session经常被人忽略——LangGraph 默认每个 Node 结束都写一次 checkpoint,图里有 20 个 Node 的复杂工作流跑 10 轮就是 200 条。不设 TTL 的话单 thread 几千 checkpoint是常态,Postgres 单表过千万只是时间问题。state.roundtrip_test_fail必须每次发版前跑——这不是线上指标是 CI 指标。随机抽一批历史 checkpoint,load 出来再 dump,比 schema 是否一致。Checkpoint 不一致的线上故障几乎都是”改 schema 没写迁移”造成的。session.cold_start.duration是用户体感最敏感的指标——用户点击”继续上一次会话”,超过 1 秒就感觉卡。要把阈值死死钉在 500 ms 内,超过就说明冷热分层判断错了(或 S3 解档链路有问题)。
13.9.2 状态可观测的三层视图
flowchart TB
subgraph L1[L1 实时调试:单 session]
SV["/sessions/:id/state 最新字段"]
TP["/sessions/:id/transcript 对话流"]
CP["/sessions/:id/checkpoints 历史 checkpoint"]
end
subgraph L2[L2 聚合监控:平台级]
DSH[Dashboard: p50/p95/p99 延迟]
ALARM[告警: 容量/一致性超阈值]
end
subgraph L3[L3 溯源审计:合规 + 回看]
REPLAY[Replay: 任意 session 从头回放]
FORK[Fork: 从任意 checkpoint 试错]
DEL[Deletion Log: GDPR 审计]
end
L1 --> L2
L2 --> L3
style L1 fill:#3b82f6,color:#fff,stroke:none
style L2 fill:#f59e0b,color:#fff,stroke:none
style L3 fill:#8b5cf6,color:#fff,stroke:none
三层自下而上、职责递进:
- L1 开发者在调试单次 bug 时用——看某个 session 走到哪一步、state 字段长什么样、哪个 checkpoint 之后开始走歪
- L2 SRE 在值班时用——看”是不是这波上线让 p95 延迟从 50ms 跳到 300ms”
- L3 法务 / 研究员在复盘时用——看一个产品决策上线后用户会话形态的变化,或者”给我所有用户 X 的数据并证明已删除”
最常见的设计错误是只做 L1(因为它离开发者最近)。没有 L2 的平台在流量上涨时完全盲飞;没有 L3 的平台在合规审计时只能手忙脚乱写 SQL。从第一天就把三层脚手架搭齐——哪怕 L2 只是一张 Grafana 看板、L3 只是一张定时导出表——也比”等出事再补”强十倍。
13.10 三大框架状态管理对比
| 维度 | Claude Code | LangGraph | OpenAI Assistants API |
|---|---|---|---|
| 状态模型 | 隐式 messages + TodoList | 显式 StateGraph + Channels | 隐式 thread + 内置 state |
| 持久化 | 本地 JSON 文件 | 可插拔 Checkpointer | OpenAI 云端 |
| 断点恢复 | --resume 命令 | 自动(checkpointer) | 自动 |
| Time Travel | 否 | ✓ | 否 |
| 并发隔离 | Git Worktree | 用户自己做 | thread 级天然隔离 |
| 可观测性 | 文件 tail | LangGraph Studio | OpenAI Platform UI |
| 最佳场景 | 开发者 CLI 工具 | 复杂工作流 | 快速 prototype |
新项目建议:
- 小而简的 chatbot → 先用 OpenAI Assistants 起步
- 复杂多步流程 / 需要 time travel → LangGraph
- CLI / 本地工具 → 抄 Claude Code 的路数(messages + TodoList + 文件持久化)
13.10.1 State schema 演进:生产 Agent 平台绕不开的活
小 Demo 的 state 是”一次定义、永不变更”,生产 Agent 平台的 state 是”每周都在漂”。新加个 plan_version 字段、改掉 phase 的枚举值、把 files_modified 从 list[str] 升级成 list[{path, reason}] 对象——这些改动每一次都意味着数据库里旧 checkpoint 的反序列化要出问题。
没有 schema 演进策略、线上跑上三个月,“恢复老 session”按钮基本就是坏的——但没人发现,因为测试只用新 session 跑。
演进的四种常见改动
| 改动类型 | 举例 | 向后兼容? | 迁移复杂度 |
|---|---|---|---|
| 加字段(有默认值) | 新增 plan_version: int = 1 | 天然兼容 | 低:读老数据时填默认值 |
| 删字段 | 废弃 legacy_flag: bool | 天然兼容(忽略) | 低:dump 时直接不写 |
| 改字段类型 | files: list[str] → list[{path, reason}] | 破坏性 | 高:必须写转换函数 |
| 改字段语义 | phase="done" 的含义从”完成”改为”待验收” | 隐形破坏 | 最高:必须版本化 + 迁移链 |
最危险的是第四类——没人改类型、但改了语义。代码里新写的逻辑默认 phase="done" 是”待验收”、读到老 checkpoint 却把它当成”完成”——你永远不会看到序列化错误,但 Agent 决策会静默走偏。
防御的三条基线
class AgentState(TypedDict):
schema_version: int # 永远是 state 里的第一个字段
# ... 其他字段
MIGRATIONS: dict[int, Callable] = {
1: migrate_v0_to_v1, # 旧 state 升到 v1
2: migrate_v1_to_v2, # v1 升 v2
3: migrate_v2_to_v3,
}
CURRENT_VERSION = 3
def load_and_migrate(raw: dict) -> AgentState:
v = raw.get("schema_version", 0)
while v < CURRENT_VERSION:
raw = MIGRATIONS[v + 1](raw)
v += 1
raw["schema_version"] = CURRENT_VERSION
return raw # type: ignore
三条不能妥协的工程纪律:
schema_version是 state 的一等字段——不是”加在末尾的补丁”、是第一个字段,迁移函数先看它再决定怎么读。- 迁移函数链式可组合——
v0 → v1 → v2 → v3,不要写”从 v0 直接到 v3”的大跳转。每一跳只管一个增量,更容易 code review、更容易单测。 - CI 保留每个 schema 版本的 fixture——
tests/fixtures/state_v1.json、state_v2.json存进仓库。每次发版跑load_and_migrate(fixture) → assert 可被当前代码正常使用。这是唯一能防”语义漂移”的工程手段。
和 ch11 Context Compaction 的串联
state 膨胀时,简单策略是”裁 messages”——但裁 messages 本身就是一次 state 变更。要和 ch11 的压缩策略配合:
- 压缩前:完整
messages: list[Message]作为第 N 版 state - 压缩后:state 多出一个
compacted_summary: str,messages保留最近 20 条——这是第 N+1 版 - 对历史 checkpoint 做 time travel 时,要能识别”这个 checkpoint 在压缩之前/之后”并走对应的读取路径
sequenceDiagram
participant Old as checkpoint v2<br/>(pre-compact)
participant Migrate as 迁移层
participant Code as 当前代码 (v3)
Code->>Migrate: load(checkpoint_v2)
Migrate->>Migrate: 检测 schema_version=2
Migrate->>Migrate: 运行 migrate_v2_to_v3
Note over Migrate: 生成空的 compacted_summary<br/>messages 原样保留
Migrate->>Code: 返回 v3 state
Code->>Code: 正常使用
没有迁移层的后果是什么——用户回到三个月前的 session,Agent 第一句回答就说”我没有看到任何历史任务”(因为新代码找 compacted_summary 找不到就当空 session 处理)。用户体感:“Claude 失忆了。” 这种失败模式极难复现、极难归因,是生产 Agent 平台最经典的”无声 bug”。
13.11 四类状态管理反模式
反模式 1:状态无边界膨胀
每轮对话都往 state 里加字段,半年后 state 对象几 MB,每次 checkpoint 几秒——用户体感卡顿。
修正:
- 状态字段严格审视,only keep what’s needed for decisions
- 长对话用 ch11 Context Compaction 技术压缩
- Checkpoint 大字段(如 files)存引用而非内容
反模式 2:隐式状态依赖
代码里到处 messages[messages.length - 3].content——依赖对话历史的具体结构。某一天换 prompt 结构,所有地方都挂。
修正:
- 任何”查询当前状态”的逻辑都走显式字段(state.current_task、state.plan_step)
- 不要从 messages 里硬 parse 信息——那是 LLM 的工作
反模式 3:Checkpoint 不一致
save 用 checkpoint-A,restore 用 checkpoint-B(A 和 B 实现不一致)。恢复出来的 state 字段错乱。
修正:
- Checkpoint schema 要版本化(加
schema_version字段) - 升级 schema 时提供迁移函数,读旧 checkpoint 时自动升级
- 单元测试覆盖 save→restore 的 roundtrip
反模式 4:无 GC 策略
session 无限积累,DB 几个月后几 TB,查询慢到爆炸。
修正:
- 上线第一天就有 GC 规则
- 分层存储(热 / 温 / 冷 / 删)
- 监控 session 总数 + 总字节数,异常增长告警
13.12 本章小结
Agent 的状态管理是能否做复杂事情的门槛:
- 多轮交互是状态机:有分支、回退、中断恢复,不是简单 req-resp
- 隐式 vs 显式:messages 简洁,StateGraph 强大;根据复杂度选
- Channel + Reducer:LangGraph 的并发安全合并协议
- TodoList 模式:Claude Code 的”模型自管状态”巧思,简单有效
- 上下文切换:隐式 / 任务栈 / TodoList 保留三种模式;TodoList 最实用
- 并发隔离:Git Worktree(文件系统)+ 进程隔离(内存);避开 lock 方案
- Checkpoint:LangGraph 的 SqliteSaver / PostgresSaver / Redis 三档后端
- Time Travel:只有显式 state + checkpoint 链才能做;调 Agent 的超能力
- 生命周期:Active → Idle → Suspended → Archived → Deleted 四阶段 GC
- 可观测性:/state API + 可视化 Studio;没有可观测的状态机 = 黑盒定时炸弹
- 四反模式:状态膨胀、隐式依赖、checkpoint 不一致、无 GC
延伸阅读
- LangGraph 状态与 checkpoint 文档:https://langchain-ai.github.io/langgraph/concepts/persistence/
- LangGraph Time Travel:https://langchain-ai.github.io/langgraph/how-tos/human_in_the_loop/time-travel/
- Claude Code 的
--resume实现:开源仓库.claude/sessions/目录- Git Worktree 官方文档:https://git-scm.com/docs/git-worktree
- Redux Time Travel debugger(前端状态机鼻祖):https://github.com/reduxjs/redux