#AI Agent#TypeScript

多轮对话的状态管理

单轮好做,多轮难——用户会追问、改主意、引用上文、打断。本文讲清多轮对话的工程:上下文管理(窗口/摘要/压缩)、对话状态(槽位/实体/阶段)、指代消解、话题切换、中断与恢复,把"聊着聊着就乱了"治好。

本文要点

  • 主题聚焦:AI Agent、TypeScript
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2025年11月25日·更新于 2026年7月23日

单轮对话是函数调用:一进一出。多轮对话是状态机:用户会追问、会改主意、会说"它的价格呢"引用上文、会中途切话题又切回来。绝大多数 Agent demo 死在第二轮——上下文一长就糊、指代一解就错、话题一换就丢。问题不在模型,在你没把"对话状态"当一回事。

一、多轮到底难在哪

把多轮的复杂性拆开看,其实是五个叠加的子问题:

  • 上下文累积:每轮都往历史里塞东西,窗口会爆,token 会烧,还会触发 lost-in-the-middle——中间的信息模型根本没在看。
  • 指代(anaphora):"它的价格""那个文件""刚才说的那个"——代词指向什么,模型靠猜,猜错整条链就错。
  • 话题漂移:用户从订机票聊到改酒店再到问天气,你如果还抱着"订机票"的状态机不放,回答全乱套。
  • 用户改主意:"不,日期改成周五"——之前的确认要作废,槽位要回滚,而不是叠加。
  • 中断与恢复:用户切走去干别的,几小时后回来"继续刚才那个",你得能接上。

单轮把这些都藏起来了。多轮把它们全暴露。治本的办法只有一个:把"上下文"(给模型看的自然语言)和"对话状态"(机器可读的结构化事实)分开管。 前者负责让模型能说话,后者负责让对话不乱。

二、上下文管理:窗口、摘要、结构化抽取

先说给模型看的上下文怎么管。核心原则:关键事实存 state,不靠历史。 历史只是为了让语气连贯,不是真相的来源。

一套生产里好用的策略是"滑动窗口 + 滚动摘要 + 结构化状态"三件套:

typescript
interface TurnContext {
  // 1. 稳定区:system prompt + 当前任务定义(放最前,优先级最高)
  system: string
  // 2. 摘要区:更早的对话压成一段,丢细节保脉络
  summary: string
  // 3. 结构化状态区:已确认的事实——这才是真相源
  state: ConversationState
  // 4. 滑动窗口:最近 N 轮原文(N 通常 4~8)
  recentMessages: Message[]
  // 5. 当前输入
  userMessage: string
}

async function buildTurnContext(session: Session, userMessage: string): Promise<TurnContext> {
  // 历史超过窗口阈值,触发滚动摘要:把最早的 K 轮压进 summary
  if (session.messages.length > WINDOW_SIZE) {
    session.summary = await summarize(session.summary, session.messages.splice(0, COMPRESS_BATCH))
    // 顺手从被压缩的轮次里抽取关键事实,写进 state——别让事实跟着原文一起消失
    await extractFactsIntoState(session)
  }
  return { system: session.system, summary: session.summary, state: session.state,
           recentMessages: session.messages.slice(-WINDOW_SIZE), userMessage }
}

三个压缩手段,强度从弱到强:

  • 滑动窗口:最近几轮留原文,保证语气连贯。最便宜,但也最浅。
  • 摘要:把旧对话压成一段自然语言,保留"聊到哪了"的脉络。注意摘要会丢细节,所以摘要前必须把可结构化的事实先抽出来。
  • 结构化状态抽取:把"用户要去上海,周五出发,经济舱"这类关键事实抽成 JSON 存进 state。这才是抗压缩的真相源——摘要可能写错,但 state 里的槽位是明确的。

经验:别一上来就上最贵的压缩。 多数对话 6 轮内,纯滑动窗口就够;超过 10 轮才需要摘要;而状态抽取是无论几轮都该有的,因为它解决的不是长度问题,是"可靠记住了什么"的问题。

三、对话状态:槽位、实体、阶段

