M10.1 · 对话历史持久化与多轮续聊#
面试向开发文档:讲「为什么这么做、取舍在哪」,不写函数签名与代码细节。 这是 M10(FastAPI + 前端闭环)之上的一个增量,配合
CLAUDE.md §2.2、docs/milestones/M10-*阅读。
一句话#
项目原本不记录历史对话:每次 POST /api/task 都是一次性的,thread_id 是「一次任务」而非
「一段对话」,任务跑完整条消息流就被丢弃。这个增量把 thread_id 升级成「一段会话」的标识——
同一个 thread 复用即接着上文聊,并把每段对话落盘,刷新/重开页面还能回看。难点不在代码量,
而在**「续聊要回喂什么」这个设计取舍**,以及如何在不引入新基础设施的前提下做到。
背景:缺的是「会话」这一层语义#
M9/M10 把链路焊通了,但它的记忆是割裂的两端:
- 长期记忆(M7 的 Store)只沉淀「跨会话的偏好结论」(「不要塑料」),按用户聚合、只在识别到 新偏好时写。它解决的是「下次新会话还记得你的口味」。
- 单轮上下文则随任务结束被丢弃。所以同一个 thread 里,你问完「旅行包」再追问「上一个再便宜 点的」,Agent 根本不知道「上一个」指什么——因为它每次都从空白开局。
中间缺的正是「一段对话」这层语义:多轮之间的承接。这个增量补的就是它。
一条红线:不挂 checkpointer#
最容易想到的方案是给 LangGraph 挂 checkpointer(Redis/Postgres),让 thread 状态自动持久化、
自动恢复。我没有走这条路,因为它会越过 CLAUDE.md §2.2 明确划下的范围线。
checkpointer 解决的是「跨进程恢复 / 中途 interrupt 续跑」——任务跑一半进程挂了能从断点接上、
human-in-the-loop 能暂停再续。本项目是「一请求一进程内 async task,跑到完成或取消」,没有这种
需求。为它挂 checkpointer,只会平添 Redis/Postgres 依赖和一套不消费的持久化语义。
而「多轮续聊」和「中途恢复」是两件不同的事:续聊只需要「上一轮聊了什么、给了什么结论」, 不需要回放上一轮的执行状态。所以我用一套轻量落盘做掉,把那条红线留着——将来真要可恢复任务, 再补 checkpointer(YAGNI)。
关键决策与取舍#
1. 续聊只回喂「结论文案」,不回放整条工具轨迹#
这是整个增量最该讲的取舍。续聊时要把多少历史塞回模型?两个极端:
- 回放整条轨迹(每一轮的 planner→fork→item_search→比价→…全部消息):信息最全,但有两个硬伤。
一是按轮数膨胀 token,直接和 M6 的上下文压缩对着干——压缩好不容易把单轮压下去,续聊又把
历史全量灌回来。二是脆弱:模型 API 要求
tool_call必须紧跟对应的tool_message,历史里任何 一处配对断裂(截断、序列化丢字段)都会让请求直接报错。 - 只回喂逐轮结论(每轮压缩成一对
user 问题 → assistant 最终答复):token 省、且天然不含 孤立的 tool_call(没有配对问题),稳。
我选了后者。代价我在代码和前面的讨论里都诚实标注了:模型续聊时看不到上一轮检索过的具体候选, 只知道「聊过什么、给了什么结论」。所以「上一个再便宜点的版本」这类追问会触发重查,而非在上次 候选集里精确定位那一件。对购物对话场景,这个精度够用;若将来要支持「在上次候选里二次精挑」,得把 上一轮的候选 id 也一并回喂——那是另一档功能,现在不做。
一句话:续聊回喂的是「记忆的结论」,不是「记忆的过程」。这跟 M7 长期记忆「只存结论性偏好、 不存几万 token 历史」是同一套信息密度哲学。
2. 两份产物,各司其职#
落盘落两份,刻意分开:
- 逐轮精简对(
turns.json):累加写,是「续聊回喂」和「前端回看」的唯一数据源。体积小、 只有文本结论。 - 完整消息轨迹(
history.json):覆盖式写最近一次 run 的全量消息(含工具调用/观察),只供 深度审计/排障,不参与续聊上下文。
为什么不合成一份?因为这两个用途的「保真度需求」相反:续聊要轻(越省越好),审计要全 (越细越好)。强行合并必然有一方将就。分开后各自最优,且续聊路径完全不被审计数据拖累。
完整轨迹落盘有个序列化坑:终结工具的结构化结果(ShoppingSummaryOutput)以自定义对象挂在消息上,
标准 JSON 不认。我给了个兜底——这类对象降级成普通字段、实在不认的退成字符串,宁可某字段降级
也不让整次落盘抛异常。审计产物的容错优先级低于「别崩」。
3. 容错:历史是附带产物,绝不反噬主链路#
这条沿用项目一以贯之的「降级不崩」口径(和 M7 Store、召回层后端挂掉同一套):turns.json 损坏、
单条结构异常、写盘失败——任一环出问题都只记日志降级。续聊退化为「从空开局」、回看返回空列表,
但主任务该跑还跑,偏好写回、结果上报一律照常。单条坏掉就跳过那条、保住其余完好的轮次,不连坐整段
对话。理由很简单:对话历史是锦上添花,不该有能力拖垮一次正经的购物任务。
4. 前端:会话级 threadId + 复用 connect-first#
前端原本是单轮模型——每次发问都新生成一个 thread_id、清空界面,根本没有「续」的概念。改动有两处:
- threadId 从「一次任务」升级成「一段会话」:首次发言时生成、存进浏览器
localStorage,之后 每一轮复用同一个 thread_id 发任务。后端据此回喂上文,「接着聊」就成立了。点「新建对话」才换 新 thread_id。 - 多轮渲染:界面从「一问一答」变成「一串问答」,实时事件只更新最后一轮(同一时刻只有一个 任务在跑,末轮即活动轮),历史轮冻结展示。
关键是:这两处改动完全复用 M10 的 connect-first 握手,一行没动。续聊的每一轮仍是「本地有 thread_id → 连 WS → 收 ws_ready → 发任务」,只不过 thread_id 这次是复用的而非新生成的。把会话语义 叠加在既有握手上,而不是另起一套——改动做在了最浅的正确深度。
5. 回看:刷新也能接着聊,但历史轮是「纯文本」#
threadId 存了 localStorage,所以刷新/重开页面后,前端能凭它去读 GET /api/history(M10 那套
接口的第七个),把对话流重建出来、继续聊。
但这里有个由设计取舍直接决定的诚实标注:回看重建的历史轮只有文本气泡,没有活动流、没有
商品卡。因为续聊的源(turns.json)只存了结论文案——这正是决策 1 的代价在 UI 上的投影。当前正在
进行的那一轮(live)才有完整事件流和商品卡。这不是 bug,是「续聊只回喂结论」这个选择的一致结果。
补充(后续增强):上面说的是「刷新时那一轮已收尾」——只能拉历史、看纯文本。但若刷新时那一轮 还在后台跑,前端不再只是「拉历史」:它会探一下该会话是否仍在跑,在跑就重建出正在跑的那一轮、 带完整事件流接着直播;切到别的对话也不再取消在跑任务,切回来照样续看。这条「冷启动续看」是叠加在 ENH-D 事件回放 那套基础设施上的,详见其 §5。
6. 产物下载只挂最后一轮#
会话产物(summary.md/result.json)按 thread 覆盖式写,盘上永远只有最新一轮的版本是当前的。
所以下载入口只挂在最后一轮——否则在历史轮点下载,拿到的会是新一轮的文件,张冠李戴。若要每轮
都能下载,得后端把产物改成分轮存(summary-<n>.md),那是另一档活,现在不做。
安全:复用既有防线,不重造#
新增的读接口 GET /api/history/{thread_id} 里,thread_id 同样是用户可控输入,复用 M10 那道
safe_join 校验(项目硬约束「所有用户可控路径片段都得 safe_join」)。这里没有新写防穿越逻辑,
而是接到既有防线上。一个边角诚实标注:单段路径参数里的分隔符过不了 Web 框架的路由(编码斜杠会
404 而非进 handler),所以 safe_join 在这条路由上更多是纵深防御,真正能触发它的是 .. 这类
不含分隔符的逃逸。
后续增强(已落地)#
- token 用量落盘回看:
append_turn新增tokens参数(input / output / total / cost_usd),随每轮 assistant 落进turns.json;_load_turns_raw读取时还原tokens字段。前端在回看的历史轮右下角也能显示「token 消耗」(与实时一致的后端权威口径)。 - 回看历史轮的思考过程 + 商品卡:
turns.json原本只存结论文案,现在activity(思考过程 AGUI 事件流)和items(精选商品卡)也一并落盘,历史轮的回看能展开思考过程折叠区 + 渲染商品卡,不再是纯文本——这修掉了决策 5 标注的那个「回看只有文本气泡」的限制。 - 冷启动续看:见上方 §5 补充说明——刷新 / 切回对话时若任务仍在跑,自动续看。
没做的(诚实标注)#
- 跨进程恢复 / 中途续跑 / checkpointer:见「一条红线」,明确划在范围外。续聊 ≠ 恢复。
- 回放工具轨迹的精确续聊:续聊看不到上一轮的候选明细,「在上次结果里二次精挑」这类追问做不到, 需要回喂候选 id,未做(决策 1 的代价)。
回看历史轮的事件流/商品卡:已落地——activity/items 随 turns.json 落盘,回看能还原。- 分轮产物下载:产物覆盖式写,只有最新一轮可下载(决策 6)。
- 真 LLM 端到端验证:续聊的承接逻辑用「桩 agent + 断言第二轮开局确实带上了上一轮」的确定性 测试验过(不依赖真实模型),后端 read 接口、前端构建也都过;但「起真后端发一条→追问一条→肉眼 看模型接住上文」这种真 LLM 的端到端 demo 没跑(要烧 token)。等需要时再补。
一句话总结#
这个增量补的不是「一个新功能」,而是「会话」这层一直缺失的语义:让 thread_id 从一次性的任务
票据,变成一段能承接、能回看、能持久的对话。而它最有价值的部分,恰恰是那个克制的取舍——续聊
只回喂结论、不回放过程,用最轻的代价拿到「接着聊」,把「可恢复任务」那条重依赖的线,继续留在范围外。