前言 今年从 Function Calling 写到 Agent,我越来越意识到一件事:大模型挑战的不只是后端架构,而是我们做前端的底层假设 。传统前端的交互是”确定性的”——用户点按钮,请求接口,返回数据,渲染结果,每一步都可预测。而 Agent 是一个黑盒:它可能思考三秒,也可能跑三分钟;可能一步答对,也可能调五次工具绕弯路;用户甚至不知道它此刻在干什么。老的一套 loading + 表格 + 弹窗的交互范式,接不住这种不确定性。这篇是今年 AI 系列的收官,聊聊这一年我思考最多的三个 UI 命题:流式界面、乐观更新、Generative UI。
1. 传统 CRUD UI vs AI 原生 UI 先把两种范式摆在一起对比,差异一目了然:
维度
传统 CRUD UI
AI 原生 UI
响应时间
毫秒~秒级,确定
不确定,可能数十秒
结果
固定结构的数据
自由文本 + 结构混合
过程
黑盒 loading
可展示的推理与工具调用过程
用户操作
精确指令(点、填、选)
模糊意图(一句话)
错误处理
明确的成功/失败
部分正确、需要追问澄清
核心反馈
loading 转圈
过程流式呈现
最刺眼的是”过程”那一行。传统 UI 里 loading 是要被压缩到无感的噪音;AI UI 里过程本身是信息 ——用户盯着”正在搜索知识库…””已找到 3 篇相关文档…””正在生成回答…”,焦虑感远低于一个干转的 spinner。过程可视化不是锦上添花,是 Agent UI 的核心体验。
2. Agent 交互的三个特点 设计 AI 原生 UI 之前,先接受 Agent 的三个”天性”:
长任务 。一个 Agent 任务动辄几十秒到几分钟,横跨多次模型请求和工具调用。UI 必须能在这么长的时间里持续给用户”活着”的信号,并且允许用户离开再回来。
不确定性 。同样的输入可能走不同的执行路径,产出质量有波动。UI 不能假设”结果一定正确”,要给用户核验、纠偏的入口。
需要可中断、可确认 。用户是任务的委托人,不是旁观者。跑偏了要能一脚刹车(中断),关键决策点要能停下来问人(确认)。这两个能力是 Agent 产品”敢让 AI 干活”的信任基础。
3. 流式 UI:让等待变成演出 3.1 流式 Markdown 渲染 流式输出我们已经很熟了(SSE 那篇讲过),但”把 token 追加到文本里”和”渲染体验好”之间还差一步:token 是碎片化的,Markdown 语法可能渲染到一半——一个 **加粗 只有前半个星号、代码块只有开头的三个反引号。处理不当页面就会闪烁、抖动。
我的方案是:要么找支持不完整语法的流式渲染器,要么简单点——对未闭合语法做容错补全:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import ReactMarkdown from 'react-markdown'; import { memo } from 'react'; /** 补全流式 Markdown 的未闭合语法,避免闪烁 */ function normalizePartialMd(text: string): string { const fenceCount = (text.match(/```/g) || []).length; if (fenceCount % 2 === 1) text += '\n```'; // 代码块未闭合 const starCount = (text.match(/\*\*/g) || []).length; if (starCount % 2 === 1) text += '**'; // 加粗未闭合 return text; } const StreamingMarkdown = memo(function StreamingMarkdown({ content }: { content: string }) { return <ReactMarkdown>{normalizePartialMd(content)}</ReactMarkdown>; });
注意 memo 很重要:每个 token 到来都会触发重渲染,React 里务必让”消息列表”和”正在流式的最后一条消息”分开渲染,只有最后一条高频更新,历史消息不陪跑。
3.2 工具调用过程可视化:”正在搜索…” 结合上一篇的 Function Calling,工具调用的每一步都可以实时渲染成步骤条。用户看到的是一条时间线,而不是黑盒:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 type Step = | { type: 'text'; content: string } | { type: 'tool'; name: string; args: unknown; status: 'running' | 'done' | 'error' }; function AgentTimeline({ steps }: { steps: Step[] }) { return ( <ol className="space-y-2"> {steps.map((s, i) => s.type === 'text' ? ( <li key={i}><StreamingMarkdown content={s.content} /></li> ) : ( <li key={i} className="flex items-center gap-2 text-sm text-gray-600"> <ToolIcon name={s.name} spinning={s.status === 'running'} /> <span> {s.status === 'running' && describeRunning(s.name, s.args)} {s.status === 'done' && describeDone(s.name, s.args)} {s.status === 'error' && describeFailed(s.name, s.args)} </span> </li> ) )} </ol> ); } // 把工具调用翻译成人话,这行很重要 function describeRunning(name: string, args: unknown) { const map = { searchKnowledgeBase: (a: any) => `正在搜索知识库:“${a.query}”…`, getWeather: (a: any) => `正在查询 ${a.city} 的天气…`, renderChart: () => '正在生成图表…', }; return map[name]?.(args) ?? '正在执行操作…'; } function describeDone(name: string, args: unknown) { const map = { searchKnowledgeBase: (a: any) => `已搜完知识库:“${a.query}”`, getWeather: (a: any) => `已查完 ${a.city} 的天气`, renderChart: () => '图表已生成', }; return map[name]?.(args) ?? '操作完成'; } function describeFailed(name: string, args: unknown) { const map = { searchKnowledgeBase: (a: any) => `知识库搜索失败:“${a.query}”`, getWeather: (a: any) => `${a.city} 的天气查询失败`, renderChart: () => '图表生成失败', }; return map[name]?.(args) ?? '操作失败'; }
两个细节:工具名要翻译成人话 (”searchKnowledgeBase” 对用户毫无意义,”正在搜索知识库”才有安全感);已完成的步骤保留 (折叠成一行),让用户可以回看 AI 做了什么。
3.3 Skeleton 与渐进呈现 流式文本之外,结构化内容要配 Skeleton 骨架屏。我的节奏是:模型开始生成 → 先出文本流;检测到即将输出图表/表格(比如收到工具调用意图)→ 该区域立即渲染 Skeleton 占位;数据就绪 → Skeleton 平滑过渡为真实组件。整页”逐步点亮”,没有任何时刻是死白屏。
4. 乐观更新在 AI 场景的妙用 乐观更新是老概念:先假设成功、立即更新 UI,失败再回滚。传统场景它用来省一次 RTT,而在 AI 场景它有新的价值——掩盖长延迟 。
典型例子:用户在 Agent 对话框说”把这篇文章归类到技术分享”。如果等 Agent 确认再更新,要等好几秒;乐观做法是立即在 UI 上把分类改了、给个”已更新”的轻提示,后台 Agent 慢慢执行,失败再回滚并明确告知。用户的”意图被立刻听到”这种即时感,比真实执行快 0.5 秒重要得多。
1 2 3 4 5 6 7 8 9 10 11 12 async function aiCategorize (postId: string , category: string ) { const prev = getPost(postId); patchPost(postId, { category }); try { await agentRun('categorize' , { postId, category }); toast.success('分类已更新' ); } catch (e) { patchPost(postId, prev); toast.error('更新失败,已还原' ); } }
一条纪律:乐观更新只用于可撤销的写操作 。不可撤销的操作(发消息、支付、删除)必须走确认流,别赌。
5. Generative UI:让模型直接”画”界面
这是我最兴奋的方向。Function Calling 让模型能”做事”,Generative UI 再进一步:模型可以返回 UI 组件描述 ,前端动态渲染成真实的界面。用户问”对比一下这三个框架”,回答不再是干巴巴的文字,而是直接渲染出一张对比表格卡片;问”团队最近的部署频率”,直接是一张趋势图。
5.1 模型返回 UI 描述 JSON 做法类似 Function Calling,但我们约定一个 renderComponent 工具,组件类型和 props 由模型给出:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 const uiTools = [{ type: 'function' , function : { name: 'renderComponent' , description: '当回答适合用结构化 UI 呈现时调用,如对比表格、数据卡片、图表。' , parameters: { type: 'object' , properties: { component: { type : 'string' , enum : ['ComparisonTable' , 'StatCard' , 'LineChart' ] }, props: { type : 'object' , description : '组件的属性,与组件定义一致' }, }, required: ['component' , 'props' ], }, }, }];
模型可能返回:
1 2 3 4 5 6 7 8 9 { "tool_calls" : [{ "id" : "call_1" , "function" : { "name" : "renderComponent" , "arguments" : "{\"component\":\"ComparisonTable\",\"props\":{\"columns\":[\"框架\",\"学习曲线\",\"生态\",\"性能\"],\"rows\":[[\"React\",\"中\",\"丰富\",\"高\"],[\"Vue\",\"平缓\",\"丰富\",\"高\"],[\"Svelte\",\"平缓\",\"成长中\",\"极高\"]]}}" } }] }
5.2 前端动态渲染 前端维护一个”组件注册表”,做一层白名单映射 + props 校验,然后渲染:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 import { z } from 'zod'; // 组件注册表:白名单机制,只渲染我们预先定义好的组件 const componentRegistry = { ComparisonTable: { schema: TableSchema, Comp: ComparisonTable }, StatCard: { schema: StatCardSchema, Comp: StatCard }, LineChart: { schema: ChartSchema, Comp: LineChart }, }; function GenerativeRenderer({ toolCall }: { toolCall: ToolCall }) { let component: string, props: unknown; try { ({ component, props } = JSON.parse(toolCall.function.arguments)); } catch { return <FallbackRawJson data={toolCall.function.arguments} />; } const entry = componentRegistry[component]; if (!entry) return <UnknownComponentFallback name={component} />; const parsed = entry.schema.safeParse(props); // zod 校验,防止模型给错结构 if (!parsed.success) return <FallbackRawJson data={props} />; const { Comp } = entry; return <Comp {...parsed.data} />; }
架构上一个关键选择:模型输出的永远是”声明式的描述”,渲染器是我们的代码 。这保证了三件事:样式统一(模型不能发明新组件,只能用注册表里的);安全(不存在”模型注入任意 HTML”的口子);可控(每个注册组件都是我们精心设计过的)。Generative UI 的本质是”受约束的生成自由”——模型决定用哪个 组件、填什么 数据,长什么样由设计系统说了算。
6. 人机协作的三条设计原则 一年实践下来,我把 AI 原生 UI 的设计原则收敛成三条:
确认门(Confirmation Gate) 。按操作风险分级:只读操作(查询、搜索)自动执行;可撤销的写操作(改主题、加标签)乐观执行 + 提示;不可撤销或高价值的操作(删除、发送、支付)必须停下等用户点头。确认弹窗里要展示模型到底要做什么 ,而不是一句”确认执行?”
可撤销(Undoable) 。所有 AI 的写操作尽量做成”命令模式”——保留前置状态快照,一键回滚。用户对 AI 的信任是”敢试”试出来的,而敢试的前提是”错了能撤”。
透明度(Transparency) 。让用户随时知道三件事:AI 正在做什么(步骤条)、它基于什么信息得出结论(RAG 的引用来源、工具返回的数据)、这个结论有多可信(相关度、置信度)。把这三件事亮在明面上,用户才敢真把任务交出去。
7. 年终小结 从年初第一次把 ChatGPT API 塞进页面,到今天聊 Generative UI,回头看这条线其实非常清晰:**模型从”文本生成器”进化成了”应用运行时”**——它会检索(RAG)、会调用工具(Function Calling)、会规划执行(Agent)、甚至会在我们的设计系统里”画”界面(Generative UI)。而前端的活儿不减反增:交互范式、安全边界、设计系统——决定 AI 应用上限的这几件事,都还在我们手里。
2025 年我想继续往深处走:多 Agent 协作的编排 UI、Agent 任务的可回放调试、更细粒度的流式结构化输出。
写在最后 写完这篇,2024 年的 AI 系列就算完整了:SSE 流式、Prompt 工程、RAG、WebGPU 端侧推理、Function Calling、Agent UI——从”接入模型”到”设计智能”。如果要我用一句话总结这一年的心法,那就是:把模型当作一个能力惊人但需要监督的新同事,而不是一台确定性的机器 。上面所有原则,说到底都在回答同一个问题:怎么在用上模型能力的同时,把控制权留在用户手里。
开年第一件事,我准备把这套 UI 模式搬进手头的产品里再打磨一轮,踩了坑继续写。