整体思路:
- 前端维护树形节点数组 +
activePath激活路径,上下文主体存储在后端; - 节点原始数据不被修改,用于前端渲染完整历史记录;摘要 / 浓缩产物为额外附属字段,不污染原始节点内容;
- 两种触发总结逻辑:①分叉节点触发向上总结;②线性路径节点数到达阈值触发截断总结;
- 拼接给大模型的 prompt 上下文:向上回溯到最近的总结锚点节点,使用锚点的浓缩上下文 + 锚点之后节点(摘要 / 原文混合) + 用户当前提问;
- 最近 N 条对话保留原文,保证对话准确性,更早内容做摘要压缩。
交互流程:前端发起提问 → 携带
activePath激活路径 + 用户新问题 → 后端基于路径回溯组装上下文 → 请求大模型 → 返回回答,后端新增对话子节点。
逻辑图:
核心概念定义
- 对话节点 Node:树的最小单元,每一轮用户提问 + AI 回答生成一个节点;节点可以拥有多个子节点,代表对话分叉。
- activePath 激活路径:节点 ID 数组,从根节点到当前正在交互的叶子节点,代表用户当前所处对话分支;例如
[rootId, n1, n3, n7]。 - 总结锚点节点 (SummaryAnchor):打上锚标记的节点,该节点保存向上回溯得到的浓缩完整上下文;回溯上下文遇到锚点就停止向上遍历,避免遍历全部历史。
- 原始内容:
userContent、aiContent,永远不变,用于前端历史回放,禁止改写。 - 摘要内容:附属字段,对历史对话压缩总结,仅用于 LLM 入参,不影响历史展示。
- 线性路径阈值:单条无分叉的链路上累计节点达到阈值,生成总结锚点;
- 最近原文保留条数:锚点之后末尾 K 条节点,不做摘要,直接使用原始对话文本,保障语义准确。
关键数据结构
对话节点结构体(后端存储,数据库 / 内存)
原始对话字段只读;summary 相关为衍生压缩数据,不修改 userContent/aiContent
interface DialogueNode { nodeId: string; // 节点唯一ID parentNodeId: string | null; // 父节点ID,根节点为null childrenNodeIds: string[]; // 子节点ID列表,多个代表对话分叉 // ========== 原始内容【只读,历史渲染使用,绝不修改】 ========== userContent: string; // 用户原始提问 aiContent: string; // AI原始返回回答 createTime: number; // ========== 压缩摘要字段【衍生数据,仅用于LLM请求】 ========== isSummaryAnchor: boolean; // 是否为总结锚点节点 anchorSummary?: string; // 锚点:该节点向上追溯到根的全部对话浓缩总结(分叉/线性阈值触发) nodeSummary?: string; // 当前节点单轮对话的简短摘要,非锚点节点使用 }会话顶层会话对象
interface DialogueSession { sessionId: string; rootNodeId: string; allNodes: Record<string, DialogueNode>; // 总节点字典,key=nodeId,维护全部节点,替代数组,快速查找 // 后端缓存前端传递的activePath,也可以每次直接由前端传入,后端不持久化activePath // activePath?: string[]; config: TreeDialogConfig; // 当前会话压缩配置 } // 系统可配置参数 interface TreeDialogConfig { linearThreshold: number; // 线性路径触发总结阈值,例如12个节点 keepRawCount: number; // 末尾保留原文节点数量,例如3,最近3轮不走摘要 }说明:使用
Record<string, DialogueNode>字典存储全部节点,里面是'n1':{nodeID:'n1'},相比数组,通过 nodeId 查找、找父、找子节点效率更高;前端可以把字典转成数组渲染历史。
前端请求入参
前端调用对话接口,不需要把全部节点传给后端,只传递激活路径和用户问题:
interface ChatRequestDTO { sessionId: string; activePath: string[]; // [rootId, n1, n3, n7],从根到当前节点id数组 userQuestion: string; // 用户最新问题 }后端返回给前端
interface ChatResponseDTO { newNodeId: string; // 新建子节点ID aiAnswer: string; }核心业务逻辑流程
流程 1:接收前端对话请求,组装 LLM 上下文
输入:sessionId、activePath、userQuestion输出:组装完成给大模型的 messages 上下文数组
- 根据
sessionId拿到会话,根据activePath校验路径合法性(路径节点必须全部存在); - 向上回溯查找最近的总结锚点节点:
- 从 activePath 最后一个节点的父节点开始,沿着 parentNodeId 向根节点遍历;
- 停止条件:找到
isSummaryAnchor=true节点 || 到达根节点;
- 分割路径:
anchorNode:找到的锚点节点;如果没有锚点,则 anchorNode=null;afterAnchorNodeIdList:锚点节点之后,一直到当前 activePath 末尾的全部节点 ID 列表;
- 处理上下文片段:
- 如果存在
anchorNode:取anchorNode.anchorSummary作为前置全局上下文; - 遍历
afterAnchorNodeIdList节点集合:- 判断距离当前路径末尾位置:属于最后
keepRawCount条,直接取原始userContent+aiContent; - 其余节点使用
nodeSummary单轮摘要;
- 判断距离当前路径末尾位置:属于最后
- 如果存在
- 将前置全局上下文 + 遍历得到的片段消息 + 当前用户新问题,组装成 LLM messages 数组,调用大模型。
关键点:原始 node 对象不会被修改,只是读取 summary 附属字段。
流程 2:大模型返回后,新建子节点
- 在 activePath 最后一个节点下创建新子节点,填充原始 userContent、aiContent;
- 将新节点 id 加入父节点的
childrenNodeIds;存入allNodes; - 触发总结判断逻辑(两个条件满足任意就执行总结生成,不修改原始字段)
- 条件 A【分叉触发总结】:父节点在新增子节点之后,
childrenNodeIds.length > 1→ 当前父节点成为分叉节点,执行向上浓缩总结,生成anchorSummary,标记isSummaryAnchor=true; - 条件 B【线性阈值触发总结】:当前分支(activePath)最新节点离上一个锚点或根节点后的节点数量 >=
linearThreshold,把该位置节点设置为锚点,生成anchorSummary;
- 条件 A【分叉触发总结】:父节点在新增子节点之后,
- 对非锚点的旧节点,生成单轮
nodeSummary(单轮对话简短摘要);
注意:生成 anchorSummary、nodeSummary 是调用大模型做总结,生成的结果写入附属字段,原始 userContent/aiContent 保持原样,用于前端历史渲染。
流程 3:锚点总结逻辑(分叉 / 线性阈值触发)
只写衍生 summary 字段,原始节点只读!
- 从待标记为锚点的节点向上回溯直到上一个锚点或根,收集该段全部原始对话内容;
- 调用 LLM:对收集的全部对话做整体浓缩总结,提示模型锚点的上下文尽量不要改,只总结后面的原始文本,与前面的上下文拼接成新的上下文输出
anchorSummary(大致是上个锚点上下文+后续总结); - 设置节点
isSummaryAnchor = true,赋值anchorSummary; - 后续回溯上下文,遇到这个节点就直接使用这份总摘要,不再继续向上遍历。
流程 4:单节点摘要逻辑(普通历史节点)
对单条用户 + AI 对话做简短摘要,输出nodeSummary,仅用于上下文组装,原始数据不动。
边界规则说明
- 分叉节点:只要一个节点产生≥2 个子对话,就触发锚点总结;分叉之后,所有子分支回溯都会复用该分叉锚点的
anchorSummary,分叉之后各自分支继续独立增长。 - 多锚点嵌套:树形路径可以存在多个锚点,回溯只取离当前节点最近那一个,更早锚点直接忽略。
- 原文保留策略:锚点之后末尾 N 轮强制原始文本,防止摘要丢失最新细节,避免大模型幻觉。
- 回退历史分支场景:当用户切换 activePath 到历史某旧节点,回溯逻辑不变,依然向上找最近锚点,自动适配旧分支上下文。
- 历史展示:前端读取
allNodes全部原始userContent、aiContent渲染对话树,完全不读取 summary 字段;summary 仅后端内部 LLM 调用使用。 - 锚点节点本身也参与对话,可以继续往下生成子节点。
伪代码实现(核心片段)
回溯查找最近锚点节点
/** * 沿着节点向上查找最近的总结锚点 * @param nodeId 当前节点id * @param allNodes 全部节点字典 * @returns {anchorNode: DialogueNode|null, pathAfterAnchor: string[]} */ function findNearestSummaryAnchor(nodeId: string, allNodes: Record<string, DialogueNode>) { let cur: string | null = nodeId; const afterAnchor: string[] = []; while(cur) { const node = allNodes[cur]; if (!node) break; // 找到锚点,直接返回,afterAnchor不包含锚点自身 if(node.isSummaryAnchor) { return { anchorNode: node, pathAfterAnchor: [...afterAnchor] } } afterAnchor.unshift(cur); cur = node.parentNodeId; } // 遍历到根节点依然没有锚点 return { anchorNode: null, pathAfterAnchor: [...afterAnchor] }; }组装 LLM messages
async function buildLLMMessages( activePath: string[], userNewQuestion: string, session: DialogueSession ) { const { allNodes, config } = session; const currentNodeId = activePath.at(-1)!; const { anchorNode, pathAfterAnchor } = findNearestSummaryAnchor(currentNodeId, allNodes); const messages: Array<{role: string, content: string}> = []; // 加入锚点全局浓缩上下文 if(anchorNode && anchorNode.anchorSummary) { messages.push({ role: "system", content: `【历史对话总摘要】${anchorNode.anchorSummary}` }); } // 处理锚点之后的节点,区分摘要/原文 const totalAfter = pathAfterAnchor.length; for(let idx = 0; idx < totalAfter; idx++) { const nid = pathAfterAnchor[idx]; const node = allNodes[nid]; // 判断是否属于末尾需要保留原文的条目 const isKeepRaw = (totalAfter - idx) <= config.keepRawCount; if(isKeepRaw) { messages.push({role:"user", content: node.userContent}); messages.push({role:"assistant", content: node.aiContent}); } else { // 使用单轮节点摘要 if(node.nodeSummary) { messages.push({role:"system", content: `【历史片段摘要】${node.nodeSummary}`}) } else { // 兜底,如果摘要还未生成,降级使用原始文本 messages.push({role:"user", content: node.userContent}); messages.push({role:"assistant", content: node.aiContent}); } } } // 追加当前用户最新提问 messages.push({role:"user", content: userNewQuestion}); return messages; }新增节点后判断是否触发锚点总结
/** * 创建完新子节点之后调用 * @param parentNodeId 父节点id,刚刚新增了子节点 * @param session 会话 */ async function tryTriggerSummaryAnchor(parentNodeId: string, session: DialogueSession) { const { allNodes, config } = session; const parentNode = allNodes[parentNodeId]; // 条件1:分叉,子节点数量大于1,触发锚点 const isFork = parentNode.childrenNodeIds.length > 1; // 条件2:获取当前从根到父节点路径,线性节点数达到阈值 const pathToParent = getPathFromRoot(parentNodeId, allNodes); const isReachLinearThreshold = pathToParent.length >= config.linearThreshold; if ((isFork || isReachLinearThreshold) && !parentNode.isSummaryAnchor) { // 收集从根到该节点全部原始对话,调用LLM做整体浓缩 const rawDialogues = collectRawDialogue(pathToParent, allNodes); const anchorSummary = await callLLMGenerateWholeSummary(rawDialogues); // 仅修改衍生字段,原始userContent/aiContent不变!! parentNode.isSummaryAnchor = true; parentNode.anchorSummary = anchorSummary; } // 给普通节点生成单轮摘要 for(const nodeId of pathToParent) { const node = allNodes[nodeId]; if(!node.isSummaryAnchor && !node.nodeSummary) { node.nodeSummary = await callLLMGenerateSingleTurnSummary(node.userContent, node.aiContent); } } }辅助工具函数:
getPathFromRoot根据节点 id 向上回溯生成完整 path 数组;collectRawDialogue收集路径上全部原始对话文本;callLLMGenerateWholeSummary、callLLMGenerateSingleTurnSummary调用大模型做摘要生成。
前后端分工
前端
- 维护会话树,维护
activePath(根到当前交互节点 ID 数组); - 渲染全部历史:读取所有节点
userContent、aiContent原始字段,渲染树形分叉对话 UI; - 用户切换历史分支时更新
activePath; - 请求接口仅传递
sessionId + activePath + 用户新问题;不把完整对话传给大模型,上下文全部后端组装。
后端
- 持久化
session会话对象,存储全部节点; - 根据
activePath回溯树节点,组装 LLM 上下文; - 新增节点后判断分叉、线性阈值,异步生成锚点摘要、单轮节点摘要;
- 绝对禁止改写节点原始对话字段,summary 全部为附属字段;
- 提供接口,返回全部节点数据给前端渲染历史。
性能与优化点
- 摘要生成可以异步任务处理,不要阻塞主对话接口;对话时摘要未生成做降级兜底,直接读取原始内容;
- 锚点越多,单次组装上下文遍历节点越少,减少 token 消耗;
- 数据库存储:节点表一条记录存一个
DialogueNode;session 表存会话基础信息; - 分叉节点会成为公共锚点,多个子分支可以复用同一个
anchorSummary,减少重复总结; - 配置调参示例:
linearThreshold=12,keepRawCount=3,可根据模型窗口大小动态调整。
异常 case
- 切换旧分支 activePath:回溯逻辑完全复用,自动找到该分支最近锚点;
- 摘要生成失败:程序降级,直接读取原始对话,保证对话可用;
- activePath 路径非法:后端校验,返回错误,前端重新同步会话节点。