对话状态(state)是多轮的灵魂。它至少要回答三个问题:现在在干嘛(阶段)、收集到什么(槽位/实体)、谁是被谈论的对象(实体栈)。

typescript
interface ConversationState {
  // 当前在哪个阶段:闲聊 / 收集信息 / 澄清 / 执行 / 确认
  phase: 'chitchat' | 'collecting' | 'clarifying' | 'executing' | 'confirming'

  // 任务型:槽位填充。每个槽位带值、置信度、是否已确认
  activeTask: {
    type: string                              // 'book_flight' | 'order_refund' ...
    slots: Record<string, Slot>
    requiredSlots: string[]
  } | null

  // 最近提及的实体栈——指代消解靠它
  entityStack: Entity[]

  // 跨话题的稳定事实(用户身份、偏好、长期约束)
  beliefs: Record<string, { value: unknown; source: 'user' | 'inferred'; updatedAt: number }>

  // 挂起的任务栈——中断时压栈,恢复时弹栈
  suspendedTasks: SuspendedTask[]
}

interface Slot {
  value: unknown
  rawText: string          // 原文,方便回显和 debug
  confidence: number       // 0~1,低置信度要主动澄清
  confirmed: boolean       // 模糊抽取的值要二次确认
  sourceTurn: number
}

槽位填充是任务型对话的骨架。要点有二:一是带置信度——"周五"是从"这周五吧,哦不,下周五"里抽的,置信度就低,低就该进 clarifying 阶段主动问一句,而不是直接执行。二是支持回滚——用户改主意不是"追加一个日期",而是"覆盖原日期"。槽位是 Map 不是 List,改主意就是赋值。

阶段(phase)决定这轮的诉求。clarifying 时别急着调工具,先把歧义问清楚;confirming 时只接受 yes/no;chitchat 时别强行拉回任务。很多"Agent 自说自话"的 bug,根因是阶段判断错了——用户在闲聊,它还在追问必填槽位。

四、指代消解:"它的价格"到底指谁

指代是多轮最容易翻车的地方。用户说"它的价格呢",这个"它"可能是上文第一个商品,也可能是最近提到的。模型自己解,常常解错,而且错了你很难事后发现。

两种解法,工程上推荐组合用:

显式 state(主):维护 entityStack,每次用户提到或系统产出实体就 push。解指代时,优先用 state 里"最近一个且类型匹配"的实体。这是确定的、可测的。

LLM 改写(辅):对用户输入做一次"指代消解改写"——把"它的价格呢"基于上文重写成"iPhone 15 的价格呢",再走后续逻辑。适合处理复杂、多跳的指代。

typescript
async function resolveCoreference(input: string, state: ConversationState): Promise<string> {
  // 命中代词才走改写,省 token
  if (!/他|她|它|那个|这个|刚才/.test(input)) return input

  // 1. 先试显式:entityStack 顶部的同类型实体
  const topEntity = state.entityStack.at(-1)
  if (topEntity && looksLikeReferenceTo(input, topEntity)) {
    return input.replace(/(它|那个|这个)/g, topEntity.name)
  }

  // 2. 兜底:让小模型带上最近 3 轮 + entityStack 做改写
  return await rewriteWithModel(input, {
    recentTurns: state.recentMessages.slice(-3),
    candidates: state.entityStack.slice(-3),
  })
}

关键是 entityStack 要显式维护:槽位填充时把目标实体 push,工具返回结果时把产出的实体 push,话题切换时该清的清。指代消解不是 NLP 问题,是状态管理问题——state 里没存,模型就只能靠历史猜,猜错率随轮数上升。

五、话题切换与打断:压栈与弹栈

用户聊着订机票,突然问"上海明天天气怎么样",这是打断。处理得不好有两种灾难:一是"硬扛"——还在机票槽位里转,把天气塞进日期槽;二是"失忆"——天气答完了,机票任务也丢了。

正确做法是任务栈:

