AI 树形会话分支管理方案
2026/8/20 23:46:37 网站建设 项目流程

整体思路:

  1. 前端维护树形节点数组 +activePath激活路径,上下文主体存储在后端;
  2. 节点原始数据不被修改,用于前端渲染完整历史记录;摘要 / 浓缩产物为额外附属字段,不污染原始节点内容
  3. 两种触发总结逻辑:①分叉节点触发向上总结;②线性路径节点数到达阈值触发截断总结;
  4. 拼接给大模型的 prompt 上下文:向上回溯到最近的总结锚点节点,使用锚点的浓缩上下文 + 锚点之后节点(摘要 / 原文混合) + 用户当前提问;
  5. 最近 N 条对话保留原文,保证对话准确性,更早内容做摘要压缩。

交互流程:前端发起提问 → 携带activePath激活路径 + 用户新问题 → 后端基于路径回溯组装上下文 → 请求大模型 → 返回回答,后端新增对话子节点。

逻辑图:

核心概念定义

  1. 对话节点 Node:树的最小单元,每一轮用户提问 + AI 回答生成一个节点;节点可以拥有多个子节点,代表对话分叉。
  2. activePath 激活路径:节点 ID 数组,从根节点到当前正在交互的叶子节点,代表用户当前所处对话分支;例如[rootId, n1, n3, n7]
  3. 总结锚点节点 (SummaryAnchor):打上锚标记的节点,该节点保存向上回溯得到的浓缩完整上下文;回溯上下文遇到锚点就停止向上遍历,避免遍历全部历史。
  4. 原始内容userContentaiContent,永远不变,用于前端历史回放,禁止改写。
  5. 摘要内容:附属字段,对历史对话压缩总结,仅用于 LLM 入参,不影响历史展示。
  6. 线性路径阈值:单条无分叉的链路上累计节点达到阈值,生成总结锚点;
  7. 最近原文保留条数:锚点之后末尾 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 上下文

输入:sessionIdactivePathuserQuestion输出:组装完成给大模型的 messages 上下文数组

  1. 根据sessionId拿到会话,根据activePath校验路径合法性(路径节点必须全部存在);
  2. 向上回溯查找最近的总结锚点节点
    • 从 activePath 最后一个节点的父节点开始,沿着 parentNodeId 向根节点遍历;
    • 停止条件:找到isSummaryAnchor=true节点 || 到达根节点;
  3. 分割路径:
    • anchorNode:找到的锚点节点;如果没有锚点,则 anchorNode=null;
    • afterAnchorNodeIdList:锚点节点之后,一直到当前 activePath 末尾的全部节点 ID 列表;
  4. 处理上下文片段:
    1. 如果存在anchorNode:取anchorNode.anchorSummary作为前置全局上下文;
    2. 遍历afterAnchorNodeIdList节点集合:
      • 判断距离当前路径末尾位置:属于最后keepRawCount条,直接取原始userContent+aiContent
      • 其余节点使用nodeSummary单轮摘要;
  5. 将前置全局上下文 + 遍历得到的片段消息 + 当前用户新问题,组装成 LLM messages 数组,调用大模型。

关键点:原始 node 对象不会被修改,只是读取 summary 附属字段

流程 2:大模型返回后,新建子节点

  1. 在 activePath 最后一个节点下创建新子节点,填充原始 userContent、aiContent;
  2. 将新节点 id 加入父节点的childrenNodeIds;存入allNodes
  3. 触发总结判断逻辑(两个条件满足任意就执行总结生成,不修改原始字段)
    • 条件 A【分叉触发总结】:父节点在新增子节点之后,childrenNodeIds.length > 1→ 当前父节点成为分叉节点,执行向上浓缩总结,生成anchorSummary,标记isSummaryAnchor=true
    • 条件 B【线性阈值触发总结】:当前分支(activePath)最新节点离上一个锚点或根节点后的节点数量 >=linearThreshold,把该位置节点设置为锚点,生成anchorSummary
  4. 对非锚点的旧节点,生成单轮nodeSummary(单轮对话简短摘要);

