Harness Engineering

第13章 多轮对话与会话状态机:Agent 状态管理的完整工程

作者 杨艺韬 · 11,104 字

本章要点

  • 理解 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_]            # 合集
str                            # 不加注解的裸类型 = 覆盖语义(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 用默认覆盖 list(没加 reducer 注解) 并行分支各自更新 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 注释——哪怕是显式声明覆盖(自定义一个 def last(a, b): return b、写成 Annotated[list, last])。Code review 时一眼就能看出"这个字段有没有考虑并发"。这比事后出 bug 再去追 100 行代码找漏注解的成本低太多。

13.3 Claude Code 的 TodoList:模型自管状态的巧思

Claude Code 走的是隐式路线,但通过一个极聪明的设计补足了"状态不可见"的缺陷——让模型自己管理一个 TodoList

13.3.1 TodoList 作为"状态备忘录"

Claude Code 提供 TodoWrite 工具(早期版本还有配套的 TodoRead,后被移除——因为写入后的列表会直接呈现在上下文里,模型不需要显式"读"一次;较新版本中还引入了更细粒度的 TaskCreate / TaskUpdate / TaskList 任务 API 与之并存)。模型在做复杂任务时会主动调用它:

Agent: 我来重构 auth 模块,先列一下要做什么。
[TodoWrite:
  - [ ] 调研现有 auth 代码结构
  - [ ] 设计新的 TokenService 接口
  - [ ] 设计 SessionStore(用 Redis)
  - [ ] 实现 TokenService
  - [ ] 实现 SessionStore
  - [ ] 迁移 login 流程
  - [ ] 更新测试
  - [ ] 运行全量测试验证
]

[... 执行若干轮后 ...]

Agent: 进度更新一下
[TodoWrite:
  - [x] 调研现有 auth 代码结构
  - [x] 设计新的 TokenService 接口
  - [x] 设计 SessionStore(用 Redis)
  - [x] 实现 TokenService
  - [>] 实现 SessionStore  ← 正在做
  - [ ] 迁移 login 流程
  - [ ] 更新测试
  - [ ] 运行全量测试验证
]

这个设计同时服务了三个目标:

  1. 帮助模型不"迷路"——TodoList 在 context 里,每次 LLM call 都能看到自己做到哪一步了
  2. 给用户可见性——用户打开终端就能看到任务进度,不用猜
  3. 无需额外 infra——不需要数据库、不需要状态机引擎,就是一个 markdown 列表

13.3.2 TodoList vs StateGraph

graph TB
    subgraph "TodoList 方案(Claude Code)"
        M1[模型] -->|TodoWrite| L1[待办列表]
        L1 -->|呈现在上下文| 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 Code 产品行为的观察,其底层持久化的 TodoList 结构很可能非常克制,核心字段只有三个(具体路径与字段名以官方实现为准):

