为什么 Agent 需要 Session Fork:从改几个字段到多方案时间线
副标题:基于 LangGraph + FastAPI + SQLite 的 Agent 后端工程实践
本文是《从 0 到 1 构建一个智能旅行 Agent 后端》系列第 3 篇。
本文基于规则 Mock Agent,当前尚未接入真实 LLM,重点讨论 Agent 后端工程设计。
摘要:在智能旅行 Agent 后端开发中,当用户需要同时对比多个方案时,简单的字段修改无法满足需求。本文通过一个真实场景——用户想保留悉尼方案同时查看墨尔本版本——探讨为什么不能原地修改 State,以及如何通过 Session Fork 机制实现多方案时间线并存。文章详细分析了最初方案的不足、核心问题本质、最终解决方案的设计取舍,以及当前实现的边界条件。
关键词:Session Fork、LangGraph、多方案、Trip、Session、TripState、Agent、后端设计、时间线隔离
1. 问题背景:从单时间线到多方案并存
在前两篇实现了 HITL(人在回路)和条件路由之后,用户已经可以在一条时间线上完成完整的确认流程:解析需求 → 确认 → 查看路线候选 → 选中一条。
然后遇到了一个非常真实的用户需求。
方案 A 已经定稿到路线阶段:目的地悉尼 + 圣灵群岛,8 天行程,三条 route_options 中用户选中了「东岸线」。用户看着地图说:
「能不能也看看墨尔本版本?悉尼这套我想留着对比。」
这不是第 2 篇中讨论的MODIFY操作。MODIFY 是在同一条确认流程里修改草稿或重新生成路线,仍然只有一条执行时间线。用户真正需要的是:两套方案并存——悉尼版继续可查,墨尔本版另开一条线从头确认。
Chatbot 可以依靠长对话上下文「假装还记得上周那版悉尼」;但旅行规划后端不行:前端需要并排展示两个方案,后端需要能分别获取两个 Session 的快照,而不是在内存中覆盖掉上一份 route_options。
核心问题因此变得清晰:当用户说「换墨尔本看看」时,为什么不能直接修改原来的 State?
2. 最初方案的局限性
我最开始考虑过最省事的做法:在同一个 TripState 上修改 destinations 字段,清空 route_options,重新运行 Requirement Agent 和 Route Planner——相当于「原地换方案」。
表面上看很合理:字段改了,重新生成一遍不就行了吗?
问题在于,修改的不只是几个 Domain 字段,还会连带抹掉整条执行时间线上的痕迹。
2.1 覆盖关键数据
覆盖 route_options 和 selected_route_id。悉尼东岸线、圣灵群岛停留点一起消失;用户无法再打开方案 A 查看当时选择了什么。
2.2 破坏时间线连续性
覆盖 LangGraph Checkpoint。LangGraph Checkpointer 按 thread_id 存储执行进度。同一 thread 上每次 invoke / update,都是在同一条时间线上前进。原地改写等于告诉系统「历史上从未存在过悉尼方案」。
2.3 丧失对比能力
无法比较。产品需要的是「A vs B」对比,原地修改只能给出「只有 B」。以后用户说「还是悉尼那版好」,系统没有独立的快照可供回溯。
对应的测试用例固定了以下行为:fork 之后父 Session 的 stage、route_options、需求软偏好都不变。如果采用原地 update,父状态会被覆盖,这类隔离断言根本写不出来。
用户不是在「修改方案」,而是在探索新的可能。这两种意图,后端结构完全不同。
3. 核心问题:容器与时间线的分离
要把「容器」和「时间线」分开,我设计的三层结构是:
Trip(一次旅行容器) └── Session(一条方案分支,session_id == thread_id) └── TripState(该分支在 LangGraph 里的业务快照)3.1 Trip:容器层
Trip管理索引:trip_id、original_user_input、active_session_id(当前哪条分支可写)、root_session_id。一个 Trip 下可以挂载多个 Session,形成 fork 树。
3.2 Session:分支层
Session管理分支元数据:session_id、parent_session_id、fork_reason、status。每条 Session 对应 LangGraph 中独立的一条 thread。
3.3 TripState:状态层
TripState是执行态投影:requirements、route_options、stage、pending_confirmation 等。它存在于 LangGraph Checkpoint 中,由 Checkpointer 按 Session 的 thread 进行读写。
关键约定:thread_id == session_id。fork 不是修改旧 thread,而是从 sess_abc123 创建新的 sess_def456,LangGraph Checkpoint 天然隔离。
这层模型不是第一天就设计好的。是在「原地改方案会覆盖悉尼版」的问题逼出来之后,才把 Trip 从「只有一个 State 的对象」升级为「多 Session 容器」。
一句话总结:
Branch 是时间线,不是版本号。
版本号暗示「同一条线上的第 N 次修订」;fork 是 Trip 下并列的多条 Session,各自有 LangGraph Checkpoint、各自走确认门。
4. 最终解决方案:Session Fork 机制
fork_session 的核心语义:创建子分支,不修改父分支,不复制父 Checkpoint。
4.1 父 Session 处理
父 Session:保留。父的 LangGraph Checkpoint 仍在原 thread_id 上;ROUTE_CONFIRMED、route_options、当时选中的 selected_route_id 全部保留。写权限不跟随 status 走,而是跟随 Trip.active_session_id。
4.2 子 Session 创建
子 Session:
- 新 session_id,新 thread_id(二者相等)
- 深拷贝父的 requirements(防止后续合并污染父分支)
- 清空 route_options、selected_route_id
- stage 回到 REQUIREMENT_DRAFT,pending_confirmation 回到 REQUIREMENTS
- 不复制父的 LangGraph Checkpoint;全新启动图
- Trip.active_session_id 切换到 child
fork 完成后,子分支通常会再次停在 wait_requirement_confirmation——用户需要在新时间线上重新确认需求,再生成墨尔本方向的路线。不能「继承父的 route interrupt 位置直接改一条线」,那在语义上是克隆半完成进程,不是「另开方案」。
4.3 权限模型
权限模型可以概括为:
读:任意 Session 写:仅 active Session fork:任意 Session → 新 child active,父保留REQUIREMENT_DRAFT 阶段默认不允许 fork(除非 force)——避免在需求还没定型时滥开分支;路线阶段 fork 是主路径。
5. 方案对比与取舍
5.1 方案 A:原地 update 几个字段
为什么考虑:最省事,不用引入多 Session。
为什么放弃:覆盖旧方案,无法并排对比;LangGraph Checkpoint 时间线也被抹掉。
5.2 方案 B:version 字段 / route_stale 标记位
为什么考虑:看起来能「保留历史」而不开新分支。
为什么放弃:version 曾存在于早期模型,但没有自动递增、没有冲突检测,后来已删除。stale 位与「独立 Session 保留完整历史」重复;历史方案靠独立时间线,不靠标记位。
5.3 方案 C:复制父 LangGraph Checkpoint
为什么考虑:省事——子分支继承父的 interrupt 位置和执行进度。
为什么放弃:用户要「墨尔本新版」,子分支却克隆了「悉尼已 confirm 到路线门」的半态,变成「克隆正在跑的进程」,不是干净的新方案。
5.4 方案 D:同 thread 内模拟 branch / 切回父分支原地编辑
为什么考虑:少管几条 thread;产品上「改回悉尼版接着写」有吸引力。
为什么放弃:LangGraph Checkpoint 仍是一条链,无法真正隔离。switch_active_session 当前刻意未做——要改历史方案,只能从历史 Session 再 fork 一条新线。
当前方案接受的代价:不能切回父 Session 直接编辑;多个 Session 可能同为 ACTIVE status,前端必须看 is_active = (session_id == trip.active_session_id);需要分支列表与权限产品化。父 status 不自动 SUPERSEDED,是已知取舍,不是疏忽。
6. 当前实现边界
6.1 已实现功能
- Session Fork:新 session / 新 thread,不复制父 LangGraph Checkpoint
- 父分支内容不被覆盖;历史 Session 可读、可再 fork
- 仅 active Session 可写(messages / confirm)
- 深拷贝 requirements;子分支清空路线并回到需求草稿
6.2 刻意未实现
- switch_active_session(切回历史分支原地编辑)
- 父 Session 自动标为 SUPERSEDED
持久化写入顺序(metadata first)将在下一篇中详细讨论。
7. 总结
第一:「换墨尔本看看」不是改几个字段,而是新开一条执行时间线。用户在探索新可能,不是在同一份草稿上覆盖。
第二:Branch 是时间线,不是版本号。Trip 下并列多条 Session,各自有 LangGraph Checkpoint、各自走确认门。
第三:不复制父 LangGraph Checkpoint、不原地覆盖——换来的是可对比、可再 fork;代价是不能切回父分支原地写,只能「从历史再 fork」。
下一篇预告:
《为什么 Agent 系统需要双存储:Business DB 和 Checkpoint 各管什么》
