对话断层最直接的表现是模型在长对话中反复询问已经回答过的问题,或者把早期约束条件悄悄丢掉。很多人会先想到扩大上下文窗口,但窗口变大并不能保证模型稳定提取关键信息,尤其在客户咨询、预订、售后等场景中,用户表达往往散落在多轮里。要真正解决问题,需要把对话管理从单纯拼接历史,升级为状态跟踪加摘要的双层结构。

先定位断层:上下文窗口不是越大越可靠
上下文断层并不只是在 token 超出限制时才会发生。即使完整历史都塞进了上下文窗口,大模型仍然可能在长文本里忽略中间位置的约束,这是注意力机制在长序列上的天然弱点。比如用户在第一轮说预算不超过一千二,中间聊了房型、早餐和停车,到第八轮模型推荐了一间一千八的房间,这不是它读不到第一轮,而是长历史中无关信息稀释了有效约束。
更大的上下文还会带来两个副作用:推理延迟上升,以及每轮 token 成本成倍增加。很多对话系统因此采用滑动窗口,只保留最近几轮,但滑动窗口会直接丢掉早期关键信息,比如用户最开始强调的无烟房偏好。要兼顾成本和稳定性,就不能只依赖原始历史,必须让系统先提炼状态,再决定哪些内容进入上下文。
一个可落地的判断标准是:如果某个字段会影响后续决策,它就应该进入状态;如果某段历史只是背景信息,它就可以被摘要压缩。状态和摘要各司其职,上下文窗口里放的是精加工后的信息,而不是不断增长的原始日志。
对话状态跟踪:把口头信息变成结构化字段
对话状态跟踪最早来自任务型对话系统,核心是用一组槽位描述用户当前意图和约束。比如酒店预订场景可以维护目的地、入住日期、离店日期、预算、人数、房型等字段。每轮用户输入到达后,系统读取现有状态,再根据新消息更新槽位。这里的关键是增量更新:用户说再便宜一点,系统应该降低预算字段,而不是重新生成整个状态。
基于大模型实现状态跟踪时,可以直接用结构化输出能力。下面是一个简化的 Python 示例,展示如何让模型只更新变化字段,同时保留已有值。
from typing import Optional
from pydantic import BaseModel
class BookingState(BaseModel):
destination: Optional[str] = None
check_in: Optional[str] = None
check_out: Optional[str] = None
budget: Optional[int] = None
guests: int = 1
non_smoking: bool = False
PROMPT = """
你是对话状态跟踪器。根据当前状态和用户新消息,只更新用户明确改变的字段。
不要覆盖用户没有提到的非空字段。输出 JSON。
当前状态:{state}
用户消息:{message}
"""
def update_state(state: BookingState, message: str, llm) -> BookingState:
text = PROMPT.format(state=state.model_dump_json(), message=message)
result = llm.complete_json(text)
new = BookingState.model_validate(result)
merged = state.model_copy(update={k: v for k, v in new.model_dump().items() if v is not None})
return merged
这段代码里,PROMPT 明确要求模型做增量更新,返回 JSON 后再合并回旧状态。实际系统还可以加一层校验,例如日期格式是否合法、预算是否为正数。状态跟踪的价值不在于把每句话都记下来,而在于把散落在多轮里的信息收敛成可查询、可校验的字段。
如果对话场景比较复杂,还可以把状态拆成多个子状态,比如订单信息、用户画像、当前流程节点。这样不同模块可以独立更新,减少单次结构化的字段数量,也能降低模型写错字段的概率。
对话摘要:压缩历史而不是简单截断
状态字段能解决强约束丢失的问题,但对话中还有一些无法完全结构化的信息,例如用户解释过自己为什么需要提前入住、之前客服承诺过什么。这些内容如果直接丢弃,后续回答会显得生硬。摘要的作用就是把较早的原始对话压缩成一小段自然语言,保留事实和约束,同时把篇幅降下来。
最简单的做法是固定滑动窗口加前置摘要:保留最近四到六轮原文,更早的内容交给模型生成一段摘要。这样模型在每一轮都能看到近期细节和更早背景。示例逻辑如下。
def build_context(history, state, llm, recent_count=6):
recent = history[-recent_count:]
older = history[:-recent_count]
context = [{"role": "system", "content": f"结构化状态:{state}"}]
if older:
summary = llm.summarize(older, instruction="保留用户偏好、承诺、未解决问题。")
context.append({"role": "system", "content": f"早期对话摘要:{summary}"})
context.extend(recent)
return context
这里的状态和摘要被放在系统消息中,作为每轮调用的固定前缀。近期原文保留了最新对话的措辞和细节,早期摘要则避免了 token 无限增长。更进一步的层次化摘要可以在每次触发压缩时,把旧摘要和新内容合并成新摘要,这样摘要本身也不会越积越长。
摘要策略需要根据场景调整。客服场景可能更强调未解决问题和承诺;教育辅导场景则要保留学生已经掌握的知识点。通用摘要指令容易丢失关键细节,最好给模型一个明确的提取目标,例如保留时间、金额、特殊要求、用户情绪变化。
状态跟踪与摘要的工程整合
两类信息不能孤立使用。状态跟踪的更新结果会影响摘要内容,摘要中的事实也可以反过来纠正状态。例如模型在生成摘要时发现用户曾说过需要两张床,但当前状态里房型字段还是大床房,这时可以触发一次校验,把摘要里的冲突信息回填给状态模块。实际工程中通常设置一个小的冲突检测步骤,避免两套信息长期不一致。
在每轮对话开始时,推荐按固定顺序组装上下文:先放系统指令,再放结构化状态,然后放早期摘要,最后放最近几轮原文。这个顺序能让模型先建立决策约束,再阅读具体对话。如果直接把摘要放在最后,模型可能只关注最近的摘要,忽略状态字段。
还需要控制更新频率。每轮都更新状态和摘要会增加延迟,可以在用户消息出现明显新信息时才触发状态更新,比如检测到数字、日期、否定词或新的意图词。摘要也不必每轮重写,可以等早期历史积累到一定长度后再压缩一次。这样可以显著降低 API 调用成本,同时保持对话流畅。
综上所述,对话断层不是单靠扩大窗口就能消除的。把状态跟踪和摘要机制嵌入到上下文组装流程里,模型才能在长对话中既记得住强约束,又不会被原始历史拖垮。