ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从流式消息到多会话:TinyRobot Kit 如何组织 AI 对话数据层?

从流式消息到多会话:TinyRobot Kit 如何组织 AI 对话数据层?

其实实现一个基础的 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-260828

AI 对话的数据层,究竟在管理什么

把聊天页面抽象成一个messages数组,只能覆盖“已经拿到结果以后怎么显示”的部分。真正需要长期维护的是一套运行时状态:当前请求处于什么阶段、正在更新哪条助手消息、某个会话是否仍在后台生成、什么时候保存消息、收到模型的特殊字段后应该触发什么行为。

Kit 将这些职责拆成五个边界:

能力在 Kit 中的角色解决的问题
useMessage单会话消息引擎消息、请求状态、流式合并、取消请求
useConversation多会话管理层会话列表、消息引擎创建与切换、后台运行状态
responseProvider响应适配边界将业务后端或模型服务适配成单次结果或异步数据流
存储策略持久化边界会话元数据和历史消息的保存、恢复
插件消息处理扩展推理内容、自动续写、工具调用等

它们之间的关系可以概括为:

本文把useMessage管理的一套单会话消息运行时简称为消息引擎(Message Engine)。它维护消息、请求状态、流式合并和取消操作。useConversation负责管理多个消息引擎,responseProvider隔离后端协议,存储策略隔离持久化实现,UI 只消费响应式状态。

后面的 Demo 都围绕这几个边界展开。

环境准备

本文基于@opentiny/tiny-robot-kit0.5.1,面向熟悉 Vue 3 基础用法的前端开发者。

本文 Demo 使用以下开发环境:

  • Node.js:22+
  • pnpm:10+

使用 Vite 创建 Vue 3 + TypeScript 工程:

pnpmcreate vite tiny-robot-kit-article-demo--templatevue-tscdtiny-robot-kit-article-demopnpminstall

安装 TinyRobot 组件与 Kit:

pnpmadd@opentiny/tiny-robot@0.5.1\@opentiny/tiny-robot-kit@0.5.1\@opentiny/tiny-robot-svgs@0.5.1

在入口文件中引入组件库样式:

import'@opentiny/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}from'@opentiny/tiny-robot-kit'constcharacters=[...'这是模拟的流式回复。Kit 会持续合并消息,页面只需要绑定状态和组件。']exportconstmockResponseProvider:ResponseProvider=asyncfunction*(_,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:index===0?'assistant':undefined,content},finish_reason:index===characters.length-1?'stop':null,logprobs:null,},],}satisfies ChatCompletion}}

这里有两个细节值得注意。

第一,Provider 不直接修改 Vue 状态,而是持续产出ChatCompletionchunk;消息引擎负责把增量内容合并到当前助手消息中。

第二,Provider 接收 Kit 传入的AbortSignal。请求取消不需要组件和 Provider 之间再维护一套额外状态,只要 Provider 在合适的位置响应同一个 signal 即可。

在App.vue中,页面只绑定 Kit 暴露出来的状态:

<script setup lang="ts"> 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: Record<string, BubbleRoleConfig> = { assistant: { placement: 'start' }, user: { placement: 'end' }, } function submit(content: string) { if (!content.trim()) return void sendMessage(content) input.value = '' } </script> <template> <main class="chat-shell"> <header> <span>TinyRobot Kit</span> <small>Mock 流式响应</small> </header> <TrBubbleList class="messages" :messages="messages" :role-configs="roles" auto-scroll /> <TrSender v-model="input" :loading="isProcessing" :placeholder="isProcessing ? '正在生成…' : '输入问题体验流式回复'" @submit="submit" @cancel="abortRequest" /> </main> </template>

页面布局与本文关注的 Kit 消息处理逻辑相互独立。单会话示例的组件布局样式见src/App.vue的<style scoped>,全局基础样式见src/style.css。

从页面视角看,状态只是在不断变化;从数据层视角看,实际发生的是一条完整的请求链路:

运行:

pnpmdev

发送消息后,可以看到回复内容持续写入同一条助手消息;生成过程中点击停止或等待请求结束,输入区随即恢复。

关键边界:Response Provider 隔离后端协议

在进入多会话管理之前,先单独看一下前面使用的responseProvider。它是消息引擎与业务后端之间的适配边界,前面的页面不需要知道响应来自 Mock 还是真实服务。

ResponseProvider的职责很窄。它接收当前请求体和AbortSignal,然后返回 Kit 可以消费的单次结果或异步数据流:

