前言

今年从 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
// 乐观更新一个AI标签操作
async function aiCategorize(postId: string, category: string) {
const prev = getPost(postId); // 1. 快照旧状态
patchPost(postId, { category }); // 2. 乐观更新,UI 立即生效
try {
await agentRun('categorize', { postId, category }); // 3. 后台执行
toast.success('分类已更新');
} catch (e) {
patchPost(postId, prev); // 4. 失败回滚
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 模式搬进手头的产品里再打磨一轮,踩了坑继续写。