[
  {
    "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 叫什么名字",除非从会话持久化里的 tool_use 入参去重建。所以生产级实现若要做"TodoList 审计追踪",不能只依赖 TodoList 快照

flowchart LR
    Model[模型]
    TW[TodoWrite 工具]
    Disk[TodoList 快照]
    Msg[会话持久化流]
    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 按需开启 worktree 隔离isolation: "worktree")——默认子 Agent 与主 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

实现:

// 伪代码:需自行引入 uuid 生成器与 exec 工具
async function isCleanWorktree(path: string): Promise<boolean> {
  const { stdout } = await exec(`cd ${path} && git status --porcelain`)
  return stdout.trim() === ''
}

async function spawnAgentWithWorktree(prompt: string, baseBranch?: string) {
  const worktreeId = uuid().slice(0, 8)
  const worktreePath = `/tmp/claude-wt-${worktreeId}`
  const branchName = `agent-${worktreeId}`

  // 若调用方未指定 base,则取当前分支(避免硬编码 main/master)
  const base = baseBranch || (await exec('git branch --show-current')).stdout.trim()

  // 基于当前 HEAD 创建独立 worktree
  await exec(`git worktree add ${worktreePath} -b ${branchName}`)

  try {
    // 子 Agent 在隔离环境里跑
    const agent = new Agent({ cwd: worktreePath, prompt })
    await agent.run()

    // 完成后与 base branch 做 diff(用户审核)
    const { stdout: diff } = await exec(`cd ${worktreePath} && git diff ${base}`)
    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 拒绝执行。

生产级实现至少要有三道清理

  1. 启动时扫描:Agent 平台进程启动时枚举 /tmp/claude-wt-*,对每个 worktree 跑 git worktree prune 清理已消失的 worktree;
  2. 定期 GC:每天一次,找 mtime 超过 24 小时且没有活跃子 Agent 对应的 worktree,强制 git worktree remove --force
  3. 磁盘压力感知:当 /tmp 使用率超过 80%,触发紧急清理——按 mtime 从老到新删除 worktree,直到降到 50% 以下。

另外还有一个细节容易忽略——worktree 数量本身就是维护成本。每个 worktree 的 HEAD / index 都是 git gc / git prune 做可达性计算时要额外纳入的引用来源,git worktree list 也要逐个访问各 worktree 的元数据——积累到几百个时这些维护操作会明显变慢。所以上限要在应用层兜底:单用户同时在线的子 Agent worktree 数不超过 20,超过就排队而不是继续创建。

13.6 Checkpoint 机制:断点恢复的工程基石

长任务可能因为网络断开、进程崩溃、用户关机、服务器重启而中断。没有 checkpoint,重启后只能从头来——用户体验崩塌。

13.6.1 LangGraph Checkpointer

LangGraph 在每个 Node 执行后自动保存状态快照到 checkpointer 后端:

import sqlite3
from langgraph.checkpoint.sqlite import SqliteSaver

# 注意:SqliteSaver.from_conn_string(...) 返回的是 context manager,
# 只能写成 `with SqliteSaver.from_conn_string("checkpoints.db") as checkpointer:` 使用;
# 长驻进程更直接的写法是自己持有连接:
checkpointer = SqliteSaver(sqlite3.connect("checkpoints.db", check_same_thread=False))
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 中的 Checkpoint 是一个 TypedDict、核心 7 个字段(字段清单随版本有增减——旧版本还有 pending_sends 等字段,以你手上的源码为准)——

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(同文件中)是另一个 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 3 驱动
checkpoint-postgres(async) postgres/aio.py ~582 基于 psycopg 3 的 async 连接 + psycopg_pool 连接池(不是 asyncpg)——比 sync 版多出的百来行主要花在 async 上下文管理与连接池处理上
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/ 下的几个目录中(具体路径与文件名以官方实现为准):

目录 格式 存什么 粒度
~/.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 少量元数据 不是单会话主存储

<cwd-encoded> 的编码方式大致是把工作目录的路径分隔符替换为 -,从而把"session 隔离"和"项目隔离"合二为一——同一工作目录下的历史 session 会聚在同一个文件夹里,claude --resume 只需扫描该文件夹即可列出候选。这是一种比 langgraph 的 thread_id 字符串更轻量的路由:文件系统自身就可以充当索引。以下命令行为基于观察的示意:

claude --resume          # 列出当前 cwd 对应目录下的所有历史 session,让用户选
claude --resume <uuid>   # 指定某个 uuid,直接跳过选择界面

这种做法简单够用,但有三个已知局限

  1. 没有 checkpoint 链——只有"append-only 全量流水",想 fork 只能全量 replay,不能"从第 3 步分叉"
  2. 文件会变大——较长 session 的 JSONL 可能达到数 MB,文本类 session 越长读取越慢
  3. GC 策略靠用户——Claude Code 不会自动删旧 session,claude --resume 随着时间推移列表越来越长,常驻项目可能积累大量 jsonl 文件

这也解释了为什么 Claude Code 在界面上强调"新 session 用 /clear"——不是为了省 token,而是为了阻止单个 jsonl 无限膨胀拖慢启动

13.6.6 JSONL 会话格式的常见 type

根据对 Claude Code 本地持久化文件的观察,JSONL 中通常包含以下几类 type 记录(具体字段与比例以官方实现为准):

type 常见角色 关键字段示例 作用
assistant 模型输出 uuid, parentUuid, message, sessionId 模型每轮输出(含 tool_use)
user 用户/工具 uuid, parentUuid, message, promptId 用户 prompt + tool_result
file-history-snapshot 文件备份 messageId, snapshot, isSnapshotUpdate Edit 前对原文件内容的备份
system 系统事件 subtype, level, error, retryAttempt 429/5xx/网络错误重试日志
queue-operation 排队操作 operation, content, sessionId 用户在流式输出时排队的后续 prompt
last-prompt 指针 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 实现

// 伪代码:LRUCache 来自 'lru-cache' 等库;DAY/MINUTE 为时间常量
import { LRUCache } from 'lru-cache'

const MINUTE = 60 * 1000
const DAY = 24 * 60 * MINUTE

class SessionManager {
  private memoryCache = new LRUCache<string, Session>({ max: 1000 })
  private db: Database
  private coldStorage: ColdStorage

  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)
      }
    }
  }

  // 以下状态转换方法为示意,具体实现省略
  private async delete(id: string): Promise<void> {}
  private async archive(id: string): Promise<void> {}
  private async suspend(id: string): Promise<void> {}
}

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 Responses API
状态模型 隐式 messages + TodoList 显式 StateGraph + Channels 隐式 conversation + 内置 state
持久化 本地 JSON 文件 可插拔 Checkpointer OpenAI 云端
断点恢复 --resume 命令 自动(checkpointer) 自动
Time Travel
并发隔离 Git Worktree(可选开启) 用户自己做 conversation 级天然隔离
可观测性 文件 tail LangGraph Studio OpenAI Platform UI
最佳场景 开发者 CLI 工具 复杂工作流 快速 prototype

