多轮对话的前端状态机:上下文窗口与轮次的工程化治理 多轮对话的前端状态机上下文窗口与轮次的工程化治理一、对话失控现场当多轮上下文在前端堆成幽灵大模型应用前端有一个高频踩坑点把对话状态散落在useEffect与若干useState里看起来能跑实际状态机是隐式的。用户连点发送、切换会话、网络抖动断流任何一个都能让界面进入未定义状态。典型失控现场有三类。第一类是流叠加上一轮流式输出还没结束用户又点了发送旧的onmessage回调仍在写状态两条流交错渲染界面出现乱码拼接。第二类是幽灵轮次用户切走会话再切回旧的fetch回调 tardy 返回把陈旧回答写到当前会话上。第三类是上下文爆炸前端把每轮历史原样塞进messages不裁剪不核算token 账单随轮次线性上涨超过模型窗口直接报 400。这三个现场根子是一个对话生命周期没有被显式建模。状态散落意味着非法迁移无人拦截上下文无管理意味着请求载荷失控。治理方向也对应两条用有限状态机把轮次管起来用上下文窗口策略把载荷压下来。本文聚焦这两条链路的工程化落地。二、状态机建模与上下文窗口的裁剪链路对话前端的生命周期可以抽象成一个有限状态机。每个状态有明确的入口动作与可接受事件非法迁移被守卫直接拒绝。下面的框图描述了主要状态流转。┌──────────────────────────────────────┐ ▼ │ ┌────────┐ send ┌─────────┐ first chunk ┌───────────┐ │ idle │────────▶│ asking │─────────────▶│ streaming │ └────────┘ └─────────┘ └───────────┘ ▲ │ │ │ │ │ abort │ │ err │ │ done │ │ ▼ │ │ │ │ ┌────────┐ backoff │ │ │ │ │ retry │──────────▶ asking │ │ └────────┘ │ │ └───────── abort ───┴───────── abort ─────────┘状态语义与守卫如下表非法事件一律忽略并记录告警。状态允许事件动作下一状态idlesend发起请求挂载 AbortControlleraskingaskingfirst-chunk / abort / err建流 / 取消 / 进入退避streaming / idle / retrystreamingchunk / done / abort / err追加缓冲 / 提交并收尾 / 取消 / 报错streaming / idle / idle / retryretrytimer-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): Promisevoid { 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, ): Promisevoid { 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); // 一轮 userassistant 两条 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静默回idle429/5xx 指数退避4xx 抛出交 UI。第三步接入 token 估算与预算裁剪保留 system 加最近轮次超限走后端摘要并对估算做硬上限校验。第四步按场景定留存策略敏感数据不留原文仅持摘要。状态机与裁剪都是权衡须以回答质量与端到端延迟为验收口径而非单看 token 节省。