1. 为什么选用Vue3 + TS:这个项目的先决条件与选型逻辑
1.1 真实项目的落地场景与需求边界
去年接到一个需求,给公司内部运营团队做一个统一的智能助手。最初的需求文档写得很简单——“做一个AI对话页面”。但真正坐到业务方对面聊完,发现事情远没那么单纯:对话要流式输出,回答要能直接渲染成图表,还要支持拆解任务、串联多个内部系统,比如先查数据、再生成报表、再推送给相关同事。
这个场景和公共聊天机器人完全是两回事。企业内部的AI对话,最核心的三个特征是:输出不确定、过程可追踪、结果要可用。输出不确定意味着AI返回的内容随时可能截断、格式走样、结构变化;过程可追踪意味着你需要把“正在思考”“调用工具”“执行中”“已完成”这些阶段呈现给用户;结果要可用意味着文本回答只是半成品,表格、图表、结构化卡片才是最终交付物。这三条叠加在一起,前端的工作量远大于实现一个“打字机动效”。
所以在我这里,这个项目的本质不是“套一个聊天UI”,而是做一个“围绕流式协议和动态渲染的终端应用”。基于这个判断,技术选型就不是追新,而是考虑稳定性、类型安全、状态管理和组件化成本。
1.2 为什么Vue3组合式API更适配对话应用
先说状态管理。对话系统天然是高频交互、长生命周期、多实体并发的场景。你需要管理消息数组、连接状态、解析进度、任务状态、上下文快照。如果用Vue2的Options API,这些逻辑会被拆散在data、computed、methods、watch里,多轮会话做下来代码很快变成一团乱麻。
而Vue3的组合式API给了我按“能力”而非“生命周期”组织代码方式。整个对话核心可以抽成一个独立模块,页面组件只是消费它:
// useChat.ts 的骨架 export function useChat() { const messages = ref<ChatMessage[]>([]) const connectionState = ref<ConnectionState>('idle') const currentStreamBuffer = ref('') async function sendUserMessage(content: string) { // 追加用户消息,创建占位回复,发起流式请求 } function handleStreamChunk(raw: string) { // 解析增量文本,更新占位回复 } return { messages, connectionState, sendUserMessage, handleStreamChunk } }这个设计让对话逻辑可以复制到任何组件里复现,也方便单测。后来我们把同一个hook用在了运营后台的弹窗助手和客服工作台的侧边栏上,几乎没有改动第二份代码。
TypeScript在这里也不只是写注解,而是承担了“前后端契约对齐”的角色。对话系统最怕的就是接口字段随意变化,AI返回的JSON里多一个字段,前端拿到的却是一个不确定的联合类型,很容易在渲染时崩掉。我们用TS把消息体定义成严格的联合类型,让“用户消息”“文本回复”“图表卡片”“任务进度”分别有各自的载荷,编译期就能拦住大多数字段拼写错误和结构遗漏。
1.3 一个被低估的企业级前提:可替换、可降级
企业级和Demo级的最大区别,不是功能多,而是当出问题的时候系统还能不能工作。我们在选型时专门留了两个后门:一是图表渲染不硬编码在某个图表库里,而是通过组件注册表分发;二是当图表配置校验失败时,自动降级成表格或普通文本展示。这个设计在后期帮了大忙,AI输出的图表配置至少出现过四次不合法的情况,如果没有降级机制,用户看到的就是一个空白卡片和一个报错角落。
所以,从选型逻辑的角度,Vue3 + TS不是因为它“现在最流行”,而是因为它能帮我把这个项目的三个特性(流式协议、动态渲染、多态消息)落到工程可控的范围里。框架本身没有魔法,真正把项目撑起来的是组合式API对复杂逻辑的收纳能力,以及TS对AI输出这种“天生不确定数据”的兜底能力。
2. 流式输出的工程化:SSE接入、中断控制与文本流渲染
2.1 为什么不用WebSocket,而是选SSE
AI对话的流式输出,第一个技术决策就是选传输协议。我当时和组里的同事讨论过WebSocket和SSE(Server-Sent Events)两个方案。
WebSocket是双向的,能力更强,但在我们的场景里反而带来了问题:首先,WebSocket服务端要单独维护连接状态,断线重连逻辑复杂;其次,内部系统有网关层,WebSocket长连接在网关上的配置和鉴权都比较麻烦;最后,AI对话的请求方向其实很单纯——客户端发起,服务端持续响应,几乎不需要服务端主动推送额外事件。
SSE恰好是“单向长响应”的标准方案。它基于普通HTTP,网关、代理、鉴权都走现成链路,而且自带事件格式、字段语义和重连机制,实现成本低得多。唯一要注意的是SSE的Content-Type必须是text/event-stream,以及连接时长可能超过网关默认超时时间,需要运维侧把读超时调长。
我最终选了SSE,还有一个现实原因:调试方便。用curl都能看到消息流:
curl -N -H "Authorization: Bearer <token>" \ https://internal.example.com/ai/chat-stream看到data: {"id":"1","delta":"你好"}这种输出,定位问题比WebSocket清晰太多。团队里任何一个人都能在没有专用工具的情况下复现链路。
2.2 基于fetch的SSE客户端封装与中断控制
浏览器里消费SSE,常见做法是使用它自带的对象,但这个对象在“中断请求”“自定义Header”“流式读取进度”上都不够灵活。我更喜欢用fetch差异化读取响应体,因为它能拿到整个Response,能通过AbortController随时中断,也能自主控制解析粒度。
核心封装如下:
export async function requestSSE( url: string, { body, signal, onMessage, }: { body: unknown signal: AbortSignal onMessage: (data: unknown) => void } ) { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream', }, body: JSON.stringify(body), signal, }) if (!response.ok) { throw new Error(`stream request failed: ${response.status}`) } const reader = response.body!.getReader() const decoder = new TextDecoder('utf-8') let buffer = '' while (true) { const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) const events = buffer.split('\n\n') buffer = events.pop() ?? '' for (const rawEvent of events) { const parsed = parseSSEEvent(rawEvent) if (parsed) onMessage(parsed) } } }这里有两个容易被忽略的细节。第一个是TextDecoder的stream选项,如果不用它,多字节字符跨分包到达时会产生乱码,中文尤其明显。第二个是buffer.split('\n\n'),因为SSE事件用空行分隔,但网络分包不保证事件边界,必须把残留在缓冲区里的半截事件留到下一轮。这两个点没做对,页面就会偶发出现半个中文字符的乱码,极其难查。
中断控制也很直接,只需要在对话“停止生成”按钮里调用abortController.abort():
const abortController = new AbortController() function stop() { abortController.abort() connectionState.value = 'idle' }问题是:中断之后,服务端可能已经把后半段回复生成完了,前端只是不再接收。如果你希望服务端真正停止浪费算力,需要在请求Header里带上X-Cancel-Token,或者考虑可中断的推理接口,否则“停止按钮”只是“停止显示”,不是“停止计算”。这个区别在企业级系统里很重要,因为GPU的每分钟推理成本是真的在烧钱。
2.3 打字机动效与Markdown流式解析的取舍
流式数据到达前端后,最直观的问题是:要不要做打字机动效?
打字机常见方案是定长延迟逐字追加,但这对AI长回答很不友好。如果一个回答有2000字,每字50ms,光显示就要100秒,用户大概率等崩溃。我后来采用的是“按块推进”策略:
- 网络到达的流式内容,以行为单位追加进缓冲区;
- 渲染层用一个定时器,每16ms从缓冲区取出一段(约8~16个字符)显示;
- 当用户手动滚动到底部时,自动将缓冲区残余部分一次性渲染完。
这样既保留了“AI正在生成”的实时感,又不会因为逐字渲染拖慢阅读速度。更重要的是,这个方案把网络速率和渲染速率解耦了。网络可以快,渲染可以平滑,再也不用担心优秀网速反而导致页面卡顿。
但打字机只是表层,真正难的问题是Markdown流式解析。
Markdown的特点是“语法符号可能跨分片出现”。比如一个代码块,```可能分成两个分片到达,如果用“半成品”去解析渲染,页面会闪烁或出现悬挂的标记符。解决思路是把Markdown解析推迟到“当前块稳定”之后,也就是使用异步分片处理:先把分片追加进原文,当遇到空行、代码块开始符结束符、标题行等结构边界时,才触发一次增量渲染。
我实际用的是一个带“安全边界判断”的渲染器,简单说就是:没有出现完整的代码块闭合符前,不渲染代码块;没有出现完整的表格分隔符前,不把表格切割成表格。这个方法牺牲了一点首屏速度,但换来了绝对的稳定。对于企业级场景,稳定比速度重要。
3. 数据可视化输出:AI生成图表的动态渲染方案
3.1 约定结构化协议:让AI输出可执行的图表配置
对话系统里,如果AI只是回答文字,价值很有限;如果它能直接把回答变成一张折线图、柱状图或饼图,运营同事的工作效率会高很多。但让AI“自己画图”不现实,前端必须给出一种机器可读的协议,让AI输出JSON配置,前端再去渲染。
我们设计的协议是Message中的一个特殊类型,简写如下:
interface ChartCardPayload { type: 'chart' title: string chartType: 'line' | 'bar' | 'pie' data: Array<Record<string, string | number>> dimensions: { xField: string yField: string seriesField?: string } config?: Record<string, unknown> }这个协议叫做“图表卡片”。AI在回答的合适位置,输出一段JSON,前端检测到这段结构后,把它解析成组件并插入消息流。
关键点在于:不要让AI生成图表库的完整配置,而是让它生成一份最小化、语义化的数据描述,再由前端翻译成图表库的配置。原因有三:第一,AI对某一图表库的版本和配置项的记忆非常不可靠;第二,图表库配置项数量极大,AI容易编造不存在的属性;第三,语义化协议可以让底层图表库随时替换而不影响上层协议。
比如AI只需要说“这是一个折线图,X轴是日期,Y轴是销售额,数据是这几条”,前端就能根据维度配置生成对应图表。
3.2 动态组件工厂与运行时Schema校验
有了协议之后,渲染端需要一个“组件工厂”。这个工厂,本质上是个注册表,key是chartType,value是一个Vue组件:
// chartRegistry.ts import { defineAsyncComponent } from 'vue' export const chartRegistry: Record<string, Component> = { line: defineAsyncComponent(() => import('@/components/charts/LineChart.vue')), bar: defineAsyncComponent(() => import('@/components/charts/BarChart.vue')), pie: defineAsyncComponent(() => import('@/components/charts/PieChart.vue')), } export function renderChartByType(type: string) { return chartRegistry[type] ?? defineAsyncComponent(() => import('@/components/charts/FallbackChart.vue')) }这里用异步组件就是为了按需加载。一个项目如果三个图表库全量引入,首屏体积会凭空增加几百KB。用动态注册加懒加载,首屏只加载常见的两种,剩下的在用户滚动到那个卡片时才加载。
但是动态组件只是第一步,更重要是校验。AI输出是不可信的——它可能少字段、多字段、类型错误、数值越界。不校验就渲染,轻则页面空白,重则整个报错白屏。我们给每个图表卡片做了一层运行时校验:
function validateChartCard(input: unknown): input is ChartCardPayload { if (!input || typeof input !== 'object') return false const obj = input as Record<string, unknown> if (obj.type !== 'chart') return false if (typeof obj.title !== 'string') return false if (!['line', 'bar', 'pie'].includes(obj.chartType as string)) return false if (!Array.isArray(obj.data)) return false return true }校验不通过时,组件会降级成“表格卡片”,把原始数据以表格形式展示。这样即使AI格式化失败,用户仍然能拿到信息。从用户体验来说,宁可看到一张朴素表格,也不能看到一个报错的白框。
3.3 流式场景下图表卡片的渲染策略
流式输出过程中,图表卡片的JSON是逐渐到达的。你不能每来一个token就重新渲染一次图表,也不能等整段流结束才渲染。我的做法是“两阶段策略”:在流进行中,只显示一个横向占位的“卡片loading区”,同时在内存里拼接JSON字符串;只有当检测到JSON已完整闭合(比如出现了}),才解析并切换为真实图表。
这里有一个经验:不要自己写JSON截断补全逻辑。很多AI返回的JSON在流式传输中会中途截断,有人想在前端把缺的括号补上。我的结论是,可以补,但这只适用于“简单的结构缺失”,一旦JSON嵌套复杂,补全逻辑反而会成为bug来源。更稳妥的方案是:等完整事件到达后再解析;如果确实需要中途预览,就用宽松模式,解析失败就等待下一个分片。
讲得再直白一点:用户面对AI回答,等2秒看到图表和等3秒看到图表,感受差异很小;但看到一次解析失败的白屏,信任感就会崩塌。所以在这个项目里我坚定选择了“宁可延后,不可错现”的渲染策略。
4. Agent编排在前端的落地:多轮会话状态机与任务流UI
4.1 Agent编排到底在编排什么
“Agent编排”这个词听起来很玄,实际落到前端,就是两件事:让AI在回答过程中主动调用工具,以及让用户能看到任务执行的中间过程。
以前端视角看,普通对话是“用户提问 -> AI回答”的直线结构;而Agent对话变成了“用户提问 -> AI思考 -> 调用工具A -> 得到结果 -> 调用工具B -> 生成回答”的网状结构。而且这些步骤可能不是顺序的,有分支、有等待、有重试。前端要做的,就是把这张网用UI呈现出来。
比如运营同学问“对比上个月和这个月的各渠道转化率,发一份总结给团队负责人”。AI可能先查数据库、再调用报表生成工具、然后封装总结消息,最后触发推送。用户需要看到的是:每个步骤当前处于什么状态、有没有失败、失败后是否自动重试。这才是“编排可感知”。
4.2 会话状态机设计
Agent编排一个很容易踩坑的地方是:把状态写死在消息内容里。比如在文本里塞了一个字符串status: "running",然后用正则去解析,这显然不靠谱。
我的做法是,让每个Agent相关消息都带上一个结构化字段,表示执行状态。前端维护一个状态机:
type AgentMessageState = | 'idle' | 'running' | 'waiting_tool_call' | 'tool_success' | 'tool_failed' | 'finished' | 'failed'状态转移遵循以下规则:
idle:消息创建,已进入渲染列队;running:AI正在生成回复或等待模型输出;waiting_tool_call:模型决定调用工具,等待工具执行结果;tool_success:工具执行成功,准备带着结果继续生成;tool_failed:工具执行失败,可能触发重试或降级;finished:整个Agent链路结束,输出最终回复;failed:整条链路不可恢复失败。
每个状态对应一组不同的UI展示。waiting_tool_call展示工具名称和参数摘要,tool_success展示返回结果摘要,tool_failed展示错误原因。这套状态机写文档时还不觉得重要,但实际部署后,售后团队排查问题全靠它——能直接看到用户问题卡在哪一步,而不是对着空白对话猜。
4.3 工具调用与中间结果的可视化
工具调用在前端应当被渲染成“独立卡片”而不是藏在正文里。我们定义了一个工具调用条目,包含工具名、入参、耗时、状态,样式类似CI流水线的step清单:
interface ToolCallEntry { toolName: string toolInput: Record<string, unknown> startedAt: number finishedAt?: number status: 'running' | 'success' | 'failed' | 'retrying' summary?: string }UI上,这个卡片放在主回答上方或侧边,用树状结构展示,点击可以展开看入参和返回值。这有一石三鸟的好处:第一,用户知道AI不是凭空回答,它有依据;第二,当任务卡住时,用户知道是哪一步的问题;第三,我们的审计系统可以直接读取这些结构化数据,不用去解析自然语言。
任务执行中的实时反馈也值得提一下。我们在长时间运行的工具上加了一个elapsed字段,每秒更新一次,比如“查询该数据仓库已运行32秒”。这样用户会继续等待,而不是因为无反馈判断“系统死掉了”而关闭页面。有一次我看到真实用户因为一个30秒的查询没有进度提示,连点了三次重试,造成了三个重复任务。加了这个倒计时后,重试率明显下降。
4.4 多轮会话上下文管理
Agent编排对多轮会话的管理比普通聊天复杂度高一个量级。普通聊天只要把历史消息全传回去就行,但Agent任务里,每轮的上下文可能是上一轮的中间结果、工具执行日志、错误信息,甚至外部系统返回的文件ID。这些信息不能一股脑塞给用户,也不能任意丢弃。
我们设计了一个会话上下文列表,按“原始用户消息”“AI中间推理”“工具调用记录”“最终回复”四类归档。渲染时,只有“最终回复”默认完整显示,其他三类折叠成可展开条目。这样既保证上下文完整,又避免页面被中间过程占满。
5. 已踩过的坑与可行解:从线上问题反推的工程实践
5.1 半截JSON与未闭合代码块:流式解析的两大难题
这个章节是实打实的踩坑记录。项目上线第一个月,我们收到了几类高频问题:页面偶发乱码、图表突然不出来、代码块高度一直在跳。排查到最后,全部指向同一类原因:流式数据边界处理不严。
第一个案例是半截JSON。AI输出图表配置时,网络传输把JSON切成了两截。前端每次收到分片就尝试JSON.parse,第一次解析必然失败,代码直接抛出异常,导致整条消息渲染中断。当时的修复方案很简单:解析失败不要抛异常,把分片存进“待解析缓冲区”,等下一次分片到来时拼起来再试。同时给解析逻辑加一个超时,如果连续超过4秒仍然无法解析,就放弃解析,降级为纯文本展示。
第二个案例是未闭合代码块。AI在流式输出代码时,可能在一段结尾出现了```前缀,但还没有结束符,前端Markdown解析器就会把它当作普通文本渲染,或者引发代码块变体,用户的阅读体验极差。我们在渲染前增加了一个“未闭合块探测”,如果发现代码块的开始标记存在但结束标记还没到达,就把该段标记为“不完整”,不执行高亮,等待结束标记后一次性渲染。这个等待成本极低,但感知提升非常明显。
5.2 TS类型收窄与AI输出不确定性之间的张力
TypeScript强类型的一个尴尬在于:AI返回的数据结构是运行时才确定的,类型系统管不住它。我们曾遇到过AI返回的图表数据里混入了null值,而我们的柱状图组件把数据当数值传入,最后整个图表Y轴变成了一堆NaN。
要解决这个问题,必须得接受一个现实:类型守卫是运行时最后一道防线。TS的类型收窄只能在你已经定义好的联合类型内部起作用,真正的敌人是“类型字段合法,值不合法”。所以我们在所有AI卡片的渲染路径上都加了更细的“值校验”。
拿数值字段举例:
function isFiniteNumberArray(arr: unknown[]): arr is number[] { return arr.every((item) => typeof item === 'number' && Number.isFinite(item)) }这类校验函数看起来繁琐,但没它们的话,线上bug会以一种极其隐蔽的方式出现。有一次,AI在某个字段里返回了一个超长字符串,被我们的数值校验拦截后降级为文本展示,用户才没有看到一屏的NaN。后来我们复盘,一致认为“把AI输出当作不可信外部输入”是项目里最重要的一条原则。
5.3 长会话性能瓶颈与虚拟列表取舍
对话应用到了50条以上消息时,页面开始明显卡顿。最耗性能的地方不是文本渲染,而是两个:一是流式过程中每次更新都全量diff消息数组,二是图表组件和代码块高亮组件的重复渲染。
第一处优化是“按消息类型拆分渲染组件”。普通文本消息是一个轻量组件,图表卡片是异步组件,工具卡也是独立组件。整个消息列表用分组渲染,不匹配的消息类型不会触发对应的重渲染。
第二处是流式更新粒度。我们不再每次收到分片都更新整个消息对象,而是把流式缓冲区和最终显示文本分开。缓冲区频繁变化只影响当前正在渲染的那一条消息,其他历史消息完全不会重新渲染。实测下来,100条以上的对话页面,滚动时的帧率从明显掉帧恢复到了稳定50帧以上。
关于虚拟列表,我们最终没有引入完整虚拟列表,原因是对话列表里的图表、代码块高度难以预先估算,而虚拟列表的长处是固定高度卡片。我们用了一个更轻量的方案:监听滚动位置,超出视口一定距离的消息卡片渲染为“占位视图”,等滚动回来再重新挂载。这个方案代码量少,又规避了高度不定的问题。如果你要处理上千条消息,那还是需要上完整虚拟列表,但对我们的场景来说,中期优化已经够用。
5.4 降级路径的统一约定
前面零散提到了降级到表格、降级到文本,到了后期我们意识到,这些零散降级政策必须统一。于是我们写了一个约定:所有AI消息类型,必须有三个层级的展示方案——完整版、降级版、兜底版。完整版是理想的图表卡片;降级版是结构简单的表格或列表;兜底版则是原始数据的文本展示,哪怕它看起来“很丑”,也不能让信息丢失。
这个约定在普通需求里看起来多余,但在AI系统里是保命条款。AI模型的输出分布存在长尾风险,即使99%的时候都正常运行,那1%的异常如果让用户拿到空白页,已经足够把一个部门的信任度打回原形。
6. 上线后的几个数据与观察:真实项目复盘的一些体会
我特意保留一个小章节讲上线后的观察,因为它往往是文档里不会写的部分。系统上线三个月后,我们拉了一次使用数据:
- 消息平均完整率(即用户看到的最终消息与AI完整回复的字节比)达到99.6%;
- 图表卡片降级率约2.3%,大部分集中在图表配置结构不完整场景;
- 用户发起“停止生成”操作的比例大约占所有对话的1/8,主要发生在长回答场景。
从这个数据里能得出一个结论:流式输出真正重要的不是加速,而是稳定。用户并没有因为某一个回答慢了1秒而增加停止操作,但当流式文本出现一次乱码或一段未闭合代码块后,信任损失会持续影响后续多次使用。
后来我们又把“停止操作比例”加进了前端监控面板,和接口报错率、渲染错误率放在同一个页面。运维同事反馈说,这比看一堆日志直观多了——三个指标一翻,基本能判断是模型侧问题还是前端渲染问题。
最后再说一个小细节,整个项目的图标卡片都带“下载PNG”和“展开全屏”功能。最初我认为这是锦上添花的运营功能,后来运营同事说,他们每周都要把AI生成的图表贴进周报,这个按钮一天能用几十次。它让我意识到一个道理:企业级AI系统不是做给“围观”的,而是做给“干活”的。任何一个能让用户少复制粘贴一次的功能,都有真实的业务价值。
如果让我给后来者一句判断:先把流式传输、状态机和降级路径这三件事做好,再谈花哨的动画和复杂的交互。AI对话前端的技术含量,从来不在于“看起来有智能感”,而在于如何处理一份随时可能不完美的数据流。把AI输出当作一个带噪声的数据源来防范,所有设计难点都会自动找到答案。