typescript
async function onTopicSwitch(detected: boolean, state: ConversationState, newIntent: string) {
  if (!detected) return
  // 当前任务还没完 → 压栈挂起,带上当时的槽位快照
  if (state.activeTask && !isTaskComplete(state.activeTask)) {
    state.suspendedTasks.push({
      task: clone(state.activeTask),
      suspendedAt: Date.now(),
      summary: '用户去问天气了',
    })
  }
  // 开启新任务(天气可能是临时闲聊,不建槽位)
  state.activeTask = newIntent === 'weather' ? null : createTask(newIntent)
  state.phase = newIntent === 'weather' ? 'chitchat' : 'collecting'
}

// 检测到用户说"继续订机票""回到刚才那个"
function resumeSuspended(state: ConversationState, keyword: string) {
  const hit = state.suspendedTasks.pop()
  if (hit) { state.activeTask = hit.task; state.phase = 'collecting' }
}

话题切换的检测本身可以是一轮轻量分类(或规则:"对了""换个话题""顺便问下"是切换信号)。挂起时务必带快照——否则恢复回来槽位是空的,等于重开。栈深度做个上限(2~3),太深的嵌套用户自己也记不住,主动澄清比硬恢复好。

六、工程化:每轮带什么、token 怎么控、跨会话怎么接

落地时几个务实决策:

每轮上下文的装配顺序(对应 lost-in-the-middle):稳定指令在最前 → 结构化 state → 摘要 → 最近原文 → 用户输入在最后。重要的信息放两头,state 放前面而不是埋在历史里。

token 预算要分桶:给 system/state 留固定额度(比如 30%),给历史留动态额度,给检索/工具结果留额度。历史超预算就触发摘要压缩,而不是无脑截断——截断可能正好砍掉关键轮。

跨会话记忆:会话级状态随会话结束就该清,但 beliefs 里那些稳定事实(用户身份、长期偏好)要写进长期记忆库,下次新会话开场就召回进 state。这部分和《Agent 的记忆系统设计》那篇的长期记忆接上——多轮对话的 state 是短期记忆,长期记忆是它的"前情提要"。别把整段历史塞进长期记忆,只存抽出来的结构化事实。

幂等与回放:把 state 的每次变更记成事件日志(event sourcing 思路),好处是能 debug("它为什么把日期改了")、能回放、能在崩溃后从 checkpoint 恢复。生产环境里对话状态比对话文本更值钱,要落库。

七、失败模式:这些坑一定会踩

最后是几个高频翻车现场,提前认识能少走弯路:

  • 上下文越长越迷糊:不是模型笨,是注意力被稀释了。监控"长会话的回答质量"曲线,一旦掉头向下,说明你的压缩策略没跟上。
  • 关键信息被冲掉:用户第 2 轮说的约束,到第 8 轮被新内容挤出了 state/摘要。对抗办法就是结构化 state——约束进 state 就不会被自然语言历史稀释。
  • 指代解错,且静默:用户说"删了它",模型解错了对象,真去删了另一个。高破坏性操作前必须二次确认,且确认时把消解后的实体明文回显("确认删除 iPhone 15 的订单?"),让用户有机会发现解错。
  • 改主意被当成追加:槽位该覆盖时被 append。解法前面说过——槽位是赋值语义,且每次写入记 sourceTurn,便于追溯。
  • 话题切换检测过敏:用户每句话都被当成切话题,任务栈切来切去。给切换信号设阈值,优先按"当前任务的必填槽位是否还被关心"来判断,而不是靠几个关键词。

小结

  • 上下文和状态要分家:上下文(自然语言)是给模型看的,状态(结构化)是真相源。关键事实进 state,别依赖历史。
  • 压缩三件套按需上:滑动窗口保语气,摘要保脉络,状态抽取保可靠。状态抽取不是为省 token,是为"确定地记住了什么"。
  • 指代是状态问题不是 NLP 问题:维护 entityStack,显式解,小模型改写兜底,高破坏操作前回显确认。
  • 话题切换用栈:压栈带快照,弹栈可恢复,栈别太深,深了就主动澄清。
  • 跨会话只存结构化事实:长期记忆是 state 的前情提要,不是聊天记录备份。

多轮对话的工程本质就一句话:单轮靠模型,多轮靠状态。 模型负责这一轮怎么说话,状态管理负责这一轮别出错。

Related

相关推荐