exporttypeResponseProvider<T=ChatCompletion>=(requestBody:MessageRequestBody,abortSignal:AbortSignal,)=>AsyncStreamableResult<T>

因此,Mock、企业内部 AI 网关、OpenAI 兼容 API 或其他 SSE 服务,都可以在这一层完成适配。只要 Provider 最终产出的数据符合消息引擎的消费格式,上层的:

  • messages;
  • isProcessing;
  • abortRequest;
  • 多会话管理;
  • UI 绑定;

都可以继续复用。

这也是后面从 Mock 切到真实 SSE 时,通常只需调整 Provider 适配层,就能继续使用同一套会话逻辑的原因。

第二步:用 useConversation 组织多个独立消息引擎

一个会话对应一个消息引擎。加入历史会话后,需要进一步解决三个问题:

  1. 每个会话的消息和请求状态如何隔离;
  2. 切换历史会话时,哪个消息引擎应该进入当前视图;
  3. 离开当前会话以后,仍在生成的请求是否继续保留。

useConversation就位于这一层。它为每个会话创建并管理独立的消息引擎,并通过activeConversation暴露当前会话:

const{conversations,activeConversation,activeConversationId,createConversation,switchConversation,sendMessage,abortActiveRequest,}=useConversation({useMessageOptions:{responseProvider:mockResponseProvider},})constmessages=computed(()=>activeConversation.value?.engine.messages.value??[])constisProcessing=computed(()=>activeConversation.value?.engine.isProcessing.value??false)

这里的conversations主要保存会话元数据;真正运行消息请求的是每个会话自己的消息引擎。

用长回复验证会话隔离

为了让后台运行状态更容易观察,把 Mock Provider 改成同时支持普通回复和长回复:

constdefaultReply='这是模拟的流式回复。Kit 会持续合并消息,页面只需要绑定状态和组件。'constlongReply='这是用于演示多会话隔离的长回复。当前会话会持续接收字符,切换到其他会话后仍会保持自己的消息和请求状态。'.repeat(20).slice(0,300)exportconstmockResponseProvider:ResponseProvider=asyncfunction*({messages},signal){constprompt=messages.at(-1)?.contentconstreply=typeofprompt==='string'&&prompt.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:index===0?'assistant':undefined,content},finish_reason:index===characters.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.value=nullinput.value=''}functionopenConversation(item:HistoryItem){if(item.id)voidswitchConversation(item.id)}

页面加入TrHistory:

<template> <main class="app-shell"> <aside class="history-panel"> <div class="history-header"> <strong>历史会话</strong> <button class="new-chat" type="button" @click="startConversation">新对话</button> </div> <TrHistory class="history-list" :data="conversations as HistoryItem[]" :selected="activeConversationId ?? undefined" :menu-items="[]" @item-click="openConversation" /> </aside> <header> <span>TinyRobot Kit</span> <small>多会话</small> </header> <TrBubbleList class="messages" :messages="messages" :role-configs="roles" auto-scroll /> <TrSender class="sender" v-model="input" :loading="isProcessing" :placeholder="isProcessing ? '正在生成…' : '输入问题体验流式回复'" @submit="submit" @cancel="abortActiveRequest" /> </main> </template>

多会话版App.vue需要补充以下 import:

