第二幕 会话调度:从守卫模型到"先判断后回答"
第二幕 会话调度:从守卫模型到"先判断后回答"
大模型没有记忆,上下文是你管的
大模型是无状态的:你给它什么消息,它就基于什么回答,它自己不会"记得"上个月聊了什么。所以 Agent 必须自己管理两样东西:消息树(对话长什么样)和会话调度(什么时候该换一个上下文)。
这一幕讲我在这两件事上踩过的坑,包括一个让我半夜爬起来改代码的 bug:模型每轮对话都在切换上下文,而凶手就是它自己留在上下文里的一条工具调用记录。
消息树:对话是树
第一版设计里,对话就是"消息数组,往里 push"。很快发现不够:用户想编辑一句旧消息、重新生成,那从这句开始应该长出一条新分支,而不是把后面的全删了。于是改成树:
{"id":"a1","parent_id":null,"kind":{"kind":"user","text":"帮我看看这道题"}}
{"id":"b2","parent_id":"a1","kind":{"kind":"assistant","text":"好的,我先看一下"}}
{"id":"c3","parent_id":"b2","kind":{"kind":"user","text":"第二题呢"}}
{"id":"d4","parent_id":"b2","kind":{"kind":"user","text":"重新讲第一题"}}每条消息有 id 和 parent_id,追加式写入 JSONL,永不截断。编辑 b2 会派生出 d4 这样的新分支,GUI 上用 < / > 翻看分支。
模型上下文里只放活跃路径(从根到当前末端的这一条链)。分支是给"人"看的,给模型看的永远是一条清晰的线。
会话:有目标的上下文
一条消息树对应一个会话(Session),会话带着元数据:
pub struct SessionMeta {
pub key: SessionKey, // 内部路由键
pub goal: Option<Goal>, // 会话目标
pub status: SessionStatus, // active / archived
pub active_path: Option<MessageId>,
}Goal 是会话的"一句话任务",比如"批改这份英语作业"。判断要不要换上下文,看的就是新消息和当前 Goal 还相不相干。
调度演进:我推翻了自己两次
第一版:独立守卫模型
最初的想法是搞一个独立的守卫模型:用户发消息时和回合结束时,用一个小模型判断 continue(继续)/ update_goal(改写目标)/ start_new(开新会话)。
pub enum GuardDecision {
Continue,
UpdateGoal(Goal),
StartNew(Goal),
}起步时先用关键词版:消息里出现"报告""新会话"就 start_new。听起来合理,跑起来全是问题:守卫模型只喂了摘要,信息比主模型少,每回合还多花一两次调用,决策质量却更低。我在序章说过"开放题要变选择题",这就是反例,我造了一个比主模型笨的模型来替主模型做决定。
第二版:切换决策全归主模型
想通之后很简单:上下文切换本来就是主模型该判断的事,为什么要外包给一个更笨的模型。
于是守卫模型退役,改成:
- 回合内主动切换:注册一个
session::switch工具(仅模型可见),模型觉得"该换话题了"就自己调; - 回合末收尾:回合结束后,主模型用最近 30 条消息判断 continue / update_goal / start_new;
- 新消息默认继续:不再额外判断,省掉每回合一次调用。
这套跑通了,但用户试用后提了一个更硬的要求:先判断要不要切换上下文,再回答。
第三版:新消息先判断,后回答
问题在于"回合末才判断"太晚了,答案都已经在旧上下文里生成完了。于是调度顺序变成:
用户发消息
→ 空闲超时检查(12 小时没动,直接开新会话)
→ 主模型预决策:continue / update_goal / start_new
· start_new:先切换上下文(归档 + 梗概 + 历史副本),再进新会话回答
· update_goal:先改写当前会话目标,再继续
· continue:继续当前会话
→ 追加用户消息
→ 进入回合回答预决策的输入是"当前目标 + 最近对话 + 新消息",输出严格 JSON。决策失败、超时、解析不出来,一律 continue:存疑即继续,宁可多聊一轮,也不丢上下文。
现在调度有三层兜底:新消息预决策、回合内 session::switch 工具、回合末收尾。发起切换的不再是某个神秘守卫,而是主模型自己。
无感知切换:豆包式体验
切换会话最怕用户感觉"断片了"。所以切换并不清空重来,流程是:
- 旧会话归档,追加一条交接摘要(保留错题 id、知识点、未完成事项);
- 新会话注入"上一会话梗概 + 最近 20 条消息副本";
- 模型上下文和消息树都保留连续历史,上上个会话的内容链式携带,依然可用。
代价是上下文会变长,所以配了两道护栏:切换频率限制(1 小时最多 5 次),以及上下文压缩。用量到窗口的 75% 时,旧消息由模型生成摘要,最近 15 条保留原文,压缩失败下回合再试。
一个 bug:模型每轮都在切换上下文
这是本项目最折腾的一个 bug,值得单独讲。
现象:模型在某轮调用了 session::switch 之后,从此每一轮对话都在切换上下文。你发一句"继续",它先切个新会话再回答;再发一句,又切。会话列表飞快地长。
一开始以为是 prompt 问题,加了一堆"不要频繁切换"的约束,没用。最后看落盘的会话 JSONL 才发现:session::switch 的工具调用被当成普通消息写进了会话树,而切换时新会话会携带旧会话最近 20 条消息,这条"切换记录"就被复制进了新上下文。模型在新上下文里看到自己"调用过 session::switch",于是认为当前会话又该切了,切完再复制一遍,循环永动。
根因是把控制消息和对话内容混在了一起。工具调用结果对模型是"这段对话的一部分",但对消息树来说,session::switch 是一个元动作:它改变的是上下文本身,不该留在上下文里供模型反复"复习"。
修复三条:
session::switch的控制消息不落会话树:落盘时过滤,它的子消息父链重接到切换前最后一条真实消息;- 历史携带也过滤:复制最近消息时跳过切换调用,旧数据也不会污染新会话;
- 切换后的回答归新会话:回合结果带上最终会话键,消息落盘、活跃路径、回合收尾都写新会话,不再写回已归档的旧会话。
修复后真实链路验证:强制切换一次,新会话里没有切换记录,下一轮"继续"不再切换,会话数保持不变。
这一幕的教训
- 对话是树,上下文是一条活跃路径;
- 会话切换的决策权最终全部收归主模型(预决策 + 回合内工具 + 回合末收尾),失败一律继续;
- 切换要做到无感知:摘要 + 历史副本,而不是清空;
- 控制消息和对话内容必须分开。Agent 的元动作,不能污染它自己的上下文。
下一幕讲模型层:双模型接入、真实 API 优先的测试观、重试哲学,以及余额和缓存命中率这两个我拿真钱测出来的数字。
