Harness Engineering

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

作者 杨艺韬 · 10,612 字

第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(单任务)隐式 + TodoListClaude 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 用 orAnnotated[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 提供 TodoWriteTodoRead 工具。模型在做复杂任务时会主动调用它:

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 -->|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": "实现高级特性中"
  }
]
字段类型取值作用
contentstring祈使句(“创建…”/“实现…”)TodoList 主文案,完成后以此回顾
statusenumpending / in_progress / completed三态枚举,没有 blocked / cancelled
activeFormstring进行时态(”…中”)正在执行时 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 拒绝执行。

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

  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% 以下。

另外还有一个细节容易忽略——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-97Checkpoint 是一个 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__.py628Checkpoint TypedDict、BaseCheckpointSaver 抽象、msgpack 序列化、memory/__init__.py(603 行 MemorySaver)
checkpoint-sqlitesqlite/__init__.py556全 SQLite 实现、单文件(含 schema DDL + CRUD + sync/async)
checkpoint-postgres(sync)postgres/__init__.py476同步 psycopg 驱动
checkpoint-postgres(async)postgres/aio.py582asyncpg 驱动——比 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>.jsonlJSON Lines完整消息流追加写、一行一条
~/.claude/todos/<uuid>-agent-<uuid>.jsonJSON 数组当前 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,直接跳过选择界面

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

  1. 没有 checkpoint 链——只有”append-only 全量流水”,想 fork 只能全量 replay,不能”从第 3 步分叉”
  2. 文件会变大——实测一个较长 session(731 行 JSONL、6 种 type)膨胀到 2.3 MB,文本类 session 越长读取越慢
  3. 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本次样本计数字段集合(前四关键)作用
assistant378uuid, parentUuid, message, sessionId模型每轮输出(含 tool_use)
user273uuid, parentUuid, message, promptId用户 prompt + tool_result
file-history-snapshot54messageId, snapshot, isSnapshotUpdateEdit 前对原文件内容的备份
system19subtype, level, error, retryAttempt429/5xx/网络错误重试日志
queue-operation6operation, content, sessionId用户在流式输出时排队的后续 prompt
last-prompt1lastPrompt, 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
SuspendedDB 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 p99Checkpoint DB 字段长度< 2 MB状态膨胀、需压缩或分片
容量messages.count p99state.messages 长度< 200未做压实(compaction)
容量checkpoint.count_per_session p99checkpoint 表 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 p99suspended → active< 500 ms冷热分层边界错误
一致性checkpoint.schema_version_mismatchmigration 路径计数= 0有历史 checkpoint 没迁移
一致性state.roundtrip_test_fail单测 / 线上抽样= 0save → 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 CodeLangGraphOpenAI Assistants API
状态模型隐式 messages + TodoList显式 StateGraph + Channels隐式 thread + 内置 state
持久化本地 JSON 文件可插拔 CheckpointerOpenAI 云端
断点恢复--resume 命令自动(checkpointer)自动
Time Travel
并发隔离Git Worktree用户自己做thread 级天然隔离
可观测性文件 tailLangGraph StudioOpenAI 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_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

延伸阅读