(表中 OpenAI 一列按 Responses API + Conversations 描述。其前身 Assistants API 已于 2025 年 8 月宣布弃用、2026 年 8 月正式下线,新项目不应再选它。)

新项目建议:

  • 小而简的 chatbot → 先用 OpenAI Responses API(配 Conversations 管理会话状态)起步
  • 复杂多步流程 / 需要 time travel → LangGraph
  • CLI / 本地工具 → 抄 Claude Code 的路数(messages + TodoList + 文件持久化)

13.10.1 State schema 演进:生产 Agent 平台绕不开的活

小 Demo 的 state 是"一次定义、永不变更",生产 Agent 平台的 state 是"每周都在漂"。新加个 plan_version 字段、改掉 phase 的枚举值、把 files_modifiedlist[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

三条不能妥协的工程纪律:

  1. schema_version 是 state 的一等字段——不是"加在末尾的补丁"、是第一个字段,迁移函数先看它再决定怎么读。
  2. 迁移函数链式可组合——v0 → v1 → v2 → v3,不要写"从 v0 直接到 v3"的大跳转。每一跳只管一个增量,更容易 code review、更容易单测。
  3. CI 保留每个 schema 版本的 fixture——tests/fixtures/state_v1.jsonstate_v2.json 存进仓库。每次发版跑 load_and_migrate(fixture) → assert 可被当前代码正常使用这是唯一能防"语义漂移"的工程手段

和 ch11 Context Compaction 的串联

state 膨胀时,简单策略是"裁 messages"——但裁 messages 本身就是一次 state 变更。要和 ch11 的压缩策略配合:

  • 压缩前:完整 messages: list[Message] 作为第 N 版 state
  • 压缩后:state 多出一个 compacted_summary: strmessages 保留最近 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

延伸阅读