Harness Engineering

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

作者 杨艺韬 · 12,143 字 · 发布于 · 更新于

本章要点

  • 理解 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
        第17章 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, or_

# 常用 Reducer(直接用标准库 operator 里的函数)
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 的合流极其关键(见第16章《多 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,直接抛 InvalidUpdateError(Can receive only one value per step);分属不同超步时则后写覆盖先写,前一个分支的文件丢失 Annotated[list, add] 或自定义去重 merge
把 dict 的 or_ 当深合并 Annotated[dict, or_] 能正常合并,但只是浅合并:两个分支写同一个键时后到的整体覆盖先到的,嵌套 dict 不会递归合并 需要深合并或同键冲突报错时,写自定义 reducer
计数器用 add 却返回累计值 节点返回 {"n": state["n"] + 1} 而不是增量 主分支先 +1 得 1,两个并行分支各返回 2,结果被累加成 5 而不是 3 节点只返回增量 {"n": 1},或改用覆盖语义

最容易搞反的是第一类——"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 等任务工具:以 2.1.89 的 cli.js 为准,交互式会话默认启用这组 Task 工具、同时停用 TodoWrite,-p / SDK 等非交互场景仍用 TodoWrite,设环境变量 CLAUDE_CODE_ENABLE_TASKS 可强制启用 Task 工具)。模型在做复杂任务时会主动调用它:

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:#333,stroke:none
    style S1 fill:#2563eb,color:#fff,stroke:none

两者的核心差异:

  • TodoList 是自然语言状态,靠模型的 instruction-following 能力维护;简单但不保证一致性
  • StateGraph 是结构化状态,靠代码更新;强一致但需要更多基础设施

Claude Code 选 TodoList,与它面向个人开发者的定位相符——零配置启动比一致性保证更重要。LangGraph 选 StateGraph,与它面向生产级 agent 平台的定位相符——可调试可追踪比简洁更重要。

没有对错,看你的产品形态。

13.3.3 TodoList 的 schema 设计

前面示意图里写成了 markdown 复选框样式([x] [>] [ ])——那是给人读的呈现层。TodoWrite 工具入参里的 TodoList 结构非常克制,每一项只有三个字段(可在 2.1.89 的 cli.js 中核对其 schema):

[
  {
    "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 两种形式都生成好,比运行时做形态转换稳得多——这是典型的"把计算前置给模型"的 harness 设计思路。

第三,TodoList 的更新语义是"覆盖写"而非"追加写"——每次 TodoWrite 把整个数组重新写一遍(2.1.89 中用新数组整体替换当前列表,全部完成时直接清空),不像对话历史那样 append。这简化了一致性:只有一个"当前视图",不需要 CRDT 合并。但代价是——没有历史:你看不到"半小时前这个 todo 叫什么名字",除非从会话持久化里的 tool_use 入参去重建。所以生产级实现若要做"TodoList 审计追踪",不能只依赖 TodoList 快照。

flowchart LR
    Model[模型]
    TW[TodoWrite 工具]
    Disk[内存中的 TodoList 快照]
    Msg[会话转录 JSONL]
    UI[终端 UI]
    Model -->|生成 3 字段数组| TW
    TW -->|整体覆盖写| Disk
    TW -->|tool_use 留痕| Msg
    Disk -->|即时读取| UI
    Msg -.恢复会话时重建.-> Disk
    style Disk fill:#10b981,color:#333,stroke:none
    style Msg fill:#2563eb,color:#fff,stroke:none

两层各管一段:TodoWrite 整体覆盖内存里的当前列表(2.1.89 中放在应用状态里,按 agent 分开),UI 直接读它;同一次调用的 tool_use 入参随消息写进会话转录。会话进行中以内存快照为准;进程退出后内存快照随之消失,--resume 时再从转录里最后一次 TodoWrite 的入参把列表重建出来(旧版本曾额外写 ~/.claude/todos/ 下的 JSON 文件,现已不再写入,见 13.6.5)。这个"快照管当下、事件流管持久与审计"的双层模型,是极简但可追溯的 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: .claude/worktrees/agent-a1b2…<br/>branch: worktree-agent-a1b2…]
    Main -->|spawn subagent B| SubB[子 Agent B<br/>working dir: .claude/worktrees/agent-c3d4…<br/>branch: worktree-agent-c3d4…]
    SubA -->|完成, 把 branch 返回| Main
    SubB -->|完成, 把 branch 返回| Main
    Main -->|审核并 merge / cherry-pick| MainRepo[主仓库]
    style Main fill:#2563eb,color:#fff,stroke:none
    style SubA fill:#f59e0b,color:#333,stroke:none
    style SubB fill:#f59e0b,color:#333,stroke:none
    style MainRepo fill:#10b981,color:#333,stroke:none

实现:

// 伪代码:需自行引入 uuid 生成器与 exec 工具
// (路径仅为示意;2.1.89 的 Claude Code 把子 Agent 的 worktree 放在仓库内 .claude/worktrees/agent-<8 位 id>)
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 创建有开销(要建目录 + checkout,仓库越大越慢)
  • 需要清理机制(僵尸 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 的"拷贝隔离"在实现复杂度和故障面上都远优于"互斥锁"——它把"并发写同一份文件"这个问题从根上消掉了,而不是给它加一层协调。

13.5.4 Worktree 僵尸问题与清理策略

Git Worktree 虽好,实际用起来会积累"僵尸 worktree"——子 Agent 进程崩溃没跑到 finally、或者用户强杀、或者机器断电——/tmp/claude-wt-xxx 目录就留在磁盘上。堆积得多了,既占磁盘,也拖慢 git worktree list 这类要逐个访问 worktree 元数据的操作。

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

  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 在每个超步(super-step)结束后自动保存状态快照到 checkpointer 后端(写入时机由 durability 控制:默认 "async" 在下一步执行的同时异步落盘,"sync" 落盘后再继续,"exit" 只在图退出时写):

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)

# 执行 — 每个超步结束后自动 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(即 InMemorySaver) 内存 单进程 进程死即丢 开发 / 单元测试
SqliteSaver 本地文件 单机 永久 单机部署 / 桌面 Agent
PostgresSaver 数据库 多节点 永久 生产集群
RedisSaver(社区) Redis 多节点 可配 TTL 短期 session cache

生产配置推荐:PostgreSQL + Redis 混合——PG 存历史 checkpoint(永久),Redis 缓存最新 state(热路径)。

13.6.3 Checkpoint 的真实 TypedDict 定义(langgraph 源码)

在本专栏对照的 checkpoint 4.0.1 里,libs/checkpoint/langgraph/checkpoint/base/__init__.py 中的 Checkpoint 是一个 TypedDict、核心 7 个字段(字段清单随版本有增减——旧版本还有 pending_sends 等字段,以你手上的源码为准)——

class Checkpoint(TypedDict):
    v: int                                    # 格式版本(docstring 写 1,langgraph 1.1.6 实际写入 4)
    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" 手工调用 graph.update_state() 打补丁(对历史 checkpoint 打补丁分叉也是它)
"fork" 以 update_state(..., as_node="__copy__") 原样复制一个历史 checkpoint

time travel 的本质是分叉——不是 "回到过去"、是 "以某个历史 checkpoint 为父节点、写出一条新历史"——无论是对它调 update_state(记为 "update")、带着它的 checkpoint_id 重新 invoke,还是 __copy__ 原样复制(记为 "fork"),原分支都保留、不被污染。同一张图内的父子链靠 parent_config 串起来;parents 字段用 dict 而非 单个 parent id,是因为它记录的是嵌套子图场景下各层命名空间(checkpoint_ns)对应的父图 checkpoint id。

13.6.4 langgraph checkpoint 家族四个包的规模

langgraph 官方把 checkpointer 拆成四个独立 pip 包、在 monorepo 的 libs/ 下各占一格(行数按本专栏对照的 langgraph 1.1.6 源码树统计,随版本演进会有出入,只看量级即可)——

包 文件 行(约) 作用
checkpoint(基础) langgraph/checkpoint/base/__init__.py ~628 Checkpoint TypedDict、BaseCheckpointSaver 抽象、msgpack 序列化、langgraph/checkpoint/memory/__init__.py(约 603 行 MemorySaver)
checkpoint-sqlite langgraph/checkpoint/sqlite/__init__.py ~556 同步 SqliteSaver(含 schema DDL + CRUD);异步版 AsyncSqliteSaver 在同目录 aio.py(约 631 行,基于 aiosqlite)
checkpoint-postgres(sync) langgraph/checkpoint/postgres/__init__.py ~476 同步 psycopg 3 驱动
checkpoint-postgres(async) langgraph/checkpoint/postgres/aio.py ~582 基于 psycopg 3 的 async 连接 + psycopg_pool 连接池(不是 asyncpg)——比 sync 版多出的百来行主要花在 async 上下文管理与连接池处理上
checkpoint-conformance 多 spec 文件 2000+ 第三方 checkpointer 的测试合约——按能力分级:put / put_writes / get_tuple / list / delete_thread 五项是必须支持的基础能力,delete_for_runs / copy_thread / prune 是可选扩展,未实现的扩展能力会被检测出来并跳过(libs/checkpoint-conformance/langgraph/checkpoint/conformance/capabilities.py、libs/checkpoint-conformance/langgraph/checkpoint/conformance/report.py)

一个被低估的工程投资——checkpoint-conformance 单独成包、2000 行测试——意味着 langgraph 不是把 checkpoint 当"可选扩展"、而是当 public 协议在维护——任何人写一个新后端,都能用它测出自己符合到哪一级(报告分 FULL / BASE+PARTIAL / BASE / NONE),而不是凭感觉说"符合 langgraph 语义"。这比很多号称"可插拔"但实际没有测试合约的框架扎实得多。

13.6.5 Claude Code 的对话持久化

Claude Code 不用 StateGraph,但依然有持久化。持久化数据分散在 ~/.claude/ 下的几个目录中(以官方 .claude 目录文档为准,核对于 2026-09-25):

目录 格式 存什么 粒度
~/.claude/projects/<cwd-encoded>/<uuid>.jsonl JSON Lines 完整消息流 追加写、一行一条
~/.claude/file-history/<session>/ 文件副本 文件编辑工具改动前的文件备份,供 /rewind 恢复代码 每轮提示一个检查点,保留最近 100 个检查点的快照
~/.claude/sessions/ JSON 每个运行中的会话一个小文件(pid、sessionId、cwd 等),用于检测并发会话与崩溃 会话退出即删,不是会话主存储
~/.claude/todos/ JSON 旧版本的 TodoList 快照 官方文档标为遗留目录、已不再写入

<cwd-encoded> 的编码方式是把工作目录路径里字母、数字以外的字符(包括路径分隔符)都替换为 -,从而把"session 隔离"和"项目隔离"合二为一——同一工作目录下的历史 session 会聚在同一个文件夹里,claude --resume 只需扫描该文件夹即可列出候选。这是一种比 langgraph 的 thread_id 字符串更轻量的路由:文件系统自身就可以充当索引。对应的命令行参数可以用 claude --help 核对:

claude --continue        # 直接接着当前目录下最近一次会话(-c)
claude --resume          # 打开会话选择界面,让用户选(-r)
claude --resume <uuid>   # 指定某个 session id,直接跳过选择界面

这种做法简单够用,但和 LangGraph 相比有三点差别:

  1. 检查点粒度粗、范围窄——Claude Code 也有检查点:每条开启新一轮的用户提示前自动记录文件状态,/rewind(或连按两次 Esc)可以把代码、对话或两者回退到之前某一轮,--fork-session 可以在恢复时另起新会话 id。但它按"用户提示"而不是按"节点"打点,只跟踪文件编辑工具的改动(Bash 命令改的文件不跟踪),也不能像 update_state 那样直接改写某个历史点的结构化状态
  2. 文件会变大——较长 session 的 JSONL 可能达到数 MB,session 越长读取越慢
  3. GC 是按天数一刀切——转录超过 cleanupPeriodDays(默认 30 天)会被自动清理,要长期保留得自己调大这个设置;清理只看时间,不区分会话重要与否

这也是切换任务时建议用 /clear 开新会话的原因之一:首要目的是让上下文只装当前任务相关的内容,顺带也避免单个 jsonl 无限膨胀。

13.6.6 JSONL 会话格式的常见 type

JSONL 中常见以下几类 type 记录(类型名与字段名可在 2.1.89 的 cli.js 中核对;这里只列常见的几类,并非全部):

type 常见角色 关键字段示例 作用
assistant 模型输出 uuid, parentUuid, message, sessionId 模型每轮输出(含 tool_use)
user 用户/工具 uuid, parentUuid, message, promptId 用户 prompt + tool_result
file-history-snapshot 文件备份索引 messageId, snapshot, isSnapshotUpdate 记录这一轮检查点对应的文件备份清单(备份文件本身在 ~/.claude/file-history/)
system 系统事件 subtype, level, error, retryAttempt API 错误重试(subtype: "api_error")、压缩边界(compact_boundary)等系统事件
queue-operation 排队操作 operation, content, sessionId 用户在流式输出时排队的后续 prompt
last-prompt 元数据 lastPrompt, sessionId 会话最后一条用户 prompt(截断到 200 字符),供会话列表展示

几个工程细节值得拆开讲:

  • parentUuid 链而非"消息索引":每条消息除了自己的 uuid 还记 parentUuid,整个会话本质是一棵有向树而不是线性数组。回退/重放不会破坏历史链——只需要从某个 parentUuid 分叉写新消息即可。这为 /rewind 回到先前某一轮提供了数据基础。
  • file-history-snapshot 单独 type:不和 assistant.message 混在一起,而是独立一类,备份文件本身也另存在 ~/.claude/file-history/。这样文件快照可以独立于对话裁剪——官方文档说明每个会话只保留最近 100 个检查点的文件快照,对话记录不受影响。
  • system 里有 retryAttempt / retryInMs:说明 API 错误的重试是结构化记录的。生产上要调查"为什么这次回答慢",不用猜,直接 grep "type":"system" 就能看到时间轴。
  • last-prompt 是元数据而不是消息:它不进 parentUuid 链,列出历史会话时读取其中的 lastPrompt 作为摘要展示,免得为了显示一行预览去解析整条消息链。(输入框里按 ↑ 召回的提问历史是另一份文件 ~/.claude/history.jsonl,见《Claude Code》专栏第 16 章。)

与 LangGraph Checkpoint 的根本差异——LangGraph 一条 checkpoint 记录完整快照(逻辑上包含所有 channel 的当前值;InMemorySaver、PostgresSaver 按 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:#333,stroke:none
    style Fork1 fill:#f59e0b,color:#333,stroke:none
    style Fork2 fill:#f59e0b,color:#333,stroke:none

给定一个历史任务,你可以:

  • 回到 C3(编辑完 file1 还没测试),改变后续 prompt 或工具,看会不会产生不同结果
  • 回到 C4(测试刚失败),用另一种修 bug 方式继续

这种能力在 Agent 调试中无可替代——传统 debugger 只能前进,不能"如果当初那样会怎样"。

13.7.2 LangGraph 的 Time Travel API

# 查看历史(按时间倒序,最新的 checkpoint 排在最前)
history = list(app.get_state_history(config))

# 找到要 fork 的 checkpoint
fork_point = history[3]  # 倒数第 4 个历史点

# 在该 checkpoint 上打补丁:以它为父节点写出一个新 checkpoint,
# 返回指向新分支的 config(仍在同一个 thread 里,原历史链不受影响)
fork_config = app.update_state(fork_point.config, {
    "plan": modified_plan,  # 我改成别的 plan 看看
})

# 从新分支继续执行
result = app.invoke(None, fork_config)

产品化应用:

  • "What if"分析:产品经理可以回到任意节点试不同方案
  • 回归测试:保留失败 case 的 checkpoint,修复后 fork 重跑验证
  • 演示 / 培训:老师给学生一个 checkpoint,学生从那里开始实验

13.7.3 Time Travel 的局限

不是所有副作用都能"回溯":

  • 文件系统修改已经发生(用 Git 回滚可部分恢复)
  • API 调用已经执行(比如发了邮件、扣了款)
  • Redis 写入已经落盘

所以 Time Travel 只能回溯"状态",不能回溯"副作用"。使用时要配合:

  • Git 做代码状态回溯
  • 数据库事务做数据回溯
  • 外部 API 不可逆操作 → 永远审批(见第17章《Human-in-the-Loop:人机协作的工程设计》)

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:#333,stroke:none
    style Idle fill:#2563eb,color:#fff,stroke:none
    style Suspended fill:#f59e0b,color:#333,stroke:none
    style Archived fill:#7c3aed,color:#fff,stroke:none
    style Deleted fill:#dc2626,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 混在一个表里,冷热分离能降低数据库压力。

一个关键经验是:冷热分离的边界不能拍脑袋定,要和你自家的"用户续会话时间分布"对齐——先埋点统计"从上次活跃到下次继续"的间隔分位数,再把热表窗口设在覆盖 p90 的位置。多数交互式产品的这条分布重尾很短,热表只留 24 小时往往偏紧,但具体数字必须用自己的数据算,不要照抄。另外要注意,"默认 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 默认每个超步结束都写一次 checkpoint,20 个节点串行的工作流跑 10 轮就是 200 条。不设 TTL 的话,单 thread 的 checkpoint 数会随轮次持续增长,Postgres 单表膨胀只是时间问题。
  • state.roundtrip_test_fail 必须每次发版前跑——这不是线上指标是 CI 指标。随机抽一批历史 checkpoint,load 出来再 dump,比 schema 是否一致。Checkpoint 不一致的线上故障几乎都是"改 schema 没写迁移"造成的。
  • session.cold_start.duration 是用户体感最敏感的指标——用户点击"继续上一次会话",稍一迟滞就会觉得卡。要把阈值钉死(例如上表的 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:#2563eb,color:#fff,stroke:none
    style L2 fill:#f59e0b,color:#333,stroke:none
    style L3 fill:#7c3aed,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
持久化 本地 JSONL 文件 可插拔 Checkpointer OpenAI 云端
断点恢复 --continue / --resume 自动(checkpointer) 自动
Time Travel 部分(/rewind 按提示轮次回退代码与对话) ✓ 否
并发隔离 Git Worktree(可选开启) 用户自己做 conversation 级天然隔离
可观测性 文件 tail LangGraph Studio OpenAI Platform UI
最佳场景 开发者 CLI 工具 复杂工作流 快速 prototype

(表中 OpenAI 一列按 Responses API + Conversations 描述。其前身 Assistants API 已被 OpenAI 宣布弃用,官方公布的下线日期为 2026 年 8 月 26 日,新项目不应再选它。)

新项目建议:

  • 小而简的 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_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

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

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

和第11章上下文压缩的串联

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

  • 压缩前:完整 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
  • 长对话用第11章的上下文压缩技术压缩
  • 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

下一章从"状态怎么存"转向"动作能不能做"——会话状态机决定了 Agent 走到哪一步,而第 14 章《Agent 权限模型设计》决定了它在这一步上被允许做什么。


延伸阅读