资讯详情 从流式消息到多会话:TinyRobot Kit 如何组织 AI 对话数据层?
📅 2026/10/20 19:59:11
其实实现一个基础的 AI 对话页面通常会从维护消息数组、调用模型接口和渲染结果开始。但真正进入产品开发后复杂度很快会从“调一次接口”扩散到整个页面流式内容需要持续合并请求需要能够停止多轮上下文要正确组织不同会话要隔离运行状态页面刷新后还要恢复历史消息。如果这些逻辑都直接堆在 Vue 组件里请求、消息和 UI 状态会越来越难拆开。opentiny/tiny-robot-kit下文简称 Kit是 TinyRobot 的 AI 对话数据层工具包。它不负责决定页面长什么样而是把消息生命周期、会话运行时、后端响应适配、持久化和插件扩展组织成几块可以独立组合的能力。本文不会只按 API 顺序介绍useMessage和useConversation。我们会从一个可运行的 Vue 3 Demo 出发先看 Kit 如何划分职责再逐步验证几件更接近真实产品的问题单个会话如何处理流式回复和请求取消后端接口变化时如何复用已有的消息状态管理多个会话如何拥有彼此独立的消息运行时切换会话时仍在生成的后台会话为什么不会被自动取消会话和消息如何通过存储策略持久化推理内容、自动续写和工具调用如何进入同一条消息处理链路。本文最终 Demo 演示如下源码地址https://github.com/opentiny/tiny-robot/tree/demo/kit-article-260828AI 对话的数据层究竟在管理什么把聊天页面抽象成一个messages数组只能覆盖“已经拿到结果以后怎么显示”的部分。真正需要长期维护的是一套运行时状态当前请求处于什么阶段、正在更新哪条助手消息、某个会话是否仍在后台生成、什么时候保存消息、收到模型的特殊字段后应该触发什么行为。Kit 将这些职责拆成五个边界能力在 Kit 中的角色解决的问题useMessage单会话消息引擎消息、请求状态、流式合并、取消请求useConversation多会话管理层会话列表、消息引擎创建与切换、后台运行状态responseProvider响应适配边界将业务后端或模型服务适配成单次结果或异步数据流存储策略持久化边界会话元数据和历史消息的保存、恢复插件消息处理扩展推理内容、自动续写、工具调用等它们之间的关系可以概括为本文把useMessage管理的一套单会话消息运行时简称为消息引擎Message Engine。它维护消息、请求状态、流式合并和取消操作。useConversation负责管理多个消息引擎responseProvider隔离后端协议存储策略隔离持久化实现UI 只消费响应式状态。后面的 Demo 都围绕这几个边界展开。环境准备本文基于opentiny/tiny-robot-kit0.5.1面向熟悉 Vue 3 基础用法的前端开发者。本文 Demo 使用以下开发环境Node.js22pnpm10使用 Vite 创建 Vue 3 TypeScript 工程pnpmcreate vite tiny-robot-kit-article-demo--templatevue-tscdtiny-robot-kit-article-demopnpminstall安装 TinyRobot 组件与 Kitpnpmaddopentiny/tiny-robot0.5.1\opentiny/tiny-robot-kit0.5.1\opentiny/tiny-robot-svgs0.5.1在入口文件中引入组件库样式importopentiny/tiny-robot/dist/style.css为了把注意力放在数据层Demo 的 UI 只使用TrBubbleList、TrSender和TrHistory这几个组件。第一步让 useMessage 接管单会话请求生命周期单会话是最小运行单元。useMessage对外提供消息列表、请求状态、发送和取消能力组件不需要自己维护“当前正在生成哪条消息”这样的中间状态const{messages,isProcessing,sendMessage,abortRequest}useMessage({responseProvider:mockResponseProvider,})这里真正决定数据从哪里来的是responseProvider。先创建src/mockResponseProvider.ts用异步生成器模拟模型逐字符返回importtype{ChatCompletion,ResponseProvider}fromopentiny/tiny-robot-kitconstcharacters[...这是模拟的流式回复。Kit 会持续合并消息页面只需要绑定状态和组件。]exportconstmockResponseProvider:ResponseProviderasyncfunction*(_,signal){for(const[index,content]ofcharacters.entries()){awaitnewPromise((resolve)setTimeout(resolve,40))if(signal.aborted)returnyield{id:mock-response,object:chat.completion.chunk,created:Math.floor(Date.now()/1000),model:mock,system_fingerprint:null,choices:[{index:0,message:undefined,delta:{role:index0?assistant:undefined,content},finish_reason:indexcharacters.length-1?stop:null,logprobs:null,},],}satisfies ChatCompletion}}这里有两个细节值得注意。第一Provider 不直接修改 Vue 状态而是持续产出ChatCompletionchunk消息引擎负责把增量内容合并到当前助手消息中。第二Provider 接收 Kit 传入的AbortSignal。请求取消不需要组件和 Provider 之间再维护一套额外状态只要 Provider 在合适的位置响应同一个 signal 即可。在App.vue中页面只绑定 Kit 暴露出来的状态script setup langts import { TrBubbleList, TrSender, type BubbleRoleConfig } from opentiny/tiny-robot import { useMessage } from opentiny/tiny-robot-kit import { ref } from vue import { mockResponseProvider } from ./mockResponseProvider const input ref() const { messages, isProcessing, sendMessage, abortRequest } useMessage({ responseProvider: mockResponseProvider, }) const roles: Recordstring, BubbleRoleConfig { assistant: { placement: start }, user: { placement: end }, } function submit(content: string) { if (!content.trim()) return void sendMessage(content) input.value } /script template main classchat-shell header spanTinyRobot Kit/span smallMock 流式响应/small /header TrBubbleList classmessages :messagesmessages :role-configsroles auto-scroll / TrSender v-modelinput :loadingisProcessing :placeholderisProcessing ? 正在生成… : 输入问题体验流式回复 submitsubmit cancelabortRequest / /main /template页面布局与本文关注的 Kit 消息处理逻辑相互独立。单会话示例的组件布局样式见src/App.vue的style scoped全局基础样式见src/style.css。从页面视角看状态只是在不断变化从数据层视角看实际发生的是一条完整的请求链路运行pnpmdev发送消息后可以看到回复内容持续写入同一条助手消息生成过程中点击停止或等待请求结束输入区随即恢复。关键边界Response Provider 隔离后端协议在进入多会话管理之前先单独看一下前面使用的responseProvider。它是消息引擎与业务后端之间的适配边界前面的页面不需要知道响应来自 Mock 还是真实服务。ResponseProvider的职责很窄。它接收当前请求体和AbortSignal然后返回 Kit 可以消费的单次结果或异步数据流exporttypeResponseProviderTChatCompletion(requestBody:MessageRequestBody,abortSignal:AbortSignal,)AsyncStreamableResultT因此Mock、企业内部 AI 网关、OpenAI 兼容 API 或其他 SSE 服务都可以在这一层完成适配。只要 Provider 最终产出的数据符合消息引擎的消费格式上层的messagesisProcessingabortRequest多会话管理UI 绑定都可以继续复用。这也是后面从 Mock 切到真实 SSE 时通常只需调整 Provider 适配层就能继续使用同一套会话逻辑的原因。第二步用 useConversation 组织多个独立消息引擎一个会话对应一个消息引擎。加入历史会话后需要进一步解决三个问题每个会话的消息和请求状态如何隔离切换历史会话时哪个消息引擎应该进入当前视图离开当前会话以后仍在生成的请求是否继续保留。useConversation就位于这一层。它为每个会话创建并管理独立的消息引擎并通过activeConversation暴露当前会话const{conversations,activeConversation,activeConversationId,createConversation,switchConversation,sendMessage,abortActiveRequest,}useConversation({useMessageOptions:{responseProvider:mockResponseProvider},})constmessagescomputed(()activeConversation.value?.engine.messages.value??[])constisProcessingcomputed(()activeConversation.value?.engine.isProcessing.value??false)这里的conversations主要保存会话元数据真正运行消息请求的是每个会话自己的消息引擎。用长回复验证会话隔离为了让后台运行状态更容易观察把 Mock Provider 改成同时支持普通回复和长回复constdefaultReply这是模拟的流式回复。Kit 会持续合并消息页面只需要绑定状态和组件。constlongReply这是用于演示多会话隔离的长回复。当前会话会持续接收字符切换到其他会话后仍会保持自己的消息和请求状态。.repeat(20).slice(0,300)exportconstmockResponseProvider:ResponseProviderasyncfunction*({messages},signal){constpromptmessages.at(-1)?.contentconstreplytypeofpromptstringprompt.startsWith(/long)?longReply:defaultReplyconstcharacters[...reply]for(const[index,content]ofcharacters.entries()){awaitnewPromise((resolve)setTimeout(resolve,40))if(signal.aborted)returnyield{id:mock-response,object:chat.completion.chunk,created:Math.floor(Date.now()/1000),model:mock,system_fingerprint:null,choices:[{index:0,message:undefined,delta:{role:index0?assistant:undefined,content},finish_reason:indexcharacters.length-1?stop:null,logprobs:null,},],}satisfies ChatCompletion}}新对话采用“首条消息创建会话”的方式点击“新对话”只清空当前选择真正发送第一条消息时再创建会话。functionsubmit(content:string){if(!content.trim())returnif(!activeConversation.value){createConversation({title:content.slice(0,16)})}sendMessage(content)input.value}functionstartConversation(){activeConversationId.valuenullinput.value}functionopenConversation(item:HistoryItem){if(item.id)voidswitchConversation(item.id)}页面加入TrHistorytemplate main classapp-shell aside classhistory-panel div classhistory-header strong历史会话/strong button classnew-chat typebutton clickstartConversation新对话/button /div TrHistory classhistory-list :dataconversations as HistoryItem[] :selectedactiveConversationId ?? undefined :menu-items[] item-clickopenConversation / /aside header spanTinyRobot Kit/span small多会话/small /header TrBubbleList classmessages :messagesmessages :role-configsroles auto-scroll / TrSender classsender v-modelinput :loadingisProcessing :placeholderisProcessing ? 正在生成… : 输入问题体验流式回复 submitsubmit cancelabortActiveRequest / /main /template多会话版App.vue需要补充以下 importimport{TrBubbleList,TrHistory,TrSender,typeBubbleRoleConfig,typeHistoryItem}fromopentiny/tiny-robotimport{useConversation}fromopentiny/tiny-robot-kitimport{computed,ref}fromvueimport{mockResponseProvider}from./mockResponseProvider多会话示例采用左侧会话列表、右侧聊天区的布局布局样式见src/App.vue的style scoped。切换的是当前视图不是把后台请求一起销毁这一点比“能显示历史会话列表”更重要。useConversation内部会缓存当前需要工作的消息引擎。切换会话时它会确保目标会话的消息引擎已经存在然后清理其他会话中已经完成请求的消息引擎仍处于isProcessing状态的消息引擎会继续保留。因此可以出现这样的运行状态这意味着“切换会话”与“取消会话请求”是两件不同的事。切换本身不会取消后台请求显式调用abortActiveRequest、删除会话或清空会话等操作才会中止对应的运行任务。实际验证时可以在会话 A 输入/long 演示多会话隔离随后新建会话 B 并发送普通问题。B 回复结束后切回 A可以看到 A 的长回复仍然在自己的消息引擎中继续更新。第三步用存储策略让运行时跨刷新恢复多会话解决的是运行时隔离页面刷新以后还需要恢复会话和消息。Kit 没有把持久化写死在useConversation内部而是通过存储策略Storage Strategy暴露保存和加载边界。当前版本在没有显式传入storage时会默认创建 LocalStorage 策略。以下示例显式传入默认策略便于直接查看持久化配置const{conversations,activeConversation,activeConversationId,createConversation,switchConversation,updateConversationTitle,deleteConversation,sendMessage,abortActiveRequest,}useConversation({useMessageOptions:{responseProvider:mockResponseProvider},storage:localStorageStrategyFactory(),autoSaveMessages:true,autoSaveThrottle:500,})这里需要区分三个概念LocalStorage 策略负责提供会话和消息的保存、加载能力autoSaveMessages决定消息发生变化时是否自动保存默认值是falseautoSaveThrottle控制流式输出期间自动保存的节流间隔避免每个 chunk 都触发一次持久化写入。会话列表会从存储策略中恢复某个历史会话真正被切换到前台时useConversation再为它创建或恢复消息引擎并加载对应消息。因此历史会话数据和当前正在工作的消息引擎不必全部同时常驻内存。LocalStorage 和 IndexedDB 适合 Demo 或非敏感数据。生产环境如涉及用户隐私或企业内部信息建议通过自定义存储策略接入服务端存储并结合访问控制、加密、数据保留期限和清理机制。重命名和删除仍然走同一个会话边界加入会话操作functionrenameConversation(title:string,item:HistoryItem){if(item.id)updateConversationTitle(item.id,title)}functionhandleHistoryAction(action:HistoryMenuItem,item:HistoryItem){if(action.iddeleteitem.id){voiddeleteConversation(item.id)}}TrHistory绑定对应事件TrHistory classhistory-list :dataconversations as HistoryItem[] :selectedactiveConversationId ?? undefined item-clickopenConversation item-title-changerenameConversation item-actionhandleHistoryAction /删除会话时Kit 会先中止该会话仍在运行的请求再移除对应的消息引擎和持久化数据。重命名则会同步更新会话元数据。完成后可以验证创建多个会话并分别发送消息重命名其中一个会话刷新页面切换到历史会话消息能够重新加载删除会话后刷新已删除的数据不会再次出现。历史消息很多时换成 IndexedDB存储策略的价值在于上层会话逻辑不需要关心数据实际存储在哪里。当历史消息量超过 LocalStorage 更适合承担的范围时可以替换为 IndexedDB 策略storage:indexedDBStorageStrategyFactory({dbName:tiny-robot-chat,dbVersion:1,})替换存储策略时useConversation、消息引擎和页面组件的调用方式保持不变。第四步替换 Provider接入真实 SSE到这里消息和会话逻辑仍然运行在 Mock Provider 上。因为 Provider 与消息引擎解耦接入真实接口时可以在 Provider 层替换数据源。Kit 提供了sseStreamToGenerator可以把服务器发送事件Server-Sent EventsSSE的Response转换成异步生成器让真实接口和前面的 Mock 使用相同的消费模型。**安全提示**下面的浏览器直连方式只用于缩短本地 Demo 链路。以VITE_开头的变量会进入浏览器构建产物不能用于保管生产环境 API Key。生产环境应由服务端或 API 网关持有模型凭据浏览器只调用受控的业务接口。创建src/sseResponseProvider.tsimport{sseStreamToGenerator,typeResponseProvider}fromopentiny/tiny-robot-kitexportconstsseResponseProvider:ResponseProviderasync(requestBody,signal){constresponseawaitfetch(${import.meta.env.VITE_LLM_BASE_URL}/chat/completions,{method:POST,headers:{Content-Type:application/json,Authorization:Bearer${import.meta.env.VITE_LLM_API_KEY},},body:JSON.stringify({...requestBody,model:import.meta.env.VITE_LLM_MODEL,stream:true,}),signal,})if(!response.ok){thrownewError(模型请求失败${response.status})}returnsseStreamToGenerator(response,{signal})}这段 Provider 做了三件事发起请求、检查 HTTP 状态、把 SSE 转换为 Kit 可消费的数据流。消息合并、请求状态和会话逻辑仍由原来的数据层处理。在项目根目录创建.env.localVITE_LLM_BASE_URLhttps://your-llm-endpoint.example.com VITE_LLM_API_KEYyour_api_key VITE_LLM_MODELyour_model_name如果使用 DeepSeek、企业内部 AI 网关或其他兼容 Chat Completions 流式格式的服务可以在本地 Demo 中把地址、鉴权方式和模型名换成实际配置。Provider 切换通常只需调整useConversation的这一处配置- responseProvider: mockResponseProvider responseProvider: sseResponseProvider重新启动开发服务器后页面层可以继续复用原有的消息与会话绑定。为什么响应里出现 reasoning_content 后页面能显示推理内容这不是 SSE 层额外写了一套逻辑。消息引擎默认注册了thinkingPlugin。当响应 chunk 中包含reasoning_content时插件会更新当前助手消息的state.thinking和state.open推理内容结束后再把对应状态收起。也就是说Provider 负责“把数据送进来”Plugin 负责“看到某类数据后怎么扩展消息行为”两者仍然是分开的。从聊天到智能体插件机制把能力挂到同一条消息链路消息引擎除了处理普通文本还提供插件生命周期。当前版本默认注册thinkingPlugin和lengthPlugin业务还可以按需加入toolPlugin等插件。插件不是另起一套状态管理而是参与同一条消息处理链路请求前可以修改请求体收到 chunk 时可以更新当前消息请求结束后还可以决定是否追加消息并继续下一次请求。推理内容thinkingPlugin如果模型返回reasoning_content默认thinkingPlugin会维护推理内容的展示状态。业务不需要这项行为时可以用同名插件覆盖默认配置并禁用plugins:[thinkingPlugin({disabled:true})]自动续写lengthPlugin当模型返回finish_reason: length时默认lengthPlugin会追加一条续写消息并继续请求。需要自定义续写指令时plugins:[lengthPlugin({continueContent:请继续上一段回答。,}),]这里的“继续请求”仍然发生在同一个消息引擎中因此可以继续复用已有的消息、请求状态和取消逻辑。工具调用toolPlugintoolPlugin把模型工具调用接入消息生命周期。getTools向模型提供可用工具callTool执行业务函数工具结果形成 tool 消息后消息引擎可以继续下一轮模型请求。plugins:[toolPlugin({getTools:()[{type:function,function:{name:get_weather,description:查询城市天气,parameters:{type:object,properties:{city:{type:string},},required:[city],},},},],callTool:async(toolCall){const{city}JSON.parse(toolCall.function?.arguments||{})return${city}晴25℃},}),]在真实业务中callTool可以进一步连接 MCP、企业 API、检索服务或其他领域能力。接入真实工具时应校验模型生成的参数并限制可调用工具和凭据的权限范围。涉及写入、删除、提交等有副作用的操作时建议增加用户确认、审计记录和异常处理。一张图看懂核心运行链路经过前面的几个步骤Demo 已经覆盖对话运行时的核心链路这套结构把变化较频繁的几部分拆开了UI 调整时可以继续复用消息运行时模型或业务后端变化时通常只需适配 ProviderLocalStorage 可以换成 IndexedDB上层会话 API 的调用方式保持不变一个会话的请求状态不会和另一个会话混在一起推理内容、自动续写和工具调用通过插件进入同一条消息链路。对于只有一个基础问答框的页面可以直接使用useMessage当产品开始出现历史会话、后台生成和持久化需求再使用useConversation管理多个消息引擎。两者复用同一套消息模型和运行能力。常见问题页面能运行但没有 TinyRobot 样式确认入口文件已经引入组件库样式importopentiny/tiny-robot/dist/style.css接入真实模型后发送消息没有回复先看浏览器 Network 面板请求是否成功返回响应是否为预期的流式格式Provider 产出的 chunk 是否包含 Kit 可以消费的choices数据.env.local中的接口地址、鉴权和模型配置是否正确。修改 Vite 环境变量后需要重新启动开发服务器。刷新后会话还在但消息丢失会话列表能够保存不代表消息已经开启自动保存。确认autoSaveMessages:true同时检查刷新前后使用的是不是同一套存储策略和存储配置。流式回复期间写入 LocalStorage 太频繁通过autoSaveThrottle控制自动保存频率autoSaveThrottle:500具体值应根据消息长度、写入成本和业务对恢复实时性的要求调整。点击停止后网络请求仍然继续abortRequest会触发 Kit 管理的取消信号但自定义 Provider 也必须把同一个AbortSignal继续传给实际网络请求和流解析fetch(url,{signal})returnsseStreamToGenerator(response,{signal})切换到其他会话后原会话为什么还在生成这是useConversation的运行时设计切换当前会话不会自动取消isProcessing仍为true的其他消息引擎。如果业务希望离开会话时立即停止生成需要在自己的交互逻辑里显式调用对应的取消行为删除会话时Kit 会主动中止该会话仍在运行的请求。什么时候适合把 Kit 放进项目如果应用只有一次性的模型调用没有流式消息、历史会话和复杂状态直接调用接口通常已经足够。当页面开始出现下面这些需求时独立的数据层会越来越有价值同一条助手消息需要持续接收流式 chunk用户需要随时停止生成多个会话拥有独立的消息和请求状态会话切换后仍要保留后台运行任务历史消息需要跨页面刷新恢复后端可能在 Mock、企业网关和不同模型服务之间切换推理内容、自动续写、工具调用等能力需要进入同一套消息生命周期。TinyRobot Kit 的定位不是替代业务后端也不是把 UI 和模型调用绑成一个黑盒而是在两者之间提供稳定的数据运行层。对于 AI 对话类前端这一层往往也是业务复杂度最先开始积累的地方。关于 OpenTinyOpenTiny 是面向企业级场景的前端 AI 应用开发平台。平台依托生成式 UI与WebMCP两大核心底层技术打造适配 Agent 智能应用生态的 NEXT-SDKs、AI 扩展工具、TinyRobot 对话 AI 组件库、GenUI SDK生成式 UI 、WebAgent 智能代理等系列产品能够依托 AI 解析用户需求意图自动驱动任务执行助力企业现有业务系统快速完成智能化升级。同时 OpenTiny 沉淀成熟通用前端基建能力提供跨端、跨框架、跨版本的 TinyVue 组件库配套基于 AngularTypeScript 技术栈的 TinyNG 组件库内置可高度自定义拓展的低代码引擎 TinyEngine搭配 TinyPro 中后台业务模板、TinyCLI 命令行工具等配套工具链全方位赋能开发者高效搭建各类 Web 端企业应用。欢迎加入 OpenTiny 开源社区。一起参与交流前端技术OpenTiny 官网https://opentiny.designGenUI SDK 代码仓库https://github.com/opentiny/genui-sdk 欢迎star ⭐如果你也想要共建可以进入代码仓库找到 good first issue标签一起参与开源贡献~如果你有任何问题欢迎在评论区留言交流