注意:生成 anchorSummary、nodeSummary 是调用大模型做总结,生成的结果写入附属字段,原始 userContent/aiContent 保持原样,用于前端历史渲染

流程 3:锚点总结逻辑(分叉 / 线性阈值触发)

只写衍生 summary 字段,原始节点只读!

  1. 从待标记为锚点的节点向上回溯直到上一个锚点或根,收集该段全部原始对话内容;
  2. 调用 LLM:对收集的全部对话做整体浓缩总结,提示模型锚点的上下文尽量不要改,只总结后面的原始文本,与前面的上下文拼接成新的上下文输出anchorSummary(大致是上个锚点上下文+后续总结)
  3. 设置节点isSummaryAnchor = true,赋值anchorSummary
  4. 后续回溯上下文,遇到这个节点就直接使用这份总摘要,不再继续向上遍历。

流程 4:单节点摘要逻辑(普通历史节点)

对单条用户 + AI 对话做简短摘要,输出nodeSummary,仅用于上下文组装,原始数据不动。

边界规则说明

  1. 分叉节点:只要一个节点产生≥2 个子对话,就触发锚点总结;分叉之后,所有子分支回溯都会复用该分叉锚点的anchorSummary,分叉之后各自分支继续独立增长。
  2. 多锚点嵌套:树形路径可以存在多个锚点,回溯只取离当前节点最近那一个,更早锚点直接忽略。
  3. 原文保留策略:锚点之后末尾 N 轮强制原始文本,防止摘要丢失最新细节,避免大模型幻觉。
  4. 回退历史分支场景:当用户切换 activePath 到历史某旧节点,回溯逻辑不变,依然向上找最近锚点,自动适配旧分支上下文。
  5. 历史展示:前端读取allNodes全部原始userContentaiContent渲染对话树,完全不读取 summary 字段;summary 仅后端内部 LLM 调用使用。
  6. 锚点节点本身也参与对话,可以继续往下生成子节点。

伪代码实现(核心片段)

回溯查找最近锚点节点

/** * 沿着节点向上查找最近的总结锚点 * @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收集路径上全部原始对话文本;callLLMGenerateWholeSummarycallLLMGenerateSingleTurnSummary调用大模型做摘要生成。

前后端分工

前端

  1. 维护会话树,维护activePath(根到当前交互节点 ID 数组);
  2. 渲染全部历史:读取所有节点userContentaiContent原始字段,渲染树形分叉对话 UI;
  3. 用户切换历史分支时更新activePath
  4. 请求接口仅传递sessionId + activePath + 用户新问题不把完整对话传给大模型,上下文全部后端组装

后端

  1. 持久化session会话对象,存储全部节点;
  2. 根据activePath回溯树节点,组装 LLM 上下文;
  3. 新增节点后判断分叉、线性阈值,异步生成锚点摘要、单轮节点摘要;
  4. 绝对禁止改写节点原始对话字段,summary 全部为附属字段
  5. 提供接口,返回全部节点数据给前端渲染历史。

性能与优化点

  1. 摘要生成可以异步任务处理,不要阻塞主对话接口;对话时摘要未生成做降级兜底,直接读取原始内容;
  2. 锚点越多,单次组装上下文遍历节点越少,减少 token 消耗;
  3. 数据库存储:节点表一条记录存一个DialogueNode;session 表存会话基础信息;
  4. 分叉节点会成为公共锚点,多个子分支可以复用同一个anchorSummary,减少重复总结;
  5. 配置调参示例:linearThreshold=12keepRawCount=3,可根据模型窗口大小动态调整。

异常 case

  1. 切换旧分支 activePath:回溯逻辑完全复用,自动找到该分支最近锚点;
  2. 摘要生成失败:程序降级,直接读取原始对话,保证对话可用;
  3. activePath 路径非法:后端校验,返回错误,前端重新同步会话节点。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询