主题
聊天助手模块是本系统的核心组件之一,承载了用户与系统之间的主要交互能力。模块通过 WebSocket 协议实现双向通信,支持大语言模型(接入了 DeepSeek)输出内容的流式返回;并深度集成 Elasticsearch,通过混合检索在海量本地知识中快速定位与用户问题相关的信息片段。
为支持多轮连续对话,模块集成了 Redis 存储和维护用户会话上下文,确保大模型能够"记住"前文内容、维持语义连贯;同时强化了 Prompt 构建与模板管理——根据检索结果动态生成 Prompt、支持多模板配置与调优,确保内容组织清晰有重点。这一机制是实现 RAG 的关键保障,确保模型回答既有语义逻辑,又有知识依据。
用户发起对话流程
用户发起对话的入口是 WebSocket。连接建立时客户端把 JWT 放在 URL 里做认证——浏览器 WS API 带不了请求头,这是个标准变通——验证通过后服务端按用户注册会话。之后的每条消息先经过三分流:心跳保活、携带内部令牌的停止指令、普通聊天消息。
普通消息的处理分四步。第一步准入和初始化:先过每日次数和 token 余额的双重限流,然后取或建会话 ID,关键是创建一个生成快照——为这次回答生成唯一 ID,在 Redis 里建一个 30 分钟过期的状态对象,后续的流式内容都实时追加进去。这是整个设计里我最看重的一环:停止、断线、超时这些异步难题都有了统一的解法。第二步上下文组装:从 Redis 取最近 20 条对话做滑动窗口,再把用户近期的点赞点踩反馈转成引导语注入。第三步是核心——ReAct 循环:整个循环丢进独立线程池异步执行,WebSocket 处理线程立刻释放,不会被几十秒的模型流阻塞。循环里模型流式输出,如果它判断需要查资料,会返回工具调用指令,服务端执行 search_knowledge——就是我们的权限混合检索——把结果回注给模型进入下一轮;如果它直接给出答案,循环结束。这里有两层预算控制:最多四轮、最多八次工具调用,预算耗尽还有一轮强制收尾——清空工具列表逼模型基于已有结果作答,防止它陷入工具死循环。第四步收尾:回答完整落库,顺序是先写 MySQL 全量持久化——包括引用映射——成功才更新 Redis 的 20 条窗口,MySQL 是事实层,Redis 是缓存;最后把预估和实际 token 上报配额结算。
两个值得单独说的设计:流式推送上,模型吐出的每个片段做三路同步——内存累积全文、Redis 快照追加、WebSocket 推前端,所以用户边看边等,断线了还能拿快照续。可中断上,用户点停止会取消上游的模型流——不是等它吐完,是真的掐断,token 不再计费——已生成的半截回答正常收尾落库。
为什么用 ReAct 让模型自己决定检索,而不是固定‘先检索再回答’的流水线?
固定流水线把每次对话都强制检索——问‘你好’也要打一次 embedding 加一次 ES,成本浪费;而且检索结果无论好坏都塞给模型,检索失败时回答质量不可控。ReAct 把决定权交给模型:需要事实依据时它主动调检索工具,拿到结果觉得不够还能再查一轮,闲聊就直接回答。代价是循环失控的风险——模型可能无限调工具——所以配了轮数、工具次数两层预算加强制收尾轮,把自主性关在笼子里。另外检索工具内部就是我们的权限混合检索,模型调工具等于自动带上了用户的权限边界,不会因为自主性引入越权。
用户中途点停止,或者页面直接关了,怎么处理?
两种情况走同一套生成状态机。停止按钮是一条带内部令牌的 WS 指令——令牌防伪造——服务端把该次生成标记取消并 cancel 掉上游的模型流,这是真取消,token 立刻停止计费;已生成的半截回答走正常收尾,落库可查。页面直接关闭没有停止指令,靠两个机制兜底:生成快照在 Redis 有 30 分钟过期,重连后凭 generationId 能查到上次答到哪;等待模型响应的地方用的是 200 毫秒短轮询而不是无限阻塞,取消和超时都能在亚秒级被感知。所有异步路径围绕这个可查询、可取消的状态对象转,这是我认为这套设计最干净的地方。
新建会话流程
用户点新建对话,前端调一个 REST 接口,服务端三步。第一步校验 JWT 身份。第二步生成一个 UUID 作为 conversationId,往会话表写一行记录——标题是‘新对话’,状态 ACTIVE,表上有会话 ID 的唯一索引兜底并发。第三步是最关键的:把 Redis 里‘用户的当前会话指针’指向这个新 ID,7 天过期。这个指针不是归属关系——会话的归属在数据库的外键里,一个用户可以有多个会话——它是‘用户现在正在哪个会话里打字’的游标。它的价值是充当 REST 和 WebSocket 两条通道的交接点:新建和切换会话走 REST 写指针,用户发消息走 WebSocket 读指针,消息自然路由进当前会话,两条通道不需要互相感知。最后把 conversationId 返回给前端,切换到空白聊天窗口。另外系统支持隐式创建:没点新建直接发消息,指针取不到就现场生成 ID、会话行惰性创建,不会丢消息。
一个用户有多个历史会话,这个 Redis 映射怎么处理?
Redis 里永远只有一条‘当前指针’,指向正在交互的那个会话,切历史会话就是翻转这个指针——切换前服务端会先校验目标会话存在且属于你。所有历史会话的完整账本在 MySQL 会话表里,按用户外键查询,谁都不会丢。指针 7 天过期也无害:过期后发消息会走惰性创建或提示选择会话,账本还在数据库里。
查询历史对话流程
历史对话查询是一个 REST 接口,支持两种查询模式。身份校验先做:从 JWT 解出用户名,无效直接 401。之后按参数分流:带了 conversationId,就按会话查——从 conversations 表按时间升序取出该会话的全部问答;没带,就查当前用户的全部历史,支持起止日期过滤——注意这里的权限控制方式:查询条件里天然带着用户的 ID,普通用户只能看到自己的数据,管理员有个特殊分支可以查全量,权限即查询范围。数据源全程是 MySQL——因为历史页面要的是完整记录加引用映射,引用信息以 JSON 存在每行问答里,前端渲染‘回答带出处’靠它。容易混淆的点主动说一下:Redis 里也有一份对话历史,但那是给大模型拼上下文用的最近 20 条快照,成本考虑刻意截断——MySQL 是全量事实层,Redis 是模型上下文缓存,历史查询走前者。查完格式化成消息列表返回,前端按时间渲染对话流。
Redis 里不是也存了一份对话历史吗?为什么查询不走 Redis?
两个消费者、两种容量。Redis 那份是给大模型拼 Prompt 用的上下文窗口——固定最近 20 条,刻意截断是为了控制 token 成本和延迟,它过期、被截断都是设计内的。历史页面要的是全量、带引用映射、可按日期过滤的记录,这些只有 MySQL 满足。所以写入时是‘先 MySQL 后更新 Redis 窗口’,查询时各取各的:模型上下文取 Redis,历史页面取 MySQL。如果把历史查询也指到 Redis,用户超过 20 轮之后历史页就缺数据了——这就是两个存储不能互换的原因。
引用机制
回答的引用靠一套‘编号即索引’的机制。检索结果进入 Prompt 前被编号成[1][2][3]的格式,真实出处——文件指纹、页码、锚点——留在服务端的一个映射表里,模型只看到编号。它按编号写引用,回答完成后,完成通知把这个编号到出处的映射一起推给前端;用户点角标,后端从映射里查真实位置跳转预览。映射有三级容灾:当前会话内存、Redis 生成快照、历史行的 JSON 字段,所以在线、断线、历史三个场景引用都可还原。这个设计的核心是引用的真相在服务端——模型只能选择引用几号,没有伪造出处的材料,从架构上消掉一类幻觉。代价是模型无法改写引用描述,灵活性低一点,但对知识库场景,可溯源的重要性远高于灵活性。
模型输出了不存在的编号怎么办?
服务端映射只包含真实注入过的编号,前端按编号查引用详情查不到就降级为普通文本,幻觉引用最多显示为无效角标,不会指向错误来源。实测中主流模型对编号引用的遵从度很高,因为编号材料就在上下文里,引用成本低。
ReAct Prompt 与工具集
Agent 的提示词和工具集是一体设计的。系统提示六段式:可配置规则打底,核心是‘知识库优先’的强制检索原则——默认每个问题都先检索,对抗模型幻觉;同时给了五类免检索白名单,把查询分流交给模型判断而不是代码分类器。工具集四个:权限检索、总结、反馈提交、知识库统计,全部转成标准 function calling schema,换模型零改动。反馈是闭环的:对话内的偏好会写入 Redis,下一轮转成引导语注入提示。工具定义本身也计入 token 配额——Agent 的每一分成本都可计量。
强制每个问题都检索,不会很浪费吗?
白名单兜住了纯寒暄类;剩下‘看着像不用查’的问题恰恰是幻觉高发区——模型自以为知道的企业内部知识它其实没见过。检索一次的成本(毫秒级 ES + 一次 embedding)远低于错误回答的信任成本。这个默认值是拿准确性换一点成本,方向对企业场景是对的。
多轮并发隔离
并发隔离分三级。任务级:每条消息一个 generationId,六个并发容器都挂它上面,同用户连发两条消息是两个独立生成任务,停止也只停那一个;连接级:推送按 userId 路由到当前 WebSocket,写同一个连接用 synchronized 串行化——WS 会话非线程安全,这是必须的;容量级:生成线程池有界(4 核心 16 上限),满了显式拒绝并回‘系统繁忙’,不挂起连接线程。一个已知边界:注册表是单连接模型,第二个标签页会顶掉第一个的推送,改进方向是用户到连接集合的广播,改动很小。
两个标签页同时发消息会怎样?
第一个标签页的连接被顶掉后它收不到流,但生成任务不受影响:快照在 Redis、落库照常,切回标签页刷新能从历史找回回答。任务本身互不干扰,因为隔离键是 generationId 不是连接。这也是为什么停止指令必须带 generationId——不带的话就只能‘停用户所有任务’,粒度就粗了。
摘要机制与上下文记忆
上下文管理是两层结构。基础层是滑动窗口:Redis 里维护最近 20 条对话,直接拼进 Prompt,控制单次请求的成本和延迟。摘要层解决窗口的固有缺陷——第 21 条开始,最早的对话会被挤出窗口,长程连续性就断了。所以当被挤出的消息积累超过阈值时,触发一次摘要压缩:把这段被挤出的历史用 LLM 压成一段实体和结论导向的摘要,存为会话级的前缀记忆;组装 Prompt 时先注入摘要前缀、再拼最近的完整窗口。摘要是摊销式的——只有被挤出的存量超过阈值才重新生成,不是每轮都调一次模型。这套两层结构在另一个 CLI Agent 项目里完整实现过;本知识库项目上线的是滑动窗口基础层——企业知识库场景事实记忆主要靠检索,窗口够用,摘要层是演进项。
摘要丢了细节怎么办?
摘要是给对话连续性用的——记的是'用户在做什么、之前定了什么偏好';事实性记忆不走摘要,走知识库检索——用户三小时前提到的某个文档细节,靠 search_knowledge 随时可以重新捞回来。摘要管连续性,RAG 管事实,两条记忆通道互补,谁也替代不了谁。
大模型依赖的可用性设计(熔断降级)
针对大模型这个外部依赖,可用性做了四层设计。第一层兜底回复:LLM 调用异常统一捕获,用户看到的是'AI 服务暂时不可用,请稍后重试'的友好提示,不是 5xx 堆栈;第二层功能降级:embedding 服务不可用时,检索自动退化为纯 BM25 加同样权限过滤的文本检索——功能保留、语义能力降级;第三层多维限流:IP 级、账号级、每日次数、token 余额四道闸,把故障流量挡在依赖调用之前;第四层供应商热切换:密钥失效或服务劣化时,管理端秒级切到备用模型,密钥加密落库、不用重启。和标准熔断器的差距我清楚:目前没有自动的失败率统计、熔断打开和半开探测,切换是运维手动触发、重试是固定退避——演进方向是按依赖维度引入 Resilience4j,加自动熔断和半开恢复。
技术栈里的 Ollama 怎么答?
事实先说清:代码里没有 Ollama 的直连实现——大模型接入走的是统一的 OpenAI 兼容端点抽象(供应商配置化、密钥加密落库、运行时热切换)。而 Ollama 本身就暴露 OpenAI 兼容接口,所以接入方式上是支持的:把供应商的 apiBaseUrl 指到本地 Ollama 的 /v1 即可,当前配置走的是云 API(DeepSeek / Grok 中转站)。这个抽象的价值在切 Grok 中转站时验证过——换供应商只改配置不改代码。被问"Ollama 部署了什么模型"就按这个口径答:抽象层支持,当前生产配置用云 API,本地部署是备选路径。