import{TrBubbleList,TrHistory,TrSender,typeBubbleRoleConfig,typeHistoryItem}from'@opentiny/tiny-robot'import{useConversation}from'@opentiny/tiny-robot-kit'import{computed,ref}from'vue'import{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决定消息发生变化时是否自动保存,默认值是false;
  • autoSaveThrottle控制流式输出期间自动保存的节流间隔,避免每个 chunk 都触发一次持久化写入。

会话列表会从存储策略中恢复;某个历史会话真正被切换到前台时,useConversation再为它创建或恢复消息引擎,并加载对应消息。因此,历史会话数据和当前正在工作的消息引擎不必全部同时常驻内存。

LocalStorage 和 IndexedDB 适合 Demo 或非敏感数据。生产环境如涉及用户隐私或企业内部信息,建议通过自定义存储策略接入服务端存储,并结合访问控制、加密、数据保留期限和清理机制。

重命名和删除仍然走同一个会话边界

加入会话操作:

functionrenameConversation(title:string,item:HistoryItem){if(item.id)updateConversationTitle(item.id,title)}functionhandleHistoryAction(action:HistoryMenuItem,item:HistoryItem){if(action.id==='delete'&&item.id){voiddeleteConversation(item.id)}}

TrHistory绑定对应事件:

<TrHistory class="history-list" :data="conversations as HistoryItem[]" :selected="activeConversationId ?? undefined" @item-click="openConversation" @item-title-change="renameConversation" @item-action="handleHistoryAction" />

删除会话时,Kit 会先中止该会话仍在运行的请求,再移除对应的消息引擎和持久化数据。重命名则会同步更新会话元数据。

完成后可以验证:

  1. 创建多个会话并分别发送消息;
  2. 重命名其中一个会话;
  3. 刷新页面;
  4. 切换到历史会话,消息能够重新加载;
  5. 删除会话后刷新,已删除的数据不会再次出现。

历史消息很多时换成 IndexedDB

存储策略的价值在于,上层会话逻辑不需要关心数据实际存储在哪里。

当历史消息量超过 LocalStorage 更适合承担的范围时,可以替换为 IndexedDB 策略:

storage:indexedDBStorageStrategyFactory({dbName:'tiny-robot-chat',dbVersion:1,})

替换存储策略时,useConversation、消息引擎和页面组件的调用方式保持不变。

第四步:替换 Provider,接入真实 SSE

到这里,消息和会话逻辑仍然运行在 Mock Provider 上。因为 Provider 与消息引擎解耦,接入真实接口时可以在 Provider 层替换数据源。

Kit 提供了sseStreamToGenerator,可以把服务器发送事件(Server-Sent Events,SSE)的Response转换成异步生成器,让真实接口和前面的 Mock 使用相同的消费模型。

**安全提示:**下面的浏览器直连方式只用于缩短本地 Demo 链路。以VITE_开头的变量会进入浏览器构建产物,不能用于保管生产环境 API Key。生产环境应由服务端或 API 网关持有模型凭据,浏览器只调用受控的业务接口。

创建src/sseResponseProvider.ts:

import{sseStreamToGenerator,typeResponseProvider}from'@opentiny/tiny-robot-kit'exportconstsseResponseProvider:ResponseProvider=async(requestBody,signal)=>{constresponse=awaitfetch(`${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.local:

VITE_LLM_BASE_URL=https://your-llm-endpoint.example.com VITE_LLM_API_KEY=your_api_key VITE_LLM_MODEL=your_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:'请继续上一段回答。',}),]

这里的“继续请求”仍然发生在同一个消息引擎中,因此可以继续复用已有的消息、请求状态和取消逻辑。

工具调用:toolPlugin

toolPlugin把模型工具调用接入消息生命周期。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 调整时可以继续复用消息运行时;
  • 模型或业务后端变化时,通常只需适配 Provider;
  • LocalStorage 可以换成 IndexedDB,上层会话 API 的调用方式保持不变;
  • 一个会话的请求状态不会和另一个会话混在一起;
  • 推理内容、自动续写和工具调用通过插件进入同一条消息链路。

对于只有一个基础问答框的页面,可以直接使用useMessage;当产品开始出现历史会话、后台生成和持久化需求,再使用useConversation管理多个消息引擎。两者复用同一套消息模型和运行能力。

常见问题

页面能运行,但没有 TinyRobot 样式

确认入口文件已经引入组件库样式:

import'@opentiny/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 对话类前端,这一层往往也是业务复杂度最先开始积累的地方。

关于 OpenTiny

OpenTiny 是面向企业级场景的前端 AI 应用开发平台。平台依托生成式 UI与WebMCP两大核心底层技术,打造适配 Agent 智能应用生态的 NEXT-SDKs、AI 扩展工具、TinyRobot 对话 AI 组件库、GenUI SDK生成式 UI 、WebAgent 智能代理等系列产品,能够依托 AI 解析用户需求意图,自动驱动任务执行,助力企业现有业务系统快速完成智能化升级。

同时 OpenTiny 沉淀成熟通用前端基建能力:提供跨端、跨框架、跨版本的 TinyVue 组件库,配套基于 Angular+TypeScript 技术栈的 TinyNG 组件库;内置可高度自定义拓展的低代码引擎 TinyEngine,搭配 TinyPro 中后台业务模板、TinyCLI 命令行工具等配套工具链,全方位赋能开发者高效搭建各类 Web 端企业应用。

欢迎加入 OpenTiny 开源社区。一起参与交流前端技术~
OpenTiny 官网:https://opentiny.design
GenUI SDK 代码仓库:https://github.com/opentiny/genui-sdk (欢迎star ⭐)

如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~如果你有任何问题,欢迎在评论区留言交流!

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进