Skip to content

AI Chat

AI Chat 是一个支持流式对话、工具调用与多轮 Agent 的聊天应用。集成了大模型对话、MCP(Model Context Protocol)工具、对话历史管理,并采用容器化部署,从「问答工具」升级为可自主调用工具的「执行助手」。

基于 Fetch API 的流式响应与渲染优化

由于原生的sse有非常多的限制,例如:

  1. sse数据格式非常严格,必须是字符串,而fetch可以自定义,可以是文本、二进制、JSON
  2. 而且原生sse只支持get请求,不支持post
  3. fetch可以通过signal:abortController,可手动控制流的暂停、恢复、取消

渲染上,如果每来一块就 setState 一次,在 React 里会触发大量重绘、容易卡顿。所以做了 requestAnimationFrame 流式缓冲:同一帧内到的多个 chunk 先攒着,下一帧再一次性更新 UI。

对比:不缓冲时容易出现输入卡顿、滚动抖动;用 requestAnimationFrame 后渲染节奏和浏览器帧率对齐,打字机效果更顺滑、DOM 操作次数也明显减少。


请求的整条生命周期用 有限状态机(FSM) 管:5 种状态(idle/loading/success/error/canceled)和 5 种事件(START/FINISH/ERROR/CANCEL/RESET),纯函数式状态转换 + 状态转换表,避免非法跳转

对比:不用状态机时,loading / 成功 / 失败 / 已取消 等多用布尔或散落的 if/else 维护,容易漏掉「取消后又报错」「卸载时未清理」等组合,状态难推理、和 UI 的对应关系也乱。用 FSM 后,所有合法路径都在转换表里显式定义,取消、错误恢复、卸载清理都在同一套模型里处理,状态可预测、加新场景(例如重试)只需加事件和转换,不改现有分支。

流式数据完整性:StreamBuffer(可以类,做chunk缓存的) 与 forceRefresh

在接入 requestAnimationFrame 做批量渲染后,发现最后一段数据有时不会出现在界面上,消息会一直停在 loading。原因是 rAF 的调度时机不确定,最后一批 chunk 可能没来得及在「下一帧」里被消费和渲染。

解决思路是抽象一个 StreamBuffer:在内存里做缓冲与批量处理,并在流结束或超时等时机增加 forceRefresh,强制把缓冲区里剩余内容推到渲染。

对比:不做的活最后一批 chunk 可能永远等不到「下一帧」触发,消息会一直停在 loading 或少一段字;用 StreamBuffer + forceRefresh 后,流结束或超时时必定把缓冲区清空到 UI,数据完整、不会卡在 loading。

插件化流式数据解析引擎

后端 SSE 会推送多种前缀的数据:普通对话内容(data)、结束标记(done)、初始化(init)、工具调用(tool)等。如果都在主流程里用 if/else 按类型写死,每增加一种类型就要改核心解析逻辑,维护成本高、容易出错。

所以设计了 插件化解析引擎,用策略模式 + 泛型:按前缀把不同数据类型路由到对应插件,由插件各自解析并产出统一结构。

对比:不插件化时主流程里一堆 if/else 按前缀分支,每加一种类型(例如后续加 progress)都要改核心解析、容易动到旧逻辑;用插件后新增类型只需注册一个插件、实现解析函数,引擎核心不动,data/done/init/tool 等 4 类数据统一接入、后续扩展只加插件即可。

MCP Server 集成与多协议支持

产品需要让 AI 能调用「外部能力」——例如查文档、执行脚本等,这些能力以 MCP Server 的形式提供,且存在本地进程、远程 HTTP 等不同部署方式,协议上也有 SSE 与 StreamableHTTP 等差异。如果前端针对每种连接方式各写一套逻辑,会重复且难维护。

采用 @modelcontextprotocol/sdk 封装统一的 MCP 客户端管理器,对外只暴露「连接配置 + 工具发现/执行」的抽象。连接参数用 Zod 做格式校验,避免脏数据导致连接失败。管理器内部兼容 SSE 与 StreamableHTTP,实现工具的动态发现与执行,支持多 Server 工具聚合。

对比:不抽象时本地 SSE 一套、远程 HTTP 一套、每种协议各写连接和重连,重复多、也不好加新协议;用管理器后前端只关心「连哪个 Server、调哪个工具、传什么参数」,底层传输和协议差异都收在管理器里,加新连接方式或新 Server 只改配置、不改业务代码。

ReAct + Function Calling 的 Agent 多轮对话

需求是把大模型从「一问一答」升级成能自主规划、调用工具、多轮迭代的 Agent。不仅要支持单次工具调用,还要在工具执行完后把结果塞回上下文,让模型决定是继续用工具还是直接回复用户,并支持多轮(例如最多 10 轮)的「推理 → 工具调用 → 结果 → 再推理」循环。

采用 ReAct + Function Calling:模型先做 reasoning(链式思考),再决定是否调用工具及调用哪些工具;后端返回的 reasoning 与 tool_calls 在流式里区分开,前端对推理过程与最终内容做分离展示,便于追溯。

为维护多轮上下文,设计了 next_id 链式结构:用户消息、AI 回复、工具调用与工具结果都通过 next_id 关联成一条链,实现消息链式管理。

对比:不用链式结构时,多轮里「这条工具结果是哪条消息触发的」「该插在哪个回复下面」要靠自己维护顺序或额外 ID,容易乱序、难做折叠/展开;用 next_id 后每条消息都有明确后继,工具结果自动挂到对应节点,按链渲染即可,对话顺序清晰、也方便做 UI 上的树形或折叠展示。

设计消息链式管理机制

设计链式管理是为了方便判断当前消息是否属于同一次对话,因为有多轮工具调用,一次调用算作一个消息,因为要渲染最后的复制和删除按钮,所以必须要找到消息的末尾。所以采用next_id来标记下一条消息。