多轮对话的前端状态机:上下文窗口与轮次的工程化治理
一、对话失控现场:当多轮上下文在前端堆成幽灵
大模型应用前端有一个高频踩坑点:把对话状态散落在 useEffect 与若干 useState 里,看起来能跑,实际状态机是隐式的。用户连点发送、切换会话、网络抖动断流,任何一个都能让界面进入未定义状态。
典型失控现场有三类。第一类是流叠加,上一轮流式输出还没结束,用户又点了发送,旧的 onmessage 回调仍在写状态,两条流交错渲染,界面出现乱码拼接。第二类是幽灵轮次,用户切走会话再切回,旧的 fetch 回调 tardy 返回,把陈旧回答写到当前会话上。第三类是上下文爆炸,前端把每轮历史原样塞进 messages,不裁剪不核算,token 账单随轮次线性上涨,超过模型窗口直接报 400。
这三个现场根子是一个:对话生命周期没有被显式建模。状态散落意味着非法迁移无人拦截,上下文无管理意味着请求载荷失控。治理方向也对应两条:用有限状态机把轮次管起来,用上下文窗口策略把载荷压下来。本文聚焦这两条链路的工程化落地。
二、状态机建模与上下文窗口的裁剪链路
对话前端的生命周期可以抽象成一个有限状态机。每个状态有明确的入口动作与可接受事件,非法迁移被守卫直接拒绝。下面的框图描述了主要状态流转。
┌──────────────────────────────────────┐
▼ │
┌────────┐ send ┌─────────┐ first chunk ┌───────────┐
│ idle │────────▶│ asking │─────────────▶│ streaming │
└────────┘ └─────────┘ └───────────┘
▲ │ │ │ │
│ abort │ │ err │ │ done
│ │ ▼ │ │
│ │ ┌────────┐ backoff │ │
│ │ │ retry │──────────▶ asking
│ │ └────────┘ │ │
└───────── abort ───┴───────── abort ─────────┘
状态语义与守卫如下表,非法事件一律忽略并记录告警。
| idle | send | 发起请求,挂载 AbortController | asking |
| asking | first-chunk / abort / err | 建流 / 取消 / 进入退避 | streaming / idle / retry |
| streaming | chunk / done / abort / err | 追加缓冲 / 提交并收尾 / 取消 / 报错 | streaming / idle / idle / retry |
| retry | timer-fire | 重发请求,复用原载荷 | asking |
状态机的价值在于把"能不能发"这件事从隐式判断变成显式守卫:非 idle 状态的 send 直接拒绝,从根上杜绝流叠加。abort 不只是取消网络请求,还要让流的尾回调写状态时发现"我已不是当前轮次"而自我丢弃。
上下文窗口的裁剪是第二条链路。模型输入有 max_input_tokens 上限,前端若把全量历史原样发送,迟早超限。裁剪链路如下。
原始历史(messages)
│
▼
[1] token 预算核算 ── 超预算 ──▶ [2] 滑动窗口截断(保留最近 N 轮)
│ │
│ ▼
│ [3] 旧轮次摘要压缩(LLM 二次调用)
│ │
▼ ▼
[4] 系统提示 + 摘要 + 近期轮次 ──▶ [5] 请求载荷(校验未超窗口) ──▶ 发送
token 估算在前端没有完美方案。tiktoken 在 WASM 下可用但体积大,多数场景用字符近似:英文约 4 字符/token,中文约 1.5 字符/token。近似有偏差,所以第 5 步必须做硬上限校验,超限则回退到更激进的裁剪。裁剪策略不是无脑截断,要保留三类信息:系统提示、最近 N 轮(上下文连续性)、关键约束轮次(用户明确的目标陈述)。摘要压缩引入二次 LLM 调用,这是成本与质量的权衡,后文边界分析会展开。
三、生产级对话状态机与上下文压缩实现
下面是一段 TypeScript 实现,包含状态机、带退避的重试、Abort 取消与竞态防护,以及上下文预算裁剪。
// 对话状态机的状态与事件定义
type ChatState = 'idle' | 'asking' | 'streaming' | 'retry';
type ChatEvent =
| { type: 'send'; payload: string }
| { type: 'first-chunk' }
| { type: 'chunk'; text: string }
| { type: 'done' }
| { type: 'error'; err: unknown }
| { type: 'abort' };
// 轮次 ID 用于竞态防护:旧流回调写状态前先比对轮次
interface TurnContext {
turnId: number; // 单调递增,每次 send 自增
controller: AbortController; // 支持中途取消,释放网络与回调
}
class ChatStateMachine {
private state: ChatState = 'idle';
private turn: TurnContext | null = null;
private turnId = 0;
// 退避参数,429/5xx 时指数退避,避免雪崩打挂下游
private readonly maxRetry = 3;
private readonly baseDelay = 500;
// 发送:唯一入口,非 idle 直接拒绝,杜绝流叠加
async send(text: string, onChunk: (t: string) => void): Promise<void> {
if (this.state !== 'idle') {
throw new Error(`非法迁移:当前状态 ${this.state} 不允许 send`);
}
const turnId = ++this.turnId;
const controller = new AbortController();
this.turn = { turnId, controller };
this.state = 'asking';
await this.runStream(text, turnId, controller, onChunk);
}
// 内部:发起流并处理退避,turnId 不符则放弃写入
private async runStream(
text: string,
turnId: number,
controller: AbortController,
onChunk: (t: string) => void,
attempt = 0,
): Promise<void> {
try {
const res = await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ text }),
signal: controller.signal,
headers: { 'Content-Type': 'application/json' },
});
// 限流与服务端错误走退避,4xx 直接终止
if (res.status === 429 || res.status >= 500) {
throw new RetryableError(`HTTP ${res.status}`);
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const reader = res.body!.getReader();
const dec = new TextDecoder();
this.state = 'streaming';
for (;;) {
const { done, value } = await reader.read();
if (done) break;
// 竞态防护:轮次已切换则丢弃本次回调,防幽灵轮次污染
if (this.turn?.turnId !== turnId) return;
onChunk(dec.decode(value, { stream: true }));
}
this.state = 'idle';
} catch (err) {
// abort 不是错误,静默回到 idle
if (controller.signal.aborted) { this.state = 'idle'; return; }
if (err instanceof RetryableError && attempt < this.maxRetry) {
this.state = 'retry';
const delay = this.baseDelay * 2 ** attempt; // 指数退避
await sleep(delay);
if (this.turn?.turnId !== turnId) return; // 退避期间被取消
this.state = 'asking';
return this.runStream(text, turnId, controller, onChunk, attempt + 1);
}
this.state = 'idle';
throw err; // 不可重试错误向上抛,交调用方处理 UI
} finally {
// 释放资源,轮次仍为本次才清理,避免误清新轮次
if (this.turn?.turnId === turnId) this.turn = null;
}
}
// 取消:用户切走或手动停止,主动 abort 并回 idle
abort(): void {
this.turn?.controller.abort();
this.state = 'idle';
}
}
class RetryableError extends Error {}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
配套的上下文预算裁剪,把 messages 压到窗口内。
interface Msg { role: 'system' | 'user' | 'assistant'; content: string }
// token 近似估算:英文 4 字符/token,中文 1.5 字符/token,混合按比例加权
function estimateTokens(text: string): number {
const cjk = (text.match(/[\\u4e00-\\u9fff]/g) || []).length;
const other = text.length – cjk;
return Math.ceil(cjk / 1.5 + other / 4);
}
// 预算裁剪:保留 system + 最近 N 轮,旧轮次超预算则整体摘要
function fitToWindow(
messages: Msg[],
budget: number,
keepRecent = 6,
): Msg[] {
const sys = messages.filter((m) => m.role === 'system');
const rest = messages.filter((m) => m.role !== 'system');
const recent = rest.slice(-keepRecent * 2); // 一轮 = user+assistant 两条
const draft = […sys, …recent];
const used = draft.reduce((s, m) => s + estimateTokens(m.content), 0);
// 未超预算直接放行
if (used <= budget) return draft;
// 超预算:旧轮次(recent 之前)交后端摘要,前端只标记摘要占位
const summary: Msg = {
role: 'system',
content: '【历史摘要占位】由后端对更早轮次做压缩,前端不持有原文。',
};
return […sys, summary, …recent];
}
这段实现的关键契约有三条。其一,send 是唯一入口且仅 idle 可调用,从入口拦截流叠加。其二,每次自增 turnId,流的回调写入前比对轮次,杜绝幽灵轮次污染当前会话。其三,abort 不抛错而是静默回 idle,因为取消是正常用户行为而非错误;而 429/5xx 走指数退避,4xx 直接抛出交 UI 处理。生产中 fetch 还应配超时(用 AbortSignal.timeout 组合 controller.signal),避免长时间挂起。摘要压缩建议放后端,前端只持有摘要占位与最近轮次原文,降低敏感数据留存面。
四、状态机的代价:复杂度、内存与可调试性边界
状态机不是免费午餐。第一个代价是复杂度,引入状态机意味着团队要学习状态词汇与迁移规则,简单单轮问答场景套这套是过度设计,徒增心智负担。判断标准是轮次数量与并发可能性:多轮、可中断、可切会话才值得建模。
第二个代价在上下文裁剪本身。滑动窗口截断会丢历史,模型可能"忘记"早期约束而答偏。摘要压缩虽能缓解,但摘要本身是 LLM 二次调用,既加延迟又加成本,且摘要有信息损失,关键细节可能在压缩中被抹平。token 估算用字符近似有偏差,尤其代码块与特殊符号,估算偏低仍会触发窗口超限,所以硬上限校验不可省。
第三个代价是内存与隐私。前端持有全量历史方便回看,但敏感对话(医疗、金融)原样留在浏览器内存与 IndexedDB 有合规风险,应按场景做留存策略或干脆只留摘要。竞态防护靠 turnId 比对,但若异步链路里有未走状态机的旁路写入(如直接调 setState),竞态仍会漏网,需在代码评审里卡死入口。
禁用场景要明确。强一致性的金融交易、医疗诊断对话,上下文裁剪可能丢关键信息,应由后端统一管控载荷,前端只做展示。轮次极少的单问单答、不可中断的批处理调用,状态机是累赘。流式渲染本身已卡顿时,再叠退避重试可能让用户长时间无反馈,需配 UI 兜底提示。
五、总结
多轮对话前端治理的核心是两件事:用有限状态机把轮次生命周期显式化,用上下文窗口策略把请求载荷压到预算内。状态机以 idle/asking/streaming/retry 四态与显式守卫,从入口拦截流叠加,以轮次 ID 比对防护幽灵轮次污染,以 AbortController 支持取消与退避重试。上下文裁剪按系统提示加最近 N 轮的滑动窗口组织,超预算部分走后端摘要,前端只持占位与近期原文。
落地步骤分四步。第一步,盘点对话状态与事件,画出迁移图并定义守卫,非 idle 拒绝 send。第二步,实现状态机与轮次 ID 竞态防护,abort 静默回 idle,429/5xx 指数退避,4xx 抛出交 UI。第三步,接入 token 估算与预算裁剪,保留 system 加最近轮次,超限走后端摘要,并对估算做硬上限校验。第四步,按场景定留存策略,敏感数据不留原文,仅持摘要。状态机与裁剪都是权衡,须以回答质量与端到端延迟为验收口径,而非单看 token 节省。

